From 892a81906090a85194b597cad419fe3b02adf9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 08:47:20 +0200 Subject: [PATCH] chore(lint): add a sweep and a rule for planning tags in comments Comments used to open with the identifier of the work item that produced them. The identifiers point into notes that never ship, so a reader meets something that looks like a reference and leads nowhere. eslint-plugins/healthlog/planning-tags.js defines the shape once: a head of tag codes and planning words before an em dash, optionally after a release version. The version and any issue reference stay; the tag goes. A line without a version must carry a code that is neither domain vocabulary (B12, PM2.5, UTF-8) nor used in the file's code. scripts/strip-planning-tags.mjs locates comments with the TypeScript parser, rewrites only their text, and refuses any file whose comment-free printout changes. The healthlog/no-planning-tags rule uses the same matcher, with an autofix; it is enabled together with the sweep. --- .../__tests__/no-planning-tags.test.mjs | 111 ++++++ eslint-plugins/healthlog/index.js | 4 + eslint-plugins/healthlog/no-planning-tags.js | 89 +++++ eslint-plugins/healthlog/planning-tags.js | 319 ++++++++++++++++++ scripts/strip-planning-tags.mjs | 189 +++++++++++ 5 files changed, 712 insertions(+) create mode 100644 eslint-plugins/healthlog/__tests__/no-planning-tags.test.mjs create mode 100644 eslint-plugins/healthlog/no-planning-tags.js create mode 100644 eslint-plugins/healthlog/planning-tags.js create mode 100644 scripts/strip-planning-tags.mjs diff --git a/eslint-plugins/healthlog/__tests__/no-planning-tags.test.mjs b/eslint-plugins/healthlog/__tests__/no-planning-tags.test.mjs new file mode 100644 index 000000000..409cc2d28 --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-planning-tags.test.mjs @@ -0,0 +1,111 @@ +/** + * Unit tests for the `healthlog/no-planning-tags` rule: a planning-note + * identifier at the head of a comment line is reported and autofixed; a + * release version, an issue reference, domain vocabulary, ordinary prose and + * string contents are left alone. + */ +import { describe, it } from "vitest"; +import { RuleTester } from "eslint"; +import rule from "../no-planning-tags.js"; + +RuleTester.describe = describe; +RuleTester.it = it; +RuleTester.itOnly = it.only; + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + parserOptions: { ecmaFeatures: { jsx: true } }, + }, +}); + +const error = { messageId: "planningTag" }; + +ruleTester.run("no-planning-tags", rule, { + valid: [ + // A release version that dates the behaviour may stay. + "// v1.4.27 — `/about` carries the attribution.\nconst x = 1;", + "// Since v1.39 every AI decision is one resolution.\nconst x = 1;", + // Issue references stay, with or without a version. + "// v1.43 (#1234) — a later start inside the same period.\nconst x = 1;", + "// v1.43 (iOS #116 item 9) — the age band.\nconst x = 1;", + "// Issue #281 — the import surface.\nconst x = 1;", + // Domain vocabulary that looks like a code. + "// B12 — cobalamin, reported in pmol/L.\nconst x = 1;", + "// PM2.5 — fine particulate matter.\nconst x = 1;", + "// UTF-8 — the wire encoding.\nconst x = 1;", + "// APP13 — Photoshop / IPTC-IIM\nconst x = 1;", + // Enumerations and prose. + "// Step 2 — parse the payload.\nconst x = 1;", + "// Round 2 — the model answers with the tool result.\nconst x = 1;", + "// OpenAPI route table — one entry per path.\nconst x = 1;", + "/** 2026-05-12 — only one source reported. */\nconst x = 1;", + // A label the file's code also uses (a test named `T1 …`). + '// T1 — no file hand-rolls the locale.\nit("T1 no file hand-rolls the locale", () => {});', + // A tag inside a string is not a comment. + 'const title = "v1.4.20 B3 — /insights mounts TrendsRow";', + // Mid-sentence references are out of the rule's reach by design. + "// The card replaced the one from W4 — see the layout.\nconst x = 1;", + ], + invalid: [ + { + code: "// v1.4.34 IW-B — the provider now mounts here.\nconst x = 1;", + output: "// v1.4.34 — the provider now mounts here.\nconst x = 1;", + errors: [error], + }, + { + code: "/**\n * v1.4.40 W-PRIVACY (SB-3) — bilingual public privacy policy.\n */\nconst x = 1;", + output: + "/**\n * v1.4.40 — bilingual public privacy policy.\n */\nconst x = 1;", + errors: [error], + }, + { + // The issue reference survives the fix. + code: "// v1.30.1 W9 (#812) — pull-to-refresh parity.\nconst x = 1;", + output: "// v1.30.1 (#812) — pull-to-refresh parity.\nconst x = 1;", + errors: [error], + }, + { + // A colon separator after a version. + code: "// v1.4.15 phase-C5: upgrade to the EmptyState primitive.\nconst x = 1;", + output: "// v1.4.15: upgrade to the EmptyState primitive.\nconst x = 1;", + errors: [error], + }, + { + // Without a version the tag goes and the sentence starts upper-case. + code: "// C4 — the replaced fields; the note is never quoted.\nconst x = 1;", + output: "// The replaced fields; the note is never quoted.\nconst x = 1;", + errors: [error], + }, + { + code: "/** RECON1 (D5-5) — the snapshot driver block. */\nconst x = 1;", + output: "/** The snapshot driver block. */\nconst x = 1;", + errors: [error], + }, + { + code: "// Phase 4 — MCP prompts.\nconst x = 1;", + output: "// MCP prompts.\nconst x = 1;", + errors: [error], + }, + { + code: "// 2026-07-17 UX/IA audit (M6) — which sidebar group this renders under.\nconst x = 1;", + output: "// Which sidebar group this renders under.\nconst x = 1;", + errors: [error], + }, + { + // JSX comment. + code: "const el =
{/* v1.4.33 IW9 — aria-label for the bar. */}
;", + output: + "const el =
{/* v1.4.33 — aria-label for the bar. */}
;", + errors: [error], + }, + { + // Two tagged lines in one block, each reported. + code: "/**\n * v1.4.25 W4 — the routed sub-page.\n * v1.4.27 F17 — gated on one observation.\n */\nconst x = 1;", + output: + "/**\n * v1.4.25 — the routed sub-page.\n * v1.4.27 — gated on one observation.\n */\nconst x = 1;", + errors: [error, error], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index da764d680..7205742f1 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -14,6 +14,8 @@ * src/lib/tz/format, never written out. * - `no-utc-day-key` — a day is cut through the tz module, not by * slicing an ISO string or parsing UTC midnight. + * - `no-planning-tags` — no planning-note identifier at the head of a + * comment line. */ "use strict"; @@ -26,6 +28,7 @@ const spacingScale = require("./spacing-scale.js"); const jobHandlerOutcome = require("./job-handler-outcome.js"); const noDefaultZoneLiteral = require("./no-default-zone-literal.js"); const noUtcDayKey = require("./no-utc-day-key.js"); +const noPlanningTags = require("./no-planning-tags.js"); module.exports = { rules: { @@ -42,5 +45,6 @@ module.exports = { "job-handler-outcome": jobHandlerOutcome, "no-default-zone-literal": noDefaultZoneLiteral, "no-utc-day-key": noUtcDayKey, + "no-planning-tags": noPlanningTags, }, }; diff --git a/eslint-plugins/healthlog/no-planning-tags.js b/eslint-plugins/healthlog/no-planning-tags.js new file mode 100644 index 000000000..2c30c6138 --- /dev/null +++ b/eslint-plugins/healthlog/no-planning-tags.js @@ -0,0 +1,89 @@ +/** + * @fileoverview ESLint rule — no planning tag at the head of a comment. + * + * Comments used to open with the identifier of the work item that produced + * them. The identifiers point into planning notes that never ship, so a + * reader meets something that looks like a reference and leads nowhere. + * `scripts/strip-planning-tags.mjs` removed the existing ones; this rule + * keeps new ones out. A release version that dates the behaviour and an + * issue reference (`#1234`) are allowed and survive the autofix. + * + * The shape is defined once, in `./planning-tags.js`, which the sweep script + * imports as well — see its header for the grammar and for what it does not + * catch. + */ + +"use strict"; + +const { matchPlanningTag, commentLines } = require("./planning-tags.js"); + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noPlanningTagsRule = { + meta: { + type: "suggestion", + fixable: "code", + docs: { + description: + "Disallow planning-note identifiers at the head of a comment line.", + }, + schema: [], + messages: { + planningTag: + "Planning tag {{tags}} at the head of a comment. Say what the code does; an issue reference (#123) or a release version may stay.", + }, + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + return { + Program() { + const comments = sourceCode.getAllComments(); + if (comments.length === 0) return; + + const text = sourceCode.getText(); + let codeOnly = null; + const usedInCode = (tag) => { + if (codeOnly === null) { + let out = ""; + let cursor = 0; + for (const c of comments) { + out += text.slice(cursor, c.range[0]); + out += " ".repeat(c.range[1] - c.range[0]); + cursor = c.range[1]; + } + codeOnly = out + text.slice(cursor); + } + return new RegExp( + `(? + fixer.replaceTextRange([start, end], hit.replacement), + }); + } + } + }, + }; + }, +}; + +module.exports = noPlanningTagsRule; diff --git a/eslint-plugins/healthlog/planning-tags.js b/eslint-plugins/healthlog/planning-tags.js new file mode 100644 index 000000000..323df2ba1 --- /dev/null +++ b/eslint-plugins/healthlog/planning-tags.js @@ -0,0 +1,319 @@ +/** + * @fileoverview Recognises a planning tag at the head of a comment line. + * + * Comments used to open with the identifier of the work item that produced + * them: `v1.4.34 IW-B — …`, `v1.4.25 W4 — …`, `v1.4.40 W-PRIVACY (SB-3) — …`, + * `RECON1 (D5-5) — …`, `Phase 4 — …`. Those identifiers point into planning + * notes that never ship with the repository, so to a reader they are noise + * that looks like a reference. A release version that dates the behaviour may + * stay, and so may an issue reference (`#1234`, `iOS #12`); the tag goes. + * + * One matcher serves two callers: the `healthlog/no-planning-tags` rule, which + * reports a tag in a new comment, and `scripts/strip-planning-tags.mjs`, which + * removed the existing ones. Sharing it keeps "what the rule flags" and "what + * the sweep removed" the same set by construction. + * + * Shape recognised (on one comment line, after the comment marker): + * + * [VERSION [/ VERSION …]] HEAD SEP REST + * + * VERSION `v1.4.34`, `v1.39` + * HEAD tokens, every one of them a tag, a planning word, an issue + * reference, a separator, or a parenthesised group of those; a + * parenthesised group of anything else (`(org-audit rec #2)`) is + * kept verbatim. At least one tag or planning word must be present. + * SEP an em dash; with a leading VERSION also a colon. + * + * A line without a version is held to more: the head must carry a tag or a + * strong planning word, no tag may be on the domain list below (`B12`, + * `PM2.5`, `UTF-8`, …), and no tag may also occur in the file's code — a + * test that names its cases `T1`, `T2` keeps its comment labels. + * + * WHAT THIS DOES NOT CATCH + * + * A tag in the middle of a sentence (`see W4`), a tag inside a string (test + * titles), and an all-capitals code without a digit (`FDREXTEND`). It reads + * the head of a line, where the convention put them. + */ + +"use strict"; + +const VERSION_RE = /^v\d+(?:\.\d+){1,3}$/; +const VERSION_PREFIX_RE = + /^(v\d+(?:\.\d+){1,3}(?:\s*[/,+&]\s*v\d+(?:\.\d+){1,3})*)\s+/; + +// A work-item code: letters then digits (B3, MB6, W9b, RECON1, D5-5, +// W9-CHART-GATE, Code-H9), a word-dash-number code (HIGH-9, SB-SCHED-9, +// MOD-12), or the two letter-only families the convention used (IW-B, +// IW-F-Perf, W-PRIVACY). +const TAG_RES = [ + /^(?:[A-Z][A-Za-z]*-)*[A-Z]{1,8}\d+[a-z]{0,2}(?:-[A-Za-z0-9]+)*$/, + /^[A-Z][A-Za-z]*(?:-[A-Z][A-Za-z]*)*-\d+[a-z]?$/, + /^IW(?:-[A-Za-z0-9]+)*$/, + /^W-[A-Z][A-Za-z0-9-]*$/, +]; + +// Codes that are domain or protocol vocabulary, not work items. Only +// consulted for a line without a version, where such a word can open an +// ordinary comment (`// B12 — cobalamin`, `// UTF-8 — the wire encoding`). +const DOMAIN_RE = + /^(?:B\d{1,2}|D[23]|K[12]|E[1-4]|T[34]|O[23]|NO2|CO2|SO2|PM\d+(?:\.\d+)?|R2|S3|H[1-6]|A[0-9]|P\d{2,3}|Q[1-4]|V8|ES\d+|SHA-?\d+|MD5|UTF-?\d+|HS\d+|RS\d+|ISO-?\d+|AES-?\d+|RFC-?\d+|COVID-\d+|SARS-CoV-\d+|HTTP-?\d+|X\d+|IPV\d|CSS\d|HTML\d|ECMA-?\d+|WCAG-?\d+|HbA1c|A1C|LDL\d*|HDL\d*|EKG\d*|ECG\d*|PHQ-\d+|GAD-\d+|WHO-\d+|AUDIT-C|ICD-\d+|FHIR-R\d|R4|R5|APP\d+)$/; + +const PLAN_WORDS = new Set([ + "phase", + "wave", + "welle", + "workstream", + "epic", + "reconcile", + "hotfix", + "follow-up", + "qa", + "qol", + "audit", + "ux", + "ia", + "a11y", + "perf", + "fix", + "item", +]); +// Words that mark a planning tag on their own, without a code beside them. +const STRONG_PLAN_WORDS = new Set([ + "phase", + "wave", + "welle", + "workstream", + "epic", + "reconcile", +]); +const PLAN_WORD_SHAPES = [ + /^phase-[A-Z]?\d*[a-z]?$/i, + /^fix-[A-Z0-9]+$/i, + /^maintainer-item-\d+$/, +]; + +const TICKET_WORDS = new Set(["ios", "issue", "issues"]); + +function isTag(token) { + return TAG_RES.some((re) => re.test(token)); +} + +function isPlanWord(token) { + return ( + PLAN_WORDS.has(token.toLowerCase()) || + PLAN_WORD_SHAPES.some((re) => re.test(token)) + ); +} + +function isTicket(token) { + return ( + /^#\d+$/.test(token) || + /^(?:ios|iOS)#\d+$/.test(token) || + TICKET_WORDS.has(token.toLowerCase()) + ); +} + +const SEPARATORS = new Set(["/", ",", "+", "&"]); + +/** + * Split a head into tokens with their offsets. Parentheses and the + * separators are their own tokens. + */ +function tokenize(head) { + const out = []; + const re = /[()]|[/,+&]|[^\s()/,+&]+/g; + let m; + while ((m = re.exec(head)) !== null) { + out.push({ text: m[0], start: m.index, end: m.index + m[0].length }); + } + return out; +} + +/** + * Classify the tokens of one head (or one parenthesised group). Returns + * null when a token is foreign; otherwise the per-token verdicts. + */ +function classify(tokens) { + const kinds = tokens.map((t) => { + if (SEPARATORS.has(t.text)) return "sep"; + if (VERSION_RE.test(t.text)) return "version"; + if (isTicket(t.text)) return "ticket"; + if (/^\d+$/.test(t.text) && t.text.length <= 3) return "digits"; + if (isTag(t.text)) return "tag"; + if (isPlanWord(t.text)) return "plan"; + if (/^\d{4}-\d{2}-\d{2}$/.test(t.text)) return "date"; + if (/^[A-Za-z]$/.test(t.text)) return "letter"; + return null; + }); + if (kinds.includes(null)) return null; + const hasTag = kinds.includes("tag"); + const hasPlan = kinds.includes("plan"); + // Bare digits, single letters and dates only belong to a planning head + // (`Phase 4`, `Welle J`, `W9b/c`, `2026-07-17 audit M5`). + if (!hasTag && !hasPlan) { + if (kinds.some((k) => k === "digits" || k === "letter" || k === "date")) { + return null; + } + } + // `item 3` after an issue reference is part of it (`iOS #12 item 3`). + for (let i = 1; i < kinds.length; i++) { + const isItem = tokens[i].text.toLowerCase() === "item"; + if (kinds[i - 1] === "ticket" && (isItem || kinds[i] === "digits")) { + kinds[i] = "ticket"; + } + } + return kinds; +} + +function tidy(s) { + let prev; + let out = s; + do { + prev = out; + out = out + .replace(/\(\s*[/,+&]?\s*\)/g, "") + .replace(/\(\s*[/,+&]\s*/g, "(") + .replace(/\s*[/,+&]\s*\)/g, ")") + .replace(/([/,+&])(\s*[/,+&])+/g, "$1") + .replace(/^\s*[/,+&]\s*/, "") + .replace(/\s*[/,+&]\s*$/, "") + .replace(/\(\s+/g, "(") + .replace(/\s+\)/g, ")") + .replace(/\s{2,}/g, " ") + .trim(); + } while (out !== prev); + return out; +} + +function capitaliseFirstWord(rest) { + const m = rest.match(/^([a-z]+)(?=[\s,.;:-]|$)/); + if (!m) return rest; + return m[1][0].toUpperCase() + rest.slice(1); +} + +/** + * Inspect the text of one comment line (marker already removed). + * + * @param {string} text the line after `//`, `/*`, or ` * `. + * @param {(tag: string) => boolean} [usedInCode] whether a tag also occurs + * in the file's code; only consulted for a line without a version. + * @returns {null | { tags: string[], replacement: string }} + */ +function matchPlanningTag(text, usedInCode = () => false) { + const verMatch = text.match(VERSION_PREFIX_RE); + const version = verMatch ? verMatch[1] : ""; + const afterVersion = verMatch ? text.slice(verMatch[0].length) : text; + + const sepRe = version + ? /^([^—:`"']{1,60}?)\s*(—|:)(\s|$)/ + : /^([^—:`"']{1,60}?)\s*(—)(\s|$)/; + const sm = afterVersion.match(sepRe); + if (!sm) return null; + const head = sm[1]; + const sep = sm[2]; + const rest = afterVersion.slice(sm[0].length); + + const tokens = tokenize(head); + if (tokens.length === 0) return null; + + // Split into bare tokens and parenthesised groups (one level). + const removable = new Array(tokens.length).fill(false); + const tags = []; + let sawTagOrPlan = false; + let sawStrong = false; + const bare = []; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].text === "(") { + let j = i + 1; + while (j < tokens.length && tokens[j].text !== ")") j++; + if (j >= tokens.length) return null; + const inner = tokens.slice(i + 1, j); + const kinds = classify(inner); + if (kinds) { + kinds.forEach((k, n) => { + if (k === "tag" || k === "plan" || k === "date" || k === "letter") { + removable[i + 1 + n] = true; + } + if (k === "digits" && kinds.includes("plan")) { + removable[i + 1 + n] = true; + } + if (k === "tag") tags.push(inner[n].text); + if (k === "tag" || k === "plan") sawTagOrPlan = true; + if ( + k === "plan" && + STRONG_PLAN_WORDS.has(inner[n].text.toLowerCase()) + ) + sawStrong = true; + }); + } + i = j; + continue; + } + if (tokens[i].text === ")") return null; + bare.push(i); + } + const bareKinds = classify(bare.map((i) => tokens[i])); + if (bare.length > 0 && !bareKinds) return null; + if (bareKinds) { + bareKinds.forEach((k, n) => { + const i = bare[n]; + if (k === "tag" || k === "plan" || k === "date" || k === "letter") { + removable[i] = true; + } + if (k === "digits" && bareKinds.includes("plan")) removable[i] = true; + if (k === "tag") tags.push(tokens[i].text); + if (k === "tag" || k === "plan") sawTagOrPlan = true; + if (k === "plan" && STRONG_PLAN_WORDS.has(tokens[i].text.toLowerCase())) + sawStrong = true; + }); + } + if (!sawTagOrPlan) return null; + + if (!version) { + if (tags.length === 0 && !sawStrong) return null; + if (tags.some((t) => DOMAIN_RE.test(t))) return null; + if (tags.some((t) => usedInCode(t))) return null; + if (rest.trim() === "") return null; + } + + let kept = ""; + let cursor = 0; + tokens.forEach((t, i) => { + kept += head.slice(cursor, t.start); + if (!removable[i]) kept += t.text; + cursor = t.end; + }); + kept += head.slice(cursor); + kept = tidy(kept); + + const lead = [version, kept].filter(Boolean).join(" "); + const replacement = lead + ? `${lead}${sep === ":" ? "" : " "}${sep}${rest ? " " + rest : ""}` + : capitaliseFirstWord(rest); + if (replacement === text) return null; + return { tags, replacement }; +} + +/** + * Split one comment's source text into lines with the marker separated, so + * a caller can run `matchPlanningTag` on each line and splice the result + * back. `raw` is the full comment including its `//` or `/* … *\/`. + */ +function commentLines(raw) { + const lines = raw.split("\n"); + let offset = 0; + return lines.map((line, index) => { + const m = line.match(/^(\s*(?:\{?\/\/+|\{?\/\*+|\*+(?!\/))?[ \t]*)(.*)$/); + const entry = { + index, + offset, + prefix: m[1], + text: m[2], + }; + offset += line.length + 1; + return entry; + }); +} + +module.exports = { matchPlanningTag, commentLines }; diff --git a/scripts/strip-planning-tags.mjs b/scripts/strip-planning-tags.mjs new file mode 100644 index 000000000..3131169c0 --- /dev/null +++ b/scripts/strip-planning-tags.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Remove planning-note identifiers from the head of comment lines. + * + * node scripts/strip-planning-tags.mjs # report only, exit 1 on hits + * node scripts/strip-planning-tags.mjs --write # rewrite the files + * node scripts/strip-planning-tags.mjs --write src/lib/foo.ts … + * + * What counts as a tag is defined once, in + * `eslint-plugins/healthlog/planning-tags.js`; the `healthlog/no-planning-tags` + * rule uses the same matcher, so a tree this script leaves clean is a tree + * the rule accepts. + * + * Only comment text changes. Comments are located with the TypeScript parser + * rather than a regex, so string and template contents are never touched, and + * every rewritten file is re-parsed and printed with comments removed: if + * that printout differs from the original's in any way, the file is left + * alone and the run fails. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { extname } from "node:path"; + +const require = createRequire(import.meta.url); +const ts = require("typescript"); +const { + matchPlanningTag, + commentLines, +} = require("../eslint-plugins/healthlog/planning-tags.js"); + +const EXTENSIONS = new Set([ + ".ts", + ".tsx", + ".mts", + ".cts", + ".js", + ".jsx", + ".mjs", + ".cjs", +]); +const EXCLUDED_PREFIXES = [ + "src/generated/", + "eslint-plugins/", + "node_modules/", +]; + +const args = process.argv.slice(2); +const write = args.includes("--write"); +const explicit = args.filter((a) => !a.startsWith("--")); + +const files = ( + explicit.length > 0 + ? explicit + : execFileSync("git", ["ls-files"], { encoding: "utf8" }).split("\n") +).filter( + (f) => + f && + EXTENSIONS.has(extname(f)) && + !EXCLUDED_PREFIXES.some((p) => f.startsWith(p)), +); + +function scriptKind(file) { + switch (extname(file)) { + case ".tsx": + return ts.ScriptKind.TSX; + case ".jsx": + return ts.ScriptKind.JSX; + case ".js": + case ".mjs": + case ".cjs": + return ts.ScriptKind.JS; + default: + return ts.ScriptKind.TS; + } +} + +function parse(file, text) { + return ts.createSourceFile( + file, + text, + ts.ScriptTarget.Latest, + true, + scriptKind(file), + ); +} + +/** Every comment range in the file, in source order, without duplicates. */ +function commentRanges(sf) { + const text = sf.getFullText(); + const seen = new Map(); + const literalSpans = []; + const add = (ranges) => { + for (const r of ranges ?? []) seen.set(r.pos, r); + }; + const visit = (node) => { + add(ts.getLeadingCommentRanges(text, node.pos)); + add(ts.getTrailingCommentRanges(text, node.end)); + if ( + node.kind === ts.SyntaxKind.JsxText || + ts.isStringLiteralLike(node) || + node.kind === ts.SyntaxKind.TemplateHead || + node.kind === ts.SyntaxKind.TemplateMiddle || + node.kind === ts.SyntaxKind.TemplateTail || + node.kind === ts.SyntaxKind.RegularExpressionLiteral + ) { + literalSpans.push([node.getStart(sf), node.end]); + return; + } + for (const child of node.getChildren(sf)) visit(child); + }; + visit(sf); + add(ts.getLeadingCommentRanges(text, sf.endOfFileToken.pos)); + return [...seen.values()] + .filter((r) => !literalSpans.some(([s, e]) => r.pos >= s && r.pos < e)) + .sort((a, b) => a.pos - b.pos); +} + +function printWithoutComments(sf) { + return ts.createPrinter({ removeComments: true }).printFile(sf); +} + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +let filesChanged = 0; +let linesChanged = 0; +let tagsRemoved = 0; +let failures = 0; + +for (const file of files) { + const original = readFileSync(file, "utf8"); + const sf = parse(file, original); + const ranges = commentRanges(sf); + if (ranges.length === 0) continue; + + let codeOnly = ""; + let cursor = 0; + for (const r of ranges) { + codeOnly += original.slice(cursor, r.pos) + " ".repeat(r.end - r.pos); + cursor = r.end; + } + codeOnly += original.slice(cursor); + const usedInCode = (tag) => + new RegExp(`(? b.start - a.start)) { + next = next.slice(0, e.start) + e.replacement + next.slice(e.end); + } + + if (printWithoutComments(parse(file, next)) !== printWithoutComments(sf)) { + console.error( + `${file}: rewrite would change code outside comments; left alone`, + ); + failures++; + continue; + } + filesChanged++; + linesChanged += edits.length; + if (write) writeFileSync(file, next); +} + +console.log( + `${write ? "rewrote" : "found"} ${linesChanged} comment lines (${tagsRemoved} tags) in ${filesChanged} files`, +); +if (failures > 0) process.exit(2); +if (!write && linesChanged > 0) process.exit(1);