* test(#4590): add no-rendered-text-length-assert ESLint rule Enforces ADR-456's typed-surface mandate for one specific bug shape: a test assertion whose pass/fail depends on the length/substring content of a template literal that interpolates an OS-derived path (os.tmpdir(), os.homedir(), path.join/resolve/..., or a PATH_RETURNING_FNS resolver). Because macOS's default tmpdir prefix is longer than Linux's, such an assertion can pass on one runner and fail on another -- the defect class behind #4421's incident (git show 4e75b836e9), already fixed there by pinning to a typed field per ADR-456 Sec(c) before this rule existed to catch a recurrence. Two repo-wide sweeps against the real tests/ tree narrowed the rule to a sound scope: an initial design that traced call arguments (to approximate the historical incident's cross-file render-function shape) produced false positives on ordinary fs.readFileSync(path.join(...)) + assert.match patterns; a second design that matched any bare direct path-returning call produced 45 false positives on path suffix/prefix/non-emptiness checks. The shipped rule matches only a path-returning expression interpolated into a template literal, directly or via one identifier hop -- disclosed in the rule's own "Known boundaries" as not covering the literal cross-file incident shape, which would require tracing into a callee's body. Phase 1 of epic #4589 (CI test-matrix Linux-primary migration) -- Phase 2's safety argument depends on this class of OS-dependent test assertion being enforced going forward, not merely fixed once. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4590): address code-review findings on no-rendered-text-length-assert Reletter the "Known boundaries" doc-comment list (a)-(e), fixing a gap left by an earlier edit pass and every stale cross-reference to it. Collapse isDirectPathTaint/isTaintedInterpolation's duplicated TemplateLiteral-walk into one recursive relationship (isTaintedInterpolation now delegates a nested-template-literal case back to isDirectPathTaint instead of re-implementing the .some() traversal) -- behavior unchanged, confirmed by re-running the repo-wide sweep (still zero false positives). Found by the Standards-axis /code-review pass on this PR. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
280 lines
11 KiB
JavaScript
280 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* no-rendered-text-length-assert.rule.test.cjs
|
|
*
|
|
* RuleTester unit tests for the local/no-rendered-text-length-assert ESLint rule.
|
|
* Mirrors the style of tests/no-path-literal-in-assert.rule.test.cjs.
|
|
*
|
|
* Rule: report when a length/substring assertion is made against rendered text
|
|
* that embeds an OS-derived path (os.tmpdir(), os.homedir(), path.join/resolve/…,
|
|
* or a PATH_RETURNING_FNS resolver) — the #4421 incident shape.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { RuleTester } = require('eslint');
|
|
|
|
const noRenderedTextLengthAssert = require('../eslint-rules/no-rendered-text-length-assert.cjs');
|
|
|
|
const ruleTester = new RuleTester({
|
|
languageOptions: {
|
|
ecmaVersion: 2022,
|
|
sourceType: 'commonjs',
|
|
},
|
|
});
|
|
|
|
// ─── module shape ─────────────────────────────────────────────────────────────
|
|
|
|
describe('no-rendered-text-length-assert rule module', () => {
|
|
test('exports meta and create', () => {
|
|
assert.strictEqual(typeof noRenderedTextLengthAssert.meta, 'object');
|
|
assert.strictEqual(typeof noRenderedTextLengthAssert.create, 'function');
|
|
assert.strictEqual(noRenderedTextLengthAssert.meta.type, 'problem');
|
|
assert.ok(noRenderedTextLengthAssert.meta.messages.renderedTextLength);
|
|
});
|
|
});
|
|
|
|
// ─── INVALID cases (violation expected) ───────────────────────────────────────
|
|
|
|
describe('no-rendered-text-length-assert invalid cases', () => {
|
|
test('invalid: matrix row 1 — template literal directly embeds path.join, .length > numeric', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: '`foo ${path.join(a, b)} bar`.length > 240;',
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'renderedTextLength' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test('invalid: matrix row 2 — identifier resolved one hop to a template literal embedding os.tmpdir()', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `const bullet = \`x \${os.tmpdir()} y\`; bullet.length <= 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'renderedTextLength' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("invalid: matrix row 3 — KNOWN BOUNDARY: a locally shadowed `path` identifier is still name-matched as the real module (inherited from portability-vocab.cjs; same accepted boundary as sibling rule no-path-literal-in-assert). This case IS flagged, not a miss.", () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [],
|
|
invalid: [
|
|
{
|
|
code: `const path = { join: () => 'safe' }; \`x \${path.join(a, b)} y\`.length > 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
errors: [{ messageId: 'renderedTextLength' }],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── VALID cases (no violation expected) ─────────────────────────────────────
|
|
|
|
describe('no-rendered-text-length-assert valid cases', () => {
|
|
test('valid: matrix row 1 — fixed literal, no path taint', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `'literal string'.length === 5;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 2 — no path-derived field in scope', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `const obj = { name: 'foo' }; JSON.stringify(obj).length === 42;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 3 — typed field access (the ADR-456-compliant fix shape); also not even the flagged AST shape', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `json.todos[0].needs === 'yes';`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 4 — path-returning call already wrapped in a POSIX normalizer before being passed as the argument; must NOT flag', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `const tmpFile = String(path.join(tmpDir(), 'x')).replace(/\\\\/g, '/'); renderX({ filePath: tmpFile }).length > 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 5 — length-vs-length, no fixed numeric threshold, no truncation-boundary defect is plausible', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `a.length === b.length;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 6 — KNOWN BOUNDARY: receiver-side two-hop chain (b→a, then a is a bare Identifier not a direct path call or template literal) exceeds the one-hop budget (see rule header "Known boundaries"); documented, deliberate miss', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `const a = path.join(x, y); const b = a; b.length > 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 7 — KNOWN BOUNDARY: tmpFile arrives as a function PARAMETER, no declarator initializer to resolve (see rule header "Known boundaries" (b)); a different resolveOneHop code path than row 6 (binding found but not a Variable def, vs. binding found and resolved to another bare Identifier); documented, deliberate miss', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `function makeAssertion(tmpFile) { tmpFile.length > 240; }`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 8 — hostile/malformed: an identifier with no resolvable declaration anywhere in scope must not crash the rule and must not be flagged', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
code: `undeclaredIdentifier.length > 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 9 — KNOWN NON-GOAL (not a known boundary miss, a deliberate scope decision): the literal historical #4421/#2618 incident shape (call-argument taint) is out of scope for this rule', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
// KNOWN NON-GOAL (not a known boundary miss, a deliberate scope
|
|
// decision): this is the literal historical #4421/#2618 incident
|
|
// shape, but detecting it would require tracing into renderX's own
|
|
// function body to know its return embeds the filePath argument —
|
|
// out of scope for a single-file AST rule (see rule file "Known
|
|
// boundaries" (e) and 40-design.md "Rejected" #2). An earlier
|
|
// broader version of this rule that DID trace call arguments
|
|
// produced false positives on ordinary
|
|
// fs.readFileSync(path.join(...)) + assert.match patterns across
|
|
// dozens of real files in this repo — proving the heuristic
|
|
// unsound, not merely theoretically risky.
|
|
code: `const tmpFile = path.join(tmpDir(), 'x'); const bullet = renderX({ filePath: tmpFile }); bullet.includes('Needs:');`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 10 — regression: earlier rule version flagged this — fs.readFileSync\'s return does not embed its path argument', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
// regression: earlier rule version flagged this — fs.readFileSync's
|
|
// return does not embed its path argument
|
|
code: `const md = fs.readFileSync(path.join(__dirname, 'x.md'), 'utf8'); assert.match(md, /some pattern/);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 11 — bare path value, not embedded in a template literal — not the target defect class (see rule\'s "Known boundaries")', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
// bare path value, not embedded in a template literal — not the
|
|
// target defect class (see rule's "Known boundaries")
|
|
code: `path.join(a, b).length > 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
{
|
|
// bare path value, not embedded in a template literal — not the
|
|
// target defect class (see rule's "Known boundaries")
|
|
code: `const p = path.resolve(a, b); p.length <= 240;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 12 — regression (repo sweep, 45 false positives): file-extension check on a bare path value, not a rendered-text embedding', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
// real false-positive shape found by a repo-wide sweep: a
|
|
// file-extension check on a path value, now correctly excluded
|
|
code: `const full = path.join(dir, 'notes.md'); full.endsWith('.md');`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 13 — regression (repo sweep, 45 false positives): path-confinement check on a bare path value, not a rendered-text embedding', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
// real false-positive shape found by a repo-wide sweep: a
|
|
// path-confinement check on a resolved path value, now correctly
|
|
// excluded
|
|
code: `const resolved = path.resolve(root, sub); resolved.startsWith(root);`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
|
|
test('valid: matrix row 14 — regression (repo sweep, 45 false positives): non-emptiness check on a bare path value, not a rendered-text embedding', () => {
|
|
ruleTester.run('no-rendered-text-length-assert', noRenderedTextLengthAssert, {
|
|
valid: [
|
|
{
|
|
// real false-positive shape found by a repo-wide sweep: a
|
|
// non-emptiness check on a path value, now correctly excluded
|
|
code: `const dir = path.dirname(x); dir.length > 0;`,
|
|
filename: 'tests/foo.test.cjs',
|
|
},
|
|
],
|
|
invalid: [],
|
|
});
|
|
});
|
|
});
|