ucn 5.2.2 → 5.3.1

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/cli/index.js CHANGED
@@ -30,6 +30,16 @@ const { buildPublicParams, isPublicCommand } = require('../core/public-command')
30
30
  const { execute } = require('../core/execute');
31
31
  const { applyOutputBudget, MAX_OUTPUT_CHARS } = require('../core/output-budget');
32
32
  const { clearAllCaches } = require('../core/cache');
33
+ const { commentLines } = require('../core/output/lines');
34
+
35
+ // A downstream consumer such as head may finish before our write drains.
36
+ // Handle the pipe closure without an unhandled Node error or bypassing cache
37
+ // cleanup. Other write failures are real command errors.
38
+ process.stdout.on('error', error => {
39
+ if (error.code === 'EPIPE') return;
40
+ process.stderr.write(`Error writing stdout: ${error.message}\n`);
41
+ process.exitCode = 2;
42
+ });
33
43
 
34
44
  let activeCanonicalCommand = null;
35
45
 
@@ -315,6 +325,8 @@ function parseFlags(tokens) {
315
325
  functions: tokens.includes('--functions') || undefined,
316
326
  hot: tokens.includes('--hot') || undefined,
317
327
  diverse: tokens.includes('--diverse') || undefined,
328
+ raw: tokens.includes('--raw') || undefined,
329
+ lines: tokens.includes('--lines') || undefined,
318
330
  git: tokens.includes('--git') || undefined,
319
331
  className: getValueFlag('--class-name'),
320
332
  // Explicit line pin (fix #249: our own disambiguation notes advertise
@@ -396,7 +408,7 @@ if (unknownFlags.length > 0) {
396
408
  emitCliError(
397
409
  `Unknown flag(s): ${unknownFlags.join(', ')}. Use --help to see available flags.`,
398
410
  );
399
- process.exit(1);
411
+ process.exit(flags.lines || flags.raw ? 2 : 1);
400
412
  }
401
413
 
402
414
  // Validate numeric flag values up front so bad input fails before we build
@@ -407,7 +419,7 @@ try {
407
419
  } catch (e) {
408
420
  if (e instanceof FlagValidationError) {
409
421
  emitCliError(e.message);
410
- process.exit(1);
422
+ process.exit(flags.lines || flags.raw ? 2 : 1);
411
423
  }
412
424
  throw e;
413
425
  }
@@ -456,6 +468,16 @@ function formatCliText(command, result, params, execution, displayFlags) {
456
468
  ...execution,
457
469
  surface: 'cli',
458
470
  });
471
+ // --lines / --raw are pipe surfaces: records are compact, a truncated
472
+ // function body is worse than a long one, and an empty answer must stay
473
+ // empty (grep prints nothing and exits 1). An explicit --max-chars acts
474
+ // as a fail-before-output guard, never a lossy source/record truncation.
475
+ if (params?.lines || params?.raw) {
476
+ if (displayFlags?.maxChars && text.length > displayFlags.maxChars) {
477
+ fail(`Output exceeds --max-chars=${displayFlags.maxChars}; shell output cannot be truncated. Narrow the query, use --limit/--max-lines, or omit --max-chars.`);
478
+ }
479
+ return text;
480
+ }
459
481
  return applyOutputBudget(text, {
460
482
  command,
461
483
  maxChars: displayFlags?.maxChars,
@@ -465,6 +487,36 @@ function formatCliText(command, result, params, execution, displayFlags) {
465
487
  }).text;
466
488
  }
467
489
 
490
+ /**
491
+ * Print a formatted answer the way the mode asks for it (fix #341).
492
+ * --lines: `path:line:text` records on stdout, `# ` comment lines (ACCOUNT,
493
+ * notes) on stderr, exit 1 when nothing matched — grep's own contract.
494
+ * --raw: the text verbatim with exactly one trailing newline.
495
+ */
496
+ function emitCliText(text, params, json, note) {
497
+ if (!json && params?.lines) {
498
+ const records = [];
499
+ const comments = [];
500
+ for (const line of String(text).split('\n')) {
501
+ if (line === '') continue;
502
+ (line.startsWith('# ') ? comments : records).push(line);
503
+ }
504
+ if (records.length > 0) process.stdout.write(records.join('\n') + '\n');
505
+ if (comments.length > 0) process.stderr.write(comments.join('\n') + '\n');
506
+ if (records.length === 0) process.exitCode = Math.max(process.exitCode || 0, 1);
507
+ return;
508
+ }
509
+ if (!json && params?.raw) {
510
+ const body = String(text);
511
+ process.stdout.write(body.endsWith('\n') ? body : body + '\n');
512
+ // Code lines are never reinterpreted (a Python comment starts with
513
+ // "# " too), so the note travels on its own channel.
514
+ if (note) process.stderr.write(commentLines(formatSurfaceMessage(note, 'cli')).join('\n') + '\n');
515
+ return;
516
+ }
517
+ console.log(text);
518
+ }
519
+
468
520
  // ============================================================================
469
521
  // MAIN
470
522
  // ============================================================================
@@ -555,7 +607,7 @@ function main() {
555
607
  if (!(e instanceof CommandError)) {
556
608
  emitCliError(`Error: ${e.message}`);
557
609
  }
558
- process.exitCode = 1;
610
+ process.exitCode = flags.lines || flags.raw ? 2 : 1;
559
611
  }
560
612
  }
561
613
 
@@ -665,11 +717,11 @@ function runFileCommand(filePath, command, arg) {
665
717
  const execution = execute(index, canonical, params);
666
718
  const { ok, result, error } = execution;
667
719
  if (!ok) fail(formatSurfaceMessage(error, 'cli'));
668
- console.log(flags.json
720
+ emitCliText(flags.json
669
721
  ? output.formatPublicJson(canonical, result, params, {
670
722
  ...execution, surface: 'cli',
671
723
  })
672
- : formatCliText(canonical, result, params, execution, scopedFlags));
724
+ : formatCliText(canonical, result, params, execution, scopedFlags), params, flags.json, execution.note);
673
725
  }
674
726
 
675
727
  // ============================================================================
@@ -737,11 +789,12 @@ function runProjectCommand(rootDir, command, arg) {
737
789
  if (!publicExecution.ok) {
738
790
  fail(formatSurfaceMessage(publicExecution.error, 'cli'));
739
791
  }
740
- console.log(flags.json
792
+ emitCliText(flags.json
741
793
  ? output.formatPublicJson(canonical, publicExecution.result, publicParams, {
742
794
  ...publicExecution, surface: 'cli',
743
795
  })
744
- : formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags));
796
+ : formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags),
797
+ publicParams, flags.json, publicExecution.note);
745
798
  // A gate that could not run (check outside git / bad base ref) must not
746
799
  // exit 0 — CI gating on the exit code would read "could not run" as "passed".
747
800
  process.exitCode = Math.max(process.exitCode || 0,
@@ -750,7 +803,7 @@ function runProjectCommand(rootDir, command, arg) {
750
803
  if (!(e instanceof CommandError)) {
751
804
  emitCliError(`Error: ${e.message}`);
752
805
  }
753
- process.exitCode = 1;
806
+ process.exitCode = flags.lines || flags.raw ? 2 : 1;
754
807
  } finally {
755
808
  // Save cache after command execution so callsCache populated
756
809
  // by findCallers/findCallees gets persisted to disk.
@@ -796,11 +849,12 @@ function runGlobCommand(pattern, command, arg) {
796
849
  if (!publicExecution.ok) {
797
850
  fail(formatSurfaceMessage(publicExecution.error, 'cli'));
798
851
  }
799
- console.log(flags.json
852
+ emitCliText(flags.json
800
853
  ? output.formatPublicJson(canonical, publicExecution.result, publicParams, {
801
854
  ...publicExecution, surface: 'cli',
802
855
  })
803
- : formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags));
856
+ : formatCliText(canonical, publicExecution.result, publicParams, publicExecution, flags),
857
+ publicParams, flags.json, publicExecution.note);
804
858
  process.exitCode = Math.max(process.exitCode || 0,
805
859
  resultExitCode(canonical, publicExecution.result));
806
860
  }
@@ -860,6 +914,13 @@ Common flags:
860
914
  --range=N-M (source with --file=PATH)
861
915
  --base=REF --staged --no-cache --clear-cache [--all] --max-files=N --workers=N
862
916
  --max-chars=N (text output; default 10K targeted / 3K broad, ceiling 100K)
917
+ --lines find/usages/search/show/impact: grep -n shape, one path:line:text
918
+ record per line (tags after a tab: # unverified: <reason>, # import,
919
+ # callee); accounting and notes go to stderr as "# " lines; exit 1
920
+ when nothing matched; exit 2 on errors. No default result cap.
921
+ show defaults to callers; --sections=callers,callees selects bands.
922
+ --raw source: full code, no header or gutter (including large classes).
923
+ Shell modes fail before output if an explicit --max-chars is exceeded.
863
924
  Cache: per-user by default; set UCN_CACHE_DIR to override the cache root.
864
925
 
865
926
  Accepted flags by command:
package/core/analysis.js CHANGED
@@ -980,6 +980,113 @@ function related(index, name, options = {}) {
980
980
  * @param {object} options - { file, className, exclude, top }
981
981
  * @returns {object|null}
982
982
  */
983
+ // Kinds whose dependents are annotation/reference sites rather than calls.
984
+ // Classes and structs stay out: `new X()` / `X{}` are call-shaped and already
985
+ // flow through the caller sweep.
986
+ const TYPE_REFERENCE_KINDS = new Set(['type', 'interface', 'enum', 'trait', 'record']);
987
+
988
+ /**
989
+ * fix #345: tiered annotation-site band for a type-kind definition.
990
+ * Confirmed needs identity evidence: same file as the definition, or an
991
+ * import binding of the name in the referencing file that reaches the
992
+ * definition's file (the #215/#217 scope discipline). Anything else is
993
+ * VISIBLE unverified with a reason. A same-name definition elsewhere is
994
+ * excluded as other-target. Spelling alone never confirms.
995
+ */
996
+ function findTypeReferences(index, name, def, options = {}) {
997
+ if (!def || !TYPE_REFERENCE_KINDS.has(def.type)) return null;
998
+ const { usages } = require('./search');
999
+ const { _importReaches, _sameNominalPackageDir } = require('./callers');
1000
+ const records = usages(index, name, {
1001
+ includeTests: true, codeOnly: true, exclude: options.exclude,
1002
+ });
1003
+ const targetFiles = new Set([def.file]);
1004
+ const sameNameDefs = (index.symbols.get(name) || []).filter(d => d !== def);
1005
+ const confirmed = [];
1006
+ const unverified = [];
1007
+ const excluded = [];
1008
+ for (const u of (Array.isArray(records) ? records : records?.usages || [])) {
1009
+ if (u.isDefinition || u.usageType !== 'reference') continue;
1010
+ const site = {
1011
+ file: u.relativePath, line: u.line,
1012
+ expression: (u.content || '').trim(),
1013
+ };
1014
+ // A same-name type defined in the referencing file owns that file's
1015
+ // bare references (the #215 scope rule): excluded, never confirmed.
1016
+ if (u.file !== def.file && sameNameDefs.some(d => d.file === u.file && TYPE_REFERENCE_KINDS.has(d.type))) {
1017
+ excluded.push({ ...site, reason: 'other-definition' });
1018
+ continue;
1019
+ }
1020
+ if (sameNameDefs.some(d => d.file === u.file && (d.nameLine || d.startLine) === u.line)) {
1021
+ excluded.push({ ...site, reason: 'other-definition' });
1022
+ continue;
1023
+ }
1024
+ if (u.file === def.file) {
1025
+ confirmed.push({ ...site, evidence: 'same-file' });
1026
+ continue;
1027
+ }
1028
+ const fileEntry = index.files.get(u.file);
1029
+ // Directory-scoped packages (Go) and Java packages see sibling files'
1030
+ // types without an import; a same-name def in another package would
1031
+ // have been excluded above only if it shared the line, so require the
1032
+ // pinned def to be the package's own.
1033
+ const packageScoped = fileEntry && (
1034
+ (langTraits(fileEntry.language).packageScope === 'directory' &&
1035
+ path.dirname(u.file) === path.dirname(def.file)) ||
1036
+ (fileEntry.language === 'java' &&
1037
+ _sameNominalPackageDir(path.dirname(def.file), path.dirname(u.file), 'java')));
1038
+ if (packageScoped) {
1039
+ const foreign = sameNameDefs.some(d => TYPE_REFERENCE_KINDS.has(d.type) &&
1040
+ path.dirname(d.file) === path.dirname(u.file));
1041
+ if (foreign) unverified.push({ ...site, reason: 'same-package-ambiguous' });
1042
+ else confirmed.push({ ...site, evidence: 'package-scope' });
1043
+ continue;
1044
+ }
1045
+ const bindings = (fileEntry?.importBindings || []).filter(b =>
1046
+ b.name === name || b.alias === name);
1047
+ if (bindings.length > 0) {
1048
+ const reaches = bindings.some(b => {
1049
+ const rel = fileEntry.moduleResolved && fileEntry.moduleResolved[b.module];
1050
+ return rel && _importReaches(index, path.join(index.root, rel), targetFiles);
1051
+ });
1052
+ if (reaches) { confirmed.push({ ...site, evidence: 'import' }); continue; }
1053
+ const otherProject = bindings.some(b =>
1054
+ fileEntry.moduleResolved && fileEntry.moduleResolved[b.module]);
1055
+ if (otherProject) { excluded.push({ ...site, reason: 'other-definition-import' }); continue; }
1056
+ unverified.push({ ...site, reason: 'import-unresolved' });
1057
+ continue;
1058
+ }
1059
+ if (fileEntry?.importNames?.includes('*')) {
1060
+ unverified.push({ ...site, reason: 'star-import' });
1061
+ continue;
1062
+ }
1063
+ unverified.push({ ...site, reason: 'no-import-link' });
1064
+ }
1065
+ if (confirmed.length === 0 && unverified.length === 0 && excluded.length === 0) {
1066
+ return { owner: def.type, confirmedCount: 0, unverifiedCount: 0, totalCandidates: 0,
1067
+ byFile: [], unverifiedSites: [], excluded: { total: 0, byReason: {} } };
1068
+ }
1069
+ const bySite = (a, b) => a.file !== b.file ? codeUnitCompare(a.file, b.file) : a.line - b.line;
1070
+ confirmed.sort(bySite); unverified.sort(bySite);
1071
+ const byFile = new Map();
1072
+ for (const site of confirmed) {
1073
+ if (!byFile.has(site.file)) byFile.set(site.file, []);
1074
+ byFile.get(site.file).push(site);
1075
+ }
1076
+ return {
1077
+ owner: def.type,
1078
+ confirmedCount: confirmed.length,
1079
+ unverifiedCount: unverified.length,
1080
+ totalCandidates: confirmed.length + unverified.length,
1081
+ byFile: [...byFile.entries()].map(([file, sites]) => ({ file, count: sites.length, sites })),
1082
+ unverifiedSites: unverified,
1083
+ excluded: {
1084
+ total: excluded.length,
1085
+ byReason: excluded.reduce((out, s) => { out[s.reason] = (out[s.reason] || 0) + 1; return out; }, {}),
1086
+ },
1087
+ };
1088
+ }
1089
+
983
1090
  function impact(index, name, options = {}) {
984
1091
  index._beginOp();
985
1092
  try {
@@ -1270,6 +1377,13 @@ function impact(index, name, options = {}) {
1270
1377
  };
1271
1378
  }
1272
1379
 
1380
+ // fix #345: a type/interface/enum/trait is consumed through annotations,
1381
+ // not calls. Those sites were counted in the ACCOUNT as references and
1382
+ // listed nowhere, so the headline said 0 for a type with dozens of
1383
+ // dependents. Same design as the accessor band: a separate band, never
1384
+ // fake caller edges (the caller oracle and the account stay call-shaped).
1385
+ let typeReferences = findTypeReferences(index, name, def, options);
1386
+
1273
1387
  // Apply top limit if specified (limits total call sites shown)
1274
1388
  const totalBeforeLimit = filteredSites.length;
1275
1389
  if (options.top && options.top > 0 && filteredSites.length > options.top) {
@@ -1315,6 +1429,8 @@ function impact(index, name, options = {}) {
1315
1429
  ...Array.from(byFile.keys()),
1316
1430
  ...(propertyAccesses?.byFile || []).map(group => group.file),
1317
1431
  ...(propertyAccesses?.unverifiedSites || []).map(site => site.file),
1432
+ ...(typeReferences?.byFile || []).map(group => group.file),
1433
+ ...(typeReferences?.unverifiedSites || []).map(site => site.file),
1318
1434
  ]);
1319
1435
 
1320
1436
  return {
@@ -1332,6 +1448,11 @@ function impact(index, name, options = {}) {
1332
1448
  totalDependencySites: totalBeforeLimit + propertyAccesses.confirmedCount,
1333
1449
  affectedFiles: affectedFiles.size,
1334
1450
  }),
1451
+ ...(typeReferences && {
1452
+ typeReferences,
1453
+ totalDependencySites: totalBeforeLimit + typeReferences.confirmedCount,
1454
+ affectedFiles: affectedFiles.size,
1455
+ }),
1335
1456
  account: impactAccount,
1336
1457
  hasEntrypoints: !!impactReachable && impactReachable.size > 0,
1337
1458
  callerHistogram,
@@ -1876,19 +1997,6 @@ function diffImpact(index, options = {}) {
1876
1997
  }
1877
1998
  }
1878
1999
 
1879
- if (!diffText || !diffText.trim()) {
1880
- return {
1881
- base: staged ? '(staged)' : base,
1882
- changedPaths: 0,
1883
- nonSourcePaths: 0,
1884
- functions: [],
1885
- moduleLevelChanges: [],
1886
- newFunctions: [],
1887
- deletedFunctions: [],
1888
- summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
1889
- };
1890
- }
1891
-
1892
2000
  // Diff paths are git-root-relative. Resolve to index.root for file lookup.
1893
2001
  // Normalize both through realpath to handle macOS /var → /private/var symlinks.
1894
2002
  let realGitRoot, realProjectRoot;
@@ -1908,6 +2016,58 @@ function diffImpact(index, options = {}) {
1908
2016
  changes.push({ ...c, gitRelativePath: c.relativePath, filePath: path.join(index.root, localRel), relativePath: localRel });
1909
2017
  }
1910
2018
 
2019
+ // fix #346: untracked files are new work too. `git diff <base>` only
2020
+ // sees tracked paths, so a session's brand-new modules were invisible to
2021
+ // the pre-commit gate until `git add -N` — a silent pass on exactly the
2022
+ // code that has never been checked. Indexed, gitignore-respecting
2023
+ // untracked source files join the working-tree diff as whole-file
2024
+ // additions (staged mode keeps its index-only meaning).
2025
+ let untrackedPaths = 0;
2026
+ if (!staged) {
2027
+ const lsArgs = ['ls-files', '--others', '--exclude-standard', '-z'];
2028
+ if (file) lsArgs.push('--', file);
2029
+ let untrackedText = '';
2030
+ try {
2031
+ untrackedText = execFileSync('git', lsArgs, {
2032
+ cwd: index.root, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024,
2033
+ stdio: ['ignore', 'pipe', 'ignore'],
2034
+ });
2035
+ } catch (_) { untrackedText = ''; }
2036
+ const known = new Set(changes.map(c => c.relativePath));
2037
+ for (const localRel of untrackedText.split('\0').filter(Boolean).sort(codeUnitCompare)) {
2038
+ if (known.has(localRel)) continue;
2039
+ const filePath = path.join(index.root, localRel);
2040
+ const fileEntry = index.files.get(filePath);
2041
+ if (!fileEntry || !detectLanguage(filePath)) continue;
2042
+ let lineCount = fileEntry.lines;
2043
+ if (!Number.isFinite(lineCount)) {
2044
+ try { lineCount = fs.readFileSync(filePath, 'utf-8').split('\n').length; } catch (_) { continue; }
2045
+ }
2046
+ const addedLines = [];
2047
+ for (let i = 1; i <= lineCount; i++) addedLines.push(i);
2048
+ untrackedPaths++;
2049
+ changes.push({
2050
+ filePath, relativePath: localRel,
2051
+ gitRelativePath: projectPrefix ? `${projectPrefix}/${localRel}` : localRel,
2052
+ addedLines, deletedLines: [], untracked: true,
2053
+ });
2054
+ }
2055
+ }
2056
+
2057
+ if (changes.length === 0) {
2058
+ return {
2059
+ base: staged ? '(staged)' : base,
2060
+ changedPaths: 0,
2061
+ nonSourcePaths: 0,
2062
+ untrackedPaths: 0,
2063
+ functions: [],
2064
+ moduleLevelChanges: [],
2065
+ newFunctions: [],
2066
+ deletedFunctions: [],
2067
+ summary: { modifiedFunctions: 0, deletedFunctions: 0, newFunctions: 0, totalCallSites: 0, unverifiedCallSites: 0, affectedFiles: 0 }
2068
+ };
2069
+ }
2070
+
1911
2071
  const functions = [];
1912
2072
  const moduleLevelChanges = [];
1913
2073
  const newFunctions = [];
@@ -2111,7 +2271,10 @@ function diffImpact(index, options = {}) {
2111
2271
  const { symbol, addedLines } = data;
2112
2272
  const identityKey = `${symbol.name}\0${symbol.className || ''}`;
2113
2273
  let isNew;
2114
- if (oldSymbolIdentities !== null) {
2274
+ if (change.untracked) {
2275
+ // fix #346: nothing in an untracked file existed at the base.
2276
+ isNew = true;
2277
+ } else if (oldSymbolIdentities !== null) {
2115
2278
  isNew = !oldSymbolIdentities.has(identityKey);
2116
2279
  } else {
2117
2280
  // Fallback: 80% of body lines added and no deletions hit this symbol.
@@ -2291,6 +2454,7 @@ function diffImpact(index, options = {}) {
2291
2454
  base: staged ? '(staged)' : base,
2292
2455
  changedPaths: changes.length,
2293
2456
  nonSourcePaths,
2457
+ untrackedPaths,
2294
2458
  functions,
2295
2459
  moduleLevelChanges,
2296
2460
  newFunctions,
package/core/cache.js CHANGED
@@ -690,7 +690,14 @@ function clearAllCaches() {
690
690
  // types for values bound from declared map, slice, and array indexes.
691
691
  // v208 (fix #335): Go indexed-value calls preserve receiver-root/field
692
692
  // provenance so sibling-file container declarations resolve query-time.
693
- const CACHE_FORMAT_VERSION = 208;
693
+ // v210 (fixes #337-#339): importDetails persisted for every language (was Python-only),
694
+ // require(path.join(__dirname, ...)) composes to a static relative specifier,
695
+ // and import records carry deferredReason (function-local / type-checking / type-only).
696
+ // v211: path-utility composition requires unshadowed binding evidence; mixed
697
+ // default/type imports and inline type re-exports preserve execution timing.
698
+ // Python TYPE_CHECKING guards require typing ownership and no rebinding.
699
+ // v212 (fix #342): extendsGraph/extendedByGraph no longer persisted (rebuilt on load).
700
+ const CACHE_FORMAT_VERSION = 212;
694
701
  const USAGE_CACHE_FILE = 'usage-results.json';
695
702
 
696
703
  /**
@@ -901,9 +908,13 @@ function saveCache(index, cachePath) {
901
908
  symbols: strippedSymbols,
902
909
  importGraph: relGraph(index.importGraph),
903
910
  exportGraph: relGraph(index.exportGraph),
904
- // extendsGraph/extendedByGraph use class names as keys (not file paths)
905
- extendsGraph: Array.from(index.extendsGraph.entries()),
906
- extendedByGraph: Array.from(index.extendedByGraph.entries()),
911
+ // extendsGraph/extendedByGraph are NOT persisted (fix #342): their
912
+ // entries carry absolute file paths in the build-time spelling, and
913
+ // the cache key is realpath-normalized — a root reached through a
914
+ // symlink (/var → /private/var on macOS) loaded a graph whose files
915
+ // matched nothing, so an overriding Go embed read as a non-overriding
916
+ // subclass and confirmed two false callers. loadCache rebuilds both
917
+ // from the rehydrated symbols (8ms on 1037 files).
907
918
  failedFiles: index.failedFiles
908
919
  ? Array.from(index.failedFiles).map(f => path.relative(root, f))
909
920
  : [],
@@ -1116,13 +1127,8 @@ function loadCache(index, cachePath) {
1116
1127
  index.buildTime = cacheData.buildTime;
1117
1128
 
1118
1129
  // Restore optional graphs if present
1119
- // extendsGraph/extendedByGraph use class names as keys (not file paths)
1120
- if (Array.isArray(cacheData.extendsGraph)) {
1121
- index.extendsGraph = new Map(cacheData.extendsGraph);
1122
- }
1123
- if (Array.isArray(cacheData.extendedByGraph)) {
1124
- index.extendedByGraph = new Map(cacheData.extendedByGraph);
1125
- }
1130
+ // extendsGraph/extendedByGraph are derived below from the rehydrated
1131
+ // symbols (fix #342) — never read from the payload.
1126
1132
 
1127
1133
  // Prepare lazy calls cache loading — load manifest but defer shard parsing.
1128
1134
  // Shards are loaded on first getCachedCalls access via ensureCallsCacheLoaded().
@@ -1194,13 +1200,16 @@ function loadCache(index, cachePath) {
1194
1200
  }
1195
1201
  }
1196
1202
 
1197
- // Only rebuild graphs if config changed (e.g., aliases modified)
1203
+ // Only rebuild the import graph if config changed (e.g., aliases
1204
+ // modified); it is persisted with relative paths. The inheritance
1205
+ // graph is always derived from the rehydrated symbols (fix #342) so
1206
+ // its file paths agree with index.root whatever spelling built it.
1198
1207
  const currentConfigHash = crypto.createHash('md5')
1199
1208
  .update(JSON.stringify(index.config || {})).digest('hex');
1200
1209
  if (currentConfigHash !== cacheData.configHash) {
1201
1210
  index.buildImportGraph();
1202
- index.buildInheritanceGraph();
1203
1211
  }
1212
+ index.buildInheritanceGraph();
1204
1213
 
1205
1214
  loadUsageCache(index, cacheFile);
1206
1215