Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
620 lines
27 KiB
JavaScript
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, Kimi, and Codex.` and
|
|
// `Supported agents include Gemini 3, Kimi, 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 };
|