201 lines
7.6 KiB
JavaScript
201 lines
7.6 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 { 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',
|
|
// Future phases: add new local/ portability rules here.
|
|
];
|
|
|
|
// ── 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 test files ────────────────────────────────────────────────────────
|
|
|
|
const SELF_ABS = __filename;
|
|
|
|
function collectTestFiles() {
|
|
return globSync('tests/**/*.test.cjs', { cwd: path.join(__dirname, '..') })
|
|
.map(rel => path.join(__dirname, '..', rel))
|
|
.filter(absPath => absPath !== SELF_ABS);
|
|
}
|
|
|
|
// ── Scan ──────────────────────────────────────────────────────────────────────
|
|
|
|
function scanFile(absPath) {
|
|
let src;
|
|
try {
|
|
src = fs.readFileSync(absPath, 'utf-8');
|
|
} catch (err) {
|
|
throw new Error(`Could not read ${absPath}: ${err.message}`);
|
|
}
|
|
|
|
let ast;
|
|
try {
|
|
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('C5: scanFile throws on parse error instead of silently returning empty result', () => {
|
|
// 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;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── 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'),
|
|
);
|
|
});
|
|
});
|