@ngockhoale/ukit 3.0.2 → 3.0.4

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 (109) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +10 -3
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +11 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +16 -6
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -308,7 +308,10 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
308
308
  ]);
309
309
  canReuseParsedArtifacts = previousSymbolsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
310
310
  && previousImportsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
311
- && previousCallsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
311
+ && previousCallsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
312
+ && Array.isArray(previousSymbolsArtifact.items)
313
+ && Array.isArray(previousImportsArtifact.items)
314
+ && Array.isArray(previousCallsArtifact.items);
312
315
 
313
316
  if (canReuseParsedArtifacts) {
314
317
  const previousSymbolsByPath = groupBy(previousSymbolsArtifact.items ?? [], (item) => item.filePath);
@@ -363,7 +366,8 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
363
366
  ? await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.testsMap)
364
367
  : null;
365
368
  const canReuseTestsMap = canReuseTestsMapCandidate
366
- && previousTestsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
369
+ && previousTestsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
370
+ && Array.isArray(previousTestsMapArtifact.items);
367
371
  const testsMap = canReuseTestsMap
368
372
  ? previousTestsMapArtifact.items
369
373
  : buildTestsMap(fileRecords);
@@ -372,6 +376,7 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
372
376
  ? await readArtifactIfExists(absoluteRoot, INDEX_ARTIFACTS.hotspots)
373
377
  : null;
374
378
  const canReuseHotspots = previousHotspotsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
379
+ && Array.isArray(previousHotspotsArtifact.items)
375
380
  && areBugIndexSnapshotsEqual(previousHotspotsArtifact?.sourceSnapshot, bugIndexSnapshot);
376
381
  const hotspots = canReuseHotspots
377
382
  ? previousHotspotsArtifact.items
@@ -386,8 +391,9 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
386
391
  : null;
387
392
  const canReuseArchetypes = canReuseArchetypesCandidate
388
393
  && previousArchetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
389
- && previousArchetypesArtifact?.items?.length === codeFiles.length
390
- && (previousArchetypesArtifact.items ?? []).every((item) => currentCodePaths.has(item.filePath));
394
+ && Array.isArray(previousArchetypesArtifact.items)
395
+ && previousArchetypesArtifact.items.length === codeFiles.length
396
+ && previousArchetypesArtifact.items.every((item) => currentCodePaths.has(item.filePath));
391
397
 
392
398
  // Reuse cached archetypes only when every indexed code file is unchanged
393
399
  const archetypes = canReuseArchetypes
@@ -413,6 +419,7 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
413
419
  };
414
420
  const canReuseRelations = canReuseGraphArtifactsCandidate
415
421
  && previousRelationsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
422
+ && Array.isArray(previousRelationsArtifact.items)
416
423
  && areStringArraysEqual(previousRelationsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
417
424
  && arePathSnapshotsEqual(
418
425
  previousRelationsArtifact?.sourceSnapshot?.importAliasSnapshot,
@@ -420,6 +427,7 @@ export async function buildCodeIndex({ rootDir = process.cwd(), changedFiles = n
420
427
  );
421
428
  const canReuseAnalogs = canReuseGraphArtifactsCandidate
422
429
  && previousAnalogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
430
+ && Array.isArray(previousAnalogsArtifact.items)
423
431
  && areStringArraysEqual(previousAnalogsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
424
432
  && arePathSnapshotsEqual(
425
433
  previousAnalogsArtifact?.sourceSnapshot?.importAliasSnapshot,
@@ -943,13 +951,15 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
943
951
 
944
952
  // Derived artifacts are recomputed in memory from the merged inputs — the
945
953
  // expensive work being avoided is discovery and re-parsing, not these pure
946
- // transformations, so their output stays identical to a clean rebuild.
947
954
  const canReuseTestsMap = haveStableTrackedFileIdentities(filesArtifact.items, mergedFileRecords)
948
- && testsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
955
+ && testsMapArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
956
+ && Array.isArray(testsMapArtifact.items);
949
957
  const testsMap = canReuseTestsMap ? testsMapArtifact.items : buildTestsMap(mergedFileRecords);
950
958
 
951
959
  const bugIndexSnapshot = await readBugIndexSnapshot(absoluteRoot);
960
+
952
961
  const canReuseHotspots = hotspotsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
962
+ && Array.isArray(hotspotsArtifact.items)
953
963
  && areBugIndexSnapshotsEqual(hotspotsArtifact?.sourceSnapshot, bugIndexSnapshot);
954
964
  const hotspots = canReuseHotspots ? hotspotsArtifact.items : await buildHotspots(absoluteRoot, bugIndexSnapshot);
955
965
 
@@ -957,13 +967,13 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
957
967
  const previousCodePaths = new Set(
958
968
  filesArtifact.items.filter((item) => CODE_EXTENSIONS.has(item?.ext)).map((item) => item.filePath),
959
969
  );
960
- const currentCodePaths = new Set(codeFiles.map((file) => file.filePath));
961
970
  const canReuseArchetypes = parsedFiles.length === 0
962
971
  && !recordsChanged
963
972
  && archetypesArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
973
+ && Array.isArray(archetypesArtifact.items)
964
974
  && codeFiles.every((file) => previousCodePaths.has(file.filePath))
965
- && archetypesArtifact?.items?.length === archetypes.length
966
- && (archetypesArtifact.items ?? []).every((item) => currentCodePaths.has(item?.filePath));
975
+ && archetypesArtifact.items.length === archetypes.length
976
+ && archetypesArtifact.items.every((item) => currentCodePaths.has(item?.filePath));
967
977
  const archetypeItems = canReuseArchetypes ? archetypesArtifact.items : archetypes;
968
978
 
969
979
  const needsAliasContext = importsNeedAliasContext(imports);
@@ -981,7 +991,8 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
981
991
  && areStringArraysEqual(relationsArtifact?.sourceSnapshot?.styleFiles, styleFilePaths)
982
992
  && arePathSnapshotsEqual(relationsArtifact?.sourceSnapshot?.importAliasSnapshot, styleSnapshot.importAliasSnapshot);
983
993
  const canReuseAnalogs = canReuseRelations
984
- && analogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION;
994
+ && analogsArtifact?.schemaVersion === INDEX_SCHEMA_VERSION
995
+ && Array.isArray(analogsArtifact.items);
985
996
 
986
997
  let relations = relationsArtifact.items;
987
998
  let analogs = analogsArtifact?.items ?? null;
@@ -1163,11 +1174,11 @@ export async function getFileOutline({ rootDir = process.cwd(), filePath, limit
1163
1174
  }
1164
1175
 
1165
1176
  export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5 } = {}) {
1166
- if (!query || !query.trim()) return [];
1177
+ const normalizedQuery = String(query ?? '').trim();
1178
+ if (!normalizedQuery) return [];
1167
1179
  if (limit <= 0) return Object.freeze([]);
1168
1180
 
1169
1181
  const absoluteRoot = path.resolve(rootDir);
1170
- const normalizedQuery = String(query).trim();
1171
1182
  const queryDescriptor = buildSearchDescriptor(normalizedQuery, { expandVietnameseAliases: true });
1172
1183
  if (queryDescriptor.tokens.size === 0) return Object.freeze([]);
1173
1184
  const queryCacheKey = JSON.stringify({
@@ -1247,12 +1258,11 @@ export async function queryCodeIndex({ rootDir = process.cwd(), query, limit = 5
1247
1258
  }
1248
1259
 
1249
1260
  // ── Analog Query ──
1250
-
1251
1261
  export async function queryAnalog({ rootDir = process.cwd(), filePath, limit = 5 } = {}) {
1252
- if (!filePath || !filePath.trim()) return [];
1262
+ const normalizedFilePath = String(filePath ?? '').trim();
1263
+ if (!normalizedFilePath) return [];
1253
1264
 
1254
1265
  const absoluteRoot = path.resolve(rootDir);
1255
- const normalizedFilePath = String(filePath).trim();
1256
1266
  const analogCacheKey = JSON.stringify({
1257
1267
  rootDir: absoluteRoot,
1258
1268
  filePath: normalizedFilePath,
@@ -1325,9 +1335,14 @@ function freezeQueryResults(results) {
1325
1335
  function freezeAnalogResults(results) {
1326
1336
  return Object.freeze(results.map((item) => Object.freeze({ ...item })));
1327
1337
  }
1328
-
1329
1338
  function ensureArtifact(artifact) {
1330
- return artifact && typeof artifact === 'object' ? artifact : { items: [] };
1339
+ if (!artifact || typeof artifact !== 'object') {
1340
+ return { items: [] };
1341
+ }
1342
+ // A schema-current artifact whose `items` is not an array (truncated or
1343
+ // hand-corrupted JSON) must degrade to an empty item list — callers iterate
1344
+ // `items` unconditionally and a non-array value crashes the query path.
1345
+ return Array.isArray(artifact.items) ? artifact : { ...artifact, items: [] };
1331
1346
  }
1332
1347
 
1333
1348
  function escapeJsonString(value) {
@@ -1368,10 +1383,20 @@ const EXPANSION_SIGNALS = [
1368
1383
  'project',
1369
1384
  'repo',
1370
1385
  'workspace',
1371
- 'all ',
1372
- 'every ',
1386
+ 'all',
1387
+ 'every',
1373
1388
  ];
1374
1389
 
1390
+ // Signals must match whole words: bare `includes` let 'score' fire 'core',
1391
+ // 'author' fire 'auth', 'context' fire 'text', and 'install ' fire 'all '.
1392
+ function escapeSignalRegExp(signal) {
1393
+ return signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1394
+ }
1395
+
1396
+ function containsSignalWord(lower, signals) {
1397
+ return signals.some((signal) => new RegExp(`\\b${escapeSignalRegExp(signal)}\\b`).test(lower));
1398
+ }
1399
+
1375
1400
  export async function resolveContext({
1376
1401
  rootDir = process.cwd(),
1377
1402
  intent = '',
@@ -1506,12 +1531,12 @@ export async function resolveContext({
1506
1531
  export function classifyTask(intent) {
1507
1532
  const lower = intent.toLowerCase();
1508
1533
 
1509
- if (RISKY_SIGNALS.some((signal) => lower.includes(signal))) {
1534
+ if (containsSignalWord(lower, RISKY_SIGNALS)) {
1510
1535
  return 'non-trivial';
1511
1536
  }
1512
1537
 
1513
- if (TRIVIAL_SIGNALS.some((signal) => lower.includes(signal))) {
1514
- if (lower.includes('all ') || lower.includes('every ') || lower.includes('multiple')) {
1538
+ if (containsSignalWord(lower, TRIVIAL_SIGNALS)) {
1539
+ if (/\ball\b/.test(lower) || /\bevery\b/.test(lower) || /\bmultiple\b/.test(lower)) {
1515
1540
  return 'simple';
1516
1541
  }
1517
1542
  return 'trivial';
@@ -1535,7 +1560,7 @@ function shouldQueryIndex({
1535
1560
  if (taskType === 'non-trivial') {
1536
1561
  return true;
1537
1562
  }
1538
- return EXPANSION_SIGNALS.some((signal) => normalizedIntent.includes(signal));
1563
+ return containsSignalWord(normalizedIntent, EXPANSION_SIGNALS);
1539
1564
  }
1540
1565
 
1541
1566
  function enforceContextBudget(result, maxFiles) {
@@ -2628,7 +2653,7 @@ function extractImports(filePath, content) {
2628
2653
  const stripped = content
2629
2654
  .replace(/\/\*[\s\S]*?\*\//g, '')
2630
2655
  .replace(/\/\/.*$/gm, '');
2631
- for (const match of stripped.matchAll(/import\s+(?:[^'";]+?\s+from\s+)?['"]([^'"]+)['"]/g)) {
2656
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*import\s*(?:[^'";]+?\s*from\s*)?['"]([^'"]+)['"]/g)) {
2632
2657
  out.push({ from: filePath, to: match[1], kind: 'import' });
2633
2658
  }
2634
2659
  for (const match of stripped.matchAll(/import\(\s*['"]([^'"]+)['"]\s*\)/g)) {
@@ -2637,10 +2662,10 @@ function extractImports(filePath, content) {
2637
2662
  for (const match of stripped.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) {
2638
2663
  out.push({ from: filePath, to: match[1], kind: 'require' });
2639
2664
  }
2640
- for (const match of stripped.matchAll(/export\s+(?:type\s+)?\{[^}]+\}\s+from\s+['"]([^'"]+)['"]/g)) {
2665
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+(?:type\s+)?\{[^}]+\}\s*from\s*['"]([^'"]+)['"]/g)) {
2641
2666
  out.push({ from: filePath, to: match[1], kind: 're-export' });
2642
2667
  }
2643
- for (const match of stripped.matchAll(/export\s+\*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s+from\s+['"]([^'"]+)['"]/g)) {
2668
+ for (const match of stripped.matchAll(/(?:^|[;\n])\s*export\s+\*(?:\s+as\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*from\s*['"]([^'"]+)['"]/g)) {
2644
2669
  out.push({ from: filePath, to: match[1], kind: 're-export-all' });
2645
2670
  }
2646
2671
  return out;
@@ -3533,9 +3558,9 @@ async function loadAliasConfig(configPath, visited = new Set(), tracker = null)
3533
3558
 
3534
3559
  let inherited = null;
3535
3560
  const extendsValue = typeof parsed?.extends === 'string' ? parsed.extends.trim() : '';
3536
- if (extendsValue && (extendsValue.startsWith('.') || extendsValue.startsWith('/'))) {
3537
- const inheritedPath = resolveExtendedConfigPath(configDir, extendsValue);
3538
- inherited = await loadAliasConfig(inheritedPath, visited, tracker);
3561
+ if (extendsValue) {
3562
+ const inheritedPath = await resolveExtendedConfigPath(configDir, extendsValue);
3563
+ inherited = inheritedPath ? await loadAliasConfig(inheritedPath, visited, tracker) : null;
3539
3564
  }
3540
3565
 
3541
3566
  const inheritedRules = inherited?.pathRules ?? [];
@@ -3573,12 +3598,54 @@ function buildBaseUrlDirs({ rootDir, compilerOptions }) {
3573
3598
  if (!baseUrl) return [];
3574
3599
  return [path.resolve(rootDir, baseUrl)];
3575
3600
  }
3601
+ async function resolveExtendedConfigPath(configDir, extendsValue) {
3602
+ if (extendsValue.startsWith('.') || extendsValue.startsWith('/')) {
3603
+ const withExtension = extendsValue.endsWith('.json')
3604
+ ? extendsValue
3605
+ : `${extendsValue}.json`;
3606
+ return path.resolve(configDir, withExtension);
3607
+ }
3608
+
3609
+ // Package specifiers split on the RAW value: appending `.json` first would
3610
+ // turn a bare `extends: "acme-base"` into the package name `acme-base.json`,
3611
+ // which never exists under node_modules.
3612
+ const parts = extendsValue.split('/').filter(Boolean);
3613
+ if (parts.length === 0) {
3614
+ return null;
3615
+ }
3576
3616
 
3577
- function resolveExtendedConfigPath(configDir, extendsValue) {
3578
- const withExtension = extendsValue.endsWith('.json')
3579
- ? extendsValue
3580
- : `${extendsValue}.json`;
3581
- return path.resolve(configDir, withExtension);
3617
+ const packageName = extendsValue.startsWith('@')
3618
+ ? parts.slice(0, 2).join('/')
3619
+ : parts[0];
3620
+ const packageRest = parts.slice(packageName.startsWith('@') ? 2 : 1).join('/');
3621
+ const packageRoot = await findNodeModulePackageRoot(configDir, packageName);
3622
+ if (!packageRoot) {
3623
+ return null;
3624
+ }
3625
+
3626
+ const rest = packageRest && !packageRest.endsWith('.json')
3627
+ ? `${packageRest}.json`
3628
+ : packageRest;
3629
+ return path.join(packageRoot, rest || 'tsconfig.json');
3630
+ }
3631
+
3632
+ async function findNodeModulePackageRoot(startDir, packageName) {
3633
+ let currentDir = path.resolve(startDir);
3634
+ while (true) {
3635
+ const candidate = path.join(currentDir, 'node_modules', packageName);
3636
+ try {
3637
+ const stat = await fs.stat(candidate);
3638
+ if (stat.isDirectory()) {
3639
+ return candidate;
3640
+ }
3641
+ } catch {}
3642
+
3643
+ const parentDir = path.dirname(currentDir);
3644
+ if (parentDir === currentDir) {
3645
+ return null;
3646
+ }
3647
+ currentDir = parentDir;
3648
+ }
3582
3649
  }
3583
3650
 
3584
3651
  async function isAliasSnapshotValid(snapshot = []) {
@@ -4187,9 +4254,10 @@ export async function resolveImpactContext({
4187
4254
  const normalizedChangedFiles = uniqueImpact(changedFiles.map(normalizeImpactPath));
4188
4255
  const normalizedChangedSymbols = uniqueImpact(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
4189
4256
 
4190
- const [callsArtifact, importsArtifact, relatedArtifacts] = await Promise.all([
4257
+ const [callsArtifact, importsArtifact, filesArtifact, relatedArtifacts] = await Promise.all([
4191
4258
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
4192
4259
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
4260
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
4193
4261
  loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
4194
4262
  ]);
4195
4263
 
@@ -4221,23 +4289,39 @@ export async function resolveImpactContext({
4221
4289
  for (const record of callItems) {
4222
4290
  const matchedSymbol = (record.calls ?? []).find((name) => callerTargets.has(name));
4223
4291
  if (!matchedSymbol) continue;
4224
- if (changedFileSet.has(normalizeImpactPath(record.filePath)) && changedSymbolSet.has(record.symbol)) continue;
4292
+ if (changedFileSet.has(normalizeImpactPath(record.filePath))) continue;
4225
4293
  callers.push({ filePath: record.filePath, symbol: record.symbol, reason: `calls ${matchedSymbol}` });
4226
4294
  }
4227
4295
  }
4228
4296
 
4229
4297
  const importers = [];
4230
4298
  const dependencies = [];
4299
+ // Non-relative specifiers (aliases like `@/x`, `~/x`, tsconfig paths) resolve
4300
+ // through the same machinery the index builder uses; relative specifiers keep
4301
+ // the candidate-list match so a changed file absent from files.json (e.g. a
4302
+ // brand-new file) still counts.
4303
+ const indexedFileSet = new Set((filesArtifact.items ?? []).map((item) => item.filePath));
4304
+ const importAliasContext = importsNeedAliasContext(importItems)
4305
+ ? await loadImportAliasContext({ rootDir: absoluteRoot }).catch(() => null)
4306
+ : null;
4231
4307
  for (const imp of importItems) {
4232
4308
  const from = normalizeImpactPath(imp.from);
4233
- const matched = resolveImpactImportTargets(from, imp.to)
4234
- .find((candidate) => changedFileSet.has(normalizeImpactPath(candidate)));
4309
+ const specifier = String(imp.to ?? '').trim();
4310
+ const targets = specifier.startsWith('.')
4311
+ ? resolveImpactImportTargets(from, specifier)
4312
+ : [resolveImportSpecifier({
4313
+ rootDir: absoluteRoot,
4314
+ fromFilePath: from,
4315
+ specifier,
4316
+ indexedFileSet,
4317
+ aliasContext: importAliasContext,
4318
+ })].filter(Boolean);
4319
+ const matched = targets.find((candidate) => changedFileSet.has(normalizeImpactPath(candidate)));
4235
4320
  if (matched) {
4236
4321
  importers.push(from);
4237
4322
  dependencies.push(matched);
4238
4323
  }
4239
4324
  }
4240
-
4241
4325
  const candidateFiles = uniqueImpact([
4242
4326
  ...normalizedChangedFiles,
4243
4327
  ...importers,