universal-dev-standards 6.14.0-beta.4 → 6.14.0-beta.5

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.
Files changed (41) hide show
  1. package/bin/uds.js +7 -1
  2. package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
  3. package/bundled/core/full-coverage-testing.md +57 -3
  4. package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
  5. package/bundled/locales/zh-CN/README.md +1 -1
  6. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  7. package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
  8. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
  9. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
  10. package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
  11. package/bundled/locales/zh-TW/README.md +1 -1
  12. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  13. package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
  14. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
  15. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
  16. package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
  17. package/bundled/templates/gates/check-stubs.mjs +644 -0
  18. package/package.json +2 -2
  19. package/src/commands/audit.js +11 -0
  20. package/src/commands/check.js +124 -24
  21. package/src/commands/init.js +16 -0
  22. package/src/commands/update.js +180 -19
  23. package/src/core/install-records.js +2 -1
  24. package/src/i18n/messages.js +50 -9
  25. package/src/reconciler/backup-manager.js +418 -82
  26. package/src/reconciler/index.js +27 -5
  27. package/src/reconciler/install-roots.js +90 -0
  28. package/src/reconciler/plan-executor.js +23 -2
  29. package/src/uninstallers/hook-uninstaller.js +2 -1
  30. package/src/utils/command-hash-ownership.js +103 -0
  31. package/src/utils/copier.js +21 -1
  32. package/src/utils/gate-scripts.js +141 -0
  33. package/src/utils/health-scorer.js +10 -7
  34. package/src/utils/skill-hash-ownership.js +64 -0
  35. package/src/utils/skills-installer.js +12 -1
  36. package/src/utils/test-change-check.js +160 -0
  37. package/src/utils/test-policy.js +214 -0
  38. package/src/utils/update-summary.js +29 -0
  39. package/standards-registry.json +7 -7
  40. package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
  41. package/bundled/extensions/languages/php/php-style.md +0 -693
@@ -0,0 +1,991 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: MIT
3
+ //
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // check-anti-fake-tests —— 假測試掃描(UDS full-coverage-testing 的出貨閘門)
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ //
8
+ // What it finds, in the test files of any project (no test framework is assumed):
9
+ // no-assertion a test whose body contains nothing that can fail
10
+ // tautology a test whose only assertions can never fail (expect(true).toBe(true))
11
+ // all-skipped a test file in which every test is skipped or marked todo
12
+ //
13
+ // Written into your project by `uds init` (XSPEC-444 R5). It is YOURS: edit it, or replace
14
+ // it. `uds update` never overwrites it. `uds check` (which your pre-commit hook runs) calls
15
+ // it and prints what it finds as a WARNING; it does not stop your commit unless you set
16
+ // "mode": "block" in .standards/test-policy.json.
17
+ //
18
+ // Usage:
19
+ // node scripts/check-anti-fake-tests.mjs scan every test file under the project
20
+ // node scripts/check-anti-fake-tests.mjs --staged scan only the test files staged for commit
21
+ // node scripts/check-anti-fake-tests.mjs --json machine-readable result
22
+ //
23
+ // Exit codes: 0 nothing found | 1 findings (run on its own, this is a hard failure —
24
+ // wire it into CI to enforce) | 2 could not judge (the scanner's own self-test failed,
25
+ // or git was unreadable in --staged mode). 2 is never a pass.
26
+ //
27
+ // Which files are tests, and which extensions are code, comes from `.standards/test-policy.json`
28
+ // (optional; see the UDS docs). Test files in a language this script has no rule for are
29
+ // LISTED as "not scanned" — never counted as clean.
30
+ //
31
+ // Extra "this call is an assertion" names for your own helpers:
32
+ // .standards/test-policy.json → "assertionPatterns": ["\\bmustMatch\\w*\\s*\\("]
33
+ //
34
+ // Heuristic by nature: it reads text, it does not run your tests. A helper that asserts
35
+ // under a name this script cannot recognise will be reported as "no-assertion" — name it
36
+ // assert*/verify*/expect*, or list it in assertionPatterns.
37
+
38
+ import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
39
+ import { join } from 'node:path';
40
+ import { execFileSync } from 'node:child_process';
41
+ import { fileURLToPath } from 'node:url';
42
+
43
+ // ---- BEGIN SHARED POLICY (identical in templates/gates/check-anti-fake-tests.mjs and check-stubs.mjs; test-policy-drift.test.js compares them) ----
44
+ /** Where an adopter's policy lives (inside `.standards/`, beside release-config.yaml). */
45
+ export const POLICY_FILE = '.standards/test-policy.json';
46
+
47
+ export const DEFAULT_POLICY = Object.freeze({
48
+ mode: 'warn',
49
+ testDirs: Object.freeze(['test', 'tests', '__tests__', 'spec', 'e2e']),
50
+ testPatterns: Object.freeze([
51
+ '*.test.*', '*.spec.*', '*_test.*', '*_spec.*', 'test_*.*', 'conftest.py',
52
+ '*Test.*', '*Tests.*', '*Spec.scala', '*Spec.kt', '*Spec.groovy'
53
+ ]),
54
+ sourceExtensions: Object.freeze([
55
+ 'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'vue', 'svelte', 'astro',
56
+ 'py', 'pyi', 'java', 'kt', 'kts', 'scala', 'groovy', 'go', 'rs', 'rb', 'php', 'cs', 'fs', 'vb',
57
+ 'swift', 'm', 'mm', 'dart', 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh',
58
+ 'ex', 'exs', 'erl', 'hrl', 'lua', 'pl', 'pm', 'r', 'jl', 'clj', 'cljs', 'hs', 'ml', 'mli',
59
+ 'sh', 'bash', 'zsh', 'ps1'
60
+ ]),
61
+ nonCodeExtensions: Object.freeze([
62
+ 'md', 'mdx', 'txt', 'rst', 'adoc', 'json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'ini', 'cfg',
63
+ 'conf', 'properties', 'env', 'lock', 'xml', 'csv', 'tsv', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'ico',
64
+ 'webp', 'avif', 'pdf', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'map', 'html', 'htm', 'css', 'scss',
65
+ 'sass', 'less', 'sql', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'nvmrc', 'license',
66
+ 'log', 'snap'
67
+ ]),
68
+ nonCodeNames: Object.freeze([
69
+ 'LICENSE', 'README', 'CHANGELOG', 'NOTICE', 'AUTHORS', 'CODEOWNERS', 'Dockerfile', 'Makefile', 'Procfile'
70
+ ]),
71
+ ignoreDirs: Object.freeze([
72
+ 'node_modules', 'vendor', 'dist', 'build', 'coverage', '.git', '.standards', '.claude', '.husky',
73
+ '.github', 'docs', 'target', '__pycache__', '.venv', 'venv', '.next', '.nuxt', '.idea', '.vscode',
74
+ '.ruff_cache', '.pytest_cache', '.mypy_cache', '.tox', '.gradle', '.cache', '.turbo', '.svelte-kit',
75
+ '.parcel-cache', '.terraform', '.dart_tool', '.eggs'
76
+ ]),
77
+ ignore: Object.freeze(['**/*.min.js', '**/*.min.css', '**/*.d.ts']),
78
+ exempt: Object.freeze([])
79
+ });
80
+
81
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '');
82
+
83
+ /** Glob → RegExp. `*` stays inside a path segment, `**` crosses them, `?` is one non-slash character. */
84
+ export function globToRegex(glob) {
85
+ const g = norm(glob);
86
+ let re = '';
87
+ for (let i = 0; i < g.length; i++) {
88
+ const c = g[i];
89
+ if (c === '*') {
90
+ if (g[i + 1] === '*') {
91
+ if (g[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
92
+ } else {
93
+ re += '[^/]*';
94
+ }
95
+ } else if (c === '?') {
96
+ re += '[^/]';
97
+ } else if ('\\^$+.()|{}[]'.includes(c)) {
98
+ re += '\\' + c;
99
+ } else {
100
+ re += c;
101
+ }
102
+ }
103
+ return new RegExp(`^${re}$`);
104
+ }
105
+
106
+ /** A glob with no slash is a file-name glob (matches the basename anywhere); otherwise it matches the whole path. */
107
+ function matchesGlob(relPath, glob) {
108
+ const p = norm(relPath);
109
+ const g = norm(glob);
110
+ const target = g.includes('/') ? p : p.split('/').pop();
111
+ return globToRegex(g).test(target);
112
+ }
113
+
114
+ const extOf = (relPath) => {
115
+ const base = norm(relPath).split('/').pop();
116
+ const i = base.lastIndexOf('.');
117
+ return i > 0 ? base.slice(i + 1).toLowerCase() : '';
118
+ };
119
+
120
+ /**
121
+ * Read `.standards/test-policy.json` and merge it over the defaults.
122
+ * @returns {{ policy: object, problems: string[], source: string|null }}
123
+ * `problems` is never swallowed: an unreadable file or a bad value is reported, and the
124
+ * defaults (mode "warn") are what runs.
125
+ */
126
+ export function loadPolicy(projectPath) {
127
+ const problems = [];
128
+ const policy = {
129
+ mode: DEFAULT_POLICY.mode,
130
+ testDirs: [...DEFAULT_POLICY.testDirs],
131
+ testPatterns: [...DEFAULT_POLICY.testPatterns],
132
+ sourceExtensions: [...DEFAULT_POLICY.sourceExtensions],
133
+ nonCodeExtensions: [...DEFAULT_POLICY.nonCodeExtensions],
134
+ nonCodeNames: [...DEFAULT_POLICY.nonCodeNames],
135
+ ignoreDirs: [...DEFAULT_POLICY.ignoreDirs],
136
+ ignore: [...DEFAULT_POLICY.ignore],
137
+ exempt: []
138
+ };
139
+ const file = join(projectPath, POLICY_FILE);
140
+ if (!existsSync(file)) return { policy, problems, source: null };
141
+
142
+ let raw;
143
+ try {
144
+ raw = JSON.parse(readFileSync(file, 'utf-8').replace(/^\uFEFF/, ''));
145
+ } catch (e) {
146
+ problems.push(`${POLICY_FILE} cannot be read (${e.message}); defaults are in effect`);
147
+ return { policy, problems, source: POLICY_FILE };
148
+ }
149
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
150
+ problems.push(`${POLICY_FILE} must be a JSON object; defaults are in effect`);
151
+ return { policy, problems, source: POLICY_FILE };
152
+ }
153
+
154
+ if (raw.mode !== undefined) {
155
+ if (raw.mode === 'warn' || raw.mode === 'block') policy.mode = raw.mode;
156
+ else problems.push(`${POLICY_FILE}: "mode" must be "warn" or "block", got ${JSON.stringify(raw.mode)}; "warn" is in effect`);
157
+ }
158
+ for (const key of ['testDirs', 'testPatterns', 'sourceExtensions', 'nonCodeExtensions', 'ignore']) {
159
+ if (raw[key] === undefined) continue;
160
+ if (!Array.isArray(raw[key]) || raw[key].some((v) => typeof v !== 'string' || !v)) {
161
+ problems.push(`${POLICY_FILE}: "${key}" must be an array of non-empty strings; it is ignored`);
162
+ continue;
163
+ }
164
+ const add = key === 'sourceExtensions' || key === 'nonCodeExtensions'
165
+ ? raw[key].map((v) => v.replace(/^\./, '').toLowerCase())
166
+ : raw[key];
167
+ policy[key] = [...policy[key], ...add];
168
+ }
169
+ if (raw.exempt !== undefined) {
170
+ if (!Array.isArray(raw.exempt)) {
171
+ problems.push(`${POLICY_FILE}: "exempt" must be an array; it is ignored`);
172
+ } else {
173
+ raw.exempt.forEach((e, i) => {
174
+ const okPattern = e && typeof e.pattern === 'string' && e.pattern.trim();
175
+ const okReason = e && typeof e.reason === 'string' && e.reason.trim();
176
+ if (!okPattern) problems.push(`${POLICY_FILE}: exempt[${i}] has no "pattern"; it is ignored`);
177
+ else if (!okReason) problems.push(`${POLICY_FILE}: exempt[${i}] ("${e.pattern}") has no "reason" and is NOT honored — an exemption must say why`);
178
+ else policy.exempt.push({ pattern: e.pattern, reason: e.reason.trim() });
179
+ });
180
+ }
181
+ }
182
+ return { policy, problems, source: POLICY_FILE };
183
+ }
184
+
185
+ /**
186
+ * Classify one project-relative path.
187
+ * @returns {{ kind: 'ignored'|'noncode'|'test'|'source'|'unclassified', ext: string, exempt?: {pattern:string, reason:string} }}
188
+ */
189
+ export function classifyPath(relPath, policy = DEFAULT_POLICY) {
190
+ const p = norm(relPath);
191
+ const segments = p.split('/');
192
+ const base = segments[segments.length - 1];
193
+ const dirs = segments.slice(0, -1);
194
+ const ext = extOf(p);
195
+ const sets = {
196
+ source: new Set(policy.sourceExtensions || DEFAULT_POLICY.sourceExtensions),
197
+ noncode: new Set(policy.nonCodeExtensions || DEFAULT_POLICY.nonCodeExtensions),
198
+ names: new Set(policy.nonCodeNames || DEFAULT_POLICY.nonCodeNames),
199
+ ignoreDirs: new Set(policy.ignoreDirs || DEFAULT_POLICY.ignoreDirs)
200
+ };
201
+
202
+ if (dirs.some((d) => sets.ignoreDirs.has(d))) return { kind: 'ignored', ext };
203
+ if ((policy.ignore || DEFAULT_POLICY.ignore).some((g) => matchesGlob(p, g))) return { kind: 'ignored', ext };
204
+
205
+ const docsLike = sets.noncode.has(ext) || sets.names.has(base) || sets.names.has(base.replace(/\.[^.]*$/, ''));
206
+ const inTestDir = dirs.some((d) => (policy.testDirs || DEFAULT_POLICY.testDirs).includes(d));
207
+ const testNamed = (policy.testPatterns || DEFAULT_POLICY.testPatterns).some((g) => matchesGlob(p, g));
208
+
209
+ // A test is a test even when its data is JSON; but a README inside tests/ is documentation.
210
+ if ((inTestDir || testNamed) && !['md', 'mdx', 'txt', 'rst', 'adoc'].includes(ext)) return { kind: 'test', ext };
211
+ // Dot-files (.prettierrc, .eslintrc.js, .env.local ...) are tool configuration, not product code.
212
+ if (docsLike || base.startsWith('.')) return { kind: 'noncode', ext };
213
+ if (sets.source.has(ext)) {
214
+ const exempt = (policy.exempt || []).find((e) => matchesGlob(p, e.pattern));
215
+ return exempt ? { kind: 'source', ext, exempt } : { kind: 'source', ext };
216
+ }
217
+ return { kind: 'unclassified', ext };
218
+ }
219
+ // ---- END SHARED POLICY ----
220
+
221
+ // ---- BEGIN SHARED ENGINE (identical in check-anti-fake-tests.mjs and check-stubs.mjs; test-policy-drift.test.js compares them) ----
222
+
223
+ /**
224
+ * Language families this script has rules for, keyed by file extension.
225
+ * An extension that is not here is UNSUPPORTED: the script says so by name. It never
226
+ * counts a file it cannot read as a file that passed.
227
+ */
228
+ export const FAMILY_BY_EXT = Object.freeze({
229
+ js: 'js', mjs: 'js', cjs: 'js', jsx: 'js', ts: 'js', tsx: 'js', mts: 'js', cts: 'js', vue: 'js', svelte: 'js',
230
+ py: 'python', pyi: 'python',
231
+ java: 'jvm', kt: 'jvm', kts: 'jvm', scala: 'jvm', groovy: 'jvm', cs: 'jvm',
232
+ go: 'go', rs: 'rust', rb: 'ruby', ex: 'elixir', exs: 'elixir',
233
+ php: 'php', swift: 'swift', dart: 'dart', lua: 'lua',
234
+ c: 'cpp', h: 'cpp', cc: 'cpp', cpp: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp'
235
+ });
236
+
237
+ const LEX = {
238
+ js: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"', '`'], triple: [], regex: true },
239
+ python: { line: ['#'], block: [], quotes: ['\'', '"'], triple: ['"""', '\'\'\''] },
240
+ jvm: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: ['"""'] },
241
+ go: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"', '`'], triple: [] },
242
+ rust: { line: ['//'], block: [['/*', '*/']], quotes: ['"', '\''], triple: [], rustChar: true },
243
+ ruby: { line: ['#'], block: [], quotes: ['\'', '"'], triple: [] },
244
+ elixir: { line: ['#'], block: [], quotes: ['"', '\''], triple: ['"""'] },
245
+ php: { line: ['//', '#'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: [], phpAttr: true },
246
+ swift: { line: ['//'], block: [['/*', '*/']], quotes: ['"'], triple: ['"""'] },
247
+ dart: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: ['"""', '\'\'\''] },
248
+ lua: { line: ['--'], block: [['--[[', ']]']], quotes: ['\'', '"'], triple: [] },
249
+ cpp: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: [] },
250
+ other: { line: ['//', '#'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: [] }
251
+ };
252
+
253
+ /**
254
+ * Same-length copies of `text`: `noComments` (comments blanked) and `noStrings` (comments
255
+ * AND the inside of string literals blanked). Same length means an index found in one is
256
+ * the same place in the other and in the original — which is how names are read back.
257
+ */
258
+ export function maskSource(text, family) {
259
+ const lex = LEX[family] || LEX.other;
260
+ const nc = text.split('');
261
+ const ns = text.split('');
262
+ const n = text.length;
263
+ const blank = (arr, from, to) => { for (let k = from; k < to; k++) if (arr[k] !== '\n') arr[k] = ' '; };
264
+ let i = 0;
265
+ while (i < n) {
266
+ let hit = false;
267
+ for (const [open, close] of lex.block) {
268
+ if (text.startsWith(open, i)) {
269
+ const end = text.indexOf(close, i + open.length);
270
+ const stop = end === -1 ? n : end + close.length;
271
+ blank(nc, i, stop); blank(ns, i, stop);
272
+ i = stop; hit = true; break;
273
+ }
274
+ }
275
+ if (hit) continue;
276
+ for (const lc of lex.line) {
277
+ if (text.startsWith(lc, i) && !(lex.phpAttr && lc === '#' && text[i + 1] === '[')) {
278
+ let end = text.indexOf('\n', i);
279
+ if (end === -1) end = n;
280
+ blank(nc, i, end); blank(ns, i, end);
281
+ i = end; hit = true; break;
282
+ }
283
+ }
284
+ if (hit) continue;
285
+ const triple = lex.triple.find((t) => text.startsWith(t, i));
286
+ if (triple) {
287
+ const end = text.indexOf(triple, i + 3);
288
+ // The delimiters go too: a closing `"""` left at column 0 would end an indented block early.
289
+ const stop = end === -1 ? n : end + 3;
290
+ blank(ns, i, stop);
291
+ i = stop;
292
+ continue;
293
+ }
294
+ const ch = text[i];
295
+ if (lex.regex && ch === '/') {
296
+ // A regex literal may hold a quote or a backtick; read it as one token so it cannot open a string.
297
+ let p = i - 1;
298
+ while (p >= 0 && /[ \t]/.test(text[p])) p--;
299
+ const before = p < 0 ? '' : text[p];
300
+ const word = /(?:^|[^\w$])(return|typeof|case|in|of|void|delete|throw|yield|await)$/.test(text.slice(Math.max(0, p - 9), p + 1));
301
+ if (p < 0 || '(,=:[!&|?{};+-*%<>~^\n'.includes(before) || word) {
302
+ let j = i + 1;
303
+ let inClass = false;
304
+ while (j < n) {
305
+ const c = text[j];
306
+ if (c === '\\') { j += 2; continue; }
307
+ if (c === '\n') { j = -1; break; }
308
+ if (c === '[') inClass = true;
309
+ else if (c === ']') inClass = false;
310
+ else if (c === '/' && !inClass) break;
311
+ j++;
312
+ }
313
+ if (j > i && j < n) { blank(ns, i + 1, j); i = j + 1; continue; }
314
+ }
315
+ }
316
+ if (lex.quotes.includes(ch)) {
317
+ if (lex.rustChar && ch === '\'') {
318
+ const isChar = text[i + 2] === '\'' || (text[i + 1] === '\\' && text[i + 3] === '\'');
319
+ if (!isChar) { i++; continue; }
320
+ }
321
+ const multiline = ch === '`';
322
+ let j = i + 1;
323
+ while (j < n) {
324
+ if (text[j] === '\\') { j += 2; continue; }
325
+ if (text[j] === ch) break;
326
+ if (text[j] === '\n' && !multiline) break;
327
+ j++;
328
+ }
329
+ blank(ns, i + 1, Math.min(j, n));
330
+ i = j + 1;
331
+ continue;
332
+ }
333
+ i++;
334
+ }
335
+ return { noComments: nc.join(''), noStrings: ns.join('') };
336
+ }
337
+
338
+ /** Index of the bracket that closes the one at `open` (counting only that bracket pair), or -1. */
339
+ export function matchClose(s, open) {
340
+ const pairs = { '(': ')', '{': '}', '[': ']' };
341
+ const o = s[open];
342
+ const c = pairs[o];
343
+ if (!c) return -1;
344
+ let depth = 0;
345
+ for (let i = open; i < s.length; i++) {
346
+ if (s[i] === o) depth++;
347
+ else if (s[i] === c && --depth === 0) return i;
348
+ }
349
+ return -1;
350
+ }
351
+
352
+ /** A function that maps a character index to its 1-based line number. */
353
+ export function lineIndex(text) {
354
+ const starts = [0];
355
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
356
+ return (idx) => {
357
+ let lo = 0; let hi = starts.length - 1;
358
+ while (lo < hi) {
359
+ const mid = (lo + hi + 1) >> 1;
360
+ if (starts[mid] <= idx) lo = mid; else hi = mid - 1;
361
+ }
362
+ return lo + 1;
363
+ };
364
+ }
365
+
366
+ /** Every project-relative file under `root`, skipping the policy's ignored directories and symlinks. */
367
+ export function walkFiles(root, policy) {
368
+ const skip = new Set(policy.ignoreDirs);
369
+ const out = [];
370
+ const stack = [''];
371
+ while (stack.length > 0 && out.length < 200000) {
372
+ const rel = stack.pop();
373
+ let entries;
374
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { continue; }
375
+ for (const e of entries) {
376
+ const r = rel ? `${rel}/${e.name}` : e.name;
377
+ if (e.isSymbolicLink()) continue;
378
+ if (e.isDirectory()) { if (!skip.has(e.name)) stack.push(r); } else if (e.isFile()) out.push(r);
379
+ }
380
+ }
381
+ return out.sort();
382
+ }
383
+
384
+ /** Files staged for the next commit (added, copied, modified, renamed), or `{ error }`. */
385
+ export function stagedFiles(root) {
386
+ try {
387
+ const out = execFileSync('git', ['-c', 'core.quotepath=off', 'diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'], {
388
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024
389
+ });
390
+ return { files: out.split('\0').filter(Boolean) };
391
+ } catch (e) {
392
+ return { error: String(e.stderr || e.message || 'git diff failed').trim().split('\n')[0] };
393
+ }
394
+ }
395
+
396
+ /** File text, or `{ skipped }` when it is binary or larger than 2 MB (said, never silently dropped). */
397
+ export function readText(abs) {
398
+ try {
399
+ if (lstatSync(abs).size > 2 * 1024 * 1024) return { skipped: 'larger than 2 MB' };
400
+ const buf = readFileSync(abs);
401
+ if (buf.subarray(0, 8000).includes(0)) return { skipped: 'binary', quiet: true };
402
+ return { text: buf.toString('utf8').replace(/^\uFEFF/, '') };
403
+ } catch (e) {
404
+ return { skipped: `unreadable (${e.code || e.message})` };
405
+ }
406
+ }
407
+
408
+ export function extOfPath(p) {
409
+ const base = String(p).replace(/\\/g, '/').split('/').pop();
410
+ const i = base.lastIndexOf('.');
411
+ return i > 0 ? base.slice(i + 1).toLowerCase() : '';
412
+ }
413
+
414
+ /** Is this module the one being run (not imported)? Resolves symlinks so `node scripts/x.mjs` and a symlinked path agree. */
415
+ export function isMain(metaUrl) {
416
+ try {
417
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(metaUrl));
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ export const indentOf = (l) => l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
424
+
425
+ /** Indented block that follows line `startLine` (0-based index into `lines`): every following line that is blank or indented deeper than `indent`. */
426
+ export function indentBlock(lines, startLine, indent) {
427
+ let end = startLine + 1;
428
+ let lastBody = startLine;
429
+ while (end < lines.length) {
430
+ const l = lines[end];
431
+ if (l.trim() === '') { end++; continue; }
432
+ const ind = indentOf(l);
433
+ if (ind <= indent) break;
434
+ lastBody = end;
435
+ end++;
436
+ }
437
+ return { first: startLine + 1, last: lastBody };
438
+ }
439
+ // ---- END SHARED ENGINE ----
440
+
441
+ // ═══════════════════════════════════════════════════════════════════════════
442
+ // Test-case finders: one per language family. Each returns the cases it can see:
443
+ // { name, idx, bodyStart, bodyEnd, skipped, credit }
444
+ // `bodyStart..bodyEnd` is the text the assertion count is taken from.
445
+ // ═══════════════════════════════════════════════════════════════════════════
446
+
447
+ const nameFrom = (original, from, to) => {
448
+ const m = /(['"`])((?:\\.|(?!\1)[^\\])*)\1/.exec(original.slice(from, to));
449
+ return m ? m[2].trim() : '(unnamed)';
450
+ };
451
+
452
+ /** `it('x', () => {...})`, `test('x', fn)`, `it.skip(...)`, `it.each(rows)('x', fn)`, `test("x") { ... }` */
453
+ function findCallCases(text, ns, nc, { names = 'it|test|specify', trailingBrace = false } = {}) {
454
+ const re = new RegExp(`(?<![.\\w$])(x?(?:${names}))((?:\\.\\w+)*)\\s*(?=\\()`, 'g');
455
+ const cases = [];
456
+ let unparsed = 0;
457
+ let m;
458
+ while ((m = re.exec(ns)) !== null) {
459
+ const mods = m[2].split('.').filter(Boolean);
460
+ let pos = m.index + m[0].length;
461
+ if (mods.includes('each')) {
462
+ const c1 = matchClose(ns, pos);
463
+ if (c1 === -1) { unparsed++; continue; }
464
+ pos = c1 + 1;
465
+ while (/\s/.test(ns[pos] || '')) pos++;
466
+ if (ns[pos] !== '(') continue;
467
+ }
468
+ let close = matchClose(ns, pos);
469
+ if (close === -1) { unparsed++; continue; }
470
+ const args = ns.slice(pos + 1, close);
471
+ const pending = !args.includes(',');
472
+ let bodyEnd = close;
473
+ if (trailingBrace) {
474
+ let k = close + 1;
475
+ while (/\s/.test(ns[k] || '')) k++;
476
+ if (ns[k] === '{') {
477
+ const c = matchClose(ns, k);
478
+ if (c !== -1) bodyEnd = c;
479
+ }
480
+ }
481
+ const skipped = mods.includes('skip') || mods.includes('todo') || (m[1].length > 1 && m[1][0] === 'x') ||
482
+ m[1] === 'pending' || pending || /\bskip\s*:\s*(?!false\b)/.test(args);
483
+ cases.push({
484
+ name: nameFrom(text, pos + 1, close), idx: m.index, bodyStart: pos + 1, bodyEnd, skipped, credit: 0
485
+ });
486
+ re.lastIndex = bodyEnd + 1;
487
+ }
488
+ return { cases, unparsed };
489
+ }
490
+
491
+ const linesAbove = (s, idx, n) => {
492
+ let start = idx;
493
+ for (let k = 0; k <= n && start > 0; k++) start = s.lastIndexOf('\n', start - 1) + 0;
494
+ return s.slice(Math.max(0, start), idx);
495
+ };
496
+
497
+ /** A test marked by an annotation/attribute: `@Test`, `[Fact]`, `#[test]`, `#[Test]`, Swift `@Test` ... */
498
+ function findAnnotationCases(text, ns, nc, annotationRe) {
499
+ const cases = [];
500
+ const re = new RegExp(annotationRe.source, annotationRe.flags.includes('g') ? annotationRe.flags : annotationRe.flags + 'g');
501
+ let m;
502
+ while ((m = re.exec(ns)) !== null) {
503
+ let pos = m.index + m[0].length;
504
+ // step over any further annotations between this one and the declaration
505
+ for (;;) {
506
+ while (/\s/.test(ns[pos] || '')) pos++;
507
+ const rest = ns.slice(pos, pos + 3);
508
+ if (rest[0] === '@') {
509
+ pos++;
510
+ while (/[\w.]/.test(ns[pos] || '')) pos++;
511
+ if (ns[pos] === '(') { const c = matchClose(ns, pos); if (c === -1) break; pos = c + 1; }
512
+ } else if (rest.startsWith('#[')) {
513
+ const c = matchClose(ns, pos + 1);
514
+ if (c === -1) break;
515
+ pos = c + 1;
516
+ } else if (rest[0] === '[' ) {
517
+ const c = matchClose(ns, pos);
518
+ if (c === -1) break;
519
+ pos = c + 1;
520
+ } else break;
521
+ }
522
+ const open = ns.indexOf('(', pos);
523
+ if (open === -1) continue;
524
+ const declText = text.slice(pos, open);
525
+ const nameM = /(?:`([^`]+)`|([\w$]+))\s*(?:<[^>]*>)?\s*$/.exec(declText);
526
+ const name = nameM ? (nameM[1] || nameM[2]) : '(unnamed)';
527
+ const pc = matchClose(ns, open);
528
+ if (pc === -1) continue;
529
+ let k = pc + 1;
530
+ while (k < ns.length && !'{=;'.includes(ns[k])) k++;
531
+ if (k >= ns.length || ns[k] === ';') continue; // abstract / interface: no body
532
+ let bodyStart; let bodyEnd;
533
+ if (ns[k] === '{') {
534
+ bodyStart = k + 1;
535
+ bodyEnd = matchClose(ns, k);
536
+ if (bodyEnd === -1) continue;
537
+ } else {
538
+ bodyStart = k + 1;
539
+ let e = bodyStart;
540
+ while (e < ns.length && !'{;\n'.includes(ns[e])) e++;
541
+ bodyEnd = ns[e] === '{' ? matchClose(ns, e) : e;
542
+ if (bodyEnd === -1) continue;
543
+ }
544
+ const header = linesAbove(nc, m.index, 5) + nc.slice(m.index, open);
545
+ const skipped = /@(?:Ignore|Disabled|Skip)\b|\[(?:Ignore|Skip)\b|#\[ignore\b|\bSkip\s*=|\.disabled\b/.test(header);
546
+ const credit = /\bexpected\s*=|ExpectedException|should_panic/.test(header) ? 1 : 0;
547
+ cases.push({ name, idx: m.index, bodyStart, bodyEnd, skipped, credit });
548
+ re.lastIndex = Math.max(re.lastIndex, bodyStart);
549
+ }
550
+ return { cases, unparsed: 0 };
551
+ }
552
+
553
+ /** `func TestX(t *testing.T) {`, `function testX() {`, `func testX() {` — a named function opens the case. */
554
+ function findFuncCases(text, ns, nc, funcRe, { skipRe } = {}) {
555
+ const cases = [];
556
+ let unparsed = 0;
557
+ const re = new RegExp(funcRe.source, funcRe.flags.includes('g') ? funcRe.flags : funcRe.flags + 'g');
558
+ let m;
559
+ while ((m = re.exec(ns)) !== null) {
560
+ const open = m.index + m[0].length - 1;
561
+ const close = matchClose(ns, open);
562
+ if (close === -1) { unparsed++; continue; }
563
+ const body = ns.slice(open + 1, close);
564
+ cases.push({
565
+ name: m[1], idx: m.index, bodyStart: open + 1, bodyEnd: close,
566
+ skipped: skipRe ? skipRe.test(body) : false, credit: 0
567
+ });
568
+ re.lastIndex = close + 1;
569
+ }
570
+ return { cases, unparsed };
571
+ }
572
+
573
+ function offsetsOf(s) {
574
+ const off = [0];
575
+ for (let i = 0; i < s.length; i++) if (s[i] === '\n') off.push(i + 1);
576
+ return off;
577
+ }
578
+
579
+ function findPythonCases(text, ns, nc) {
580
+ const lines = ns.split('\n');
581
+ const ncLines = nc.split('\n');
582
+ const off = offsetsOf(ns);
583
+ const cases = [];
584
+ for (let li = 0; li < lines.length; li++) {
585
+ const m = /^([ \t]*)(?:async\s+)?def\s+(test\w*)\s*\(/.exec(lines[li]);
586
+ if (!m) continue;
587
+ const indent = indentOf(m[1] + 'x') - 1;
588
+ const open = off[li] + lines[li].indexOf('(');
589
+ const pc = matchClose(ns, open);
590
+ if (pc === -1) continue;
591
+ const colon = ns.indexOf(':', pc);
592
+ if (colon === -1) continue;
593
+ const colonLine = off.findIndex((o, i) => o <= colon && (i === off.length - 1 || off[i + 1] > colon));
594
+ const { first, last } = indentBlock(lines, colonLine, indent);
595
+ const bodyStart = colon + 1;
596
+ const bodyEnd = last >= first ? (off[last + 1] !== undefined ? off[last + 1] - 1 : ns.length) : (off[colonLine + 1] !== undefined ? off[colonLine + 1] - 1 : ns.length);
597
+ let decorators = '';
598
+ for (let k = li - 1; k >= 0 && /^\s*@/.test(ncLines[k]); k--) decorators += ncLines[k] + '\n';
599
+ const body = ns.slice(bodyStart, bodyEnd);
600
+ const skipped = /@(?:pytest\.mark\.skip|unittest\.skip|skip)\b(?!if)/.test(decorators) ||
601
+ /^\s*(?:pytest\.skip|self\.skipTest)\s*\(/.test(body.replace(/^[\s]*/, ''));
602
+ cases.push({ name: m[2], idx: off[li], bodyStart, bodyEnd, skipped, credit: 0 });
603
+ }
604
+ return { cases, unparsed: 0 };
605
+ }
606
+
607
+ function findRubyCases(text, ns, nc) {
608
+ const lines = ns.split('\n');
609
+ const off = offsetsOf(ns);
610
+ const cases = [];
611
+ for (let li = 0; li < lines.length; li++) {
612
+ const line = lines[li];
613
+ let m = /^([ \t]*)def\s+(test_\w+)/.exec(line);
614
+ let name; let kind;
615
+ if (m) { name = m[2]; kind = 'def'; } else {
616
+ m = /^([ \t]*)(x?it|x?specify|x?example|x?scenario|test)\b(?![_?!:\w])\s*(.*)$/.exec(line);
617
+ if (!m) continue;
618
+ const rest = m[3];
619
+ if (!/^\(?\s*['":%]/.test(rest) && !/^(?:do\b|\{)/.test(rest)) continue;
620
+ kind = m[2];
621
+ name = nameFrom(text, off[li], off[li] + line.length);
622
+ }
623
+ const indent = indentOf(m[1] + 'x') - 1;
624
+ const hasDo = /\bdo\b(?:\s*\|[^|]*\|)?\s*$/.test(line);
625
+ const braceAt = line.indexOf('{');
626
+ let bodyStart; let bodyEnd; let skipped = kind.length > 2 && kind[0] === 'x' && kind !== 'example';
627
+ if (kind === 'def' || hasDo) {
628
+ const { first, last } = indentBlock(lines, li, indent);
629
+ bodyStart = off[li] + line.length; bodyEnd = last >= first ? (off[last + 1] !== undefined ? off[last + 1] - 1 : ns.length) : bodyStart;
630
+ } else if (braceAt !== -1) {
631
+ const c = matchClose(ns, off[li] + braceAt);
632
+ bodyStart = off[li] + braceAt + 1; bodyEnd = c === -1 ? off[li] + line.length : c;
633
+ } else { bodyStart = off[li] + line.length; bodyEnd = bodyStart; skipped = true; }
634
+ if (/^\s*(?:skip|pending)\b/.test(ns.slice(bodyStart, bodyEnd).replace(/^[\s]*/, ''))) skipped = true;
635
+ cases.push({ name, idx: off[li], bodyStart, bodyEnd, skipped, credit: 0 });
636
+ }
637
+ return { cases, unparsed: 0 };
638
+ }
639
+
640
+ function findElixirCases(text, ns, nc) {
641
+ const lines = ns.split('\n');
642
+ const ncLines = nc.split('\n');
643
+ const off = offsetsOf(ns);
644
+ const cases = [];
645
+ for (let li = 0; li < lines.length; li++) {
646
+ const m = /^([ \t]*)test\s+"[^"\n]*"(?:\s*,\s*[^\n]*?)?\s+do\s*$/.exec(lines[li]);
647
+ if (!m) continue;
648
+ const indent = indentOf(m[1] + 'x') - 1;
649
+ const { first, last } = indentBlock(lines, li, indent);
650
+ const bodyStart = off[li] + lines[li].length;
651
+ const bodyEnd = last >= first ? (off[last + 1] !== undefined ? off[last + 1] - 1 : ns.length) : bodyStart;
652
+ const above = ncLines.slice(Math.max(0, li - 3), li).join('\n');
653
+ cases.push({ name: nameFrom(text, off[li], off[li] + lines[li].length), idx: off[li], bodyStart, bodyEnd, skipped: /@tag\s+(?::skip|skip:)/.test(above), credit: 0 });
654
+ }
655
+ return { cases, unparsed: 0 };
656
+ }
657
+
658
+ function findCppCases(text, ns, nc) {
659
+ const re = /\b(TEST|TEST_F|TEST_P|TYPED_TEST|TYPED_TEST_P|TEST_CASE|SCENARIO|BOOST_AUTO_TEST_CASE|BOOST_FIXTURE_TEST_CASE|BOOST_DATA_TEST_CASE)\s*\(/g;
660
+ const cases = [];
661
+ let unparsed = 0;
662
+ let m;
663
+ while ((m = re.exec(ns)) !== null) {
664
+ const open = m.index + m[0].length - 1;
665
+ const close = matchClose(ns, open);
666
+ if (close === -1) { unparsed++; continue; }
667
+ let k = close + 1;
668
+ while (/\s/.test(ns[k] || '')) k++;
669
+ if (ns[k] !== '{') continue;
670
+ const end = matchClose(ns, k);
671
+ if (end === -1) { unparsed++; continue; }
672
+ const argsOriginal = text.slice(open + 1, close);
673
+ cases.push({
674
+ name: argsOriginal.replace(/\s+/g, ' ').trim(), idx: m.index, bodyStart: k + 1, bodyEnd: end,
675
+ skipped: /DISABLED_|\[\.\]|\[!hide\]/.test(argsOriginal), credit: 0
676
+ });
677
+ re.lastIndex = end + 1;
678
+ }
679
+ return { cases, unparsed };
680
+ }
681
+
682
+ const LIT = '[\\w.$\'"]+';
683
+ const JS_TOKENS = /\b(?:expect\w*|assert\w*|verify\w*|should\w*)\s*\(|\bfail\s*\(|\.should\b|\bassert\s*\.|\bexpect\s*\.|\bt\.(?:is|not|true|false|truthy|falsy|deepEqual|throws|notThrows|pass|fail|equal|ok|same|match|rejects|snapshot|assert|like|plan)\w*\s*\(/g;
684
+ const JS_TAUT = [
685
+ new RegExp(`\\bexpect\\(\\s*(${LIT})\\s*\\)\\s*\\.\\s*(?:toBe|toEqual|toStrictEqual)\\s*\\(\\s*\\1\\s*\\)`, 'g'),
686
+ /\bexpect\(\s*(?:true|1)\s*\)\s*\.\s*toBeTruthy\s*\(\s*\)/g,
687
+ /\bexpect\(\s*(?:false|0|null|undefined)\s*\)\s*\.\s*toBeFalsy\s*\(\s*\)/g,
688
+ /\bexpect\(\s*true\s*\)\s*\.\s*to\s*\.\s*(?:be\s*\.\s*)?(?:true|ok)\b/g,
689
+ /\bassert(?:\s*\.\s*ok)?\s*\(\s*(?:true|1)\s*[),]/g,
690
+ new RegExp(`\\bassert\\s*\\.\\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*[),]`, 'g')
691
+ ];
692
+ const JVM_TOKENS = /\b(?:assert\w*|Assert\w*|verify\w*|Verify\w*|expect|expectThat|expectThrows|Expect|fail|check|require)\s*[(.]|\b(?:should\w*|must\w*)\b|\.Should\w*\s*[(.]/g;
693
+ const JVM_TAUT = [
694
+ /\bassert(?:True)?\s*\(\s*true\s*[),]/gi,
695
+ /\bAssert\s*\.\s*(?:True|IsTrue)\s*\(\s*true\s*[),]/gi,
696
+ new RegExp(`\\bassert(?:Equals?|Same|AreEqual|Equal)\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*[),]`, 'gi'),
697
+ new RegExp(`\\bAssert\\s*\\.\\s*(?:AreEqual|Equal)\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*[),]`, 'gi')
698
+ ];
699
+
700
+ const FAMILY_RULES = {
701
+ js: {
702
+ tokens: JS_TOKENS, taut: JS_TAUT,
703
+ find: (t, ns, nc) => findCallCases(t, ns, nc)
704
+ },
705
+ python: {
706
+ tokens: /^[ \t]*assert\b|\bself\.(?:assert|fail)\w*\s*\(|\bpytest\.(?:raises|warns|fail)\b|\.assert_\w+\s*\(|\bassert_\w+\s*\(|\braise\s+AssertionError\b|\bexpect\s*\(|\b(?:verify|check)_\w+\s*\(/gm,
707
+ taut: [
708
+ /^[ \t]*assert\s+(?:True|1)\s*(?:,.*)?$/gm,
709
+ new RegExp(`^[ \\t]*assert\\s+(${LIT})\\s*==\\s*\\1\\s*(?:,.*)?$`, 'gm'),
710
+ /\bself\.assertTrue\(\s*(?:True|1)\s*[),]/g,
711
+ new RegExp(`\\bself\\.assertEqual\\(\\s*(${LIT})\\s*,\\s*\\1\\s*[),]`, 'g')
712
+ ],
713
+ find: findPythonCases
714
+ },
715
+ jvm: {
716
+ tokens: JVM_TOKENS, taut: JVM_TAUT,
717
+ find: (t, ns, nc, ext) => {
718
+ const a = findAnnotationCases(t, ns, nc, /(@(?:\w+\.)*(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b(?:\s*\([^)]*\))?|\[(?:Fact|Theory|Test|TestMethod|TestCase|DataTestMethod)\b[^\]]*\])/);
719
+ if (ext === 'scala' || ext === 'kt' || ext === 'kts') {
720
+ const b = findCallCases(t, ns, nc, { names: 'test|it', trailingBrace: true });
721
+ return { cases: [...a.cases, ...b.cases], unparsed: a.unparsed + b.unparsed };
722
+ }
723
+ return a;
724
+ }
725
+ },
726
+ go: {
727
+ tokens: /\bt\.(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow|Run)\b|\b(?:assert|require)\.\w+\s*\(|\bcmp\.(?:Diff|Equal)\s*\(|\bsuite\.\w+|\b\w+\s*\(\s*t\s*[,)]|\bExpect\s*\(|\.Should\s*\(|\bgomega\.\w+/g,
728
+ taut: [
729
+ /\bassert\.True\s*\(\s*t\s*,\s*true\s*\)/g,
730
+ new RegExp(`\\bassert\\.Equal\\s*\\(\\s*t\\s*,\\s*(${LIT})\\s*,\\s*\\1\\s*\\)`, 'g')
731
+ ],
732
+ find: (t, ns, nc) => findFuncCases(t, ns, nc, /^func\s+(Test\w*|Example\w*|Fuzz\w*)\s*\(\s*\w+\s+\*testing\.[TF]\s*\)\s*\{/m, { skipRe: /^\s*t\.Skip\w*\(/ })
733
+ },
734
+ rust: {
735
+ tokens: /\b(?:assert\w*|debug_assert\w*|prop_assert\w*)!|\bpanic!|\bunreachable!|\.unwrap\s*\(|\.expect\s*\(|\bunwrap_err\s*\(|\?\s*;/g,
736
+ taut: [/\bassert!\s*\(\s*true\s*[),]/g, new RegExp(`\\bassert_eq!\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*[),]`, 'g')],
737
+ find: (t, ns, nc) => findAnnotationCases(t, ns, nc, /#\[(?:\w+::)*(?:test|rstest|test_case)\b[^\]]*\]/)
738
+ },
739
+ ruby: {
740
+ tokens: /\b(?:assert\w*|refute\w*|flunk|verify\w*)\b|\bexpect\b|\bis_expected\b|\.should(?:_not)?\b|\bshould(?:_not)?\b|\bmust_\w+|\bwont_\w+/g,
741
+ taut: [
742
+ /\bassert\s*\(?\s*true\s*\)?[ \t]*$/gm,
743
+ /\bassert_equal\s*\(?\s*([\w:'"]+)\s*,\s*\1\s*\)?[ \t]*$/gm,
744
+ /\bexpect\(\s*(?:true|1)\s*\)\.to\s+(?:be_truthy|be\s*\(?\s*true|eq\(\s*(?:true|1)\s*\))/g
745
+ ],
746
+ find: findRubyCases
747
+ },
748
+ elixir: {
749
+ tokens: /\b(?:assert\w*|refute\w*|flunk|catch_\w+)\b/g,
750
+ taut: [/\bassert\s+true\b/g, /\bassert\s+([\w:'"]+)\s*==\s*\1\b/g],
751
+ find: findElixirCases
752
+ },
753
+ php: {
754
+ tokens: /\$this\s*->\s*(?:assert\w*|expect\w*|fail)\s*\(|\b(?:Assert\s*::\s*\w+|assert\w*|expect\w*|self\s*::\s*assert\w*|static\s*::\s*assert\w*)\s*\(|->\s*(?:shouldReceive|expects|verify)\w*\s*\(/g,
755
+ taut: [
756
+ /\$this\s*->\s*assertTrue\s*\(\s*true\s*\)/g,
757
+ /\$this\s*->\s*assert(?:Equals|Same)\s*\(\s*([\w.'"$]+)\s*,\s*\1\s*\)/g
758
+ ],
759
+ find: (t, ns, nc) => {
760
+ const a = findFuncCases(t, ns, nc, /\bfunction\s+(test\w*)\s*\([^)]*\)\s*(?::\s*\??[\w\\]+\s*)?\{/, { skipRe: /^\s*\$this\s*->\s*markTest(?:Skipped|Incomplete)\s*\(/ });
761
+ const b = findAnnotationCases(t, ns, nc, /#\[(?:\\?[\w\\]+\\)?Test\b[^\]]*\]/);
762
+ const c = findCallCases(t, ns, nc, { names: 'it|test' });
763
+ const seen = new Set(a.cases.map((x) => x.bodyStart));
764
+ const extra = [...b.cases, ...c.cases].filter((x) => !seen.has(x.bodyStart));
765
+ return { cases: [...a.cases, ...extra], unparsed: a.unparsed + b.unparsed + c.unparsed };
766
+ }
767
+ },
768
+ swift: {
769
+ tokens: /\bXCT(?:Assert\w*|Fail|Unwrap|ExpectFailure)\b|#(?:expect|require)\b|\bIssue\.record\b|\b(?:expect|require)\s*\(/g,
770
+ taut: [
771
+ /\bXCTAssertTrue\s*\(\s*true\s*[),]/g,
772
+ new RegExp(`\\bXCTAssertEqual\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*[),]`, 'g'),
773
+ /#expect\s*\(\s*true\s*\)/g
774
+ ],
775
+ find: (t, ns, nc) => {
776
+ const a = findFuncCases(t, ns, nc, /\bfunc\s+(test\w*)\s*\([^)]*\)\s*(?:async\s*)?(?:throws\s*)?\{/, { skipRe: /XCTSkip/ });
777
+ const b = findAnnotationCases(t, ns, nc, /@Test\b(?:\s*\([^)]*\))?/);
778
+ const seen = new Set(a.cases.map((x) => x.bodyStart));
779
+ return { cases: [...a.cases, ...b.cases.filter((x) => !seen.has(x.bodyStart))], unparsed: a.unparsed };
780
+ }
781
+ },
782
+ dart: {
783
+ tokens: JS_TOKENS,
784
+ taut: [
785
+ /\bexpect\(\s*true\s*,\s*(?:isTrue|true|equals\(\s*true\s*\))\s*\)/g,
786
+ new RegExp(`\\bexpect\\(\\s*(${LIT})\\s*,\\s*(?:equals\\(\\s*\\1\\s*\\)|\\1)\\s*\\)`, 'g')
787
+ ],
788
+ find: (t, ns, nc) => findCallCases(t, ns, nc, { names: 'test|testWidgets|it' })
789
+ },
790
+ lua: {
791
+ tokens: /\bassert\w*\s*[.(]|\bspy\b|\bstub\b|\bmock\b|\bexpect\s*[(.]|\bshould\b/g,
792
+ taut: [
793
+ /\bassert\.is_true\s*\(\s*true\s*\)/g,
794
+ /\bassert\s*\(\s*true\s*\)/g,
795
+ new RegExp(`\\bassert\\.are\\.equal\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*\\)`, 'g')
796
+ ],
797
+ find: (t, ns, nc) => findCallCases(t, ns, nc, { names: 'it|test|pending' })
798
+ },
799
+ cpp: {
800
+ tokens: /\b(?:ASSERT|EXPECT)_\w+\s*\(|\b(?:CHECK|REQUIRE|CHECK_THAT|REQUIRE_THAT|ADD_FAILURE|FAIL|SUCCEED|GTEST_FAIL|GTEST_SKIP)\w*\s*\(|\bBOOST_(?:CHECK|REQUIRE|TEST|ERROR|FAIL)\w*\s*\(|\bassert\s*\(/g,
801
+ taut: [
802
+ /\b(?:ASSERT|EXPECT)_TRUE\s*\(\s*true\s*\)/g,
803
+ new RegExp(`\\b(?:ASSERT|EXPECT)_EQ\\s*\\(\\s*(${LIT})\\s*,\\s*\\1\\s*\\)`, 'g'),
804
+ /\b(?:CHECK|REQUIRE|BOOST_CHECK)\s*\(\s*true\s*\)/g
805
+ ],
806
+ find: findCppCases
807
+ }
808
+ };
809
+
810
+ const countMatches = (re, s) => {
811
+ const r = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
812
+ let n = 0;
813
+ while (r.exec(s) !== null) { n++; if (r.lastIndex === 0) break; }
814
+ return n;
815
+ };
816
+
817
+ /**
818
+ * Analyze one test file's text.
819
+ * @returns {{ family: string|null, cases: number, skipped: number, unparsed: number,
820
+ * findings: { rule: string, line: number|null, name: string, detail: string }[] }}
821
+ */
822
+ export function analyzeTestText(text, ext, extraAssertion = []) {
823
+ const family = FAMILY_BY_EXT[ext] || null;
824
+ const rules = family && FAMILY_RULES[family];
825
+ if (!rules) return { family: null, cases: 0, skipped: 0, unparsed: 0, findings: [] };
826
+ const { noComments, noStrings } = maskSource(text, family);
827
+ const { cases, unparsed } = rules.find(text, noStrings, noComments, ext);
828
+ const lineOf = lineIndex(text);
829
+ const findings = [];
830
+ let skipped = 0;
831
+ for (const c of cases) {
832
+ if (c.skipped) { skipped++; continue; }
833
+ const nsBody = noStrings.slice(c.bodyStart, c.bodyEnd);
834
+ const ncBody = noComments.slice(c.bodyStart, c.bodyEnd);
835
+ let tokens = countMatches(rules.tokens, nsBody);
836
+ for (const re of extraAssertion) tokens += countMatches(re, nsBody);
837
+ let taut = 0;
838
+ for (const re of rules.taut) taut += countMatches(re, ncBody);
839
+ const real = Math.max(0, tokens - taut) + c.credit;
840
+ if (real > 0) continue;
841
+ findings.push(taut > 0
842
+ ? { rule: 'tautology', line: lineOf(c.idx), name: c.name, detail: 'its only assertion(s) can never fail' }
843
+ : { rule: 'no-assertion', line: lineOf(c.idx), name: c.name, detail: 'the body contains nothing that can fail an assertion' });
844
+ }
845
+ if (cases.length > 0 && skipped === cases.length) {
846
+ findings.push({ rule: 'all-skipped', line: null, name: '(file)', detail: `every test in this file is skipped or todo (${skipped})` });
847
+ }
848
+ return { family, cases: cases.length, skipped, unparsed, findings };
849
+ }
850
+
851
+ // ═══════════════════════════════════════════════════════════════════════════
852
+ // Self-test — runs on EVERY invocation, before a single project file is read.
853
+ // A scanner that always says "clean" and a scanner that works print the same report;
854
+ // so the scanner must first catch fakes it was handed and let real tests through.
855
+ // ═══════════════════════════════════════════════════════════════════════════
856
+
857
+ const SELF_TEST = [
858
+ { ext: 'js', rules: ['no-assertion'], text: "it('does nothing', () => { const a = 1; });\n" },
859
+ { ext: 'js', rules: ['tautology'], text: "it('always passes', () => { expect(true).toBe(true); });\n" },
860
+ { ext: 'js', rules: [], text: "it('adds', () => { expect(add(1, 2)).toBe(3); });\ntest('t', async () => { await expect(f()).rejects.toThrow(); });\n" },
861
+ { ext: 'js', rules: ['all-skipped'], text: "it.skip('a', () => { expect(1).toBe(2); });\nit.todo('b');\n" },
862
+ { ext: 'js', rules: ['no-assertion'], text: "it('commented out', () => {\n // expect(a).toBe(b);\n const s = \"expect(a).toBe(b)\";\n});\n" },
863
+ { ext: 'py', rules: ['no-assertion'], text: "def test_nothing():\n x = 1\n pass\n" },
864
+ { ext: 'py', rules: ['tautology'], text: "def test_true():\n assert True\n" },
865
+ { ext: 'py', rules: [], text: "def test_ok():\n assert add(1, 2) == 3\n\n\ndef test_raises():\n with pytest.raises(ValueError):\n f()\n" },
866
+ { ext: 'go', rules: ['no-assertion'], text: 'func TestNothing(t *testing.T) {\n\tx := 1\n\t_ = x\n}\n' },
867
+ { ext: 'go', rules: [], text: 'func TestOk(t *testing.T) {\n\tif got := Add(1, 2); got != 3 {\n\t\tt.Errorf("got %d", got)\n\t}\n}\n' },
868
+ { ext: 'java', rules: ['no-assertion'], text: 'class A {\n @Test\n public void doesNothing() {\n int a = 1;\n }\n}\n' },
869
+ { ext: 'java', rules: ['tautology'], text: 'class A {\n @Test\n void t() {\n assertTrue(true);\n }\n}\n' },
870
+ { ext: 'java', rules: [], text: 'class A {\n @Test\n void t() {\n assertEquals(3, add(1, 2));\n }\n}\n' },
871
+ { ext: 'rs', rules: ['no-assertion'], text: '#[test]\nfn does_nothing() {\n let a = 1;\n}\n' },
872
+ { ext: 'rs', rules: [], text: '#[test]\nfn adds() {\n assert_eq!(add(1, 2), 3);\n}\n' },
873
+ { ext: 'rb', rules: ['no-assertion'], text: "describe 'x' do\n it 'does nothing' do\n a = 1\n end\nend\n" },
874
+ { ext: 'rb', rules: [], text: "describe 'x' do\n it 'adds' do\n expect(add(1, 2)).to eq(3)\n end\nend\n" },
875
+ { ext: 'php', rules: ['no-assertion'], text: "<?php\nclass T {\n public function testNothing() {\n $a = 1;\n }\n}\n" },
876
+ { ext: 'cpp', rules: ['no-assertion'], text: 'TEST(Suite, Nothing) {\n int a = 1;\n}\n' },
877
+ { ext: 'cpp', rules: [], text: 'TEST(Suite, Adds) {\n EXPECT_EQ(3, Add(1, 2));\n}\n' }
878
+ ];
879
+
880
+ /** @returns {string[]} what went wrong; empty = the scanner behaves on every known sample */
881
+ export function selfTest() {
882
+ const problems = [];
883
+ for (const [i, s] of SELF_TEST.entries()) {
884
+ const got = analyzeTestText(s.text, s.ext).findings.map((f) => f.rule).sort();
885
+ const want = [...s.rules].sort();
886
+ if (JSON.stringify(got) !== JSON.stringify(want)) {
887
+ problems.push(`self-test #${i} (.${s.ext}): expected [${want.join(', ')}], got [${got.join(', ')}]`);
888
+ }
889
+ }
890
+ return problems;
891
+ }
892
+
893
+ // ═══════════════════════════════════════════════════════════════════════════
894
+ // Run
895
+ // ═══════════════════════════════════════════════════════════════════════════
896
+
897
+ function readAssertionPatterns(root) {
898
+ const file = join(root, POLICY_FILE);
899
+ if (!existsSync(file)) return { patterns: [], problems: [] };
900
+ try {
901
+ const raw = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
902
+ const list = Array.isArray(raw.assertionPatterns) ? raw.assertionPatterns : [];
903
+ const patterns = [];
904
+ const problems = [];
905
+ for (const s of list) {
906
+ try { patterns.push(new RegExp(String(s), 'g')); } catch (e) { problems.push(`assertionPatterns: "${s}" is not a valid regular expression (${e.message})`); }
907
+ }
908
+ return { patterns, problems };
909
+ } catch {
910
+ return { patterns: [], problems: [] }; // loadPolicy already reported an unreadable file
911
+ }
912
+ }
913
+
914
+ // Directories under a test tree that hold data, not tests.
915
+ const FIXTURE_DIRS = new Set(['fixtures', 'fixture', '__fixtures__', '__snapshots__', 'snapshots', 'testdata', '__mocks__']);
916
+
917
+ export function scan(root, { staged = false } = {}) {
918
+ const { policy, problems } = loadPolicy(root);
919
+ const extra = readAssertionPatterns(root);
920
+ problems.push(...extra.problems);
921
+ let files;
922
+ if (staged) {
923
+ const s = stagedFiles(root);
924
+ if (s.error) return { error: `cannot read the staged files: ${s.error}`, problems };
925
+ files = s.files;
926
+ } else {
927
+ files = walkFiles(root, policy);
928
+ }
929
+ const result = {
930
+ mode: staged ? 'staged' : 'all', problems, testFiles: 0, cases: 0, skippedCases: 0, unparsed: 0,
931
+ findings: [], unsupported: [], unreadable: []
932
+ };
933
+ for (const rel of files) {
934
+ if (classifyPath(rel, policy).kind !== 'test') continue;
935
+ const base = rel.split('/').pop();
936
+ if (policy.nonCodeNames.includes(base) || base === 'conftest.py' || base.startsWith('.')) continue;
937
+ if (rel.split('/').some((d) => FIXTURE_DIRS.has(d))) continue;
938
+ const ext = extOfPath(rel);
939
+ // A test-directory file that is not code at all (a JSON fixture, a snapshot) is not a test case holder.
940
+ if (!FAMILY_BY_EXT[ext]) {
941
+ if (policy.sourceExtensions.includes(ext) || !policy.nonCodeExtensions.includes(ext)) result.unsupported.push(rel);
942
+ continue;
943
+ }
944
+ const r = readText(join(root, rel));
945
+ if (r.skipped) { if (!r.quiet) result.unreadable.push(`${rel} (${r.skipped})`); continue; }
946
+ const a = analyzeTestText(r.text, ext, extra.patterns);
947
+ result.testFiles++;
948
+ result.cases += a.cases;
949
+ result.skippedCases += a.skipped;
950
+ result.unparsed += a.unparsed;
951
+ for (const f of a.findings) result.findings.push({ file: rel, ...f });
952
+ }
953
+ return result;
954
+ }
955
+
956
+ function report(r) {
957
+ const out = [];
958
+ for (const p of r.problems) out.push(` ! ${p}`);
959
+ out.push(`check-anti-fake-tests: scanned ${r.testFiles} test file(s), ${r.cases} test case(s) (${r.mode === 'staged' ? 'staged files only' : 'whole project'})`);
960
+ for (const f of r.findings) {
961
+ out.push(` ✗ ${f.file}${f.line ? `:${f.line}` : ''} ${f.rule} "${f.name}" — ${f.detail}`);
962
+ }
963
+ if (r.unsupported.length > 0) {
964
+ const exts = [...new Set(r.unsupported.map((p) => `.${extOfPath(p) || '(none)'}`))].join(', ');
965
+ out.push(` · NOT scanned — no rule for this language (${exts}): ${r.unsupported.slice(0, 5).join(', ')}${r.unsupported.length > 5 ? `, +${r.unsupported.length - 5} more` : ''}`);
966
+ out.push(' These files were not judged. Fake tests in them would not be found by this script.');
967
+ }
968
+ for (const u of r.unreadable) out.push(` · NOT scanned — ${u}`);
969
+ if (r.unparsed > 0) out.push(` · ${r.unparsed} test call(s) could not be parsed (unbalanced brackets) and were not judged`);
970
+ if (r.testFiles === 0 && r.unsupported.length === 0 && r.mode === 'all') out.push(' · no test files found — nothing was measured');
971
+ out.push(r.findings.length > 0 ? `RESULT: ${r.findings.length} finding(s)` : 'RESULT: no fake tests found among the files scanned');
972
+ return out.join('\n');
973
+ }
974
+
975
+ export function main(argv, root = process.cwd()) {
976
+ const bad = selfTest();
977
+ if (bad.length > 0) {
978
+ console.error('check-anti-fake-tests: the scanner failed its own self-test, so its result would mean nothing:');
979
+ for (const b of bad) console.error(` ${b}`);
980
+ return 2;
981
+ }
982
+ const r = scan(root, { staged: argv.includes('--staged') });
983
+ if (r.error) {
984
+ console.error(`check-anti-fake-tests: ${r.error}`);
985
+ return 2;
986
+ }
987
+ console.log(argv.includes('--json') ? JSON.stringify(r, null, 2) : report(r));
988
+ return r.findings.length > 0 ? 1 : 0;
989
+ }
990
+
991
+ if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));