@ngockhoale/ukit 2.6.7 → 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/manifests/documentation.yaml +77 -6
- package/manifests/hostCapabilities.yaml +49 -0
- package/manifests/instructionRules.yaml +7 -0
- package/package.json +1 -1
- package/scripts/bench/goldTasks.json +38 -0
- package/scripts/bench/runGold.mjs +220 -0
- package/scripts/index/build-index.mjs +2 -1
- package/scripts/index/query-index.mjs +2 -0
- package/scripts/release/verify-release.mjs +6 -0
- package/src/cli/commands/code.js +182 -0
- package/src/cli/commands/doctor.js +35 -3
- package/src/cli/commands/indexTools.js +107 -1
- package/src/cli/commands/install.js +2 -1
- package/src/cli/commands/memory.js +137 -0
- package/src/cli/index.js +7 -0
- package/src/core/codeintel/analogy.js +197 -0
- package/src/core/codeintel/cochange.js +205 -0
- package/src/core/codeintel/compiler.js +389 -0
- package/src/core/codeintel/diagnostics.js +114 -0
- package/src/core/codeintel/freshness.js +295 -0
- package/src/core/codeintel/graph.js +291 -0
- package/src/core/codeintel/impact.js +274 -0
- package/src/core/codeintel/invalidation.js +150 -0
- package/src/core/codeintel/manifest.js +176 -0
- package/src/core/codeintel/packet.js +147 -0
- package/src/core/codeintel/providers.js +201 -0
- package/src/core/codeintel/retriever.js +418 -0
- package/src/core/codeintel/router.js +149 -0
- package/src/core/codeintel/semanticProvider.js +235 -0
- package/src/core/codeintel/summaries.js +194 -0
- package/src/core/codeintel/vectorProvider.js +213 -0
- package/src/core/docContracts.js +723 -0
- package/src/core/memory/migrate.js +324 -0
- package/src/core/memory/records.js +172 -0
- package/src/core/memory/retrieval.js +161 -11
- package/src/core/memory/store.js +398 -0
- package/src/core/memory/storeV2.js +171 -0
- package/src/core/memory/storeV2Loader.js +22 -0
- package/src/core/runtimeConfig.js +173 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/index/buildIndex.js +29 -0
- package/src/index/paths.js +2 -0
- package/src/index/taskRouting.js +39 -0
- package/templates/.claude/ukit/index/route-task.mjs +40 -0
- package/templates/AGENTS.md +46 -99
- package/templates/CLAUDE.md +46 -99
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
- package/templates/docs/BUGFIX.md +2 -19
- package/templates/docs/BUG_INDEX.md +43 -0
- package/templates/docs/BUG_METRICS.md +1 -5
- package/templates/docs/BUG_TEMPLATE.md +1 -11
- package/templates/docs/UKIT_INTERNALS.md +4 -0
- package/templates/instructions/core.md +46 -99
- package/templates/ukit/storage/config.json +35 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS } from '../../index/paths.js';
|
|
5
|
+
import { createEdge, NullSemanticProvider } from './providers.js';
|
|
6
|
+
|
|
7
|
+
const NO_CAPS = Object.freeze({ defs: false, refs: false, types: false });
|
|
8
|
+
const ALL_CAPS = Object.freeze({ defs: true, refs: true, types: true });
|
|
9
|
+
const DEFAULT_LIMIT = 50;
|
|
10
|
+
const MAX_FILES = 200;
|
|
11
|
+
const SEMANTIC_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']);
|
|
12
|
+
|
|
13
|
+
function defaultLoader() {
|
|
14
|
+
return import('typescript');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isSemanticFile(filePath) {
|
|
18
|
+
return typeof filePath === 'string' && SEMANTIC_EXTS.has(path.extname(filePath));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* SemanticProvider backed by the TypeScript language service, loaded via an
|
|
23
|
+
* injectable async loader (default: dynamic `import('typescript')`). When the
|
|
24
|
+
* loader fails — package absent, throw, any reason — the provider degrades to
|
|
25
|
+
* `available()===false`, `resolve()→null`, all-false capabilities. Never throws.
|
|
26
|
+
*/
|
|
27
|
+
export class TypeScriptSemanticProvider {
|
|
28
|
+
constructor({ projectRoot = process.cwd(), loader = defaultLoader } = {}) {
|
|
29
|
+
this.name = 'typescript-semantic';
|
|
30
|
+
this.projectRoot = projectRoot;
|
|
31
|
+
this._loader = loader;
|
|
32
|
+
this._probe = null;
|
|
33
|
+
this._caps = NO_CAPS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#probeTs() {
|
|
37
|
+
if (!this._probe) {
|
|
38
|
+
this._probe = Promise.resolve()
|
|
39
|
+
.then(() => this._loader())
|
|
40
|
+
.then((ts) => {
|
|
41
|
+
this._caps = ts ? ALL_CAPS : NO_CAPS;
|
|
42
|
+
return ts ?? null;
|
|
43
|
+
})
|
|
44
|
+
.catch(() => {
|
|
45
|
+
this._caps = NO_CAPS;
|
|
46
|
+
return null;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return this._probe;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async available() {
|
|
53
|
+
return (await this.#probeTs()) !== null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
capabilities() {
|
|
57
|
+
return { ...this._caps };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* ctx = { file?, mode?: 'definition'|'references'|'both', limit?, snapshot? }
|
|
62
|
+
* → edge[] (createEdge shape) | null when unavailable or on any failure.
|
|
63
|
+
*/
|
|
64
|
+
async resolve(symbol, ctx = {}) {
|
|
65
|
+
try {
|
|
66
|
+
const ts = await this.#probeTs();
|
|
67
|
+
if (!ts || typeof symbol !== 'string' || symbol.length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const safeCtx = ctx && typeof ctx === 'object' ? ctx : {};
|
|
71
|
+
const mode = safeCtx.mode ?? 'both';
|
|
72
|
+
const limit = Number.isInteger(safeCtx.limit) && safeCtx.limit > 0 ? safeCtx.limit : DEFAULT_LIMIT;
|
|
73
|
+
const files = this.#boundedFiles(safeCtx);
|
|
74
|
+
if (files.length === 0) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return this.#resolveWith(ts, symbol, files, mode, limit, safeCtx.snapshot ?? null);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#boundedFiles(ctx) {
|
|
84
|
+
if (isSemanticFile(ctx.file)) {
|
|
85
|
+
return [path.resolve(this.projectRoot, ctx.file)];
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const filesPath = getArtifactPath(this.projectRoot, INDEX_ARTIFACTS.files);
|
|
89
|
+
const raw = JSON.parse(fs.readFileSync(filesPath, 'utf8'));
|
|
90
|
+
const list = Array.isArray(raw) ? raw : (raw.files ?? []);
|
|
91
|
+
return list
|
|
92
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.filePath ?? entry?.path))
|
|
93
|
+
.filter(isSemanticFile)
|
|
94
|
+
.slice(0, MAX_FILES)
|
|
95
|
+
.map((file) => path.resolve(this.projectRoot, file));
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#resolveWith(ts, symbol, files, mode, limit, snapshot) {
|
|
102
|
+
const sources = new Map();
|
|
103
|
+
const readSource = (fileName) => {
|
|
104
|
+
if (!sources.has(fileName)) {
|
|
105
|
+
try {
|
|
106
|
+
sources.set(fileName, fs.readFileSync(fileName, 'utf8'));
|
|
107
|
+
} catch {
|
|
108
|
+
sources.set(fileName, null);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return sources.get(fileName);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const host = {
|
|
115
|
+
getScriptFileNames: () => files,
|
|
116
|
+
getScriptVersion: () => '1',
|
|
117
|
+
getScriptSnapshot: (fileName) => {
|
|
118
|
+
const text = readSource(fileName);
|
|
119
|
+
if (text === null) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
return ts.ScriptSnapshot?.fromString ? ts.ScriptSnapshot.fromString(text) : text;
|
|
123
|
+
},
|
|
124
|
+
getCurrentDirectory: () => this.projectRoot,
|
|
125
|
+
getCompilationSettings: () => ({ allowJs: true, checkJs: false }),
|
|
126
|
+
getDefaultLibFileName: () => 'lib.d.ts',
|
|
127
|
+
readFile: (fileName) => readSource(fileName) ?? undefined,
|
|
128
|
+
fileExists: (fileName) => readSource(fileName) !== null,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
let service = null;
|
|
132
|
+
const edges = [];
|
|
133
|
+
const seen = new Set();
|
|
134
|
+
const pushEdge = (kind, fileName, start) => {
|
|
135
|
+
if (edges.length >= limit) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const source = readSource(fileName);
|
|
139
|
+
const line = source === null ? 0 : lineOf(source, start);
|
|
140
|
+
const key = `${kind}:${fileName}:${start}`;
|
|
141
|
+
if (seen.has(key)) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
seen.add(key);
|
|
145
|
+
edges.push(createEdge({
|
|
146
|
+
from: symbol,
|
|
147
|
+
to: `${fileName}:${line}`,
|
|
148
|
+
kind,
|
|
149
|
+
confidence: 1,
|
|
150
|
+
provider: this.name,
|
|
151
|
+
evidence: 'typescript-ls',
|
|
152
|
+
snapshot,
|
|
153
|
+
}));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
service = ts.createLanguageService(host);
|
|
158
|
+
const wantsDefs = mode === 'definition' || mode === 'both';
|
|
159
|
+
const wantsRefs = mode === 'references' || mode === 'both';
|
|
160
|
+
for (const fileName of files) {
|
|
161
|
+
if (edges.length >= limit) {
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
const source = readSource(fileName);
|
|
165
|
+
if (source === null) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const pos of occurrencesOf(source, symbol)) {
|
|
169
|
+
if (edges.length >= limit) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
if (wantsDefs && typeof service.getDefinitionAtPosition === 'function') {
|
|
173
|
+
for (const entry of service.getDefinitionAtPosition(fileName, pos) ?? []) {
|
|
174
|
+
if (entry?.fileName && entry?.textSpan) {
|
|
175
|
+
pushEdge('def', entry.fileName, entry.textSpan.start);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (wantsRefs && typeof service.findReferences === 'function') {
|
|
180
|
+
for (const group of service.findReferences(fileName, pos) ?? []) {
|
|
181
|
+
for (const entry of group?.references ?? []) {
|
|
182
|
+
if (entry?.fileName && entry?.textSpan) {
|
|
183
|
+
pushEdge('ref', entry.fileName, entry.textSpan.start);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
try {
|
|
192
|
+
service?.dispose?.();
|
|
193
|
+
} catch {
|
|
194
|
+
// dispose is best-effort
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return edges;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function occurrencesOf(source, symbol) {
|
|
202
|
+
const positions = [];
|
|
203
|
+
const pattern = new RegExp(`\\b${symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
|
|
204
|
+
let match;
|
|
205
|
+
while ((match = pattern.exec(source)) !== null && positions.length < 50) {
|
|
206
|
+
positions.push(match.index);
|
|
207
|
+
}
|
|
208
|
+
return positions;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function lineOf(source, offset) {
|
|
212
|
+
let line = 1;
|
|
213
|
+
for (let i = 0; i < offset && i < source.length; i += 1) {
|
|
214
|
+
if (source[i] === '\n') {
|
|
215
|
+
line += 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return line;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Factory honoring `config.codeIntel.providers.semantic`:
|
|
223
|
+
* 'typescript' (or 'auto' when typescript resolves) → TypeScriptSemanticProvider
|
|
224
|
+
* anything else / missing ('null' default) → NullSemanticProvider
|
|
225
|
+
*/
|
|
226
|
+
export function createSemanticProvider({ projectRoot = process.cwd(), config = {}, loader } = {}) {
|
|
227
|
+
const setting = config?.codeIntel?.providers?.semantic ?? 'null';
|
|
228
|
+
if (setting === 'typescript' || setting === 'auto') {
|
|
229
|
+
return new TypeScriptSemanticProvider({
|
|
230
|
+
projectRoot,
|
|
231
|
+
loader: loader ?? defaultLoader,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return new NullSemanticProvider();
|
|
235
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getSyntaxProvider } from './providers.js';
|
|
5
|
+
import { resolveProjectRelativePath } from '../fileOps.js';
|
|
6
|
+
|
|
7
|
+
// Deterministic extractive summaries (SPEC §7/§8, CI-304) — the L3 detail tier.
|
|
8
|
+
// No model calls, no clock/random, no new deps. File reads are bounded to the
|
|
9
|
+
// first MAX_READ_LINES lines; extraction is pure text scanning over docblocks,
|
|
10
|
+
// declaration regexes, and the index-file symbol provider.
|
|
11
|
+
|
|
12
|
+
const MAX_READ_LINES = 200;
|
|
13
|
+
const DEFAULT_MAX_CHARS = 240;
|
|
14
|
+
const DEFAULT_SYMBOL_MAX_CHARS = 160;
|
|
15
|
+
const DEFAULT_MAX_SYMBOLS = 8;
|
|
16
|
+
|
|
17
|
+
const DECL_RE = /^\s*(?:export\s+default\s+|export\s+)?(?:async\s+)?(?:function\*?|class|const|let|var)\s+([A-Za-z_$][\w$]*)/;
|
|
18
|
+
|
|
19
|
+
async function readHead(rootDir, relPath) {
|
|
20
|
+
try {
|
|
21
|
+
const abs = resolveProjectRelativePath(rootDir, relPath);
|
|
22
|
+
if (!abs) return null;
|
|
23
|
+
const content = await fs.readFile(abs, 'utf8');
|
|
24
|
+
return { content, lines: content.split('\n').slice(0, MAX_READ_LINES), bytes: Buffer.byteLength(content, 'utf8') };
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Extract the first sentence of a docblock: with `endIndex` omitted, reads the
|
|
31
|
+
// leading file-level docblock at the top of `lines`; with `endIndex`, finds the
|
|
32
|
+
// docblock ending right before a declaration line (lines above decl passed in).
|
|
33
|
+
function docblockFirstSentence(lines, endIndex = lines.length) {
|
|
34
|
+
if (endIndex === lines.length) {
|
|
35
|
+
// File-level lane: the docblock must start at (or near) the top of file.
|
|
36
|
+
let start = 0;
|
|
37
|
+
while (start < lines.length && (lines[start].trim() === '' || lines[start].startsWith('#!'))) start += 1;
|
|
38
|
+
const first = (lines[start] ?? '').trim();
|
|
39
|
+
const isBlock = first.startsWith('/*');
|
|
40
|
+
const isLine = first.startsWith('//');
|
|
41
|
+
if (!isBlock && !isLine) return null;
|
|
42
|
+
const block = [];
|
|
43
|
+
if (isLine) {
|
|
44
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
45
|
+
const t = lines[i].trim();
|
|
46
|
+
if (!t.startsWith('//')) break;
|
|
47
|
+
block.push(t.replace(/^\/\/+\s?/, ''));
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
for (let i = start; i < lines.length; i += 1) {
|
|
51
|
+
const t = lines[i].trim();
|
|
52
|
+
block.push(t.replace(/^\/\*\*?\s?/, '').replace(/\*\/\s?$/, '').replace(/^\*\s?/, ''));
|
|
53
|
+
if (t.endsWith('*/')) break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const text = block.join(' ').replace(/\s+/g, ' ').trim();
|
|
57
|
+
if (!text) return null;
|
|
58
|
+
const m = text.match(/^.+?[.!?](?:\s|$)/);
|
|
59
|
+
return (m ? m[0] : text).trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Find the last docblock ending at or before endIndex, scanning backwards
|
|
63
|
+
// for a '*/' terminator so decl-adjacent blocks are preferred.
|
|
64
|
+
let end = -1;
|
|
65
|
+
for (let i = Math.min(endIndex, lines.length) - 1; i >= 0; i -= 1) {
|
|
66
|
+
const t = lines[i].trim();
|
|
67
|
+
if (t === '' || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*') || t.endsWith('*/')) {
|
|
68
|
+
if (t.endsWith('*/')) { end = i; break; }
|
|
69
|
+
if (t.startsWith('//')) { end = i; break; }
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
if (end < 0) return null;
|
|
75
|
+
|
|
76
|
+
const block = [];
|
|
77
|
+
const tail = lines[end].trim();
|
|
78
|
+
if (tail.startsWith('//')) {
|
|
79
|
+
for (let i = end; i >= 0; i -= 1) {
|
|
80
|
+
const t = lines[i].trim();
|
|
81
|
+
if (!t.startsWith('//')) break;
|
|
82
|
+
block.unshift(t.replace(/^\/\/+\s?/, ''));
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
for (let i = end; i >= 0; i -= 1) {
|
|
86
|
+
const t = lines[i].trim();
|
|
87
|
+
const isBoundary = t.startsWith('/**') || t.startsWith('/*');
|
|
88
|
+
block.unshift(t.replace(/^\/\*\*?\s?/, '').replace(/\*\/\s?$/, '').replace(/^\*\s?/, ''));
|
|
89
|
+
if (isBoundary) break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const text = block.join(' ').replace(/\s+/g, ' ').trim();
|
|
94
|
+
if (!text) return null;
|
|
95
|
+
const m = text.match(/^.+?[.!?](?:\s|$)/);
|
|
96
|
+
return (m ? m[0] : text).trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Truncate on a word boundary — never mid-word.
|
|
100
|
+
function truncateWords(text, maxChars) {
|
|
101
|
+
if (text.length <= maxChars) return text;
|
|
102
|
+
const slice = text.slice(0, maxChars);
|
|
103
|
+
const lastSpace = slice.lastIndexOf(' ');
|
|
104
|
+
const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
|
|
105
|
+
return cut.trimEnd();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Regex fallback when the index symbols artifact is absent — bounded, dep-free.
|
|
109
|
+
function scanDeclarations(lines, maxSymbols) {
|
|
110
|
+
const names = [];
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const m = line.match(DECL_RE);
|
|
113
|
+
if (m && !names.includes(m[1])) names.push(m[1]);
|
|
114
|
+
if (names.length >= maxSymbols) break;
|
|
115
|
+
}
|
|
116
|
+
return names;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function symbolNames(rootDir, relPath, lines, maxSymbols) {
|
|
120
|
+
try {
|
|
121
|
+
const provider = getSyntaxProvider(relPath);
|
|
122
|
+
if (provider) {
|
|
123
|
+
const syms = await provider.symbols(relPath);
|
|
124
|
+
if (Array.isArray(syms) && syms.length > 0) {
|
|
125
|
+
return syms.slice(0, maxSymbols).map((s) => s.name).filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// provider failure falls through to the regex lane — never throws
|
|
130
|
+
}
|
|
131
|
+
return scanDeclarations(lines, maxSymbols);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* summarizeFile(rootDir, relPath, { maxSymbols=8, maxChars=240 })
|
|
136
|
+
* → { path, summary, symbolCount, bytes } | null
|
|
137
|
+
*
|
|
138
|
+
* Extractive summary: leading docblock first sentence + top symbol names.
|
|
139
|
+
* Falls back to a path/size description for docblock-free files. Returns null
|
|
140
|
+
* only when the file cannot be read. Deterministic across calls.
|
|
141
|
+
*/
|
|
142
|
+
export async function summarizeFile(rootDir, relPath, opts = {}) {
|
|
143
|
+
if (!relPath || typeof relPath !== 'string') return null;
|
|
144
|
+
const maxChars = typeof opts.maxChars === 'number' ? opts.maxChars : DEFAULT_MAX_CHARS;
|
|
145
|
+
const maxSymbols = typeof opts.maxSymbols === 'number' ? opts.maxSymbols : DEFAULT_MAX_SYMBOLS;
|
|
146
|
+
|
|
147
|
+
const head = await readHead(rootDir, relPath);
|
|
148
|
+
if (!head) return null;
|
|
149
|
+
|
|
150
|
+
const parts = [];
|
|
151
|
+
const doc = docblockFirstSentence(head.lines);
|
|
152
|
+
if (doc) parts.push(doc);
|
|
153
|
+
|
|
154
|
+
const names = await symbolNames(rootDir, relPath, head.lines, maxSymbols);
|
|
155
|
+
if (names.length > 0) parts.push(`exports: ${names.join(', ')}`);
|
|
156
|
+
|
|
157
|
+
const summary = parts.length > 0
|
|
158
|
+
? parts.join(' — ')
|
|
159
|
+
: `${relPath} (${head.bytes} bytes)`;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
path: relPath,
|
|
163
|
+
summary: truncateWords(summary, maxChars),
|
|
164
|
+
symbolCount: names.length,
|
|
165
|
+
bytes: head.bytes,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* summarizeSymbol(rootDir, relPath, symbolName, { maxChars=160 })
|
|
171
|
+
* → { path, symbol, summary } | null
|
|
172
|
+
*
|
|
173
|
+
* Docblock-above-decl + the declaration signature line. Null when the file is
|
|
174
|
+
* unreadable or the symbol is absent. Deterministic.
|
|
175
|
+
*/
|
|
176
|
+
export async function summarizeSymbol(rootDir, relPath, symbolName, opts = {}) {
|
|
177
|
+
if (!relPath || !symbolName) return null;
|
|
178
|
+
const maxChars = typeof opts.maxChars === 'number' ? opts.maxChars : DEFAULT_SYMBOL_MAX_CHARS;
|
|
179
|
+
|
|
180
|
+
const head = await readHead(rootDir, relPath);
|
|
181
|
+
if (!head) return null;
|
|
182
|
+
|
|
183
|
+
const declIndex = head.lines.findIndex((line) => {
|
|
184
|
+
const m = line.match(DECL_RE);
|
|
185
|
+
return m && m[1] === symbolName;
|
|
186
|
+
});
|
|
187
|
+
if (declIndex < 0) return null;
|
|
188
|
+
|
|
189
|
+
const signature = head.lines[declIndex].trim();
|
|
190
|
+
const doc = docblockFirstSentence(head.lines, declIndex);
|
|
191
|
+
const summary = doc ? `${doc} ${signature}` : signature;
|
|
192
|
+
|
|
193
|
+
return { path: relPath, symbol: symbolName, summary: truncateWords(summary, maxChars) };
|
|
194
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/paths.js';
|
|
4
|
+
import { createEdge } from './providers.js';
|
|
5
|
+
|
|
6
|
+
// Dep-free embedding lane (SPEC §2): feature hashing (FNV-1a 32-bit) over word
|
|
7
|
+
// tokens + word-boundary trigrams, L2-normalized, cosine-ranked against the
|
|
8
|
+
// same doc text the BM25 lane uses (file paths + symbol names). No file reads,
|
|
9
|
+
// no deps — real embeddings plug in later through the injectable `loader`
|
|
10
|
+
// seam (config.codeIntel.embedding.provider === 'auto'), degrading to the
|
|
11
|
+
// hashed provider on ANY failure. Never throws.
|
|
12
|
+
|
|
13
|
+
const DEFAULT_DIMENSIONS = 256;
|
|
14
|
+
const DEFAULT_LIMIT = 20;
|
|
15
|
+
|
|
16
|
+
function fnv1a(str) {
|
|
17
|
+
let h = 0x811c9dc5;
|
|
18
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
19
|
+
h ^= str.charCodeAt(i);
|
|
20
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
21
|
+
}
|
|
22
|
+
return h >>> 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function wordTokens(text) {
|
|
26
|
+
return String(text ?? '')
|
|
27
|
+
.split(/[^A-Za-z0-9_$]+/)
|
|
28
|
+
.map((t) => t.toLowerCase())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function trigrams(token) {
|
|
33
|
+
const padded = `#${token}#`;
|
|
34
|
+
const grams = [];
|
|
35
|
+
for (let i = 0; i + 3 <= padded.length; i += 1) {
|
|
36
|
+
grams.push(padded.slice(i, i + 3));
|
|
37
|
+
}
|
|
38
|
+
return grams;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* hashVectorize(text, { dimensions }) → number[] of length `dimensions`,
|
|
43
|
+
* L2-normalized (zero vector for empty/null input). Deterministic.
|
|
44
|
+
*/
|
|
45
|
+
export function hashVectorize(text, { dimensions = DEFAULT_DIMENSIONS } = {}) {
|
|
46
|
+
const dims = Number.isInteger(dimensions) && dimensions > 0 ? dimensions : DEFAULT_DIMENSIONS;
|
|
47
|
+
const vec = new Array(dims).fill(0);
|
|
48
|
+
const tokens = wordTokens(text);
|
|
49
|
+
for (const token of tokens) {
|
|
50
|
+
vec[fnv1a(`w:${token}`) % dims] += 1;
|
|
51
|
+
for (const gram of trigrams(token)) {
|
|
52
|
+
vec[fnv1a(`t:${gram}`) % dims] += 0.1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
|
|
56
|
+
if (norm > 0) {
|
|
57
|
+
for (let i = 0; i < dims; i += 1) vec[i] /= norm;
|
|
58
|
+
}
|
|
59
|
+
return vec;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** cosineSimilarity(a, b) → number in [-1, 1]; 0 on dim mismatch/empty. */
|
|
63
|
+
export function cosineSimilarity(a, b) {
|
|
64
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) {
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
let dot = 0;
|
|
68
|
+
let na = 0;
|
|
69
|
+
let nb = 0;
|
|
70
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
71
|
+
dot += a[i] * b[i];
|
|
72
|
+
na += a[i] * a[i];
|
|
73
|
+
nb += b[i] * b[i];
|
|
74
|
+
}
|
|
75
|
+
if (na === 0 || nb === 0) return 0;
|
|
76
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readArtifact(rootDir, name) {
|
|
80
|
+
try {
|
|
81
|
+
const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (parsed?.schemaVersion !== undefined && parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class HashedEmbeddingProvider {
|
|
93
|
+
constructor({ projectRoot = process.cwd(), dimensions = DEFAULT_DIMENSIONS, embedFn = null } = {}) {
|
|
94
|
+
this.name = 'hashed-vector';
|
|
95
|
+
this.projectRoot = projectRoot;
|
|
96
|
+
this.dimensions = Number.isInteger(dimensions) && dimensions > 0 ? dimensions : DEFAULT_DIMENSIONS;
|
|
97
|
+
this._embedFn = typeof embedFn === 'function' ? embedFn : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async available() {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
capabilities() {
|
|
105
|
+
return { embeddings: true, similarity: true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async embed(text) {
|
|
109
|
+
if (this._embedFn) {
|
|
110
|
+
const vec = await this._embedFn(String(text ?? ''));
|
|
111
|
+
if (Array.isArray(vec)) return vec;
|
|
112
|
+
}
|
|
113
|
+
return hashVectorize(text, { dimensions: this.dimensions });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* resolve(query, ctx { limit?, snapshot? }) → edge[] | null.
|
|
118
|
+
* Corpus: files.json paths + symbols.json names — index-bound, no file reads.
|
|
119
|
+
* Missing index → null (caller records 'unavailable'); empty index → [].
|
|
120
|
+
*/
|
|
121
|
+
async resolve(query, ctx = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const [filesArtifact, symbolsArtifact] = await Promise.all([
|
|
124
|
+
readArtifact(this.projectRoot, INDEX_ARTIFACTS.files),
|
|
125
|
+
readArtifact(this.projectRoot, INDEX_ARTIFACTS.symbols),
|
|
126
|
+
]);
|
|
127
|
+
if (!filesArtifact && !symbolsArtifact) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const limit = Number.isInteger(ctx?.limit) && ctx.limit > 0 ? ctx.limit : DEFAULT_LIMIT;
|
|
131
|
+
const queryVec = await this.embed(query);
|
|
132
|
+
const docs = new Map();
|
|
133
|
+
for (const item of filesArtifact?.items ?? []) {
|
|
134
|
+
if (!item?.filePath) continue;
|
|
135
|
+
docs.set(item.filePath, item.filePath);
|
|
136
|
+
}
|
|
137
|
+
for (const sym of symbolsArtifact?.items ?? []) {
|
|
138
|
+
if (!sym?.filePath || !sym?.name) continue;
|
|
139
|
+
docs.set(sym.filePath, `${docs.get(sym.filePath) ?? sym.filePath} ${sym.name}`);
|
|
140
|
+
}
|
|
141
|
+
const snapshot = ctx?.snapshot ?? null;
|
|
142
|
+
const scored = await Promise.all([...docs.entries()].map(async ([filePath, docText]) => ({
|
|
143
|
+
filePath,
|
|
144
|
+
score: cosineSimilarity(queryVec, await this.embed(docText)),
|
|
145
|
+
})));
|
|
146
|
+
return scored
|
|
147
|
+
.filter((entry) => entry.score > 0)
|
|
148
|
+
.sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
|
|
149
|
+
.slice(0, limit)
|
|
150
|
+
.map((entry) => createEdge({
|
|
151
|
+
from: String(query ?? ''),
|
|
152
|
+
to: `${entry.filePath}:0`,
|
|
153
|
+
kind: 'vector',
|
|
154
|
+
confidence: entry.score,
|
|
155
|
+
provider: this.name,
|
|
156
|
+
evidence: 'hash-vectorizer',
|
|
157
|
+
snapshot,
|
|
158
|
+
}));
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class NullEmbeddingProvider {
|
|
166
|
+
constructor() {
|
|
167
|
+
this.name = 'null';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async available() {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
capabilities() {
|
|
175
|
+
return { embeddings: false, similarity: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async embed() {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async resolve() {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* createEmbeddingProvider({ projectRoot, config, loader }) → provider.
|
|
189
|
+
* config.codeIntel.embedding.provider:
|
|
190
|
+
* 'hashed' (default) → HashedEmbeddingProvider
|
|
191
|
+
* 'auto' → loader() module exposing embed(); ANY failure → Hashed
|
|
192
|
+
* 'null' → NullEmbeddingProvider
|
|
193
|
+
*/
|
|
194
|
+
export function createEmbeddingProvider({ projectRoot = process.cwd(), config, loader } = {}) {
|
|
195
|
+
const embedding = config?.codeIntel?.embedding;
|
|
196
|
+
const setting = embedding?.provider ?? 'hashed';
|
|
197
|
+
const dimensions = embedding?.dimensions;
|
|
198
|
+
if (setting === 'null') {
|
|
199
|
+
return new NullEmbeddingProvider();
|
|
200
|
+
}
|
|
201
|
+
if (setting === 'auto' && typeof loader === 'function') {
|
|
202
|
+
try {
|
|
203
|
+
const mod = loader();
|
|
204
|
+
if (mod && typeof mod.embed === 'function') {
|
|
205
|
+
return new HashedEmbeddingProvider({ projectRoot, dimensions, embedFn: mod.embed });
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// fall through — degrade to hashed
|
|
209
|
+
}
|
|
210
|
+
return new HashedEmbeddingProvider({ projectRoot, dimensions });
|
|
211
|
+
}
|
|
212
|
+
return new HashedEmbeddingProvider({ projectRoot, dimensions });
|
|
213
|
+
}
|