@lifeaitools/rdc-skills 0.34.0 → 0.35.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +284 -1
  2. package/VALIDATOR-ARCHITECTURE.md +534 -0
  3. package/commands/analyze-tests.md +11 -0
  4. package/commands/check-clean-code.md +11 -0
  5. package/commands/check-packages.md +10 -0
  6. package/commands/compare-compliance.md +14 -0
  7. package/commands/full-analysis.md +50 -0
  8. package/commands/get-refactoring-plan.md +13 -0
  9. package/commands/quick-check.md +13 -0
  10. package/commands/recover.md +149 -0
  11. package/commands/review-arch.md +12 -0
  12. package/commands/review.md +12 -113
  13. package/commands/suggest-patterns.md +11 -0
  14. package/commands/validate-solid.md +11 -0
  15. package/package.json +14 -2
  16. package/scripts/architecture-score.mjs +157 -0
  17. package/scripts/clean-code-score.mjs +177 -0
  18. package/scripts/duplication-score.mjs +66 -0
  19. package/scripts/lib/architecture-scoring.mjs +695 -0
  20. package/scripts/lib/clean-code-scoring.mjs +258 -0
  21. package/scripts/lib/duplication-scoring.mjs +238 -0
  22. package/scripts/lib/language-plugin.mjs +82 -0
  23. package/scripts/lib/package-metrics.mjs +439 -0
  24. package/scripts/lib/pattern-scoring.mjs +351 -0
  25. package/scripts/lib/plugins/treesitter.mjs +1182 -0
  26. package/scripts/lib/plugins/typescript.mjs +672 -0
  27. package/scripts/lib/refactoring-scoring.mjs +307 -0
  28. package/scripts/lib/solid-scoring.mjs +101 -0
  29. package/scripts/lib/test-smell-scoring.mjs +581 -0
  30. package/scripts/lib/vendor/codeflow-parser/.source-commit +1 -0
  31. package/scripts/lib/vendor/codeflow-parser/grammars.d.ts +23 -0
  32. package/scripts/lib/vendor/codeflow-parser/grammars.js +57 -0
  33. package/scripts/lib/vendor/codeflow-parser/memberFacts.d.ts +274 -0
  34. package/scripts/lib/vendor/codeflow-parser/memberFacts.js +1117 -0
  35. package/scripts/lib/vendor/codeflow-parser/nativeParser.d.ts +115 -0
  36. package/scripts/lib/vendor/codeflow-parser/nativeParser.js +759 -0
  37. package/scripts/lib/vendor/codeflow-parser/package.json +3 -0
  38. package/scripts/lib/vendor/codeflow-parser/xmlParser.d.ts +77 -0
  39. package/scripts/lib/vendor/codeflow-parser/xmlParser.js +400 -0
  40. package/scripts/package-metrics-cli.mjs +112 -0
  41. package/scripts/pattern-score.mjs +143 -0
  42. package/scripts/refactoring-score.mjs +253 -0
  43. package/scripts/solid-score.mjs +337 -0
  44. package/skills/architecture-reviewer/SKILL.md +287 -0
  45. package/skills/clean-code-analyzer/SKILL.md +147 -0
  46. package/skills/package-design/SKILL.md +118 -0
  47. package/skills/pattern-advisor/SKILL.md +237 -0
  48. package/skills/pattern-refactoring-guide/SKILL.md +262 -0
  49. package/skills/review/SKILL.md +29 -0
  50. package/skills/solid-validator/SKILL.md +92 -0
  51. package/skills/testing-strategy/SKILL.md +132 -0
  52. package/tests/lib/architecture-scoring.test.mjs +335 -0
  53. package/tests/lib/clean-code-scoring.test.mjs +241 -0
  54. package/tests/lib/duplication-scoring.test.mjs +144 -0
  55. package/tests/lib/fixtures.mjs +58 -0
  56. package/tests/lib/package-metrics.test.mjs +241 -0
  57. package/tests/lib/pattern-scoring.test.mjs +251 -0
  58. package/tests/lib/refactoring-scoring.test.mjs +264 -0
  59. package/tests/lib/solid-scoring.test.mjs +291 -0
  60. package/tests/lib/test-smell-scoring.test.mjs +281 -0
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Test-smell scoring — T1/T2/T5/T6/T7/T8/T9 plus F.I.R.S.T "Independent",
3
+ * operating on TEST FILES (`.test.mjs` / `.test.ts` / `*.spec.*`).
4
+ *
5
+ * Language-independent in shape, same discipline as language-plugin.mjs and
6
+ * solid-scoring.mjs: every exported check here is a PURE function over TEXT
7
+ * FACTS — a test file's source text, plus (for T1 only) an externally
8
+ * supplied exported-member count. Nothing in this file imports ts-morph or
9
+ * any language-specific parser. Where a check genuinely needs an AST fact
10
+ * (T1's "N exported functions/methods" on the SOURCE file under test), the
11
+ * caller supplies it via the SAME `NormalizedUnit` contract solid-score.mjs
12
+ * already uses (see language-plugin.mjs) — never reimplemented in this file,
13
+ * and typescript.mjs / language-plugin.mjs are untouched by this change.
14
+ *
15
+ * ── Reuse provenance ────────────────────────────────────────────────────
16
+ * T1/T2/T5/T6/T7/T8/T9 rule IDs, names, and several thresholds are reused
17
+ * from OnSightTeam/architecture-toolkit (MIT), commit `main` as fetched
18
+ * 2026-08-20, `src/agents/testing-strategy/tools/test-quality-validator.ts`:
19
+ * - T1 Insufficient Tests — checkInsufficientTests, lines 47-65
20
+ * - T2 Ignored Test — checkIgnoredTests, lines 67-85 (`xit|it.skip|test.skip`)
21
+ * - T5 Exhaustive Testing — checkExhaustiveTesting, lines 127-145 (threshold: 10)
22
+ * - T6 Long Tests — checkLongTests, lines 147-171 (threshold: 30 lines)
23
+ * - T7 Slow Tests — checkSlowTests, lines 173-190 (`setTimeout|sleep|delay`)
24
+ * - T8 Fragile Tests — checkFragileTests, lines 192-219 (`new Date()|Math.random()|process.env`)
25
+ * - T9 Test Code Duplication— checkTestCodeDuplication, lines 221-240 (setup-duplication concept)
26
+ * F.I.R.S.T "Independent" is reused from the same repo's
27
+ * `first-principles-validator.ts` checkIndependent (lines 71-107, shared
28
+ * mutable state as the Independent-violation signal) and
29
+ * `test-independence-validator.ts` checkSharedMutableState (lines 42-70,
30
+ * "top-level `let` = shared-state risk").
31
+ *
32
+ * Every reused check below is ADAPTED, not copied verbatim, because the
33
+ * reference implementation's block/line extraction is a non-greedy regex
34
+ * (`/\b(test|it)\s*\([^{]*{([^}]*)}/gs`) that cannot balance nested braces —
35
+ * it breaks on the first `}` inside any if/for/object literal in a test
36
+ * body, which is most real tests. This file replaces that with an actual
37
+ * brace-balanced scanner (`scanBalanced`) that tracks string/template/
38
+ * comment context, so line counts and assertion counts are measured against
39
+ * the REAL test body, not a regex's best guess at one. Where a threshold
40
+ * carries over unchanged (T5's 10, T6's 30), that is a deliberate reuse,
41
+ * cited above; where the scope changed (T1 per-file→paired-file, T7/T8
42
+ * file-wide-count→per-block presence, T9 same-file-literal→cross-file
43
+ * structural), the deviation and reason are in each function's docstring.
44
+ *
45
+ * ── Skipped ─────────────────────────────────────────────────────────────
46
+ * T3 (Test Per Class) and T4 (Untested Method) are the reference's weakest
47
+ * checks even in their own repo — T3 keys off a `describe()` block count
48
+ * that has no reliable meaning across test runners (node:test files in this
49
+ * fleet mostly don't use `describe` at all; see dogfood evidence), and T4 is
50
+ * a *coverage-gap* guess from a raw exported-vs-test ratio that duplicates
51
+ * T1's actual measurement without adding a new fact. Faking either here
52
+ * would be exactly the "weak check invented to fill a slot" this task said
53
+ * to avoid. FIRST's Fast/Repeatable/SelfValidating/Timely are also skipped:
54
+ * Fast and Repeatable are already fully covered by this file's T7 (slow
55
+ * calls) and T8 (fragile/non-deterministic references) — a second AST-only
56
+ * check would just re-flag the same regex hits under a different label.
57
+ * SelfValidating ("has an assertion") and Timely ("has a paired test file")
58
+ * are not test-SMELL checks at all in the sense this task asked for — they
59
+ * are presence/absence checks with no meaningful mechanical signal beyond
60
+ * "count is zero", which is already reported structurally by T1.
61
+ */
62
+
63
+ import { relative } from 'node:path';
64
+
65
+ // ── text-fact primitives ────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Balanced-bracket scan from `openIdx` (where `text[openIdx] === openChar`)
69
+ * to the matching close, skipping over string/template literals and
70
+ * comments so brace/paren counting inside real code doesn't miscount on a
71
+ * `'{'` or `'('` that appears inside a string. Returns the index of the
72
+ * matching close char, or -1 if the text ends unbalanced.
73
+ */
74
+ function scanBalanced(text, openIdx, openChar, closeChar) {
75
+ let depth = 0;
76
+ let i = openIdx;
77
+ const n = text.length;
78
+ while (i < n) {
79
+ const ch = text[i];
80
+ if (ch === '"' || ch === "'" || ch === '`') { i = skipString(text, i, ch); continue; }
81
+ if (ch === '/' && text[i + 1] === '/') { const nl = text.indexOf('\n', i); if (nl === -1) return -1; i = nl + 1; continue; }
82
+ if (ch === '/' && text[i + 1] === '*') { const end = text.indexOf('*/', i + 2); if (end === -1) return -1; i = end + 2; continue; }
83
+ if (ch === openChar) depth++;
84
+ else if (ch === closeChar) { depth--; if (depth === 0) return i; }
85
+ i++;
86
+ }
87
+ return -1;
88
+ }
89
+
90
+ /** Skip a string/template literal starting at `i` (text[i] === quote). Handles `${...}` nesting in template literals. */
91
+ function skipString(text, i, quote) {
92
+ i++;
93
+ const n = text.length;
94
+ while (i < n) {
95
+ if (text[i] === '\\') { i += 2; continue; }
96
+ if (text[i] === quote) return i + 1;
97
+ if (quote === '`' && text[i] === '$' && text[i + 1] === '{') {
98
+ const close = scanBalanced(text, i + 1, '{', '}');
99
+ i = close === -1 ? n : close + 1;
100
+ continue;
101
+ }
102
+ i++;
103
+ }
104
+ return i;
105
+ }
106
+
107
+ function lineOf(text, index) {
108
+ let line = 1;
109
+ for (let i = 0; i < index && i < text.length; i++) if (text[i] === '\n') line++;
110
+ return line;
111
+ }
112
+
113
+ /**
114
+ * Find every `test(...)` / `it(...)` call (optionally `.skip`/`.only`/`.todo`
115
+ * modified) at any nesting depth, with its title, skip flag, and — if the
116
+ * callback has a block body (`() => { ... }` or `function () { ... }`) —
117
+ * that body's exact text span. A callback with an expression body (no `{}`,
118
+ * e.g. `it('x', () => expect(f()).toBe(1))`) has `body: null`; T5/T6/T7/T8/
119
+ * Independent all treat `body: null` as "nothing to measure", not a smell,
120
+ * since there is no assertion/line/call count to read without a body.
121
+ */
122
+ export function findTestBlocks(text) {
123
+ const blocks = [];
124
+ const callRe = /\b(test|it)(\.\w+)?\s*\(/g;
125
+ let m;
126
+ while ((m = callRe.exec(text))) {
127
+ const kind = m[1];
128
+ const modifier = m[2] ? m[2].slice(1) : null;
129
+ const openParen = m.index + m[0].length - 1;
130
+ const closeParen = scanBalanced(text, openParen, '(', ')');
131
+ if (closeParen === -1) continue;
132
+ const callText = text.slice(m.index, closeParen + 1);
133
+
134
+ const titleMatch = /^\s*['"`]([^'"`]*)['"`]/.exec(callText.slice(m[0].length));
135
+ const title = titleMatch ? titleMatch[1] : null;
136
+
137
+ const body = extractCallbackBody(callText);
138
+ blocks.push({
139
+ kind, modifier, title,
140
+ skipped: modifier === 'skip' || modifier === 'todo',
141
+ start: m.index, end: closeParen + 1,
142
+ startLine: lineOf(text, m.index), endLine: lineOf(text, closeParen),
143
+ body: body ? {
144
+ text: body.text,
145
+ // absolute offsets back in the ORIGINAL text, not callText
146
+ start: m.index + body.start + 1,
147
+ end: m.index + body.end,
148
+ } : null,
149
+ });
150
+ callRe.lastIndex = closeParen + 1;
151
+ }
152
+ return blocks;
153
+ }
154
+
155
+ /** Locate the callback's block body `{...}` inside one `test(...)`/`it(...)` call's full text. */
156
+ function extractCallbackBody(callText) {
157
+ const arrowIdx = callText.indexOf('=>');
158
+ const funcMatch = /\bfunction\b/.exec(callText);
159
+ const funcIdx = funcMatch ? funcMatch.index : -1;
160
+ let searchFrom;
161
+ if (arrowIdx !== -1 && (funcIdx === -1 || arrowIdx < funcIdx)) searchFrom = arrowIdx + 2;
162
+ else if (funcIdx !== -1) searchFrom = funcIdx;
163
+ else return null;
164
+
165
+ const braceIdx = callText.indexOf('{', searchFrom);
166
+ if (braceIdx === -1) return null;
167
+ // Reject a `{` that belongs to an object literal before any `{` truly
168
+ // opens the block — e.g. `() => ({ ok: true })`. A block body's `{` is
169
+ // never preceded by `(` with no intervening non-whitespace back to `=>`.
170
+ const between = callText.slice(searchFrom, braceIdx);
171
+ if (/\(\s*$/.test(between)) return null;
172
+ const closeIdx = scanBalanced(callText, braceIdx, '{', '}');
173
+ if (closeIdx === -1) return null;
174
+ return { start: braceIdx, end: closeIdx, text: callText.slice(braceIdx + 1, closeIdx) };
175
+ }
176
+
177
+ // ── T1 — Insufficient Tests ─────────────────────────────────────────────
178
+ // Reused: test-quality-validator.ts:47-65 (checkInsufficientTests). The
179
+ // reference compares testCount to `publicMethods * 0.5` using a Java/C#-
180
+ // shaped `\bpublic\s+\w+\s*\(` regex that matches nothing in JS/TS. This
181
+ // task's spec is explicit and stricter: "fewer than N corresponding test()
182
+ // blocks" for N exported functions/methods — so the multiplier is dropped
183
+ // and the comparison is a direct testCount < exportedUnitCount, sourced from
184
+ // the SAME NormalizedUnit contract solid-score.mjs already uses (never a
185
+ // hand-rolled `public` regex).
186
+ /**
187
+ * @param {ReturnType<typeof findTestBlocks>} testBlocks
188
+ * @param {number|null} exportedUnitCount - public member/function count from
189
+ * the paired SOURCE file's NormalizedUnit[] (see countExportedUnits below).
190
+ * null means "no source file could be paired" — T1 is unmeasured, not clean.
191
+ */
192
+ export function checkInsufficientTests(testBlocks, exportedUnitCount) {
193
+ if (exportedUnitCount == null) return null;
194
+ const measured = testBlocks.filter((b) => !b.skipped).length;
195
+ if (exportedUnitCount === 0) return null; // nothing exported to require coverage for
196
+ if (measured < exportedUnitCount) {
197
+ return {
198
+ ruleId: 'T1', severity: 'high',
199
+ description: `T1 Insufficient Tests — ${measured} test() block(s) for ${exportedUnitCount} exported function(s)/method(s) in the paired source file`,
200
+ recommendation: 'Add tests until every exported function/method has at least one corresponding test() block.',
201
+ line: 1,
202
+ };
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // ── T2 — Ignored Tests ──────────────────────────────────────────────────
208
+ // Reused: test-quality-validator.ts:67-85. Reference pattern
209
+ // `xit|it\.skip|test\.skip|@Ignore` (the `@Ignore` decorator is a JUnit
210
+ // idiom, dropped as not applicable to JS/TS). Spec adds `xdescribe`.
211
+ export function checkIgnoredTests(text, testBlocks) {
212
+ const findings = [];
213
+ for (const b of testBlocks) {
214
+ if (b.skipped) {
215
+ findings.push({
216
+ ruleId: 'T2', severity: 'medium', line: b.startLine,
217
+ description: `T2 Ignored Test — ${b.kind}.${b.modifier}('${b.title ?? '?'}')`,
218
+ recommendation: 'Either fix and enable the skipped test or delete it — a skipped test provides no coverage but reads as if it does.',
219
+ });
220
+ }
221
+ }
222
+ const xdescribeRe = /\bxdescribe\s*\(\s*['"`]([^'"`]*)['"`]/g;
223
+ let m;
224
+ while ((m = xdescribeRe.exec(text))) {
225
+ findings.push({
226
+ ruleId: 'T2', severity: 'medium', line: lineOf(text, m.index),
227
+ description: `T2 Ignored Test — xdescribe('${m[1]}') disables its entire suite`,
228
+ recommendation: 'Either fix and enable the suite or delete it.',
229
+ });
230
+ }
231
+ return findings;
232
+ }
233
+
234
+ // ── T5 — Exhaustive Testing ─────────────────────────────────────────────
235
+ // Reused: test-quality-validator.ts:127-145, threshold 10 kept as-is (it's
236
+ // the same number Robert C. Martin's original T5 write-up in "Clean Code"
237
+ // uses for "too many assert calls in one test" — a single test asserting
238
+ // more than ~10 distinct facts is usually testing more than one behavior).
239
+ // Reference measures the FILE-WIDE average (expectCount/testCount); this
240
+ // measures PER-BLOCK count, because a file average of 10 hides one 40-
241
+ // assertion test sitting next to nine 1-assertion tests — the actual T5
242
+ // smell is about ONE test doing too much, not the file's mean.
243
+ const ASSERT_CALL_RE = /\b(?:assert(?:\.\w+)?|expect|should)\s*\(/g;
244
+ export function checkExhaustiveTesting(testBlocks, threshold = 10) {
245
+ const findings = [];
246
+ for (const b of testBlocks) {
247
+ if (!b.body) continue;
248
+ const count = (b.body.text.match(ASSERT_CALL_RE) ?? []).length;
249
+ if (count > threshold) {
250
+ findings.push({
251
+ ruleId: 'T5', severity: 'medium', line: b.startLine,
252
+ description: `T5 Exhaustive Testing — ${b.kind}('${b.title ?? '?'}') has ${count} assertion calls (threshold: >${threshold})`,
253
+ recommendation: 'Split this test into focused tests, one behavior each.',
254
+ });
255
+ }
256
+ }
257
+ return findings;
258
+ }
259
+
260
+ // ── T6 — Long Tests ──────────────────────────────────────────────────────
261
+ // Reused: test-quality-validator.ts:147-171, threshold 30 lines kept as-is.
262
+ export function checkLongTests(testBlocks, threshold = 30) {
263
+ const findings = [];
264
+ for (const b of testBlocks) {
265
+ if (!b.body) continue;
266
+ const lines = b.body.text.split('\n').length;
267
+ if (lines > threshold) {
268
+ findings.push({
269
+ ruleId: 'T6', severity: 'medium', line: b.startLine,
270
+ description: `T6 Long Tests — ${b.kind}('${b.title ?? '?'}') body is ${lines} lines (threshold: >${threshold})`,
271
+ recommendation: 'Extract setup to beforeEach/helper functions; keep the test body to the behavior under test.',
272
+ });
273
+ }
274
+ }
275
+ return findings;
276
+ }
277
+
278
+ // ── T7 — Slow Tests ──────────────────────────────────────────────────────
279
+ // Reused: test-quality-validator.ts:173-190 (`setTimeout|sleep|delay`
280
+ // indicator names). Reference flags the FILE when count > 2; this task asks
281
+ // for "literal setTimeout/sleep/hardcoded delay calls inside a test" with
282
+ // no stated minimum, so it is adapted to per-block presence (>=1) — any
283
+ // literal timer call inside a unit test body is a real smell regardless of
284
+ // how many others are in the file.
285
+ const SLOW_CALL_RE = /\b(setTimeout|setInterval|sleep|delay)\s*\(/g;
286
+ export function checkSlowTests(testBlocks) {
287
+ const findings = [];
288
+ for (const b of testBlocks) {
289
+ if (!b.body) continue;
290
+ const hits = b.body.text.match(SLOW_CALL_RE);
291
+ if (hits && hits.length) {
292
+ findings.push({
293
+ ruleId: 'T7', severity: 'high', line: b.startLine,
294
+ description: `T7 Slow Tests — ${b.kind}('${b.title ?? '?'}') calls ${[...new Set(hits.map((h) => h.replace(/\s*\($/, '')))].join(', ')} directly`,
295
+ recommendation: 'Use a fake/mocked clock or an event-driven wait instead of a literal timer/delay call.',
296
+ });
297
+ }
298
+ }
299
+ return findings;
300
+ }
301
+
302
+ // ── T8 — Fragile Tests ───────────────────────────────────────────────────
303
+ // Reused: test-quality-validator.ts:192-219 (`new Date()|Math.random()|
304
+ // process.env`), also cross-referenced against first-principles-validator's
305
+ // checkRepeatable (lines 109-133), same pattern family. Reference flags the
306
+ // FILE when total count > 3; adapted to per-block presence (>=1) for the
307
+ // same reason as T7 — a single `Date.now()` inside one test is already
308
+ // non-deterministic, no threshold needed. `Date.now()` is added per this
309
+ // task's spec (the reference only checks bare `new Date()`).
310
+ const FRAGILE_PATTERNS = [
311
+ { re: /\bDate\.now\s*\(\s*\)/g, label: 'Date.now()' },
312
+ { re: /\bnew\s+Date\s*\(\s*\)/g, label: 'new Date() with no args' },
313
+ { re: /\bMath\.random\s*\(\s*\)/g, label: 'Math.random()' },
314
+ { re: /\bprocess\.env\.\w+/g, label: 'process.env.*' },
315
+ ];
316
+ export function checkFragileTests(testBlocks) {
317
+ const findings = [];
318
+ for (const b of testBlocks) {
319
+ if (!b.body) continue;
320
+ const hitLabels = [];
321
+ for (const { re, label } of FRAGILE_PATTERNS) {
322
+ re.lastIndex = 0;
323
+ if (re.test(b.body.text)) hitLabels.push(label);
324
+ }
325
+ if (hitLabels.length) {
326
+ findings.push({
327
+ ruleId: 'T8', severity: 'high', line: b.startLine,
328
+ description: `T8 Fragile Tests — ${b.kind}('${b.title ?? '?'}') references ${hitLabels.join(', ')} directly`,
329
+ recommendation: 'Inject a fixed clock/seeded RNG/explicit config instead of reading real time, randomness, or env vars inside a test.',
330
+ });
331
+ }
332
+ }
333
+ return findings;
334
+ }
335
+
336
+ // ── T9 — Duplicated Setup (cross-file) ──────────────────────────────────
337
+ // Reused CONCEPT only: test-quality-validator.ts:221-240 checks literal
338
+ // `const x = new Y` duplication WITHIN one file via exact-string Set dedup.
339
+ // This task's spec is explicitly a different, harder check: near-identical
340
+ // beforeEach/setup blocks ACROSS MULTIPLE test files, "structural
341
+ // similarity, not exact string match". The reference's exact-match approach
342
+ // cannot do that at all (two setups differing only by a variable name or a
343
+ // literal value are, structurally, the same duplication and would be missed
344
+ // by exact string comparison) — so the comparator below is new, built to
345
+ // satisfy the spec's explicit requirement, not adapted from their code.
346
+ //
347
+ // Similarity heuristic: normalize each candidate block by stripping
348
+ // comments, collapsing every string/template literal to `STR` and every
349
+ // numeric literal to `NUM` (so two setups differing only in a fixture path
350
+ // or a port number still compare equal), then compare as a SET of
351
+ // contiguous 3-token shingles (Jaccard index). Token-set shingling is
352
+ // chosen over a raw string/Levenshtein diff because it is order-tolerant
353
+ // for small local reorderings (e.g. two `beforeEach`s that create the same
354
+ // three fixtures in a different order) while still requiring genuine
355
+ // structural overlap — a shuffled-but-unrelated block scores low because
356
+ // its 3-grams don't line up. Threshold 0.75 chosen so accidental overlap
357
+ // (e.g. two setups that both just do `const h = new Harness(...)`) doesn't
358
+ // swamp real duplication; the reference's own bar for "smell" (ratio > 2 in
359
+ // their file-local check) was structurally different so does not transfer.
360
+
361
+ function normalizeForStructuralCompare(codeText) {
362
+ return codeText
363
+ .replace(/\/\/.*$/gm, '')
364
+ .replace(/\/\*[\s\S]*?\*\//g, '')
365
+ .replace(/(['"`])(?:\\.|(?!\1)[\s\S])*?\1/g, 'STR')
366
+ .replace(/\b\d+(\.\d+)?\b/g, 'NUM');
367
+ }
368
+
369
+ function tokenize(text) {
370
+ return (text.match(/[A-Za-z_$][\w$]*|[^\sA-Za-z_$]/g) ?? []);
371
+ }
372
+
373
+ function shingles(tokens, n = 3) {
374
+ const set = new Set();
375
+ for (let i = 0; i + n <= tokens.length; i++) set.add(tokens.slice(i, i + n).join('\u0001'));
376
+ return set;
377
+ }
378
+
379
+ function jaccard(a, b) {
380
+ if (!a.size && !b.size) return 0;
381
+ let intersection = 0;
382
+ for (const x of a) if (b.has(x)) intersection++;
383
+ const union = a.size + b.size - intersection;
384
+ return union === 0 ? 0 : intersection / union;
385
+ }
386
+
387
+ /** Every `beforeEach(...)` / `beforeAll(...)` call's block body in one file, as setup candidates for T9. */
388
+ export function findSetupBlocks(text) {
389
+ const out = [];
390
+ const re = /\b(beforeEach|beforeAll)\s*\(/g;
391
+ let m;
392
+ while ((m = re.exec(text))) {
393
+ const openParen = m.index + m[0].length - 1;
394
+ const closeParen = scanBalanced(text, openParen, '(', ')');
395
+ if (closeParen === -1) continue;
396
+ const callText = text.slice(m.index, closeParen + 1);
397
+ const body = extractCallbackBody(callText);
398
+ if (body && body.text.trim()) {
399
+ out.push({ kind: m[1], startLine: lineOf(text, m.index), text: body.text });
400
+ }
401
+ re.lastIndex = closeParen + 1;
402
+ }
403
+ return out;
404
+ }
405
+
406
+ /**
407
+ * @param {{file: string, setupBlocks: ReturnType<typeof findSetupBlocks>}[]} files
408
+ * @returns findings for every cross-file setup-block pair scoring >= threshold
409
+ */
410
+ export function checkDuplicatedSetupAcrossFiles(files, threshold = 0.75) {
411
+ const findings = [];
412
+ const candidates = [];
413
+ for (const f of files) {
414
+ for (const s of f.setupBlocks) {
415
+ const tokens = tokenize(normalizeForStructuralCompare(s.text));
416
+ if (tokens.length < 6) continue; // too small to compare meaningfully
417
+ candidates.push({ file: f.file, startLine: s.startLine, shingleSet: shingles(tokens) });
418
+ }
419
+ }
420
+ for (let i = 0; i < candidates.length; i++) {
421
+ for (let j = i + 1; j < candidates.length; j++) {
422
+ const a = candidates[i], b = candidates[j];
423
+ if (a.file === b.file) continue; // same-file duplication is a different, T9-adjacent question the reference already covers within one file
424
+ const sim = jaccard(a.shingleSet, b.shingleSet);
425
+ if (sim >= threshold) {
426
+ findings.push({
427
+ ruleId: 'T9', severity: 'medium',
428
+ description: `T9 Duplicated Setup — beforeEach/beforeAll at ${a.file}:${a.startLine} and ${b.file}:${b.startLine} are ${(sim * 100).toFixed(0)}% structurally similar`,
429
+ recommendation: 'Extract the shared setup into one factory/helper both test files import.',
430
+ locations: [{ file: a.file, line: a.startLine }, { file: b.file, line: b.startLine }],
431
+ similarity: sim,
432
+ });
433
+ }
434
+ }
435
+ }
436
+ return findings;
437
+ }
438
+
439
+ // ── FIRST — Independent ──────────────────────────────────────────────────
440
+ // Reused CONCEPT: first-principles-validator.ts:71-107 checkIndependent
441
+ // (shared mutable state → Independent violation) and
442
+ // test-independence-validator.ts:42-70 checkSharedMutableState ("top-level
443
+ // `let` = shared-state risk"). Both reference checks stop at PRESENCE —
444
+ // "there exist > N top-level `let`s" or "a `let`-with-comment-'shared'" —
445
+ // which flags files with harmless outer `let`s that are declared once and
446
+ // read, never mutated, by more than one test (a false positive the spec's
447
+ // exact wording avoids: "declared outside test() AND mutated inside more
448
+ // than one test() block"). This implementation requires BOTH halves: the
449
+ // declaration site is outside every test() body span, AND an assignment
450
+ // (not just a read) to that name is found inside 2+ DISTINCT test() bodies.
451
+ const MUTATION_RE_FOR = (name) => new RegExp(
452
+ `\\b${name}\\s*(?:=[^=]|\\+\\+|--|\\+=|-=|\\*=|\\/=|%=)`,
453
+ );
454
+
455
+ export function checkIndependentSharedState(text, testBlocks) {
456
+ const findings = [];
457
+ const letRe = /\blet\s+([A-Za-z_$][\w$]*)\s*(?:=|;)/g;
458
+ let m;
459
+ const seen = new Set();
460
+ while ((m = letRe.exec(text))) {
461
+ const name = m[1];
462
+ if (seen.has(name)) continue;
463
+ // Declared inside some test() body — that's fine, local to one test.
464
+ const insideATest = testBlocks.some((b) => b.body && m.index >= b.body.start && m.index < b.body.end);
465
+ if (insideATest) continue;
466
+ seen.add(name);
467
+
468
+ const mutRe = MUTATION_RE_FOR(name);
469
+ const mutatingBlocks = testBlocks.filter((b) => b.body && mutRe.test(b.body.text));
470
+ if (mutatingBlocks.length > 1) {
471
+ findings.push({
472
+ ruleId: 'FIRST-Independent', severity: 'critical', line: lineOf(text, m.index),
473
+ description: `FIRST Independent — '${name}' is declared outside test() and mutated inside ${mutatingBlocks.length} separate test() blocks (${mutatingBlocks.map((b) => `'${b.title ?? '?'}'`).join(', ')})`,
474
+ recommendation: `Move '${name}' into a beforeEach() reset or declare it fresh inside each test — a variable mutated by one test and read by the next makes execution order load-bearing.`,
475
+ });
476
+ }
477
+ }
478
+ return findings;
479
+ }
480
+
481
+ // ── source-file pairing for T1 ──────────────────────────────────────────
482
+
483
+ /**
484
+ * Sum of `isPublic` members across a source file's NormalizedUnit[] — "N
485
+ * exported functions/methods" per the language-plugin.mjs contract. Callers
486
+ * supply `units` (already extracted via whatever LanguagePlugin claimed the
487
+ * source file) rather than this file resolving a plugin itself, so this
488
+ * module never needs to import language-plugin.mjs's registry machinery —
489
+ * it only needs the shape, keeping the "no language-specific parser here"
490
+ * invariant intact even for this one AST-sourced fact.
491
+ * @param {import('./language-plugin.mjs').NormalizedUnit[]} units
492
+ */
493
+ export function countExportedUnits(units) {
494
+ if (!units || !units.length) return null;
495
+ return units.reduce((sum, u) => sum + u.members.filter((m) => m.isPublic).length, 0);
496
+ }
497
+
498
+ /** Guess a source file path from a test file path: `foo.test.mjs` → `foo.mjs`, `test/x.test.ts` → `src/x.ts`, etc. Best-effort; callers may override. */
499
+ export function guessSourceFilePath(testFilePath) {
500
+ const stripped = testFilePath.replace(/\.(test|spec)\.(m?[jt]sx?)$/, '.$2');
501
+ return stripped.replace(/[\\/](test|tests|__tests__|spec)[\\/]/, (m) => m.replace(/test|tests|__tests__|spec/, 'src'));
502
+ }
503
+
504
+ // ── per-file orchestration ───────────────────────────────────────────────
505
+
506
+ /**
507
+ * Extract every text fact one test file needs for T1-T9/Independent, ONCE,
508
+ * so scoring a file never re-scans it per rule.
509
+ */
510
+ export function extractTestFileFacts(text) {
511
+ const testBlocks = findTestBlocks(text);
512
+ const setupBlocks = findSetupBlocks(text);
513
+ return { testBlocks, setupBlocks };
514
+ }
515
+
516
+ /**
517
+ * Score a single test file's single-file-scope smells (everything except
518
+ * T9, which is inherently cross-file — see checkDuplicatedSetupAcrossFiles).
519
+ * @param {string} text - the test file's source text
520
+ * @param {object} [opts]
521
+ * @param {number|null} [opts.exportedUnitCount] - see checkInsufficientTests
522
+ */
523
+ export function scoreTestFile(text, opts = {}) {
524
+ const facts = extractTestFileFacts(text);
525
+ const findings = [
526
+ ...(checkInsufficientTests(facts.testBlocks, opts.exportedUnitCount ?? null) ? [checkInsufficientTests(facts.testBlocks, opts.exportedUnitCount ?? null)] : []),
527
+ ...checkIgnoredTests(text, facts.testBlocks),
528
+ ...checkExhaustiveTesting(facts.testBlocks),
529
+ ...checkLongTests(facts.testBlocks),
530
+ ...checkSlowTests(facts.testBlocks),
531
+ ...checkFragileTests(facts.testBlocks),
532
+ ...checkIndependentSharedState(text, facts.testBlocks),
533
+ ];
534
+ return { testBlockCount: facts.testBlocks.length, skippedCount: facts.testBlocks.filter((b) => b.skipped).length, findings };
535
+ }
536
+
537
+ // ── CLI (dogfooding entry point) ─────────────────────────────────────────
538
+ // Usage: node test-smell-scoring.mjs <file-or-dir> [--repo-root <dir>]
539
+
540
+ if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}` || import.meta.url === `file:///${process.argv[1]?.replace(/\\/g, '/')}`) {
541
+ const { readFileSync, readdirSync, statSync } = await import('node:fs');
542
+ const { join, resolve } = await import('node:path');
543
+
544
+ function walkTestFiles(dir, out = []) {
545
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
546
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
547
+ const full = join(dir, entry.name);
548
+ if (entry.isDirectory()) walkTestFiles(full, out);
549
+ else if (/\.(test|spec)\.(m?[jt]sx?)$/.test(entry.name)) out.push(full);
550
+ }
551
+ return out;
552
+ }
553
+
554
+ const target = resolve(process.cwd(), process.argv[2] ?? '.');
555
+ const repoRootArgIdx = process.argv.indexOf('--repo-root');
556
+ const repoRoot = repoRootArgIdx !== -1 ? resolve(process.cwd(), process.argv[repoRootArgIdx + 1]) : process.cwd();
557
+
558
+ const files = statSync(target).isDirectory() ? walkTestFiles(target) : [target];
559
+ const perFile = [];
560
+ for (const f of files) {
561
+ const text = readFileSync(f, 'utf8');
562
+ const result = scoreTestFile(text);
563
+ const setupBlocks = findSetupBlocks(text);
564
+ perFile.push({ file: relative(repoRoot, f).split('\\').join('/'), text, ...result, setupBlocks });
565
+ }
566
+
567
+ let totalFindings = 0;
568
+ for (const pf of perFile) {
569
+ for (const f of pf.findings) {
570
+ totalFindings++;
571
+ console.log(`${pf.file}:${f.line} ${f.description}`);
572
+ }
573
+ }
574
+ const t9 = checkDuplicatedSetupAcrossFiles(perFile.map((pf) => ({ file: pf.file, setupBlocks: pf.setupBlocks })));
575
+ for (const f of t9) {
576
+ totalFindings++;
577
+ console.log(`${f.description}`);
578
+ }
579
+ console.log(`\n${files.length} test file(s) scanned, ${totalFindings} finding(s).`);
580
+ process.exit(0);
581
+ }
@@ -0,0 +1 @@
1
+ a354d5be2d1db865faea92c1013eb2a62981e271
@@ -0,0 +1,23 @@
1
+ /**
2
+ * grammars.ts — Pre-built WASM grammar registry for CodeFlow parsing.
3
+ *
4
+ * Grammar assets are resolved from tree-sitter-wasms, avoiding the native
5
+ * Node ABI dependency that makes tree-sitter unavailable on newer runtimes.
6
+ */
7
+ export interface GrammarInfo {
8
+ language: string;
9
+ /** npm package containing the pre-built grammar asset */
10
+ packageName: string;
11
+ /** Version from the installed package */
12
+ version: string;
13
+ /** Whether the grammar asset resolved successfully */
14
+ available: boolean;
15
+ /** Resolved .wasm grammar path (null if unavailable) */
16
+ grammar: string | null;
17
+ }
18
+ /** Resolve a grammar asset by language identifier. */
19
+ export declare function loadGrammar(language: string): GrammarInfo;
20
+ /** Load all known grammars and return their status. */
21
+ export declare function loadAllGrammars(): GrammarInfo[];
22
+ /** Get the list of supported languages (those with available grammar assets). */
23
+ export declare function getSupportedLanguages(): string[];
@@ -0,0 +1,57 @@
1
+ /**
2
+ * grammars.ts — Pre-built WASM grammar registry for CodeFlow parsing.
3
+ *
4
+ * Grammar assets are resolved from tree-sitter-wasms, avoiding the native
5
+ * Node ABI dependency that makes tree-sitter unavailable on newer runtimes.
6
+ */
7
+ import { createRequire } from 'module';
8
+ const require = createRequire(import.meta.url);
9
+ const GRAMMAR_FILES = {
10
+ typescript: 'tree-sitter-typescript.wasm',
11
+ javascript: 'tree-sitter-javascript.wasm',
12
+ python: 'tree-sitter-python.wasm',
13
+ c: 'tree-sitter-c.wasm',
14
+ cpp: 'tree-sitter-cpp.wasm',
15
+ csharp: 'tree-sitter-c_sharp.wasm',
16
+ };
17
+ const PACKAGE_NAME = 'tree-sitter-wasms';
18
+ const _cache = new Map();
19
+ /** Resolve a grammar asset by language identifier. */
20
+ export function loadGrammar(language) {
21
+ const cached = _cache.get(language);
22
+ if (cached)
23
+ return cached;
24
+ const grammarFile = GRAMMAR_FILES[language];
25
+ if (!grammarFile) {
26
+ const info = { language, packageName: '', version: '', available: false, grammar: null };
27
+ _cache.set(language, info);
28
+ return info;
29
+ }
30
+ try {
31
+ const grammar = require.resolve(`${PACKAGE_NAME}/out/${grammarFile}`);
32
+ let version = '';
33
+ try {
34
+ version = require(`${PACKAGE_NAME}/package.json`).version ?? '';
35
+ }
36
+ catch {
37
+ // Package metadata is optional; the resolved asset is the availability proof.
38
+ }
39
+ const info = { language, packageName: PACKAGE_NAME, version, available: true, grammar };
40
+ _cache.set(language, info);
41
+ return info;
42
+ }
43
+ catch {
44
+ const info = { language, packageName: PACKAGE_NAME, version: '', available: false, grammar: null };
45
+ _cache.set(language, info);
46
+ return info;
47
+ }
48
+ }
49
+ /** Load all known grammars and return their status. */
50
+ export function loadAllGrammars() {
51
+ return Object.keys(GRAMMAR_FILES).map(loadGrammar);
52
+ }
53
+ /** Get the list of supported languages (those with available grammar assets). */
54
+ export function getSupportedLanguages() {
55
+ return loadAllGrammars().filter(grammar => grammar.available).map(grammar => grammar.language);
56
+ }
57
+ //# sourceMappingURL=grammars.js.map