202 lines
6.4 KiB
JavaScript
202 lines
6.4 KiB
JavaScript
/**
|
|
* Remove comments from src/.
|
|
*
|
|
* Uses the TypeScript compiler's own parser to locate comments. Pattern
|
|
* matching cannot do this safely: `//` appears inside string literals
|
|
* ("https://..."), inside regex literals, and inside template literals, and a
|
|
* regex that strips those corrupts the file in a way nothing catches until
|
|
* runtime.
|
|
*
|
|
* JSDoc goes too, by default. Unlike a Python docstring it is not a language
|
|
* feature — there is no runtime `__doc__`, and TypeScript takes its types from
|
|
* the signature, so `@param` and `@returns` here are prose. Pass --keep-jsdoc
|
|
* to leave it.
|
|
*
|
|
* Never touched:
|
|
*
|
|
* - **Generated files.** `src/types/api/schema.d.ts` is emitted by
|
|
* openapi-typescript. Stripping it would be undone by the next
|
|
* `npm run types:api` and would fail the staleness check in CI meanwhile.
|
|
* - **Directives**, which read as comments but instruct a tool:
|
|
* @ts-nocheck / @ts-ignore / @ts-expect-error typechecking
|
|
* eslint-disable* linting
|
|
* /// <reference ... /> file inclusion
|
|
* @deprecated editor + lint behaviour
|
|
* prettier-ignore, @vite-ignore, webpackChunkName
|
|
*
|
|
* Usage: node scripts/strip-comments.mjs [--dry] [--keep-jsdoc]
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import ts from "typescript";
|
|
|
|
const DRY = process.argv.includes("--dry");
|
|
const KEEP_JSDOC = process.argv.includes("--keep-jsdoc");
|
|
const ROOT = "src";
|
|
const EXT = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
|
|
// Emitted by a generator; editing it here is undone on the next run.
|
|
const GENERATED = [path.join("src", "types", "api", "schema.d.ts")];
|
|
|
|
const DIRECTIVE =
|
|
/^\s*(\/\/\/?\s*<reference|\/\/\s*@ts-|\/\/\s*eslint-|\/\*\s*eslint|\/\/\s*prettier-ignore|\/\*\s*prettier-ignore|\/\/\s*@vite-|\/\*\s*webpackChunkName|\/\/\s*webpackChunkName|\/\/\s*biome-ignore|\/\/\s*@jsx|\/\*\s*global)/;
|
|
|
|
// `@deprecated` drives editor strikethrough and lint rules, so it is an
|
|
// annotation rather than prose even though it lives in a JSDoc block.
|
|
const ANNOTATED_JSDOC = /@(deprecated|ts-[a-z-]+|jsx|internal)\b/;
|
|
|
|
function walk(dir, out = []) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === "node_modules") continue;
|
|
walk(full, out);
|
|
} else if (EXT.has(path.extname(entry.name))) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Every comment range in the file, found through the parser. */
|
|
function commentRanges(source, text) {
|
|
const seen = new Set();
|
|
const ranges = [];
|
|
|
|
const add = (found) => {
|
|
for (const r of found || []) {
|
|
const key = `${r.pos}:${r.end}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
ranges.push(r);
|
|
}
|
|
}
|
|
};
|
|
|
|
const visit = (node) => {
|
|
add(ts.getLeadingCommentRanges(text, node.pos));
|
|
add(ts.getTrailingCommentRanges(text, node.end));
|
|
for (const child of node.getChildren(source)) visit(child);
|
|
};
|
|
|
|
visit(source);
|
|
return ranges.sort((a, b) => a.pos - b.pos);
|
|
}
|
|
|
|
let filesTouched = 0;
|
|
let removed = 0;
|
|
let keptJsdoc = 0;
|
|
let keptDirective = 0;
|
|
const failures = [];
|
|
|
|
let skippedGenerated = 0;
|
|
|
|
for (const file of walk(ROOT)) {
|
|
if (GENERATED.includes(file)) {
|
|
skippedGenerated++;
|
|
continue;
|
|
}
|
|
const original = fs.readFileSync(file, "utf8");
|
|
const kind = file.endsWith(".tsx")
|
|
? ts.ScriptKind.TSX
|
|
: file.endsWith(".jsx")
|
|
? ts.ScriptKind.JSX
|
|
: file.endsWith(".ts")
|
|
? ts.ScriptKind.TS
|
|
: ts.ScriptKind.JS;
|
|
|
|
const source = ts.createSourceFile(
|
|
file,
|
|
original,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
kind,
|
|
);
|
|
|
|
const drop = [];
|
|
for (const range of commentRanges(source, original)) {
|
|
const text = original.slice(range.pos, range.end);
|
|
if (DIRECTIVE.test(text)) {
|
|
keptDirective++;
|
|
continue;
|
|
}
|
|
if (text.startsWith("/**") && (KEEP_JSDOC || ANNOTATED_JSDOC.test(text))) {
|
|
keptJsdoc++;
|
|
continue;
|
|
}
|
|
drop.push(range);
|
|
removed += text.split("\n").length;
|
|
}
|
|
|
|
if (drop.length === 0) continue;
|
|
|
|
// Right to left, so earlier offsets stay valid.
|
|
let updated = original;
|
|
for (const range of drop.slice().reverse()) {
|
|
const before = updated.slice(0, range.pos);
|
|
const after = updated.slice(range.end);
|
|
|
|
// If the comment was alone on its line, take the whole line rather than
|
|
// leaving an indented blank behind.
|
|
const lineStart = before.lastIndexOf("\n") + 1;
|
|
const aloneOnLine =
|
|
before.slice(lineStart).trim() === "" && /^[ \t]*(\r?\n|$)/.test(after);
|
|
|
|
if (aloneOnLine) {
|
|
updated = before.slice(0, lineStart) + after.replace(/^[ \t]*\r?\n?/, "");
|
|
} else {
|
|
updated = before.replace(/[ \t]+$/, "") + after;
|
|
}
|
|
}
|
|
|
|
updated = updated.replace(/\n{4,}/g, "\n\n\n");
|
|
|
|
// The strip must not have changed the code. Re-parse and compare the
|
|
// non-trivia token stream against the original: identical sequences mean
|
|
// only comments went.
|
|
const tokensOf = (src, k) => {
|
|
const sf = ts.createSourceFile("t", src, ts.ScriptTarget.Latest, true, k);
|
|
const acc = [];
|
|
const walkTokens = (n) => {
|
|
// TypeScript parses JSDoc into real AST nodes, so a removed JSDoc block
|
|
// is a removed node. Skip them, or this check rejects the exact edit it
|
|
// was written to permit.
|
|
if (
|
|
n.kind >= ts.SyntaxKind.FirstJSDocNode &&
|
|
n.kind <= ts.SyntaxKind.LastJSDocNode
|
|
) {
|
|
return;
|
|
}
|
|
const kids = n.getChildren(sf);
|
|
if (kids.length === 0) acc.push(`${n.kind}:${n.getText(sf)}`);
|
|
else for (const c of kids) walkTokens(c);
|
|
};
|
|
walkTokens(sf);
|
|
return acc.join(" |