parent
ae6996372b
commit
59843f5dd2
@ -0,0 +1,345 @@ |
|||||||
|
// you may not need all of these but they come up a lot
|
||||||
|
import fs from "fs"; |
||||||
|
import assert from "assert"; |
||||||
|
import logging from '../lib/logging.js'; |
||||||
|
import { mkdir_to, glob } from "../lib/builderator.js"; |
||||||
|
import { create_renderer } from "../lib/docgen.js"; |
||||||
|
import path from "path"; |
||||||
|
import slugify from "slugify"; |
||||||
|
import * as acorn from "acorn"; |
||||||
|
import * as acorn_walk from "acorn-walk"; |
||||||
|
|
||||||
|
const log = logging.create(import.meta.url); |
||||||
|
|
||||||
|
export const description = "Describe your command here." |
||||||
|
|
||||||
|
// your command uses the npm package commander's options format
|
||||||
|
export const options = [ |
||||||
|
["--quiet", "Don't output the stats at the end."], |
||||||
|
["--readme", "README file to use as the initial page", "README.md"] |
||||||
|
] |
||||||
|
|
||||||
|
// example of a positional argument, it's the 1st argument to main
|
||||||
|
export const argument = ["<source...>", "source input globs"]; |
||||||
|
|
||||||
|
// put required options in the required variable
|
||||||
|
export const required = [ |
||||||
|
["--output <string>", "Save to file rather than stdout."], |
||||||
|
] |
||||||
|
|
||||||
|
const RENDERER = create_renderer(); |
||||||
|
const CAPS_WORDS = ["BUG", "TODO", "WARNING", "FOOTGUN", "DEPRECATED"]; |
||||||
|
const STATS = {total: 0, docs: 0, undoc: 0}; |
||||||
|
|
||||||
|
const slug = (instring) => slugify(instring, { lower: true, strict: true, trim: true}); |
||||||
|
|
||||||
|
/* |
||||||
|
Strips 1 leading space from the comments, or the \s\* combinations |
||||||
|
in traditional documentation comments. |
||||||
|
|
||||||
|
If we strip more it'll mess up formatting in markdown for indentation |
||||||
|
formats. Weirdly Remarkable seems to be able to handle leading spaces pretty |
||||||
|
well so only need to remove one space or \s\* combinations like with |
||||||
|
traditional comment docs. |
||||||
|
*/ |
||||||
|
const render_comment = (comment) => { |
||||||
|
const lines = comment.split(/\n/).map(l => l.replace(/^(\s*\*\s?|\s)/, '')); |
||||||
|
return RENDERER.render(lines.join("\n")); |
||||||
|
} |
||||||
|
|
||||||
|
/* Handy function for checking things are good and aborting. */ |
||||||
|
const check = (test, fail_message) => { |
||||||
|
if(!test) { |
||||||
|
log.error(fail_message); |
||||||
|
process.exit(1); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const dump = (obj) => { |
||||||
|
return JSON.stringify(obj, null, 4); |
||||||
|
} |
||||||
|
|
||||||
|
class ParseWalker { |
||||||
|
constructor(comments, code) { |
||||||
|
this.comments = comments; |
||||||
|
this.exported = []; |
||||||
|
this.code = code; |
||||||
|
} |
||||||
|
|
||||||
|
handle_class(root) { |
||||||
|
const new_class = { |
||||||
|
isa: "class", |
||||||
|
slug: slug(root.declaration.id.name), |
||||||
|
name: root.declaration.id.name, |
||||||
|
line_start: root.loc.start.line, |
||||||
|
methods: [], |
||||||
|
} |
||||||
|
|
||||||
|
acorn_walk.simple(root, { |
||||||
|
ClassDeclaration: (cls_node) => { |
||||||
|
assert(cls_node.id.name === new_class.name, "Name of class changed!"); |
||||||
|
new_class.range = [cls_node.start, cls_node.body.start]; |
||||||
|
this.add_export(cls_node.id, new_class); |
||||||
|
}, |
||||||
|
|
||||||
|
MethodDefinition: (meth_node) => { |
||||||
|
const new_method = { |
||||||
|
isa: "method", |
||||||
|
static: meth_node.static, |
||||||
|
async: meth_node.value.async, |
||||||
|
generator: meth_node.value.generator, |
||||||
|
slug: slug(`${new_class.name}-${meth_node.key.name}`), |
||||||
|
name: meth_node.key.name, |
||||||
|
line_start: meth_node.loc.start.line, |
||||||
|
range: [meth_node.start, meth_node.value.body.start], |
||||||
|
params: this.handle_params(meth_node.value.params), |
||||||
|
comment: this.find_comment(meth_node.loc.start.line), |
||||||
|
} |
||||||
|
|
||||||
|
this.has_CAPS(new_method); |
||||||
|
|
||||||
|
new_method.code = this.slice_code(new_method.range); |
||||||
|
|
||||||
|
this.update_stats(new_method); // methods can't go through add_export
|
||||||
|
new_class.methods.push(new_method); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
handle_params(param_list) { |
||||||
|
const result = []; |
||||||
|
|
||||||
|
for(let param of param_list) { |
||||||
|
acorn_walk.simple(param, { |
||||||
|
Identifier: (_node) => { |
||||||
|
result.push({isa: "identifier", name: _node.name}); |
||||||
|
}, |
||||||
|
|
||||||
|
AssignmentPattern: (_node) => { |
||||||
|
result.push({ |
||||||
|
isa: "assignment", |
||||||
|
name: _node.left.name, |
||||||
|
right: { |
||||||
|
type: _node.right.type.toLowerCase(), |
||||||
|
raw: _node.right.raw, |
||||||
|
value: _node.right.value, |
||||||
|
name: _node.right.name, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/* |
||||||
|
Used to add information when something is mentioned in the |
||||||
|
comment like BUG, TODO, etc. |
||||||
|
*/ |
||||||
|
has_CAPS(exp) { |
||||||
|
if(exp.comment) { |
||||||
|
exp.caps = CAPS_WORDS.filter(phrase => exp.comment.includes(phrase)); |
||||||
|
} else { |
||||||
|
exp.caps = []; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
update_stats(exp) { |
||||||
|
STATS.total += 1; |
||||||
|
|
||||||
|
if(exp.comment) { |
||||||
|
STATS.docs += 1; |
||||||
|
} else { |
||||||
|
STATS.undoc += 1; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
add_export(id, exp) { |
||||||
|
exp.name = id.name; |
||||||
|
exp.slug = exp.slug ? exp.slug : slug(id.name); |
||||||
|
exp.line_start = id.loc.start.line; |
||||||
|
exp.comment = this.find_comment(exp.line_start); |
||||||
|
this.has_CAPS(exp); |
||||||
|
exp.code = this.slice_code(exp.range); |
||||||
|
this.exported.push(exp); |
||||||
|
|
||||||
|
this.update_stats(exp); |
||||||
|
} |
||||||
|
|
||||||
|
slice_code(range) { |
||||||
|
return this.code.slice(range[0], range[1]); |
||||||
|
} |
||||||
|
|
||||||
|
handle_arrow_func(id, arrow) { |
||||||
|
this.add_export(id, { |
||||||
|
isa: "function", |
||||||
|
async: arrow.async, |
||||||
|
generator: arrow.generator, |
||||||
|
expression: arrow.expression, |
||||||
|
range: [id.start, arrow.body.start], |
||||||
|
params: this.handle_params(arrow.params), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
handle_variable(root) { |
||||||
|
const declare = root.declaration.declarations[0]; |
||||||
|
const id = declare.id; |
||||||
|
const _node = declare.init; |
||||||
|
const init_is = declare.init.type; |
||||||
|
|
||||||
|
if(init_is === "ArrowFunctionExpression") { |
||||||
|
this.handle_arrow_func(id, declare.init); |
||||||
|
} else { |
||||||
|
this.add_export(id, { |
||||||
|
isa: _node.type.toLowerCase(), |
||||||
|
value: _node.value, |
||||||
|
range: declare.range, |
||||||
|
raw: _node.raw |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* |
||||||
|
Find the nearest comment to this line, giving |
||||||
|
about 2 lines of slack. |
||||||
|
*/ |
||||||
|
find_comment(line) { |
||||||
|
for(let c of this.comments) { |
||||||
|
const distance = c.end - line; |
||||||
|
if(!c.found && distance == -1) { |
||||||
|
c.found = true; |
||||||
|
return render_comment(c.value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/* |
||||||
|
Returns the first comment as the file's main doc comment, or undefined if there isn't one. |
||||||
|
*/ |
||||||
|
file_comment() { |
||||||
|
const comment = this.comments[0]; |
||||||
|
|
||||||
|
if(comment && comment.start === 1) { |
||||||
|
// kind of a hack, but find_comment will find this now
|
||||||
|
return this.find_comment(comment.end + 1); |
||||||
|
} else { |
||||||
|
return undefined; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
handle_export(_node) { |
||||||
|
switch(_node.declaration.type) { |
||||||
|
case "ClassDeclaration": |
||||||
|
this.handle_class(_node); |
||||||
|
break; |
||||||
|
case "VariableDeclaration": { |
||||||
|
this.handle_variable(_node); |
||||||
|
break; |
||||||
|
} |
||||||
|
default: |
||||||
|
console.log(">>>", _node.declaration.type); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const parse_source = (source) => { |
||||||
|
const code = fs.readFileSync(source); |
||||||
|
|
||||||
|
let comments = []; |
||||||
|
|
||||||
|
const acorn_opts = { |
||||||
|
sourceType: "module", |
||||||
|
ecmaVersion: "2023", |
||||||
|
locations: true, |
||||||
|
sourceFile: source, |
||||||
|
ranges: true, |
||||||
|
onComment: comments |
||||||
|
} |
||||||
|
|
||||||
|
const parsed = acorn.parse(code, acorn_opts); |
||||||
|
|
||||||
|
comments = comments.filter(c => c.type === "Block").map(c => { |
||||||
|
return { |
||||||
|
start: c.loc.start.line, |
||||||
|
end: c.loc.end.line, |
||||||
|
value: c.value, |
||||||
|
type: "comment", |
||||||
|
found: false, |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const walker = new ParseWalker(comments, code.toString()); |
||||||
|
// acorn is stupid and they grab a reference to the functions so that _removes_
|
||||||
|
// this from the object, instead of just...calling walker.function() like a normal person
|
||||||
|
acorn_walk.simple(parsed, { |
||||||
|
ExportNamedDeclaration: (_node) => walker.handle_export(_node), |
||||||
|
}); |
||||||
|
|
||||||
|
let comment = walker.file_comment(); |
||||||
|
|
||||||
|
return { |
||||||
|
// normalize to / even on windows
|
||||||
|
source: source.replaceAll("\\", "/"), |
||||||
|
// find the first comment for the file's comment
|
||||||
|
comment, |
||||||
|
exports: walker.exported, |
||||||
|
orphan_comments: walker.comments.filter(c => !c.found) |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
const normalize_name = (fname) => { |
||||||
|
const no_slash = fname.replaceAll("\\", "/"); |
||||||
|
|
||||||
|
if(fname.startsWith("./")) { |
||||||
|
return no_slash.slice(2); |
||||||
|
} else if(fname.startsWith("/")) { |
||||||
|
return no_slash.slice(1); |
||||||
|
} else { |
||||||
|
return no_slash; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
export const main = async (source_globs, opts) => { |
||||||
|
const index = {}; |
||||||
|
mkdir_to(opts.output); |
||||||
|
|
||||||
|
for(let source of source_globs) { |
||||||
|
const source_list = glob(source); |
||||||
|
|
||||||
|
for(let fname of source_list) { |
||||||
|
const result = parse_source(fname); |
||||||
|
|
||||||
|
const target = `${path.join(opts.output, fname)}.json`; |
||||||
|
mkdir_to(target); |
||||||
|
fs.writeFileSync(target, dump(result)); |
||||||
|
|
||||||
|
const name = normalize_name(fname); |
||||||
|
|
||||||
|
index[name] = result.exports.map(e => { |
||||||
|
return {isa: e.isa, name: e.name}; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// now write the grand index
|
||||||
|
const index_name = path.join(opts.output, "index.json"); |
||||||
|
fs.writeFileSync(index_name, dump(index)); |
||||||
|
|
||||||
|
// render the README.md to the initial docs
|
||||||
|
const readme_name = path.join(opts.output, "index.html"); |
||||||
|
const md_out = RENDERER.render(fs.readFileSync(opts.readme).toString()); |
||||||
|
fs.writeFileSync(readme_name, md_out); |
||||||
|
|
||||||
|
const percent = Math.floor(100 * STATS.docs / STATS.total); |
||||||
|
|
||||||
|
if(!opts.quiet) { |
||||||
|
console.log(`Total ${STATS.total}, ${percent}% documented (${STATS.docs} docs vs. ${STATS.undoc} no docs).`); |
||||||
|
} |
||||||
|
|
||||||
|
process.exit(0); |
||||||
|
} |
@ -0,0 +1,105 @@ |
|||||||
|
import libCoverage from 'istanbul-lib-coverage'; |
||||||
|
import libReport from 'istanbul-lib-report'; |
||||||
|
import reports from 'istanbul-reports'; |
||||||
|
import { glob } from "../lib/builderator.js"; |
||||||
|
import fs from "fs"; |
||||||
|
import v8toIstanbul from 'v8-to-istanbul'; |
||||||
|
import assert from "assert"; |
||||||
|
import url from "url"; |
||||||
|
import normalize from "normalize-path"; |
||||||
|
|
||||||
|
export const description = "Takes the output of a nv8 coverage directory and generates a report."; |
||||||
|
|
||||||
|
export const argument = ["<coverage_dir>", "coverage directory"]; |
||||||
|
|
||||||
|
export const main = async (coverage_dir) => { |
||||||
|
const covdir = normalize(coverage_dir); |
||||||
|
const covpattern = `${covdir}/**/*.json`; |
||||||
|
console.log(`Searching ${covpattern} for coverage files...`); |
||||||
|
|
||||||
|
const covfiles = glob(covpattern); |
||||||
|
console.log(`Found ${covfiles.length} .json files in ${covdir}`); |
||||||
|
|
||||||
|
const coverage = {}; |
||||||
|
|
||||||
|
const excludes = [ |
||||||
|
"node_modules", |
||||||
|
"secrets", |
||||||
|
"/net" |
||||||
|
]; |
||||||
|
|
||||||
|
for(const fname of covfiles) { |
||||||
|
const data = JSON.parse(fs.readFileSync(fname)); |
||||||
|
|
||||||
|
// test removing just node modules
|
||||||
|
data.result = data.result.filter(x => { |
||||||
|
if(x.url) { |
||||||
|
// we need to do surgery on the URL because node is bad at them
|
||||||
|
let pathname = url.parse(x.url).pathname; |
||||||
|
|
||||||
|
// fix the URL and turn it into a file name
|
||||||
|
if(!pathname) { |
||||||
|
return false; |
||||||
|
} else if(pathname.startsWith("/C:")) { |
||||||
|
// why does url not parse windows paths right?
|
||||||
|
// remove the leading / so it's parsed correctly
|
||||||
|
x.url = pathname.slice(1); |
||||||
|
} else { |
||||||
|
x.url = pathname; |
||||||
|
} |
||||||
|
|
||||||
|
const excluded = excludes.filter(e => x.url.includes(e)); |
||||||
|
return excluded.length === 0 && fs.existsSync(x.url); |
||||||
|
} else { |
||||||
|
return false; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// looks good, save it
|
||||||
|
if(data.result.length > 0) { |
||||||
|
coverage[fname] = data; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const coverageMap = libCoverage.createCoverageMap(); |
||||||
|
console.log("After filtering, found count is:", Object.entries(coverage).length); |
||||||
|
|
||||||
|
for(const [fname, data] of Object.entries(coverage)) { |
||||||
|
for(const entry of data.result) { |
||||||
|
let converter; |
||||||
|
const pathname = url.parse(entry.url).pathname |
||||||
|
assert(fs.existsSync(pathname), `coverage entry in ${fname} contains ${entry.url} that doesn't exist but should`); |
||||||
|
|
||||||
|
converter = v8toIstanbul(pathname, 0, {source: entry.source}, path => { |
||||||
|
const excluded = excludes.filter(e => path.includes(e)); |
||||||
|
return excluded.length > 0; |
||||||
|
}); |
||||||
|
|
||||||
|
try { |
||||||
|
await converter.load(); |
||||||
|
converter.applyCoverage(entry.functions); |
||||||
|
coverageMap.merge(converter.toIstanbul()); |
||||||
|
} catch(error) { |
||||||
|
console.error(error, "load", entry.url); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const watermarks = undefined; // used in check coverage ignored here
|
||||||
|
|
||||||
|
const context = libReport.createContext({ |
||||||
|
dir: "coverage", |
||||||
|
watermarks, |
||||||
|
coverageMap |
||||||
|
}); |
||||||
|
|
||||||
|
["text","html"].forEach(format => { |
||||||
|
reports.create(format, { |
||||||
|
skipEmpty: false, |
||||||
|
skipFull: true, |
||||||
|
maxCols: 100 |
||||||
|
}).execute(context); |
||||||
|
}); |
||||||
|
|
||||||
|
process.exit(0); |
||||||
|
} |
Loading…
Reference in new issue