parent
59843f5dd2
commit
c8d47a4be2
@ -1,345 +0,0 @@ |
||||
// 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); |
||||
} |
@ -1,105 +0,0 @@ |
||||
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