@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.
@@ -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
+ }
@@ -174,11 +174,17 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
174
174
  bm25: { k1: 1.2, b: 0.75 },
175
175
  merge: 'rrf',
176
176
  rrfK: 60,
177
- weights: { exact: 1.0, symbol: 1.2, bm25: 0.8, semantic: 1.0 },
177
+ weights: { exact: 1.0, symbol: 1.2, bm25: 0.8, semantic: 1.0, vector: 0.6 },
178
178
  },
179
179
  impact: { defaultDepth: 2, maxDepth: 4, maxNodes: 200 },
180
180
  diagnostics: { enabled: true, timeoutMs: 8000 },
181
181
  providers: { semantic: 'null' },
182
+ // C30 additive blocks (SPEC §10 — TASK-222 single owner).
183
+ embedding: { provider: 'hashed', dimensions: 256 },
184
+ analogy: { enabled: true, limit: 5 },
185
+ cochange: { enabled: true, maxCommits: 500, minCount: 2, timeoutMs: 5000 },
186
+ summaries: { enabled: true, maxItems: 5 },
187
+ graph: { enabled: true, store: 'json' },
182
188
  },
183
189
  memoryV2: {
184
190
  enabled: true,
@@ -433,7 +439,7 @@ export function validateRuntimeConfig(config) {
433
439
  if (!isPlainObject(codeIntel.retriever.weights)) {
434
440
  errors.push('codeIntel.retriever.weights must be an object.');
435
441
  } else {
436
- for (const lane of ['exact', 'symbol', 'bm25', 'semantic']) {
442
+ for (const lane of ['exact', 'symbol', 'bm25', 'semantic', 'vector']) {
437
443
  if (typeof codeIntel.retriever.weights[lane] !== 'number' || Number.isNaN(codeIntel.retriever.weights[lane])) {
438
444
  errors.push(`codeIntel.retriever.weights.${lane} must be a number.`);
439
445
  }
@@ -458,6 +464,48 @@ export function validateRuntimeConfig(config) {
458
464
  } else {
459
465
  pushNonEmptyStringError(errors, codeIntel.providers.semantic, 'codeIntel.providers.semantic');
460
466
  }
467
+ // C30 additive blocks (SPEC §10 — TASK-222).
468
+ if (!isPlainObject(codeIntel.embedding)) {
469
+ errors.push('codeIntel.embedding must be an object.');
470
+ } else {
471
+ const VALID_EMBEDDING_PROVIDERS = new Set(['hashed', 'auto', 'null']);
472
+ if (!VALID_EMBEDDING_PROVIDERS.has(codeIntel.embedding.provider)) {
473
+ errors.push(`codeIntel.embedding.provider must be one of: ${[...VALID_EMBEDDING_PROVIDERS].join(', ')}.`);
474
+ }
475
+ const dimensions = codeIntel.embedding.dimensions;
476
+ if (typeof dimensions !== 'number' || !Number.isInteger(dimensions) || dimensions <= 0 || dimensions > 4096) {
477
+ errors.push('codeIntel.embedding.dimensions must be a positive integer <= 4096.');
478
+ }
479
+ }
480
+ if (!isPlainObject(codeIntel.analogy)) {
481
+ errors.push('codeIntel.analogy must be an object.');
482
+ } else {
483
+ pushBooleanError(errors, codeIntel.analogy.enabled, 'codeIntel.analogy.enabled');
484
+ pushPositiveNumberError(errors, codeIntel.analogy.limit, 'codeIntel.analogy.limit');
485
+ }
486
+ if (!isPlainObject(codeIntel.cochange)) {
487
+ errors.push('codeIntel.cochange must be an object.');
488
+ } else {
489
+ pushBooleanError(errors, codeIntel.cochange.enabled, 'codeIntel.cochange.enabled');
490
+ pushPositiveNumberError(errors, codeIntel.cochange.maxCommits, 'codeIntel.cochange.maxCommits');
491
+ pushPositiveNumberError(errors, codeIntel.cochange.minCount, 'codeIntel.cochange.minCount');
492
+ pushPositiveNumberError(errors, codeIntel.cochange.timeoutMs, 'codeIntel.cochange.timeoutMs');
493
+ }
494
+ if (!isPlainObject(codeIntel.summaries)) {
495
+ errors.push('codeIntel.summaries must be an object.');
496
+ } else {
497
+ pushBooleanError(errors, codeIntel.summaries.enabled, 'codeIntel.summaries.enabled');
498
+ pushPositiveNumberError(errors, codeIntel.summaries.maxItems, 'codeIntel.summaries.maxItems');
499
+ }
500
+ if (!isPlainObject(codeIntel.graph)) {
501
+ errors.push('codeIntel.graph must be an object.');
502
+ } else {
503
+ pushBooleanError(errors, codeIntel.graph.enabled, 'codeIntel.graph.enabled');
504
+ const VALID_GRAPH_STORES = new Set(['json', 'scip', 'pdg', 'lsp', 'null']);
505
+ if (!VALID_GRAPH_STORES.has(codeIntel.graph.store)) {
506
+ errors.push(`codeIntel.graph.store must be one of: ${[...VALID_GRAPH_STORES].join(', ')}.`);
507
+ }
508
+ }
461
509
  }
462
510
 
463
511
  if (!isPlainObject(config.memoryV2)) {
@@ -1156,6 +1156,35 @@ async function tryIncrementalIndexUpdate({ absoluteRoot, indexDir, changedPaths,
1156
1156
  };
1157
1157
  }
1158
1158
 
1159
+ // Derived artifacts (CI-303/CI-305): git-history co-change mining and the
1160
+ // merged code-graph store. Exported for the index CLI/install callers to run
1161
+ // after buildCodeIndex — intentionally NOT inside buildCodeIndex so the core
1162
+ // build's read/write discipline (atomic renames, no secondary artifact reads
1163
+ // on fresh builds) stays untouched. Optional, config-gated, never fatal: a
1164
+ // failure leaves the artifact absent and readers degrade to omitted/empty.
1165
+ export async function buildDerivedIndexArtifacts(absoluteRoot) {
1166
+ const built = { cochange: null, codegraph: null };
1167
+ try {
1168
+ const { loadRuntimeConfig } = await import('../core/runtimeConfig.js');
1169
+ const config = await loadRuntimeConfig(absoluteRoot);
1170
+ const codeIntel = config?.codeIntel ?? {};
1171
+ if (codeIntel.cochange?.enabled !== false) {
1172
+ const { mineCoChanges } = await import('../core/codeintel/cochange.js');
1173
+ built.cochange = await mineCoChanges(absoluteRoot, {
1174
+ maxCommits: codeIntel.cochange?.maxCommits,
1175
+ timeoutMs: codeIntel.cochange?.timeoutMs,
1176
+ });
1177
+ }
1178
+ if (codeIntel.graph?.enabled !== false) {
1179
+ const { buildGraphArtifact } = await import('../core/codeintel/graph.js');
1180
+ built.codegraph = await buildGraphArtifact(absoluteRoot);
1181
+ }
1182
+ } catch {
1183
+ // Derivation is best-effort; core artifacts already landed.
1184
+ }
1185
+ return built;
1186
+ }
1187
+
1159
1188
  async function collectFiles(scanRoots, discovery = null) {
1160
1189
  const budget = discovery ?? createDiscoveryBudget({});
1161
1190
  const result = [];
@@ -11,6 +11,8 @@ export const INDEX_ARTIFACTS = {
11
11
  archetypes: 'archetypes.json',
12
12
  relations: 'relations.json',
13
13
  analogs: 'analogs.json',
14
+ cochange: 'cochange.json',
15
+ codegraph: 'codegraph.json',
14
16
  };
15
17
 
16
18
  export const INDEX_SCHEMA_VERSION = 8;
@@ -192,11 +192,16 @@
192
192
  "bm25": { "k1": 1.2, "b": 0.75 },
193
193
  "merge": "rrf",
194
194
  "rrfK": 60,
195
- "weights": { "exact": 1.0, "symbol": 1.2, "bm25": 0.8, "semantic": 1.0 }
195
+ "weights": { "exact": 1.0, "symbol": 1.2, "bm25": 0.8, "semantic": 1.0, "vector": 0.6 }
196
196
  },
197
197
  "impact": { "defaultDepth": 2, "maxDepth": 4, "maxNodes": 200 },
198
198
  "diagnostics": { "enabled": true, "timeoutMs": 8000 },
199
- "providers": { "semantic": "null" }
199
+ "providers": { "semantic": "null" },
200
+ "embedding": { "provider": "hashed", "dimensions": 256 },
201
+ "analogy": { "enabled": true, "limit": 5 },
202
+ "cochange": { "enabled": true, "maxCommits": 500, "minCount": 2, "timeoutMs": 5000 },
203
+ "summaries": { "enabled": true, "maxItems": 5 },
204
+ "graph": { "enabled": true, "store": "json" }
200
205
  },
201
206
  "memoryV2": {
202
207
  "enabled": true,