Files
msd-core/scripts/lint-retired-runtime-name.cjs
Jakub Zych fe3ed06691
Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
chore: clear dead test and allowlist leftovers of dropped runtimes
2026-10-06 20:35:12 +02:00

620 lines
27 KiB
JavaScript

#!/usr/bin/env node
/**
* lint-retired-runtime-name.cjs
*
* Prevents a RETIRED MSD runtime name from being presented as if it were a
* live runtime (#1928 retired the "Gemini CLI" runtime lane in favor of
* Antigravity). Mirrors scripts/lint-legacy-dir-name.cjs's structure and
* conventions (same problem shape: forbid a retired token, allowlist
* frozen/legitimate content, self-exempt, inline marker, `MSD_LINT_*_REPO_ROOT`
* test seam, ./lib/cli-exit.cjs, binary-file skip).
*
* THE PREDICATE (load-bearing — do not "simplify"):
* The forbidden token is matched CASE-SENSITIVELY, standalone (word
* boundaries), capitalised: /\bGemini\b/ — NO `i` flag.
*
* Case sensitivity IS the mechanism. Every LEGITIMATE reference to the
* "gemini" string in this repo is spelled differently and therefore cannot
* match a case-sensitive `Gemini`:
* - Antigravity's config homes are lowercase with a slash:
* ~/.gemini/antigravity, ~/.gemini/antigravity-ide, ~/.gemini/config
* - Google's model ids are lowercase and hyphenated:
* gemini-3.1-pro-preview, gemini-2.5-flash-lite
* - env vars are uppercase: GEMINI_CONFIG_DIR, GEMINI_SESSION_ID, GEMINI_API_KEY
* - the instruction file is GEMINI.md
* A bare capitalised `Gemini` therefore means the retired RUNTIME (or its
* retired reviewer lane) is being named as if live — which is the defect
* this guard exists to catch.
*
* RETIRED_RUNTIMES is a table, not a hardcoded single name, so the guard
* generalises to the next retired runtime for free. The name is built via
* split-string concatenation (same trick the legacy-dir-name precedent
* uses) so this guard script cannot flag itself.
*
* THREE TIERS OF EXEMPTION, deliberately ordered narrowest-first. The
* allowlist is the entire risk surface of this guard, so each tier states
* what it can and cannot see:
*
* 1. GENERAL RULES (apply anywhere, no per-file registration) — the two
* spellings that are legitimate wherever they appear:
* (a) the hook DIALECT Antigravity genuinely inherits: `Gemini-style`,
* `Gemini-compatible`, `Gemini スタイル`;
* (b) a model-display version on the provider/model axis
* (`Gemini 2.5 Pro`), REFUSED when a runtime word also appears on
* the line so a version can never launder a runtime claim.
* 2. PINNED OCCURRENCES (`ALLOWLIST_OCCURRENCES`) — per-file, per-line
* approved snippets. This is what makes a NEW occurrence in an
* otherwise-legitimate file FAIL: the file is not blanket-trusted, only
* the enumerated lines are. A pin that stops matching is reported as a
* stale allowlist entry, so the allowlist cannot silently rot.
* 3. FILE / DIR ALLOWLIST — blanket trust, and therefore reserved for
* content that is append-only by policy (ADRs, dated research) or
* generated-and-locked (CHANGELOG.md). A new occurrence inside these
* IS invisible to this guard; that is the accepted cost of not
* re-litigating frozen history on every run, and it is why the tier is
* kept as small as it is.
*
* Plus an anti-vacuity floor on files actually READ (not merely listed), so
* an empty or failed walk can never report a false clean.
*
* Exit 0 if no violations; exit 1 if any are found (with stderr diagnostics).
*/
'use strict';
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { escapeRegex } = require('../msd-core/bin/lib/pattern.cjs');
// Table of retired runtimes. Each name is constructed via split-string
// concatenation so this guard script cannot flag itself when scanned.
const RETIRED_RUNTIMES = [
{
name: 'Gem' + 'ini',
retiredBy: '#1928',
sunset: '2026-06-18',
successor: 'Antigravity',
},
];
const RETIRED_RUNTIME_MATCHERS = RETIRED_RUNTIMES.map((r) => ({
runtime: r,
// Case-sensitive, no 'i' flag — see module header for why.
re: new RegExp('\\b' + r.name + '\\b', 'g'),
}));
// Minimum plausible number of files actually READ. An empty or failed walk
// must never silently report "clean" (anti-vacuity requirement).
const MIN_EXPECTED_FILES = 150;
const ALLOW_MARKER = 'msd-allow-retired-runtime-name';
// The escape hatch must carry a justification. Bare-marker abuse was raised in
// review: the marker is checked first, excuses the whole line, and the failure
// message advertises it, so an unexplained one is indistinguishable from a
// defect somebody silenced. Require `msd-allow-retired-runtime-name: <reason>`.
const ALLOW_MARKER_RE = new RegExp(escapeRegex(ALLOW_MARKER) + ':\\s*\\S{3,}');
const SELF_PATH = path.resolve(__filename);
// MSD_LINT_RETIRED_RUNTIME_REPO_ROOT is used by tests to redirect the guard
// to a temporary fixture git repo without touching the real working tree.
const REPO_ROOT = process.env.MSD_LINT_RETIRED_RUNTIME_REPO_ROOT
? path.resolve(process.env.MSD_LINT_RETIRED_RUNTIME_REPO_ROOT)
: path.resolve(__dirname, '..');
// Every tracked *.md file is in scope. An earlier prefix list
// (docs/ msd-core/ commands/ agents/ skills/ + root) left `.changeset/`,
// `.github/`, `capabilities/`, `playbooks/` and `references/` invisible —
// and `.changeset/*.md` RENDERS INTO CHANGELOG.md, which is blanket-trusted,
// so a live claim introduced there was invisible at both ends.
// TIER 3 — blanket trust. Append-only by policy, or generated-and-locked.
// A new occurrence inside these is invisible to this guard; see the module
// header for why that cost is accepted here and nowhere else.
const ALLOWLIST_FILES = new Set([
// Locked by CLAUDE.md ("CHANGELOG.md is Locked") and generated from
// changeset fragments: frozen release history, one entry per shipped
// release, including the releases that removed the runtime.
'CHANGELOG.md',
]);
const ALLOWLIST_DIR_PREFIXES = [
// `.changeset/` is CHANGELOG.md before rendering — the same surface, one
// step upstream — so it gets the same treatment. Measured: all 21 hits are
// fragments DESCRIBING the retirement or a fix to it (e.g. "Retired the
// Gemini CLI reviewer lane", "Gemini install output is valid on Windows
// PowerShell"), which is what a release note is for. 464 of the 479 live
// under `archived/`. Pinning them would add friction with no signal: a
// fragment can only describe what already shipped, and it is deleted at
// release. The cost is stated rather than hidden — a fragment introducing a
// live claim is invisible here, as it is in CHANGELOG.md itself.
'.changeset/',
// Architecture decision records are append-only history.
'docs/adr/',
// Frozen historical release notes.
'docs/RELEASE-NOTES-LEGACY.md',
// Dated research records, incl. the gemini-to-antigravity migration note.
'docs/research/',
// Dated records.
'docs/issueevidence/',
'docs/discussions/',
'docs/superpowers/',
];
// TIER 2 — pinned occurrences. relPath -> [{ snippet, reason }]. A match is
// excused only when its LINE contains one of that file's snippets, so a new,
// unrelated occurrence in the same file still fails. Every snippet is a
// verbatim fragment of the line it approves.
const ALLOWLIST_OCCURRENCES = new Map([
['CONTEXT.md', [
// The name precedes the annotation on this line, so the snippet must span
// both for span containment to cover the match.
{ snippet: 'Gemini [runtime removed #1928]', reason: 'session log annotates the removal inline' },
]],
['GEMINI.md', [
{ snippet: 'Gemini CLI was sunset by Google on 2026-06-18', reason: 'states the sunset' },
{ snippet: 'shared Gemini 3 backend', reason: 'names the shared model backend Antigravity uses' },
]],
['docs/whats-new-1.7.0.md', [
// Two occurrences on one line, so two pins: span containment deliberately
// refuses to let the first excuse the second.
{ snippet: 'Gemini CLI removed** (#1928)', reason: 'release note announcing the removal' },
{ snippet: 'Google discontinued Gemini CLI on 2026-06-18', reason: 'states the sunset date' },
]],
['docs/explanation/embeddable-orchestration-system.md', [
{ snippet: 'Gemini CLI was retired in', reason: 'records the retirement' },
]],
// The fragment is the SOURCE; docs/FEATURES.md is generated from it by
// scripts/gen-features.cjs, so both carry the same approved line and the
// same pin covers each. A marker written into FEATURES.md by hand would be
// dropped on the next regeneration.
['docs/features/embeddable-orchestration-system-host-integration-interface.md', [
{ snippet: 'retired Gemini CLI now redirects to Antigravity CLI', reason: 'records the redirect' },
]],
['docs/FEATURES.md', [
{ snippet: 'retired Gemini CLI now redirects to Antigravity CLI', reason: 'generated from the fragment above' },
]],
['docs/reference/host-integration-capability-matrix.md', [
{ snippet: '(Gemini CLI\'s `thinkingConfig`) was removed as a sunset runtime', reason: 'explains why no config-file member exists' },
{ snippet: 'successor to the sunset Gemini CLI per #1928', reason: 'records the sunset and the inherited contract' },
]],
// --- The inherited hook dialect, where the line ALSO names a runtime -----
// Tier 1(a) requires no runtime word on the line, because the tight
// "immediately after the name" veto let real laundering through. These ten
// lines legitimately pair a dialect compound with a runtime word (usually
// `~/.gemini/antigravity-cli` in a table cell, or "runtime files" in the
// same sentence), so each is pinned explicitly instead of loosening the
// veto for everyone.
['docs/ARCHITECTURE.md', [
{ snippet: 'Gemini-style `settings.json` hook entries', reason: 'hook dialect Antigravity inherits' },
]],
['docs/ja-JP/ARCHITECTURE.md', [
{ snippet: 'Gemini スタイル `settings.json` フックエントリ', reason: 'hook dialect Antigravity inherits' },
]],
['docs/ko-KR/ARCHITECTURE.md', [
{ snippet: 'Gemini 스타일 `settings.json` 훅 항목', reason: 'hook dialect Antigravity inherits' },
]],
['docs/pt-BR/ARCHITECTURE.md', [
{ snippet: 'no estilo Gemini quando instalado', reason: 'hook dialect Antigravity inherits' },
]],
['docs/zh-CN/ARCHITECTURE.md', [
{ snippet: 'Gemini 风格 `settings.json` hook 条目', reason: 'hook dialect Antigravity inherits' },
]],
['docs/how-to/install-on-your-runtime.md', [
{ snippet: 'Uses Gemini-compatible settings policy', reason: 'settings dialect Antigravity inherits' },
]],
['docs/ja-JP/how-to/install-on-your-runtime.md', [
{ snippet: 'Gemini 互換', reason: 'settings dialect Antigravity inherits' },
]],
['docs/ko-KR/how-to/install-on-your-runtime.md', [
{ snippet: 'Gemini 호환 설정 정책을 사용합니다', reason: 'settings dialect Antigravity inherits' },
]],
['docs/pt-BR/how-to/install-on-your-runtime.md', [
{ snippet: 'compatível com Gemini', reason: 'settings dialect Antigravity inherits' },
]],
['docs/zh-CN/how-to/install-on-your-runtime.md', [
{ snippet: '与 Gemini 兼容的设置策略', reason: 'settings dialect Antigravity inherits' },
]],
['msd-core/workflows/reapply-patches.md', [
{ snippet: 'pre-#1928 Gemini CLI install', reason: 'legacy-install patch location, explicitly pre-retirement' },
]],
['msd-core/workflows/settings-advanced.md', [
{ snippet: '(Claude / OpenAI / Gemini / Qwen)', reason: 'model-provider menu, not a MSD runtime' },
]],
['msd-core/references/ai-frameworks.md', [
{ snippet: 'Google Cloud / Gemini-committed teams', reason: 'provider axis (Google ADK)' },
{ snippet: 'Optimized for Gemini; supports other models', reason: 'model axis' },
{ snippet: 'teams already committed to Gemini', reason: 'model axis' },
{ snippet: 'Model flexibility is required beyond Gemini', reason: 'model axis' },
{ snippet: 'Gemini vendor lock-in in practice', reason: 'model axis' },
{ snippet: 'Google/Gemini-committed', reason: 'provider axis (Google ADK)' },
]],
['agents/msd-framework-selector.md', [
// Two occurrences on one line: the menu label and its description.
{ snippet: 'Google (Gemini)', reason: 'model-provider choice, not a MSD runtime' },
{ snippet: 'Committed to Gemini / Google Cloud / Vertex AI', reason: 'the same menu entry\'s description' },
]],
['agents/msd-framework-selector.compact.md', [
{ snippet: 'Google (Gemini)', reason: 'model-provider choice, not a MSD runtime' },
]],
]);
// Every pin must contain a retired runtime name. Span containment means a
// snippet that does not contain the name can never cover a match, so such a
// pin is dead config that would also be reported stale forever — fail loud at
// load rather than mislead later.
for (const [relPath, pins] of ALLOWLIST_OCCURRENCES) {
for (const pin of pins) {
const names = RETIRED_RUNTIMES.filter((r) => pin.snippet.includes(r.name));
if (names.length === 0) {
throw new Error(
'lint-retired-runtime-name: pin for ' + relPath + ' does not contain a retired '
+ 'runtime name and can never match: ' + JSON.stringify(pin.snippet),
);
}
}
}
// docs/RELEASE-NOTES-LEGACY.md is a file, not a directory, but the brief
// lists it alongside the dir prefixes — treat both files and dirs the same
// way via a "startsWith" prefix check.
function isAllowlisted(relPath) {
if (ALLOWLIST_FILES.has(relPath)) return true;
for (const prefix of ALLOWLIST_DIR_PREFIXES) {
if (relPath === prefix || relPath.startsWith(prefix)) return true;
}
return false;
}
function isInScanSet(relPath) {
return relPath.endsWith('.md');
}
function isBinary(fullPath) {
// Read a small chunk and check for NUL bytes.
let fd;
try {
fd = fs.openSync(fullPath, 'r');
const buf = Buffer.allocUnsafe(512);
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
return buf.subarray(0, bytesRead).includes(0);
} catch {
return false;
} finally {
if (fd != null) {
try { fs.closeSync(fd); } catch { /* best-effort */ }
}
}
}
// --- TIER 1(a): the hook dialect Antigravity genuinely inherits -------------
// Antigravity reads Gemini-shaped `settings.json` hook entries, and every
// locale names that dialect. Marker POSITION is language-dependent and was
// measured: en `Gemini-style` / `Gemini-compatible`, ja `Gemini スタイル`,
// ko `Gemini 스타일` / `호환`, zh `Gemini 风格` / `与 Gemini 兼容的`, and pt
// `no estilo Gemini` / `compatível com Gemini`, where it PRECEDES the name.
//
// The marker must form an ADJACENT COMPOUND with the name. An earlier version
// searched a ±24-character window, which let one legitimate dialect reference
// launder a second, unrelated live claim on the same line — a reviewer
// demonstrated `| Antigravity | Gemini-style hooks | Gemini support is live |`
// exiting 0, because the second occurrence sat 21 characters from `style`.
// Adjacency is the property that actually distinguishes a dialect NAME from a
// runtime CLAIM, so it is what is tested.
const DIALECT_MARKERS = [
'style', 'compatible', // en
'スタイル', '互換', // ja
'스타일', '호환', // ko
'风格', '兼容', // zh
'estilo', 'compatível', 'compatibilidade', // pt
];
const DIALECT_ALT = DIALECT_MARKERS.map(escapeRegex).join('|');
// Marker immediately follows: "Gemini-style", "Gemini スタイル", "Gemini 兼容".
const DIALECT_AFTER_RE = new RegExp('^[-\\s]?(?:' + DIALECT_ALT + ')', 'i');
// Marker immediately precedes, optionally through ONE short connective:
// "no estilo Gemini", "compatível com Gemini".
const DIALECT_BEFORE_RE = new RegExp(
'(?:' + DIALECT_ALT + ')\\s*(?:com|with|de|da|do|and)?\\s*$',
'i',
);
function isDialectReference(line, matchIndex, afterMatch) {
const adjacent = DIALECT_AFTER_RE.test(afterMatch)
|| DIALECT_BEFORE_RE.test(line.slice(0, matchIndex));
if (!adjacent) return false;
// A dialect marker is NOT a licence when the line also asserts a runtime.
// This veto is line-global on purpose: the tight "immediately after the
// name" form let `Suportamos Gemini, no estilo padrao, como runtime de
// instalacao.` and `Gemini 兼容,并且是受支持的运行时之一。` both exit 0 while
// literally containing `runtime` / `运行时`. Real lines that legitimately
// pair a dialect compound with a runtime word are handled by an explicit
// Tier-2 pin, not by loosening this.
return !RUNTIME_WORD_RE.test(line);
}
// --- TIER 1(b): the provider/model axis ------------------------------------
// Matches the display-name spelling of a model version: a version number
// optionally followed by a model qualifier, or one that ends the list item.
// Deliberately NOT "space then a digit" — that blanket rule also matched
// "Gemini 2.5 CLI as a supported runtime.", laundering a genuine stale-runtime
// claim through an attached version number. Full-width digits and CJK
// punctuation are included because the translated docs use them.
const DIGIT = '0-9\\uFF10-\\uFF19';
const SP = ' \\u3000'; // ASCII space + ideographic space
const QUALIFIER = 'Pro|Flash|Ultra|Nano|Lite|Preview|Exp|Thinking';
const PUNCT = ',;.:)\\]\\u3001\\u3002\\uFF0C\\uFF09\\uFF3D\\uFF1A';
const MODEL_DISPLAY_AFTER = new RegExp(
'^[' + SP + ']'
+ '[' + DIGIT + ']+(?:[.\\uFF0E][' + DIGIT + ']+)*'
+ '(?:'
+ '[' + SP + ']+(?:' + QUALIFIER + ')\\b[\\w-]*'
+ '|[' + SP + ']*[' + PUNCT + ']'
+ '|[' + SP + ']*$'
+ ')',
);
// Words that indicate a RUNTIME (not a model) is being named on the line, in
// every language this repo ships translated docs for. If any appears
// alongside a model-display version, the version does NOT get to launder the
// runtime claim — the line is still a violation.
//
// Latin terms are anchored with \b so "CLI" cannot match inside "client" and
// "host" cannot match inside "ghost"; CJK terms are matched raw because \b is
// ASCII-word-based and would never fire next to a non-word ideograph.
//
// Deliberately EXCLUDED: "agent" and "target". Both occur throughout ordinary
// prose ("AI coding agents (Claude Code, Codex, Gemini 2.5 Pro)"), so vetoing
// on them would red legitimate model lists rather than catch runtime claims.
const RUNTIME_WORDS = [
'CLI',
'runtime',
'runtimes',
'IDE',
'extension',
'host',
'ランタイム', // ja
'実行環境', // ja
'런타임', // ko
'运行时', // zh
'运行环境', // zh
'tempo de execução', // pt
];
const RUNTIME_WORD_RE = new RegExp(
RUNTIME_WORDS.map((w) => {
const esc = escapeRegex(w);
// Anchor only when the term begins/ends with an ASCII word character;
// \b is meaningless beside an ideograph.
return /^[A-Za-z0-9]/.test(w) ? '\\b' + esc + '\\b' : esc;
}).join('|'),
'i',
);
// Positive evidence that the line is on the MODEL axis. Required, not merely
// the absence of a runtime word: a reviewer demonstrated that "absence of a
// veto word" is not evidence, with `The installer now offers Gemini 3.`,
// `MSD installs cleanly on Gemini 3, Cursor, and Codex.` and
// `Supported agents include Gemini 3, OpenCode, and Cursor.` all exiting 0 — the
// exact laundering class this guard exists to catch. Every real model-axis
// line in this repo names a model explicitly, so requiring it costs nothing
// and inverts the failure direction from "silently allow" to "flag".
const MODEL_WORDS = [
'model', 'models', // en
'モデル', // ja
'모델', // ko
'模型', // zh
'modelo', 'modelos', // pt
];
const MODEL_WORD_RE = new RegExp(
MODEL_WORDS.map((w) => {
const esc = escapeRegex(w);
return /^[A-Za-z0-9]/.test(w) ? '\\b' + esc + '\\b' : esc;
}).join('|'),
'i',
);
/**
* Is `pin` satisfied AT this match? The match must fall INSIDE an occurrence
* of the pin's snippet, not merely share a line with it.
*
* Line-level containment was too weak: a reviewer showed
* `Known provider menu update: Gemini CLI is once again a selectable MSD
* runtime.` and `Install target: Google (Gemini) — choose Gemini CLI as your
* MSD runtime.` both exiting 0, because a short snippet elsewhere on the line
* pre-approved a brand-new claim. Span containment means a pin can only ever
* excuse the occurrence its own text covers.
*/
function pinCoversMatch(line, matchIndex, matchedText, pin) {
const end = matchIndex + matchedText.length;
let from = 0;
for (;;) {
const at = line.indexOf(pin.snippet, from);
if (at === -1) return false;
if (matchIndex >= at && end <= at + pin.snippet.length) return true;
from = at + 1;
}
}
/**
* Decide whether one MATCH (not the whole line) is legitimate.
*
* Returns the matched pin, `true` for a general-rule exemption, or `false`
* for a violation.
*/
function classifyMatch(line, matchIndex, matchedText, pins) {
// The explicit escape hatch, which must carry a reason.
if (ALLOW_MARKER_RE.test(line)) return true;
const afterMatch = line.slice(matchIndex + matchedText.length);
// TIER 1(a) — inherited hook dialect, as an adjacent compound.
if (isDialectReference(line, matchIndex, afterMatch)) return true;
// TIER 1(b) — provider/model axis. Needs positive model evidence AND no
// runtime word; a version number alone never launders a claim.
if (MODEL_DISPLAY_AFTER.test(afterMatch)
&& MODEL_WORD_RE.test(line)
&& !RUNTIME_WORD_RE.test(line)) {
return true;
}
// TIER 2 — a pinned occurrence for this specific file, span-scoped.
for (const pin of pins) {
if (pinCoversMatch(line, matchIndex, matchedText, pin)) return pin;
}
return false;
}
function main() {
// Enumerate tracked files via git ls-files so only committed/staged source is checked.
let trackedFiles;
try {
trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' })
.split('\n')
.map((f) => f.trim())
.filter(Boolean);
} catch (err) {
throw new ExitError(1, 'ERROR lint-retired-runtime-name: git ls-files failed: ' + err.message);
}
const scanFiles = trackedFiles.filter(isInScanSet);
const violations = [];
const usedPins = new Set();
// Files present in the scan set, so a stale-pin check can distinguish
// "this pin's file is gone/not in this tree" from "this pin matched nothing".
const scannedRelPaths = new Set();
// Anti-vacuity counts files actually READ, not merely listed: if every read
// failed, a candidate count would still look healthy.
let filesRead = 0;
for (const relPath of scanFiles) {
if (isAllowlisted(relPath)) continue;
const fullPath = path.join(REPO_ROOT, relPath);
// Skip this guard script itself (path-based, though it is not a .md file
// and would never be in-scan-set anyway — defense in depth).
if (path.resolve(fullPath) === SELF_PATH) continue;
if (isBinary(fullPath)) continue;
let content;
try {
content = fs.readFileSync(fullPath, 'utf8');
} catch {
// Unreadable files (permissions, etc.) — skip silently.
continue;
}
filesRead += 1;
scannedRelPaths.add(relPath);
const pins = ALLOWLIST_OCCURRENCES.get(relPath) || [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Mark pins used by PRESENCE on the line, independent of which tier
// ultimately excused the match. Previously this was recorded only on
// the Tier-2 branch, so a pinned line that a general rule also matched
// never marked its pin used and the pin was reported stale — a reviewer
// demonstrated a provably false "no line matches pinned snippet" whose
// printed remedy told the maintainer to delete a still-needed pin.
for (const pin of pins) {
if (line.includes(pin.snippet)) usedPins.add(relPath + ' ' + pin.snippet);
}
for (const { runtime, re } of RETIRED_RUNTIME_MATCHERS) {
re.lastIndex = 0;
let match;
while ((match = re.exec(line)) !== null) {
const verdict = classifyMatch(line, match.index, match[0], pins);
if (verdict) continue;
violations.push({
file: relPath,
line: i + 1,
col: match.index + 1,
text: line.trim(),
runtime,
});
}
}
}
}
// Anti-vacuity: the scan must have READ a plausible number of files.
if (filesRead < MIN_EXPECTED_FILES) {
throw new ExitError(
1,
'ERROR lint-retired-runtime-name: only ' + filesRead + ' file(s) read '
+ '(expected at least ' + MIN_EXPECTED_FILES + '); the walk is empty or broken — refusing '
+ 'to report a false clean.',
);
}
// A pin that no longer matches anything is a stale allowlist entry: the
// content it approved was edited or removed, and leaving it in place would
// pre-approve a future occurrence nobody reviewed. Only checked for files
// actually present in this tree, so fixture roots are unaffected.
const stalePins = [];
for (const [relPath, pins] of ALLOWLIST_OCCURRENCES) {
if (!scannedRelPaths.has(relPath)) continue;
for (const pin of pins) {
if (!usedPins.has(relPath + ' ' + pin.snippet)) {
stalePins.push({ file: relPath, snippet: pin.snippet });
}
}
}
if (violations.length === 0 && stalePins.length === 0) {
process.stdout.write(
'ok lint-retired-runtime-name: ' + filesRead + ' files scanned, 0 violations\n',
);
return 0;
}
if (violations.length > 0) {
process.stderr.write('\nERROR lint-retired-runtime-name: ' + violations.length + ' violation(s) found\n\n');
for (const v of violations) {
process.stderr.write(' ' + v.file + ':' + v.line + ': ' + v.text + '\n');
}
process.stderr.write('\n');
for (const runtime of RETIRED_RUNTIMES) {
process.stderr.write(
'Fix: `' + runtime.name + '` was retired by ' + runtime.retiredBy + ' (sunset ' + runtime.sunset + ') '
+ 'in favor of `' + runtime.successor + '`. Either correct the reference to name the live runtime, '
+ 'or, if the use is a legitimate historical/provider reference, add the `' + ALLOW_MARKER + '` '
+ 'marker on the line.\n',
);
}
process.stderr.write('\n');
}
if (stalePins.length > 0) {
process.stderr.write(
'ERROR lint-retired-runtime-name: ' + stalePins.length + ' stale allowlist entr'
+ (stalePins.length === 1 ? 'y' : 'ies') + ' in ALLOWLIST_OCCURRENCES\n\n',
);
for (const s of stalePins) {
process.stderr.write(' ' + s.file + ': no line matches pinned snippet ' + JSON.stringify(s.snippet) + '\n');
}
process.stderr.write(
'\nFix: the approved line was edited or removed. Delete the pin, or update its '
+ 'snippet to the new wording — an unmatched pin pre-approves a future occurrence '
+ 'nobody reviewed.\n\n',
);
}
return 1;
}
// Only run the lint when invoked directly. `require()`ing this module (the
// parity test in tests/runtime-name-policy.test.cjs does) must not
// execute a full repository walk as a side effect.
if (require.main === module) {
runMain(main);
}
module.exports = { RETIRED_RUNTIMES };