mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user