brainclaw 1.25.0 → 1.26.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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/code-map.js +1 -4
- package/dist/commands/mcp.js +7 -7
- package/dist/commands/session-start.js +137 -15
- package/dist/core/bootstrap.js +28 -4
- package/dist/core/code-map/aggregate.js +36 -31
- package/dist/core/code-map/backend.js +4 -4
- package/dist/core/code-map/core.js +1 -0
- package/dist/core/code-map/export.js +4 -4
- package/dist/core/code-map/finalizer.js +57 -2
- package/dist/core/code-map/freshness.js +78 -13
- package/dist/core/code-map/impact.js +36 -4
- package/dist/core/code-map/indexes.js +37 -0
- package/dist/core/code-map/lang/python/index.js +4 -2
- package/dist/core/code-map/lang/query-runtime.js +2 -0
- package/dist/core/code-map/lang/typescript/index.js +4 -2
- package/dist/core/code-map/lang/usages.js +333 -0
- package/dist/core/code-map/memory-reader.js +15 -0
- package/dist/core/code-map/query.js +209 -58
- package/dist/core/code-map/refresh.js +0 -0
- package/dist/core/code-map/resolve.js +27 -2
- package/dist/core/code-map/store.js +1 -0
- package/dist/core/code-map/types.js +55 -9
- package/dist/core/code-map/vocabulary.js +6 -0
- package/dist/core/code-map/work-section.js +12 -14
- package/dist/core/context-diff.js +17 -3
- package/dist/core/entity-operations.js +14 -2
- package/dist/core/hint-aging.js +4 -1
- package/dist/core/identity.js +284 -91
- package/dist/core/io.js +192 -0
- package/dist/core/project-discovery.js +7 -1
- package/dist/core/runtime.js +99 -11
- package/dist/core/store-resolution.js +5 -21
- package/dist/facts.js +12 -12
- package/dist/facts.json +11 -11
- package/docs/code-map.md +36 -27
- package/package.json +1 -1
|
@@ -26,6 +26,12 @@ function coercePaths(raw) {
|
|
|
26
26
|
return raw.filter((p) => typeof p === 'string');
|
|
27
27
|
return [];
|
|
28
28
|
}
|
|
29
|
+
function optionalString(raw) {
|
|
30
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
31
|
+
}
|
|
32
|
+
function optionalCount(raw) {
|
|
33
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined;
|
|
34
|
+
}
|
|
29
35
|
function toRelatedMemoryItem(kind, item) {
|
|
30
36
|
return {
|
|
31
37
|
id: typeof item.id === 'string' ? item.id : '',
|
|
@@ -33,6 +39,15 @@ function toRelatedMemoryItem(kind, item) {
|
|
|
33
39
|
text: typeof item.text === 'string' ? item.text : '',
|
|
34
40
|
tags: coerceTags(item.tags),
|
|
35
41
|
related_paths: coercePaths(item.related_paths),
|
|
42
|
+
created_at: optionalString(item.created_at),
|
|
43
|
+
last_confirmed_at: optionalString(item.last_confirmed_at),
|
|
44
|
+
last_infirmed_at: optionalString(item.last_infirmed_at),
|
|
45
|
+
confirmation_count: optionalCount(item.confirmation_count),
|
|
46
|
+
infirmation_count: optionalCount(item.infirmation_count),
|
|
47
|
+
saved_me_count: optionalCount(item.saved_me_count),
|
|
48
|
+
misled_me_count: optionalCount(item.misled_me_count),
|
|
49
|
+
verified_at: optionalString(item.verified_at),
|
|
50
|
+
verify_cmd: optionalString(item.verify_cmd),
|
|
36
51
|
};
|
|
37
52
|
}
|
|
38
53
|
/**
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
+
import { getLifecycleStats } from '../memory-lifecycle.js';
|
|
14
15
|
import { hashContent } from './extractor.js';
|
|
15
|
-
import {
|
|
16
|
+
import { makeFreshnessBadge } from './freshness.js';
|
|
16
17
|
import { readImportsIndex, readManifest, readResolutionIndex, readShard, readSymbolsIndex, } from './store.js';
|
|
17
18
|
// --- lazy read-path freshness budget (spec §6.1) ---
|
|
18
19
|
/** Default per-query lazy-check budget (spec §6.1). */
|
|
@@ -50,6 +51,7 @@ function budgetExhausted(checker) {
|
|
|
50
51
|
}
|
|
51
52
|
export function newAccumulator() {
|
|
52
53
|
return {
|
|
54
|
+
checkedPaths: new Set(),
|
|
53
55
|
staleChangedPaths: new Set(),
|
|
54
56
|
missingPaths: new Set(),
|
|
55
57
|
uncheckedPaths: new Set(),
|
|
@@ -76,6 +78,7 @@ function validateEntry(entry, checker, acc, projectRoot, maxParseFileBytes, cwd,
|
|
|
76
78
|
if (cached !== undefined)
|
|
77
79
|
return cached;
|
|
78
80
|
const abs = path.join(projectRoot, entry.path);
|
|
81
|
+
acc.checkedPaths.add(entry.path);
|
|
79
82
|
let stat;
|
|
80
83
|
try {
|
|
81
84
|
stat = fs.statSync(abs);
|
|
@@ -151,46 +154,26 @@ export function validateStoreEntry(entry, checker, acc, cwd, preferredDirName) {
|
|
|
151
154
|
* change/deletion yields `stale_changed_files`; else the manifest base status.
|
|
152
155
|
*/
|
|
153
156
|
export function deriveBadge(base, acc, budgetExhausted, hadConfidentMatch, emptyIndex) {
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (acc.missingPaths.size > 0) {
|
|
159
|
-
details.deleted_files = [...acc.missingPaths].sort();
|
|
160
|
-
}
|
|
161
|
-
if (acc.uncheckedPaths.size > 0) {
|
|
162
|
-
details.unchecked_files = [...acc.uncheckedPaths].sort();
|
|
163
|
-
}
|
|
164
|
-
let status = base;
|
|
165
|
-
if (emptyIndex && base !== 'missing_index') {
|
|
166
|
-
// §6.1 — zero confident matches: hint refresh rather than imply absence.
|
|
167
|
-
details.hint = 'missing_index_or_refresh';
|
|
168
|
-
}
|
|
169
|
-
if (acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0) {
|
|
170
|
-
status = 'stale_changed_files';
|
|
171
|
-
}
|
|
172
|
-
// §6.1.6 — `partial` means the lazy-check budget (file count / wall clock) ran
|
|
173
|
-
// out before we could validate everything. Reserve it for that cause only:
|
|
174
|
-
// unchecked-for-other-reasons (oversized file per §6.1.4, missing shard,
|
|
175
|
-
// unreadable file) must NOT be mislabeled as budget exhaustion. When the budget
|
|
176
|
-
// truly ran out, `partial` wins the top-line status — the agent should refresh
|
|
177
|
-
// before trusting the result — and the confirmed-stale list still rides along
|
|
178
|
-
// in `details.stale_changed_files`.
|
|
179
|
-
if (budgetExhausted || acc.budgetSkippedPaths.size > 0) {
|
|
180
|
-
status = 'partial';
|
|
181
|
-
details.partial_reason = 'lazy_check_budget_exhausted';
|
|
182
|
-
details.budget = { ...LAZY_BUDGET };
|
|
183
|
-
}
|
|
184
|
-
// pln#593 #2 — distinguish INDEX freshness (manifest state) from THIS call's
|
|
185
|
-
// read-path spot-check. When the call-level status diverges from the index
|
|
186
|
-
// status (a budget-limited `partial`, or a per-file `stale_changed_files` over a
|
|
187
|
-
// `fresh` index), surface the index status so an agent does not read
|
|
188
|
-
// status()=fresh vs find()/brief()=partial as a contradiction: it's "index
|
|
189
|
-
// <index_status>, this call's spot-check <status>".
|
|
190
|
-
if (status !== base)
|
|
191
|
-
details.index_status = base;
|
|
157
|
+
const hasStale = acc.staleChangedPaths.size > 0 || acc.missingPaths.size > 0;
|
|
158
|
+
const partial = budgetExhausted || acc.budgetSkippedPaths.size > 0;
|
|
159
|
+
const spotStatus = partial ? 'partial' : hasStale ? 'stale' : acc.checkedPaths.size > 0 ? 'fresh' : 'not_run';
|
|
160
|
+
const hint = emptyIndex && base !== 'missing_index' ? 'missing_index_or_refresh' : undefined;
|
|
192
161
|
void hadConfidentMatch;
|
|
193
|
-
|
|
162
|
+
// `base` stays the top-level index classification. A stale or budget-limited
|
|
163
|
+
// candidate spot-check is honest evidence, but it must not turn find()/brief()
|
|
164
|
+
// into a different badge than status()/work() for the same index state.
|
|
165
|
+
return makeFreshnessBadge(base, {
|
|
166
|
+
spotCheck: {
|
|
167
|
+
status: spotStatus,
|
|
168
|
+
checked_files: acc.checkedPaths.size,
|
|
169
|
+
stale_changed_files: [...acc.staleChangedPaths].sort(),
|
|
170
|
+
deleted_files: [...acc.missingPaths].sort(),
|
|
171
|
+
unchecked_files: [...acc.uncheckedPaths].sort(),
|
|
172
|
+
budget_exhausted: partial,
|
|
173
|
+
partial_reason: partial ? 'lazy_check_budget_exhausted' : null,
|
|
174
|
+
},
|
|
175
|
+
extra: hint ? { hint } : undefined,
|
|
176
|
+
});
|
|
194
177
|
}
|
|
195
178
|
const DEFAULT_FIND_LIMIT = 20;
|
|
196
179
|
/** Lowercase token normalization mirroring indexes.ts (spec §5.6 keys). */
|
|
@@ -411,7 +394,7 @@ export function find(query, limit, ctx) {
|
|
|
411
394
|
return {
|
|
412
395
|
query,
|
|
413
396
|
matches: [],
|
|
414
|
-
freshness_badge:
|
|
397
|
+
freshness_badge: makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }),
|
|
415
398
|
};
|
|
416
399
|
}
|
|
417
400
|
const capped = r.matches.slice(0, limit ?? DEFAULT_FIND_LIMIT);
|
|
@@ -420,43 +403,178 @@ export function find(query, limit, ctx) {
|
|
|
420
403
|
}
|
|
421
404
|
/** spec §11 — cap related memory at top 5 by relevance. */
|
|
422
405
|
export const RELATED_MEMORY_CAP = 5;
|
|
406
|
+
const MEMORY_JOIN_MIN_IDENTIFIER_LENGTH = 4;
|
|
407
|
+
const MEMORY_JOIN_STOP_WORDS = new Set([
|
|
408
|
+
'default', 'module', 'index', 'main', 'test', 'tests', 'spec', 'react', 'node',
|
|
409
|
+
]);
|
|
410
|
+
function stableNames(names) {
|
|
411
|
+
const byNormalized = new Map();
|
|
412
|
+
for (const raw of names) {
|
|
413
|
+
const name = raw.trim();
|
|
414
|
+
const normalized = normIdent(name);
|
|
415
|
+
if (!normalized || byNormalized.has(normalized))
|
|
416
|
+
continue;
|
|
417
|
+
byNormalized.set(normalized, name);
|
|
418
|
+
}
|
|
419
|
+
return [...byNormalized.values()].sort((a, b) => a.localeCompare(b));
|
|
420
|
+
}
|
|
421
|
+
/** Avoid joining a prose memory to generic import noise such as `default` or `React`. */
|
|
422
|
+
function memoryJoinTerms(names) {
|
|
423
|
+
return stableNames(names).filter((name) => {
|
|
424
|
+
const normalized = normIdent(name);
|
|
425
|
+
return normalized.length >= MEMORY_JOIN_MIN_IDENTIFIER_LENGTH
|
|
426
|
+
&& !MEMORY_JOIN_STOP_WORDS.has(normalized)
|
|
427
|
+
&& !isTestHelperSymbol(name);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
function escapeRegExp(text) {
|
|
431
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Identifier-aware, case-insensitive text match. The surrounding guards keep
|
|
435
|
+
* `Auth` from matching `author`, while still matching `ENTITY_REGISTRY` in a
|
|
436
|
+
* prose trap written as `entity_registry`.
|
|
437
|
+
*/
|
|
438
|
+
function textMentionsIdentifier(text, identifier) {
|
|
439
|
+
const escaped = escapeRegExp(identifier);
|
|
440
|
+
return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}(?=$|[^A-Za-z0-9_$])`, 'i').test(text);
|
|
441
|
+
}
|
|
442
|
+
function isLifecycleMemoryKind(kind) {
|
|
443
|
+
return kind === 'decision' || kind === 'constraint' || kind === 'trap';
|
|
444
|
+
}
|
|
445
|
+
function validDate(value) {
|
|
446
|
+
if (!value)
|
|
447
|
+
return null;
|
|
448
|
+
const ms = Date.parse(value);
|
|
449
|
+
return Number.isFinite(ms) ? ms : null;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Make memory age explicit on the brief response. `verify_cmd` marks a
|
|
453
|
+
* perishable fact, so its empirical verification wins over the gentler
|
|
454
|
+
* lifecycle curve: missing verification is `unverified`, and one older than
|
|
455
|
+
* 30 days is `stale` (the same threshold used by memory anti-staleness).
|
|
456
|
+
*/
|
|
457
|
+
function describeMemoryFreshness(item) {
|
|
458
|
+
const verifiedAt = item.verified_at ?? null;
|
|
459
|
+
const verificationMs = validDate(item.verified_at);
|
|
460
|
+
const nowMs = Date.now();
|
|
461
|
+
if (item.verify_cmd) {
|
|
462
|
+
if (verificationMs === null) {
|
|
463
|
+
return { status: 'unverified', as_of: item.created_at ?? null, age_days: null, verified_at: verifiedAt };
|
|
464
|
+
}
|
|
465
|
+
const ageDays = Math.max(0, Math.floor((nowMs - verificationMs) / 86_400_000));
|
|
466
|
+
if (ageDays > 30)
|
|
467
|
+
return { status: 'stale', as_of: item.verified_at, age_days: ageDays, verified_at: verifiedAt };
|
|
468
|
+
}
|
|
469
|
+
if (!isLifecycleMemoryKind(item.kind) || validDate(item.created_at) === null) {
|
|
470
|
+
return { status: 'unknown', as_of: item.created_at ?? null, age_days: null, verified_at: verifiedAt };
|
|
471
|
+
}
|
|
472
|
+
const stats = getLifecycleStats({
|
|
473
|
+
entity: item.kind,
|
|
474
|
+
created_at: item.created_at,
|
|
475
|
+
last_confirmed_at: item.last_confirmed_at,
|
|
476
|
+
last_infirmed_at: item.last_infirmed_at,
|
|
477
|
+
confirmation_count: item.confirmation_count,
|
|
478
|
+
infirmation_count: item.infirmation_count,
|
|
479
|
+
saved_me_count: item.saved_me_count,
|
|
480
|
+
misled_me_count: item.misled_me_count,
|
|
481
|
+
nowMs,
|
|
482
|
+
});
|
|
483
|
+
return {
|
|
484
|
+
status: stats.classification,
|
|
485
|
+
as_of: stats.anchor_at,
|
|
486
|
+
age_days: stats.age_days,
|
|
487
|
+
verified_at: verifiedAt,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
423
490
|
/**
|
|
424
491
|
* Match memory items to a set of candidate file paths + the query symbol name
|
|
425
492
|
* by (spec §11): related_paths, tags, or a literal file-path mention in the
|
|
426
493
|
* memory text. Returns the top `RELATED_MEMORY_CAP` by relevance.
|
|
427
494
|
*/
|
|
428
|
-
export function attachRelatedMemory(items, paths, symbolNames) {
|
|
495
|
+
export function attachRelatedMemory(items, paths, symbolNames, importNames = []) {
|
|
429
496
|
const pathSet = new Set(paths.map((p) => p.replace(/\\/g, '/')));
|
|
430
497
|
const baseNames = new Set(paths.map((p) => path.basename(p)));
|
|
431
498
|
const symLower = new Set(symbolNames.map((s) => s.toLowerCase()));
|
|
499
|
+
const symbolTerms = memoryJoinTerms(symbolNames);
|
|
500
|
+
const importTerms = memoryJoinTerms(importNames);
|
|
432
501
|
const scored = [];
|
|
433
502
|
for (const item of items) {
|
|
434
503
|
let score = 0;
|
|
504
|
+
const sources = new Set();
|
|
435
505
|
// related_paths — strongest signal.
|
|
436
506
|
for (const rp of item.related_paths ?? []) {
|
|
437
507
|
const norm = rp.replace(/\\/g, '/');
|
|
438
|
-
if (pathSet.has(norm))
|
|
508
|
+
if (pathSet.has(norm)) {
|
|
439
509
|
score += 5;
|
|
440
|
-
|
|
510
|
+
sources.add('related_path');
|
|
511
|
+
}
|
|
512
|
+
else if (baseNames.has(path.basename(norm))) {
|
|
441
513
|
score += 3;
|
|
514
|
+
sources.add('related_path_basename');
|
|
515
|
+
}
|
|
442
516
|
}
|
|
443
517
|
// literal file-path mention in the memory text.
|
|
444
518
|
const text = item.text ?? '';
|
|
445
519
|
for (const p of pathSet) {
|
|
446
|
-
if (text.includes(p))
|
|
520
|
+
if (text.includes(p)) {
|
|
447
521
|
score += 2;
|
|
522
|
+
sources.add('path_mention');
|
|
523
|
+
}
|
|
448
524
|
}
|
|
449
525
|
for (const bn of baseNames) {
|
|
450
|
-
if (text.includes(bn))
|
|
526
|
+
if (text.includes(bn)) {
|
|
451
527
|
score += 1;
|
|
528
|
+
sources.add('path_basename_mention');
|
|
529
|
+
}
|
|
452
530
|
}
|
|
453
531
|
// tags matching a symbol name (e.g. tag "App" / "useAuth").
|
|
454
532
|
for (const tag of item.tags ?? []) {
|
|
455
|
-
if (symLower.has(tag.toLowerCase()))
|
|
533
|
+
if (symLower.has(tag.toLowerCase())) {
|
|
456
534
|
score += 2;
|
|
535
|
+
sources.add('symbol_tag');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// pln#601 amendment — response-local join from MEMORY TEXT to the names
|
|
539
|
+
// defined/imported by the brief target. We only admit identifier-shaped,
|
|
540
|
+
// non-test-helper terms and retain the exact terms as inspectable evidence.
|
|
541
|
+
const matchedSymbols = symbolTerms.filter((term) => textMentionsIdentifier(text, term));
|
|
542
|
+
const matchedImports = importTerms.filter((term) => textMentionsIdentifier(text, term));
|
|
543
|
+
if (matchedSymbols.length > 0) {
|
|
544
|
+
score += 4 * matchedSymbols.length;
|
|
545
|
+
sources.add('symbol_text');
|
|
546
|
+
}
|
|
547
|
+
if (matchedImports.length > 0) {
|
|
548
|
+
score += 3 * matchedImports.length;
|
|
549
|
+
sources.add('import_text');
|
|
550
|
+
}
|
|
551
|
+
if (score > 0) {
|
|
552
|
+
let confidence = 0.6;
|
|
553
|
+
if (sources.has('related_path'))
|
|
554
|
+
confidence = 1;
|
|
555
|
+
else if (sources.has('path_mention'))
|
|
556
|
+
confidence = 0.95;
|
|
557
|
+
else if (sources.has('related_path_basename'))
|
|
558
|
+
confidence = 0.85;
|
|
559
|
+
else if (sources.has('symbol_text'))
|
|
560
|
+
confidence = 0.82;
|
|
561
|
+
else if (sources.has('import_text'))
|
|
562
|
+
confidence = 0.72;
|
|
563
|
+
else if (sources.has('symbol_tag'))
|
|
564
|
+
confidence = 0.65;
|
|
565
|
+
const evidence = {
|
|
566
|
+
sources: [...sources].sort(),
|
|
567
|
+
matched_symbols: matchedSymbols,
|
|
568
|
+
matched_imports: matchedImports,
|
|
569
|
+
confidence,
|
|
570
|
+
};
|
|
571
|
+
// Clone: these response annotations must never mutate the records returned
|
|
572
|
+
// by the memory reader or be persisted into Code Map shards.
|
|
573
|
+
scored.push({
|
|
574
|
+
item: { ...item, match_evidence: evidence, memory_freshness: describeMemoryFreshness(item) },
|
|
575
|
+
score,
|
|
576
|
+
});
|
|
457
577
|
}
|
|
458
|
-
if (score > 0)
|
|
459
|
-
scored.push({ item, score });
|
|
460
578
|
}
|
|
461
579
|
scored.sort((a, b) => b.score - a.score || a.item.id.localeCompare(b.item.id));
|
|
462
580
|
return scored.slice(0, RELATED_MEMORY_CAP).map((s) => s.item);
|
|
@@ -649,6 +767,24 @@ function reverseDeps(resolutionIndex, definingPaths, definingByNodeId) {
|
|
|
649
767
|
}
|
|
650
768
|
return [...byPath.values()];
|
|
651
769
|
}
|
|
770
|
+
function targetMemoryTerms(definingPaths, definingFileIds, nodeIndex, cwd, preferredDirName) {
|
|
771
|
+
const symbols = [...nodeIndex.values()]
|
|
772
|
+
.filter((entry) => definingPaths.has(entry.path))
|
|
773
|
+
.map((entry) => entry.name);
|
|
774
|
+
const imports = new Set();
|
|
775
|
+
for (const fileId of new Set(definingFileIds)) {
|
|
776
|
+
const shard = readShard(fileId, cwd, preferredDirName);
|
|
777
|
+
if (!shard)
|
|
778
|
+
continue;
|
|
779
|
+
for (const node of shard.nodes) {
|
|
780
|
+
if (node.kind !== 'module')
|
|
781
|
+
continue;
|
|
782
|
+
for (const imported of node.imported_names)
|
|
783
|
+
imports.add(imported);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return { symbolNames: stableNames(symbols), importNames: stableNames([...imports]) };
|
|
787
|
+
}
|
|
652
788
|
/**
|
|
653
789
|
* Heuristic: does the brief target denote a file PATH rather than a bare symbol
|
|
654
790
|
* name? A path separator or a supported source extension marks a path target —
|
|
@@ -694,6 +830,8 @@ export function briefInStore(target, ctx, checker, acc) {
|
|
|
694
830
|
return {
|
|
695
831
|
defining: [],
|
|
696
832
|
definingPaths: new Set(),
|
|
833
|
+
memorySymbolNames: [],
|
|
834
|
+
memoryImportNames: [],
|
|
697
835
|
matchKind: 'none',
|
|
698
836
|
confident: [],
|
|
699
837
|
base: 'missing_index',
|
|
@@ -749,6 +887,7 @@ export function briefInStore(target, ctx, checker, acc) {
|
|
|
749
887
|
confidentDefiningFileIds.set(e.path, e.file_id);
|
|
750
888
|
}
|
|
751
889
|
const nodeIndex = buildNodeIdIndex(symbolsIndex);
|
|
890
|
+
const memoryTerms = targetMemoryTerms(definingPaths, defining.map((entry) => entry.file_id), nodeIndex, ctx.cwd, ctx.preferredDirName);
|
|
752
891
|
const fwd = forwardDeps(confidentDefiningFileIds, nodeIndex, ctx.cwd, ctx.preferredDirName);
|
|
753
892
|
const rev = reverseDeps(resolutionIndex, definingPaths, definingByNodeId);
|
|
754
893
|
const ranked = rankFiles(defining, fwd, rev, symbolsIndex, importsIndex, target);
|
|
@@ -763,13 +902,25 @@ export function briefInStore(target, ctx, checker, acc) {
|
|
|
763
902
|
continue;
|
|
764
903
|
confident.push(rf);
|
|
765
904
|
}
|
|
766
|
-
return {
|
|
905
|
+
return {
|
|
906
|
+
defining,
|
|
907
|
+
definingPaths,
|
|
908
|
+
memorySymbolNames: memoryTerms.symbolNames,
|
|
909
|
+
memoryImportNames: memoryTerms.importNames,
|
|
910
|
+
matchKind,
|
|
911
|
+
confident,
|
|
912
|
+
base,
|
|
913
|
+
hasIndex: true,
|
|
914
|
+
emptyRanked: ranked.length === 0,
|
|
915
|
+
acc,
|
|
916
|
+
};
|
|
767
917
|
}
|
|
768
918
|
/**
|
|
769
919
|
* Attach related-memory ids per reading-list entry (spec §11). Shared by the
|
|
770
920
|
* single-store brief() and the workspace aggregation so both surface memory identically.
|
|
771
921
|
*/
|
|
772
|
-
export function attachMemoryIds(capped, related) {
|
|
922
|
+
export function attachMemoryIds(capped, related, targetPaths = new Set()) {
|
|
923
|
+
const normalizedTargetPaths = new Set([...targetPaths].map((p) => p.replace(/\\/g, '/')));
|
|
773
924
|
return capped.map((f) => {
|
|
774
925
|
const ids = related
|
|
775
926
|
.filter((m) => {
|
|
@@ -777,7 +928,9 @@ export function attachMemoryIds(capped, related) {
|
|
|
777
928
|
const base2 = path.basename(fileNorm);
|
|
778
929
|
const inPaths = (m.related_paths ?? []).some((rp) => rp.replace(/\\/g, '/') === fileNorm || path.basename(rp) === base2);
|
|
779
930
|
const inText = (m.text ?? '').includes(fileNorm) || (m.text ?? '').includes(base2);
|
|
780
|
-
|
|
931
|
+
const symbolTextMatch = (m.match_evidence?.matched_symbols.length ?? 0) > 0
|
|
932
|
+
|| (m.match_evidence?.matched_imports.length ?? 0) > 0;
|
|
933
|
+
return inPaths || inText || (normalizedTargetPaths.has(fileNorm) && symbolTextMatch);
|
|
781
934
|
})
|
|
782
935
|
.map((m) => m.id);
|
|
783
936
|
return { path: f.path, reason: f.reason, score: f.score, related_memory_ids: ids };
|
|
@@ -792,7 +945,7 @@ export function brief(target, limit, ctx, memoryReader) {
|
|
|
792
945
|
target,
|
|
793
946
|
suggested_files_to_read: [],
|
|
794
947
|
related_memory: [],
|
|
795
|
-
freshness_badge:
|
|
948
|
+
freshness_badge: makeFreshnessBadge('missing_index', { extra: { hint: 'run refresh' } }),
|
|
796
949
|
};
|
|
797
950
|
}
|
|
798
951
|
const cap = Math.min(limit ?? BRIEF_FILE_CAP, BRIEF_FILE_CAP);
|
|
@@ -800,11 +953,9 @@ export function brief(target, limit, ctx, memoryReader) {
|
|
|
800
953
|
const capped = reserveSourceSlots(r.confident, cap, r.definingPaths);
|
|
801
954
|
// Related memory (spec §11): match by the candidate paths + symbol names.
|
|
802
955
|
const candidatePaths = capped.map((f) => f.path);
|
|
803
|
-
const symbolNames =
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames);
|
|
807
|
-
const suggested = attachMemoryIds(capped, related);
|
|
956
|
+
const symbolNames = r.memorySymbolNames.length > 0 ? r.memorySymbolNames : [target];
|
|
957
|
+
const related = attachRelatedMemory(memoryReader(ctx), candidatePaths, symbolNames, r.memoryImportNames);
|
|
958
|
+
const suggested = attachMemoryIds(capped, related, r.definingPaths);
|
|
808
959
|
const badge = deriveBadge(r.base, acc, checker.exhausted, capped.length > 0, r.emptyRanked);
|
|
809
960
|
return { target, suggested_files_to_read: suggested, related_memory: related, freshness_badge: badge };
|
|
810
961
|
}
|
|
Binary file
|
|
@@ -55,7 +55,7 @@ function edgesArrayEqual(a, b) {
|
|
|
55
55
|
for (let i = 0; i < a.length; i++) {
|
|
56
56
|
const x = a[i];
|
|
57
57
|
const y = b[i];
|
|
58
|
-
if (x.id !== y.id || x.from !== y.from || x.to !== y.to || x.kind !== y.kind || x.confidence !== y.confidence) {
|
|
58
|
+
if (x.id !== y.id || x.from !== y.from || x.to !== y.to || x.kind !== y.kind || x.confidence !== y.confidence || x.origin !== y.origin) {
|
|
59
59
|
return false;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
@@ -101,9 +101,12 @@ export async function resolveProjectImports(input) {
|
|
|
101
101
|
const provider = registry.providerForLang(shard.lang);
|
|
102
102
|
// Non-pass-owned edges are preserved byte-identical (same order). Filtering BOTH
|
|
103
103
|
// pass-owned kinds also strips any stale A/B edge from a prior run (idempotency).
|
|
104
|
-
const kept = shard.edges.filter((e) => !PASS_OWNED_EDGE_KINDS.has(e.kind));
|
|
104
|
+
const kept = shard.edges.filter((e) => !PASS_OWNED_EDGE_KINDS.has(e.kind) && e.origin !== 'usage_import');
|
|
105
105
|
// Fresh pass-owned set (A resolves_to + B imports_symbol). Empty when no resolver.
|
|
106
106
|
const fresh = [];
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
// module + imported-name → unique target proven by imports_symbol below.
|
|
109
|
+
const resolvedImportedBindings = new Map();
|
|
107
110
|
let freshSymbolCount = 0;
|
|
108
111
|
if (provider?.resolveImport) {
|
|
109
112
|
const seen = new Set();
|
|
@@ -149,11 +152,33 @@ export async function resolveProjectImports(input) {
|
|
|
149
152
|
continue; // dedup (duplicate names / re-imports)
|
|
150
153
|
seen.add(symId);
|
|
151
154
|
fresh.push({ id: symId, from: mod.id, to: target.id, kind: 'imports_symbol', confidence, source });
|
|
155
|
+
resolvedImportedBindings.set(`${r.source}\0${name}`, target);
|
|
152
156
|
freshSymbolCount++;
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
}
|
|
161
|
+
// P4 imported-binding usages are emitted only after the exact `imports_symbol`
|
|
162
|
+
// proof above. If a module is unresolved, ambiguous, wildcard/default, or its
|
|
163
|
+
// target no longer exports the symbol, no call/reference edge survives.
|
|
164
|
+
for (const candidate of shard.reference_candidates ?? []) {
|
|
165
|
+
const target = resolvedImportedBindings.get(`${candidate.module}\0${candidate.imported_name}`);
|
|
166
|
+
if (!target)
|
|
167
|
+
continue;
|
|
168
|
+
const id = edgeId({ projectId, from: candidate.from, to: target.id, kind: candidate.kind });
|
|
169
|
+
if (seen.has(id))
|
|
170
|
+
continue;
|
|
171
|
+
seen.add(id);
|
|
172
|
+
fresh.push({
|
|
173
|
+
id,
|
|
174
|
+
from: candidate.from,
|
|
175
|
+
to: target.id,
|
|
176
|
+
kind: candidate.kind,
|
|
177
|
+
confidence: Math.min(clampConfidence(candidate.confidence), 1.0),
|
|
178
|
+
source: candidate.source,
|
|
179
|
+
origin: 'usage_import',
|
|
180
|
+
});
|
|
181
|
+
}
|
|
157
182
|
// Single deterministic order over A+B so re-runs are byte-identical (from, to, kind).
|
|
158
183
|
fresh.sort((a, b) => {
|
|
159
184
|
if (a.from !== b.from)
|
|
@@ -193,6 +193,7 @@ export function readResolutionIndex(cwd, preferredDirName) {
|
|
|
193
193
|
// 'toString' (a path or node id) can't resolve to an inherited prototype member.
|
|
194
194
|
parsed.data.dependents_by_file = Object.assign(Object.create(null), parsed.data.dependents_by_file);
|
|
195
195
|
parsed.data.dependents_by_symbol = Object.assign(Object.create(null), parsed.data.dependents_by_symbol);
|
|
196
|
+
parsed.data.usages_by_symbol = Object.assign(Object.create(null), parsed.data.usages_by_symbol);
|
|
196
197
|
return parsed.data;
|
|
197
198
|
}
|
|
198
199
|
export function writeResolutionIndex(index, cwd, preferredDirName) {
|
|
@@ -110,6 +110,30 @@ export const EdgeSchema = z.object({
|
|
|
110
110
|
})
|
|
111
111
|
.nullable()
|
|
112
112
|
.optional(),
|
|
113
|
+
/**
|
|
114
|
+
* Optional provenance for P4 usage edges. It lets the project resolver replace
|
|
115
|
+
* only import-derived usages on a later refresh without deleting lexical calls
|
|
116
|
+
* already proven inside the source file.
|
|
117
|
+
*/
|
|
118
|
+
origin: z.enum(['usage_local', 'usage_import', 'usage_textual']).optional(),
|
|
119
|
+
});
|
|
120
|
+
/** P4's three deliberately non-interchangeable usage classifications. */
|
|
121
|
+
export const UsageKindSchema = z.enum(['calls', 'references', 'possible_textual_match']);
|
|
122
|
+
/**
|
|
123
|
+
* An import-binding usage that is lexical in one file but needs the project-wide
|
|
124
|
+
* import pass before its target symbol is known. This is persisted on the shard,
|
|
125
|
+
* never surfaced as a `calls`/`references` edge until that target is unique.
|
|
126
|
+
*/
|
|
127
|
+
export const ReferenceCandidateSchema = z.object({
|
|
128
|
+
from: z.string(),
|
|
129
|
+
kind: z.enum(['calls', 'references']),
|
|
130
|
+
module: z.string(),
|
|
131
|
+
imported_name: z.string(),
|
|
132
|
+
confidence: z.number().default(1.0),
|
|
133
|
+
source: z.object({
|
|
134
|
+
path: z.string(),
|
|
135
|
+
line: z.number().int().nullable().optional(),
|
|
136
|
+
}),
|
|
113
137
|
});
|
|
114
138
|
// --- Per-file shard (spec §5.3) ---
|
|
115
139
|
export const ShardFreshnessSchema = z.object({
|
|
@@ -136,6 +160,8 @@ export const FileShardSchema = z.object({
|
|
|
136
160
|
freshness: ShardFreshnessSchema,
|
|
137
161
|
nodes: z.array(NodeSchema).default([]),
|
|
138
162
|
edges: z.array(EdgeSchema).default([]),
|
|
163
|
+
/** Deferred P4 imported-binding usages; resolved by the whole-project pass. */
|
|
164
|
+
reference_candidates: z.array(ReferenceCandidateSchema).optional(),
|
|
139
165
|
diagnostics: z.array(z.unknown()).default([]),
|
|
140
166
|
});
|
|
141
167
|
// --- manifest.json (spec §5.1) ---
|
|
@@ -282,6 +308,19 @@ export const DependencyIndexEntrySchema = z.object({
|
|
|
282
308
|
* read-path scan of every shard. Forward deps are read straight from a target's own
|
|
283
309
|
* shard, so they need no index.
|
|
284
310
|
*/
|
|
311
|
+
/** A persisted, high-confidence P4 usage cause targeting one symbol. */
|
|
312
|
+
export const UsageReasonSchema = z.object({
|
|
313
|
+
kind: z.enum(['calls', 'references']),
|
|
314
|
+
caller_node_id: z.string(),
|
|
315
|
+
confidence: z.number(),
|
|
316
|
+
source_line: z.number().int().nullable().optional(),
|
|
317
|
+
});
|
|
318
|
+
/** One importing file's lexical uses of a target symbol. */
|
|
319
|
+
export const UsageIndexEntrySchema = z.object({
|
|
320
|
+
path: z.string(),
|
|
321
|
+
file_id: Sha256Hash,
|
|
322
|
+
reasons: z.array(UsageReasonSchema).default([]),
|
|
323
|
+
});
|
|
285
324
|
export const ResolutionIndexSchema = z.object({
|
|
286
325
|
schema_version: z.number().int().default(CODE_MAP_SCHEMA_VERSION),
|
|
287
326
|
project_id: z.string(),
|
|
@@ -290,6 +329,8 @@ export const ResolutionIndexSchema = z.object({
|
|
|
290
329
|
dependents_by_file: z.record(z.string(), z.array(DependencyIndexEntrySchema)).default({}),
|
|
291
330
|
/** Keys are TARGET symbol node ids (reverse `imports_symbol`). */
|
|
292
331
|
dependents_by_symbol: z.record(z.string(), z.array(DependencyIndexEntrySchema)).default({}),
|
|
332
|
+
/** Keys are TARGET symbol ids; only proven P4 call/reference usages are indexed. */
|
|
333
|
+
usages_by_symbol: z.record(z.string(), z.array(UsageIndexEntrySchema)).default({}),
|
|
293
334
|
});
|
|
294
335
|
// --- .lock (spec §5.8) ---
|
|
295
336
|
export const CodeLockSchema = z.object({
|
|
@@ -307,20 +348,25 @@ export const CodeLockSchema = z.object({
|
|
|
307
348
|
stale_after_ms: z.number().int(),
|
|
308
349
|
});
|
|
309
350
|
/**
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* status, but an agent wants one consistent top-line signal to decide "trust this
|
|
313
|
-
* or refresh first" without memorizing which `stale_*` variant applies. `coarse`
|
|
314
|
-
* collapses the detail: every `stale_*` → `stale`, `missing_index` → `missing`,
|
|
315
|
-
* `partial`/`fresh` unchanged. Derived (never independently authored) via
|
|
316
|
-
* `coarseFreshness()` so it can never contradict `status`.
|
|
351
|
+
* The one agent-facing freshness signal. Detailed index causes and per-call
|
|
352
|
+
* spot-check observations intentionally live under `FreshnessBadge.details`.
|
|
317
353
|
*/
|
|
318
354
|
export const CoarseFreshnessSchema = z.enum(['fresh', 'stale', 'partial', 'missing']);
|
|
319
355
|
/** Freshness badge attached to every agent-facing read response (spec §9). */
|
|
320
356
|
export const FreshnessBadgeSchema = z.object({
|
|
357
|
+
/**
|
|
358
|
+
* Stable top-line signal shared by work/status/find/brief. It MUST NOT be
|
|
359
|
+
* changed by a query's bounded spot-check: that observation belongs in
|
|
360
|
+
* `details.spot_check`, so an agent never sees incompatible badges for the
|
|
361
|
+
* same index state.
|
|
362
|
+
*/
|
|
363
|
+
freshness: CoarseFreshnessSchema,
|
|
364
|
+
/**
|
|
365
|
+
* Detailed index classification retained for API compatibility. It describes
|
|
366
|
+
* the index only (never a query spot-check); new consumers branch on
|
|
367
|
+
* `freshness` and inspect `details` for the reason.
|
|
368
|
+
*/
|
|
321
369
|
status: FreshnessStatusSchema,
|
|
322
|
-
/** pln#601 — coarse rollup of `status`, uniform across all read surfaces. */
|
|
323
|
-
coarse: CoarseFreshnessSchema.optional(),
|
|
324
370
|
details: z.record(z.string(), z.unknown()).default({}),
|
|
325
371
|
});
|
|
326
372
|
//# sourceMappingURL=types.js.map
|
|
@@ -47,6 +47,12 @@ export const UniversalEdgeKinds = [
|
|
|
47
47
|
'resolves_to',
|
|
48
48
|
'imports_symbol',
|
|
49
49
|
'tests_for',
|
|
50
|
+
// P4 usages: a direct lexical invocation, a non-call lexical binding use, or
|
|
51
|
+
// an explicitly low-confidence text/property hint. Only `calls` is a proven
|
|
52
|
+
// invocation; consumers must never promote `possible_textual_match`.
|
|
53
|
+
'calls',
|
|
54
|
+
'references',
|
|
55
|
+
'possible_textual_match',
|
|
50
56
|
'extends',
|
|
51
57
|
'implements',
|
|
52
58
|
'annotates',
|