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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 08:52:23 +02:00
1 parent b50b5fcd21
commit 892a819060
5 files changed
+712

No files matched your search

@@ -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 = <div>{/* v1.4.33 IW9 — aria-label for the bar. */}</div>;",
output:
"const el = <div>{/* v1.4.33 — aria-label for the bar. */}</div>;",
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],
},
],
});
+4
View File
@@ -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,
},
};
@@ -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(
`(?<![A-Za-z0-9-])${escapeRegExp(tag)}(?![A-Za-z0-9-])`,
).test(codeOnly);
};
for (const comment of comments) {
const raw = text.slice(comment.range[0], comment.range[1]);
for (const line of commentLines(raw)) {
const hit = matchPlanningTag(line.text, usedInCode);
if (!hit) continue;
const start = comment.range[0] + line.offset + line.prefix.length;
const end = start + line.text.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(start),
end: sourceCode.getLocFromIndex(end),
},
messageId: "planningTag",
data: { tags: hit.tags.join(", ") || "(planning word)" },
fix: (fixer) =>
fixer.replaceTextRange([start, end], hit.replacement),
});
}
}
},
};
},
};
module.exports = noPlanningTagsRule;
+319
View File
@@ -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 };
+189
View File
@@ -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(`(?<![A-Za-z0-9-])${escapeRegExp(tag)}(?![A-Za-z0-9-])`).test(
codeOnly,
);
const edits = [];
for (const r of ranges) {
const raw = original.slice(r.pos, r.end);
for (const line of commentLines(raw)) {
const hit = matchPlanningTag(line.text, usedInCode);
if (!hit) continue;
const start = r.pos + line.offset + line.prefix.length;
edits.push({
start,
end: start + line.text.length,
replacement: hit.replacement,
});
tagsRemoved += Math.max(hit.tags.length, 1);
if (!write) console.log(`${file}\t${line.text}\t${hit.replacement}`);
}
}
if (edits.length === 0) continue;
let next = original;
for (const e of edits.sort((a, b) => 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);