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/.claude/skills/ucn/SKILL.md +59 -3
- package/.claude/skills/ucn/references/commands.md +3 -1
- package/README.md +158 -533
- package/cli/index.js +71 -10
- package/core/analysis.js +178 -14
- package/core/cache.js +22 -13
- package/core/callers.js +51 -52
- package/core/check.js +2 -2
- package/core/execute.js +24 -6
- package/core/graph.js +167 -35
- package/core/index-ir.js +12 -9
- package/core/output/analysis-ext.js +4 -0
- package/core/output/analysis.js +30 -2
- package/core/output/endpoints.js +1 -1
- package/core/output/graph.js +60 -11
- package/core/output/lines.js +272 -0
- package/core/output/public.js +15 -0
- package/core/output/reporting.js +9 -2
- package/core/output/tracing.js +10 -2
- package/core/output-budget.js +7 -4
- package/core/project.js +15 -2
- package/core/registry.js +7 -6
- package/core/reporting.js +159 -14
- package/languages/javascript.js +213 -6
- package/languages/python.js +167 -39
- package/mcp/server.js +3 -1
- package/package.json +2 -2
- package/assets/demo.svg +0 -31
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
905
|
-
|
|
906
|
-
|
|
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
|
|
1120
|
-
|
|
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
|
|
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
|
|