@ngockhoale/ukit 1.4.0 → 1.4.2

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 (40) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/bug/triageBug.js +1 -33
  4. package/src/cli/commands/install.js +5 -10
  5. package/src/context/detectProjectContext.js +3 -24
  6. package/src/core/compact/index.js +19 -27
  7. package/src/core/ensureGitignore.js +1 -1
  8. package/src/core/fileOps.js +41 -2
  9. package/src/core/memory/hygiene.js +17 -1
  10. package/src/core/memory/store.js +14 -36
  11. package/src/core/metadata.js +5 -5
  12. package/src/core/output/index.js +20 -20
  13. package/src/core/packageManager.js +51 -0
  14. package/src/core/router/router.js +22 -6
  15. package/src/core/runInstallPipeline.js +1 -36
  16. package/src/core/runtimeConfig.js +71 -3
  17. package/src/core/token/index.js +21 -1
  18. package/src/core/uninstall.js +15 -38
  19. package/src/index/buildIndex.js +217 -49
  20. package/src/index/gitHooks.js +32 -7
  21. package/src/index/impactContext.js +16 -6
  22. package/src/index/importResolution.js +105 -28
  23. package/src/index/paths.js +29 -0
  24. package/src/index/queryIndex.js +20 -35
  25. package/src/index/relatedTests.js +15 -2
  26. package/src/index/routeCatalog.js +1 -1
  27. package/src/index/taskRouting.js +438 -18
  28. package/src/index/verificationPlan.js +2 -36
  29. package/templates/.claude/hooks/reinject-context.sh +2 -0
  30. package/templates/.claude/hooks/session-start.md +2 -0
  31. package/templates/.claude/hooks/skill-router.sh +657 -15
  32. package/templates/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/templates/.claude/ukit/index/route-task.mjs +475 -5
  34. package/templates/.claude/ukit/runtime/reinject-context.mjs +120 -3
  35. package/templates/.codex/README.md +8 -1
  36. package/templates/.codex/settings.json +53 -0
  37. package/templates/AGENTS.md +3 -0
  38. package/templates/CLAUDE.md +5 -1
  39. package/templates/ukit/README.md +1 -1
  40. package/templates/ukit/storage/config.json +61 -2
@@ -12,7 +12,7 @@ import { summarizeDiff, toDiffRows } from './report.js';
12
12
  import { writeInstallMetadata } from './metadata.js';
13
13
  import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
14
14
  import { ensureGitignore } from './ensureGitignore.js';
15
- import { readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
15
+ import { cleanupEmptyParents, readJsonIfExists, removeFileOrLinkOnly, resolveProjectRelativePath } from './fileOps.js';
16
16
 
17
17
  const AUTO_PRUNE_OBSOLETE_PREFIXES = [
18
18
  '.claude/skills/',
@@ -57,41 +57,6 @@ function shouldAutoPruneObsoletePath(relativePath) {
57
57
  || AUTO_PRUNE_OBSOLETE_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
58
58
  }
59
59
 
60
- async function isDirEmpty(dirPath) {
61
- try {
62
- const entries = await fs.readdir(dirPath);
63
- return entries.length === 0;
64
- } catch {
65
- return false;
66
- }
67
- }
68
-
69
- async function cleanupEmptyParents(targetPath, projectRoot) {
70
- let currentDir = path.dirname(targetPath);
71
- const stopDir = path.resolve(projectRoot);
72
-
73
- while (currentDir.startsWith(stopDir) && currentDir !== stopDir) {
74
- let stat;
75
- try {
76
- stat = await fs.lstat(currentDir);
77
- } catch {
78
- currentDir = path.dirname(currentDir);
79
- continue;
80
- }
81
-
82
- if (!stat.isDirectory()) {
83
- break;
84
- }
85
-
86
- if (!(await isDirEmpty(currentDir))) {
87
- break;
88
- }
89
-
90
- await fs.rmdir(currentDir);
91
- currentDir = path.dirname(currentDir);
92
- }
93
- }
94
-
95
60
  async function isTrackedManagedDirectoryTree({
96
61
  dirPath,
97
62
  projectRoot,
@@ -49,7 +49,7 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
49
49
  const safeOverrides = isPlainObject(overrides) ? overrides : {};
50
50
 
51
51
  return mergeObjects({
52
- version: '1.4.0',
52
+ version: '1.4.2',
53
53
  agent: 'claude-code',
54
54
  autonomy: {
55
55
  level: 'balanced',
@@ -112,6 +112,59 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
112
112
  advisorEnabled: true,
113
113
  maxAdvisorCalls: 3,
114
114
  },
115
+ orchestration: {
116
+ enabled: true,
117
+ orchestratorModel: 'claude-sonnet-4-6',
118
+ advisorEnabled: true,
119
+ contracts: {
120
+ 'tiny-fix': {
121
+ maxReadPasses: 0,
122
+ maxContextPulls: 0,
123
+ verificationPolicy: 'minimal-or-targeted',
124
+ completionRule: 'never-claim-done-without-write',
125
+ delegationPolicy: 'disallow',
126
+ },
127
+ 'local-fix': {
128
+ maxReadPasses: 1,
129
+ maxContextPulls: 1,
130
+ verificationPolicy: 'targeted-if-covered',
131
+ completionRule: 'require-write',
132
+ delegationPolicy: 'disallow',
133
+ },
134
+ 'local-build': {
135
+ maxReadPasses: 2,
136
+ maxContextPulls: 1,
137
+ verificationPolicy: 'targeted-if-covered',
138
+ completionRule: 'require-write',
139
+ delegationPolicy: 'disallow-by-default',
140
+ },
141
+ 'find-cause': {
142
+ maxReadPassesBeforeReassess: 3,
143
+ verificationPolicy: 'root-cause-then-targeted',
144
+ completionRule: 'never-claim-fixed-without-write-and-verification',
145
+ delegationPolicy: 'allow-specialized-debug-lane',
146
+ },
147
+ 'shared-edit': {
148
+ maxReadPasses: 2,
149
+ maxContextPulls: 2,
150
+ verificationPolicy: 'targeted-then-widen-on-risk',
151
+ completionRule: 'require-write-and-verification',
152
+ delegationPolicy: 'allow-qualified-sidecar',
153
+ },
154
+ 'map-impact': {
155
+ maxReadPasses: 3,
156
+ maxContextPulls: 3,
157
+ verificationPolicy: 'impact-first-then-targeted-then-widen-on-risk',
158
+ completionRule: 'require-impact-evidence-before-edit-claim',
159
+ delegationPolicy: 'allow-impact-sidecar',
160
+ },
161
+ 'review-release': {
162
+ verificationPolicy: 'evidence-first',
163
+ completionRule: 'report-findings-not-implementation',
164
+ delegationPolicy: 'allow-review-sidecar',
165
+ },
166
+ },
167
+ },
115
168
  memory: {
116
169
  enabled: true,
117
170
  autoCapture: true,
@@ -268,16 +321,27 @@ export function validateRuntimeConfig(config) {
268
321
  errors.push('router must be an object.');
269
322
  } else {
270
323
  pushBooleanError(errors, config.router.enabled, 'router.enabled');
271
- if (typeof config.router.defaultModel !== 'string' || config.router.defaultModel.trim() == '') {
324
+ if (typeof config.router.defaultModel !== 'string' || config.router.defaultModel.trim() === '') {
272
325
  errors.push('router.defaultModel must be a non-empty string.');
273
326
  }
274
- if (typeof config.router.advisorModel !== 'string' || config.router.advisorModel.trim() == '') {
327
+ if (typeof config.router.advisorModel !== 'string' || config.router.advisorModel.trim() === '') {
275
328
  errors.push('router.advisorModel must be a non-empty string.');
276
329
  }
277
330
  pushBooleanError(errors, config.router.advisorEnabled, 'router.advisorEnabled');
278
331
  pushPositiveNumberError(errors, config.router.maxAdvisorCalls, 'router.maxAdvisorCalls');
279
332
  }
280
333
 
334
+ if (!isPlainObject(config.orchestration)) {
335
+ errors.push('orchestration must be an object.');
336
+ } else {
337
+ pushBooleanError(errors, config.orchestration.enabled, 'orchestration.enabled');
338
+ pushNonEmptyStringError(errors, config.orchestration.orchestratorModel, 'orchestration.orchestratorModel');
339
+ pushBooleanError(errors, config.orchestration.advisorEnabled, 'orchestration.advisorEnabled');
340
+ if (!isPlainObject(config.orchestration.contracts)) {
341
+ errors.push('orchestration.contracts must be an object.');
342
+ }
343
+ }
344
+
281
345
  if (!isPlainObject(config.memory)) {
282
346
  errors.push('memory must be an object.');
283
347
  } else {
@@ -372,5 +436,9 @@ export async function inspectRuntimeConfig(projectRoot) {
372
436
 
373
437
  export async function loadRuntimeConfig(projectRoot) {
374
438
  const inspection = await inspectRuntimeConfig(projectRoot);
439
+ if (inspection.exists && !inspection.valid) {
440
+ console.warn(`[UKit] Invalid UKit runtime config: ${inspection.errors.join('; ')}`);
441
+ return buildDefaultRuntimeConfig();
442
+ }
375
443
  return inspection.config;
376
444
  }
@@ -30,6 +30,25 @@ export function estimateTokenCount(value) {
30
30
  return Math.max(1, Math.ceil(text.length / 4));
31
31
  }
32
32
 
33
+ export function normalizeLineForDedupe(line) {
34
+ return String(line ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
35
+ }
36
+
37
+ export function uniqueLines(lines) {
38
+ const seen = new Set();
39
+ const unique = [];
40
+ for (const line of lines) {
41
+ const trimmed = String(line ?? '').trim();
42
+ const normalized = normalizeLineForDedupe(trimmed);
43
+ if (!normalized || seen.has(normalized)) {
44
+ continue;
45
+ }
46
+ seen.add(normalized);
47
+ unique.push(trimmed);
48
+ }
49
+ return unique;
50
+ }
51
+
33
52
  export function buildCompactMachineKey(prefix, payload = {}) {
34
53
  return `${prefix}:${crypto.createHash('sha256').update(JSON.stringify(payload)).digest('base64url')}`;
35
54
  }
@@ -47,7 +66,8 @@ function isStructuredLine(line) {
47
66
  function normalizeWhitespace(value) {
48
67
  return value
49
68
  .replace(/\s+\|\s+/g, ' | ')
50
- .replace(/\s*[:;]\s*/g, ': ')
69
+ .replace(/\s*:\s*/g, ': ')
70
+ .replace(/\s*;\s*/g, '; ')
51
71
  .replace(/\s*,\s*/g, ', ')
52
72
  .replace(/\s+/g, ' ')
53
73
  .trim();
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
+ cleanupEmptyParents,
4
5
  readJsonIfExists,
5
6
  removeLinkOrDir,
6
7
  removeLinkOnly,
@@ -8,41 +9,6 @@ import {
8
9
  } from './fileOps.js';
9
10
  import { removeGitignoreBlock } from './ensureGitignore.js';
10
11
 
11
- async function isDirEmpty(dirPath) {
12
- try {
13
- const entries = await fs.readdir(dirPath);
14
- return entries.length === 0;
15
- } catch {
16
- return false;
17
- }
18
- }
19
-
20
- async function cleanupEmptyParents(targetPath, projectRoot) {
21
- let currentDir = path.dirname(targetPath);
22
- const stopDir = path.resolve(projectRoot);
23
-
24
- while (currentDir.startsWith(stopDir) && currentDir !== stopDir) {
25
- let stat;
26
- try {
27
- stat = await fs.lstat(currentDir);
28
- } catch {
29
- currentDir = path.dirname(currentDir);
30
- continue;
31
- }
32
-
33
- if (!stat.isDirectory()) {
34
- break;
35
- }
36
-
37
- if (!(await isDirEmpty(currentDir))) {
38
- break;
39
- }
40
-
41
- await fs.rmdir(currentDir);
42
- currentDir = path.dirname(currentDir);
43
- }
44
- }
45
-
46
12
  // Use lstat (not access) so broken symlinks are also detected as existing.
47
13
  async function pathExistsLstat(targetPath) {
48
14
  try {
@@ -134,7 +100,12 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
134
100
  // An attacker (or confused user) could forge the file to trigger uninstall of
135
101
  // the hardcoded managed paths. Requiring 'tool: ukit' ensures the file was
136
102
  // written by the UKit installer, not created manually.
137
- const installData = await readJsonIfExists(installMetaPath);
103
+ let installData;
104
+ try {
105
+ installData = await readJsonIfExists(installMetaPath);
106
+ } catch {
107
+ return { removed: 0, attempted: 0, wasInstalled: false };
108
+ }
138
109
  if (!installData || installData.tool !== 'ukit') {
139
110
  return { removed: 0, attempted: 0, wasInstalled: false };
140
111
  }
@@ -233,14 +204,20 @@ export async function uninstallUkit({ projectRoot, dryRun = false }) {
233
204
  // Clean up .gitignore block added during install
234
205
  await removeGitignoreBlock(projectRoot);
235
206
 
236
- // Cleanup empty parent directories sequentially (order matters)
237
207
  let removed = 0;
208
+ const removedPaths = [];
238
209
  for (const { abs, didRemove } of results) {
239
210
  if (didRemove) {
240
211
  removed += 1;
241
- await cleanupEmptyParents(abs, projectRoot);
212
+ removedPaths.push(abs);
242
213
  }
243
214
  }
244
215
 
216
+ const deepestRemovedPaths = [...new Set(removedPaths)]
217
+ .sort((left, right) => right.length - left.length);
218
+ for (const removedPath of deepestRemovedPaths) {
219
+ await cleanupEmptyParents(removedPath, projectRoot);
220
+ }
221
+
245
222
  return { removed, attempted: allEntries.length, wasInstalled: true };
246
223
  }
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
4
 
5
- import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from './paths.js';
5
+ import { getArtifactPath, getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, isLikelyTestFilePath, normalizeRelative } from './paths.js';
6
6
  import { importsNeedAliasContext, loadImportAliasContextState, resolveImportSpecifier } from './importResolution.js';
7
7
  import { clearIndexArtifactCache } from './queryIndex.js';
8
8
  import { clearRelatedTestArtifactCache } from './relatedTests.js';
@@ -466,16 +466,163 @@ async function collectFiles(scanRoots) {
466
466
  return result;
467
467
  }
468
468
 
469
+
470
+ function stripCommentsStringAware(content) {
471
+ let result = '';
472
+ let state = 'normal';
473
+ let escaped = false;
474
+ let templateExpressionDepth = 0;
475
+ let regexCharClass = false;
476
+
477
+ for (let index = 0; index < content.length; index += 1) {
478
+ const char = content[index];
479
+ const next = content[index + 1];
480
+
481
+ if (state === 'line-comment') {
482
+ if (char === '\n') {
483
+ result += char;
484
+ state = 'normal';
485
+ }
486
+ continue;
487
+ }
488
+
489
+ if (state === 'block-comment') {
490
+ if (char === '*' && next === '/') {
491
+ index += 1;
492
+ state = 'normal';
493
+ }
494
+ continue;
495
+ }
496
+
497
+ result += char;
498
+
499
+ if (state === 'single' || state === 'double' || state === 'template' || state === 'regex') {
500
+ if (escaped) {
501
+ escaped = false;
502
+ continue;
503
+ }
504
+ if (char === '\\') {
505
+ escaped = true;
506
+ continue;
507
+ }
508
+ if (state === 'single' && char === "'") state = 'normal';
509
+ if (state === 'double' && char === '"') state = 'normal';
510
+ if (state === 'template') {
511
+ if (char === '`' && templateExpressionDepth === 0) state = 'normal';
512
+ else if (char === '{' && content[index - 1] === '$') templateExpressionDepth += 1;
513
+ else if (char === '}' && templateExpressionDepth > 0) templateExpressionDepth -= 1;
514
+ }
515
+ if (state === 'regex') {
516
+ if (char === '[') regexCharClass = true;
517
+ else if (char === ']') regexCharClass = false;
518
+ else if (char === '/' && !regexCharClass) state = 'normal';
519
+ }
520
+ continue;
521
+ }
522
+
523
+ if (char === '/' && next === '/') {
524
+ result = result.slice(0, -1);
525
+ state = 'line-comment';
526
+ index += 1;
527
+ continue;
528
+ }
529
+ if (char === '/' && next === '*') {
530
+ result = result.slice(0, -1);
531
+ state = 'block-comment';
532
+ index += 1;
533
+ continue;
534
+ }
535
+ if (char === "'") state = 'single';
536
+ else if (char === '"') state = 'double';
537
+ else if (char === '`') state = 'template';
538
+ else if (char === '/' && isRegexLiteralStart(result.slice(0, -1))) {
539
+ state = 'regex';
540
+ regexCharClass = false;
541
+ }
542
+ }
543
+
544
+ return result;
545
+ }
546
+
547
+ function isRegexLiteralStart(prefix) {
548
+ const trimmed = prefix.trimEnd();
549
+ return !trimmed || /(?:[=(:,!&|?{};\[\n]|\b(?:return|throw|case|delete|typeof|void|new|in|of|yield|await)\s*)$/.test(trimmed);
550
+ }
551
+
552
+ function findScriptClose(content, startIndex) {
553
+ const lower = content.toLowerCase();
554
+ let state = 'normal';
555
+ let escaped = false;
556
+ let templateExpressionDepth = 0;
557
+
558
+ for (let index = startIndex; index < content.length; index += 1) {
559
+ const char = content[index];
560
+ const next = content[index + 1];
561
+
562
+ if (state === 'line-comment') {
563
+ if (char === '\n') state = 'normal';
564
+ continue;
565
+ }
566
+ if (state === 'block-comment') {
567
+ if (char === '*' && next === '/') {
568
+ index += 1;
569
+ state = 'normal';
570
+ }
571
+ continue;
572
+ }
573
+ if (state === 'single' || state === 'double' || state === 'template') {
574
+ if (escaped) {
575
+ escaped = false;
576
+ continue;
577
+ }
578
+ if (char === '\\') {
579
+ escaped = true;
580
+ continue;
581
+ }
582
+ if (state === 'single' && char === "'") state = 'normal';
583
+ else if (state === 'double' && char === '"') state = 'normal';
584
+ else if (state === 'template') {
585
+ if (char === '`' && templateExpressionDepth === 0) state = 'normal';
586
+ else if (char === '{' && content[index - 1] === '$') templateExpressionDepth += 1;
587
+ else if (char === '}' && templateExpressionDepth > 0) templateExpressionDepth -= 1;
588
+ }
589
+ continue;
590
+ }
591
+
592
+ if (lower.startsWith('</script>', index)) return index;
593
+ if (char === '/' && next === '/') {
594
+ state = 'line-comment';
595
+ index += 1;
596
+ } else if (char === '/' && next === '*') {
597
+ state = 'block-comment';
598
+ index += 1;
599
+ } else if (char === "'") state = 'single';
600
+ else if (char === '"') state = 'double';
601
+ else if (char === '`') state = 'template';
602
+ }
603
+
604
+ return -1;
605
+ }
606
+
469
607
  function extractScriptContent(filePath, content) {
470
608
  if (!filePath.endsWith('.vue')) {
471
609
  return content;
472
610
  }
473
611
 
474
- const allMatches = [...content.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/gi)];
475
- if (allMatches.length === 0) return '';
476
- return allMatches.map((m) => m[1]).join('\n');
612
+ const scripts = [];
613
+ const startTagPattern = /<script[^>]*>/gi;
614
+ for (const match of content.matchAll(startTagPattern)) {
615
+ const bodyStart = match.index + match[0].length;
616
+ const bodyEnd = findScriptClose(content, bodyStart);
617
+ if (bodyEnd !== -1) {
618
+ scripts.push(content.slice(bodyStart, bodyEnd));
619
+ }
620
+ }
621
+ if (scripts.length === 0) return '';
622
+ return scripts.join('\n');
477
623
  }
478
624
 
625
+
479
626
  function extractSymbols(filePath, content) {
480
627
  const symbols = [];
481
628
  const addSymbol = (name, type) => {
@@ -529,13 +676,10 @@ function extractSymbols(filePath, content) {
529
676
  }
530
677
 
531
678
  function extractImports(filePath, content) {
532
- // Strip comments to avoid false-positive imports from commented-out code
533
- const stripped = content
534
- .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
535
- .replace(/\/\/.*$/gm, ''); // line comments
679
+ const stripped = stripCommentsStringAware(content);
536
680
  const imports = [];
537
681
 
538
- for (const match of stripped.matchAll(/import\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]/g)) {
682
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*import\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]/g)) {
539
683
  imports.push({
540
684
  from: filePath,
541
685
  to: match[1],
@@ -559,7 +703,7 @@ function extractImports(filePath, content) {
559
703
  });
560
704
  }
561
705
 
562
- for (const match of stripped.matchAll(/export\s+(?:type\s+)?\{[^}]+\}\s+from\s+['"]([^'"]+)['"]/g)) {
706
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+(?:type\s+)?\{[^}]+\}\s+from\s+['"]([^'"]+)['"]/g)) {
563
707
  imports.push({
564
708
  from: filePath,
565
709
  to: match[1],
@@ -567,7 +711,7 @@ function extractImports(filePath, content) {
567
711
  });
568
712
  }
569
713
 
570
- for (const match of stripped.matchAll(/export\s+\*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s+from\s+['"]([^'"]+)['"]/g)) {
714
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+\*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s+from\s+['"]([^'"]+)['"]/g)) {
571
715
  imports.push({
572
716
  from: filePath,
573
717
  to: match[1],
@@ -629,18 +773,71 @@ function extractFunctionBlocks(content) {
629
773
 
630
774
  function sliceBalancedBlock(content, startIndex) {
631
775
  let depth = 1;
776
+ let state = 'normal';
777
+ let escaped = false;
778
+ let templateExpressionDepth = 0;
779
+
632
780
  for (let index = startIndex; index < content.length; index += 1) {
633
781
  const char = content[index];
634
- if (char === '{') {
635
- depth += 1;
782
+ const next = content[index + 1];
783
+
784
+ if (state === 'line-comment') {
785
+ if (char === '\n') state = 'normal';
786
+ continue;
787
+ }
788
+ if (state === 'block-comment') {
789
+ if (char === '*' && next === '/') {
790
+ index += 1;
791
+ state = 'normal';
792
+ }
793
+ continue;
636
794
  }
637
- if (char === '}') {
638
- depth -= 1;
795
+ if (state === 'single' || state === 'double' || state === 'template') {
796
+ if (escaped) {
797
+ escaped = false;
798
+ continue;
799
+ }
800
+ if (char === '\\') {
801
+ escaped = true;
802
+ continue;
803
+ }
804
+ if (state === 'single' && char === "'") state = 'normal';
805
+ else if (state === 'double' && char === '"') state = 'normal';
806
+ else if (state === 'template') {
807
+ if (char === '`' && templateExpressionDepth === 0) state = 'normal';
808
+ else if (char === '{' && content[index - 1] === '$') templateExpressionDepth += 1;
809
+ else if (char === '}' && templateExpressionDepth > 0) templateExpressionDepth -= 1;
810
+ }
811
+ continue;
639
812
  }
640
- if (depth === 0) {
641
- return content.slice(startIndex, index);
813
+
814
+ if (char === '/' && next === '/') {
815
+ state = 'line-comment';
816
+ index += 1;
817
+ continue;
818
+ }
819
+ if (char === '/' && next === '*') {
820
+ state = 'block-comment';
821
+ index += 1;
822
+ continue;
642
823
  }
824
+ if (char === "'") {
825
+ state = 'single';
826
+ continue;
827
+ }
828
+ if (char === '"') {
829
+ state = 'double';
830
+ continue;
831
+ }
832
+ if (char === '`') {
833
+ state = 'template';
834
+ continue;
835
+ }
836
+ if (char === '{') depth += 1;
837
+ if (char === '}') depth -= 1;
838
+ if (depth === 0) return content.slice(startIndex, index);
643
839
  }
840
+
644
841
  return '';
645
842
  }
646
843
 
@@ -666,8 +863,8 @@ function extractFunctionCalls(filePath, content) {
666
863
  }
667
864
 
668
865
  function buildTestsMap(fileRecords) {
669
- const testFiles = fileRecords.filter((file) => isLikelyTestFile(file.filePath));
670
- const sourceFiles = fileRecords.filter((file) => CODE_EXTENSIONS.has(file.ext) && !isLikelyTestFile(file.filePath));
866
+ const testFiles = fileRecords.filter((file) => isLikelyTestFilePath(file.filePath));
867
+ const sourceFiles = fileRecords.filter((file) => CODE_EXTENSIONS.has(file.ext) && !isLikelyTestFilePath(file.filePath));
671
868
 
672
869
  return sourceFiles.map((sourceFile) => {
673
870
  const matches = testFiles
@@ -771,35 +968,6 @@ function shouldSkipDirectory(name) {
771
968
  return name.startsWith('.') && name !== '.claude' && name !== '.codex' && name !== '.antigravity';
772
969
  }
773
970
 
774
- function isLikelyTestFile(filePath) {
775
- const lower = filePath.toLowerCase();
776
- return (
777
- lower.startsWith('__tests__/')
778
- || lower.startsWith('test/')
779
- || lower.startsWith('tests/')
780
- || lower.startsWith('spec/')
781
- || lower.startsWith('specs/')
782
- || lower.includes('/__tests__/')
783
- || lower.includes('/test/')
784
- || lower.includes('/spec/')
785
- || lower.includes('/specs/')
786
- || lower.endsWith('.test.js')
787
- || lower.endsWith('.test.ts')
788
- || lower.endsWith('.test.vue')
789
- || lower.endsWith('.test.tsx')
790
- || lower.endsWith('.test.jsx')
791
- || lower.endsWith('.test.mjs')
792
- || lower.endsWith('.test.cjs')
793
- || lower.endsWith('.spec.js')
794
- || lower.endsWith('.spec.ts')
795
- || lower.endsWith('.spec.vue')
796
- || lower.endsWith('.spec.tsx')
797
- || lower.endsWith('.spec.jsx')
798
- || lower.endsWith('.spec.mjs')
799
- || lower.endsWith('.spec.cjs')
800
- );
801
- }
802
-
803
971
  function createSourceFingerprint(rootDir, discoveredFiles) {
804
972
  const hash = crypto.createHash('sha256');
805
973
  const normalizedEntries = discoveredFiles
@@ -985,7 +1153,7 @@ function classifyArchetypes(fileRecords, symbols) {
985
1153
  return fileRecords
986
1154
  .filter((f) => CODE_EXTENSIONS.has(f.ext))
987
1155
  .map((file) => {
988
- if (isLikelyTestFile(file.filePath)) {
1156
+ if (isLikelyTestFilePath(file.filePath)) {
989
1157
  return {
990
1158
  filePath: file.filePath,
991
1159
  archetype: 'test',