knodin 0.7.4 → 0.7.6
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/README.md +4 -1
- package/dist/bin/cli.js +108 -1859
- package/dist/bin/launcher.js +3 -10
- package/dist/chunks/chunk-DMQAGX77.js +654 -0
- package/dist/chunks/chunk-F4Z3Z766.js +4 -0
- package/dist/chunks/chunk-SIJAQVSX.js +3 -0
- package/dist/chunks/chunk-X6M4HUUE.js +2 -0
- package/dist/chunks/chunk-YPRMY2LP.js +8 -0
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +5 -0
- package/dist/chunks/server-7EDF4CBY.js +14 -0
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +4 -0
- package/dist/src/init-progress-worker.js +2 -106
- package/dist/src/repair-progress-worker.js +2 -120
- package/docs/INSTALLATION.md +17 -1
- package/docs/releases/0.7.6.md +25 -0
- package/package.json +1 -110
- package/dist/src/agent-integration.js +0 -238
- package/dist/src/artifact-refresh.js +0 -81
- package/dist/src/cli-args.js +0 -274
- package/dist/src/cli-model.js +0 -358
- package/dist/src/codeflow-replay.js +0 -80
- package/dist/src/compact-structural.js +0 -96
- package/dist/src/competitive-cold-mcp.js +0 -40
- package/dist/src/competitive-constraints.js +0 -20
- package/dist/src/competitive-manifest.js +0 -411
- package/dist/src/competitive-measurement.js +0 -183
- package/dist/src/competitive-runner.js +0 -487
- package/dist/src/competitive-sandbox.js +0 -108
- package/dist/src/context-export.js +0 -422
- package/dist/src/context.js +0 -102
- package/dist/src/diagnostics.js +0 -449
- package/dist/src/docs-sections.js +0 -141
- package/dist/src/doctor.js +0 -380
- package/dist/src/engine/ann-hnsw.js +0 -271
- package/dist/src/engine/embeddings.js +0 -193
- package/dist/src/engine/file-walker.js +0 -43
- package/dist/src/engine/git-history.js +0 -289
- package/dist/src/engine/index.js +0 -13377
- package/dist/src/engine/perf.js +0 -115
- package/dist/src/engine/prune.js +0 -112
- package/dist/src/engine/scip-import.js +0 -408
- package/dist/src/engine/source-policy.js +0 -69
- package/dist/src/engine/sqlite.js +0 -71
- package/dist/src/engine/symbol-delete.js +0 -58
- package/dist/src/execution-profile.js +0 -203
- package/dist/src/failure-diagnosis.js +0 -649
- package/dist/src/fleet.js +0 -7
- package/dist/src/git-executable.js +0 -31
- package/dist/src/graph-query-health.js +0 -115
- package/dist/src/hook-manager-integration.js +0 -156
- package/dist/src/index-activity.js +0 -125
- package/dist/src/init-progress.js +0 -155
- package/dist/src/init.js +0 -1254
- package/dist/src/lifecycle-health.js +0 -250
- package/dist/src/lsp-readonly.js +0 -217
- package/dist/src/output-compression.js +0 -629
- package/dist/src/output-telemetry.js +0 -363
- package/dist/src/pr-triage.js +0 -638
- package/dist/src/progressive-evidence.js +0 -473
- package/dist/src/pure-compression-cli.js +0 -101
- package/dist/src/relationship-adapters.js +0 -370
- package/dist/src/release-attestation.js +0 -533
- package/dist/src/release-preflight.js +0 -510
- package/dist/src/repair-progress.js +0 -262
- package/dist/src/repository-init-process.js +0 -173
- package/dist/src/repository-management.js +0 -1231
- package/dist/src/response-budget.js +0 -194
- package/dist/src/server.js +0 -73
- package/dist/src/structural-fast-path.js +0 -338
- package/dist/src/structural-snapshot.js +0 -33
- package/dist/src/system-config.js +0 -615
- package/dist/src/terminal-help.js +0 -83
- package/dist/src/tools/knodin-tools.js +0 -1529
- package/dist/src/update-ceremony.js +0 -158
- package/dist/src/update-policy.js +0 -944
- package/dist/src/update-trust.js +0 -503
- package/dist/src/version.js +0 -13
- package/dist/src/visualization.js +0 -162
- package/dist/src/wait-for-fresh.js +0 -98
- package/dist/src/worktree-lifecycle.js +0 -231
- package/docs/CONTAINED-EXECUTION.md +0 -77
- package/docs/DIAGNOSTICS.md +0 -45
- package/docs/GIT-HISTORY-REVIEW.md +0 -39
- package/docs/PROGRESSIVE-EVIDENCE.md +0 -37
- package/docs/PT-ACCESS-RECOMMENDATION.md +0 -91
- package/docs/RELEASE-0.3-EVIDENCE.md +0 -73
- package/docs/SCIP-IMPORT.md +0 -57
- package/docs/SIGNED-UPDATES.md +0 -151
- package/docs/TELEMETRY.md +0 -46
- package/docs/assets/knodin-favicon.svg +0 -4
- package/docs/releases/0.3.0.md +0 -46
- package/docs/releases/0.4.0.md +0 -68
- package/docs/releases/0.4.1.md +0 -28
- package/docs/releases/0.4.2.md +0 -27
- package/docs/releases/0.4.3.md +0 -23
- package/docs/releases/0.5.0.md +0 -29
- package/docs/releases/0.5.1.md +0 -17
- package/docs/releases/0.6.0.md +0 -18
- package/docs/releases/0.7.0.md +0 -24
- package/docs/releases/0.7.1.md +0 -21
- package/docs/releases/0.7.2.md +0 -21
- package/docs/releases/0.7.3.md +0 -23
- package/docs/releases/0.7.4.md +0 -17
- package/schemas/release-attestation-v1.schema.json +0 -210
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local-only by design (ADR 003, "Future Considerations" section,
|
|
3
|
-
* docs/adr/003-local-semantic-search.md): a cloud embedding provider
|
|
4
|
-
* (OpenAI/Google/MiniMax, as some competitors offer) was evaluated and
|
|
5
|
-
* deliberately deferred, not merely unbuilt. knodin's core positioning is
|
|
6
|
-
* zero-auth/local-first/no-egress — sending symbol text to a cloud API for
|
|
7
|
-
* embedding would break that guarantee for every indexed repo, not just tune
|
|
8
|
-
* a knob. If ever added, it must be opt-in with `local` staying the default,
|
|
9
|
-
* and reads as a natural pro-tier feature precisely because enabling it is a
|
|
10
|
-
* deliberate trade against this file's local/no-egress guarantee.
|
|
11
|
-
*/
|
|
12
|
-
import os from "node:os";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
let embedderPromise = null;
|
|
15
|
-
let embedderOverride = null;
|
|
16
|
-
const embedderProgressListeners = new Set();
|
|
17
|
-
let embedderReady = false;
|
|
18
|
-
const EMBEDDING_DIMENSIONS = 384;
|
|
19
|
-
const DEFAULT_MODEL_REMOTE_HOST = "https://huggingface.co/";
|
|
20
|
-
/**
|
|
21
|
-
* One machine-level model cache shared by every repository and every installed
|
|
22
|
-
* knodin version. An explicit override makes managed/offline environments and
|
|
23
|
-
* the clean-package acceptance gate deterministic.
|
|
24
|
-
*/
|
|
25
|
-
export function getModelCacheDirectory(options = {}) {
|
|
26
|
-
const environment = options.env ?? process.env;
|
|
27
|
-
const override = environment.KNODIN_MODEL_CACHE?.trim();
|
|
28
|
-
if (override)
|
|
29
|
-
return path.resolve(override);
|
|
30
|
-
const platform = options.platform ?? process.platform;
|
|
31
|
-
const home = options.home ?? os.homedir();
|
|
32
|
-
if (platform === "win32") {
|
|
33
|
-
return path.join(environment.LOCALAPPDATA || path.join(home, "AppData", "Local"), "knodin", "models");
|
|
34
|
-
}
|
|
35
|
-
if (platform === "darwin")
|
|
36
|
-
return path.join(home, "Library", "Caches", "knodin", "models");
|
|
37
|
-
return path.join(environment.XDG_CACHE_HOME || path.join(home, ".cache"), "knodin", "models");
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Remote model origin used only when the shared machine cache is cold.
|
|
41
|
-
* The default remains the public Hugging Face Hub; managed environments may
|
|
42
|
-
* point at an approved mirror without changing where model files are cached.
|
|
43
|
-
*/
|
|
44
|
-
export function getModelRemoteHost(environment = process.env) {
|
|
45
|
-
const configured = environment.KNODIN_MODEL_HOST?.trim() || DEFAULT_MODEL_REMOTE_HOST;
|
|
46
|
-
let parsed;
|
|
47
|
-
try {
|
|
48
|
-
parsed = new URL(configured);
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
throw new Error(`KNODIN_MODEL_HOST must be an absolute HTTP(S) URL: ${configured}`);
|
|
52
|
-
}
|
|
53
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
54
|
-
throw new Error(`KNODIN_MODEL_HOST must use HTTP(S): ${configured}`);
|
|
55
|
-
}
|
|
56
|
-
parsed.hash = "";
|
|
57
|
-
parsed.search = "";
|
|
58
|
-
return parsed.href.endsWith("/") ? parsed.href : `${parsed.href}/`;
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Number of times the real ONNX model pipeline has actually been loaded in this
|
|
62
|
-
* process. Observability only — it never changes embedding behavior. The test
|
|
63
|
-
* suite uses it to guarantee that non-model suites keep the deterministic test
|
|
64
|
-
* embedder installed and never fall through to native model initialization
|
|
65
|
-
* (which, repeated across a long-lived worker, is what C50 removes).
|
|
66
|
-
*/
|
|
67
|
-
let realEmbedderLoadCount = 0;
|
|
68
|
-
/** Returns how many times the real ONNX pipeline has loaded in this process. */
|
|
69
|
-
export function getRealEmbedderLoadCount() {
|
|
70
|
-
return realEmbedderLoadCount;
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* Overrides the embedding pipeline (e.g. with a fast mock in tests). Pass `null`
|
|
74
|
-
* to clear the override and fall back to the real lazily-loaded model.
|
|
75
|
-
*/
|
|
76
|
-
export function setEmbedder(pipeline) {
|
|
77
|
-
embedderOverride = pipeline;
|
|
78
|
-
embedderPromise = null;
|
|
79
|
-
embedderReady = pipeline !== null;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Release the native ONNX model session if one was loaded, freeing its resident
|
|
83
|
-
* memory. Best-effort and test-facing: the single long-lived test worker
|
|
84
|
-
* (isolate:false) otherwise keeps the ~model resident for the whole run, bloating
|
|
85
|
-
* the process so later subprocess-spawning tests fork slowly. The dedicated
|
|
86
|
-
* embedding spec calls this in teardown once its real-model assertions are done.
|
|
87
|
-
* A no-op when only a mock embedder (or nothing) has been used.
|
|
88
|
-
*/
|
|
89
|
-
export async function disposeEmbedder() {
|
|
90
|
-
const pending = embedderPromise;
|
|
91
|
-
embedderPromise = null;
|
|
92
|
-
embedderReady = false;
|
|
93
|
-
if (!pending)
|
|
94
|
-
return;
|
|
95
|
-
try {
|
|
96
|
-
const pipe = (await pending);
|
|
97
|
-
if (typeof pipe.dispose === "function")
|
|
98
|
-
await pipe.dispose();
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
/* best-effort native teardown; never fail a test on dispose */
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Lazy-loads and caches the local embedding pipeline.
|
|
106
|
-
* Uses a lightweight, high-performance 384-dimension model (all-MiniLM-L6-v2)
|
|
107
|
-
* running in-process via ONNX Runtime Web.
|
|
108
|
-
* Uses dynamic imports to keep CLI and MCP server startup latency near-zero.
|
|
109
|
-
*/
|
|
110
|
-
export function getEmbedder(onProgress) {
|
|
111
|
-
if (embedderOverride) {
|
|
112
|
-
onProgress?.({ status: "ready", name: "local test embedder" });
|
|
113
|
-
return Promise.resolve(embedderOverride);
|
|
114
|
-
}
|
|
115
|
-
if (onProgress && embedderReady)
|
|
116
|
-
onProgress({ status: "ready", name: "Xenova/all-MiniLM-L6-v2" });
|
|
117
|
-
else if (onProgress)
|
|
118
|
-
embedderProgressListeners.add(onProgress);
|
|
119
|
-
if (!embedderPromise) {
|
|
120
|
-
embedderPromise = (async () => {
|
|
121
|
-
try {
|
|
122
|
-
const { env, pipeline } = await import("@huggingface/transformers");
|
|
123
|
-
env.cacheDir = getModelCacheDirectory();
|
|
124
|
-
env.remoteHost = getModelRemoteHost();
|
|
125
|
-
const notify = (event) => {
|
|
126
|
-
for (const listener of embedderProgressListeners) {
|
|
127
|
-
try {
|
|
128
|
-
listener(event);
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
/* Observability must not alter model initialization. */
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
notify({ status: "loading", name: "Xenova/all-MiniLM-L6-v2" });
|
|
136
|
-
const pipe = await pipeline("feature-extraction", "Xenova/all-MiniLM-L6-v2", {
|
|
137
|
-
progress_callback: notify,
|
|
138
|
-
});
|
|
139
|
-
realEmbedderLoadCount++;
|
|
140
|
-
embedderReady = true;
|
|
141
|
-
notify({ status: "ready", name: "Xenova/all-MiniLM-L6-v2" });
|
|
142
|
-
embedderProgressListeners.clear();
|
|
143
|
-
return pipe;
|
|
144
|
-
}
|
|
145
|
-
catch (error) {
|
|
146
|
-
embedderPromise = null; // Reset promise so we can retry on next call
|
|
147
|
-
embedderProgressListeners.clear();
|
|
148
|
-
console.error("Failed to initialize Hugging Face embedding pipeline:", error);
|
|
149
|
-
throw error;
|
|
150
|
-
}
|
|
151
|
-
})();
|
|
152
|
-
}
|
|
153
|
-
return embedderPromise;
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Generates a normalized Float32Array embedding (384 dimensions) for a given text block.
|
|
157
|
-
*/
|
|
158
|
-
export async function generateEmbeddings(texts, onModelProgress) {
|
|
159
|
-
if (texts.length === 0)
|
|
160
|
-
return [];
|
|
161
|
-
const extractor = await getEmbedder(onModelProgress);
|
|
162
|
-
const result = await extractor([...texts], { pooling: "mean", normalize: true });
|
|
163
|
-
const data = result.data instanceof Float32Array ? result.data : Float32Array.from(result.data);
|
|
164
|
-
const dims = result.dims;
|
|
165
|
-
if (dims?.length !== 2 || dims[0] !== texts.length || dims[1] !== EMBEDDING_DIMENSIONS) {
|
|
166
|
-
throw new Error(`Unexpected batched embedding shape: expected [${texts.length}, ${EMBEDDING_DIMENSIONS}], got ${JSON.stringify(dims)}`);
|
|
167
|
-
}
|
|
168
|
-
const dimension = dims[1];
|
|
169
|
-
if (data.length !== texts.length * dimension) {
|
|
170
|
-
throw new Error(`Unexpected batched embedding data length: expected ${texts.length * dimension}, got ${data.length}`);
|
|
171
|
-
}
|
|
172
|
-
return texts.map((_, index) => data.slice(index * dimension, (index + 1) * dimension));
|
|
173
|
-
}
|
|
174
|
-
/** Compatibility wrapper used by query-time search. */
|
|
175
|
-
export async function generateEmbedding(text) {
|
|
176
|
-
return (await generateEmbeddings([text]))[0];
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Computes the cosine similarity between two normalized Float32Arrays.
|
|
180
|
-
* Since the vectors are pre-normalized, the cosine similarity simplifies
|
|
181
|
-
* to the mathematical dot product: sum(u_i * v_i).
|
|
182
|
-
*/
|
|
183
|
-
export function computeSimilarity(queryVec, targetVec) {
|
|
184
|
-
if (queryVec.length !== targetVec.length) {
|
|
185
|
-
throw new Error(`Embedding dimension mismatch: query has ${queryVec.length} dims, target has ${targetVec.length}`);
|
|
186
|
-
}
|
|
187
|
-
let dotProduct = 0;
|
|
188
|
-
const len = queryVec.length;
|
|
189
|
-
for (let i = 0; i < len; i++) {
|
|
190
|
-
dotProduct += queryVec[i] * targetVec[i];
|
|
191
|
-
}
|
|
192
|
-
return dotProduct;
|
|
193
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { isIndexablePath } from "./prune.js";
|
|
4
|
-
function comparePaths(left, right) {
|
|
5
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
6
|
-
}
|
|
7
|
-
/**
|
|
8
|
-
* Deterministically enumerate regular files beneath a repository without
|
|
9
|
-
* following symlinks. Directory pruning happens before descent so dependency,
|
|
10
|
-
* build, VCS, worktree, and local graph state never incur a recursive scan.
|
|
11
|
-
*/
|
|
12
|
-
export function walkRepoFiles(repoPath, options = {}) {
|
|
13
|
-
const root = path.resolve(repoPath);
|
|
14
|
-
const pending = [{ absolute: root, relative: "" }];
|
|
15
|
-
const files = [];
|
|
16
|
-
while (pending.length > 0) {
|
|
17
|
-
const directory = pending.pop();
|
|
18
|
-
if (!directory)
|
|
19
|
-
break;
|
|
20
|
-
let entries;
|
|
21
|
-
try {
|
|
22
|
-
entries = fs.readdirSync(directory.absolute, { withFileTypes: true });
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
continue;
|
|
26
|
-
}
|
|
27
|
-
entries.sort((left, right) => comparePaths(left.name, right.name));
|
|
28
|
-
for (const entry of entries) {
|
|
29
|
-
const relative = directory.relative ? `${directory.relative}/${entry.name}` : entry.name;
|
|
30
|
-
if (!isIndexablePath(relative))
|
|
31
|
-
continue;
|
|
32
|
-
const absolute = path.join(directory.absolute, entry.name);
|
|
33
|
-
if (entry.isDirectory()) {
|
|
34
|
-
pending.push({ absolute, relative });
|
|
35
|
-
}
|
|
36
|
-
else if (entry.isFile() && (options.accept?.(relative) ?? true)) {
|
|
37
|
-
files.push(relative);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
files.sort(comparePaths);
|
|
42
|
-
return options.limit === undefined ? files : files.slice(0, Math.max(0, options.limit));
|
|
43
|
-
}
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
import childProcess from "node:child_process";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
export const DEFAULT_GIT_HISTORY_BOUNDS = Object.freeze({
|
|
4
|
-
maxCommits: 250,
|
|
5
|
-
maxFiles: 25,
|
|
6
|
-
maxRelatedFiles: 100,
|
|
7
|
-
timeoutMs: 2_000,
|
|
8
|
-
});
|
|
9
|
-
const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
10
|
-
const HISTORY_CACHE_LIMIT = 8;
|
|
11
|
-
const historyCache = new Map();
|
|
12
|
-
const INTERPRETATION = "Co-change and coupling are bounded historical correlation, not evidence of causation, defects, ownership, or required remediation.";
|
|
13
|
-
function positiveBound(value, fallback, name, hardMaximum) {
|
|
14
|
-
const resolved = value ?? fallback;
|
|
15
|
-
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > hardMaximum) {
|
|
16
|
-
throw new Error(`git history ${name} must be an integer between 1 and ${hardMaximum}`);
|
|
17
|
-
}
|
|
18
|
-
return resolved;
|
|
19
|
-
}
|
|
20
|
-
function resolveBounds(input) {
|
|
21
|
-
return {
|
|
22
|
-
maxCommits: positiveBound(input.maxCommits, DEFAULT_GIT_HISTORY_BOUNDS.maxCommits, "maxCommits", 10_000),
|
|
23
|
-
maxFiles: positiveBound(input.maxFiles, DEFAULT_GIT_HISTORY_BOUNDS.maxFiles, "maxFiles", 1_000),
|
|
24
|
-
maxRelatedFiles: positiveBound(input.maxRelatedFiles, DEFAULT_GIT_HISTORY_BOUNDS.maxRelatedFiles, "maxRelatedFiles", 10_000),
|
|
25
|
-
timeoutMs: positiveBound(input.timeoutMs, DEFAULT_GIT_HISTORY_BOUNDS.timeoutMs, "timeoutMs", 30_000),
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
function safeHistoryFile(file) {
|
|
29
|
-
const normalized = file.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
30
|
-
if (!normalized ||
|
|
31
|
-
path.isAbsolute(normalized) ||
|
|
32
|
-
normalized === ".." ||
|
|
33
|
-
normalized.startsWith("../")) {
|
|
34
|
-
throw new Error(`git history file must be repo-relative: ${file}`);
|
|
35
|
-
}
|
|
36
|
-
return normalized;
|
|
37
|
-
}
|
|
38
|
-
function failureReason(error) {
|
|
39
|
-
const failure = error;
|
|
40
|
-
if (failure.code === "ENOENT")
|
|
41
|
-
return "git-unavailable";
|
|
42
|
-
if (failure.code === "ETIMEDOUT" || failure.signal === "SIGTERM" || failure.killed)
|
|
43
|
-
return "timeout";
|
|
44
|
-
const message = `${String(failure.message ?? error)}\n${String(failure.stderr ?? "")}`;
|
|
45
|
-
if (/not a git repository/i.test(message))
|
|
46
|
-
return "not-a-repository";
|
|
47
|
-
return "history-command-failed";
|
|
48
|
-
}
|
|
49
|
-
function isUnbornHeadFailure(error) {
|
|
50
|
-
const failure = error;
|
|
51
|
-
const diagnostic = `${String(failure.message ?? error)}\n${String(failure.stderr ?? "")}`;
|
|
52
|
-
return /needed a single revision|ambiguous argument ['"]?HEAD|unknown revision or path/i.test(diagnostic);
|
|
53
|
-
}
|
|
54
|
-
function emptyResult(availability, bounds, commands, omission, files = []) {
|
|
55
|
-
return {
|
|
56
|
-
availability,
|
|
57
|
-
bounds,
|
|
58
|
-
commands,
|
|
59
|
-
commitsAnalyzed: 0,
|
|
60
|
-
filesAnalyzed: files.length,
|
|
61
|
-
truncated: false,
|
|
62
|
-
churn: files.map((file) => ({ file, commitCount: 0, commits: [] })),
|
|
63
|
-
coChange: [],
|
|
64
|
-
coupling: [],
|
|
65
|
-
omissions: [omission],
|
|
66
|
-
interpretation: INTERPRETATION,
|
|
67
|
-
confidence: availability === "unborn" ? "exact-bounded-git-history" : "unavailable",
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
function parseLog(output) {
|
|
71
|
-
const commits = [];
|
|
72
|
-
for (const rawRecord of output.split("\x1e").slice(1)) {
|
|
73
|
-
const tokens = rawRecord.split("\0").filter(Boolean);
|
|
74
|
-
const [header, ...changes] = tokens;
|
|
75
|
-
if (!header)
|
|
76
|
-
continue;
|
|
77
|
-
const [hash, parentsText = ""] = header.split("\x1f");
|
|
78
|
-
const parsedChanges = [];
|
|
79
|
-
for (let index = 0; index < changes.length;) {
|
|
80
|
-
const status = (changes[index++] ?? "").trim();
|
|
81
|
-
if (/^[RC]/.test(status)) {
|
|
82
|
-
const oldPath = changes[index++];
|
|
83
|
-
const renamedPath = changes[index++];
|
|
84
|
-
if (oldPath && renamedPath)
|
|
85
|
-
parsedChanges.push({ status, oldPath, path: renamedPath });
|
|
86
|
-
}
|
|
87
|
-
else {
|
|
88
|
-
const changedPath = changes[index++];
|
|
89
|
-
if (changedPath)
|
|
90
|
-
parsedChanges.push({ status, path: changedPath });
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
commits.push({ hash, parents: parentsText.split(" ").filter(Boolean), changes: parsedChanges });
|
|
94
|
-
}
|
|
95
|
-
return commits;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* Collect deterministic, itemized Git-history facts in one bounded history pass.
|
|
99
|
-
* The command receives no user-provided revisions or filesystem paths; `cwd` is
|
|
100
|
-
* the resolved repository and every requested file is validated before launch.
|
|
101
|
-
*/
|
|
102
|
-
export function collectGitHistorySignals(repoPath, filePaths, limits = {}) {
|
|
103
|
-
const bounds = resolveBounds(limits);
|
|
104
|
-
const requestedAll = [...new Set(filePaths.map(safeHistoryFile))].sort();
|
|
105
|
-
const requested = requestedAll.slice(0, bounds.maxFiles);
|
|
106
|
-
const omissions = [];
|
|
107
|
-
if (requestedAll.length > requested.length) {
|
|
108
|
-
omissions.push({
|
|
109
|
-
reason: "file-limit",
|
|
110
|
-
detail: `${requestedAll.length - requested.length} requested file(s) omitted by maxFiles=${bounds.maxFiles}`,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
let commands = 0;
|
|
114
|
-
const run = (args) => {
|
|
115
|
-
commands++;
|
|
116
|
-
return childProcess.execFileSync("git", args, {
|
|
117
|
-
cwd: path.resolve(repoPath),
|
|
118
|
-
encoding: "utf8",
|
|
119
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
120
|
-
timeout: bounds.timeoutMs,
|
|
121
|
-
maxBuffer: MAX_GIT_OUTPUT_BYTES,
|
|
122
|
-
});
|
|
123
|
-
};
|
|
124
|
-
let head;
|
|
125
|
-
try {
|
|
126
|
-
head = run(["rev-parse", "--verify", "HEAD"]).trim();
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
const reason = failureReason(error);
|
|
130
|
-
if (reason === "history-command-failed" && isUnbornHeadFailure(error)) {
|
|
131
|
-
return emptyResult("unborn", bounds, commands, {
|
|
132
|
-
reason: "unborn-repository",
|
|
133
|
-
detail: "HEAD does not exist; no historical facts are available",
|
|
134
|
-
}, requested);
|
|
135
|
-
}
|
|
136
|
-
const availability = reason === "git-unavailable"
|
|
137
|
-
? "git-unavailable"
|
|
138
|
-
: reason === "not-a-repository"
|
|
139
|
-
? "not-a-repository"
|
|
140
|
-
: reason === "timeout"
|
|
141
|
-
? "timeout"
|
|
142
|
-
: "unavailable";
|
|
143
|
-
return emptyResult(availability, bounds, commands, {
|
|
144
|
-
reason,
|
|
145
|
-
detail: "Git HEAD probe failed; history signals were omitted",
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
let shallow = false;
|
|
149
|
-
try {
|
|
150
|
-
shallow = run(["rev-parse", "--is-shallow-repository"]).trim() === "true";
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
// Older Git versions may not expose the probe; bounded history remains usable.
|
|
154
|
-
}
|
|
155
|
-
if (shallow) {
|
|
156
|
-
omissions.push({
|
|
157
|
-
reason: "shallow-history",
|
|
158
|
-
detail: "Only commits present in the shallow clone contribute facts",
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
const cacheKey = `${path.resolve(repoPath)}\0${head}\0${requestedAll.join("\0")}\0${JSON.stringify(bounds)}`;
|
|
162
|
-
if (!shallow) {
|
|
163
|
-
const cached = historyCache.get(cacheKey);
|
|
164
|
-
if (cached)
|
|
165
|
-
return structuredClone({ ...cached, commands });
|
|
166
|
-
}
|
|
167
|
-
let commits;
|
|
168
|
-
try {
|
|
169
|
-
const output = run([
|
|
170
|
-
"log",
|
|
171
|
-
`--max-count=${bounds.maxCommits + 1}`,
|
|
172
|
-
"--find-renames",
|
|
173
|
-
"--name-status",
|
|
174
|
-
"-z",
|
|
175
|
-
"--format=%x1e%H%x1f%P%x00",
|
|
176
|
-
"--no-decorate",
|
|
177
|
-
]);
|
|
178
|
-
commits = parseLog(output);
|
|
179
|
-
}
|
|
180
|
-
catch (error) {
|
|
181
|
-
const reason = failureReason(error);
|
|
182
|
-
const availability = reason === "timeout" ? "timeout" : "unavailable";
|
|
183
|
-
return emptyResult(availability, bounds, commands, {
|
|
184
|
-
reason,
|
|
185
|
-
detail: "Bounded Git log failed; history signals were omitted",
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
if (commits.length > bounds.maxCommits) {
|
|
189
|
-
commits = commits.slice(0, bounds.maxCommits);
|
|
190
|
-
omissions.push({
|
|
191
|
-
reason: "commit-limit",
|
|
192
|
-
detail: `Older commits omitted by maxCommits=${bounds.maxCommits}`,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
const aliases = new Map(requested.map((file) => [file, new Set([file])]));
|
|
196
|
-
const commitsByFile = new Map(requested.map((file) => [file, new Set()]));
|
|
197
|
-
const shared = new Map();
|
|
198
|
-
for (const commit of commits) {
|
|
199
|
-
for (const change of commit.changes) {
|
|
200
|
-
if (!change.oldPath)
|
|
201
|
-
continue;
|
|
202
|
-
for (const aliasSet of aliases.values()) {
|
|
203
|
-
if (change.status.startsWith("R") && aliasSet.has(change.path))
|
|
204
|
-
aliasSet.add(change.oldPath);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
for (const file of requested) {
|
|
208
|
-
const aliasSet = aliases.get(file) ?? new Set();
|
|
209
|
-
const touchesTarget = commit.changes.some((change) => [change.path, change.oldPath].some((candidate) => candidate && aliasSet.has(candidate)));
|
|
210
|
-
if (!touchesTarget)
|
|
211
|
-
continue;
|
|
212
|
-
commitsByFile.get(file)?.add(commit.hash);
|
|
213
|
-
for (const change of commit.changes) {
|
|
214
|
-
for (const relatedFile of [change.path, change.oldPath]) {
|
|
215
|
-
if (!relatedFile || aliasSet.has(relatedFile))
|
|
216
|
-
continue;
|
|
217
|
-
const key = `${file}\0${relatedFile}`;
|
|
218
|
-
let hashes = shared.get(key);
|
|
219
|
-
if (!hashes) {
|
|
220
|
-
hashes = new Set();
|
|
221
|
-
shared.set(key, hashes);
|
|
222
|
-
}
|
|
223
|
-
hashes.add(commit.hash);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
const allCoChange = [...shared.entries()]
|
|
229
|
-
.map(([key, hashes]) => {
|
|
230
|
-
const [file, relatedFile] = key.split("\0");
|
|
231
|
-
return {
|
|
232
|
-
file,
|
|
233
|
-
relatedFile,
|
|
234
|
-
sharedCommitCount: hashes.size,
|
|
235
|
-
commits: [...hashes].sort(),
|
|
236
|
-
};
|
|
237
|
-
})
|
|
238
|
-
.sort((a, b) => b.sharedCommitCount - a.sharedCommitCount ||
|
|
239
|
-
a.file.localeCompare(b.file) ||
|
|
240
|
-
a.relatedFile.localeCompare(b.relatedFile));
|
|
241
|
-
const coChange = allCoChange.slice(0, bounds.maxRelatedFiles);
|
|
242
|
-
if (coChange.length < allCoChange.length) {
|
|
243
|
-
omissions.push({
|
|
244
|
-
reason: "related-file-limit",
|
|
245
|
-
detail: `${allCoChange.length - coChange.length} co-change fact(s) omitted by maxRelatedFiles=${bounds.maxRelatedFiles}`,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
const churn = requested.map((file) => {
|
|
249
|
-
const hashes = [...(commitsByFile.get(file) ?? [])].sort();
|
|
250
|
-
return { file, commitCount: hashes.length, commits: hashes };
|
|
251
|
-
});
|
|
252
|
-
const coupling = coChange.map((fact) => {
|
|
253
|
-
const fileCommitCount = commitsByFile.get(fact.file)?.size ?? 0;
|
|
254
|
-
return {
|
|
255
|
-
file: fact.file,
|
|
256
|
-
relatedFile: fact.relatedFile,
|
|
257
|
-
sharedCommitCount: fact.sharedCommitCount,
|
|
258
|
-
fileCommitCount,
|
|
259
|
-
ratio: fileCommitCount === 0 ? 0 : Number((fact.sharedCommitCount / fileCommitCount).toFixed(4)),
|
|
260
|
-
};
|
|
261
|
-
});
|
|
262
|
-
const result = {
|
|
263
|
-
availability: shallow ? "shallow" : "available",
|
|
264
|
-
bounds,
|
|
265
|
-
commands,
|
|
266
|
-
commitsAnalyzed: commits.length,
|
|
267
|
-
filesAnalyzed: requested.length,
|
|
268
|
-
truncated: omissions.some((item) => item.reason.endsWith("limit")),
|
|
269
|
-
churn,
|
|
270
|
-
coChange,
|
|
271
|
-
coupling,
|
|
272
|
-
omissions,
|
|
273
|
-
interpretation: INTERPRETATION,
|
|
274
|
-
confidence: "exact-bounded-git-history",
|
|
275
|
-
};
|
|
276
|
-
if (!shallow) {
|
|
277
|
-
historyCache.set(cacheKey, structuredClone(result));
|
|
278
|
-
while (historyCache.size > HISTORY_CACHE_LIMIT) {
|
|
279
|
-
const oldest = historyCache.keys().next().value;
|
|
280
|
-
if (oldest === undefined)
|
|
281
|
-
break;
|
|
282
|
-
historyCache.delete(oldest);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
return result;
|
|
286
|
-
}
|
|
287
|
-
export function clearGitHistorySignalCache() {
|
|
288
|
-
historyCache.clear();
|
|
289
|
-
}
|