@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,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
|
+
}
|