Files
2026-09-08 10:50:13 +05:30

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("");
};
try {
if (tokensOf(original, kind) !== tokensOf(updated, kind)) {
failures.push(`${file}: token stream changed — left untouched`);
continue;
}
} catch (err) {
failures.push(`${file}: ${err.message} — left untouched`);
continue;
}
if (!DRY) fs.writeFileSync(file, updated, "utf8");
filesTouched++;
}
console.log(`${DRY ? "[dry run] " : ""}files rewritten : ${filesTouched}`);
console.log(`comment lines removed : ${removed}`);
console.log(`JSDoc blocks kept : ${keptJsdoc}`);
console.log(`directives kept : ${keptDirective}`);
console.log(`generated files skipped: ${skippedGenerated}`);
if (failures.length) {
console.log(`\nSKIPPED (${failures.length}):`);
for (const f of failures) console.log(" ", f);
}