@mmnto/totem 1.118.0 → 1.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifacts/admission.d.ts +12 -12
- package/dist/artifacts/verdict.d.ts +16 -16
- package/dist/compile-manifest.d.ts +89 -0
- package/dist/compile-manifest.d.ts.map +1 -1
- package/dist/compile-manifest.js +180 -31
- package/dist/compile-manifest.js.map +1 -1
- package/dist/compile-manifest.test.js +169 -1
- package/dist/compile-manifest.test.js.map +1 -1
- package/dist/compiler-schema.d.ts +1067 -0
- package/dist/compiler-schema.d.ts.map +1 -1
- package/dist/compiler-schema.js +204 -1
- package/dist/compiler-schema.js.map +1 -1
- package/dist/compiler-schema.test.js +92 -0
- package/dist/compiler-schema.test.js.map +1 -1
- package/dist/compiler.d.ts +2 -6
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +6 -17
- package/dist/compiler.js.map +1 -1
- package/dist/freeze.d.ts +3 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -12
- package/dist/lesson-format.d.ts +5 -2
- package/dist/lesson-format.d.ts.map +1 -1
- package/dist/lesson-format.js +16 -5
- package/dist/lesson-format.js.map +1 -1
- package/dist/lesson-format.test.js +11 -0
- package/dist/lesson-format.test.js.map +1 -1
- package/dist/regex-safety/apply-rules-bounded.d.ts +11 -0
- package/dist/regex-safety/apply-rules-bounded.d.ts.map +1 -1
- package/dist/regex-safety/apply-rules-bounded.js +59 -5
- package/dist/regex-safety/apply-rules-bounded.js.map +1 -1
- package/dist/regex-validation.d.ts +7 -0
- package/dist/regex-validation.d.ts.map +1 -0
- package/dist/regex-validation.js +32 -0
- package/dist/regex-validation.js.map +1 -0
- package/dist/rule-engine.d.ts +11 -1
- package/dist/rule-engine.d.ts.map +1 -1
- package/dist/rule-engine.js +125 -18
- package/dist/rule-engine.js.map +1 -1
- package/dist/spine/authored-rule.d.ts +669 -245
- package/dist/spine/authored-rule.d.ts.map +1 -1
- package/dist/spine/authored-rule.js +113 -27
- package/dist/spine/authored-rule.js.map +1 -1
- package/dist/spine/authored-rule.test.js +170 -4
- package/dist/spine/authored-rule.test.js.map +1 -1
- package/dist/spine/authoring-ledger.d.ts +69 -10
- package/dist/spine/authoring-ledger.d.ts.map +1 -1
- package/dist/spine/authoring-ledger.js +46 -25
- package/dist/spine/authoring-ledger.js.map +1 -1
- package/dist/spine/authoring-ledger.test.js +20 -7
- package/dist/spine/authoring-ledger.test.js.map +1 -1
- package/dist/spine/candidate-rule.d.ts +18 -1
- package/dist/spine/candidate-rule.d.ts.map +1 -1
- package/dist/spine/candidate-rule.js.map +1 -1
- package/dist/spine/cert-corpus-seed.d.ts +8 -8
- package/dist/spine/compile.d.ts +4 -1
- package/dist/spine/compile.d.ts.map +1 -1
- package/dist/spine/compile.js +50 -3
- package/dist/spine/compile.js.map +1 -1
- package/dist/spine/compile.test.js +189 -23
- package/dist/spine/compile.test.js.map +1 -1
- package/dist/spine/frozen-split.d.ts +18 -18
- package/dist/spine/ledgers.d.ts +24 -24
- package/dist/spine/lf-normalize.d.ts +9 -0
- package/dist/spine/lf-normalize.d.ts.map +1 -0
- package/dist/spine/lf-normalize.js +38 -0
- package/dist/spine/lf-normalize.js.map +1 -0
- package/dist/spine/preimage-differential.d.ts +3 -1
- package/dist/spine/preimage-differential.d.ts.map +1 -1
- package/dist/spine/preimage-differential.js +9 -6
- package/dist/spine/preimage-differential.js.map +1 -1
- package/dist/spine/preimage-differential.test.js +59 -0
- package/dist/spine/preimage-differential.test.js.map +1 -1
- package/dist/spine/record-exemplars.fixture.d.ts +5 -0
- package/dist/spine/record-exemplars.fixture.d.ts.map +1 -0
- package/dist/spine/record-exemplars.fixture.js +61 -0
- package/dist/spine/record-exemplars.fixture.js.map +1 -0
- package/dist/spine/record-lower.d.ts +90 -0
- package/dist/spine/record-lower.d.ts.map +1 -0
- package/dist/spine/record-lower.js +418 -0
- package/dist/spine/record-lower.js.map +1 -0
- package/dist/spine/record-lower.test.d.ts +2 -0
- package/dist/spine/record-lower.test.d.ts.map +1 -0
- package/dist/spine/record-lower.test.js +562 -0
- package/dist/spine/record-lower.test.js.map +1 -0
- package/dist/spine/record-runtime.d.ts +215 -0
- package/dist/spine/record-runtime.d.ts.map +1 -0
- package/dist/spine/record-runtime.js +330 -0
- package/dist/spine/record-runtime.js.map +1 -0
- package/dist/spine/record-runtime.test.d.ts +2 -0
- package/dist/spine/record-runtime.test.d.ts.map +1 -0
- package/dist/spine/record-runtime.test.js +814 -0
- package/dist/spine/record-runtime.test.js.map +1 -0
- package/dist/spine/rule-record.d.ts +864 -0
- package/dist/spine/rule-record.d.ts.map +1 -0
- package/dist/spine/rule-record.js +781 -0
- package/dist/spine/rule-record.js.map +1 -0
- package/dist/spine/rule-record.test.d.ts +2 -0
- package/dist/spine/rule-record.test.d.ts.map +1 -0
- package/dist/spine/rule-record.test.js +1262 -0
- package/dist/spine/rule-record.test.js.map +1 -0
- package/dist/spine/windtunnel-firing.d.ts.map +1 -1
- package/dist/spine/windtunnel-firing.js +28 -1
- package/dist/spine/windtunnel-firing.js.map +1 -1
- package/dist/spine/windtunnel-lock.d.ts +10 -10
- package/dist/stage4-verifier.d.ts.map +1 -1
- package/dist/stage4-verifier.js +61 -17
- package/dist/stage4-verifier.js.map +1 -1
- package/dist/sys/glob.d.ts +30 -0
- package/dist/sys/glob.d.ts.map +1 -1
- package/dist/sys/glob.js +62 -1
- package/dist/sys/glob.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
// ─── Prop 310 § Design 14 — conformance suite, slice 2: RUNTIME EVALUATION ───
|
|
2
|
+
//
|
|
3
|
+
// What a lowered record MEANS when `totem lint` runs. Three obligations:
|
|
4
|
+
//
|
|
5
|
+
// § Design 7 — the two-array scope rule AND the dialect's own matching
|
|
6
|
+
// semantics, including the "no silent promotion" clause slice 1
|
|
7
|
+
// deferred to this slice: `*.ts` is ROOT-LEVEL, tree-wide is
|
|
8
|
+
// written `**\/*.ts`. Slice 1 could only pin that a glob is
|
|
9
|
+
// CARRIED verbatim; only a matcher can pin what it MEANS.
|
|
10
|
+
// § Design 8 — the `requires:` two-pass, at both implemented scopes, positive
|
|
11
|
+
// and negative, plus the end-to-end differential over the spec's
|
|
12
|
+
// own exemplar (fire on `bad`, silent on `good` — ADR-112 §4).
|
|
13
|
+
// Non-regression — the legacy corpus keeps the shipped matcher byte-for-byte.
|
|
14
|
+
// The record dialect is reachable ONLY through the § Design 12
|
|
15
|
+
// compiled homes, and the promotion tests below assert BOTH
|
|
16
|
+
// directions: promoted on the legacy path, not on the record
|
|
17
|
+
// path, from the same glob string.
|
|
18
|
+
import * as fs from 'node:fs';
|
|
19
|
+
import * as os from 'node:os';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
22
|
+
import { stringify as yamlStringify } from 'yaml';
|
|
23
|
+
import { CompiledRuleSchema } from '../compiler-schema.js';
|
|
24
|
+
import { applyRulesToAdditionsBounded } from '../regex-safety/apply-rules-bounded.js';
|
|
25
|
+
import { RegexEvaluator } from '../regex-safety/evaluator.js';
|
|
26
|
+
import { applyAstRulesToAdditions, applyRulesToAdditions, matchesGlob, } from '../rule-engine.js';
|
|
27
|
+
import { verifyAgainstCodebase } from '../stage4-verifier.js';
|
|
28
|
+
import { matchesRecordGlob } from '../sys/glob.js';
|
|
29
|
+
import { design4ExemplarRecord, design8ExemplarRecord } from './record-exemplars.fixture.js';
|
|
30
|
+
import { compileRuleRecord } from './record-lower.js';
|
|
31
|
+
import { assertNoAstGrepLineScope, assertNoTornRecordRules, assertRequiresPatternsSafe, isInsideRoot, isRecordPathRule, recordScopeMatchesFile, requiresSuppressesMatch, ruleAppliesToFile, ruleBadExampleLines, ruleGoodExampleLines, } from './record-runtime.js';
|
|
32
|
+
import { parseRuleRecord } from './rule-record.js';
|
|
33
|
+
import { buildFirings } from './windtunnel-firing.js';
|
|
34
|
+
const FILE = '.totem/rules/exemplar.rule.yaml';
|
|
35
|
+
const RULE_ID = '0123456789abcdef';
|
|
36
|
+
const NOW = '2026-08-21T00:00:00.000Z';
|
|
37
|
+
function ctx() {
|
|
38
|
+
return { logger: { warn: () => { } }, state: { hasWarnedShieldContext: false } };
|
|
39
|
+
}
|
|
40
|
+
/** Parse (slice 1) → lower (slice 2). Throws on a rejection so a fixture defect is loud. */
|
|
41
|
+
function compile(record) {
|
|
42
|
+
const outcome = compileRuleRecord(parseRuleRecord(yamlStringify(record), FILE), {
|
|
43
|
+
ruleId: RULE_ID,
|
|
44
|
+
now: NOW,
|
|
45
|
+
});
|
|
46
|
+
if (outcome.kind !== 'compiled')
|
|
47
|
+
throw new Error(`fixture did not lower: ${outcome.reason}`);
|
|
48
|
+
return outcome.rule;
|
|
49
|
+
}
|
|
50
|
+
function addition(file, line, lineNumber = 1) {
|
|
51
|
+
return { file, line, lineNumber, precedingLine: null };
|
|
52
|
+
}
|
|
53
|
+
/** A record-path regex rule scoped to exactly the globs given. */
|
|
54
|
+
function scopedRegexRecord(fileGlobs, excludeGlobs) {
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
severity: 'error',
|
|
58
|
+
message: 'no console.log',
|
|
59
|
+
target: {
|
|
60
|
+
type: 'regex',
|
|
61
|
+
pattern: 'console\\.log',
|
|
62
|
+
scope: { fileGlobs, ...(excludeGlobs ? { excludeGlobs } : {}) },
|
|
63
|
+
},
|
|
64
|
+
examples: [{ bad: 'console.log(1)', good: 'logger.info(1)' }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** A LEGACY-shaped compiled rule: carries none of the § Design 12 homes. */
|
|
68
|
+
function legacyRule(fileGlobs) {
|
|
69
|
+
return CompiledRuleSchema.parse({
|
|
70
|
+
lessonHash: 'fedcba9876543210',
|
|
71
|
+
lessonHeading: 'legacy rule',
|
|
72
|
+
pattern: 'console\\.log',
|
|
73
|
+
message: 'no console.log',
|
|
74
|
+
engine: 'regex',
|
|
75
|
+
severity: 'error',
|
|
76
|
+
compiledAt: NOW,
|
|
77
|
+
fileGlobs,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// ─── F. § Design 7 — dialect semantics, and the silent-promotion half ────────
|
|
81
|
+
describe('§ Design 7 — the record dialect means what the glob says', () => {
|
|
82
|
+
it('matches `*.ts` at ROOT LEVEL ONLY (no silent promotion)', () => {
|
|
83
|
+
expect(matchesRecordGlob('a.ts', '*.ts')).toBe(true);
|
|
84
|
+
expect(matchesRecordGlob('src/a.ts', '*.ts')).toBe(false);
|
|
85
|
+
expect(matchesRecordGlob('packages/core/src/a.ts', '*.ts')).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
it('matches `**\\/*.ts` TREE-WIDE, including the root', () => {
|
|
88
|
+
expect(matchesRecordGlob('a.ts', '**/*.ts')).toBe(true);
|
|
89
|
+
expect(matchesRecordGlob('src/a.ts', '**/*.ts')).toBe(true);
|
|
90
|
+
expect(matchesRecordGlob('packages/core/src/a.ts', '**/*.ts')).toBe(true);
|
|
91
|
+
expect(matchesRecordGlob('src/a.js', '**/*.ts')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
it('keeps the LEGACY matcher’s promotion byte-for-byte (the non-regression half)', () => {
|
|
94
|
+
// Same glob string, opposite meaning, and that is the point: the shipped
|
|
95
|
+
// rule-engine profile promotes a shallow glob tree-wide, which is exactly the
|
|
96
|
+
// silent semantic rewrite § Design 7 removes — on the record path ONLY.
|
|
97
|
+
expect(matchesGlob('src/a.ts', '*.ts')).toBe(true);
|
|
98
|
+
expect(matchesGlob('a.ts', '*.ts')).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it('anchors segments: `**` is whole-segment and `packages/**` is a subtree', () => {
|
|
101
|
+
expect(matchesRecordGlob('packages/core/src/a.ts', 'packages/**/*.ts')).toBe(true);
|
|
102
|
+
expect(matchesRecordGlob('packages/a.ts', 'packages/**/*.ts')).toBe(true);
|
|
103
|
+
expect(matchesRecordGlob('other/core/a.ts', 'packages/**/*.ts')).toBe(false);
|
|
104
|
+
expect(matchesRecordGlob('packages/core/src/a.ts', 'packages/**')).toBe(true);
|
|
105
|
+
expect(matchesRecordGlob('packages', 'packages/**')).toBe(false);
|
|
106
|
+
// `*` never crosses a separator.
|
|
107
|
+
expect(matchesRecordGlob('src/core/a.ts', 'src/*/a.ts')).toBe(true);
|
|
108
|
+
expect(matchesRecordGlob('src/core/deep/a.ts', 'src/*/a.ts')).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
it('is CASE-SENSITIVE against path names, regardless of host case-folding', () => {
|
|
111
|
+
expect(matchesRecordGlob('a.ts', '*.TS')).toBe(false);
|
|
112
|
+
expect(matchesRecordGlob('SRC/a.ts', 'src/*.ts')).toBe(false);
|
|
113
|
+
expect(matchesRecordGlob('src/a.ts', 'src/*.ts')).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
it('normalizes HOST separators on the path before evaluating', () => {
|
|
116
|
+
// Records use `/` exclusively (a backslash in a glob is a parse error), and
|
|
117
|
+
// "matchers normalize host separators before evaluation" — so a Windows-shaped
|
|
118
|
+
// path still matches a `/`-written glob.
|
|
119
|
+
expect(matchesRecordGlob('src\\a.ts', 'src/*.ts')).toBe(true);
|
|
120
|
+
expect(matchesRecordGlob('packages\\core\\src\\a.ts', 'packages/**/*.ts')).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
it('admits NO brace expansion (a hand-edited manifest cannot smuggle one in)', () => {
|
|
123
|
+
expect(matchesRecordGlob('a.ts', '*.{ts,js}')).toBe(false);
|
|
124
|
+
expect(matchesRecordGlob('a.{ts,js}', '*.{ts,js}')).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
// ─── G. § Design 7 — the two-array scope rule ────────────────────────────────
|
|
128
|
+
describe('§ Design 7 — a file is in scope iff it matches a positive and NO exclusion', () => {
|
|
129
|
+
it('applies exclusions as POSITIVE-form globs (`positiveMatch && !excludeMatch`)', () => {
|
|
130
|
+
const rule = compile(design4ExemplarRecord());
|
|
131
|
+
expect(rule.excludeGlobs).toEqual(['**/*.test.ts']);
|
|
132
|
+
expect(ruleAppliesToFile(rule, 'packages/core/src/a.ts')).toBe(true);
|
|
133
|
+
expect(ruleAppliesToFile(rule, 'packages/core/src/a.test.ts')).toBe(false);
|
|
134
|
+
expect(ruleAppliesToFile(rule, 'scripts/a.ts')).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
it('needs a positive match first — an exclusion alone never widens scope', () => {
|
|
137
|
+
expect(recordScopeMatchesFile('src/a.ts', ['src/*.ts'], ['**/*.test.ts'])).toBe(true);
|
|
138
|
+
expect(recordScopeMatchesFile('src/a.test.ts', ['src/*.ts'], ['**/*.test.ts'])).toBe(false);
|
|
139
|
+
expect(recordScopeMatchesFile('other/a.ts', ['src/*.ts'], undefined)).toBe(false);
|
|
140
|
+
// Empty positives match NOTHING on the record path — never everything.
|
|
141
|
+
expect(recordScopeMatchesFile('src/a.ts', [], undefined)).toBe(false);
|
|
142
|
+
expect(recordScopeMatchesFile('src/a.ts', undefined, undefined)).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
it('carries the no-promotion rule THROUGH the engine, not just the matcher', () => {
|
|
145
|
+
// The end-to-end half of slice 1's deferred silent-promotion fixture: a
|
|
146
|
+
// COMPILED `*.ts` scopes a real lint dispatch to the repo root, and the same
|
|
147
|
+
// rule written `**\/*.ts` scopes it tree-wide. Same source line, same engine,
|
|
148
|
+
// two globs — the only difference is what the dialect says they mean.
|
|
149
|
+
const shallow = compile(scopedRegexRecord(['*.ts']));
|
|
150
|
+
const deep = compile(scopedRegexRecord(['**/*.ts']));
|
|
151
|
+
const hit = 'console.log(1)';
|
|
152
|
+
expect(applyRulesToAdditions(ctx(), [shallow], [addition('a.ts', hit)])).toHaveLength(1);
|
|
153
|
+
expect(applyRulesToAdditions(ctx(), [shallow], [addition('src/a.ts', hit)])).toEqual([]);
|
|
154
|
+
expect(applyRulesToAdditions(ctx(), [deep], [addition('a.ts', hit)])).toHaveLength(1);
|
|
155
|
+
expect(applyRulesToAdditions(ctx(), [deep], [addition('src/a.ts', hit)])).toHaveLength(1);
|
|
156
|
+
// The legacy path from the SAME glob string still promotes — non-regression.
|
|
157
|
+
expect(applyRulesToAdditions(ctx(), [legacyRule(['*.ts'])], [addition('src/a.ts', hit)])).toHaveLength(1);
|
|
158
|
+
});
|
|
159
|
+
it('applies exclusions through the engine as well as the predicate', () => {
|
|
160
|
+
const rule = compile(scopedRegexRecord(['**/*.ts'], ['**/*.test.ts']));
|
|
161
|
+
const hit = 'console.log(1)';
|
|
162
|
+
expect(applyRulesToAdditions(ctx(), [rule], [addition('src/a.ts', hit)])).toHaveLength(1);
|
|
163
|
+
expect(applyRulesToAdditions(ctx(), [rule], [addition('src/a.test.ts', hit)])).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
it('routes a LEGACY rule through the shipped predicate, `!`-negation included', () => {
|
|
166
|
+
const legacy = legacyRule(['**/*.ts', '!**/*.test.ts']);
|
|
167
|
+
expect(isRecordPathRule(legacy)).toBe(false);
|
|
168
|
+
expect(ruleAppliesToFile(legacy, 'src/a.ts')).toBe(true);
|
|
169
|
+
expect(ruleAppliesToFile(legacy, 'src/a.test.ts')).toBe(false);
|
|
170
|
+
// Shipped unscoped behaviour: a rule with no globs applies everywhere.
|
|
171
|
+
expect(ruleAppliesToFile(legacyRule([]), 'anything.rs')).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
// ─── H. § Design 8 — the requires two-pass ───────────────────────────────────
|
|
175
|
+
describe('§ Design 8 — requires fires on ABSENT context and stays silent on PRESENT', () => {
|
|
176
|
+
const lineRule = compile(design8ExemplarRecord());
|
|
177
|
+
const fileRule = compile({
|
|
178
|
+
...design8ExemplarRecord(),
|
|
179
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
180
|
+
});
|
|
181
|
+
it('line scope — absent context suppresses nothing, present context suppresses', () => {
|
|
182
|
+
expect(requiresSuppressesMatch(lineRule, { line: 'git log --oneline', file: () => null })).toBe(false);
|
|
183
|
+
expect(requiresSuppressesMatch(lineRule, {
|
|
184
|
+
line: 'LC_ALL=C git log --oneline',
|
|
185
|
+
file: () => null,
|
|
186
|
+
})).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
it('line scope is LINE-local — the same token elsewhere in the file does not count', () => {
|
|
189
|
+
const elsewhere = ['LC_ALL=C echo hi', 'git log --oneline'].join('\n');
|
|
190
|
+
expect(requiresSuppressesMatch(lineRule, {
|
|
191
|
+
line: 'git log --oneline',
|
|
192
|
+
file: () => elsewhere,
|
|
193
|
+
})).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
it('file scope — the requirement is satisfied ANYWHERE in the file', () => {
|
|
196
|
+
const elsewhere = ['export LC_ALL=C', 'git log --oneline'].join('\n');
|
|
197
|
+
expect(requiresSuppressesMatch(fileRule, { line: 'git log --oneline', file: () => elsewhere })).toBe(true);
|
|
198
|
+
expect(requiresSuppressesMatch(fileRule, {
|
|
199
|
+
line: 'git log --oneline',
|
|
200
|
+
file: () => 'git log --oneline',
|
|
201
|
+
})).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
it('file scope with an UNREADABLE file fails toward FLAGGING, never suppression', () => {
|
|
204
|
+
expect(requiresSuppressesMatch(fileRule, { line: 'git log --oneline', file: () => null })).toBe(false);
|
|
205
|
+
});
|
|
206
|
+
it('never suppresses a rule that carries no requires block (every legacy rule)', () => {
|
|
207
|
+
expect(requiresSuppressesMatch(legacyRule(['**/*.ts']), { line: 'anything', file: () => 'x' })).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
it('fails LOUD on an uncompilable requires.pattern (a hand-edited manifest)', () => {
|
|
210
|
+
const tampered = CompiledRuleSchema.parse({
|
|
211
|
+
...lineRule,
|
|
212
|
+
requires: { pattern: '(', scope: 'line' },
|
|
213
|
+
});
|
|
214
|
+
expect(() => requiresSuppressesMatch(tampered, { line: 'x', file: () => null })).toThrow(/invalid `requires.pattern`/);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
// ─── I. End-to-end through the rule engine ───────────────────────────────────
|
|
218
|
+
describe('end-to-end — parse → lower → evaluate (§ Design 8 exemplar, regex path)', () => {
|
|
219
|
+
const rule = compile(design8ExemplarRecord());
|
|
220
|
+
it('FIRES on its own `bad` example and stays SILENT on its `good`', () => {
|
|
221
|
+
const bad = applyRulesToAdditions(ctx(), [rule], [addition('scripts/x.sh', 'git log --oneline')]);
|
|
222
|
+
expect(bad).toHaveLength(1);
|
|
223
|
+
expect(bad[0].rule.lessonHash).toBe(RULE_ID);
|
|
224
|
+
const good = applyRulesToAdditions(ctx(), [rule], [addition('scripts/x.sh', 'LC_ALL=C git log --oneline')]);
|
|
225
|
+
expect(good).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
it('emits NO rule event when the required context is present — silence, not suppression', () => {
|
|
228
|
+
// § Design 8 makes the requirement part of the MATCH PREDICATE: a locus with
|
|
229
|
+
// its context present is not a match, so it is neither a violation nor a
|
|
230
|
+
// `suppress` telemetry row (which would misreport it as a directive escape).
|
|
231
|
+
const events = [];
|
|
232
|
+
applyRulesToAdditions(ctx(), [rule], [addition('scripts/x.sh', 'LC_ALL=C git log --oneline')], (kind) => events.push(kind));
|
|
233
|
+
expect(events).toEqual([]);
|
|
234
|
+
const firing = [];
|
|
235
|
+
applyRulesToAdditions(ctx(), [rule], [addition('scripts/x.sh', 'git log --oneline')], (kind) => firing.push(kind));
|
|
236
|
+
expect(firing).toEqual(['trigger']);
|
|
237
|
+
});
|
|
238
|
+
it('respects the record dialect at dispatch — `**\\/*.sh` matches, other extensions do not', () => {
|
|
239
|
+
expect(applyRulesToAdditions(ctx(), [rule], [addition('scripts/x.ts', 'git log --oneline')])).toEqual([]);
|
|
240
|
+
expect(applyRulesToAdditions(ctx(), [rule], [addition('deep/nest/x.cjs', 'git log --oneline')])).toHaveLength(1);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('end-to-end — a file-scoped requires reads the real file', () => {
|
|
244
|
+
let workDir;
|
|
245
|
+
beforeAll(() => {
|
|
246
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-p310-'));
|
|
247
|
+
fs.mkdirSync(path.join(workDir, 'scripts'), { recursive: true });
|
|
248
|
+
fs.writeFileSync(path.join(workDir, 'scripts', 'pinned.sh'), ['export LC_ALL=C', 'git log --oneline'].join('\n'), 'utf-8');
|
|
249
|
+
fs.writeFileSync(path.join(workDir, 'scripts', 'unpinned.sh'), ['git log --oneline'].join('\n'), 'utf-8');
|
|
250
|
+
});
|
|
251
|
+
afterAll(() => {
|
|
252
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
253
|
+
});
|
|
254
|
+
const rule = compile({
|
|
255
|
+
...design8ExemplarRecord(),
|
|
256
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
257
|
+
});
|
|
258
|
+
it('stays silent when the requirement is satisfied elsewhere in the file', () => {
|
|
259
|
+
const violations = applyRulesToAdditions(ctx(), [rule], [addition('scripts/pinned.sh', 'git log --oneline', 2)], undefined, workDir);
|
|
260
|
+
expect(violations).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
it('fires when the file never satisfies the requirement', () => {
|
|
263
|
+
const violations = applyRulesToAdditions(ctx(), [rule], [addition('scripts/unpinned.sh', 'git log --oneline')], undefined, workDir);
|
|
264
|
+
expect(violations).toHaveLength(1);
|
|
265
|
+
});
|
|
266
|
+
it('fires when the file cannot be read at all (fails toward flagging)', () => {
|
|
267
|
+
const violations = applyRulesToAdditions(ctx(), [rule], [addition('scripts/absent.sh', 'git log --oneline')], undefined, workDir);
|
|
268
|
+
expect(violations).toHaveLength(1);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
describe('end-to-end — parse → lower → evaluate (§ Design 4 exemplar, ast-grep path)', () => {
|
|
272
|
+
const rule = compile(design4ExemplarRecord());
|
|
273
|
+
const example = design4ExemplarRecord().examples;
|
|
274
|
+
const bad = example[0].bad;
|
|
275
|
+
const good = example[0].good;
|
|
276
|
+
async function run(file, source) {
|
|
277
|
+
return applyAstRulesToAdditions(ctx(), [rule], [addition(file, source)], os.tmpdir(), undefined, undefined, async () => source);
|
|
278
|
+
}
|
|
279
|
+
it('FIRES on its own `bad` example (ADR-112 §4 fire-on-preimage)', async () => {
|
|
280
|
+
const violations = await run('packages/core/src/a.ts', bad);
|
|
281
|
+
expect(violations).toHaveLength(1);
|
|
282
|
+
expect(violations[0].rule.lessonHash).toBe(RULE_ID);
|
|
283
|
+
expect(violations[0].rule.message).toContain('fail-open catch is banned');
|
|
284
|
+
});
|
|
285
|
+
it('stays SILENT on its own `good` example (silent-on-postimage)', async () => {
|
|
286
|
+
expect(await run('packages/core/src/a.ts', good)).toEqual([]);
|
|
287
|
+
});
|
|
288
|
+
it('honours `excludeGlobs` at dispatch — the same bad source in a test file is out of scope', async () => {
|
|
289
|
+
expect(await run('packages/core/src/a.test.ts', bad)).toEqual([]);
|
|
290
|
+
});
|
|
291
|
+
it('honours the positive glob — the same bad source outside `packages/` is out of scope', async () => {
|
|
292
|
+
expect(await run('scripts/a.ts', bad)).toEqual([]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
// ─── Cure 1 — the requirement reads the bytes the lint is judging ────────────
|
|
296
|
+
describe('§ Design 8 — a file-scoped requirement honours the caller’s read seam', () => {
|
|
297
|
+
let workDir;
|
|
298
|
+
const rule = compile({
|
|
299
|
+
...design8ExemplarRecord(),
|
|
300
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
301
|
+
});
|
|
302
|
+
const hit = addition('scripts/x.sh', 'git log --oneline');
|
|
303
|
+
beforeAll(() => {
|
|
304
|
+
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-p310-seam-'));
|
|
305
|
+
fs.mkdirSync(path.join(workDir, 'scripts'), { recursive: true });
|
|
306
|
+
// The WORKTREE satisfies the requirement.
|
|
307
|
+
fs.writeFileSync(path.join(workDir, 'scripts', 'x.sh'), ['export LC_ALL=C', 'git log --oneline'].join('\n'), 'utf-8');
|
|
308
|
+
});
|
|
309
|
+
afterAll(() => {
|
|
310
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
311
|
+
});
|
|
312
|
+
it('FAIL-OPEN COUNTEREXAMPLE: context deleted in the staged edit is not excused by the worktree', () => {
|
|
313
|
+
// Without the seam this is the hole: the requirement is satisfied on disk,
|
|
314
|
+
// so the staged violation is suppressed and the commit passes. With the
|
|
315
|
+
// staged reader threaded, the requirement is judged on the staged bytes and
|
|
316
|
+
// the rule fires.
|
|
317
|
+
expect(applyRulesToAdditions(ctx(), [rule], [hit], undefined, workDir)).toEqual([]);
|
|
318
|
+
const staged = applyRulesToAdditions(ctx(), [rule], [hit], undefined, workDir, () => 'git log --oneline');
|
|
319
|
+
expect(staged).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
it('the inverse direction too: context added in the staged edit silences the rule', () => {
|
|
322
|
+
const worktreeOnly = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-p310-inv-'));
|
|
323
|
+
try {
|
|
324
|
+
fs.mkdirSync(path.join(worktreeOnly, 'scripts'), { recursive: true });
|
|
325
|
+
fs.writeFileSync(path.join(worktreeOnly, 'scripts', 'x.sh'), 'git log --oneline', 'utf-8');
|
|
326
|
+
expect(applyRulesToAdditions(ctx(), [rule], [hit], undefined, worktreeOnly)).toHaveLength(1);
|
|
327
|
+
expect(applyRulesToAdditions(ctx(), [rule], [hit], undefined, worktreeOnly, () => ['export LC_ALL=C', 'git log --oneline'].join('\n'))).toEqual([]);
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
fs.rmSync(worktreeOnly, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
it('a reader that reports the file absent still FIRES (direction unchanged)', () => {
|
|
334
|
+
expect(applyRulesToAdditions(ctx(), [rule], [hit], undefined, workDir, () => null)).toHaveLength(1);
|
|
335
|
+
});
|
|
336
|
+
it('a reader that THROWS propagates — its failure contract is not swallowed', () => {
|
|
337
|
+
// Staged mode's reader throws `STAGED_READ_FAILED` precisely so an unreadable
|
|
338
|
+
// index entry surfaces; converting that to "context absent" would turn a loud
|
|
339
|
+
// failure into a quiet one.
|
|
340
|
+
expect(() => applyRulesToAdditions(ctx(), [rule], [hit], undefined, workDir, () => {
|
|
341
|
+
throw new Error('STAGED_READ_FAILED');
|
|
342
|
+
})).toThrow(/STAGED_READ_FAILED/);
|
|
343
|
+
});
|
|
344
|
+
it('never consults the reader for a LINE-scoped requirement', () => {
|
|
345
|
+
const lineRule = compile(design8ExemplarRecord());
|
|
346
|
+
let calls = 0;
|
|
347
|
+
applyRulesToAdditions(ctx(), [lineRule], [hit], undefined, workDir, () => {
|
|
348
|
+
calls += 1;
|
|
349
|
+
return null;
|
|
350
|
+
});
|
|
351
|
+
expect(calls).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
// ─── Cure 1 (extended) — the dispatcher `totem lint` actually uses ───────────
|
|
355
|
+
describe('applyRulesToAdditionsBounded — the record semantics reach the lint path', () => {
|
|
356
|
+
it('applies the § Design 7 two-array scope, not the legacy predicate', async () => {
|
|
357
|
+
// This dispatcher — not `applyRulesToAdditions` — is what `totem lint` runs
|
|
358
|
+
// regex rules through. Without the shared predicate a record rule's
|
|
359
|
+
// `excludeGlobs` is ignored here and its `*.ts` is promoted tree-wide: a
|
|
360
|
+
// silent scope WIDENING on the only path that ships.
|
|
361
|
+
const evaluator = new RegexEvaluator();
|
|
362
|
+
try {
|
|
363
|
+
const rule = compile(scopedRegexRecord(['*.ts'], ['**/*.test.ts']));
|
|
364
|
+
const result = await applyRulesToAdditionsBounded(ctx(), [rule], [
|
|
365
|
+
addition('a.ts', 'console.log(1)'),
|
|
366
|
+
addition('src/a.ts', 'console.log(1)'),
|
|
367
|
+
addition('a.test.ts', 'console.log(1)'),
|
|
368
|
+
], { evaluator, timeoutMode: 'strict', repoRoot: os.tmpdir() });
|
|
369
|
+
expect(result.violations.map((v) => v.file)).toEqual(['a.ts']);
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
await evaluator.dispose();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
it('applies the § Design 8 two-pass, at line scope', async () => {
|
|
376
|
+
const evaluator = new RegexEvaluator();
|
|
377
|
+
try {
|
|
378
|
+
const rule = compile(design8ExemplarRecord());
|
|
379
|
+
const result = await applyRulesToAdditionsBounded(ctx(), [rule], [
|
|
380
|
+
addition('scripts/a.sh', 'git log --oneline'),
|
|
381
|
+
addition('scripts/b.sh', 'LC_ALL=C git log --oneline'),
|
|
382
|
+
], { evaluator, timeoutMode: 'strict', repoRoot: os.tmpdir() });
|
|
383
|
+
expect(result.violations.map((v) => v.file)).toEqual(['scripts/a.sh']);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
await evaluator.dispose();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
it('threads the staged reader into a FILE-scoped requirement', async () => {
|
|
390
|
+
const evaluator = new RegexEvaluator();
|
|
391
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-p310-bnd-'));
|
|
392
|
+
try {
|
|
393
|
+
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
|
394
|
+
fs.writeFileSync(path.join(dir, 'scripts', 'x.sh'), ['export LC_ALL=C', 'git log --oneline'].join('\n'), 'utf-8');
|
|
395
|
+
const rule = compile({
|
|
396
|
+
...design8ExemplarRecord(),
|
|
397
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
398
|
+
});
|
|
399
|
+
const additions = [addition('scripts/x.sh', 'git log --oneline')];
|
|
400
|
+
const opts = { evaluator, timeoutMode: 'strict', repoRoot: dir };
|
|
401
|
+
// Worktree read: the requirement is satisfied → silent.
|
|
402
|
+
const worktree = await applyRulesToAdditionsBounded(ctx(), [rule], additions, opts);
|
|
403
|
+
expect(worktree.violations).toEqual([]);
|
|
404
|
+
// Staged read: the requirement is gone from the index → fires.
|
|
405
|
+
const staged = await applyRulesToAdditionsBounded(ctx(), [rule], additions, {
|
|
406
|
+
...opts,
|
|
407
|
+
readStrategy: async () => 'git log --oneline',
|
|
408
|
+
});
|
|
409
|
+
expect(staged.violations).toHaveLength(1);
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
await evaluator.dispose();
|
|
413
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
it('leaves a LEGACY rule on the shipped predicate byte-for-byte', async () => {
|
|
417
|
+
const evaluator = new RegexEvaluator();
|
|
418
|
+
try {
|
|
419
|
+
const result = await applyRulesToAdditionsBounded(ctx(), [legacyRule(['*.ts'])], [addition('src/a.ts', 'console.log(1)')], { evaluator, timeoutMode: 'strict', repoRoot: os.tmpdir() });
|
|
420
|
+
// Shallow-glob promotion survives on the legacy path.
|
|
421
|
+
expect(result.violations).toHaveLength(1);
|
|
422
|
+
}
|
|
423
|
+
finally {
|
|
424
|
+
await evaluator.dispose();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
// ─── Torn-manifest guard (§ Design 12) ───────────────────────────────────────
|
|
429
|
+
describe('§ Design 12 — a rule that is neither record nor legacy fails LOUD', () => {
|
|
430
|
+
function tornRule(extra) {
|
|
431
|
+
return CompiledRuleSchema.parse({
|
|
432
|
+
lessonHash: 'fedcba9876543210',
|
|
433
|
+
lessonHeading: 'hand-edited torn rule',
|
|
434
|
+
pattern: 'console\\.log',
|
|
435
|
+
message: 'x',
|
|
436
|
+
engine: 'regex',
|
|
437
|
+
compiledAt: NOW,
|
|
438
|
+
fileGlobs: ['**/*.ts'],
|
|
439
|
+
...extra,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
it('throws on a record home with no `examples` — the dropped constructs get a signal', () => {
|
|
443
|
+
for (const extra of [
|
|
444
|
+
{ excludeGlobs: ['**/*.test.ts'] },
|
|
445
|
+
{ requires: { pattern: 'x', scope: 'line' } },
|
|
446
|
+
{ language: 'typescript' },
|
|
447
|
+
{ verificationShadow: { type: 'rego', source: 'p' } },
|
|
448
|
+
]) {
|
|
449
|
+
expect(() => assertNoTornRecordRules([tornRule(extra)])).toThrow(/carries Prop 310 record field\(s\).*but no `examples`/s);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
it('names the offending homes so the operator knows what would have been dropped', () => {
|
|
453
|
+
expect(() => assertNoTornRecordRules([
|
|
454
|
+
tornRule({ excludeGlobs: ['**/*.test.ts'], language: 'typescript' }),
|
|
455
|
+
])).toThrow(/excludeGlobs, language/);
|
|
456
|
+
});
|
|
457
|
+
it('leaves a clean LEGACY rule and a clean RECORD rule untouched', () => {
|
|
458
|
+
expect(() => assertNoTornRecordRules([legacyRule(['**/*.ts'])])).not.toThrow();
|
|
459
|
+
expect(() => assertNoTornRecordRules([compile(design4ExemplarRecord())])).not.toThrow();
|
|
460
|
+
expect(() => assertNoTornRecordRules([])).not.toThrow();
|
|
461
|
+
});
|
|
462
|
+
it('fires at DISPATCHER altitude on every regex path', async () => {
|
|
463
|
+
const torn = tornRule({ excludeGlobs: ['**/*.test.ts'] });
|
|
464
|
+
const hit = addition('src/a.ts', 'console.log(1)');
|
|
465
|
+
expect(() => applyRulesToAdditions(ctx(), [torn], [hit])).toThrow(/no `examples`/);
|
|
466
|
+
const evaluator = new RegexEvaluator();
|
|
467
|
+
try {
|
|
468
|
+
await expect(applyRulesToAdditionsBounded(ctx(), [torn], [hit], {
|
|
469
|
+
evaluator,
|
|
470
|
+
timeoutMode: 'strict',
|
|
471
|
+
repoRoot: os.tmpdir(),
|
|
472
|
+
})).rejects.toThrow(/no `examples`/);
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
await evaluator.dispose();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
// ─── Runtime safety gates on a hand-edited manifest ──────────────────────────
|
|
480
|
+
describe('§ Design 8 — runtime gates on a torn manifest', () => {
|
|
481
|
+
/** A record-shaped rule (carries `examples`) with a hand-set requires block. */
|
|
482
|
+
function handEdited(requires, engine = 'regex') {
|
|
483
|
+
return CompiledRuleSchema.parse({
|
|
484
|
+
lessonHash: 'fedcba9876543210',
|
|
485
|
+
lessonHeading: 'hand-edited rule',
|
|
486
|
+
pattern: 'console\\.log',
|
|
487
|
+
message: 'x',
|
|
488
|
+
engine,
|
|
489
|
+
compiledAt: NOW,
|
|
490
|
+
fileGlobs: ['**/*.ts'],
|
|
491
|
+
examples: [{ bad: 'b', good: 'g' }],
|
|
492
|
+
...(engine === 'ast-grep' ? { astGrepPattern: 'console.log($A)' } : {}),
|
|
493
|
+
requires,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
it('rejects a ReDoS-shaped requires.pattern BEFORE evaluating it', () => {
|
|
497
|
+
// The compile gate runs safe-regex2 on this exact field, so an unsafe pattern
|
|
498
|
+
// here means a bypassed producer. At `scope: file` it would run unbounded
|
|
499
|
+
// against whole-file text on the pre-commit path.
|
|
500
|
+
const unsafe = handEdited({ pattern: '(a+)+$', scope: 'file' });
|
|
501
|
+
expect(() => assertRequiresPatternsSafe([unsafe])).toThrow(/ReDoS/);
|
|
502
|
+
expect(() => applyRulesToAdditions(ctx(), [unsafe], [addition('src/a.ts', 'console.log(1)')])).toThrow(/unusable `requires.pattern`/);
|
|
503
|
+
});
|
|
504
|
+
it('rejects an uncompilable requires.pattern at the same gate', () => {
|
|
505
|
+
expect(() => assertRequiresPatternsSafe([handEdited({ pattern: '(', scope: 'line' })])).toThrow(/invalid syntax/);
|
|
506
|
+
});
|
|
507
|
+
it('passes a safe pattern and every legacy rule untouched', () => {
|
|
508
|
+
expect(() => assertRequiresPatternsSafe([handEdited({ pattern: 'LC_ALL=C', scope: 'line' })])).not.toThrow();
|
|
509
|
+
expect(() => assertRequiresPatternsSafe([legacyRule(['**/*.ts'])])).not.toThrow();
|
|
510
|
+
});
|
|
511
|
+
it('rejects `ast-grep` + `requires.scope: line` — the span-vs-line backstop', async () => {
|
|
512
|
+
// The lowering refuses this combination; a hand-edited manifest can still
|
|
513
|
+
// carry it, and the ast dispatcher would evaluate `match.lineText`.
|
|
514
|
+
const offender = handEdited({ pattern: 'LC_ALL=C', scope: 'line' }, 'ast-grep');
|
|
515
|
+
expect(() => assertNoAstGrepLineScope([offender])).toThrow(/`requires\.scope: line` on the `ast-grep` engine/);
|
|
516
|
+
// The span-vs-line GROUNDS travel with the error, in its recovery hint.
|
|
517
|
+
try {
|
|
518
|
+
assertNoAstGrepLineScope([offender]);
|
|
519
|
+
expect.unreachable('expected the backstop to throw');
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
expect(err.recoveryHint).toContain('an ast-grep match is a SPAN');
|
|
523
|
+
}
|
|
524
|
+
await expect(applyAstRulesToAdditions(ctx(), [offender], [addition('src/a.ts', 'console.log(1)')], os.tmpdir(), undefined, undefined, async () => 'console.log(1)')).rejects.toThrow(/`requires\.scope: line` on the `ast-grep` engine/);
|
|
525
|
+
});
|
|
526
|
+
it('leaves `ast-grep` + `file` and `regex` + `line` alone', () => {
|
|
527
|
+
expect(() => assertNoAstGrepLineScope([handEdited({ pattern: 'x', scope: 'file' }, 'ast-grep')])).not.toThrow();
|
|
528
|
+
expect(() => assertNoAstGrepLineScope([handEdited({ pattern: 'x', scope: 'line' })])).not.toThrow();
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
describe('§ Design 8 — the whole-file readers stay inside the root', () => {
|
|
532
|
+
it('treats an out-of-root path as unreadable, so the rule FIRES', () => {
|
|
533
|
+
// Diff-supplied paths are attacker-shaped on any lint over contributed
|
|
534
|
+
// changes. Containment yields "unreadable", which keeps the documented
|
|
535
|
+
// fail-toward-flagging direction rather than inventing a new failure mode.
|
|
536
|
+
expect(isInsideRoot(os.tmpdir(), '../outside.sh')).toBe(false);
|
|
537
|
+
expect(isInsideRoot(os.tmpdir(), 'nested/inside.sh')).toBe(true);
|
|
538
|
+
// A sibling-directory prefix must NOT pass — the reason for `path.relative`
|
|
539
|
+
// over a `startsWith` string test.
|
|
540
|
+
expect(isInsideRoot('/app', '../app-secrets/x.sh')).toBe(false);
|
|
541
|
+
const rule = compile({
|
|
542
|
+
...design8ExemplarRecord(),
|
|
543
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
544
|
+
});
|
|
545
|
+
const violations = applyRulesToAdditions(ctx(), [rule], [addition('../escape/x.sh', 'git log --oneline')], undefined, os.tmpdir());
|
|
546
|
+
expect(violations).toHaveLength(1);
|
|
547
|
+
});
|
|
548
|
+
it('applies containment on the bounded dispatcher too', async () => {
|
|
549
|
+
const evaluator = new RegexEvaluator();
|
|
550
|
+
try {
|
|
551
|
+
const rule = compile({
|
|
552
|
+
...design8ExemplarRecord(),
|
|
553
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
554
|
+
});
|
|
555
|
+
const result = await applyRulesToAdditionsBounded(ctx(), [rule], [addition('../escape/x.sh', 'git log --oneline')], { evaluator, timeoutMode: 'strict', repoRoot: os.tmpdir() });
|
|
556
|
+
expect(result.violations).toHaveLength(1);
|
|
557
|
+
}
|
|
558
|
+
finally {
|
|
559
|
+
await evaluator.dispose();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
describe('§ Design 8 — the compiled requires pattern is cached, not recompiled', () => {
|
|
564
|
+
it('returns identical verdicts across repeated evaluation (cache is transparent)', () => {
|
|
565
|
+
const rule = compile(design8ExemplarRecord());
|
|
566
|
+
const absent = { line: 'git log --oneline', file: () => null };
|
|
567
|
+
const present = { line: 'LC_ALL=C git log --oneline', file: () => null };
|
|
568
|
+
// Repeated because the cache is only exercised from the second call on, and a
|
|
569
|
+
// stateful `lastIndex` bug (a cached /g/ regex) would surface as an
|
|
570
|
+
// ALTERNATING verdict rather than a constant one.
|
|
571
|
+
for (let i = 0; i < 5; i += 1) {
|
|
572
|
+
expect(requiresSuppressesMatch(rule, absent)).toBe(false);
|
|
573
|
+
expect(requiresSuppressesMatch(rule, present)).toBe(true);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
// ─── The single-homed bad-example reader (slice 3) ───────────────────────────
|
|
578
|
+
describe('ruleBadExampleLines — one home for both badExample readers', () => {
|
|
579
|
+
it('reads a record rule’s `examples[i].bad`, every pair, in ordinal order', () => {
|
|
580
|
+
const rule = compile({
|
|
581
|
+
...scopedRegexRecord(['packages/**/*.ts']),
|
|
582
|
+
examples: [
|
|
583
|
+
{ bad: 'console.log(1)', good: 'logger.info(1)' },
|
|
584
|
+
{ bad: 'console.log(2)\nconsole.log(3)', good: 'logger.info(2)' },
|
|
585
|
+
],
|
|
586
|
+
});
|
|
587
|
+
expect(ruleBadExampleLines(rule)).toEqual([
|
|
588
|
+
'console.log(1)',
|
|
589
|
+
'console.log(2)',
|
|
590
|
+
'console.log(3)',
|
|
591
|
+
]);
|
|
592
|
+
});
|
|
593
|
+
it('reads a LEGACY rule’s `badExample`, split on lines — byte-identical to the shipped read', () => {
|
|
594
|
+
expect(ruleBadExampleLines({ ...legacyRule(['**/*.ts']), badExample: 'a()\r\nb()' })).toEqual([
|
|
595
|
+
'a()',
|
|
596
|
+
'b()',
|
|
597
|
+
]);
|
|
598
|
+
});
|
|
599
|
+
it('returns [] for a legacy rule with no badExample — the doctor `no-badExample` test', () => {
|
|
600
|
+
expect(ruleBadExampleLines(legacyRule(['**/*.ts']))).toEqual([]);
|
|
601
|
+
});
|
|
602
|
+
it('returns [] for a WHITESPACE-ONLY legacy badExample — absent, exactly as before', () => {
|
|
603
|
+
// The shipped doctor reason was `!badExample || badExample.trim().length === 0`;
|
|
604
|
+
// dropping blank lines is what keeps `.length === 0` equal to that test rather
|
|
605
|
+
// than silently un-flagging a whitespace-only field.
|
|
606
|
+
expect(ruleBadExampleLines({ ...legacyRule(['**/*.ts']), badExample: ' \n\t' })).toEqual([]);
|
|
607
|
+
});
|
|
608
|
+
it('IGNORES a legacy `badExample` on a record rule — the record’s examples are the home', () => {
|
|
609
|
+
// A hand-edited manifest could carry both. `examples` is § Design 10's editable
|
|
610
|
+
// home, so it wins; reading both would give one rule two preimage sources.
|
|
611
|
+
const rule = compile(scopedRegexRecord(['packages/**/*.ts']));
|
|
612
|
+
expect(ruleBadExampleLines({ ...rule, badExample: 'never-read()' })).toEqual([
|
|
613
|
+
'console.log(1)',
|
|
614
|
+
]);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
describe('ruleGoodExampleLines — the POSTIMAGE twin, on the identical contract', () => {
|
|
618
|
+
it('reads a record rule’s `examples[i].good`, every pair, in ordinal order', () => {
|
|
619
|
+
const rule = compile({
|
|
620
|
+
...scopedRegexRecord(['packages/**/*.ts']),
|
|
621
|
+
examples: [
|
|
622
|
+
{ bad: 'console.log(1)', good: 'logger.info(1)' },
|
|
623
|
+
{ bad: 'console.log(2)', good: 'logger.info(2)\nlogger.info(3)' },
|
|
624
|
+
],
|
|
625
|
+
});
|
|
626
|
+
expect(ruleGoodExampleLines(rule)).toEqual([
|
|
627
|
+
'logger.info(1)',
|
|
628
|
+
'logger.info(2)',
|
|
629
|
+
'logger.info(3)',
|
|
630
|
+
]);
|
|
631
|
+
});
|
|
632
|
+
it('reads a LEGACY rule’s `goodExample`, split on lines', () => {
|
|
633
|
+
expect(ruleGoodExampleLines({ ...legacyRule(['**/*.ts']), goodExample: 'a()\r\nb()' })).toEqual(['a()', 'b()']);
|
|
634
|
+
});
|
|
635
|
+
it('returns [] for absent and for whitespace-only — the doctor `no-goodExample` test', () => {
|
|
636
|
+
expect(ruleGoodExampleLines(legacyRule(['**/*.ts']))).toEqual([]);
|
|
637
|
+
expect(ruleGoodExampleLines({ ...legacyRule(['**/*.ts']), goodExample: ' \n\t' })).toEqual([]);
|
|
638
|
+
});
|
|
639
|
+
it('reads the GOOD side, never the bad — the two readers are not aliases', () => {
|
|
640
|
+
// The failure this pins: a copy-paste twin that reads `example.bad` would make
|
|
641
|
+
// doctor's two reasons agree by accident and hide a genuine one-sided absence.
|
|
642
|
+
const rule = compile(scopedRegexRecord(['packages/**/*.ts']));
|
|
643
|
+
expect(ruleGoodExampleLines(rule)).toEqual(['logger.info(1)']);
|
|
644
|
+
expect(ruleBadExampleLines(rule)).toEqual(['console.log(1)']);
|
|
645
|
+
});
|
|
646
|
+
it('IGNORES a legacy `goodExample` on a record rule — the record’s examples are the home', () => {
|
|
647
|
+
const rule = compile(scopedRegexRecord(['packages/**/*.ts']));
|
|
648
|
+
expect(ruleGoodExampleLines({ ...rule, goodExample: 'never-read()' })).toEqual([
|
|
649
|
+
'logger.info(1)',
|
|
650
|
+
]);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
// ─── Certification seams — Stage 4 and the wind tunnel ───────────────────────
|
|
654
|
+
describe('Stage 4 — record scope is visible, and “unscoped” does not invert', () => {
|
|
655
|
+
// An EMPTY baseline, so every classification below is the RULE's scope talking
|
|
656
|
+
// and nothing else. Regex engine on purpose: this is a scope-classification
|
|
657
|
+
// test, and routing it through ast-grep would add a parser to the equation.
|
|
658
|
+
const emptyBaseline = {
|
|
659
|
+
excludeFileGlobs: [],
|
|
660
|
+
extendedFromIgnoreFile: [],
|
|
661
|
+
extendedFromConfig: [],
|
|
662
|
+
excludedFromConfig: [],
|
|
663
|
+
};
|
|
664
|
+
const record = compile(scopedRegexRecord(['packages/**/*.ts'], ['**/*.test.ts']));
|
|
665
|
+
function deps(file, content) {
|
|
666
|
+
return {
|
|
667
|
+
listFiles: async () => [file],
|
|
668
|
+
readFile: async () => content,
|
|
669
|
+
workingDirectory: os.tmpdir(),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
it('classifies an EXCLUDED file as out-of-scope instead of over-broad firing', async () => {
|
|
673
|
+
// Passing bare `rule.fileGlobs` made `excludeGlobs` structurally invisible
|
|
674
|
+
// here, so a hit on a file the rule's own scope EXCLUDES read as an in-scope
|
|
675
|
+
// match — the rule looked over-broad because of a scope it never had.
|
|
676
|
+
const result = await verifyAgainstCodebase(record, emptyBaseline, deps('packages/core/src/a.test.ts', 'console.log(1)\n'));
|
|
677
|
+
expect(result.outcome).toBe('out-of-scope');
|
|
678
|
+
});
|
|
679
|
+
it('still finds an IN-SCOPE match — the strip does not zero the record path', async () => {
|
|
680
|
+
// The inversion this guards: `runRuleAgainstAllFiles` expresses "fires
|
|
681
|
+
// everywhere" as ABSENT `fileGlobs`, which means include-all on the legacy
|
|
682
|
+
// matcher and match-NOTHING on the record dialect. Unfixed, every record rule
|
|
683
|
+
// Stage 4 ever verified would have reported `no-matches`.
|
|
684
|
+
const result = await verifyAgainstCodebase(record, emptyBaseline, deps('packages/core/src/a.ts', 'console.log(1)\n'));
|
|
685
|
+
// EXACT outcome. Slice 3 DISCHARGED the slice-2 pin that stood here: this
|
|
686
|
+
// fixture's matched line (`console.log(1)`) equals `examples[0].bad`, and
|
|
687
|
+
// Stage 4 now resolves a rule's authored preimages through the single-homed
|
|
688
|
+
// `ruleBadExampleLines` — which reads a record rule's `examples[i].bad`
|
|
689
|
+
// instead of the legacy `badExample` field the lowering deliberately does not
|
|
690
|
+
// mirror (Tenet 20 — one editable home). So the outcome is
|
|
691
|
+
// `in-scope-bad-example`, and the downstream consequence flips with it: the
|
|
692
|
+
// rule maps to `status: active` + `confidence: high` rather than being forced
|
|
693
|
+
// to the `candidate-debt` warning floor.
|
|
694
|
+
expect(result.outcome).toBe('in-scope-bad-example');
|
|
695
|
+
expect(result.baselineMatches).toEqual([]);
|
|
696
|
+
// The PRICED half of that flip (spec § Failure modes, "Priced consequence"):
|
|
697
|
+
// `confidence: high` is now reachable from an author-written exemplar that
|
|
698
|
+
// happens to occur verbatim in the tree — but `unverified: true` survives the
|
|
699
|
+
// Stage-4 patch, so the rule stays advisory. The label moves; the enforcement
|
|
700
|
+
// tier does not. Asserted here so a future patch that dropped the flag would
|
|
701
|
+
// turn a labelling change into a silent sense→enforce crossing.
|
|
702
|
+
const patched = CompiledRuleSchema.parse({
|
|
703
|
+
...record,
|
|
704
|
+
status: 'active',
|
|
705
|
+
confidence: 'high',
|
|
706
|
+
});
|
|
707
|
+
expect(patched.unverified).toBe(true);
|
|
708
|
+
});
|
|
709
|
+
it('leaves a NON-matching in-scope line at candidate-debt — the derivation is not a blanket promotion', async () => {
|
|
710
|
+
// Same record, real code that fires the matcher but is NOT any `examples[i].bad`
|
|
711
|
+
// line. The sibling of the assertion above: `in-scope-bad-example` is earned by
|
|
712
|
+
// line equality, not by being a record rule, so `candidate-debt` (and its
|
|
713
|
+
// `severity: 'warning'` floor) is still the answer for genuine codebase debt.
|
|
714
|
+
const result = await verifyAgainstCodebase(record, emptyBaseline, deps('packages/core/src/a.ts', 'console.log("a different call site")\n'));
|
|
715
|
+
expect(result.outcome).toBe('candidate-debt');
|
|
716
|
+
expect(result.candidateDebtLines).toEqual(['console.log("a different call site")']);
|
|
717
|
+
});
|
|
718
|
+
it('reads EVERY `examples[i].bad` line, not just the first pair (§ Design 5 min-1, no maximum)', async () => {
|
|
719
|
+
// ADR-112 §6 admits the two-loci-one-PR multi-fixture rule, so § Design 5 makes
|
|
720
|
+
// `examples` an array with no maximum. A second pair's `bad` must be just as
|
|
721
|
+
// reachable as the first, or a multi-pair record would silently promote only
|
|
722
|
+
// one of its own exemplars.
|
|
723
|
+
const multiPair = compile({
|
|
724
|
+
...scopedRegexRecord(['packages/**/*.ts']),
|
|
725
|
+
examples: [
|
|
726
|
+
{ bad: 'console.log(1)', good: 'logger.info(1)' },
|
|
727
|
+
{ bad: 'console.log("second locus")', good: 'logger.info("second locus")' },
|
|
728
|
+
],
|
|
729
|
+
});
|
|
730
|
+
const result = await verifyAgainstCodebase(multiPair, emptyBaseline, deps('packages/core/src/b.ts', 'console.log("second locus")\n'));
|
|
731
|
+
expect(result.outcome).toBe('in-scope-bad-example');
|
|
732
|
+
});
|
|
733
|
+
it('leaves a LEGACY rule’s classification byte-identical', async () => {
|
|
734
|
+
const result = await verifyAgainstCodebase(legacyRule(['**/*.ts']), emptyBaseline, deps('src/a.ts', 'console.log(1)\n'));
|
|
735
|
+
// Same exact-outcome discipline: this fixture carries no `badExample`
|
|
736
|
+
// either, so `candidate-debt` is the shipped answer and any drift shows up
|
|
737
|
+
// here rather than passing a loose negative assertion.
|
|
738
|
+
expect(result.outcome).toBe('candidate-debt');
|
|
739
|
+
});
|
|
740
|
+
});
|
|
741
|
+
describe('wind tunnel — a file-scoped requirement reads the POST-IMAGE', () => {
|
|
742
|
+
const rule = compile({
|
|
743
|
+
...design8ExemplarRecord(),
|
|
744
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
745
|
+
});
|
|
746
|
+
const diff = [
|
|
747
|
+
'diff --git a/scripts/x.sh b/scripts/x.sh',
|
|
748
|
+
'--- a/scripts/x.sh',
|
|
749
|
+
'+++ b/scripts/x.sh',
|
|
750
|
+
'@@ -1,1 +1,1 @@',
|
|
751
|
+
'+git log --oneline',
|
|
752
|
+
'',
|
|
753
|
+
].join('\n');
|
|
754
|
+
async function fire(postImage) {
|
|
755
|
+
return buildFirings({
|
|
756
|
+
rules: [rule],
|
|
757
|
+
prDiffs: [{ pr: 1, diff, controlKind: 'corpus' }],
|
|
758
|
+
cwd: os.tmpdir(),
|
|
759
|
+
readStrategy: async () => postImage,
|
|
760
|
+
ruleEngineCtx: ctx(),
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
it('stays silent when the POST-IMAGE satisfies the requirement', async () => {
|
|
764
|
+
const result = await fire(['export LC_ALL=C', 'git log --oneline'].join('\n'));
|
|
765
|
+
expect(result.firings).toEqual([]);
|
|
766
|
+
});
|
|
767
|
+
it('fires when the POST-IMAGE does not — the local worktree never decides', async () => {
|
|
768
|
+
// The seam this closes: `applyRulesToAdditions` was called with five args, so
|
|
769
|
+
// the requirement was judged against whatever happened to be on the
|
|
770
|
+
// certifying machine's disk rather than the PR's post-image — the same class
|
|
771
|
+
// of divergence as the staged-read hole, in the certification corpus.
|
|
772
|
+
const result = await fire('git log --oneline');
|
|
773
|
+
expect(result.firings).toHaveLength(1);
|
|
774
|
+
expect(result.firings[0].ruleId).toBe(RULE_ID);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
// ─── Fail-loud backstop on the unevaluated branch ────────────────────────────
|
|
778
|
+
describe('§ Design 12 — `requires` is never silently unevaluated', () => {
|
|
779
|
+
it('throws when a tree-sitter rule carries a requires block', async () => {
|
|
780
|
+
const astRule = CompiledRuleSchema.parse({
|
|
781
|
+
lessonHash: 'fedcba9876543210',
|
|
782
|
+
lessonHeading: 'hand-edited ast rule',
|
|
783
|
+
pattern: '',
|
|
784
|
+
message: 'x',
|
|
785
|
+
engine: 'ast',
|
|
786
|
+
astQuery: '(call_expression) @c',
|
|
787
|
+
compiledAt: NOW,
|
|
788
|
+
// `examples` present, so this is a well-formed RECORD-shaped rule on the
|
|
789
|
+
// wrong engine — it reaches the tree-sitter backstop rather than being
|
|
790
|
+
// caught upstream as a torn manifest.
|
|
791
|
+
examples: [{ bad: 'b', good: 'g' }],
|
|
792
|
+
requires: { pattern: 'LC_ALL=C', scope: 'line' },
|
|
793
|
+
});
|
|
794
|
+
await expect(applyAstRulesToAdditions(ctx(), [astRule], [addition('src/a.ts', 'foo()')], os.tmpdir(), undefined, undefined, async () => 'foo()')).rejects.toThrow(/has no two-pass evaluator/);
|
|
795
|
+
});
|
|
796
|
+
it('throws even when the ast rule carries NO astQuery (the widened search)', async () => {
|
|
797
|
+
// The backstop searched `treeSitterRules`, which is pre-filtered on
|
|
798
|
+
// `astQuery` being present — so this shape slipped past the throw and had its
|
|
799
|
+
// `requires` silently unevaluated, which is the exact hole the backstop
|
|
800
|
+
// exists to close.
|
|
801
|
+
const astRule = CompiledRuleSchema.parse({
|
|
802
|
+
lessonHash: 'fedcba9876543210',
|
|
803
|
+
lessonHeading: 'hand-edited ast rule, no query',
|
|
804
|
+
pattern: '',
|
|
805
|
+
message: 'x',
|
|
806
|
+
engine: 'ast',
|
|
807
|
+
compiledAt: NOW,
|
|
808
|
+
examples: [{ bad: 'b', good: 'g' }],
|
|
809
|
+
requires: { pattern: 'LC_ALL=C', scope: 'file' },
|
|
810
|
+
});
|
|
811
|
+
await expect(applyAstRulesToAdditions(ctx(), [astRule], [addition('src/a.ts', 'foo()')], os.tmpdir(), undefined, undefined, async () => 'foo()')).rejects.toThrow(/has no two-pass evaluator/);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
//# sourceMappingURL=record-runtime.test.js.map
|