@orangepro/orangepro-mcp 0.1.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. package/scripts/spikes/failure-summary.mjs +29 -0
@@ -0,0 +1,430 @@
1
+ import ts from "typescript";
2
+ import { redactSecrets } from "../util/redact.js";
3
+ export const MAX_SYMBOLS_PER_FILE = 1000;
4
+ const MAX_TEST_NAMES_PER_FILE = 60;
5
+ const SYMBOL_PATTERNS_BY_LANG = {
6
+ // Python: module-level defs are functions; indented defs are methods (a class
7
+ // still counts once — methods do not multiply the class itself).
8
+ python: [
9
+ { re: /^(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/gm, kind: "function" },
10
+ { re: /^[ \t]+(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/gm, kind: "method" },
11
+ { re: /^\s*class\s+([A-Za-z_][\w]*)\s*[:(]/gm, kind: "class" }
12
+ ],
13
+ // Line-anchored (with /m): defense-in-depth alongside comment/string blanking,
14
+ // and avoids substring matches like `myfunc Foo(`.
15
+ go: [{ re: /^\s*func\s+([A-Z][\w]*)\s*\(/gm, kind: "function" }],
16
+ // Allow leading annotations on the same line (`@Entity public class X`,
17
+ // `@Getter @Setter public class Y`) — idiomatic Spring/JPA/Lombok.
18
+ java: [{ re: /^\s*(?:@\w+(?:\([^)]*\))?\s+)*public\s+(?:static\s+)?class\s+([A-Za-z_][\w]*)/gm, kind: "class" }]
19
+ };
20
+ /** Which extractor family a classified language (from classify.languageOf) uses. */
21
+ function symbolLangFamily(language) {
22
+ // `|| "typescript"` (not `??`) so an empty-string language also defaults to TS.
23
+ switch ((language || "typescript").toLowerCase()) {
24
+ case "typescript":
25
+ case "javascript":
26
+ return "tsjs";
27
+ case "python":
28
+ return "python";
29
+ case "go":
30
+ return "go";
31
+ case "java":
32
+ return "java";
33
+ default:
34
+ return "none";
35
+ }
36
+ }
37
+ // Per-language comment + string/docstring grammar. Triple-quoted Python strings
38
+ // are listed BEFORE the single-char ones so the longer delimiter wins.
39
+ const LEX_BY_LANG = {
40
+ python: {
41
+ lineComment: "#",
42
+ strings: [
43
+ // escape:true — Python honors `\"""` inside a triple, so the escaped
44
+ // delimiter must NOT close the string early.
45
+ { open: '"""', close: '"""', escape: true, multiline: true },
46
+ { open: "'''", close: "'''", escape: true, multiline: true },
47
+ { open: '"', close: '"', escape: true, multiline: false },
48
+ { open: "'", close: "'", escape: true, multiline: false }
49
+ ]
50
+ },
51
+ go: {
52
+ lineComment: "//",
53
+ blockComment: ["/*", "*/"],
54
+ strings: [
55
+ { open: "`", close: "`", escape: false, multiline: true }, // raw string (no escapes)
56
+ { open: '"', close: '"', escape: true, multiline: false },
57
+ { open: "'", close: "'", escape: true, multiline: false } // rune
58
+ ]
59
+ },
60
+ java: {
61
+ lineComment: "//",
62
+ blockComment: ["/*", "*/"],
63
+ strings: [
64
+ { open: '"""', close: '"""', escape: true, multiline: true }, // text block (Java 15+, honors escapes) — must precede the single "
65
+ { open: '"', close: '"', escape: true, multiline: false },
66
+ { open: "'", close: "'", escape: true, multiline: false } // char
67
+ ]
68
+ }
69
+ };
70
+ /**
71
+ * Blank out comment and string/docstring contents (preserving newlines) so the
72
+ * plain-text Python/Go/Java symbol regexes never match `def`/`class`/`func`
73
+ * inside a docstring, comment, or string literal — the same comment/string
74
+ * safety the TS/JS AST path gets for free. A small conservative lexer, not a
75
+ * full parser: over-blanking a malformed literal only loses a symbol, never
76
+ * invents one.
77
+ */
78
+ function blankCommentsAndStrings(content, family) {
79
+ const rules = LEX_BY_LANG[family];
80
+ const n = content.length;
81
+ const out = new Array(n);
82
+ const blankRange = (from, to) => {
83
+ for (let k = from; k < to; k++)
84
+ out[k] = content[k] === "\n" ? "\n" : " ";
85
+ };
86
+ let i = 0;
87
+ while (i < n) {
88
+ if (content.startsWith(rules.lineComment, i)) {
89
+ let j = i;
90
+ while (j < n && content[j] !== "\n")
91
+ j++;
92
+ blankRange(i, j);
93
+ i = j;
94
+ continue;
95
+ }
96
+ if (rules.blockComment && content.startsWith(rules.blockComment[0], i)) {
97
+ const close = rules.blockComment[1];
98
+ const found = content.indexOf(close, i + rules.blockComment[0].length);
99
+ const j = found === -1 ? n : found + close.length;
100
+ blankRange(i, j);
101
+ i = j;
102
+ continue;
103
+ }
104
+ let matched = false;
105
+ for (const s of rules.strings) {
106
+ if (!content.startsWith(s.open, i))
107
+ continue;
108
+ let j = i + s.open.length;
109
+ while (j < n) {
110
+ if (s.escape && content[j] === "\\") {
111
+ j += 2;
112
+ continue;
113
+ }
114
+ if (!s.multiline && content[j] === "\n")
115
+ break; // unterminated single-line literal
116
+ if (content.startsWith(s.close, j)) {
117
+ j += s.close.length;
118
+ break;
119
+ }
120
+ j++;
121
+ }
122
+ blankRange(i, Math.min(j, n));
123
+ i = Math.min(j, n);
124
+ matched = true;
125
+ break;
126
+ }
127
+ if (matched)
128
+ continue;
129
+ out[i] = content[i];
130
+ i++;
131
+ }
132
+ return out.join("");
133
+ }
134
+ /** Parens / as / satisfies / angle-bracket assertions are transparent wrappers —
135
+ * unwrapping them is still PROOF of callability, not evaluation. */
136
+ function unwrapInitializer(e) {
137
+ let cur = e;
138
+ for (;;) {
139
+ if (ts.isParenthesizedExpression(cur))
140
+ cur = cur.expression;
141
+ else if (ts.isAsExpression(cur) || ts.isSatisfiesExpression(cur) || ts.isTypeAssertionExpression(cur))
142
+ cur = cur.expression;
143
+ else
144
+ return cur;
145
+ }
146
+ }
147
+ function hasExportModifier(n) {
148
+ return n.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
149
+ }
150
+ /**
151
+ * The LOCAL identifier a `export default <expr>` ultimately wraps, or null.
152
+ * - `export default Foo` → "Foo"
153
+ * - `export default connect(mapState)(Foo)` → "Foo" (HOC: the LAST arg of the
154
+ * OUTERMOST call, recursively — so inner-call args like mapState are never
155
+ * mistaken for the subject)
156
+ * - `export default memo(Foo)` / `injectIntl(connect(...)(Foo))` → "Foo"
157
+ * Conservative: anonymous defaults, object/JSX/member expressions → null. The
158
+ * CALLER still requires the name to be a locally-declared callable before
159
+ * recording it, so an imported or non-behavior subject is never counted.
160
+ */
161
+ function defaultSubject(expr) {
162
+ let cur = unwrapInitializer(expr);
163
+ for (let depth = 0; depth < 8; depth++) {
164
+ if (ts.isIdentifier(cur))
165
+ return cur.text;
166
+ if (ts.isCallExpression(cur) && cur.arguments.length > 0) {
167
+ cur = unwrapInitializer(cur.arguments[cur.arguments.length - 1]);
168
+ continue;
169
+ }
170
+ return null;
171
+ }
172
+ return null;
173
+ }
174
+ /**
175
+ * Conservative ALLOWLIST for a const that is the subject of a default export.
176
+ * A default export is the file's primary behavior, but NOT every non-literal
177
+ * const is behavior — `require('./x').default` re-export shims, `new Foo()`
178
+ * singletons, and `createRoutes(...)` config objects are values, not testable
179
+ * behavior surface. Counts ONLY:
180
+ * - an arrow/function-expression const (a function, any case), or
181
+ * - a PascalCase const wrapped by a call or tagged template — the React
182
+ * component idiom (forwardRef/memo/styled/connect/HOC) — EXCLUDING bare
183
+ * `require(...)` CommonJS shims.
184
+ * Everything else (new-expressions, property access, lowercase call results,
185
+ * plain literals) is skipped.
186
+ */
187
+ function isComponentLikeConst(name, init) {
188
+ if (!init)
189
+ return false;
190
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init))
191
+ return true;
192
+ if (!/^[A-Z]/.test(name))
193
+ return false; // component wrappers are PascalCase
194
+ if (ts.isTaggedTemplateExpression(init))
195
+ return true; // styled.div``
196
+ if (ts.isCallExpression(init)) {
197
+ if (ts.isIdentifier(init.expression) && init.expression.text === "require")
198
+ return false; // CommonJS shim
199
+ return true; // forwardRef(...) / memo(...) / connect(...)(X) / menuItem(Impl) / …
200
+ }
201
+ return false; // new Foo(), require(...).default (property access), etc.
202
+ }
203
+ /**
204
+ * Exported TS/JS symbols from the AST — comment- and string-safe by
205
+ * construction (the parser sees structure, not text). Exported function and
206
+ * class declarations, and exported const bindings (callability proven per
207
+ * declarator, so declaration lists keep every callable). Interfaces, type
208
+ * aliases, and enums are intentionally NOT behavior. Parses under BOTH grammars
209
+ * and unions — `<T>(v) => v` is an arrow in .ts but JSX in .tsx and this layer
210
+ * never sees the filename; call-wrapped factories (memo(() => {}), styled(...))
211
+ * stay non-callable, which is the documented v1 policy.
212
+ */
213
+ function collectTsJsExports(content) {
214
+ const acc = new Map();
215
+ const nodeLines = (sf, node) => ({
216
+ start_line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
217
+ end_line: sf.getLineAndCharacterOfPosition(node.getEnd()).line + 1
218
+ });
219
+ const record = (name, kind, callable, member_of, lines) => {
220
+ const prev = acc.get(name);
221
+ if (!prev) {
222
+ acc.set(name, {
223
+ kind,
224
+ callable,
225
+ ...(member_of ? { member_of } : {}),
226
+ ...(lines?.start_line ? { start_line: lines.start_line } : {}),
227
+ ...(lines?.end_line ? { end_line: lines.end_line } : {})
228
+ });
229
+ return;
230
+ }
231
+ // OR callability across the two grammar passes; prefer a real fn/class kind.
232
+ acc.set(name, {
233
+ kind: prev.kind === "const" && kind !== "const" ? kind : prev.kind,
234
+ callable: prev.callable || callable,
235
+ ...(prev.member_of ?? member_of ? { member_of: prev.member_of ?? member_of } : {}),
236
+ ...(prev.start_line ?? lines?.start_line ? { start_line: prev.start_line ?? lines?.start_line } : {}),
237
+ ...(prev.end_line ?? lines?.end_line ? { end_line: prev.end_line ?? lines?.end_line } : {})
238
+ });
239
+ };
240
+ // Methods of a class body → qualified `Class.method` member symbols. Only the
241
+ // PUBLIC surface counts — `private`/`protected` (and JS `#private`) members are
242
+ // internal, mirroring the export-surface boundary that keeps module-private
243
+ // functions out of the denominator. Constructor + get/set accessors excluded.
244
+ // Recorded once per class (the two grammar passes would otherwise duplicate).
245
+ const membersDone = new Set();
246
+ const isInternalMember = (m) => {
247
+ if (ts.isPrivateIdentifier(m.name))
248
+ return true; // JS `#method`
249
+ return (ts.canHaveModifiers(m) &&
250
+ (ts.getModifiers(m)?.some((mod) => mod.kind === ts.SyntaxKind.PrivateKeyword || mod.kind === ts.SyntaxKind.ProtectedKeyword) ??
251
+ false));
252
+ };
253
+ const recordClassMethods = (sourceFile, node, className) => {
254
+ if (membersDone.has(className))
255
+ return;
256
+ membersDone.add(className);
257
+ // A `declare class` declares an ambient shape — none of its members have a
258
+ // runtime implementation to test.
259
+ if (ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((mod) => mod.kind === ts.SyntaxKind.DeclareKeyword))
260
+ return;
261
+ for (const m of node.members) {
262
+ // Require a runtime BODY: abstract methods, ambient declarations, and
263
+ // overload SIGNATURES have none — they are contracts, not testable
264
+ // behavior. The overload IMPLEMENTATION (which has the body) still counts.
265
+ if (ts.isMethodDeclaration(m) &&
266
+ m.body &&
267
+ m.name &&
268
+ ts.isIdentifier(m.name) &&
269
+ m.name.text !== "constructor" &&
270
+ !isInternalMember(m)) {
271
+ record(`${className}.${m.name.text}`, "method", false, className, nodeLines(sourceFile, m));
272
+ }
273
+ }
274
+ };
275
+ for (const scriptKind of [ts.ScriptKind.TS, ts.ScriptKind.TSX]) {
276
+ const sf = ts.createSourceFile(scriptKind === ts.ScriptKind.TS ? "symbols.ts" : "symbols.tsx", content, ts.ScriptTarget.Latest, false, scriptKind);
277
+ // Local declarations (exported OR not) so `const X = …; export default X` —
278
+ // the common React component shape — resolves to its real subject. Default
279
+ // exports (`export default <expr>`) are collected and resolved after the
280
+ // pass, since the subject const may be declared before OR after them.
281
+ // Local declarations eligible to be a default-export behavior subject:
282
+ // functions/classes always; consts only when component-like (isComponentLikeConst).
283
+ const localDecls = new Map();
284
+ const classNodes = new Map(); // for default-subject member extraction
285
+ const defaultExprs = [];
286
+ for (const stmt of sf.statements) {
287
+ if (ts.isFunctionDeclaration(stmt) && stmt.name) {
288
+ const lines = nodeLines(sf, stmt);
289
+ localDecls.set(stmt.name.text, { kind: "function", eligible: true, lines });
290
+ if (hasExportModifier(stmt))
291
+ record(stmt.name.text, "function", false, undefined, lines);
292
+ }
293
+ else if (ts.isClassDeclaration(stmt) && stmt.name) {
294
+ // A `declare class` is an ambient TYPE shape with no runtime implementation
295
+ // to test — skip it entirely (neither the class node nor its members are
296
+ // behavior, and it must not become a default-export subject).
297
+ if (ts.canHaveModifiers(stmt) && ts.getModifiers(stmt)?.some((mod) => mod.kind === ts.SyntaxKind.DeclareKeyword))
298
+ continue;
299
+ const lines = nodeLines(sf, stmt);
300
+ localDecls.set(stmt.name.text, { kind: "class", eligible: true, lines });
301
+ classNodes.set(stmt.name.text, stmt);
302
+ if (hasExportModifier(stmt)) {
303
+ record(stmt.name.text, "class", false, undefined, lines);
304
+ recordClassMethods(sf, stmt, stmt.name.text); // exported class → its methods are behaviors
305
+ }
306
+ }
307
+ else if (ts.isVariableStatement(stmt) && (stmt.declarationList.flags & ts.NodeFlags.Const) !== 0) {
308
+ const exported = hasExportModifier(stmt); // const only (match prior scope; not let/var)
309
+ for (const decl of stmt.declarationList.declarations) {
310
+ if (!ts.isIdentifier(decl.name))
311
+ continue;
312
+ const init = decl.initializer ? unwrapInitializer(decl.initializer) : undefined;
313
+ const callable = !!init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init));
314
+ const lines = nodeLines(sf, decl);
315
+ localDecls.set(decl.name.text, { kind: "const", eligible: isComponentLikeConst(decl.name.text, init), lines });
316
+ if (exported)
317
+ record(decl.name.text, "const", callable, undefined, lines); // regular exports keep the strict callable rule
318
+ }
319
+ }
320
+ else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
321
+ // `export default <expr>` — NOT `export = …` (CommonJS). Re-export barrels
322
+ // (`export { default } from './x'`) are ExportDeclarations, never collected.
323
+ defaultExprs.push(stmt.expression);
324
+ }
325
+ }
326
+ // A default export is the file's primary behavior. Record its LOCAL subject
327
+ // only when it is a function/class or a component-like const — skips require()
328
+ // re-export shims, new-expression singletons, config objects, and any
329
+ // non-local (imported) subject. Conservative: no phantom behavior.
330
+ for (const expr of defaultExprs) {
331
+ const name = defaultSubject(expr);
332
+ if (!name)
333
+ continue;
334
+ const d = localDecls.get(name);
335
+ if (!d || !d.eligible)
336
+ continue;
337
+ record(name, d.kind, d.kind === "const", undefined, d.lines); // component-like const → callable
338
+ if (d.kind === "class") {
339
+ const node = classNodes.get(name);
340
+ if (node)
341
+ recordClassMethods(sf, node, name); // default-exported class → its methods
342
+ }
343
+ }
344
+ }
345
+ return acc;
346
+ }
347
+ /**
348
+ * Cheap, safe symbol metadata. Names only — never signatures or bodies.
349
+ * LANGUAGE-AWARE: only the extractor for `language` (from classify.languageOf)
350
+ * runs, so another language's regex can never mint phantom symbols from a
351
+ * comment or string. Defaults to TS/JS when language is omitted.
352
+ */
353
+ export function extractSymbolsWithMeta(content, language) {
354
+ const byName = new Map();
355
+ let truncated = false;
356
+ const lineOf = (index) => content.slice(0, Math.max(0, index)).split(/\r?\n/).length;
357
+ const consider = (name, kind, callable, member_of, lines) => {
358
+ const existing = byName.get(name);
359
+ if (existing) {
360
+ // Prefer a real declaration over a const match (a name that surfaces as
361
+ // both a const and a fn/class collapses to the fn/class).
362
+ if (existing.symbol_kind === "const" && kind !== "const")
363
+ byName.set(name, { name, symbol_kind: kind });
364
+ return;
365
+ }
366
+ if (byName.size >= MAX_SYMBOLS_PER_FILE) {
367
+ truncated = true;
368
+ return;
369
+ }
370
+ const sym = kind === "const" ? { name, symbol_kind: kind, callable: callable ?? false } : { name, symbol_kind: kind };
371
+ if (lines?.start_line)
372
+ sym.start_line = lines.start_line;
373
+ if (lines?.end_line)
374
+ sym.end_line = lines.end_line;
375
+ if (member_of)
376
+ sym.member_of = member_of;
377
+ byName.set(name, sym);
378
+ };
379
+ const family = symbolLangFamily(language);
380
+ if (family === "tsjs") {
381
+ // TS/JS from the AST — comment/string-safe. Cheap gate: any "export"
382
+ // followed by whitespace (matches the old `export\s+`, so `export\tfunction`
383
+ // / `export\nclass` are NOT skipped).
384
+ if (/export\s/.test(content)) {
385
+ for (const [name, sym] of collectTsJsExports(content))
386
+ consider(name, sym.kind, sym.callable, sym.member_of, sym);
387
+ }
388
+ }
389
+ else if (family !== "none") {
390
+ // Blank comments/strings/docstrings first so a `def`/`class`/`func` inside
391
+ // them can never mint a phantom symbol (mirrors the AST safety for TS/JS).
392
+ const code = blankCommentsAndStrings(content, family);
393
+ for (const { re, kind } of SYMBOL_PATTERNS_BY_LANG[family]) {
394
+ re.lastIndex = 0;
395
+ let m;
396
+ while ((m = re.exec(code)) !== null) {
397
+ if (m[1])
398
+ consider(m[1], kind, undefined, undefined, { start_line: lineOf(m.index), end_line: lineOf(m.index) });
399
+ }
400
+ }
401
+ }
402
+ return { symbols: [...byName.values()], truncated };
403
+ }
404
+ export function extractSymbols(content, language) {
405
+ return extractSymbolsWithMeta(content, language).symbols;
406
+ }
407
+ const TEST_NAME_PATTERNS = [
408
+ /(?:describe|it|test)\s*\(\s*["'`]([^"'`]{1,160})["'`]/g,
409
+ /def\s+(test_[A-Za-z0-9_]{1,120})\s*\(/g,
410
+ /func\s+(Test[A-Za-z0-9_]{1,120})\s*\(/g
411
+ ];
412
+ /** Extract human-readable test names (behavior hints) from a test file. */
413
+ export function extractTestNames(content) {
414
+ const seen = new Set();
415
+ const out = [];
416
+ for (const re of TEST_NAME_PATTERNS) {
417
+ re.lastIndex = 0;
418
+ let m;
419
+ while ((m = re.exec(content)) !== null) {
420
+ const name = redactSecrets(m[1].trim());
421
+ if (!name || seen.has(name))
422
+ continue;
423
+ seen.add(name);
424
+ out.push(name);
425
+ if (out.length >= MAX_TEST_NAMES_PER_FILE)
426
+ return out;
427
+ }
428
+ }
429
+ return out;
430
+ }
@@ -0,0 +1,135 @@
1
+ // AST/framework-aware test-layer classifier (Phase 4.6).
2
+ //
3
+ // Replaces the path-only `testLayerOf` heuristic as the AUTHORITATIVE signal for a
4
+ // test file's layer. The layer is read from the file's parsed imports + call
5
+ // expressions (the framework actually used), with path/name hints as a
6
+ // low-confidence fallback only. The honest default is `unknown` — we NEVER
7
+ // silently call an unclassifiable test `unit`, because that would fold e2e/api
8
+ // behaviors (which a structural resolver cannot confirm) into the structural
9
+ // confirmed-% and corrupt the headline.
10
+ //
11
+ // Precedence (first match wins): e2e > api > integration > component > unit > unknown.
12
+ import ts from "typescript";
13
+ import { extOf, testLayerOf } from "./classify.js";
14
+ // Import specifiers that pin a layer. A trailing "/" means a scope/namespace prefix.
15
+ const E2E_IMPORTS = ["@playwright/test", "playwright", "cypress", "@cypress/", "puppeteer", "webdriverio", "@wdio/"];
16
+ const API_IMPORTS = ["supertest", "@nestjs/testing"];
17
+ const INTEGRATION_IMPORTS = [
18
+ "testcontainers",
19
+ "@testcontainers/",
20
+ "pg",
21
+ "mongodb",
22
+ "mongoose",
23
+ "ioredis",
24
+ "redis",
25
+ "mysql",
26
+ "mysql2",
27
+ "better-sqlite3",
28
+ "knex",
29
+ "typeorm",
30
+ "prisma"
31
+ ];
32
+ const COMPONENT_IMPORTS = ["@testing-library/", "@vue/test-utils", "enzyme", "@solidjs/testing-library"];
33
+ const TSJS_EXTS = new Set(["ts", "tsx", "js", "jsx", "mts", "cts", "mjs", "cjs"]);
34
+ function isInternalSpecifier(spec) {
35
+ return spec.startsWith(".") || spec.startsWith("/");
36
+ }
37
+ function matchesImport(specs, list) {
38
+ return specs.find((s) => list.some((p) => (p.endsWith("/") ? s.startsWith(p) : s === p || s.startsWith(p + "/"))));
39
+ }
40
+ function calleeText(call) {
41
+ const e = call.expression;
42
+ if (ts.isIdentifier(e))
43
+ return e.text;
44
+ if (ts.isPropertyAccessExpression(e)) {
45
+ const base = ts.isIdentifier(e.expression) ? e.expression.text + "." : "";
46
+ return base + e.name.text;
47
+ }
48
+ return "";
49
+ }
50
+ /** A type-only import (`import type ...` or an all-`type` named import) — never a framework signal. */
51
+ function isTypeOnlyImport(node) {
52
+ const clause = node.importClause;
53
+ if (!clause)
54
+ return false; // side-effect import runs code
55
+ if (clause.isTypeOnly)
56
+ return true;
57
+ const named = clause.namedBindings;
58
+ if (clause.name === undefined && !(named && ts.isNamespaceImport(named)) && named && ts.isNamedImports(named)) {
59
+ return named.elements.length > 0 && named.elements.every((el) => el.isTypeOnly);
60
+ }
61
+ return false;
62
+ }
63
+ /**
64
+ * Classify a test file's layer from its parsed imports + calls (TS/JS), falling
65
+ * back to path hints (low confidence) for non-TS/JS or unread files. Never
66
+ * defaults to `unit`: no decisive signal yields `unknown`.
67
+ */
68
+ export function classifyTestLayer(relPath, content) {
69
+ const ext = extOf(relPath);
70
+ const isTsJs = TSJS_EXTS.has(ext);
71
+ const pathLayer = testLayerOf(relPath); // path-only; "unknown" when no path signal
72
+ if (!content || !isTsJs) {
73
+ // No AST available — the path hint is the only (low-confidence) signal.
74
+ if (pathLayer !== "unknown")
75
+ return { layer: pathLayer, confidence: "low", signals: [`path:${pathLayer}`] };
76
+ return { layer: "unknown", confidence: "none", signals: [] };
77
+ }
78
+ const sf = ts.createSourceFile(relPath, content, ts.ScriptTarget.Latest, false, ext === "tsx" || ext === "jsx" ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
79
+ const importSpecs = [];
80
+ const calls = [];
81
+ let hasJsx = false;
82
+ let hasInternalImport = false;
83
+ const visit = (node) => {
84
+ if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
85
+ // Type-only imports never witness a framework's RUNTIME use (a `import type
86
+ // { Pool } from "pg"` does not make a unit test an integration test).
87
+ if (!isTypeOnlyImport(node)) {
88
+ const s = node.moduleSpecifier.text;
89
+ importSpecs.push(s);
90
+ if (isInternalSpecifier(s))
91
+ hasInternalImport = true;
92
+ }
93
+ }
94
+ else if (ts.isCallExpression(node)) {
95
+ calls.push(calleeText(node));
96
+ }
97
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))
98
+ hasJsx = true;
99
+ ts.forEachChild(node, visit);
100
+ };
101
+ visit(sf);
102
+ // e2e — browser/driver IMPORT, or a DISTINCTIVE driver idiom (cy.* / browser.* /
103
+ // a Playwright page.* navigation). Bare `page.`/`goto`/`visit` are NOT trusted
104
+ // (collide with pagination / unrelated locals); a path hint alone never upgrades
105
+ // past unknown here (it would mislabel a unit test under an e2e/ dir).
106
+ const e2eImp = matchesImport(importSpecs, E2E_IMPORTS);
107
+ const e2eCall = calls.find((c) => /^cy\.\w/.test(c) ||
108
+ /^browser\.\w/.test(c) ||
109
+ /^page\.(goto|waitForURL|waitForNavigation|locator|getBy\w+|click|fill|press|check|selectOption)\b/.test(c));
110
+ if (e2eImp || e2eCall)
111
+ return { layer: "e2e", confidence: "high", signals: [`e2e:${e2eImp ?? e2eCall}`] };
112
+ // api — supertest / nestjs testing IMPORT. A bare `request(...)` call is NOT
113
+ // trusted (collides with a local request() helper); rely on the import.
114
+ const apiImp = matchesImport(importSpecs, API_IMPORTS);
115
+ if (apiImp)
116
+ return { layer: "api", confidence: "high", signals: [`api:${apiImp}`] };
117
+ // integration — a real DB/container client RUNTIME import.
118
+ const intImp = matchesImport(importSpecs, INTEGRATION_IMPORTS);
119
+ if (intImp)
120
+ return { layer: "integration", confidence: "high", signals: [`integration:${intImp}`] };
121
+ // component — RTL/component-testing IMPORT, a DISTINCTIVE RTL idiom (screen.* /
122
+ // userEvent.*), or a JSX subject rendered in a .tsx/.jsx test. Bare
123
+ // `render()`/`mount()` are NOT trusted (generic names); the import covers RTL.
124
+ const compImp = matchesImport(importSpecs, COMPONENT_IMPORTS);
125
+ const compCall = calls.find((c) => /^screen\.\w/.test(c) || /^userEvent\.\w/.test(c));
126
+ if (compImp || compCall || (hasJsx && (ext === "tsx" || ext === "jsx"))) {
127
+ return { layer: "component", confidence: "high", signals: [`component:${compImp ?? compCall ?? "jsx"}`] };
128
+ }
129
+ // unit — exercises an in-repo module directly, with no higher-tier signal.
130
+ if (hasInternalImport)
131
+ return { layer: "unit", confidence: "medium", signals: ["unit:internal-import"] };
132
+ // No decisive AST signal: honest unknown (path api/integration/component names
133
+ // collide with module dirs and do NOT upgrade past unknown on their own).
134
+ return { layer: "unknown", confidence: "none", signals: [] };
135
+ }