fini-proof 0.3.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/LICENSE +5 -0
- package/README.md +220 -0
- package/action.yml +53 -0
- package/bin/fini-proof.mjs +6 -0
- package/ci/Jenkinsfile +13 -0
- package/ci/Jenkinsfile.daily +23 -0
- package/ci/bitbucket-pipelines-daily.yml +15 -0
- package/ci/bitbucket-pipelines.yml +12 -0
- package/ci/github-actions-daily.yml +31 -0
- package/ci/github-actions-example.yml +14 -0
- package/ci/gitlab-ci-daily.yml +17 -0
- package/ci/gitlab-ci.yml +13 -0
- package/package.json +31 -0
- package/src/cli.mjs +258 -0
- package/src/engines/fail-open.mjs +133 -0
- package/src/engines/gitleaks.mjs +143 -0
- package/src/engines/hollow-tests.mjs +121 -0
- package/src/engines/migrations.mjs +316 -0
- package/src/engines/secrets.mjs +88 -0
- package/src/engines/skip-ratchet.mjs +78 -0
- package/src/engines/tenant-filter.mjs +516 -0
- package/src/engines/vendor/entrypoint-guard-core.mjs +43 -0
- package/src/engines/vendor/migration-invariants-core.mjs +127 -0
- package/src/engines/vendor/secrets-core.mjs +123 -0
- package/src/engines/vendor/vacuous-spec-core.mjs +1066 -0
- package/src/evidence.mjs +130 -0
- package/src/files.mjs +89 -0
- package/src/hooks.mjs +118 -0
- package/src/licence.mjs +85 -0
- package/src/mcp.mjs +230 -0
- package/src/report.mjs +73 -0
- package/src/runner.mjs +306 -0
- package/src/state.mjs +189 -0
- package/src/upload.mjs +85 -0
- package/src/usage.mjs +64 -0
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
// PROVENANCE — ported, not rewritten.
|
|
2
|
+
// source : finipe/scripts/ci/check-vacuous-spec.mjs (EOS F4 vacuous-spec detector)
|
|
3
|
+
// commit : finipe 4ac53b367b348d23d000629cb14e19633beb9613 (branch claude/aeos-factory-batch-2026-09-26;
|
|
4
|
+
// file last changed in 04152854b; rule families from 4de27d974)
|
|
5
|
+
// kept : lines 159-1210 verbatim (masking, block finder, all 11 rule families, helper resolution).
|
|
6
|
+
// changed: Finipe range resolution (resolveBase/changedSpecFiles) removed — the fini-proof runner owns scope;
|
|
7
|
+
// ROOT is settable (setRoot) instead of the Finipe repo root; CLI/self-test/guard dropped
|
|
8
|
+
// (self-test cases live on as negative controls in src/engines/hollow-tests.mjs + test/).
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
let ROOT = process.cwd()
|
|
13
|
+
export function setRoot(r) { ROOT = r }
|
|
14
|
+
|
|
15
|
+
// `}` `(` `)` that only exists inside text. Same technique this repo already
|
|
16
|
+
// uses in backend/scripts/check-super-admin-root-only.ts's literals() /
|
|
17
|
+
// backend/scripts/lib/strip-ts-comments.ts, reimplemented in plain .mjs here
|
|
18
|
+
// because this gate must run without ts-node (host law §2 — no toolchain, no
|
|
19
|
+
// jest, on the Mac).
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
21
|
+
function regexMayStart(source, i) {
|
|
22
|
+
let k = i - 1
|
|
23
|
+
while (k >= 0 && (source[k] === ' ' || source[k] === '\t')) k--
|
|
24
|
+
if (k < 0) return true
|
|
25
|
+
const p = source[k]
|
|
26
|
+
if ('(,=:[!&|?{};\n+-*%<>~^'.includes(p)) return true
|
|
27
|
+
const word = /([A-Za-z_$][\w$]*)$/.exec(source.slice(Math.max(0, k - 12), k + 1))
|
|
28
|
+
return !!word && ['return', 'typeof', 'case', 'in', 'of', 'delete', 'void', 'throw', 'new', 'else', 'do', 'await', 'yield'].includes(word[1])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function maskStringsAndComments(source) {
|
|
32
|
+
const out = source.split('')
|
|
33
|
+
const n = out.length
|
|
34
|
+
let i = 0
|
|
35
|
+
while (i < n) {
|
|
36
|
+
const c = source[i]
|
|
37
|
+
if (c === '/' && source[i + 1] === '/') {
|
|
38
|
+
let j = i
|
|
39
|
+
while (j < n && source[j] !== '\n') { out[j] = ' '; j++ }
|
|
40
|
+
i = j
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
if (c === '/' && source[i + 1] === '*') {
|
|
44
|
+
out[i] = ' '; out[i + 1] = ' '
|
|
45
|
+
let j = i + 2
|
|
46
|
+
while (j < n && !(source[j] === '*' && source[j + 1] === '/')) {
|
|
47
|
+
if (source[j] !== '\n') out[j] = ' '
|
|
48
|
+
j++
|
|
49
|
+
}
|
|
50
|
+
if (j < n) { out[j] = ' '; out[j + 1] = ' '; j += 2 }
|
|
51
|
+
i = j
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
// REGEX LITERAL (factory batch 2026-09-26): `/\{([^}]*)\}/` holds braces the
|
|
55
|
+
// bracket matcher counted, so a test whose body carried such a literal was cut
|
|
56
|
+
// short at the first unbalanced `}` and read as NO_ASSERTION. A `/` starts a
|
|
57
|
+
// regex (not a division) when the previous significant character cannot end an
|
|
58
|
+
// operand, or the previous word is `return`/`typeof`/…; its contents — including
|
|
59
|
+
// a character class, where `/` does not end the literal — are blanked like a
|
|
60
|
+
// string's.
|
|
61
|
+
if (c === '/' && source[i + 1] !== '/' && source[i + 1] !== '*' && regexMayStart(source, i)) {
|
|
62
|
+
let j = i + 1
|
|
63
|
+
let inClass = false
|
|
64
|
+
let closed = false
|
|
65
|
+
while (j < n && source[j] !== '\n') {
|
|
66
|
+
const d = source[j]
|
|
67
|
+
if (d === '\\') { out[j] = ' '; if (j + 1 < n && source[j + 1] !== '\n') out[j + 1] = ' '; j += 2; continue }
|
|
68
|
+
if (d === '[') inClass = true
|
|
69
|
+
else if (d === ']') inClass = false
|
|
70
|
+
else if (d === '/' && !inClass) { closed = true; break }
|
|
71
|
+
out[j] = ' '
|
|
72
|
+
j++
|
|
73
|
+
}
|
|
74
|
+
if (closed) { i = j + 1; continue }
|
|
75
|
+
// not a regex after all (no closing `/` on the line): undo, treat as an operator
|
|
76
|
+
for (let k = i + 1; k < j; k++) out[k] = source[k]
|
|
77
|
+
i++
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
81
|
+
const quote = c
|
|
82
|
+
out[i] = ' '
|
|
83
|
+
let j = i + 1
|
|
84
|
+
while (j < n && source[j] !== quote) {
|
|
85
|
+
if (source[j] === '\\') {
|
|
86
|
+
if (source[j] !== '\n') out[j] = ' '
|
|
87
|
+
j++
|
|
88
|
+
if (j < n && source[j] !== '\n') out[j] = ' '
|
|
89
|
+
j++
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (source[j] !== '\n') out[j] = ' '
|
|
93
|
+
j++
|
|
94
|
+
}
|
|
95
|
+
if (j < n) { out[j] = ' '; j++ }
|
|
96
|
+
i = j
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
i++
|
|
100
|
+
}
|
|
101
|
+
return out.join('')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function lineAt(source, idx) {
|
|
105
|
+
let line = 1
|
|
106
|
+
for (let i = 0; i < idx && i < source.length; i++) if (source[i] === '\n') line++
|
|
107
|
+
return line
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Find the index of the `{`/`(` matching the one at `openIdx`, scanning the
|
|
111
|
+
* MASKED text so nothing inside a string/comment can desync the count. */
|
|
112
|
+
function matchBracket(masked, openIdx, openCh, closeCh) {
|
|
113
|
+
let depth = 0
|
|
114
|
+
for (let i = openIdx; i < masked.length; i++) {
|
|
115
|
+
if (masked[i] === openCh) depth++
|
|
116
|
+
else if (masked[i] === closeCh) {
|
|
117
|
+
depth--
|
|
118
|
+
if (depth === 0) return i
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return -1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
125
|
+
// Block extraction
|
|
126
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
127
|
+
const ACTIVE_RE = /\b(?:it|test|fit|ftest)\s*\(/g
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The test callback's BODY inside the call `(`…`)` at [callOpen, callClose] of the
|
|
131
|
+
* masked source → { start, end } (exclusive end), or null when no function literal.
|
|
132
|
+
*
|
|
133
|
+
* FALSE-POSITIVE CLASS (factory batch 2026-09-26, measured): this used to take the
|
|
134
|
+
* FIRST `{` among the call's arguments as the body. For an expression-bodied arrow —
|
|
135
|
+
* `it('x', async () => expect(await f({ a: 1 })).toBe(2))` — that `{` is an object
|
|
136
|
+
* literal INSIDE the assertion, so the "body" was `a: 1` and a real assertion read as
|
|
137
|
+
* NO_ASSERTION. The body is now the callback's own: after `=>` either its `{…}` block
|
|
138
|
+
* or, for an expression body, the expression itself; for a `function (…) {…}` the
|
|
139
|
+
* block after its parameter list.
|
|
140
|
+
*/
|
|
141
|
+
function functionBody(masked, callOpen, callClose) {
|
|
142
|
+
const args = masked.slice(callOpen + 1, callClose)
|
|
143
|
+
// top-level `=>` of the callback (depth 0 relative to the call's own parens)
|
|
144
|
+
let depth = 0
|
|
145
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
146
|
+
const ch = args[i]
|
|
147
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++
|
|
148
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
|
149
|
+
else if (depth === 0 && ch === '=' && args[i + 1] === '>') {
|
|
150
|
+
let p = i - 1
|
|
151
|
+
while (p >= 0 && /\s/.test(args[p])) p--
|
|
152
|
+
let params = ''
|
|
153
|
+
if (args[p] === ')') { const o = matchBracketBack(args, p, '(', ')'); if (o !== -1) params = args.slice(o + 1, p) }
|
|
154
|
+
else { let q = p; while (q >= 0 && /[\w$]/.test(args[q])) q--; params = args.slice(q + 1, p + 1) }
|
|
155
|
+
let j = i + 2
|
|
156
|
+
while (j < args.length && /\s/.test(args[j])) j++
|
|
157
|
+
if (args[j] === '{') {
|
|
158
|
+
const open = callOpen + 1 + j
|
|
159
|
+
const close = matchBracket(masked, open, '{', '}')
|
|
160
|
+
return close === -1 ? null : { start: open + 1, end: close, params }
|
|
161
|
+
}
|
|
162
|
+
// expression body: up to the next top-level `,` (a timeout argument) or the call's end
|
|
163
|
+
let d = 0
|
|
164
|
+
let k = j
|
|
165
|
+
for (; k < args.length; k++) {
|
|
166
|
+
const c = args[k]
|
|
167
|
+
if (c === '(' || c === '[' || c === '{') d++
|
|
168
|
+
else if (c === ')' || c === ']' || c === '}') d--
|
|
169
|
+
else if (d === 0 && c === ',') break
|
|
170
|
+
}
|
|
171
|
+
return { start: callOpen + 1 + j, end: callOpen + 1 + k, params }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const fn = /\bfunction\b[^(]*\(/.exec(args)
|
|
175
|
+
if (fn) {
|
|
176
|
+
const pOpen = callOpen + 1 + fn.index + fn[0].length - 1
|
|
177
|
+
const pClose = matchBracket(masked, pOpen, '(', ')')
|
|
178
|
+
if (pClose === -1) return null
|
|
179
|
+
const open = masked.indexOf('{', pClose)
|
|
180
|
+
if (open === -1 || open > callClose) return null
|
|
181
|
+
const close = matchBracket(masked, open, '{', '}')
|
|
182
|
+
return close === -1 ? null : { start: open + 1, end: close, params: masked.slice(pOpen + 1, pClose) }
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
const SKIP_RE = /\b(?:it\.skip|test\.skip|xit|xtest|describe\.skip|xdescribe)\s*\(/g
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @returns {Array<{kind:'active', line:number, bodyText:string, bodyMasked:string}
|
|
190
|
+
* | {kind:'skip', line:number, matchStart:number}>}
|
|
191
|
+
*/
|
|
192
|
+
export function findTestBlocks(source) {
|
|
193
|
+
const masked = maskStringsAndComments(source)
|
|
194
|
+
const blocks = []
|
|
195
|
+
|
|
196
|
+
SKIP_RE.lastIndex = 0
|
|
197
|
+
let m
|
|
198
|
+
while ((m = SKIP_RE.exec(masked)) !== null) {
|
|
199
|
+
blocks.push({ kind: 'skip', line: lineAt(source, m.index), matchStart: m.index })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
ACTIVE_RE.lastIndex = 0
|
|
203
|
+
while ((m = ACTIVE_RE.exec(masked)) !== null) {
|
|
204
|
+
const callOpen = m.index + m[0].length - 1 // index of the call's own '('
|
|
205
|
+
const callClose = matchBracket(masked, callOpen, '(', ')')
|
|
206
|
+
if (callClose === -1) continue
|
|
207
|
+
const body = functionBody(masked, callOpen, callClose)
|
|
208
|
+
if (!body) continue // no function-literal argument (e.g. it.todo, a bare reference) — nothing to assert
|
|
209
|
+
blocks.push({
|
|
210
|
+
kind: 'active',
|
|
211
|
+
line: lineAt(source, m.index),
|
|
212
|
+
bodyText: source.slice(body.start, body.end),
|
|
213
|
+
bodyMasked: masked.slice(body.start, body.end),
|
|
214
|
+
bodyStart: body.start,
|
|
215
|
+
params: body.params || '',
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return blocks.sort((a, b) => a.line - b.line)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
223
|
+
// Criterion 1 + 2 — assertion presence / tautology
|
|
224
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
225
|
+
const EXPECT_CALL_RE = /\bexpect\s*\(/g
|
|
226
|
+
// NOT global. A /g regex keeps `lastIndex` between `.test()` calls, so after one block
|
|
227
|
+
// matched, the NEXT block was searched from the previous block's match offset — a
|
|
228
|
+
// shorter block then "had no assert" (factory batch 2026-09-26: 130 of the 243 findings
|
|
229
|
+
// on the release line were this one defect, in node:test `assert.*` specs).
|
|
230
|
+
const ASSERT_CALL_RE = /\bassert(?:\.\w+)?\s*\(/
|
|
231
|
+
|
|
232
|
+
function findExpectCalls(bodyText, bodyMasked) {
|
|
233
|
+
const calls = []
|
|
234
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
235
|
+
let m
|
|
236
|
+
while ((m = EXPECT_CALL_RE.exec(bodyMasked)) !== null) {
|
|
237
|
+
const argOpen = m.index + m[0].length - 1
|
|
238
|
+
const argClose = matchBracket(bodyMasked, argOpen, '(', ')')
|
|
239
|
+
if (argClose === -1) continue
|
|
240
|
+
const arg = bodyText.slice(argOpen + 1, argClose).trim()
|
|
241
|
+
// matcher: `.toBeX(...)` immediately following the expect(...) call.
|
|
242
|
+
const rest = bodyMasked.slice(argClose + 1)
|
|
243
|
+
const matcherM = /^\s*\.\s*(\w+)\s*\(/.exec(rest)
|
|
244
|
+
let matcherName = null
|
|
245
|
+
let matcherArg = null
|
|
246
|
+
if (matcherM) {
|
|
247
|
+
const matcherOpen = argClose + 1 + matcherM.index + matcherM[0].length - 1
|
|
248
|
+
const matcherClose = matchBracket(bodyMasked, matcherOpen, '(', ')')
|
|
249
|
+
matcherName = matcherM[1]
|
|
250
|
+
if (matcherClose !== -1) matcherArg = bodyText.slice(matcherOpen + 1, matcherClose).trim()
|
|
251
|
+
}
|
|
252
|
+
// F4 idioms (2026-09-26): the FULL matcher chain (`not.toBeUndefined`,
|
|
253
|
+
// `resolves.toBe`) and the MASKED argument texts, so a name that occurs only inside a
|
|
254
|
+
// quoted string (`toHaveProperty('status')`) is never read as the identifier itself.
|
|
255
|
+
const chainM = /^(?:\s*\.\s*\w+)+\s*\(/.exec(rest)
|
|
256
|
+
let chain = null
|
|
257
|
+
let matcherArgMasked = null
|
|
258
|
+
if (chainM) {
|
|
259
|
+
chain = chainM[0].replace(/[\s(]/g, '').replace(/^\./, '')
|
|
260
|
+
const cOpen = argClose + 1 + chainM[0].length - 1
|
|
261
|
+
const cClose = matchBracket(bodyMasked, cOpen, '(', ')')
|
|
262
|
+
if (cClose !== -1) matcherArgMasked = bodyMasked.slice(cOpen + 1, cClose).trim()
|
|
263
|
+
}
|
|
264
|
+
const argMasked = bodyMasked.slice(argOpen + 1, argClose).trim()
|
|
265
|
+
calls.push({ arg, matcherName, matcherArg, chain, argMasked, matcherArgMasked, index: m.index, argOpen, argClose })
|
|
266
|
+
}
|
|
267
|
+
return calls
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isBooleanTautology(call) {
|
|
271
|
+
if (call.arg === 'true' && (call.matcherName === 'toBe' && call.matcherArg === 'true')) return true
|
|
272
|
+
if (call.arg === 'true' && call.matcherName === 'toBeTruthy') return true
|
|
273
|
+
if (call.arg === 'false' && (call.matcherName === 'toBe' && call.matcherArg === 'false')) return true
|
|
274
|
+
if (call.arg === 'false' && call.matcherName === 'toBeFalsy') return true
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Is `ident`'s nearest same-block binding before `beforeIdx` a "just
|
|
279
|
+
* constructed" literal (object/array/string/number/template literal or a
|
|
280
|
+
* bare `new X(...)`), as opposed to a call to the unit under test? */
|
|
281
|
+
function isJustConstructed(bodyText, bodyMasked, ident, beforeIdx) {
|
|
282
|
+
const bindRe = new RegExp(`\\b(?:const|let|var)\\s+${ident}\\s*=\\s*`, 'g')
|
|
283
|
+
let lastBind = null
|
|
284
|
+
let m
|
|
285
|
+
while ((m = bindRe.exec(bodyMasked)) !== null) {
|
|
286
|
+
if (m.index < beforeIdx) lastBind = m
|
|
287
|
+
else break
|
|
288
|
+
}
|
|
289
|
+
if (!lastBind) return false
|
|
290
|
+
const rhsStart = lastBind.index + lastBind[0].length
|
|
291
|
+
const rhsMasked = bodyMasked.slice(rhsStart).trimStart()
|
|
292
|
+
const rhsText = bodyText.slice(rhsStart).trimStart()
|
|
293
|
+
if (/^[{[]/.test(rhsMasked)) return true // object / array literal
|
|
294
|
+
if (/^["'`]/.test(rhsText) && !/^["'`]\s*[)+]/.test(rhsMasked)) return true // quote char survives only in bodyText (masked blanks it) — detect via original
|
|
295
|
+
if (/^-?\d/.test(rhsMasked)) return true // number literal
|
|
296
|
+
if (/^new\s+[A-Za-z_$][\w$.]*\s*\(/.test(rhsMasked)) return true // bare constructor
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `assertingHelpers` (optional): names of functions, defined in this spec file or
|
|
302
|
+
* imported from a RELATIVE module, whose own body asserts (expect/assert, or a call
|
|
303
|
+
* to another asserting helper). A test whose only assertion is `await
|
|
304
|
+
* expectRefusedAndNothingPersisted(...)` asserts; a test that calls a helper that
|
|
305
|
+
* asserts nothing is still NO_ASSERTION. (Factory batch 2026-09-26: 42 of the
|
|
306
|
+
* release line's NO_ASSERTION findings were assertions the detector could not see
|
|
307
|
+
* because they lived in a named helper — `qFail`, `expectRejectedBeforeSideEffects`,
|
|
308
|
+
* `assertNoNegativeBalances`, … — not missing assertions.)
|
|
309
|
+
*/
|
|
310
|
+
export function isNoAssertion(block, assertingHelpers = new Set(), ruleTesters = new Set()) {
|
|
311
|
+
if (findExpectCalls(block.bodyText, block.bodyMasked).length > 0) return false
|
|
312
|
+
if (ASSERT_CALL_RE.test(block.bodyMasked)) return false
|
|
313
|
+
for (const name of calledNames(block.bodyMasked)) if (assertingHelpers.has(name)) return false
|
|
314
|
+
// F4 idioms 2026-09-26 — [FAIL_CAPABLE_THROW] a hand-rolled `if (bad) throw …` at test
|
|
315
|
+
// level, or inside a SYNCHRONOUS array callback / an IIFE (the throw propagates), fails
|
|
316
|
+
// the test: it is an assertion. A throw inside `.then(…)`/`setTimeout(…)` does not.
|
|
317
|
+
if (hasPropagatingThrow(block.bodyMasked)) return false
|
|
318
|
+
// [RULE_TESTER] `<receiver>.run(name, rule, {valid, invalid})` asserts every case.
|
|
319
|
+
if (callsRuleTesterRun(block.bodyMasked, ruleTesters)) return false
|
|
320
|
+
return true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
324
|
+
// Structural helpers for the idiom rules (F4 idioms, 2026-09-26).
|
|
325
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
326
|
+
function matchBracketBack(masked, closeIdx, openCh, closeCh) {
|
|
327
|
+
let depth = 0
|
|
328
|
+
for (let i = closeIdx; i >= 0; i--) {
|
|
329
|
+
if (masked[i] === closeCh) depth++
|
|
330
|
+
else if (masked[i] === openCh) {
|
|
331
|
+
depth--
|
|
332
|
+
if (depth === 0) return i
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return -1
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Index of the innermost UNMATCHED `(`/`[`/`{` before `idx`, or -1. */
|
|
339
|
+
function enclosingOpen(masked, idx) {
|
|
340
|
+
let depth = 0
|
|
341
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
342
|
+
const c = masked[i]
|
|
343
|
+
if (c === ')' || c === ']' || c === '}') depth++
|
|
344
|
+
else if (c === '(' || c === '[' || c === '{') {
|
|
345
|
+
if (depth === 0) return i
|
|
346
|
+
depth--
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return -1
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const CONTROL_KW = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'typeof', 'await', 'new', 'else', 'do', 'try', 'finally'])
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Every BLOCK-bodied function literal in `masked`: { start, open, close, callee, iife }.
|
|
356
|
+
* `callee` = name of the call the literal is a direct argument of (`forEach`, `then`,
|
|
357
|
+
* `setTimeout`…), `iife` = the literal is invoked where it is written.
|
|
358
|
+
*/
|
|
359
|
+
export function functionLiterals(masked) {
|
|
360
|
+
const out = []
|
|
361
|
+
for (let i = 0; i < masked.length; i++) {
|
|
362
|
+
if (masked[i] !== '{') continue
|
|
363
|
+
let k = i - 1
|
|
364
|
+
while (k >= 0 && /\s/.test(masked[k])) k--
|
|
365
|
+
let start = -1
|
|
366
|
+
if (k >= 1 && masked[k] === '>' && masked[k - 1] === '=') {
|
|
367
|
+
let p = k - 2
|
|
368
|
+
while (p >= 0 && /\s/.test(masked[p])) p--
|
|
369
|
+
if (masked[p] === ')') { start = matchBracketBack(masked, p, '(', ')'); if (start === -1) continue }
|
|
370
|
+
else { let q = p; while (q >= 0 && /[\w$]/.test(masked[q])) q--; start = q + 1 }
|
|
371
|
+
} else if (masked[k] === ')') {
|
|
372
|
+
const o = matchBracketBack(masked, k, '(', ')')
|
|
373
|
+
if (o === -1) continue
|
|
374
|
+
const before = masked.slice(0, o)
|
|
375
|
+
const w = /([A-Za-z_$][\w$]*)\s*(?:<[^<>()]*>)?\s*$/.exec(before)
|
|
376
|
+
if (!w || CONTROL_KW.has(w[1])) continue
|
|
377
|
+
const fnKw = /(?:\basync\s+)?\bfunction\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*$/.exec(before)
|
|
378
|
+
start = fnKw ? fnKw.index : w.index
|
|
379
|
+
} else continue
|
|
380
|
+
const am = /\basync\s*$/.exec(masked.slice(0, start))
|
|
381
|
+
if (am) start = am.index
|
|
382
|
+
const close = matchBracket(masked, i, '{', '}')
|
|
383
|
+
if (close === -1) continue
|
|
384
|
+
let callee = null
|
|
385
|
+
let iife = false
|
|
386
|
+
let b = start - 1
|
|
387
|
+
while (b >= 0 && /\s/.test(masked[b])) b--
|
|
388
|
+
const encl = enclosingOpen(masked, start)
|
|
389
|
+
if (encl !== -1 && masked[encl] === '(' && (masked[b] === '(' || masked[b] === ',')) {
|
|
390
|
+
const nm = /([A-Za-z_$][\w$]*)\s*$/.exec(masked.slice(0, encl))
|
|
391
|
+
if (masked[b] === '(' && b === encl) {
|
|
392
|
+
const pc = matchBracket(masked, encl, '(', ')')
|
|
393
|
+
let a = close + 1
|
|
394
|
+
while (a < masked.length && /\s/.test(masked[a])) a++
|
|
395
|
+
if (pc !== -1 && pc === a && /^\)\s*\(/.test(masked.slice(pc))) iife = true
|
|
396
|
+
}
|
|
397
|
+
if (nm && !CONTROL_KW.has(nm[1])) callee = nm[1]
|
|
398
|
+
}
|
|
399
|
+
out.push({ start, open: i, close, callee, iife })
|
|
400
|
+
}
|
|
401
|
+
return out
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const SYNC_CALLBACK = new Set(['forEach', 'map', 'flatMap', 'filter', 'some', 'every', 'find', 'findIndex', 'findLast', 'findLastIndex', 'reduce', 'reduceRight', 'sort', 'toSorted'])
|
|
405
|
+
const DEFERRED_CALLBACK = new Set(['then', 'catch', 'finally', 'setTimeout', 'setImmediate', 'setInterval', 'nextTick', 'queueMicrotask'])
|
|
406
|
+
|
|
407
|
+
/** [{tryOpen, tryClose, catchOpen, catchClose, rethrows}] for every try/catch in `masked`. */
|
|
408
|
+
function tryCatches(masked) {
|
|
409
|
+
const out = []
|
|
410
|
+
const re = /\btry\s*\{/g
|
|
411
|
+
let m
|
|
412
|
+
while ((m = re.exec(masked)) !== null) {
|
|
413
|
+
const tryOpen = m.index + m[0].length - 1
|
|
414
|
+
const tryClose = matchBracket(masked, tryOpen, '{', '}')
|
|
415
|
+
if (tryClose === -1) continue
|
|
416
|
+
const cm = /^\s*catch\s*(?:\([^)]*\))?\s*\{/.exec(masked.slice(tryClose + 1))
|
|
417
|
+
if (!cm) continue
|
|
418
|
+
const catchOpen = tryClose + 1 + cm.index + cm[0].length - 1
|
|
419
|
+
const catchClose = matchBracket(masked, catchOpen, '{', '}')
|
|
420
|
+
if (catchClose === -1) continue
|
|
421
|
+
out.push({ tryOpen, tryClose, catchOpen, catchClose, rethrows: ASSERT_IN_CATCH_RE.test(masked.slice(catchOpen + 1, catchClose)) })
|
|
422
|
+
}
|
|
423
|
+
return out
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function hasPropagatingThrow(masked) {
|
|
427
|
+
const fns = functionLiterals(masked)
|
|
428
|
+
const tcs = tryCatches(masked)
|
|
429
|
+
const re = /\bthrow\b/g
|
|
430
|
+
let m
|
|
431
|
+
while ((m = re.exec(masked)) !== null) {
|
|
432
|
+
const at = m.index
|
|
433
|
+
const enclosing = fns.filter((f) => f.open < at && at < f.close)
|
|
434
|
+
if (!enclosing.every((f) => f.iife || (f.callee && SYNC_CALLBACK.has(f.callee)))) continue
|
|
435
|
+
if (tcs.some((t) => t.tryOpen < at && at < t.tryClose && !t.rethrows)) continue
|
|
436
|
+
return true
|
|
437
|
+
}
|
|
438
|
+
return false
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Local names bound to a RuleTester: `= new RuleTester(`, anything imported/required
|
|
442
|
+
* from a `*rule-tester*` module (any case, any alias), or a name containing "ruletester". */
|
|
443
|
+
export function ruleTesterNames(source) {
|
|
444
|
+
const masked = maskStringsAndComments(source)
|
|
445
|
+
const names = new Set()
|
|
446
|
+
for (const m of masked.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*new\s+(?:[\w$]+\.)?RuleTester\b/g)) names.add(m[1])
|
|
447
|
+
for (const m of source.matchAll(/import\s+(?:type\s+)?([\s\S]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
|
|
448
|
+
if (!/rule[-_]?tester/i.test(m[2])) continue
|
|
449
|
+
const clause = m[1]
|
|
450
|
+
const def = /^([A-Za-z_$][\w$]*)/.exec(clause)
|
|
451
|
+
if (def) names.add(def[1])
|
|
452
|
+
const named = /\{([^}]*)\}/.exec(clause)
|
|
453
|
+
if (named) for (const part of named[1].split(',')) { const [o, a] = part.trim().split(/\s+as\s+/); if (o) names.add((a || o).trim()) }
|
|
454
|
+
}
|
|
455
|
+
for (const m of source.matchAll(/\b(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
456
|
+
if (!/rule[-_]?tester/i.test(m[2])) continue
|
|
457
|
+
if (m[1].startsWith('{')) for (const part of m[1].slice(1, -1).split(',')) { const [o, a] = part.trim().split(/\s*:\s*/); if (o) names.add((a || o).trim()) }
|
|
458
|
+
else names.add(m[1])
|
|
459
|
+
}
|
|
460
|
+
names.delete('')
|
|
461
|
+
return names
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function callsRuleTesterRun(masked, ruleTesters) {
|
|
465
|
+
if (/(?<![.\w$])[\w$]*rule_?tester[\w$]*\s*\.\s*run\s*\(/i.test(masked)) return true
|
|
466
|
+
for (const n of ruleTesters) {
|
|
467
|
+
if (new RegExp(`(?<![.\\w$])${n.replace(/\$/g, '\\$')}\\s*\\.\\s*run\\s*\\(`).test(masked)) return true
|
|
468
|
+
}
|
|
469
|
+
return false
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Strip TS-only syntax from a MASKED argument: `x as { status: T }`, `x as Foo<Bar>`,
|
|
473
|
+
* `x satisfies T`, `x!` — a name that occurs only in a type is not a value read. */
|
|
474
|
+
function stripTypeSyntax(maskedArg) {
|
|
475
|
+
let s = maskedArg
|
|
476
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
477
|
+
const m = /\b(?:as|satisfies)\s+/.exec(s)
|
|
478
|
+
if (!m) break
|
|
479
|
+
let j = m.index + m[0].length
|
|
480
|
+
let d = 0
|
|
481
|
+
for (; j < s.length; j++) {
|
|
482
|
+
const c = s[j]
|
|
483
|
+
if (c === '{' || c === '<' || c === '(' || c === '[') d++
|
|
484
|
+
else if (c === '}' || c === '>' || c === ')' || c === ']') { if (d === 0) break; d-- }
|
|
485
|
+
else if (d === 0 && (c === ',' || c === ';' || c === '&' || c === '|' || c === '?' || c === '=' || c === '\n')) break
|
|
486
|
+
}
|
|
487
|
+
s = s.slice(0, m.index) + ' '.repeat(j - m.index) + s.slice(j)
|
|
488
|
+
}
|
|
489
|
+
return s
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const CALL_NAME_RE = /(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g
|
|
493
|
+
const NOT_A_CALL = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'typeof', 'await', 'new', 'expect', 'assert'])
|
|
494
|
+
function calledNames(masked) {
|
|
495
|
+
const out = new Set()
|
|
496
|
+
CALL_NAME_RE.lastIndex = 0
|
|
497
|
+
let m
|
|
498
|
+
while ((m = CALL_NAME_RE.exec(masked)) !== null) if (!NOT_A_CALL.has(m[1])) out.add(m[1])
|
|
499
|
+
return out
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** name → masked body of every function this source DEFINES:
|
|
503
|
+
* `function NAME(…) {…}` and `const|let NAME = [async] (…|x) => {…}|expr` / `= [async] function (…) {…}`. */
|
|
504
|
+
export function functionDefinitions(source) {
|
|
505
|
+
const masked = maskStringsAndComments(source)
|
|
506
|
+
const defs = new Map()
|
|
507
|
+
const fnRe = /\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*(?:<[^>(]*>)?\s*\(/g
|
|
508
|
+
let m
|
|
509
|
+
while ((m = fnRe.exec(masked)) !== null) {
|
|
510
|
+
const pOpen = m.index + m[0].length - 1
|
|
511
|
+
const pClose = matchBracket(masked, pOpen, '(', ')')
|
|
512
|
+
if (pClose === -1) continue
|
|
513
|
+
const open = masked.indexOf('{', pClose)
|
|
514
|
+
if (open === -1) continue
|
|
515
|
+
const close = matchBracket(masked, open, '{', '}')
|
|
516
|
+
if (close !== -1) defs.set(m[1], masked.slice(open + 1, close))
|
|
517
|
+
}
|
|
518
|
+
const constRe = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?/g
|
|
519
|
+
while ((m = constRe.exec(masked)) !== null) {
|
|
520
|
+
const at = m.index + m[0].length
|
|
521
|
+
const rest = masked.slice(at)
|
|
522
|
+
let open = -1
|
|
523
|
+
if (/^function\b/.test(rest) || /^\(/.test(rest) || /^[A-Za-z_$][\w$]*\s*=>/.test(rest)) {
|
|
524
|
+
const arrow = masked.indexOf('=>', at)
|
|
525
|
+
const fnKw = /^function\b/.test(rest)
|
|
526
|
+
if (fnKw) {
|
|
527
|
+
const pOpen = masked.indexOf('(', at)
|
|
528
|
+
const pClose = pOpen === -1 ? -1 : matchBracket(masked, pOpen, '(', ')')
|
|
529
|
+
open = pClose === -1 ? -1 : masked.indexOf('{', pClose)
|
|
530
|
+
} else if (arrow !== -1) {
|
|
531
|
+
let j = arrow + 2
|
|
532
|
+
while (j < masked.length && /\s/.test(masked[j])) j++
|
|
533
|
+
if (masked[j] === '{') open = j
|
|
534
|
+
else {
|
|
535
|
+
// expression body: to the end of the statement (newline at depth 0 or `;`)
|
|
536
|
+
let d = 0
|
|
537
|
+
let k = j
|
|
538
|
+
for (; k < masked.length; k++) {
|
|
539
|
+
const c = masked[k]
|
|
540
|
+
if (c === '(' || c === '[' || c === '{') d++
|
|
541
|
+
else if (c === ')' || c === ']' || c === '}') { if (d === 0) break; d-- }
|
|
542
|
+
else if (d === 0 && (c === ';' || c === '\n')) break
|
|
543
|
+
}
|
|
544
|
+
defs.set(m[1], masked.slice(j, k))
|
|
545
|
+
continue
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (open === -1) continue
|
|
550
|
+
const close = matchBracket(masked, open, '{', '}')
|
|
551
|
+
if (close !== -1) defs.set(m[1], masked.slice(open + 1, close))
|
|
552
|
+
}
|
|
553
|
+
return defs
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Relative imports of a spec: local name → absolute module path (resolved on disk). */
|
|
557
|
+
function relativeImports(relPath, source) {
|
|
558
|
+
const out = new Map()
|
|
559
|
+
const dir = path.dirname(path.join(ROOT, relPath))
|
|
560
|
+
const re = /import\s*(?:type\s+)?\{([^}]*)\}\s*from\s*['"](\.{1,2}\/[^'"]+)['"]/g
|
|
561
|
+
let m
|
|
562
|
+
while ((m = re.exec(source)) !== null) {
|
|
563
|
+
const base = path.resolve(dir, m[2])
|
|
564
|
+
const file = ['', '.ts', '.tsx', '.js', '.jsx', '.mjs', '/index.ts', '/index.js'].map((e) => base + e).find((f) => existsSync(f) && !f.endsWith('/'))
|
|
565
|
+
if (!file) continue
|
|
566
|
+
for (const part of m[1].split(',')) {
|
|
567
|
+
const [orig, alias] = part.trim().split(/\s+as\s+/)
|
|
568
|
+
if (orig) out.set((alias || orig).trim(), { file, name: orig.trim() })
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return out
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The set of helper names, callable from this spec, whose body asserts (fixpoint, so a
|
|
575
|
+
* helper that only calls an asserting helper asserts too). */
|
|
576
|
+
export function assertingHelpersOf(relPath, source, readFile = (f) => readFileSync(f, 'utf8')) {
|
|
577
|
+
const bodies = new Map(functionDefinitions(source))
|
|
578
|
+
for (const [local, { file, name }] of relativeImports(relPath, source)) {
|
|
579
|
+
try {
|
|
580
|
+
const b = functionDefinitions(readFile(file)).get(name)
|
|
581
|
+
if (b !== undefined && !bodies.has(local)) bodies.set(local, b)
|
|
582
|
+
} catch { /* unreadable import: its helpers simply do not count */ }
|
|
583
|
+
}
|
|
584
|
+
const asserting = new Set()
|
|
585
|
+
let grew = true
|
|
586
|
+
while (grew) {
|
|
587
|
+
grew = false
|
|
588
|
+
for (const [name, body] of bodies) {
|
|
589
|
+
if (asserting.has(name)) continue
|
|
590
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
591
|
+
const direct = EXPECT_CALL_RE.test(body) || ASSERT_CALL_RE.test(body)
|
|
592
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
593
|
+
if (direct || [...calledNames(body)].some((n) => n !== name && asserting.has(n))) { asserting.add(name); grew = true }
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return asserting
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function isAllTautological(block) {
|
|
600
|
+
const calls = findExpectCalls(block.bodyText, block.bodyMasked)
|
|
601
|
+
if (calls.length === 0) return false // that's criterion 1, not 2
|
|
602
|
+
return calls.every((call) => {
|
|
603
|
+
if (isBooleanTautology(call)) return true
|
|
604
|
+
if (call.matcherName === 'toBeDefined' && /^[A-Za-z_$][\w$]*$/.test(call.arg)) {
|
|
605
|
+
// Need the call's position to bound "nearest binding before this call" —
|
|
606
|
+
// recompute it via indexOf since findExpectCalls doesn't carry position.
|
|
607
|
+
const callIdx = block.bodyMasked.indexOf(`${call.arg}`)
|
|
608
|
+
return isJustConstructed(block.bodyText, block.bodyMasked, call.arg, callIdx >= 0 ? callIdx : block.bodyMasked.length)
|
|
609
|
+
}
|
|
610
|
+
return false
|
|
611
|
+
})
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
615
|
+
// Criterion 3 — unlinked skip
|
|
616
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
617
|
+
const SKIP_LEDGER_RE = /SKIP-LEDGER:\s*\S+/
|
|
618
|
+
|
|
619
|
+
export function skipLacksLedgerEntry(source, skipLine) {
|
|
620
|
+
const lines = source.split('\n')
|
|
621
|
+
const from = Math.max(0, skipLine - 1 - 3)
|
|
622
|
+
const window = lines.slice(from, skipLine).join('\n')
|
|
623
|
+
return !SKIP_LEDGER_RE.test(window)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
627
|
+
// Criterion 4 — swallowed catch around the unit under test
|
|
628
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
629
|
+
const TRY_RE = /\btry\s*\{/g
|
|
630
|
+
const ASSERT_IN_CATCH_RE = /\b(expect|assert|fail)\s*\(|\bthrow\b/
|
|
631
|
+
|
|
632
|
+
/** Is any of `names` referenced inside an expect(…)/assert…(…) call (its arguments, or
|
|
633
|
+
* the matcher's) in `seg`? */
|
|
634
|
+
const EXISTENCE_ONLY = new Set(['toBeDefined', 'not.toBeUndefined', 'not.toBeNull'])
|
|
635
|
+
function assertedAfter(seg, names, { excludeExistence = false } = {}) {
|
|
636
|
+
// V1 (Opus cert f66867f32, 2026-09-26): the binding must appear as a NAME, not as a
|
|
637
|
+
// property of something else — `res.status` / `row.error` must not count as asserting
|
|
638
|
+
// a captured `status` / `error`. Same lookbehind the capture side already uses.
|
|
639
|
+
// F4 idioms (cert A10 baecbbf7f gap): matched on the MASKED, type-stripped text — a
|
|
640
|
+
// name that occurs only in a quoted matcher arg (`toHaveProperty('status')`) or only in
|
|
641
|
+
// a TS type (`x as { status: T }`) is not an assertion on that binding.
|
|
642
|
+
const re = new RegExp(`(?<![.\\w$])(?:${names.map((n) => n.replace(/[$]/g, '\\$')).join('|')})\\b`)
|
|
643
|
+
for (const c of findExpectCalls(seg.text, seg.masked)) {
|
|
644
|
+
if (excludeExistence && EXISTENCE_ONLY.has(c.chain)) continue
|
|
645
|
+
if (re.test(stripTypeSyntax(c.argMasked)) || (c.matcherArgMasked && re.test(stripTypeSyntax(c.matcherArgMasked)))) return true
|
|
646
|
+
}
|
|
647
|
+
const aRe = /\bassert(?:\.\w+)?\s*\(/g
|
|
648
|
+
let m
|
|
649
|
+
while ((m = aRe.exec(seg.masked)) !== null) {
|
|
650
|
+
const open = m.index + m[0].length - 1
|
|
651
|
+
const close = matchBracket(seg.masked, open, '(', ')')
|
|
652
|
+
if (close !== -1 && re.test(stripTypeSyntax(seg.masked.slice(open + 1, close)))) return true
|
|
653
|
+
}
|
|
654
|
+
return false
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function hasSwallowedCatch(block) {
|
|
658
|
+
const masked = block.bodyMasked
|
|
659
|
+
TRY_RE.lastIndex = 0
|
|
660
|
+
let m
|
|
661
|
+
while ((m = TRY_RE.exec(masked)) !== null) {
|
|
662
|
+
const tryBraceOpen = m.index + m[0].length - 1
|
|
663
|
+
const tryBraceClose = matchBracket(masked, tryBraceOpen, '{', '}')
|
|
664
|
+
if (tryBraceClose === -1) continue
|
|
665
|
+
const tryBody = masked.slice(tryBraceOpen + 1, tryBraceClose)
|
|
666
|
+
if (!/\(/.test(tryBody)) continue // try body calls nothing — not "around the unit under test"
|
|
667
|
+
|
|
668
|
+
const afterTry = masked.slice(tryBraceClose + 1)
|
|
669
|
+
const catchM = /^\s*catch\s*(?:\([^)]*\))?\s*\{/.exec(afterTry)
|
|
670
|
+
if (!catchM) continue
|
|
671
|
+
const catchBraceOpen = tryBraceClose + 1 + catchM.index + catchM[0].length - 1
|
|
672
|
+
const catchBraceClose = matchBracket(masked, catchBraceOpen, '{', '}')
|
|
673
|
+
if (catchBraceClose === -1) continue
|
|
674
|
+
const catchBody = masked.slice(catchBraceOpen + 1, catchBraceClose)
|
|
675
|
+
if (ASSERT_IN_CATCH_RE.test(catchBody)) continue
|
|
676
|
+
// CAPTURE-THEN-ASSERT is not a swallow: `catch (e) { caught = e }` followed by
|
|
677
|
+
// `expect(caught).toBeInstanceOf(X)` asserts on exactly what the catch took. It
|
|
678
|
+
// counts ONLY when a binding the catch assigns is referenced inside an assertion
|
|
679
|
+
// AFTER the try/catch; a captured error that nothing asserts is still RED.
|
|
680
|
+
// (Factory batch 2026-09-26: 27 of the 41 SWALLOWED_CATCH findings were this.)
|
|
681
|
+
const assigned = [
|
|
682
|
+
...[...catchBody.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*=(?![=>])/g)].map((x) => x[1]),
|
|
683
|
+
// …or collected into an outer list/set/map: `unparsed.push(err)`
|
|
684
|
+
...[...catchBody.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\.\s*(?:push|add|set)\s*\(/g)].map((x) => x[1]),
|
|
685
|
+
]
|
|
686
|
+
const after = { text: block.bodyText.slice(catchBraceClose + 1), masked: masked.slice(catchBraceClose + 1) }
|
|
687
|
+
if (assigned.length && assertedAfter(after, assigned)) continue
|
|
688
|
+
// [SENTINEL] F4 idioms 2026-09-26: `try { unit(); return true } catch { return false }`
|
|
689
|
+
// inside an IIFE (or a named arrow) whose RESULT is asserted — `assert.ok((() => …)())`,
|
|
690
|
+
// `const ok = (() => …)(); expect(ok).toBe(true)`, `expect(probe()).toBeTruthy()` — is
|
|
691
|
+
// fail-capable: the catch turns the error into a value the assertion reads. It stays RED
|
|
692
|
+
// when the sentinel is discarded, or only asserted to exist (`toBeDefined`).
|
|
693
|
+
if (sentinelAsserted(block, m.index, catchBody)) continue
|
|
694
|
+
return true
|
|
695
|
+
}
|
|
696
|
+
return false
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function sentinelAsserted(block, tryIdx, catchBody) {
|
|
700
|
+
if (!/^\s*return\b[^;{}]*;?\s*$/.test(catchBody)) return false
|
|
701
|
+
const masked = block.bodyMasked
|
|
702
|
+
const fns = functionLiterals(masked).filter((f) => f.open < tryIdx && tryIdx < f.close)
|
|
703
|
+
if (!fns.length) return false
|
|
704
|
+
const fn = fns.reduce((a, b) => (b.open > a.open ? b : a))
|
|
705
|
+
let exprStart = fn.start
|
|
706
|
+
let exprEnd = fn.close + 1
|
|
707
|
+
if (fn.iife) {
|
|
708
|
+
exprStart = enclosingOpen(masked, fn.start)
|
|
709
|
+
const pc = matchBracket(masked, exprStart, '(', ')')
|
|
710
|
+
const ao = masked.indexOf('(', pc + 1)
|
|
711
|
+
exprEnd = matchBracket(masked, ao, '(', ')') + 1
|
|
712
|
+
}
|
|
713
|
+
// (a) the IIFE is itself an assertion's argument
|
|
714
|
+
if (fn.iife) {
|
|
715
|
+
for (const c of findExpectCalls(block.bodyText, masked)) {
|
|
716
|
+
if (c.argOpen < exprStart && exprEnd <= c.argClose + 1 && !EXISTENCE_ONLY.has(c.chain)) return true
|
|
717
|
+
}
|
|
718
|
+
const aRe = /\bassert(?:\.\w+)?\s*\(/g
|
|
719
|
+
let m
|
|
720
|
+
while ((m = aRe.exec(masked)) !== null) {
|
|
721
|
+
const open = m.index + m[0].length - 1
|
|
722
|
+
const close = matchBracket(masked, open, '(', ')')
|
|
723
|
+
if (open < exprStart && exprEnd <= close + 1) return true
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
// (b) bound to a name that a later assertion reads (IIFE result, or the named function called)
|
|
727
|
+
const bind = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*$/.exec(masked.slice(0, exprStart))
|
|
728
|
+
if (!bind) return false
|
|
729
|
+
const after = { text: block.bodyText.slice(exprEnd), masked: masked.slice(exprEnd) }
|
|
730
|
+
return assertedAfter(after, [bind[1]], { excludeExistence: true })
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
734
|
+
// Idiom rules (F4 idioms, 2026-09-26). Evidence: aeos-live-status/reports/
|
|
735
|
+
// aeos-a10-skill.md §10 — on 7 of 16 sealed-holdout cases F4 never fired, so no
|
|
736
|
+
// repair skill (whose trigger IS F4) could do better. One rule per missed FAMILY,
|
|
737
|
+
// written from the family, not from any holdout snippet. Each is proven RED, GREEN
|
|
738
|
+
// (a control of the same shape that is fail-capable) and by a negative control
|
|
739
|
+
// (rule neutered in a scratch copy ⇒ its RED fixture passes) — see the report
|
|
740
|
+
// aeos-live-status/reports/aeos-f4-idioms.md.
|
|
741
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
/** Every assertion call in a block: expect(...) calls (with chain) and assert*(...) calls. */
|
|
744
|
+
function assertionCalls(block) {
|
|
745
|
+
const out = findExpectCalls(block.bodyText, block.bodyMasked).map((c) => ({ kind: 'expect', ...c }))
|
|
746
|
+
const re = /\bassert(?:\.(\w+))?\s*\(/g
|
|
747
|
+
let m
|
|
748
|
+
while ((m = re.exec(block.bodyMasked)) !== null) {
|
|
749
|
+
const open = m.index + m[0].length - 1
|
|
750
|
+
const close = matchBracket(block.bodyMasked, open, '(', ')')
|
|
751
|
+
if (close === -1) continue
|
|
752
|
+
out.push({ kind: 'assert', method: m[1] || '', index: m.index, argOpen: open, argClose: close, argMasked: block.bodyMasked.slice(open + 1, close).trim(), arg: block.bodyText.slice(open + 1, close).trim() })
|
|
753
|
+
}
|
|
754
|
+
return out
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function insideFunctionLiteral(fns, idx) {
|
|
758
|
+
return fns.some((f) => f.open < idx && idx < f.close)
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const DONE_NAMES = new Set(['done', 'cb', 'callback', 'finish', 'next', 'end'])
|
|
762
|
+
function doneParam(block) {
|
|
763
|
+
const ps = block.params.split(',').map((x) => x.trim().replace(/[:=][\s\S]*$/, '').trim()).filter(Boolean)
|
|
764
|
+
const last = ps[ps.length - 1]
|
|
765
|
+
return last && DONE_NAMES.has(last) ? last : null
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Asserting deferred callbacks in a masked body: [{ callee, callOpen, callClose, nameIdx }]. */
|
|
769
|
+
function assertingDeferredCalls(masked) {
|
|
770
|
+
const out = []
|
|
771
|
+
const re = /(?:\.\s*(then|catch|finally)|(?<![.\w$])(setTimeout|setImmediate|setInterval|queueMicrotask|(?:process\s*\.\s*)?nextTick))\s*\(/g
|
|
772
|
+
let m
|
|
773
|
+
while ((m = re.exec(masked)) !== null) {
|
|
774
|
+
const callOpen = m.index + m[0].length - 1
|
|
775
|
+
const callClose = matchBracket(masked, callOpen, '(', ')')
|
|
776
|
+
if (callClose === -1) continue
|
|
777
|
+
const inner = masked.slice(callOpen + 1, callClose)
|
|
778
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
779
|
+
const asserts = EXPECT_CALL_RE.test(inner) || ASSERT_CALL_RE.test(inner)
|
|
780
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
781
|
+
if (asserts) out.push({ callee: m[1] || m[2], callOpen, callClose, nameIdx: m.index })
|
|
782
|
+
}
|
|
783
|
+
return out
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** [PROMISE_NOT_AWAITED] an assertion lives in a `.then/.catch/.finally` (or timer)
|
|
787
|
+
* callback whose chain the test neither awaits, returns, nor hands to anything — the
|
|
788
|
+
* test ends before the callback runs, so the assertion can never fail it. */
|
|
789
|
+
export function hasFloatingAsyncAssertion(block) {
|
|
790
|
+
if (doneParam(block)) return false // done-style: DONE_EARLY's territory
|
|
791
|
+
const masked = block.bodyMasked
|
|
792
|
+
const fns = functionLiterals(masked)
|
|
793
|
+
for (const d of assertingDeferredCalls(masked)) {
|
|
794
|
+
if (insideFunctionLiteral(fns, d.nameIdx)) continue // nested inside another callback: not judged here
|
|
795
|
+
// walk back over the chain `a.b(x).c[0]\n .then` to the token before its root
|
|
796
|
+
let i = d.nameIdx - 1
|
|
797
|
+
let rootStart = d.nameIdx
|
|
798
|
+
for (;;) {
|
|
799
|
+
while (i >= 0 && /\s/.test(masked[i])) i--
|
|
800
|
+
if (i < 0) break
|
|
801
|
+
const c = masked[i]
|
|
802
|
+
if (c === ')' || c === ']') { const o = matchBracketBack(masked, i, c === ')' ? '(' : '[', c); if (o === -1) break; i = o - 1; continue }
|
|
803
|
+
if (c === '.') { i--; if (masked[i] === '?') i--; continue }
|
|
804
|
+
if (/[\w$]/.test(c)) {
|
|
805
|
+
let q = i
|
|
806
|
+
while (q >= 0 && /[\w$]/.test(masked[q])) q--
|
|
807
|
+
let r = q
|
|
808
|
+
while (r >= 0 && /\s/.test(masked[r])) r--
|
|
809
|
+
if (r >= 0 && masked[r] === '.') { i = r; continue } // `x\n .y` — still the chain
|
|
810
|
+
rootStart = q + 1
|
|
811
|
+
i = r
|
|
812
|
+
// `new X(…).then`: step over `new`
|
|
813
|
+
const nw = /\bnew\s*$/.exec(masked.slice(0, i + 1))
|
|
814
|
+
if (nw) { i = nw.index - 1; while (i >= 0 && /\s/.test(masked[i])) i-- }
|
|
815
|
+
break
|
|
816
|
+
}
|
|
817
|
+
break
|
|
818
|
+
}
|
|
819
|
+
const prev = i < 0 ? '' : masked[i]
|
|
820
|
+
// a string/comment (blanked in the mask) ending the previous line is an ASI boundary
|
|
821
|
+
const gap = block.bodyText.slice(i + 1, rootStart)
|
|
822
|
+
if (/\S/.test(gap) && /\n/.test(gap.slice(gap.search(/\S/)))) return true
|
|
823
|
+
if (/[\w$]/.test(prev)) {
|
|
824
|
+
const w = /([\w$]+)$/.exec(masked.slice(0, i + 1))
|
|
825
|
+
if (w && ['await', 'return', 'yield'].includes(w[1])) continue // awaited / returned
|
|
826
|
+
return true // ASI statement boundary after a word
|
|
827
|
+
}
|
|
828
|
+
if (prev === '' || prev === ';' || prev === '{' || prev === '}' || prev === ')') return true
|
|
829
|
+
// `=`, `(`, `,`, `[`, `=>`, `:` … — the chain is assigned, passed, or returned: not judged
|
|
830
|
+
}
|
|
831
|
+
return false
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** [DONE_EARLY] a done-callback test calls `done()` synchronously at test level while an
|
|
835
|
+
* assertion sits in a promise/timer callback — jest ends the test at `done()`, so that
|
|
836
|
+
* assertion can never fail it. (A done that is NEVER called is not vacuous: jest times
|
|
837
|
+
* the test out RED.) */
|
|
838
|
+
export function hasDoneBeforeAsyncAssertion(block) {
|
|
839
|
+
const done = doneParam(block)
|
|
840
|
+
if (!done) return false
|
|
841
|
+
const masked = block.bodyMasked
|
|
842
|
+
const fns = functionLiterals(masked)
|
|
843
|
+
const re = new RegExp(`(?<![.\\w$])${done.replace(/\$/g, '\\$')}\\s*\\(`, 'g')
|
|
844
|
+
let early = false
|
|
845
|
+
let m
|
|
846
|
+
while ((m = re.exec(masked)) !== null) if (!insideFunctionLiteral(fns, m.index) && !/=>\s*$/.test(masked.slice(0, m.index))) early = true
|
|
847
|
+
if (!early) return false
|
|
848
|
+
return assertingDeferredCalls(masked).length > 0
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** [EARLY_RETURN] a bare `return` at test level (not inside a nested callback) that comes
|
|
852
|
+
* BEFORE an assertion: whenever that path is taken the test passes having asserted
|
|
853
|
+
* nothing — a skip in disguise. Linkable like a skip: `// SKIP-LEDGER: <ref>` on the
|
|
854
|
+
* line or within 3 lines above exempts it. Returns the body offset of the return, or -1. */
|
|
855
|
+
export function earlyReturnIndex(block, assertingHelpers = new Set()) {
|
|
856
|
+
const masked = block.bodyMasked
|
|
857
|
+
const fns = functionLiterals(masked)
|
|
858
|
+
const re = /\breturn\b/g
|
|
859
|
+
let m
|
|
860
|
+
while ((m = re.exec(masked)) !== null) {
|
|
861
|
+
if (insideFunctionLiteral(fns, m.index)) continue
|
|
862
|
+
const tail = masked.slice(m.index + 6).split('\n')[0].trim()
|
|
863
|
+
if (!(tail === '' || tail === ';' || tail.startsWith('}') || /^(?:undefined|void\s+0)\s*;?\s*(?:}|$)/.test(tail))) continue
|
|
864
|
+
// Only a return reached BEFORE anything was asserted is a vacuous path. A type-narrowing
|
|
865
|
+
// `expect(r.ok).toBe(true); if (!r.ok) return` or an `if (x) { expect(…); return }`
|
|
866
|
+
// branch has already asserted — measured on the release range, 2026-09-26. A
|
|
867
|
+
// tautological `expect(true).toBe(true); return` has not.
|
|
868
|
+
const before = { text: block.bodyText.slice(0, m.index), masked: masked.slice(0, m.index) }
|
|
869
|
+
const deferred = assertingDeferredCalls(before.masked)
|
|
870
|
+
const assertedBefore =
|
|
871
|
+
findExpectCalls(before.text, before.masked).some((c) => !isBooleanTautology(c) && !deferred.some((d) => d.callOpen < c.index && c.index < d.callClose)) ||
|
|
872
|
+
assertionCalls(before).some((c) => c.kind === 'assert' && !deferred.some((d) => d.callOpen < c.index && c.index < d.callClose)) ||
|
|
873
|
+
[...calledNames(before.masked)].some((n) => assertingHelpers.has(n))
|
|
874
|
+
if (assertedBefore) continue
|
|
875
|
+
// `test.skip(); return` / `t.skip(…); return` / `this.skip(); return`: a runtime skip the
|
|
876
|
+
// runner REPORTS as skipped — not a silent pass (UNLINKED_SKIP governs the skip call).
|
|
877
|
+
if (/(?:\b(?:test|it|t|ctx|context|this)\s*\.\s*skip\s*\([^;{}]*\)\s*;?\s*$)/.test(before.masked)) continue
|
|
878
|
+
const after = masked.slice(m.index)
|
|
879
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
880
|
+
let asserts = EXPECT_CALL_RE.test(after) || ASSERT_CALL_RE.test(after)
|
|
881
|
+
EXPECT_CALL_RE.lastIndex = 0
|
|
882
|
+
if (!asserts) for (const n of calledNames(after)) if (assertingHelpers.has(n)) { asserts = true; break }
|
|
883
|
+
if (asserts) return m.index
|
|
884
|
+
}
|
|
885
|
+
return -1
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** [MOCK_ONLY_ASSERTION] every assertion reads only what the TEST ITSELF got back from a
|
|
889
|
+
* mock it defined (`const f = jest.fn().mockReturnValue(x); expect(f(…)).toBe(x)`) — the
|
|
890
|
+
* code under test is never exercised, so the assertion is the mock echoing its setup. */
|
|
891
|
+
export function isMockOnlyAssertion(block) {
|
|
892
|
+
const masked = block.bodyMasked
|
|
893
|
+
const mocks = new Set()
|
|
894
|
+
for (const m of masked.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:jest|vi|vitest)\s*\.\s*fn\b|\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*sinon\s*\.\s*(?:stub|fake|spy)\b/g)) mocks.add(m[1] || m[2])
|
|
895
|
+
if (!mocks.size) return false
|
|
896
|
+
const alt = [...mocks].map((n) => n.replace(/\$/g, '\\$')).join('|')
|
|
897
|
+
const results = new Set()
|
|
898
|
+
for (const m of masked.matchAll(new RegExp(`\\b(?:const|let|var)\\s+(\\{[^}]*\\}|[A-Za-z_$][\\w$]*)\\s*(?::[^=]+)?=\\s*(?:await\\s+)?(?:${alt})\\s*\\(`, 'g'))) {
|
|
899
|
+
if (m[1].startsWith('{')) for (const part of m[1].slice(1, -1).split(',')) { const n = part.split(':').pop().trim(); if (n) results.add(n) }
|
|
900
|
+
else results.add(m[1])
|
|
901
|
+
}
|
|
902
|
+
const calls = assertionCalls(block)
|
|
903
|
+
if (!calls.length) return false
|
|
904
|
+
const direct = new RegExp(`^(?:await\\s+)?(?:${alt})\\s*\\(`)
|
|
905
|
+
return calls.every((c) => {
|
|
906
|
+
const a = stripTypeSyntax(c.argMasked)
|
|
907
|
+
if (direct.test(a)) return true
|
|
908
|
+
const root = /^(?:await\s+)?([A-Za-z_$][\w$]*)/.exec(a)
|
|
909
|
+
return !!root && results.has(root[1])
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** [TYPE_ONLY_ASSERTION] a binding annotated with an inline object type
|
|
914
|
+
* (`const r: { status?: 'ok' } = await f()`) whose only assertions are existence checks
|
|
915
|
+
* on the bare binding: the field the test is about appears only in the TYPE, which is
|
|
916
|
+
* erased at runtime — nothing reads it. */
|
|
917
|
+
export function isTypeOnlyAssertion(block) {
|
|
918
|
+
const masked = block.bodyMasked
|
|
919
|
+
const typed = new Set()
|
|
920
|
+
for (const m of masked.matchAll(/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*:\s*\{/g)) typed.add(m[1])
|
|
921
|
+
if (!typed.size) return false
|
|
922
|
+
const calls = assertionCalls(block)
|
|
923
|
+
if (!calls.length) return false
|
|
924
|
+
const EXIST = new Set(['toBeDefined', 'not.toBeUndefined', 'not.toBeNull', 'toBeTruthy'])
|
|
925
|
+
return calls.every((c) => {
|
|
926
|
+
const a = stripTypeSyntax(c.argMasked).trim()
|
|
927
|
+
if (!typed.has(a)) return false
|
|
928
|
+
if (c.kind === 'expect') return EXIST.has(c.chain)
|
|
929
|
+
return c.method === '' || c.method === 'ok'
|
|
930
|
+
})
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** [KEY_PRESENCE_ONLY] every assertion checks only that a QUOTED key exists
|
|
934
|
+
* (`toHaveProperty('k')` with no value, `expect(Object.keys(x)).toContain('k')`,
|
|
935
|
+
* `'k' in x`) — the value under that key is never read, so any value (null, the wrong
|
|
936
|
+
* status, an error shape) passes. */
|
|
937
|
+
export function isKeyPresenceOnly(block) {
|
|
938
|
+
const calls = assertionCalls(block)
|
|
939
|
+
if (!calls.length) return false
|
|
940
|
+
const STR = /^(['"`])[^'"`]*\1$/
|
|
941
|
+
return calls.every((c) => {
|
|
942
|
+
if (c.kind === 'assert') return (c.method === '' || c.method === 'ok') && /^(['"])[^'"]*\1\s+in\s+/.test(c.arg) && !/,/.test(c.argMasked)
|
|
943
|
+
if (c.chain === 'toHaveProperty' && c.matcherArg && STR.test(c.matcherArg)) return true
|
|
944
|
+
if (/^Object\s*\.\s*(?:keys|getOwnPropertyNames)\s*\(/.test(c.argMasked) && ['toContain', 'toContainEqual'].includes(c.chain) && c.matcherArg && STR.test(c.matcherArg)) return true
|
|
945
|
+
if (/^(['"])[^'"]*\1\s+in\s+/.test(c.arg) && (c.chain === 'toBeTruthy' || (c.chain === 'toBe' && c.matcherArg === 'true'))) return true
|
|
946
|
+
return false
|
|
947
|
+
})
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** [CONDITIONAL_SKIP] a skip used as a VALUE — `(cond ? describe : describe.skip)(…)`,
|
|
951
|
+
* `const d = ok ? it : xit`, `describe.skipIf(c)`, `it.runIf(c)`, `test.skip.each` —
|
|
952
|
+
* the suite silently vanishes whenever the condition flips (e.g. in CI). Same
|
|
953
|
+
* SKIP-LEDGER linkage as a plain skip. Returns masked-source offsets. */
|
|
954
|
+
const CONDITIONAL_SKIP_RE = /\b(?:describe|it|test)\s*\.\s*skip\b(?!\s*\()|(?<![.\w$])(?:xdescribe|xit|xtest)\b(?!\s*\()|\b(?:describe|it|test)\s*\.\s*(?:skipIf|runIf)\s*\(/g
|
|
955
|
+
export function conditionalSkipOffsets(masked) {
|
|
956
|
+
const out = []
|
|
957
|
+
CONDITIONAL_SKIP_RE.lastIndex = 0
|
|
958
|
+
let m
|
|
959
|
+
while ((m = CONDITIONAL_SKIP_RE.exec(masked)) !== null) {
|
|
960
|
+
out.push(m.index)
|
|
961
|
+
}
|
|
962
|
+
return out
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
966
|
+
// Orchestration
|
|
967
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
968
|
+
export function findVacuousSpecs(relPath, source, assertingHelpers = null) {
|
|
969
|
+
const violations = []
|
|
970
|
+
const helpers = assertingHelpers ?? (() => { try { return assertingHelpersOf(relPath, source) } catch { return new Set() } })()
|
|
971
|
+
const ruleTesters = ruleTesterNames(source)
|
|
972
|
+
const srcMasked = maskStringsAndComments(source)
|
|
973
|
+
for (const at of conditionalSkipOffsets(srcMasked)) {
|
|
974
|
+
const line = lineAt(source, at)
|
|
975
|
+
if (!skipLacksLedgerEntry(source, line)) continue
|
|
976
|
+
violations.push({
|
|
977
|
+
file: relPath,
|
|
978
|
+
line,
|
|
979
|
+
rule: 'CONDITIONAL_SKIP',
|
|
980
|
+
message:
|
|
981
|
+
'a skip used as a value (`cond ? describe : describe.skip`, `skipIf`, `runIf`, `xit` as a ' +
|
|
982
|
+
'variable) — whenever the condition flips (e.g. in CI) the suite silently disappears and ' +
|
|
983
|
+
'the run stays green. FIX: run it unconditionally, or add `// SKIP-LEDGER: <ref>` naming ' +
|
|
984
|
+
'why and where that decision is recorded.',
|
|
985
|
+
})
|
|
986
|
+
}
|
|
987
|
+
for (const block of findTestBlocks(source)) {
|
|
988
|
+
if (block.kind === 'skip') {
|
|
989
|
+
if (skipLacksLedgerEntry(source, block.line)) {
|
|
990
|
+
violations.push({
|
|
991
|
+
file: relPath,
|
|
992
|
+
line: block.line,
|
|
993
|
+
rule: 'UNLINKED_SKIP',
|
|
994
|
+
message:
|
|
995
|
+
`skipped test with no SKIP-LEDGER: tag. FIX: add ` +
|
|
996
|
+
`\`// SKIP-LEDGER: <doc path, work-order id, or ledger id>\` on this line ` +
|
|
997
|
+
`or within the 3 lines above it, naming why this is skipped and where that ` +
|
|
998
|
+
`decision is recorded.`,
|
|
999
|
+
})
|
|
1000
|
+
}
|
|
1001
|
+
continue
|
|
1002
|
+
}
|
|
1003
|
+
if (isNoAssertion(block, helpers, ruleTesters)) {
|
|
1004
|
+
violations.push({
|
|
1005
|
+
file: relPath,
|
|
1006
|
+
line: block.line,
|
|
1007
|
+
rule: 'NO_ASSERTION',
|
|
1008
|
+
message: 'test body calls no expect(...)/assert(...) — it can never fail. FIX: assert the behaviour this test claims to cover, or remove it.',
|
|
1009
|
+
})
|
|
1010
|
+
continue
|
|
1011
|
+
}
|
|
1012
|
+
if (isAllTautological(block)) {
|
|
1013
|
+
violations.push({
|
|
1014
|
+
file: relPath,
|
|
1015
|
+
line: block.line,
|
|
1016
|
+
rule: 'TAUTOLOGICAL',
|
|
1017
|
+
message:
|
|
1018
|
+
'every assertion in this test is tautological (a literal compared to itself, or ' +
|
|
1019
|
+
'toBeDefined() on a value the test just constructed) — it cannot fail regardless of ' +
|
|
1020
|
+
'the code under test. FIX: assert against the real output of the unit under test.',
|
|
1021
|
+
})
|
|
1022
|
+
continue
|
|
1023
|
+
}
|
|
1024
|
+
if (hasSwallowedCatch(block)) {
|
|
1025
|
+
violations.push({
|
|
1026
|
+
file: relPath,
|
|
1027
|
+
line: block.line,
|
|
1028
|
+
rule: 'SWALLOWED_CATCH',
|
|
1029
|
+
message:
|
|
1030
|
+
'a try/catch around a call swallows the error with no assertion in the catch body — ' +
|
|
1031
|
+
'if the unit under test throws, this test still passes. FIX: assert on the caught ' +
|
|
1032
|
+
'error (expect(...).rejects/toThrow, or assert its shape), or remove the catch and let ' +
|
|
1033
|
+
'a real failure fail the test.',
|
|
1034
|
+
})
|
|
1035
|
+
continue
|
|
1036
|
+
}
|
|
1037
|
+
const idiom = idiomViolation(source, block, helpers)
|
|
1038
|
+
if (idiom) violations.push({ file: relPath, line: block.line, ...idiom })
|
|
1039
|
+
}
|
|
1040
|
+
return violations
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** The idiom rules, in order; the first that fires is reported (one rule per block). */
|
|
1044
|
+
function idiomViolation(source, block, helpers) {
|
|
1045
|
+
const er = earlyReturnIndex(block, helpers)
|
|
1046
|
+
if (er !== -1 && skipLacksLedgerEntry(source, lineAt(source, block.bodyStart + er))) {
|
|
1047
|
+
return { rule: 'EARLY_RETURN', message: `a bare \`return\` at test level (line ${lineAt(source, block.bodyStart + er)}) comes before the assertions — on that path the test passes having asserted nothing. FIX: assert on both paths, make the precondition itself an assertion, or add \`// SKIP-LEDGER: <ref>\` if it is a recorded skip.` }
|
|
1048
|
+
}
|
|
1049
|
+
if (hasDoneBeforeAsyncAssertion(block)) {
|
|
1050
|
+
return { rule: 'DONE_EARLY', message: 'done() is called synchronously while an assertion sits in a promise/timer callback — jest ends the test at done(), so that assertion can never fail it. FIX: make the test async and await the promise, or call done() inside the callback after the assertion.' }
|
|
1051
|
+
}
|
|
1052
|
+
if (hasFloatingAsyncAssertion(block)) {
|
|
1053
|
+
return { rule: 'PROMISE_NOT_AWAITED', message: 'an assertion lives in a .then/.catch/.finally (or timer) callback whose promise the test never awaits or returns — the test finishes before the assertion runs. FIX: `await` (or `return`) the chain, or assert on `await`ed values.' }
|
|
1054
|
+
}
|
|
1055
|
+
if (isMockOnlyAssertion(block)) {
|
|
1056
|
+
return { rule: 'MOCK_ONLY_ASSERTION', message: 'every assertion reads only what a mock this test defined returned when the test itself called it — the unit under test is never exercised. FIX: call the real unit (inject the mock as its dependency) and assert on its output.' }
|
|
1057
|
+
}
|
|
1058
|
+
if (isTypeOnlyAssertion(block)) {
|
|
1059
|
+
return { rule: 'TYPE_ONLY_ASSERTION', message: 'the fields this test is about appear only in a TypeScript type annotation (erased at runtime); the only assertion is an existence check on the whole value. FIX: assert the field values, e.g. expect(r.status).toBe(...).' }
|
|
1060
|
+
}
|
|
1061
|
+
if (isKeyPresenceOnly(block)) {
|
|
1062
|
+
return { rule: 'KEY_PRESENCE_ONLY', message: 'every assertion checks only that a quoted key EXISTS (toHaveProperty(\'k\') / Object.keys(x) toContain(\'k\') / \'k\' in x) — the value is never read, so any value passes. FIX: assert the value, e.g. toHaveProperty(\'k\', expected) or expect(x.k).toBe(...).' }
|
|
1063
|
+
}
|
|
1064
|
+
return null
|
|
1065
|
+
}
|
|
1066
|
+
|