@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.
- package/.claude-plugin/plugin.json +284 -1
- package/VALIDATOR-ARCHITECTURE.md +534 -0
- package/commands/analyze-tests.md +11 -0
- package/commands/check-clean-code.md +11 -0
- package/commands/check-packages.md +10 -0
- package/commands/compare-compliance.md +14 -0
- package/commands/full-analysis.md +50 -0
- package/commands/get-refactoring-plan.md +13 -0
- package/commands/quick-check.md +13 -0
- package/commands/recover.md +149 -0
- package/commands/review-arch.md +12 -0
- package/commands/review.md +12 -113
- package/commands/suggest-patterns.md +11 -0
- package/commands/validate-solid.md +11 -0
- package/package.json +14 -2
- package/scripts/architecture-score.mjs +157 -0
- package/scripts/clean-code-score.mjs +177 -0
- package/scripts/duplication-score.mjs +66 -0
- package/scripts/lib/architecture-scoring.mjs +695 -0
- package/scripts/lib/clean-code-scoring.mjs +258 -0
- package/scripts/lib/duplication-scoring.mjs +238 -0
- package/scripts/lib/language-plugin.mjs +82 -0
- package/scripts/lib/package-metrics.mjs +439 -0
- package/scripts/lib/pattern-scoring.mjs +351 -0
- package/scripts/lib/plugins/treesitter.mjs +1182 -0
- package/scripts/lib/plugins/typescript.mjs +672 -0
- package/scripts/lib/refactoring-scoring.mjs +307 -0
- package/scripts/lib/solid-scoring.mjs +101 -0
- package/scripts/lib/test-smell-scoring.mjs +581 -0
- package/scripts/lib/vendor/codeflow-parser/.source-commit +1 -0
- package/scripts/lib/vendor/codeflow-parser/grammars.d.ts +23 -0
- package/scripts/lib/vendor/codeflow-parser/grammars.js +57 -0
- package/scripts/lib/vendor/codeflow-parser/memberFacts.d.ts +274 -0
- package/scripts/lib/vendor/codeflow-parser/memberFacts.js +1117 -0
- package/scripts/lib/vendor/codeflow-parser/nativeParser.d.ts +115 -0
- package/scripts/lib/vendor/codeflow-parser/nativeParser.js +759 -0
- package/scripts/lib/vendor/codeflow-parser/package.json +3 -0
- package/scripts/lib/vendor/codeflow-parser/xmlParser.d.ts +77 -0
- package/scripts/lib/vendor/codeflow-parser/xmlParser.js +400 -0
- package/scripts/package-metrics-cli.mjs +112 -0
- package/scripts/pattern-score.mjs +143 -0
- package/scripts/refactoring-score.mjs +253 -0
- package/scripts/solid-score.mjs +337 -0
- package/skills/architecture-reviewer/SKILL.md +287 -0
- package/skills/clean-code-analyzer/SKILL.md +147 -0
- package/skills/package-design/SKILL.md +118 -0
- package/skills/pattern-advisor/SKILL.md +237 -0
- package/skills/pattern-refactoring-guide/SKILL.md +262 -0
- package/skills/review/SKILL.md +29 -0
- package/skills/solid-validator/SKILL.md +92 -0
- package/skills/testing-strategy/SKILL.md +132 -0
- package/tests/lib/architecture-scoring.test.mjs +335 -0
- package/tests/lib/clean-code-scoring.test.mjs +241 -0
- package/tests/lib/duplication-scoring.test.mjs +144 -0
- package/tests/lib/fixtures.mjs +58 -0
- package/tests/lib/package-metrics.test.mjs +241 -0
- package/tests/lib/pattern-scoring.test.mjs +251 -0
- package/tests/lib/refactoring-scoring.test.mjs +264 -0
- package/tests/lib/solid-scoring.test.mjs +291 -0
- 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
|