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);