* feat(#1733): normalize-path-in-content production AST rule (Phase 5) ADR-1703 Phase 5 — the first production-code rule. local/normalize-path-in-content (src/**/*.cts, @typescript-eslint/parser): flags a path-returning fn result (path.basename excluded — returns a separator-less filename) interpolated into an @-reference / config-dir markdown body without .replace(/\\/g,'/') normalization, per RULESET.CONTENT-PATH-NORMALIZATION / DEFECT.WINDOWS-PATH-LEAK-IN-MARKDOWN-CONTENT. Build-and-assess found the canonical defect site (computePathPrefix) already compliant and only 1 src/ hit — a false positive (path.basename in a status message) — eliminated by narrowing (exclude basename; require a real @-ref/ config-dir marker, not bare .md). 0 src/ violations: clean forward-prevention. The out-of-band disable-ban now scans src/**/*.cts too (typescript-estree) so the production rule also cannot be eslint-disabled. Registered (error) + PROTECTED_RULES; CONTEXT.md predicates + how-to doc updated. - RuleTester suite (26 cases) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#1733): add changeset for Windows agent-skills path-leak fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: harden mutation-matrix.cjs stdin read against EAGAIN on non-blocking pipe scripts/mutation-matrix.cjs read piped stdin via readFileSync(process.stdin.fd). On macOS libuv marks the stdin pipe fd non-blocking, so a synchronous read can throw EAGAIN before the writer fills the pipe — intermittently, under heavy CI shard load — aborting the script (status 2) and flaking mutation-matrix-ratchet. Replace with readStdinSync(): an fs.readSync loop that retries on EAGAIN (1ms synchronous Atomics.wait yield), stops on 0-byte/EOF, and rethrows other errors. Deterministic regression test injects EAGAIN via an fs.readSync monkeypatch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci: re-run golden-install-parity on src/lib + installer changes (close drift guard) golden-install-parity hashes every installed bin/lib/*.cjs per runtime, so it must re-run whenever the built lib could change. ci-test-scope selected it for neither src/** nor installer changes, so a source-only edit (e.g. #1691's milestone.cts/roadmap.cts) recompiled bin/lib and silently drifted the golden fixtures past the scoped lane. Add golden-install-parity.test.cjs to both the 'TS runtime sources' and 'installer and package layout' selection rules, with behavioral regression tests for each. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: review-bot <review-bot@gsd>
273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* portability-rule-disable-ban.test.cjs
|
||
*
|
||
* Out-of-band disable-ban scan (ADR-1703).
|
||
*
|
||
* ESLint inline suppression of portability rules is banned. This test runs
|
||
* OUTSIDE ESLint so it cannot itself be eslint-disabled.
|
||
*
|
||
* PROTECTED_RULES grows as later phases add rules. Each new portability rule
|
||
* in the `local/` namespace should be appended to this list.
|
||
*
|
||
* Hard-fails on:
|
||
* (a) Any `eslint-disable*` comment that NAMES a protected portability rule.
|
||
* (b) Any BLANKET `eslint-disable*` comment (no rule list) — these suppress
|
||
* every rule including the protected ones.
|
||
*
|
||
* NOTE: This file itself is excluded from the scan by absolute path. It
|
||
* references the disable keyword only inside regex/string data structures to
|
||
* avoid being detected as a real directive.
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const espree = require('espree');
|
||
const tsEstree = require('@typescript-eslint/typescript-estree');
|
||
const { globSync } = require('glob');
|
||
|
||
// ── Protected portability rules (grows with each ADR-1703 phase) ──────────────
|
||
const PROTECTED_RULES = [
|
||
'no-path-literal-in-assert',
|
||
'no-posix-mode-bit-assert',
|
||
'no-unguarded-nonportable-exec',
|
||
// ADR-1703 Phase 4 rules (issue #1726)
|
||
'no-crlf-fragile-split',
|
||
'no-hardcoded-tmp',
|
||
'no-bare-npm-exec',
|
||
'require-userprofile-with-home',
|
||
// ADR-1703 Phase 5 rule (issue #1733) — applies to src/**/*.cts (production sources)
|
||
'normalize-path-in-content',
|
||
];
|
||
|
||
// ── Detect disable directives via the comment text ───────────────────────────
|
||
|
||
// The three directive forms ESLint recognises (built as concatenated strings so
|
||
// this source file contains NO real disable directive of its own).
|
||
const D = 'eslint-' + 'disable';
|
||
const DN = 'eslint-' + 'disable-next-line';
|
||
const DL = 'eslint-' + 'disable-line';
|
||
const DISABLE_PREFIXES = [DN, DL, D]; // longest first so prefix-match is greedy
|
||
|
||
/**
|
||
* Classify a comment node. Returns:
|
||
* 'blanket' — a disable with NO rule list (suppresses everything)
|
||
* 'named' — a disable that lists at least one protected portability rule
|
||
* null — not a disable directive, or a non-portability named disable
|
||
*/
|
||
function classifyComment(commentValue) {
|
||
const txt = commentValue.trim();
|
||
for (const prefix of DISABLE_PREFIXES) {
|
||
if (txt.startsWith(prefix)) {
|
||
// Text after the directive keyword
|
||
const rest = txt.slice(prefix.length).trim();
|
||
// Blanket: nothing after the keyword, or only a prose comment (starts with --)
|
||
if (!rest || rest.startsWith('--')) {
|
||
return 'blanket';
|
||
}
|
||
// Named: rest is a comma-separated rule list (possibly with -- prose)
|
||
const ruleList = rest.split('--')[0]; // strip trailing prose
|
||
const rules = ruleList.split(',').map(r => r.trim()).filter(Boolean);
|
||
for (const rule of rules) {
|
||
for (const protected_ of PROTECTED_RULES) {
|
||
if (rule === 'local/' + protected_ || rule === protected_) {
|
||
return 'named';
|
||
}
|
||
}
|
||
}
|
||
return null; // named disable but not for a protected rule
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Collect scanned files ─────────────────────────────────────────────────────
|
||
//
|
||
// The disable-ban covers:
|
||
// - tests/**/*.test.cjs — test sources (phase 1–4 scope)
|
||
// - src/**/*.cts — production TypeScript sources (extended in phase 5 to
|
||
// protect normalize-path-in-content, which applies to
|
||
// src/**/*.cts; an eslint-disable there would bypass the
|
||
// production rule entirely)
|
||
|
||
const SELF_ABS = __filename;
|
||
|
||
function collectTestFiles() {
|
||
const root = path.join(__dirname, '..');
|
||
const testFiles = globSync('tests/**/*.test.cjs', { cwd: root })
|
||
.map(rel => path.join(root, rel))
|
||
.filter(absPath => absPath !== SELF_ABS);
|
||
const srcFiles = globSync('src/**/*.cts', { cwd: root })
|
||
.map(rel => path.join(root, rel));
|
||
return [...testFiles, ...srcFiles];
|
||
}
|
||
|
||
// ── Scan ──────────────────────────────────────────────────────────────────────
|
||
|
||
function scanFile(absPath) {
|
||
let src;
|
||
try {
|
||
src = fs.readFileSync(absPath, 'utf-8');
|
||
} catch (err) {
|
||
throw new Error(`Could not read ${absPath}: ${err.message}`);
|
||
}
|
||
|
||
// .cts files use TypeScript syntax — use @typescript-eslint/typescript-estree.
|
||
// .cjs files use plain JS — use espree (the original parser).
|
||
const isCts = absPath.endsWith('.cts');
|
||
|
||
let ast;
|
||
try {
|
||
if (isCts) {
|
||
ast = tsEstree.parse(src, { comment: true, loc: true, range: true });
|
||
} else {
|
||
ast = espree.parse(src, {
|
||
comment: true,
|
||
ecmaVersion: 2022,
|
||
loc: true,
|
||
range: true,
|
||
tolerant: true,
|
||
});
|
||
}
|
||
} catch (parseErr) {
|
||
// C5: fail CLOSED on parse error — a file that fails to parse must FAIL the
|
||
// test with its path, not be silently skipped. Silent skip is a false-green:
|
||
// an unparseable test file could contain a real disable directive.
|
||
throw new Error(`Parse error in ${absPath}: ${parseErr.message}`);
|
||
}
|
||
|
||
const blanket = [];
|
||
const named = [];
|
||
|
||
for (const cmt of ast.comments || []) {
|
||
const kind = classifyComment(cmt.value);
|
||
if (!kind) continue;
|
||
const line = cmt.loc ? cmt.loc.start.line : '?';
|
||
const entry = { file: absPath, line, text: cmt.value.trim() };
|
||
if (kind === 'blanket') blanket.push(entry);
|
||
else if (kind === 'named') named.push(entry);
|
||
}
|
||
|
||
return { blanket, named };
|
||
}
|
||
|
||
// ── C5: parse-error fail-closed ───────────────────────────────────────────────
|
||
|
||
describe('C5 — scanFile fails closed on parse error', () => {
|
||
test('C5a: scanFile throws on parse error instead of silently returning empty result (.cjs path, espree)', () => {
|
||
// Inject a parse error deterministically by monkeypatching espree.parse.
|
||
// This is the cross-platform approach (works under root/Docker too).
|
||
const origParse = espree.parse;
|
||
try {
|
||
espree.parse = () => { throw new SyntaxError('injected parse error for C5 test'); };
|
||
// Create a minimal real file to scan (use this test file itself, which exists).
|
||
assert.throws(
|
||
() => scanFile(__filename),
|
||
(err) => {
|
||
return err instanceof Error &&
|
||
err.message.includes('injected parse error for C5 test');
|
||
},
|
||
'scanFile must throw on parse error, not silently return empty result'
|
||
);
|
||
} finally {
|
||
espree.parse = origParse;
|
||
}
|
||
});
|
||
|
||
test('W1/C5b: scanFile throws on parse error for .cts path (tsEstree path — fail-closed)', () => {
|
||
// W1: The existing C5a test only exercises the espree (.cjs) path. This test
|
||
// exercises the tsEstree (.cts) path by monkeypatching tsEstree.parse and
|
||
// pointing scanFile at a synthetic .cts-suffixed path.
|
||
//
|
||
// Cross-platform approach: monkeypatch the module method, not chmod/permissions
|
||
// (chmod 0o000 is bypassed by root in Docker and behaves differently per OS).
|
||
//
|
||
// tsEstree exports 'parse' via a configurable getter (no setter), so we use
|
||
// Object.defineProperty to inject a throwing stub, then restore the original
|
||
// descriptor in the finally block.
|
||
const tsEstreeModule = require('@typescript-eslint/typescript-estree');
|
||
const origDescriptor = Object.getOwnPropertyDescriptor(tsEstreeModule, 'parse');
|
||
const injected = () => { throw new SyntaxError('injected tsEstree parse error for W1/C5b test'); };
|
||
Object.defineProperty(tsEstreeModule, 'parse', {
|
||
value: injected,
|
||
writable: true,
|
||
configurable: true,
|
||
enumerable: true,
|
||
});
|
||
|
||
// Also monkeypatch fs.readFileSync to return dummy content for the fake .cts
|
||
// path, so the .cts branch in scanFile runs without needing a real file.
|
||
const origReadFileSync = fs.readFileSync;
|
||
fs.readFileSync = (p, enc) => {
|
||
if (typeof p === 'string' && p.endsWith('.cts')) return '// dummy cts content';
|
||
return origReadFileSync.call(fs, p, enc);
|
||
};
|
||
|
||
try {
|
||
assert.throws(
|
||
() => scanFile(path.join(__dirname, 'dummy-fixture.cts')),
|
||
(err) => {
|
||
return err instanceof Error &&
|
||
err.message.includes('injected tsEstree parse error for W1/C5b test');
|
||
},
|
||
'scanFile must throw on tsEstree parse error for .cts files (fail-closed)'
|
||
);
|
||
} finally {
|
||
fs.readFileSync = origReadFileSync;
|
||
// Restore original descriptor (getter-only)
|
||
Object.defineProperty(tsEstreeModule, 'parse', origDescriptor);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||
|
||
describe('portability-rule disable-ban (ADR-1703)', () => {
|
||
const testFiles = collectTestFiles();
|
||
|
||
test('test file enumeration finds at least 10 test files', () => {
|
||
assert.ok(
|
||
testFiles.length >= 10,
|
||
`Expected at least 10 test files, got ${testFiles.length}`,
|
||
);
|
||
});
|
||
|
||
test('no test file contains a named eslint-disable for a portability rule (category a)', () => {
|
||
const offenders = [];
|
||
for (const absPath of testFiles) {
|
||
const { named } = scanFile(absPath);
|
||
for (const o of named) {
|
||
offenders.push(`${path.relative(path.join(__dirname, '..'), o.file)}:${o.line} — ${o.text}`);
|
||
}
|
||
}
|
||
assert.deepStrictEqual(
|
||
offenders,
|
||
[],
|
||
'Found inline disable directives suppressing protected portability rules.\n' +
|
||
'These MUST be removed — the rule exists to enforce cross-platform safety:\n\n' +
|
||
offenders.map(s => ' ' + s).join('\n'),
|
||
);
|
||
});
|
||
|
||
test('no test file contains a blanket eslint-disable (category b — suppresses all rules including portability)', () => {
|
||
const offenders = [];
|
||
for (const absPath of testFiles) {
|
||
const { blanket } = scanFile(absPath);
|
||
for (const o of blanket) {
|
||
offenders.push(`${path.relative(path.join(__dirname, '..'), o.file)}:${o.line} — ${o.text}`);
|
||
}
|
||
}
|
||
assert.deepStrictEqual(
|
||
offenders,
|
||
[],
|
||
'Found blanket eslint-disable directives in test files.\n' +
|
||
'Blanket disables suppress ALL rules including portability rules and are banned.\n' +
|
||
'Replace with targeted per-rule disables for non-portability rules, or remove:\n\n' +
|
||
offenders.map(s => ' ' + s).join('\n'),
|
||
);
|
||
});
|
||
});
|