@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/manifests/documentation.yaml +77 -6
  3. package/manifests/hostCapabilities.yaml +49 -0
  4. package/manifests/instructionRules.yaml +7 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/goldTasks.json +38 -0
  7. package/scripts/bench/runGold.mjs +220 -0
  8. package/scripts/index/build-index.mjs +2 -1
  9. package/scripts/index/query-index.mjs +2 -0
  10. package/scripts/release/verify-release.mjs +6 -0
  11. package/src/cli/commands/code.js +182 -0
  12. package/src/cli/commands/doctor.js +35 -3
  13. package/src/cli/commands/indexTools.js +107 -1
  14. package/src/cli/commands/install.js +2 -1
  15. package/src/cli/commands/memory.js +137 -0
  16. package/src/cli/index.js +7 -0
  17. package/src/core/codeintel/analogy.js +197 -0
  18. package/src/core/codeintel/cochange.js +205 -0
  19. package/src/core/codeintel/compiler.js +389 -0
  20. package/src/core/codeintel/diagnostics.js +114 -0
  21. package/src/core/codeintel/freshness.js +295 -0
  22. package/src/core/codeintel/graph.js +291 -0
  23. package/src/core/codeintel/impact.js +274 -0
  24. package/src/core/codeintel/invalidation.js +150 -0
  25. package/src/core/codeintel/manifest.js +176 -0
  26. package/src/core/codeintel/packet.js +147 -0
  27. package/src/core/codeintel/providers.js +201 -0
  28. package/src/core/codeintel/retriever.js +418 -0
  29. package/src/core/codeintel/router.js +149 -0
  30. package/src/core/codeintel/semanticProvider.js +235 -0
  31. package/src/core/codeintel/summaries.js +194 -0
  32. package/src/core/codeintel/vectorProvider.js +213 -0
  33. package/src/core/docContracts.js +723 -0
  34. package/src/core/memory/migrate.js +324 -0
  35. package/src/core/memory/records.js +172 -0
  36. package/src/core/memory/retrieval.js +161 -11
  37. package/src/core/memory/store.js +398 -0
  38. package/src/core/memory/storeV2.js +171 -0
  39. package/src/core/memory/storeV2Loader.js +22 -0
  40. package/src/core/runtimeConfig.js +173 -0
  41. package/src/core/runtimePaths.js +3 -0
  42. package/src/index/buildIndex.js +29 -0
  43. package/src/index/paths.js +2 -0
  44. package/src/index/taskRouting.js +39 -0
  45. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  46. package/templates/AGENTS.md +46 -99
  47. package/templates/CLAUDE.md +46 -99
  48. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  49. package/templates/docs/BUGFIX.md +2 -19
  50. package/templates/docs/BUG_INDEX.md +43 -0
  51. package/templates/docs/BUG_METRICS.md +1 -5
  52. package/templates/docs/BUG_TEMPLATE.md +1 -11
  53. package/templates/docs/UKIT_INTERNALS.md +4 -0
  54. package/templates/instructions/core.md +46 -99
  55. package/templates/ukit/storage/config.json +35 -0
@@ -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
+ }