Files
msd-core/tests/portability-rule-disable-ban.test.cjs
Tom Boucher 9d52043f50 feat(#1733): normalize-path-in-content production AST rule + fix Windows agent-skills content leak (Phase 5) (#1736)
* 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>
2026-06-25 21:49:22 -04:00

273 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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'),
);
});
});