@ngockhoale/ukit 2.6.8 → 2.6.9
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/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/scripts/index/build-index.mjs +2 -1
- package/scripts/index/query-index.mjs +2 -0
- package/src/cli/commands/indexTools.js +5 -0
- package/src/cli/commands/install.js +2 -1
- package/src/core/codeintel/analogy.js +197 -0
- package/src/core/codeintel/cochange.js +205 -0
- package/src/core/codeintel/compiler.js +82 -9
- package/src/core/codeintel/graph.js +291 -0
- package/src/core/codeintel/impact.js +23 -0
- package/src/core/codeintel/packet.js +1 -0
- package/src/core/codeintel/retriever.js +49 -3
- package/src/core/codeintel/summaries.js +194 -0
- package/src/core/codeintel/vectorProvider.js +213 -0
- package/src/core/runtimeConfig.js +50 -2
- package/src/index/buildIndex.js +29 -0
- package/src/index/paths.js +2 -0
- package/templates/ukit/storage/config.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to UKit are documented here.
|
|
4
4
|
|
|
5
|
+
## 2.6.9 - 2026-09-19
|
|
6
|
+
|
|
7
|
+
Code Intelligence scale release — cycle C30 (CI-301..305, TASK-218..222).
|
|
8
|
+
|
|
9
|
+
- Vector lane: dep-free `vectorProvider.js` (FNV-1a feature-hashed embeddings,
|
|
10
|
+
L2-normalized, cosine-ranked; injectable loader seam for real embeddings,
|
|
11
|
+
degrade to hashed/null); wired as RRF lane w=0.6 between bm25 and semantic.
|
|
12
|
+
- Analogy lane: `analogy.js` fingerprints memory `procedure` records + index
|
|
13
|
+
symbols/files; `analogy` compile mode ranks by Jaccard, hits land in
|
|
14
|
+
packet.memory + L2 evidence.
|
|
15
|
+
- Co-change mining: `cochange.js` mines `git log --name-only` (spawnSync,
|
|
16
|
+
bounded, core.quotePath=false) into `cochange.json`; impact sets gain a
|
|
17
|
+
provenance-preserving co-change lane (kind:'cochange', confidence=count/10).
|
|
18
|
+
- L3 summaries: `summaries.js` extractive docblock/symbol summaries for
|
|
19
|
+
deep_flow+analogy packets; `packetToText` renders `summary:` lines.
|
|
20
|
+
- Graph store: `graph.js` `codegraph.json` JsonGraphStore (imports+calls+
|
|
21
|
+
cochange → nodes/edges, content-gated atomic write), contract-only
|
|
22
|
+
scip/pdg/lsp stubs via `createGraphProvider`.
|
|
23
|
+
- Derived artifacts wired into `ukit index build|refresh`, stale-refresh,
|
|
24
|
+
install, and index scripts via `buildDerivedIndexArtifacts` (config-gated,
|
|
25
|
+
never fatal, outside the core build's atomic-rename contract).
|
|
26
|
+
- Config: `codeIntel.{embedding,analogy,cochange,summaries,graph}` +
|
|
27
|
+
`retriever.weights.vector` validated in runtimeConfig with template parity;
|
|
28
|
+
`enabled:false` honored for analogy/graph lanes.
|
|
29
|
+
- Verification: full suite 2720/2720 green; unpacked ceiling 6.9MB.
|
|
30
|
+
|
|
31
|
+
## 2.6.8 - 2026-09-19
|
|
32
|
+
|
|
33
|
+
Code Intelligence Plane + memory v2 release — cycles C26–C29
|
|
34
|
+
(TASK-001..011, CI-201..206, DOC-201..206, DOC-301..305).
|
|
35
|
+
|
|
36
|
+
- Memory v2: 4-type record model (`project_rule|derived_fact|episode|procedure`),
|
|
37
|
+
atomic `records.json` store, backup-first idempotent migration from v1,
|
|
38
|
+
`ukit memory v2` ops; legacy `.ukit/storage/memory/` layout and all
|
|
39
|
+
`ukit memory *` subcommands stay compatible.
|
|
40
|
+
- Code Intelligence Plane: `compileContext` → Context Packet v1 with
|
|
41
|
+
peek→expand→read levels and token budgets; Task Router v1; freshness
|
|
42
|
+
identity (`HEAD + overlay + config`) with incremental dirty-gated reuse
|
|
43
|
+
and per-branch snapshots; hybrid retriever (exact + symbol + BM25 + RRF
|
|
44
|
+
rerank); `impact.js` multi-hop impact sets; post-edit `invalidation.js`
|
|
45
|
+
dirty-set and `diagnostics.js` feeding packet `next_actions`;
|
|
46
|
+
`TypeScriptSemanticProvider` (optional `typescript` import — no new deps,
|
|
47
|
+
degrade-safe).
|
|
48
|
+
- CLI: `ukit code peek|search|context|impact` (`--depth`, `--diagnostics`),
|
|
49
|
+
`ukit index status|doctor`, `ukit doctor --docs` (doc-contract checks).
|
|
50
|
+
- Docs governance: `docContracts.js` validator (links/stale-version/
|
|
51
|
+
render-vars/commands/budgets/registry/templates/dead-docs/archive-suggestions),
|
|
52
|
+
`context_layers` + `docs=` route segment, host capability matrix, BUG docs
|
|
53
|
+
consolidated into `docs/BUG_INDEX.md`, PROMPT_CACHING canonical/vendor split,
|
|
54
|
+
`templates/instructions/core.md` 210→157 lines.
|
|
55
|
+
- Benchmarks: `scripts/bench/runGold.mjs` scorecard + `--json` export.
|
|
56
|
+
|
|
5
57
|
## 2.6.7 - 2026-09-19
|
|
6
58
|
|
|
7
59
|
Canonical-instructions release — second roadmap slice (cycle C25,
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { buildCodeIndex } from '../../src/index/buildIndex.js';
|
|
4
|
+
import { buildCodeIndex, buildDerivedIndexArtifacts } from '../../src/index/buildIndex.js';
|
|
5
5
|
|
|
6
6
|
const args = process.argv.slice(2);
|
|
7
7
|
const rootArg = readFlagValue(args, '--root');
|
|
8
8
|
const rootDir = rootArg ? path.resolve(rootArg) : process.cwd();
|
|
9
9
|
|
|
10
10
|
const summary = await buildCodeIndex({ rootDir });
|
|
11
|
+
await buildDerivedIndexArtifacts(rootDir);
|
|
11
12
|
|
|
12
13
|
console.log('[index:build] completed');
|
|
13
14
|
console.log(`root: ${rootDir}`);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../../src/index/queryIndex.js';
|
|
7
7
|
import {
|
|
8
8
|
buildCodeIndex,
|
|
9
|
+
buildDerivedIndexArtifacts,
|
|
9
10
|
isIndexStale,
|
|
10
11
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
11
12
|
} from '../../src/index/buildIndex.js';
|
|
@@ -28,6 +29,7 @@ if (!query) {
|
|
|
28
29
|
const stale = await isIndexStale({ rootDir, maxAgeMs: DEFAULT_INDEX_CACHE_MAX_AGE_MS });
|
|
29
30
|
if (stale) {
|
|
30
31
|
await buildCodeIndex({ rootDir });
|
|
32
|
+
await buildDerivedIndexArtifacts(rootDir);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const limit = Number.parseInt(flags.get('--limit') ?? '5', 10);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildCodeIndex,
|
|
3
|
+
buildDerivedIndexArtifacts,
|
|
3
4
|
isIndexStale,
|
|
4
5
|
DEFAULT_INDEX_CACHE_MAX_AGE_MS,
|
|
5
6
|
getIndexArtifactGeneratedAt,
|
|
@@ -42,6 +43,9 @@ export async function runIndexTools({ projectRoot, argv = [] }) {
|
|
|
42
43
|
|
|
43
44
|
if (subcommand === 'build' || subcommand === 'refresh') {
|
|
44
45
|
const summary = await buildCodeIndex({ rootDir: projectRoot });
|
|
46
|
+
// Derived artifacts (CI-303/CI-305): cochange.json + codegraph.json.
|
|
47
|
+
// Best-effort, config-gated, never fatal.
|
|
48
|
+
await buildDerivedIndexArtifacts(projectRoot);
|
|
45
49
|
const label = subcommand === 'build' ? 'build' : 'refresh';
|
|
46
50
|
console.log(`[UKit] Index ${label} completed.`);
|
|
47
51
|
console.log(`[UKit] files=${summary.fileCount}, symbols=${summary.symbolCount}, imports=${summary.importCount}`);
|
|
@@ -389,6 +393,7 @@ async function refreshIndexIfStale(rootDir) {
|
|
|
389
393
|
}
|
|
390
394
|
|
|
391
395
|
await buildCodeIndex({ rootDir });
|
|
396
|
+
await buildDerivedIndexArtifacts(rootDir);
|
|
392
397
|
|
|
393
398
|
if (lastRefreshMs !== null) {
|
|
394
399
|
console.log(`[UKit] stale index refreshed (last=${new Date(lastRefreshMs).toISOString()})`);
|
|
@@ -2,7 +2,7 @@ import { buildPathConfig } from '../../core/paths.js';
|
|
|
2
2
|
import { runInstallPipeline } from '../../core/runInstallPipeline.js';
|
|
3
3
|
import { formatRepairReport } from '../../core/repairBrokenHooks.js';
|
|
4
4
|
import { formatGatewayResilienceReport } from '../../core/gatewayResilienceEnv.js';
|
|
5
|
-
import { buildCodeIndex } from '../../index/buildIndex.js';
|
|
5
|
+
import { buildCodeIndex, buildDerivedIndexArtifacts } from '../../index/buildIndex.js';
|
|
6
6
|
import { installIndexRefreshHooks } from '../../index/gitHooks.js';
|
|
7
7
|
import fs from 'node:fs/promises';
|
|
8
8
|
import { pathExists, readJsonIfExists, removeFileOrLinkOnly } from '../../core/fileOps.js';
|
|
@@ -323,6 +323,7 @@ export async function runInstall({ packageRoot, projectRoot, packageVersion, arg
|
|
|
323
323
|
let indexSummary;
|
|
324
324
|
try {
|
|
325
325
|
indexSummary = await buildCodeIndex({ rootDir: projectRoot });
|
|
326
|
+
await buildDerivedIndexArtifacts(projectRoot);
|
|
326
327
|
} catch (error) {
|
|
327
328
|
console.log('[UKit] Install files synced, but source index build failed.');
|
|
328
329
|
console.log('[UKit] Fix the repo state, then rerun `ukit install` so the AI can index source code first.');
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/paths.js';
|
|
5
|
+
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
6
|
+
|
|
7
|
+
// Analogy lane (SPEC §4, CI-302): ranks memory `procedure` records and
|
|
8
|
+
// index-bound file/symbol fingerprints against the query by Jaccard overlap.
|
|
9
|
+
// Read-only over both stores; every failure mode degrades into `omitted`
|
|
10
|
+
// reasons — never throws, never fabricates analogies.
|
|
11
|
+
//
|
|
12
|
+
// fingerprint = sorted-unique normalized features:
|
|
13
|
+
// lowercase [a-z0-9_$.-] tokens (length >= 3, minus stopwords) plus
|
|
14
|
+
// camelCase/punctuation sub-splits so `routeTaskFiles` shares features with
|
|
15
|
+
// "route task files" and `src/routing.js` shares `routing`.
|
|
16
|
+
|
|
17
|
+
const STOPWORDS = new Set([
|
|
18
|
+
'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
|
|
19
|
+
'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
|
|
20
|
+
'cần', 'và', 'là', 'cho', 'một', 'những', 'dùng',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const MAX_MATCHED_FEATURES = 8;
|
|
24
|
+
const DEFAULT_LIMIT = 5;
|
|
25
|
+
|
|
26
|
+
function pushToken(set, raw) {
|
|
27
|
+
const token = String(raw ?? '').toLowerCase();
|
|
28
|
+
if (token.length >= 3 && !STOPWORDS.has(token)) set.add(token);
|
|
29
|
+
// Sub-split on punctuation + camelCase boundaries so compound tokens share
|
|
30
|
+
// features with their parts (e.g. `routeTaskFiles` → route/task/files).
|
|
31
|
+
const split = String(raw ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
|
32
|
+
for (const part of split.split(/[^A-Za-z0-9_$]+/)) {
|
|
33
|
+
const sub = part.toLowerCase();
|
|
34
|
+
if (sub.length >= 3 && !STOPWORDS.has(sub)) set.add(sub);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* fingerprintText(text) → string[] sorted-unique normalized features.
|
|
40
|
+
*/
|
|
41
|
+
export function fingerprintText(text) {
|
|
42
|
+
const features = new Set();
|
|
43
|
+
for (const token of String(text ?? '').split(/[^A-Za-z0-9_$.-]+/)) {
|
|
44
|
+
pushToken(features, token);
|
|
45
|
+
}
|
|
46
|
+
return [...features].sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* fingerprintRecord(record) → string[] | null.
|
|
51
|
+
* Only `procedure` records carry an analogy fingerprint (SPEC §4).
|
|
52
|
+
*/
|
|
53
|
+
export function fingerprintRecord(record) {
|
|
54
|
+
if (!record || typeof record !== 'object') return null;
|
|
55
|
+
if (record.type !== 'procedure') return null;
|
|
56
|
+
const parts = [record.text];
|
|
57
|
+
if (record.meta && typeof record.meta === 'object') {
|
|
58
|
+
parts.push(JSON.stringify(record.meta));
|
|
59
|
+
}
|
|
60
|
+
if (record.provenance) parts.push(record.provenance);
|
|
61
|
+
return fingerprintText(parts.filter(Boolean).join(' '));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* fingerprintFile({ filePath, symbols }) → string[] — path-shape tokens
|
|
66
|
+
* (incl. dir basenames) + symbol names.
|
|
67
|
+
*/
|
|
68
|
+
export function fingerprintFile({ filePath, symbols } = {}) {
|
|
69
|
+
const features = new Set(fingerprintText(filePath));
|
|
70
|
+
for (const sym of Array.isArray(symbols) ? symbols : []) {
|
|
71
|
+
for (const feature of fingerprintText(sym?.name)) features.add(feature);
|
|
72
|
+
}
|
|
73
|
+
return [...features].sort();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readArtifact(rootDir, name) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
|
|
79
|
+
const parsed = JSON.parse(raw);
|
|
80
|
+
if (parsed?.schemaVersion !== undefined && parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Memory lane — lazy-imported inside try/catch so a missing/disabled memory
|
|
90
|
+
// v2 store degrades instead of throwing.
|
|
91
|
+
async function loadProcedureCandidates(projectRoot, config) {
|
|
92
|
+
if (config?.memoryV2?.enabled === false) {
|
|
93
|
+
return { records: null, why: 'memory-unavailable' };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const loader = await import('../memory/storeV2Loader.js');
|
|
97
|
+
const records = await import('../memory/records.js');
|
|
98
|
+
if (typeof loader?.loadV2Records !== 'function' || typeof records?.isRecordUsable !== 'function') {
|
|
99
|
+
return { records: null, why: 'memory-unavailable' };
|
|
100
|
+
}
|
|
101
|
+
const loaded = await loader.loadV2Records(projectRoot, { type: 'procedure' });
|
|
102
|
+
if (!Array.isArray(loaded) || loaded.length === 0) {
|
|
103
|
+
return { records: [], why: 'memory-unavailable' };
|
|
104
|
+
}
|
|
105
|
+
return { records: loaded.filter((r) => records.isRecordUsable(r)), why: null };
|
|
106
|
+
} catch {
|
|
107
|
+
return { records: null, why: 'memory-unavailable' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function jaccard(querySet, candidateFeatures) {
|
|
112
|
+
let shared = 0;
|
|
113
|
+
const matched = [];
|
|
114
|
+
for (const feature of candidateFeatures) {
|
|
115
|
+
if (querySet.has(feature)) {
|
|
116
|
+
shared += 1;
|
|
117
|
+
if (matched.length < MAX_MATCHED_FEATURES) matched.push(feature);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (shared === 0) return null;
|
|
121
|
+
const union = querySet.size + candidateFeatures.length - shared;
|
|
122
|
+
return { score: shared / union, matched };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* findAnalogies(projectRoot, query, { limit = 5 }) →
|
|
127
|
+
* { analogies: [{ kind:'procedure'|'code', ref, path?, score, matched:string[] }],
|
|
128
|
+
* omitted: [{what,why}] }
|
|
129
|
+
*
|
|
130
|
+
* Jaccard over fingerprint sets; `score>0` rows only. Ties → ref localeCompare.
|
|
131
|
+
*/
|
|
132
|
+
export async function findAnalogies(projectRoot, query, { limit = DEFAULT_LIMIT } = {}) {
|
|
133
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
134
|
+
const effectiveLimit = Math.max(1, typeof limit === 'number' && Number.isFinite(limit) ? limit : DEFAULT_LIMIT);
|
|
135
|
+
const omitted = [];
|
|
136
|
+
const analogies = [];
|
|
137
|
+
|
|
138
|
+
const queryFeatures = new Set(fingerprintText(query));
|
|
139
|
+
if (queryFeatures.size === 0) {
|
|
140
|
+
omitted.push({ what: 'analogy-lane', why: 'empty-query-features' });
|
|
141
|
+
return { analogies, omitted };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let config = null;
|
|
145
|
+
try {
|
|
146
|
+
config = await loadRuntimeConfig(rootDir);
|
|
147
|
+
} catch {
|
|
148
|
+
config = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Lane 1 — memory `procedure` records.
|
|
152
|
+
const memory = await loadProcedureCandidates(rootDir, config);
|
|
153
|
+
if (memory.why) {
|
|
154
|
+
omitted.push({ what: 'analogy-lane', why: memory.why });
|
|
155
|
+
}
|
|
156
|
+
for (const record of memory.records ?? []) {
|
|
157
|
+
const features = fingerprintRecord(record);
|
|
158
|
+
if (!features || features.length === 0) continue;
|
|
159
|
+
const hit = jaccard(queryFeatures, features);
|
|
160
|
+
if (!hit) continue;
|
|
161
|
+
analogies.push({ kind: 'procedure', ref: record.id, ...hit });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Lane 2 — index files/symbols (schema-guarded artifact reads).
|
|
165
|
+
const [filesArtifact, symbolsArtifact] = await Promise.all([
|
|
166
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.files),
|
|
167
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.symbols),
|
|
168
|
+
]);
|
|
169
|
+
if (!filesArtifact && !symbolsArtifact) {
|
|
170
|
+
omitted.push({ what: 'analogy-lane', why: 'index-missing' });
|
|
171
|
+
}
|
|
172
|
+
const symbolsByFile = new Map();
|
|
173
|
+
for (const sym of symbolsArtifact?.items ?? []) {
|
|
174
|
+
if (!sym?.filePath) continue;
|
|
175
|
+
const list = symbolsByFile.get(sym.filePath) ?? [];
|
|
176
|
+
list.push(sym);
|
|
177
|
+
symbolsByFile.set(sym.filePath, list);
|
|
178
|
+
}
|
|
179
|
+
const codePaths = new Set();
|
|
180
|
+
for (const item of filesArtifact?.items ?? []) {
|
|
181
|
+
if (item?.filePath) codePaths.add(item.filePath);
|
|
182
|
+
}
|
|
183
|
+
for (const filePath of symbolsByFile.keys()) codePaths.add(filePath);
|
|
184
|
+
for (const filePath of codePaths) {
|
|
185
|
+
const features = fingerprintFile({ filePath, symbols: symbolsByFile.get(filePath) ?? [] });
|
|
186
|
+
if (features.length === 0) continue;
|
|
187
|
+
const hit = jaccard(queryFeatures, features);
|
|
188
|
+
if (!hit) continue;
|
|
189
|
+
analogies.push({ kind: 'code', ref: filePath, path: filePath, ...hit });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
analogies.sort((a, b) => b.score - a.score || a.ref.localeCompare(b.ref));
|
|
193
|
+
if (analogies.length === 0 && !memory.why && filesArtifact && symbolsArtifact) {
|
|
194
|
+
omitted.push({ what: 'analogy-lane', why: 'no-match' });
|
|
195
|
+
}
|
|
196
|
+
return { analogies: analogies.slice(0, effectiveLimit), omitted };
|
|
197
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
import { getArtifactPath, INDEX_ARTIFACTS } from '../../index/paths.js';
|
|
6
|
+
import { writeJson } from '../fileOps.js';
|
|
7
|
+
|
|
8
|
+
// Co-change mining (SPEC §6 — CI-303). Parses bounded `git log --name-only`
|
|
9
|
+
// history into co-commit pair counts persisted as `.cache/index/cochange.json`.
|
|
10
|
+
// The artifact feeds `impactSet` context edges (kind:'cochange') only — it does
|
|
11
|
+
// NOT create edges in the structural import/call graph. Never throws: non-git
|
|
12
|
+
// dirs and git failures yield null.
|
|
13
|
+
|
|
14
|
+
export const COCHANGE_SCHEMA_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
const MAX_FILES_PER_COMMIT = 50;
|
|
17
|
+
const GIT_MAX_BUFFER = 16 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
// Same discipline as freshness.js runGit: spawnSync (no shell), bounded
|
|
20
|
+
// timeout/maxBuffer, null on any failure — never throws.
|
|
21
|
+
function runGit(rootDir, args, timeoutMs) {
|
|
22
|
+
try {
|
|
23
|
+
const result = spawnSync('git', args, {
|
|
24
|
+
cwd: rootDir,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
timeout: timeoutMs,
|
|
27
|
+
maxBuffer: GIT_MAX_BUFFER,
|
|
28
|
+
});
|
|
29
|
+
if (result.error || result.status !== 0) return null;
|
|
30
|
+
return result.stdout ?? '';
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getHeadSha(rootDir, timeoutMs) {
|
|
37
|
+
const out = runGit(rootDir, ['rev-parse', 'HEAD'], timeoutMs);
|
|
38
|
+
const sha = (out || '').trim();
|
|
39
|
+
return /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parses `git log --name-only --format=sha:%H` output into
|
|
43
|
+
// [{ sha, files: string[] }] commits. File lines are the non-sha, non-blank
|
|
44
|
+
// lines following a `sha:` header.
|
|
45
|
+
function parseLog(output) {
|
|
46
|
+
const commits = [];
|
|
47
|
+
let current = null;
|
|
48
|
+
for (const line of output.split('\n')) {
|
|
49
|
+
if (line.startsWith('sha:')) {
|
|
50
|
+
const sha = line.slice(4).trim();
|
|
51
|
+
if (/^[0-9a-f]{40}$/i.test(sha)) {
|
|
52
|
+
current = { sha: sha.toLowerCase(), files: [] };
|
|
53
|
+
commits.push(current);
|
|
54
|
+
} else {
|
|
55
|
+
current = null;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!current) continue;
|
|
60
|
+
const rel = line.trim();
|
|
61
|
+
if (rel) current.files.push(rel);
|
|
62
|
+
}
|
|
63
|
+
return commits;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pairKey(a, b) {
|
|
67
|
+
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* mineCoChanges(projectRoot, { maxCommits?, timeoutMs? })
|
|
72
|
+
* → artifact { schemaVersion, generatedAt, headSha,
|
|
73
|
+
* pairs: [{a,b,count,lastSha}] sorted a|b asc,
|
|
74
|
+
* fileCounts: {file: n}, truncated: bool }
|
|
75
|
+
* → null when git is unavailable/fails.
|
|
76
|
+
* Writes getArtifactPath(root, 'cochange') when non-null.
|
|
77
|
+
*/
|
|
78
|
+
export async function mineCoChanges(projectRoot, { maxCommits = 500, timeoutMs = 5000 } = {}) {
|
|
79
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
80
|
+
const cap = Number.isFinite(Number(maxCommits)) && Number(maxCommits) > 0
|
|
81
|
+
? Math.floor(Number(maxCommits))
|
|
82
|
+
: 500;
|
|
83
|
+
const timeout = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
|
|
84
|
+
? Math.floor(Number(timeoutMs))
|
|
85
|
+
: 5000;
|
|
86
|
+
|
|
87
|
+
const headSha = getHeadSha(rootDir, timeout);
|
|
88
|
+
if (!headSha) return null;
|
|
89
|
+
|
|
90
|
+
// Fetch cap+1 so we can detect truncation when history runs deeper.
|
|
91
|
+
const log = runGit(
|
|
92
|
+
rootDir,
|
|
93
|
+
['-c', 'core.quotePath=false', 'log', '--name-only', '--format=sha:%H', '-n', String(cap + 1)],
|
|
94
|
+
timeout,
|
|
95
|
+
);
|
|
96
|
+
if (log === null) return null;
|
|
97
|
+
|
|
98
|
+
let commits = parseLog(log);
|
|
99
|
+
const truncated = commits.length > cap;
|
|
100
|
+
if (truncated) commits = commits.slice(0, cap);
|
|
101
|
+
|
|
102
|
+
const fileCounts = {};
|
|
103
|
+
const pairs = new Map(); // 'a|b' → { a, b, count, lastSha }
|
|
104
|
+
for (const commit of commits) {
|
|
105
|
+
const seen = new Set(commit.files);
|
|
106
|
+
for (const rel of seen) {
|
|
107
|
+
fileCounts[rel] = (fileCounts[rel] ?? 0) + 1;
|
|
108
|
+
}
|
|
109
|
+
// Giant commits (merge/squash noise) still count for fileCounts but are
|
|
110
|
+
// skipped for pair accumulation.
|
|
111
|
+
if (seen.size > MAX_FILES_PER_COMMIT) continue;
|
|
112
|
+
const files = [...seen];
|
|
113
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
114
|
+
for (let j = i + 1; j < files.length; j += 1) {
|
|
115
|
+
const a = files[i] < files[j] ? files[i] : files[j];
|
|
116
|
+
const b = files[i] < files[j] ? files[j] : files[i];
|
|
117
|
+
const key = pairKey(a, b);
|
|
118
|
+
const existing = pairs.get(key);
|
|
119
|
+
if (existing) {
|
|
120
|
+
existing.count += 1;
|
|
121
|
+
// git log is newest-first: keep the first-seen sha (latest commit).
|
|
122
|
+
} else {
|
|
123
|
+
pairs.set(key, { a, b, count: 1, lastSha: commit.sha });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const artifact = {
|
|
130
|
+
schemaVersion: COCHANGE_SCHEMA_VERSION,
|
|
131
|
+
generatedAt: new Date().toISOString(),
|
|
132
|
+
headSha,
|
|
133
|
+
pairs: [...pairs.values()].sort((x, y) => pairKey(x.a, x.b).localeCompare(pairKey(y.a, y.b))),
|
|
134
|
+
fileCounts,
|
|
135
|
+
truncated,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await writeJson(getArtifactPath(rootDir, INDEX_ARTIFACTS.cochange), artifact);
|
|
140
|
+
} catch {
|
|
141
|
+
// Artifact write failure is non-fatal: caller still gets the data.
|
|
142
|
+
}
|
|
143
|
+
return artifact;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* readCoChanges(projectRoot) → artifact | null
|
|
148
|
+
* Schema-guarded: schemaVersion !== COCHANGE_SCHEMA_VERSION → null.
|
|
149
|
+
*/
|
|
150
|
+
export async function readCoChanges(projectRoot) {
|
|
151
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
152
|
+
try {
|
|
153
|
+
const raw = await fs.readFile(getArtifactPath(rootDir, INDEX_ARTIFACTS.cochange), 'utf8');
|
|
154
|
+
const artifact = JSON.parse(raw);
|
|
155
|
+
if (!artifact || typeof artifact !== 'object') return null;
|
|
156
|
+
if (artifact.schemaVersion !== COCHANGE_SCHEMA_VERSION) return null;
|
|
157
|
+
if (!Array.isArray(artifact.pairs)) return null;
|
|
158
|
+
return artifact;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* coChangeEdges(projectRoot, seeds, { minCount? })
|
|
166
|
+
* → edge[] { from: seed, to: peer, kind: 'cochange',
|
|
167
|
+
* confidence: min(1, count/10), provider: 'git-cochange',
|
|
168
|
+
* evidence: 'cochange.json', snapshot: headSha }
|
|
169
|
+
* Returns [] when the artifact is missing/stale or no pairs qualify.
|
|
170
|
+
*/
|
|
171
|
+
export async function coChangeEdges(projectRoot, seeds, { minCount = 2 } = {}) {
|
|
172
|
+
const artifact = await readCoChanges(projectRoot);
|
|
173
|
+
if (!artifact) return [];
|
|
174
|
+
const threshold = Number.isFinite(Number(minCount)) ? Number(minCount) : 2;
|
|
175
|
+
const seedSet = new Set(
|
|
176
|
+
Array.isArray(seeds) ? seeds.filter((s) => typeof s === 'string' && s.length > 0) : [],
|
|
177
|
+
);
|
|
178
|
+
if (seedSet.size === 0) return [];
|
|
179
|
+
|
|
180
|
+
const edges = [];
|
|
181
|
+
for (const pair of artifact.pairs) {
|
|
182
|
+
if (!pair?.a || !pair?.b || typeof pair.count !== 'number') continue;
|
|
183
|
+
if (pair.count < threshold) continue;
|
|
184
|
+
let from = null;
|
|
185
|
+
let to = null;
|
|
186
|
+
if (seedSet.has(pair.a) && !seedSet.has(pair.b)) {
|
|
187
|
+
from = pair.a;
|
|
188
|
+
to = pair.b;
|
|
189
|
+
} else if (seedSet.has(pair.b) && !seedSet.has(pair.a)) {
|
|
190
|
+
from = pair.b;
|
|
191
|
+
to = pair.a;
|
|
192
|
+
}
|
|
193
|
+
if (!from) continue;
|
|
194
|
+
edges.push({
|
|
195
|
+
from,
|
|
196
|
+
to,
|
|
197
|
+
kind: 'cochange',
|
|
198
|
+
confidence: Math.min(1, pair.count / 10),
|
|
199
|
+
provider: 'git-cochange',
|
|
200
|
+
evidence: 'cochange.json',
|
|
201
|
+
snapshot: artifact.headSha,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return edges;
|
|
205
|
+
}
|
|
@@ -8,6 +8,8 @@ import { retrieve } from './retriever.js';
|
|
|
8
8
|
import { impactSet } from './impact.js';
|
|
9
9
|
import { readDirty } from './invalidation.js';
|
|
10
10
|
import { runDiagnostics } from './diagnostics.js';
|
|
11
|
+
import { findAnalogies } from './analogy.js';
|
|
12
|
+
import { summarizeFile } from './summaries.js';
|
|
11
13
|
import { createEdge } from './providers.js';
|
|
12
14
|
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
13
15
|
|
|
@@ -21,8 +23,10 @@ import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
|
21
23
|
// peek → L0 outline only (no excerpts, no relations)
|
|
22
24
|
// targeted | explore → L1 + anchors + relations
|
|
23
25
|
// impact → L2 + 1-hop reverse-import edges (retrieve mode impact)
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
+
// analogy → L3 + procedural-fingerprint lane (memory procedures
|
|
27
|
+
// + index symbols/files ranked by Jaccard) + summaries
|
|
28
|
+
// deep_flow → L3 + deterministic extractive summaries on top-N
|
|
29
|
+
// path evidence (summaries.js — no graph lane in v1)
|
|
26
30
|
// read → L2 excerpts; emitted when depth >= 2 or `read:true`.
|
|
27
31
|
//
|
|
28
32
|
// Budget units are token estimates (≈ 4 chars/token over serialized evidence).
|
|
@@ -40,11 +44,13 @@ const DEPTH_LEVEL = Object.freeze({
|
|
|
40
44
|
targeted: 'L1',
|
|
41
45
|
explore: 'L1',
|
|
42
46
|
impact: 'L2',
|
|
43
|
-
deep_flow: '
|
|
44
|
-
analogy: '
|
|
47
|
+
deep_flow: 'L3',
|
|
48
|
+
analogy: 'L3',
|
|
45
49
|
});
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
// Kept for the unavailable-lane contract — deep_flow became a real L3 lane in
|
|
52
|
+
// CI-304, so the set is empty but the omission mechanism remains for future lanes.
|
|
53
|
+
const UNAVAILABLE_LANES = new Set();
|
|
48
54
|
|
|
49
55
|
function estimateTokens(value) {
|
|
50
56
|
try {
|
|
@@ -218,10 +224,10 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
218
224
|
from: edge.from,
|
|
219
225
|
to: edge.to,
|
|
220
226
|
kind: edge.kind ?? 'import',
|
|
221
|
-
confidence: 1,
|
|
222
|
-
provider: 'index-file',
|
|
223
|
-
evidence: 'imports.json',
|
|
224
|
-
snapshot: typeof identity.identity === 'string' ? identity.identity : null,
|
|
227
|
+
confidence: typeof edge.confidence === 'number' ? edge.confidence : 1,
|
|
228
|
+
provider: edge.provider ?? 'index-file',
|
|
229
|
+
evidence: edge.evidence ?? 'imports.json',
|
|
230
|
+
snapshot: edge.snapshot ?? (typeof identity.identity === 'string' ? identity.identity : null),
|
|
225
231
|
})),
|
|
226
232
|
};
|
|
227
233
|
for (const entry of impact.omitted ?? []) packet.omitted.push(entry);
|
|
@@ -233,6 +239,50 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
233
239
|
}
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
// Analogy lane (SPEC §4/§5, CI-302): fingerprints memory `procedure`
|
|
243
|
+
// records + index symbols/files, ranks by Jaccard. Procedure hits land in
|
|
244
|
+
// `packet.memory` + L2 memory evidence; code hits become anchors + L2 index
|
|
245
|
+
// evidence. Read-only, degrades into `omitted` — never throws.
|
|
246
|
+
if (mode === 'analogy' && config?.codeIntel?.analogy?.enabled !== false) {
|
|
247
|
+
try {
|
|
248
|
+
const analogyLimit = typeof config?.codeIntel?.analogy?.limit === 'number'
|
|
249
|
+
? config.codeIntel.analogy.limit
|
|
250
|
+
: 5;
|
|
251
|
+
const lane = await findAnalogies(rootDir, query, { limit: analogyLimit });
|
|
252
|
+
const seenAnchorPaths = new Set((result.anchors ?? []).map((a) => a?.path));
|
|
253
|
+
for (const entry of lane.analogies ?? []) {
|
|
254
|
+
if (entry.kind === 'procedure') {
|
|
255
|
+
result.evidence.push({
|
|
256
|
+
level: 'L2',
|
|
257
|
+
source: 'memory',
|
|
258
|
+
why: `analogy: ${entry.ref} (score ${entry.score.toFixed(2)})`,
|
|
259
|
+
...(entry.path ? { path: entry.path } : {}),
|
|
260
|
+
});
|
|
261
|
+
packet.memory.push({ id: entry.ref, score: entry.score });
|
|
262
|
+
} else {
|
|
263
|
+
result.evidence.push({
|
|
264
|
+
level: 'L2',
|
|
265
|
+
source: 'index',
|
|
266
|
+
why: `analogy: ${entry.ref} (score ${entry.score.toFixed(2)})`,
|
|
267
|
+
path: entry.path,
|
|
268
|
+
});
|
|
269
|
+
if (entry.path && !seenAnchorPaths.has(entry.path)) {
|
|
270
|
+
seenAnchorPaths.add(entry.path);
|
|
271
|
+
result.anchors.push({ path: entry.path });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if ((lane.analogies ?? []).length === 0) {
|
|
276
|
+
packet.omitted.push({ what: 'analogy-lane', why: 'no-match' });
|
|
277
|
+
}
|
|
278
|
+
for (const entry of lane.omitted ?? []) {
|
|
279
|
+
packet.omitted.push(entry);
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
packet.omitted.push({ what: 'analogy-lane', why: 'analogy-error' });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
236
286
|
// L0 peek emits outline-level evidence only: same paths, no excerpts.
|
|
237
287
|
const candidates = (result.evidence ?? []).map((item) => {
|
|
238
288
|
const next = { ...item, level };
|
|
@@ -240,6 +290,29 @@ export async function compileContext(projectRoot, task, options = {}) {
|
|
|
240
290
|
return next;
|
|
241
291
|
});
|
|
242
292
|
|
|
293
|
+
// L3 summaries lane (SPEC §7, CI-304): attach a deterministic extractive
|
|
294
|
+
// summary to the top `codeIntel.summaries.maxItems` (default 5) evidence
|
|
295
|
+
// items that carry a path. Pre-budget so token cost is accounted for.
|
|
296
|
+
if (level === 'L3' && config?.codeIntel?.summaries?.enabled !== false) {
|
|
297
|
+
const maxItems = typeof config?.codeIntel?.summaries?.maxItems === 'number'
|
|
298
|
+
? config.codeIntel.summaries.maxItems
|
|
299
|
+
: 5;
|
|
300
|
+
let attached = 0;
|
|
301
|
+
for (const item of candidates) {
|
|
302
|
+
if (attached >= maxItems) break;
|
|
303
|
+
if (!item.path) continue;
|
|
304
|
+
try {
|
|
305
|
+
const result = await summarizeFile(rootDir, item.path);
|
|
306
|
+
if (result && result.summary) {
|
|
307
|
+
item.summary = result.summary;
|
|
308
|
+
attached += 1;
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// summary lane degrades silently — evidence stays valid without it
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
243
316
|
// L2 read lane: attach excerpts to the top candidates only.
|
|
244
317
|
if (wantRead && level !== 'L0') {
|
|
245
318
|
for (const item of candidates) {
|