* chore(#4654): add local/no-unconfined-path-join and drain it to zero Phase 4 of epic #4636 — the ratchet, and the phase that makes the epic hold. THE MEASUREMENT THAT RESHAPED THE PHASE. An AST census (the repo's own parser, not grep) found what the epic never enumerated: ADR-4650 named seven containment implementations; `src/` alone held roughly 24 more hand-rolled gates across ~13 files, several guarding a write or an `fs.rmSync`. Two verified by reading rather than pattern-matching — `research-store.cts` comments its own as "ensure the resolved file path stays inside the store dir" immediately before a write, and `capability-lifecycle.cts` gates `fs.rmSync` with one. So the epic's Done-when "one containment predicate, used at every site" was FALSE when Phase 3 reported it satisfied. It is true now: the rule is clean across src/, scripts/, gsd-core/bin/ and hooks/ with an EMPTY allowlist. WHY NOT THE RULE THE ISSUE PROPOSED. #4654 proposed flagging `path.join` whose first argument is a managed root and whose later arguments derive from argv. That is a taint analysis over 2046 call sites, in ESLint, without type information; "derives from argv" is not locally decidable. Any approximation either floods or is trivially evaded, and a rule that fires on hundreds of correct sites earns an allowlist of hundreds — the opposite of a ratchet. What is actually duplicated is the COMPARISON, not the join, and that has one recognizable shape. Arm 1 X.startsWith(Y + sep) the hand-rolled containment idiom Arm 2 a containment predicate called as a bare statement, answer discarded Arm 2 is the issue's "asserts the result was narrowed, not merely that a helper was called". Its example `validatePath(x, root).resolved` is already structurally impossible — Phase 3 un-exported `validatePath` — so the remaining expressible failure is ignoring the answer, which is the defect that recurred five times in this epic. The census found exactly one live instance (`milestone.cts:1643`); it now returns the proven `ContainedPath` so consumers stop re-deriving the path the comment above it was extracted to stop them re-deriving. The rule deliberately does NOT try to catch validate-one-path-use-another where the answer is used but a different variable flows onward. That needs flow analysis; the branded `ContainedPath` from Phase 3 is the defense there, and the two are complementary. PER-SITE FAMILY CHOICE, NOT A DEFAULT. Phase 3's lesson binds: collapsing a lexical site onto the realpath family broke four tests and was caught only by the matrix. Every migrated site was triaged individually. The six installer-migrations tree-walks and the six capability-lifecycle gates take the LEXICAL family because their operands are already realpath-resolved and they deliberately treat the final component as a link; boundary sites take realpath. TWO SITES WITH AN INVERTED CONTRACT, which a mechanical swap would have broken. `installer-migrations.cts:127` and `runtime-artifact-install-plan.cts:144` REJECT `target === root` by contract, while the canonical comparison ACCEPTS it. Swapped naively, a migration could `rmdir` the user's config root and a third-party descriptor could write at configHome itself. Both keep `=== root` as an explicit additional arm alongside the predicate call — the predicate decides containment, the call site keeps its own extra condition (ADR-4650 decision 6). ONE DUPLICATE DELETED OUTRIGHT: `planning-inspect.cts`'s `isWithinRoot` was byte-identical to `isContainedIn` and said so in its own docstring. `isContainedIn` is now exported for callers that have already resolved both operands and need only the comparison, with a doc note that a caller which has NOT resolved them must use a full predicate instead. THE MARKER, AND WHY IT IS NOT THE ALLOWLIST. Nine sites are justified holdouts and carry `// allow-handrolled-containment: <reason>` with a mandatory, reviewable reason. Two justifications: (a) not a containment decision — an ancestor-walk loop condition, sub-repo grouping, worktree identity matching, declared-path coverage; (b) it IS containment but the canonical predicate is unreachable — `capability-validator.cjs` is a committed pre-build `.cjs` and the compiled `security.cjs` is untracked build output, so requiring it would break a fresh clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci` with the same exposure. The marker was renamed from `allow-lexical-prefix-match` mid-phase because that name asserted only (a) and would have stated something false at the (b) sites. A marker suppresses BEFORE the violation counter increments, so a file whose every occurrence is marked still reports `staleAllowlistEntry` — otherwise a drained entry lingers and silently re-permits the site later. DEMONSTRATED RED, per #4654: a hand-rolled copy reintroduced into a real `src/` file made `npm run lint` fail with the rule's full guidance message; removing it returned the tree to clean. Both halves recorded — red alone proves nothing, since a rule red for an unrelated reason looks identical. DISCLOSED: `defaultRequireFromInstallRoot` (gsd-tools.cjs) previously carried two distinct rejection messages and two manual realpath calls; routing it through `tryWithinRoot` collapses them to one message, and a missing module now surfaces as MODULE_NOT_FOUND rather than ENOENT. No test asserts either message. The security property is preserved and slightly strengthened — the candidate is realpathed and containment re-checked, and the dangling-symlink oracle closure comes along with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#4654): record the containment ratchet in CONTEXT.md and the security model Both entries previously described the seam without the thing that keeps it a seam. They now state what the rule bans, and — more usefully for whoever reads this next — what it deliberately does NOT attempt: deciding per path.join call whether an argument came from user input. That question is not locally decidable, and an approximation across ~2000 join sites would earn an exemption list of hundreds, which is the opposite of a ratchet. Also records the marker's two legitimate justifications and that its reason is mandatory, so the escape stays reviewable rather than becoming a mute button. Glossary gate 270 refs exit 0; install-tree goldens and CONTEXT-INDEX.json regenerated and confirmed byte-identical rather than assumed — which also confirms eslint-rules/ is not a shipped path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4654): close review findings and the two matrix failures MATRIX FAILURE 1 — a collapsed message broke a negative-proof test, and my evidence for collapsing it was wrong. I searched tests/ for the literal string "resolves outside its install root", found nothing, and reported that no test asserted it. The test matches a REGEX SUBSTRING, /outside its install root/, so the literal search missed it. What broke was "NEGATIVE PROOF: a symlinked module pointing OUTSIDE the install root is not loaded" — the test guarding the exact property I claimed was preserved. defaultRequireFromInstallRoot now does both checks again with both messages byte-identical, each routed through the canonical predicate, which is better than the original since that hand-rolled both comparisons. MATRIX FAILURE 2 — shipped migrations are checksum-locked, and a marker cannot serve there. migrationChecksum hashes plan.toString(), which INCLUDES comments, so a suppression marker inside a plan body drifts the baseline exactly as an edit does. Measured: with markers in place, two of the four still differed from their committed checksums. The four shipped bodies are now byte-identical to next, and the rule's config excludes those four paths BY NAME rather than by a directory wildcard, so a NEW migration is still covered. Six containment comparisons stay un-ratcheted there; that gap is recorded in the rule's Known gaps, in CONTEXT.md and in the security model rather than left implicit. Justification (c) is removed from the marker's documented reasons, because a marker was proven unable to express it. ADVERSARIAL REVIEW — the sharpest finding was that the rule banned the CORRECT shape while permitting the incorrect one: startsWith(root) with no separator is the genuinely unsafe form, since it accepts a sibling such as root-evil, and my own test blessed it as valid. Flagging every bare startsWith would swamp the rule, so that stays a STATED gap rather than a silent one. Closed for real: the template-literal spelling, which the census never saw because it only inspected plus-concatenation — that surfaced TWELVE more sites, now triaged and migrated. A separator reached through a const alias is now resolved via scope analysis. And isContainedIn, exported in Phase 3, was missing from the discarded-result set, so a bare no-op call went unflagged on the one function the epic funnels through. SECURITY REVIEW — the marker could over-suppress two ways: a block comment worked identically to a line comment, and one marker silently covered every violation sharing its line. It now requires a Line comment positioned after the flagged node ends, so it anchors to the node it trails. Four sites had dropped an unreachable-but-deliberate equality rejection against the root; each is restored as the call site's own arm. eslint.config.mjs still documented the OLD marker token, which my rename missed — it would have sent the next author in circles. A FALSE GREEN, recorded because it nearly stuck: lint:ci reported exit 0 from a stale eslint cache while twelve real violations existed. Every lint check here now clears the cache first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4654): anchor a suppression marker to the violation it actually trails The matrix caught this; my own test caught it, on its first execution. The case "two violations on one line: trailing marker suppresses only the one it trails" expected 1 error and got 0 — both were suppressed. ROOT CAUSE: the anchoring accepted any Line comment on the node's line whose range started at or after the node's end. A trailing marker at the END of a line sits after EVERY node on that line, so that condition held for all of them. "After the node" does not identify WHICH node the marker trails. The fix reads as correct and is not. FIX: deferred reporting. Violations accumulate during traversal instead of being reported immediately; at Program:exit each marker claims exactly ONE pending violation — the one on its line whose end is nearest before the marker begins — and every unclaimed violation is then counted and reported. One marker, one suppression. An earlier violation sharing the line is still reported, which is the property the security review asked for and the previous attempt only appeared to deliver. The counter now increments at flush time rather than during traversal, so a suppressed occurrence still does not keep an allowlist entry alive. AND A TOOL THAT SHOULD HAVE EXISTED BEFORE THE FIRST MATRIX RUN. `node --test` is hard-blocked here, so this rule's test file could only ever be executed on the remote matrix — which is why a broken anchoring shipped into a run. ESLint's programmatic Linter API is not a test runner, and exercising the rule through it verifies every case locally in seconds. All 24 now pass locally, including the two-on-one-line case that failed remotely. That loop should have been built before the rule was first sent to the matrix rather than after it failed twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4654): backfill PR 4674 into the changeset and complete 70-docs.json The phase gate requires enablementSequence and the Diataxis quadrants; 70-docs now carries both, with the how-to quadrant skipped for a stated reason rather than an empty field. The audience for this deliverable is a contributor who trips the rule, and the task-oriented guidance reaches them in the ESLint message itself — which names the correct predicate, says how to choose between the realpath and lexical families, cites the Phase 3 regression caused by choosing wrong, and gives the marker syntax. A docs/how-to page would be a second, driftable copy read by nobody at the moment of failure. enablementSequence is recorded as what it actually is: a VERIFICATION sequence, not an enablement one. The rule is never off, so there is no off-to-on transition to describe. scripts/lint-docs-required.cjs now passes (ok_docs_updated) — it could not evaluate against the mandated pr:0 placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1
eslint-rules/no-unconfined-path-join.allowlist.json
Normal file
1
eslint-rules/no-unconfined-path-join.allowlist.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
426
eslint-rules/no-unconfined-path-join.cjs
Normal file
426
eslint-rules/no-unconfined-path-join.cjs
Normal file
@@ -0,0 +1,426 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* no-unconfined-path-join
|
||||
*
|
||||
* Epic #4636 consolidated every hand-rolled path-containment check in this
|
||||
* repo onto a single implementation, `isContainedIn` in `src/security.cts`,
|
||||
* exported as two deliberate families:
|
||||
*
|
||||
* - `assertWithinRoot` / `tryWithinRoot` — realpath-resolved containment.
|
||||
* This is the DEFAULT for any boundary that takes external input (a user
|
||||
* path, a config value, an argv path): it resolves symlinks before
|
||||
* comparing, so a symlink planted inside the root that points outside it
|
||||
* cannot escape the check.
|
||||
* - `assertWithinRootLexical` / `tryWithinRootLexical` — lexical (string
|
||||
* prefix) containment, no filesystem access. This family exists ONLY for
|
||||
* the narrow case where a symlink must be PRESERVED rather than
|
||||
* resolved, or where the candidate path does not exist on disk yet (so
|
||||
* `realpath` would throw or silently resolve nothing). A caller must
|
||||
* pick the family deliberately — collapsing a lexical call site onto the
|
||||
* realpath form is not a safe "simplification": doing exactly that broke
|
||||
* four tests in Phase 3 of this epic (#4653), because those sites
|
||||
* depended on the symlink surviving unresolved.
|
||||
*
|
||||
* This rule has two arms, both aimed at call sites that never went through
|
||||
* that consolidation and so get neither guarantee.
|
||||
*
|
||||
* ## Arm 1 — hand-rolled containment comparison (`handRolledContainment`)
|
||||
*
|
||||
* Flags `x.startsWith(y + path.sep)` and the string-literal equivalents
|
||||
* (`y + '/'`, `y + '\\'`). This shape looks like a containment check but is
|
||||
* not one: it is a plain string-prefix test with no symlink resolution, no
|
||||
* normalization of `..` segments, and no protection against a `y` that is
|
||||
* itself a prefix of a sibling directory name (`/root-evil` starts with
|
||||
* `/root` + sep only if the separator is included, which this shape happens
|
||||
* to get right — but every other edge case `isContainedIn` handles is still
|
||||
* missing). Replace it with `assertWithinRoot`/`tryWithinRoot` (or the
|
||||
* `*Lexical` variant only if a symlink genuinely must not be resolved, or
|
||||
* the target does not exist yet) from `src/security.cts`.
|
||||
*
|
||||
* A one-line justified holdout can suppress a single occurrence with a
|
||||
* trailing same-line comment:
|
||||
*
|
||||
* resolved.startsWith(root + path.sep); // allow-handrolled-containment: <reason>
|
||||
*
|
||||
* Reporting is deferred to `Program:exit` so a marker can be matched against
|
||||
* the SPECIFIC violation it trails: among the violations that END on the
|
||||
* marker's line and before the marker starts, only the one with the LARGEST
|
||||
* end offset (i.e. the one immediately preceding the marker) is suppressed.
|
||||
* An earlier violation sharing the same line is still reported, and a marker
|
||||
* suppresses at most one violation.
|
||||
*
|
||||
* The marker covers two distinct justifications, and the mandatory reason
|
||||
* text is what distinguishes them for review:
|
||||
*
|
||||
* (a) the comparison is not a containment decision at all — prefix
|
||||
* filtering for selection, grouping or display, an ancestor-walk loop
|
||||
* condition, or identity matching.
|
||||
* (b) it IS a containment decision, but the canonical predicate in
|
||||
* `src/security.cts` is unreachable from this file. Two real cases:
|
||||
* `gsd-core/bin/lib/capability-validator.cjs` is a committed `.cjs`
|
||||
* that must run before `npm run build:lib`, and
|
||||
* `gsd-core/bin/lib/security.cjs` (the compiled predicate) is build
|
||||
* output that is gitignored and untracked — requiring it would break
|
||||
* a fresh clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci`
|
||||
* with the same exposure.
|
||||
*
|
||||
* The text after the colon must be non-empty after trimming — an empty or
|
||||
* missing reason does not suppress, and neither does the marker text without
|
||||
* a colon at all.
|
||||
*
|
||||
* ## Arm 2 — a discarded containment answer (`discardedContainmentResult`)
|
||||
*
|
||||
* Flags a bare statement-position call to one of the `src/security.cts`
|
||||
* containment predicates (`assertWithinRoot`, `tryWithinRoot`,
|
||||
* `requireSafePath`, `assertWithinRootLexical`, `tryWithinRootLexical`,
|
||||
* `isPathConfined`, `assertDestWithinConfigHome`) whose return value is
|
||||
* discarded. The return value IS the answer — for the `try*`/`is*` members
|
||||
* of this family in particular, calling them and throwing away the result is
|
||||
* indistinguishable from never having called them at all: no exception is
|
||||
* thrown on containment failure, so the call is a no-op that merely looks
|
||||
* like a check. This "validate one path, use another" defect recurred five
|
||||
* separate times across epic #4636's call sites. `assertWithinRoot` and its
|
||||
* lexical sibling throw on failure, so a bare statement call to those two
|
||||
* IS meaningful as a guard — but this rule still flags it, because the
|
||||
* common bug pattern is copy-pasting a `try*`/`is*` call as if it were an
|
||||
* `assert*` one; the fix in every case is to use the returned/normalized
|
||||
* path (or the boolean) rather than re-deriving it, or re-reading the
|
||||
* original unchecked value, downstream.
|
||||
*
|
||||
* ## Allowlist
|
||||
*
|
||||
* `allowlist` (repo-relative POSIX paths) exempts pre-existing legacy
|
||||
* violations pending migration, with mechanics identical to
|
||||
* `no-adhoc-timeout-literal.cjs`: an allowlisted file's violations are
|
||||
* counted internally but not reported; a listed file with zero violations
|
||||
* reports `staleAllowlistEntry` so the dead entry gets deleted. The
|
||||
* allowlist only ever ratchets down.
|
||||
*
|
||||
* ## Known gaps
|
||||
*
|
||||
* This rule raises the COST of the accidental hand-rolled copy — the failure
|
||||
* mode this repo actually observed five separate times — it is not a proof
|
||||
* that every unconfined path comparison is caught. Specifically:
|
||||
*
|
||||
* - `x.startsWith(root)` with NO separator — the genuinely unsafe variant,
|
||||
* since it accepts a sibling like `/root-evil` — is NOT flagged. Flagging
|
||||
* every bare `startsWith(identifier)` call in the codebase would swamp
|
||||
* the rule with unrelated string-prefix checks, so arm 1 only fires once
|
||||
* a separator is visibly appended. The canonical predicate
|
||||
* (`isContainedIn` / `assertWithinRoot` / `tryWithinRoot`) is
|
||||
* separator-aware internally, which is exactly why replacing either
|
||||
* shape — the correct-looking `root + sep` form and the actually-unsafe
|
||||
* bare form — with a call to it is the fix.
|
||||
* - Other spellings of the same comparison are not recognized:
|
||||
* `indexOf(root + path.sep) === 0` and
|
||||
* `x.slice(root.length).startsWith(path.sep)`.
|
||||
* - The suppression marker is anchored to the reported node's END line
|
||||
* (`node.loc.end.line`), not its start line: a call whose closing paren
|
||||
* lands on a later line than its first argument needs the marker after
|
||||
* THAT (closing) line, not after the call's opening line. `end.line` was
|
||||
* chosen over `start.line` specifically so a multi-line call CAN be
|
||||
* suppressed — the marker just has to trail the line the call's closing
|
||||
* paren is on, which is where a reader's eye (and a trailing `//`
|
||||
* comment) naturally lands after a multi-line expression. There is still
|
||||
* no "anywhere in this call" anchoring: a marker placed after the
|
||||
* opening line of a multi-line call does not suppress it.
|
||||
* - A separator reached through more than one level of `const` aliasing
|
||||
* (e.g. `const s1 = path.sep; const sep = s1; x.startsWith(root + sep)`)
|
||||
* is not resolved — only a single hop from a `+` right-operand Identifier
|
||||
* to its unique `const` initializer is followed, and only when that
|
||||
* initializer is itself a separator operand. `let`/reassigned/parameter
|
||||
* bindings are deliberately left unresolved: this is targeted alias
|
||||
* resolution for the one common shape, not general constant folding.
|
||||
* - Four shipped installer-migration bodies —
|
||||
* `src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts`,
|
||||
* `004-prune-stale-pristine-snapshots.cts`,
|
||||
* `009-pi-retire-reserved-hooks-dir.cts`, and
|
||||
* `010-antigravity-retire-confighome-artifacts.cts` — are entirely
|
||||
* un-ratcheted (excluded via `ignores` in `eslint.config.mjs`, listed by
|
||||
* exact path, not a directory wildcard). Their `plan` bodies are hashed
|
||||
* into `EXPECTED_CHECKSUMS` via `plan.toString()`
|
||||
* (tests/installer-migrations.test.cjs, issue #670), and that hash
|
||||
* includes comments — so neither a code fix NOR a suppression marker can
|
||||
* land inside these four bodies without drifting the checksum and
|
||||
* breaking upgrade state for anyone who already applied the migration.
|
||||
* A justification-(c) marker was tried and measured to still drift the
|
||||
* checksum, which is why (c) does not appear above: a marker cannot
|
||||
* serve this case. The only remedy is a fix-forward migration; a NEW
|
||||
* migration file is unaffected and still fully linted.
|
||||
*/
|
||||
|
||||
const DISCARDED_CALL_NAMES = new Set([
|
||||
'assertWithinRoot',
|
||||
'tryWithinRoot',
|
||||
'requireSafePath',
|
||||
'assertWithinRootLexical',
|
||||
'tryWithinRootLexical',
|
||||
'isPathConfined',
|
||||
'assertDestWithinConfigHome',
|
||||
// `isContainedIn` (src/security.cts) is a boolean predicate: calling it as a
|
||||
// bare statement and discarding the boolean is a pure no-op — arm 2's
|
||||
// failure mode, on the one function the whole consolidation funnels through.
|
||||
'isContainedIn',
|
||||
]);
|
||||
|
||||
const MARKER_RE = /allow-handrolled-containment:(.*)$/;
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow hand-rolled path-containment comparisons and discarded containment-check results',
|
||||
category: 'Security',
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
allowlist: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
handRolledContainment:
|
||||
'Hand-rolled containment comparison `x.startsWith(y + sep)`: this repo consolidated every ' +
|
||||
'path-containment check onto one implementation (`isContainedIn`, src/security.cts), exported ' +
|
||||
'as `assertWithinRoot`/`tryWithinRoot` (realpath-resolved — use this at any boundary that takes ' +
|
||||
'external input) and `assertWithinRootLexical`/`tryWithinRootLexical` (lexical, no filesystem ' +
|
||||
'access — use ONLY where a symlink must be PRESERVED rather than resolved, or the target does ' +
|
||||
'not exist yet). A caller must pick the family deliberately: collapsing a lexical site onto the ' +
|
||||
'realpath form broke four tests in Phase 3 of epic #4636 (#4653). Replace this comparison with a ' +
|
||||
'call to the correct family member, or if this is a justified holdout add a trailing same-line ' +
|
||||
'comment `// allow-handrolled-containment: <non-empty reason>` explaining either (a) this is not ' +
|
||||
'actually a containment decision (prefix filtering for selection/grouping/display, an ' +
|
||||
'ancestor-walk loop condition, or identity matching), or (b) it is a containment decision but the ' +
|
||||
'canonical predicate in src/security.cts is unreachable from this file.',
|
||||
discardedContainmentResult:
|
||||
'Containment predicate `{{name}}(...)` called as a bare statement: the return value IS the ' +
|
||||
'answer, and discarding it is indistinguishable from never calling it — no exception is thrown ' +
|
||||
'on failure for the `try*`/`is*` members of this family, so the call becomes a no-op that only ' +
|
||||
'looks like a check. This "validate one path, use another" defect recurred five times across ' +
|
||||
'epic #4636. Use the returned/normalized path (or boolean) at the point where you assign, branch, ' +
|
||||
'or pass a value downstream instead of discarding it.',
|
||||
staleAllowlistEntry:
|
||||
'{{file}} no longer contains an unconfined path-join violation. Delete its line from ' +
|
||||
'eslint-rules/no-unconfined-path-join.allowlist.json — the allowlist only ratchets down.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const options = context.options[0] || {};
|
||||
const allowlist = Array.isArray(options.allowlist) ? options.allowlist : [];
|
||||
|
||||
const filename = context.filename || context.getFilename();
|
||||
const cwd = context.cwd || (context.getCwd ? context.getCwd() : process.cwd());
|
||||
const rel = path.relative(cwd, filename).split(path.sep).join('/');
|
||||
const allowlisted = allowlist.includes(rel);
|
||||
let violations = 0;
|
||||
|
||||
const sourceCode = context.sourceCode || context.getSourceCode();
|
||||
|
||||
/**
|
||||
* Returns the list of valid suppression-marker comments in the file: a
|
||||
* Line comment carrying `allow-handrolled-containment: <non-empty
|
||||
* reason>`. A BLOCK comment never qualifies — the convention is a
|
||||
* trailing `//` marker, and accepting `/* *\/` would let an unrelated
|
||||
* block comment silently suppress too.
|
||||
*/
|
||||
function findMarkerComments() {
|
||||
const allComments =
|
||||
typeof sourceCode.getAllComments === 'function' ? sourceCode.getAllComments() : [];
|
||||
const markers = [];
|
||||
for (const comment of allComments) {
|
||||
if (comment.type !== 'Line') continue;
|
||||
const match = MARKER_RE.exec(comment.value);
|
||||
if (match && match[1] && match[1].trim().length > 0) {
|
||||
markers.push(comment);
|
||||
}
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right operand of `y + <right>` is a separator: either `path.sep`-shaped
|
||||
* (a non-computed MemberExpression whose property is the identifier
|
||||
* `sep`) or a single-character separator string literal.
|
||||
*/
|
||||
function isSeparatorOperand(node) {
|
||||
if (!node) return false;
|
||||
if (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.type === 'Identifier' &&
|
||||
node.property.name === 'sep'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value === '/' || node.value === '\\';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A template-literal argument ends with a separator: either the trailing
|
||||
* quasi text itself ends with `/` or `\` (`` `${root}/` ``), or the
|
||||
* trailing quasi is empty and the LAST expression is a separator operand
|
||||
* (`` `${root}${path.sep}` ``). Only the tail matters — a separator
|
||||
* embedded mid-template followed by further literal text is not a
|
||||
* containment-prefix shape.
|
||||
*/
|
||||
function isSeparatorEndingTemplate(node) {
|
||||
if (!node || node.type !== 'TemplateLiteral') return false;
|
||||
const quasis = node.quasis;
|
||||
if (!quasis || quasis.length === 0) return false;
|
||||
const lastQuasi = quasis[quasis.length - 1];
|
||||
const tail =
|
||||
lastQuasi.value.cooked !== null && lastQuasi.value.cooked !== undefined
|
||||
? lastQuasi.value.cooked
|
||||
: lastQuasi.value.raw;
|
||||
if (tail && (tail.endsWith('/') || tail.endsWith('\\'))) {
|
||||
return true;
|
||||
}
|
||||
if (tail === '' && node.expressions.length > 0) {
|
||||
const lastExpr = node.expressions[node.expressions.length - 1];
|
||||
return isSeparatorOperand(lastExpr);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a `+` right-operand Identifier to a separator via a single
|
||||
* `const` alias hop: `const sep = path.sep; x.startsWith(root + sep)`.
|
||||
* Uses scope analysis to find the unique binding for the identifier and
|
||||
* checks whether ITS initializer is a separator operand. Deliberately
|
||||
* narrow: only a `const` declarator with exactly one definition is
|
||||
* resolved, and only one hop is followed — this is not general constant
|
||||
* folding, and `let`/reassigned/parameter bindings are left unresolved.
|
||||
*/
|
||||
function resolveSeparatorAlias(identifierNode) {
|
||||
const scope = sourceCode.getScope(identifierNode);
|
||||
const ref = scope.references.find((r) => r.identifier === identifierNode);
|
||||
if (!ref || !ref.resolved) return false;
|
||||
const variable = ref.resolved;
|
||||
if (variable.defs.length !== 1) return false;
|
||||
const def = variable.defs[0];
|
||||
if (def.type !== 'Variable') return false;
|
||||
if (!def.parent || def.parent.kind !== 'const') return false;
|
||||
const declarator = def.node;
|
||||
if (!declarator || !declarator.init) return false;
|
||||
return isSeparatorOperand(declarator.init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the callee name of a discarded-containment candidate call:
|
||||
* a bare Identifier callee, or a non-computed MemberExpression whose
|
||||
* property is an Identifier.
|
||||
*/
|
||||
function calleeName(callee) {
|
||||
if (callee.type === 'Identifier') return callee.name;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
return callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Violations are not reported (or counted) during traversal. Each
|
||||
// would-be violation is deferred so that, once every node in the file has
|
||||
// been visited, a trailing marker can be matched against the SPECIFIC
|
||||
// violation it trails rather than every violation that happens to share
|
||||
// its line. See `Program:exit` for the resolution pass.
|
||||
//
|
||||
// Truth table (after resolution):
|
||||
// marked -> not counted, not reported
|
||||
// allowlisted + real violations -> counted (entry justified), not reported
|
||||
// allowlisted + only marked violations -> counter 0 -> staleAllowlistEntry fires, entry removable
|
||||
// neither -> counted and reported
|
||||
const pending = [];
|
||||
function reportViolation(node, messageId, data) {
|
||||
pending.push({ node, messageId, data });
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Arm 1: x.startsWith(y + sep)
|
||||
const callee = node.callee;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'startsWith' &&
|
||||
node.arguments.length === 1
|
||||
) {
|
||||
const arg = node.arguments[0];
|
||||
if (
|
||||
arg.type === 'BinaryExpression' &&
|
||||
arg.operator === '+' &&
|
||||
(isSeparatorOperand(arg.right) ||
|
||||
(arg.right.type === 'Identifier' && resolveSeparatorAlias(arg.right)))
|
||||
) {
|
||||
reportViolation(node, 'handRolledContainment');
|
||||
return;
|
||||
}
|
||||
if (isSeparatorEndingTemplate(arg)) {
|
||||
reportViolation(node, 'handRolledContainment');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Arm 2: a discarded containment-predicate result.
|
||||
if (node.parent && node.parent.type === 'ExpressionStatement') {
|
||||
const name = calleeName(callee);
|
||||
if (name && DISCARDED_CALL_NAMES.has(name)) {
|
||||
reportViolation(node, 'discardedContainmentResult', { name });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'(node) {
|
||||
// Resolve marker suppression: for each valid marker comment, find
|
||||
// among the still-unresolved pending violations the one whose node
|
||||
// ends on the marker's line and before the marker starts, preferring
|
||||
// the LARGEST such end offset — i.e. the violation the marker
|
||||
// visibly trails most closely. A marker suppresses at most one
|
||||
// violation; a violation is suppressed by at most one marker.
|
||||
const markers = findMarkerComments();
|
||||
const suppressed = new Set();
|
||||
for (const comment of markers) {
|
||||
let best = null;
|
||||
for (const entry of pending) {
|
||||
if (suppressed.has(entry)) continue;
|
||||
if (entry.node.loc.end.line !== comment.loc.start.line) continue;
|
||||
if (entry.node.range[1] > comment.range[0]) continue;
|
||||
if (!best || entry.node.range[1] > best.node.range[1]) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
if (best) suppressed.add(best);
|
||||
}
|
||||
|
||||
for (const entry of pending) {
|
||||
if (suppressed.has(entry)) continue;
|
||||
violations += 1;
|
||||
if (allowlisted) continue;
|
||||
context.report({ node: entry.node, messageId: entry.messageId, data: entry.data });
|
||||
}
|
||||
|
||||
if (allowlisted && violations === 0) {
|
||||
context.report({ node, messageId: 'staleAllowlistEntry', data: { file: rel } });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
Reference in New Issue
Block a user