@dailephd/my-dev-kit-lab 0.5.1 → 0.6.0
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 +28 -1
- package/README.md +23 -8
- package/dist/src/agents/adapters/claudeAdapter.js +47 -4
- package/dist/src/agents/adapters/codexAdapter.js +64 -5
- package/dist/src/cli/help.js +22 -1
- package/dist/src/commands/runExperimentDescribeCommand.js +6 -1
- package/dist/src/commands/runExperimentRunCommand.js +87 -5
- package/dist/src/commands/runWarmIndexCampaignPresentation.js +64 -0
- package/dist/src/core/runMeasuredCommand.js +49 -10
- package/dist/src/evaluation/indexFreshness.js +199 -0
- package/dist/src/evaluation/indexSnapshot.js +393 -0
- package/dist/src/evaluation/runMyDevKitRetrieval.js +34 -0
- package/dist/src/experiments/plugins/warmIndexReuse/agentEvaluation.js +233 -0
- package/dist/src/experiments/plugins/warmIndexReuse/campaignPresets.js +83 -0
- package/dist/src/experiments/plugins/warmIndexReuse/config.js +51 -0
- package/dist/src/experiments/plugins/warmIndexReuse/execution.js +15 -0
- package/dist/src/experiments/plugins/warmIndexReuse/executionArtifact.js +2 -0
- package/dist/src/experiments/plugins/warmIndexReuse/fakeAgentEvaluation.js +6 -118
- package/dist/src/experiments/plugins/warmIndexReuse/index.js +3 -1
- package/dist/src/experiments/plugins/warmIndexReuse/metrics.js +35 -11
- package/dist/src/experiments/plugins/warmIndexReuse/plugin.js +50 -14
- package/dist/src/experiments/plugins/warmIndexReuse/realAgentPrompt.js +52 -0
- package/dist/src/experiments/plugins/warmIndexReuse/selection.js +35 -0
- package/dist/src/experiments/plugins/warmIndexReuse/warmIndexSession.js +18 -3
- package/dist/src/gallery/index.js +1 -0
- package/dist/src/gallery/writeGalleryManifest.js +5 -1
- package/dist/src/gallery/writeWarmIndexCampaignGallery.js +116 -0
- package/dist/src/plots/buildWarmIndexPlotData.js +21 -2
- package/dist/src/report/experiments/buildPluginExperimentReport.js +47 -8
- package/dist/src/report/experiments/buildWarmIndexReuseReport.js +190 -13
- package/dist/src/report/experiments/renderPluginExperimentReportText.js +90 -12
- package/dist/src/report/experiments/renderWarmIndexReuseHtml.js +114 -14
- package/dist/src/report/experiments/warmIndexReuseReportModel.js +3 -0
- package/docs/METRICS.md +39 -2
- package/package.json +1 -1
|
@@ -41,13 +41,15 @@ export async function runMeasuredCommand(options) {
|
|
|
41
41
|
let spawnError;
|
|
42
42
|
let timedOut = false;
|
|
43
43
|
let timeout;
|
|
44
|
+
let killGraceTimeout;
|
|
45
|
+
let settled = false;
|
|
44
46
|
let child;
|
|
45
47
|
try {
|
|
46
48
|
child = spawn(executable, args, {
|
|
47
49
|
cwd: options.cwd,
|
|
48
50
|
env: { ...process.env, ...options.env },
|
|
49
51
|
shell: false,
|
|
50
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
52
|
+
stdio: options.stdinText !== undefined ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"]
|
|
51
53
|
});
|
|
52
54
|
}
|
|
53
55
|
catch (error) {
|
|
@@ -80,6 +82,7 @@ export async function runMeasuredCommand(options) {
|
|
|
80
82
|
]).then(() => resolve(measured));
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
85
|
+
// stdout/stderr are always piped in both stdio branches above.
|
|
83
86
|
child.stdout.on("data", (chunk) => {
|
|
84
87
|
stdout += String(chunk);
|
|
85
88
|
});
|
|
@@ -89,17 +92,40 @@ export async function runMeasuredCommand(options) {
|
|
|
89
92
|
child.on("error", (error) => {
|
|
90
93
|
spawnError = error.message;
|
|
91
94
|
});
|
|
92
|
-
if (options.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
spawnError = `
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
if (options.stdinText !== undefined) {
|
|
96
|
+
// A child that exits or closes stdin before we write must not crash the process (EPIPE).
|
|
97
|
+
child.stdin?.on("error", (error) => {
|
|
98
|
+
spawnError = spawnError ?? `Failed to write to command stdin: ${error.message}`;
|
|
99
|
+
});
|
|
100
|
+
child.stdin?.write(options.stdinText, "utf8");
|
|
101
|
+
child.stdin?.end();
|
|
98
102
|
}
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
// forceTerminateProcess is fire-and-forget (it never confirms the target process tree actually
|
|
104
|
+
// exited: on Windows it spawns a detached `taskkill /T /F` and does not await or check its
|
|
105
|
+
// result). That is normally sufficient, but a grandchild process spawned through an
|
|
106
|
+
// intermediate shim (for example a Windows .cmd launcher) can occasionally outlive an
|
|
107
|
+
// already-killed intermediate process. Without a bounded fallback, a caller whose kill silently
|
|
108
|
+
// failed would await this promise forever instead of receiving the already-known timeout error.
|
|
109
|
+
const KILL_GRACE_MS = 3000;
|
|
110
|
+
const settle = async (exitCode) => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
settled = true;
|
|
114
|
+
if (timeout)
|
|
101
115
|
clearTimeout(timeout);
|
|
102
|
-
|
|
116
|
+
if (killGraceTimeout)
|
|
117
|
+
clearTimeout(killGraceTimeout);
|
|
118
|
+
// The caller's `scripts/cli.ts` entry point sets `process.exitCode` rather than calling
|
|
119
|
+
// `process.exit()`, so the process only exits once Node's event loop actually drains. A
|
|
120
|
+
// still-open child stdio pipe -- the same situation the kill-grace fallback above exists for
|
|
121
|
+
// -- would otherwise keep this process alive forever even though this promise has already
|
|
122
|
+
// resolved. Detaching is safe to do unconditionally: once "close" has fired the streams are
|
|
123
|
+
// already ended, so this is a harmless no-op on the normal (non-fallback) path.
|
|
124
|
+
child.stdout?.destroy();
|
|
125
|
+
child.stderr?.destroy();
|
|
126
|
+
child.stdin?.destroy();
|
|
127
|
+
child.removeAllListeners();
|
|
128
|
+
child.unref();
|
|
103
129
|
const endedAt = new Date().toISOString();
|
|
104
130
|
const durationMs = Date.now() - started;
|
|
105
131
|
await writeArtifact(stdoutPath, stdout);
|
|
@@ -125,6 +151,19 @@ export async function runMeasuredCommand(options) {
|
|
|
125
151
|
};
|
|
126
152
|
await writeArtifact(telemetryPath, JSON.stringify(measured, null, 2));
|
|
127
153
|
resolve(measured);
|
|
154
|
+
};
|
|
155
|
+
if (options.timeoutMs !== undefined) {
|
|
156
|
+
timeout = setTimeout(() => {
|
|
157
|
+
timedOut = true;
|
|
158
|
+
spawnError = `Command timed out after ${options.timeoutMs}ms.`;
|
|
159
|
+
forceTerminateProcess(child.pid);
|
|
160
|
+
killGraceTimeout = setTimeout(() => {
|
|
161
|
+
void settle(null);
|
|
162
|
+
}, KILL_GRACE_MS);
|
|
163
|
+
}, options.timeoutMs);
|
|
164
|
+
}
|
|
165
|
+
child.on("close", (exitCode) => {
|
|
166
|
+
void settle(exitCode);
|
|
128
167
|
});
|
|
129
168
|
});
|
|
130
169
|
return result;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { realpath } from "node:fs/promises";
|
|
2
|
+
import { compareCodeUnits, mapInBatches, resolveIndexedFilePath, snapshotIndexedFile, } from "./indexSnapshot.js";
|
|
3
|
+
export const INDEX_FRESHNESS_SCHEMA_VERSION = "my-dev-kit-lab-index-freshness-v1";
|
|
4
|
+
const MAX_CHANGES = 100;
|
|
5
|
+
const MAX_UNRESOLVED = 50;
|
|
6
|
+
const MAX_REPORTED_PATH_LENGTH = 260;
|
|
7
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
8
|
+
/**
|
|
9
|
+
* Policy: the four-state freshness decision table. `fresh` requires a complete baseline and a
|
|
10
|
+
* complete comparison with no confirmed change; a confirmed change is `stale` only when the
|
|
11
|
+
* comparison was complete, otherwise `partially-stale`; without a confirmed change an incomplete
|
|
12
|
+
* comparison is `unknown`. Never converts uncertainty to `fresh`. Pure.
|
|
13
|
+
*/
|
|
14
|
+
export function classifyIndexFreshness(input) {
|
|
15
|
+
if (input.baselineSnapshotStatus === "unavailable") {
|
|
16
|
+
return "unknown";
|
|
17
|
+
}
|
|
18
|
+
const comparisonComplete = input.baselineSnapshotStatus === "complete" && input.unresolvedCount === 0;
|
|
19
|
+
if (input.confirmedChangeCount > 0) {
|
|
20
|
+
return comparisonComplete ? "stale" : "partially-stale";
|
|
21
|
+
}
|
|
22
|
+
return comparisonComplete ? "fresh" : "unknown";
|
|
23
|
+
}
|
|
24
|
+
function boundedText(value) {
|
|
25
|
+
return value.length > MAX_REPORTED_PATH_LENGTH ? `${value.slice(0, MAX_REPORTED_PATH_LENGTH)}...` : value;
|
|
26
|
+
}
|
|
27
|
+
function isValidBaselineEntry(entry) {
|
|
28
|
+
if (typeof entry !== "object" || entry === null)
|
|
29
|
+
return false;
|
|
30
|
+
const candidate = entry;
|
|
31
|
+
return (typeof candidate.path === "string" &&
|
|
32
|
+
candidate.path.length > 0 &&
|
|
33
|
+
typeof candidate.sha256 === "string" &&
|
|
34
|
+
SHA256_PATTERN.test(candidate.sha256) &&
|
|
35
|
+
typeof candidate.sizeBytes === "number" &&
|
|
36
|
+
typeof candidate.modifiedAt === "string");
|
|
37
|
+
}
|
|
38
|
+
async function compareIndexedFile(targetRoot, realTargetRoot, sourceRoots, entry) {
|
|
39
|
+
if (!isValidBaselineEntry(entry)) {
|
|
40
|
+
const listedPath = typeof entry?.path === "string" ? boundedText(entry.path) : null;
|
|
41
|
+
return {
|
|
42
|
+
kind: "unresolved",
|
|
43
|
+
unresolved: {
|
|
44
|
+
path: listedPath,
|
|
45
|
+
reasonCode: "unsupported-snapshot-entry",
|
|
46
|
+
message: "Snapshot entry lacks a path, SHA-256, size, or modified time.",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const resolution = resolveIndexedFilePath(targetRoot, sourceRoots, entry.path);
|
|
51
|
+
if (!resolution.ok) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "unresolved",
|
|
54
|
+
unresolved: { path: boundedText(entry.path), reasonCode: "unsafe-path", message: `Path cannot be safely resolved (${resolution.reason}).` },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const current = await snapshotIndexedFile(targetRoot, realTargetRoot, resolution.relativePath);
|
|
58
|
+
if (!current.ok) {
|
|
59
|
+
if (current.reason === "missing") {
|
|
60
|
+
return {
|
|
61
|
+
kind: "change",
|
|
62
|
+
change: {
|
|
63
|
+
path: entry.path,
|
|
64
|
+
changeType: "missing",
|
|
65
|
+
baselineSha256: entry.sha256,
|
|
66
|
+
baselineSizeBytes: entry.sizeBytes,
|
|
67
|
+
baselineModifiedAt: entry.modifiedAt,
|
|
68
|
+
currentSha256: null,
|
|
69
|
+
currentSizeBytes: null,
|
|
70
|
+
currentModifiedAt: null,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const unsafe = current.reason === "outside-target-or-source-roots" || current.reason === "invalid-path";
|
|
75
|
+
return {
|
|
76
|
+
kind: "unresolved",
|
|
77
|
+
unresolved: {
|
|
78
|
+
path: boundedText(entry.path),
|
|
79
|
+
reasonCode: unsafe ? "unsafe-path" : "file-read-failed",
|
|
80
|
+
message: unsafe ? "Current file resolves outside the target." : `Current file could not be read as a regular file (${current.reason}).`,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (current.file.sha256 === entry.sha256) {
|
|
85
|
+
return { kind: "unchanged" };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
kind: "change",
|
|
89
|
+
change: {
|
|
90
|
+
path: entry.path,
|
|
91
|
+
changeType: "modified",
|
|
92
|
+
baselineSha256: entry.sha256,
|
|
93
|
+
baselineSizeBytes: entry.sizeBytes,
|
|
94
|
+
baselineModifiedAt: entry.modifiedAt,
|
|
95
|
+
currentSha256: current.file.sha256,
|
|
96
|
+
currentSizeBytes: current.file.sizeBytes,
|
|
97
|
+
currentModifiedAt: current.file.modifiedAt,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Compares the files an index snapshot lists against their current state. Read-only: never
|
|
103
|
+
* modifies the target, invokes my-dev-kit, re-indexes, or retrieves. Files that exist now but are
|
|
104
|
+
* absent from the snapshot are deliberately ignored — the snapshot records the exact indexed set,
|
|
105
|
+
* not everything under the source roots, so "added" files cannot be inferred truthfully. Never
|
|
106
|
+
* throws; an unassessable input yields an explicit `unknown` assessment.
|
|
107
|
+
*/
|
|
108
|
+
export async function assessIndexFreshness(options) {
|
|
109
|
+
const { snapshot, targetRoot, sourceRoots } = options;
|
|
110
|
+
const assessedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
111
|
+
const baselineSnapshotStatus = snapshot?.status ?? "unavailable";
|
|
112
|
+
const changes = [];
|
|
113
|
+
const unresolved = [];
|
|
114
|
+
let unchangedFileCount = 0;
|
|
115
|
+
let unresolvedTotal = 0;
|
|
116
|
+
const addUnresolved = (entry, count = 1) => {
|
|
117
|
+
unresolvedTotal += count;
|
|
118
|
+
unresolved.push(entry);
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
if (!snapshot || snapshot.status === "unavailable") {
|
|
122
|
+
addUnresolved({
|
|
123
|
+
path: null,
|
|
124
|
+
reasonCode: "snapshot-unavailable",
|
|
125
|
+
message: snapshot?.unavailable ? boundedText(snapshot.unavailable.message) : "No index snapshot is available for this session.",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
let realTargetRoot;
|
|
130
|
+
try {
|
|
131
|
+
realTargetRoot = await realpath(targetRoot);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
addUnresolved({ path: null, reasonCode: "file-read-failed", message: "Target root could not be resolved." });
|
|
135
|
+
}
|
|
136
|
+
if (snapshot.status === "complete" && snapshot.files.length !== snapshot.indexedFileCount) {
|
|
137
|
+
addUnresolved({
|
|
138
|
+
path: null,
|
|
139
|
+
reasonCode: "unsupported-snapshot-entry",
|
|
140
|
+
message: `Complete snapshot lists ${snapshot.indexedFileCount} indexed files but carries ${snapshot.files.length} entries.`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const entry of snapshot.unresolvedFiles) {
|
|
144
|
+
addUnresolved({
|
|
145
|
+
path: entry.path,
|
|
146
|
+
reasonCode: "snapshot-file-unresolved",
|
|
147
|
+
message: `The index snapshot could not hash this indexed file (${entry.reason}).`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Unresolved entries the snapshot itself truncated still count toward the total.
|
|
151
|
+
unresolvedTotal += Math.max(0, snapshot.unresolvedFileCount - snapshot.unresolvedFiles.length);
|
|
152
|
+
if (realTargetRoot !== undefined) {
|
|
153
|
+
const rootForComparison = realTargetRoot;
|
|
154
|
+
const comparisons = await mapInBatches(snapshot.files, (entry) => compareIndexedFile(targetRoot, rootForComparison, sourceRoots, entry));
|
|
155
|
+
for (const comparison of comparisons) {
|
|
156
|
+
if (comparison.kind === "unchanged")
|
|
157
|
+
unchangedFileCount += 1;
|
|
158
|
+
else if (comparison.kind === "change")
|
|
159
|
+
changes.push(comparison.change);
|
|
160
|
+
else
|
|
161
|
+
addUnresolved(comparison.unresolved);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
addUnresolved({
|
|
168
|
+
path: null,
|
|
169
|
+
reasonCode: "file-read-failed",
|
|
170
|
+
message: boundedText(`Freshness comparison failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
changes.sort((left, right) => compareCodeUnits(left.path, right.path) || compareCodeUnits(left.changeType, right.changeType));
|
|
174
|
+
unresolved.sort((left, right) => compareCodeUnits(left.path ?? "", right.path ?? "") || compareCodeUnits(left.reasonCode, right.reasonCode) || compareCodeUnits(left.message, right.message));
|
|
175
|
+
const modifiedCount = changes.filter((change) => change.changeType === "modified").length;
|
|
176
|
+
const missingCount = changes.length - modifiedCount;
|
|
177
|
+
const warnings = [];
|
|
178
|
+
if (changes.length > MAX_CHANGES)
|
|
179
|
+
warnings.push(`Change list truncated to ${MAX_CHANGES} of ${changes.length} entries.`);
|
|
180
|
+
if (unresolved.length > MAX_UNRESOLVED)
|
|
181
|
+
warnings.push(`Unresolved list truncated to ${MAX_UNRESOLVED} of ${unresolved.length} entries.`);
|
|
182
|
+
return {
|
|
183
|
+
schemaVersion: INDEX_FRESHNESS_SCHEMA_VERSION,
|
|
184
|
+
status: classifyIndexFreshness({ baselineSnapshotStatus, confirmedChangeCount: changes.length, unresolvedCount: unresolvedTotal }),
|
|
185
|
+
assessedAt,
|
|
186
|
+
baselineSnapshotStatus,
|
|
187
|
+
indexedFileCount: snapshot?.indexedFileCount ?? 0,
|
|
188
|
+
comparableFileCount: unchangedFileCount + modifiedCount,
|
|
189
|
+
unchangedFileCount,
|
|
190
|
+
changedFileCount: modifiedCount,
|
|
191
|
+
missingFileCount: missingCount,
|
|
192
|
+
unresolvedFileCount: unresolvedTotal,
|
|
193
|
+
changes: changes.slice(0, MAX_CHANGES),
|
|
194
|
+
changesTruncated: changes.length > MAX_CHANGES,
|
|
195
|
+
unresolved: unresolved.slice(0, MAX_UNRESOLVED),
|
|
196
|
+
unresolvedTruncated: unresolvedTotal > Math.min(unresolved.length, MAX_UNRESOLVED),
|
|
197
|
+
warnings,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { relativeWithinRoot, resolveWithinRoot } from "../core/pathSafety.js";
|
|
6
|
+
export const INDEX_SNAPSHOT_SCHEMA_VERSION = "my-dev-kit-lab-index-snapshot-v1";
|
|
7
|
+
const SUPPORTED_MANIFEST_ARTIFACT_KIND = "my-dev-kit-v1-manifest";
|
|
8
|
+
const SUPPORTED_MANIFEST_VERSION_PATTERN = /^1\./;
|
|
9
|
+
const MANIFEST_FILE = "manifest.json";
|
|
10
|
+
const MAX_ARTIFACT_ENTRIES = 512;
|
|
11
|
+
const MAX_UNRESOLVED_ENTRIES = 50;
|
|
12
|
+
const MAX_REPORTED_PATH_LENGTH = 260;
|
|
13
|
+
const HASH_CONCURRENCY = 32;
|
|
14
|
+
const MAX_TOOL_VERSION_LENGTH = 128;
|
|
15
|
+
const TOOL_VERSION_NOT_PROBED = {
|
|
16
|
+
name: "my-dev-kit",
|
|
17
|
+
version: null,
|
|
18
|
+
availability: "unavailable",
|
|
19
|
+
reason: "The my-dev-kit index manifest and index command output do not expose the tool version, and no --version probe result was supplied.",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Policy: turns the outcome of a `<kit-command> --version` probe into bounded tool evidence. Uses
|
|
23
|
+
* the first non-empty output line verbatim (no semver requirement); anything unsupported, empty,
|
|
24
|
+
* unprintable, or oversized is explicitly unavailable rather than guessed. Pure.
|
|
25
|
+
*/
|
|
26
|
+
export function interpretToolVersionOutput(probe) {
|
|
27
|
+
const unavailable = (reason) => ({
|
|
28
|
+
name: "my-dev-kit",
|
|
29
|
+
version: null,
|
|
30
|
+
availability: "unavailable",
|
|
31
|
+
reason: boundedText(reason),
|
|
32
|
+
});
|
|
33
|
+
if (!probe.ok) {
|
|
34
|
+
const detail = probe.error ? `: ${probe.error}` : "";
|
|
35
|
+
return unavailable(`The --version probe did not succeed (exit code ${probe.exitCode ?? "none"})${detail}`);
|
|
36
|
+
}
|
|
37
|
+
const line = probe.stdout.split(/\r?\n/).map((entry) => entry.trim()).find((entry) => entry.length > 0);
|
|
38
|
+
if (line === undefined) {
|
|
39
|
+
return unavailable("The --version probe produced no output.");
|
|
40
|
+
}
|
|
41
|
+
if (/[\u0000-\u001f\u007f]/.test(line)) {
|
|
42
|
+
return unavailable("The --version probe reported a non-printable value.");
|
|
43
|
+
}
|
|
44
|
+
if (line.length > MAX_TOOL_VERSION_LENGTH) {
|
|
45
|
+
return unavailable(`The --version probe reported a value longer than ${MAX_TOOL_VERSION_LENGTH} characters.`);
|
|
46
|
+
}
|
|
47
|
+
return { name: "my-dev-kit", version: line, availability: "available", reason: null };
|
|
48
|
+
}
|
|
49
|
+
function isRecord(value) {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
/** Locale- and platform-independent ordering so snapshots never depend on enumeration order. */
|
|
53
|
+
export function compareCodeUnits(left, right) {
|
|
54
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
55
|
+
}
|
|
56
|
+
function boundedText(value) {
|
|
57
|
+
return value.length > MAX_REPORTED_PATH_LENGTH ? `${value.slice(0, MAX_REPORTED_PATH_LENGTH)}...` : value;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Policy: validates the manifest against the supported my-dev-kit contract and the expected
|
|
61
|
+
* target. Pure; performs no filesystem access.
|
|
62
|
+
*/
|
|
63
|
+
export function interpretIndexManifest(manifest, expected) {
|
|
64
|
+
const unsupported = (message) => ({ ok: false, code: "manifest-unsupported", message });
|
|
65
|
+
if (!isRecord(manifest)) {
|
|
66
|
+
return unsupported("Index manifest is not a JSON object.");
|
|
67
|
+
}
|
|
68
|
+
if (manifest.artifactKind !== SUPPORTED_MANIFEST_ARTIFACT_KIND) {
|
|
69
|
+
return unsupported(`Index manifest artifactKind is not ${SUPPORTED_MANIFEST_ARTIFACT_KIND}.`);
|
|
70
|
+
}
|
|
71
|
+
if (typeof manifest.version !== "string" || !SUPPORTED_MANIFEST_VERSION_PATTERN.test(manifest.version)) {
|
|
72
|
+
return unsupported("Index manifest version is missing or not a supported 1.x version.");
|
|
73
|
+
}
|
|
74
|
+
const artifacts = manifest.artifacts;
|
|
75
|
+
const symbolIndexPath = isRecord(artifacts) ? artifacts.symbolIndex : undefined;
|
|
76
|
+
if (typeof symbolIndexPath !== "string" || symbolIndexPath.length === 0) {
|
|
77
|
+
return unsupported("Index manifest does not name a symbolIndex artifact.");
|
|
78
|
+
}
|
|
79
|
+
if (typeof manifest.projectRoot !== "string" || manifest.projectRoot.length === 0) {
|
|
80
|
+
return unsupported("Index manifest does not record a projectRoot.");
|
|
81
|
+
}
|
|
82
|
+
const sourceRoots = manifest.sourceRoots;
|
|
83
|
+
if (!Array.isArray(sourceRoots) || !sourceRoots.every((root) => typeof root === "string")) {
|
|
84
|
+
return unsupported("Index manifest does not record sourceRoots.");
|
|
85
|
+
}
|
|
86
|
+
const sameSourceRoots = sourceRoots.length === expected.sourceRoots.length && sourceRoots.every((root, index) => root === expected.sourceRoots[index]);
|
|
87
|
+
if (!sameSourceRoots) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
code: "manifest-target-mismatch",
|
|
91
|
+
message: `Index manifest source roots [${sourceRoots.join(", ")}] do not match [${expected.sourceRoots.join(", ")}].`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const summary = manifest.summary;
|
|
95
|
+
const manifestFileCount = isRecord(summary) && typeof summary.fileCount === "number" ? summary.fileCount : null;
|
|
96
|
+
return {
|
|
97
|
+
ok: true,
|
|
98
|
+
contract: {
|
|
99
|
+
manifestArtifactKind: manifest.artifactKind,
|
|
100
|
+
manifestVersion: manifest.version,
|
|
101
|
+
createdAt: typeof manifest.createdAt === "string" ? manifest.createdAt : null,
|
|
102
|
+
symbolIndexRelativePath: symbolIndexPath,
|
|
103
|
+
manifestFileCount,
|
|
104
|
+
sourceRoots: [...sourceRoots],
|
|
105
|
+
projectRoot: manifest.projectRoot,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Policy: extracts the exact indexed-file path list from a symbol-index artifact. The count the
|
|
111
|
+
* index declares must agree with the list; duplicates are rejected rather than merged.
|
|
112
|
+
*/
|
|
113
|
+
export function interpretSymbolIndexFiles(symbolIndex, manifestFileCount) {
|
|
114
|
+
const unsupported = (message) => ({ ok: false, code: "symbol-index-unsupported", message });
|
|
115
|
+
if (!isRecord(symbolIndex)) {
|
|
116
|
+
return unsupported("Symbol index is not a JSON object.");
|
|
117
|
+
}
|
|
118
|
+
if (typeof symbolIndex.schemaVersion !== "string") {
|
|
119
|
+
return unsupported("Symbol index does not record a schemaVersion.");
|
|
120
|
+
}
|
|
121
|
+
const files = symbolIndex.files;
|
|
122
|
+
if (!Array.isArray(files)) {
|
|
123
|
+
return unsupported("Symbol index does not list indexed files.");
|
|
124
|
+
}
|
|
125
|
+
const paths = [];
|
|
126
|
+
for (const entry of files) {
|
|
127
|
+
if (!isRecord(entry) || typeof entry.path !== "string" || entry.path.length === 0) {
|
|
128
|
+
return unsupported("Symbol index contains a file entry without a path.");
|
|
129
|
+
}
|
|
130
|
+
paths.push(entry.path);
|
|
131
|
+
}
|
|
132
|
+
if (new Set(paths).size !== paths.length) {
|
|
133
|
+
return unsupported("Symbol index lists the same file path more than once.");
|
|
134
|
+
}
|
|
135
|
+
const declaredCounts = [symbolIndex.fileCount, manifestFileCount].filter((count) => typeof count === "number");
|
|
136
|
+
if (declaredCounts.some((count) => count !== paths.length)) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
code: "indexed-file-count-mismatch",
|
|
140
|
+
message: `Symbol index lists ${paths.length} files but the index declares ${declaredCounts.join("/")}.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, schemaVersion: symbolIndex.schemaVersion, paths };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Policy: normalizes an index-listed path to a target-relative forward-slash path and confirms it
|
|
147
|
+
* stays inside the target and one of the configured source roots. Pure.
|
|
148
|
+
*/
|
|
149
|
+
export function resolveIndexedFilePath(targetRoot, sourceRoots, listedPath) {
|
|
150
|
+
const forward = listedPath.replace(/\\/g, "/");
|
|
151
|
+
if (forward.length === 0 || forward.includes("\0") || forward.startsWith("/") || /^[A-Za-z]:/.test(forward)) {
|
|
152
|
+
return { ok: false, reason: "invalid-path" };
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const absolute = resolveWithinRoot(targetRoot, forward);
|
|
156
|
+
const relativePath = relativeWithinRoot(targetRoot, absolute);
|
|
157
|
+
if (relativePath.length === 0) {
|
|
158
|
+
return { ok: false, reason: "invalid-path" };
|
|
159
|
+
}
|
|
160
|
+
const insideSourceRoot = sourceRoots.some((sourceRoot) => {
|
|
161
|
+
try {
|
|
162
|
+
relativeWithinRoot(resolveWithinRoot(targetRoot, sourceRoot), absolute);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
return insideSourceRoot ? { ok: true, relativePath } : { ok: false, reason: "outside-target-or-source-roots" };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { ok: false, reason: "outside-target-or-source-roots" };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function hashFile(absolutePath) {
|
|
176
|
+
const hash = createHash("sha256");
|
|
177
|
+
let sizeBytes = 0;
|
|
178
|
+
for await (const chunk of createReadStream(absolutePath)) {
|
|
179
|
+
const buffer = chunk;
|
|
180
|
+
sizeBytes += buffer.length;
|
|
181
|
+
hash.update(buffer);
|
|
182
|
+
}
|
|
183
|
+
return { sha256: hash.digest("hex"), sizeBytes };
|
|
184
|
+
}
|
|
185
|
+
/** Reads and hashes one target-relative file without leaving the target (symlinks are resolved first). */
|
|
186
|
+
export async function snapshotIndexedFile(targetRoot, realTargetRoot, relativePath) {
|
|
187
|
+
const absolutePath = path.resolve(targetRoot, relativePath);
|
|
188
|
+
try {
|
|
189
|
+
// A symlink must not carry the read outside the target.
|
|
190
|
+
const realPath = await realpath(absolutePath);
|
|
191
|
+
try {
|
|
192
|
+
relativeWithinRoot(realTargetRoot, realPath);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { ok: false, reason: "outside-target-or-source-roots" };
|
|
196
|
+
}
|
|
197
|
+
const stats = await stat(realPath);
|
|
198
|
+
if (!stats.isFile()) {
|
|
199
|
+
return { ok: false, reason: "not-a-file" };
|
|
200
|
+
}
|
|
201
|
+
const { sha256, sizeBytes } = await hashFile(realPath);
|
|
202
|
+
return { ok: true, file: { path: relativePath, sha256, sizeBytes, modifiedAt: stats.mtime.toISOString() } };
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
return { ok: false, reason: error.code === "ENOENT" ? "missing" : "unreadable" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function enumerateIndexArtifacts(indexDir) {
|
|
209
|
+
const artifacts = [];
|
|
210
|
+
let truncated = false;
|
|
211
|
+
const pending = [indexDir];
|
|
212
|
+
while (pending.length > 0) {
|
|
213
|
+
const directory = pending.pop();
|
|
214
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
215
|
+
const absolutePath = path.join(directory, entry.name);
|
|
216
|
+
if (entry.isDirectory()) {
|
|
217
|
+
pending.push(absolutePath);
|
|
218
|
+
}
|
|
219
|
+
else if (entry.isFile()) {
|
|
220
|
+
if (artifacts.length >= MAX_ARTIFACT_ENTRIES) {
|
|
221
|
+
truncated = true;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
artifacts.push({ path: relativeWithinRoot(indexDir, absolutePath), sizeBytes: (await stat(absolutePath)).size });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
artifacts.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
229
|
+
return { artifacts, truncated };
|
|
230
|
+
}
|
|
231
|
+
async function readJson(filePath) {
|
|
232
|
+
let text;
|
|
233
|
+
try {
|
|
234
|
+
text = await readFile(filePath, "utf8");
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
const missing = error.code === "ENOENT";
|
|
238
|
+
return { ok: false, missing, message: missing ? "file does not exist" : `file could not be read: ${error.message}` };
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
return { ok: true, value: JSON.parse(text) };
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
return { ok: false, missing: false, message: `file is not valid JSON: ${error.message}` };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** Runs `fn` over `items` with bounded concurrency, returning results in input order. */
|
|
248
|
+
export async function mapInBatches(items, fn) {
|
|
249
|
+
const results = [];
|
|
250
|
+
for (let start = 0; start < items.length; start += HASH_CONCURRENCY) {
|
|
251
|
+
results.push(...(await Promise.all(items.slice(start, start + HASH_CONCURRENCY).map(fn))));
|
|
252
|
+
}
|
|
253
|
+
return results;
|
|
254
|
+
}
|
|
255
|
+
function unavailableSnapshot(command, failure, tool, manifest = null) {
|
|
256
|
+
return {
|
|
257
|
+
schemaVersion: INDEX_SNAPSHOT_SCHEMA_VERSION,
|
|
258
|
+
status: "unavailable",
|
|
259
|
+
unavailable: { code: failure.code, message: boundedText(failure.message) },
|
|
260
|
+
manifest,
|
|
261
|
+
tool: { ...tool },
|
|
262
|
+
indexCommand: summarizeIndexCommand(command),
|
|
263
|
+
indexedFileCount: 0,
|
|
264
|
+
files: [],
|
|
265
|
+
unresolvedFileCount: 0,
|
|
266
|
+
unresolvedFiles: [],
|
|
267
|
+
artifacts: [],
|
|
268
|
+
artifactsTruncated: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function summarizeIndexCommand(command) {
|
|
272
|
+
return { commandString: command.commandString, executable: command.executable, args: [...command.args] };
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Captures bounded index-build evidence from an already built index: which files the index lists,
|
|
276
|
+
* their SHA-256 content identity, and the generated artifact inventory. Reads only the index
|
|
277
|
+
* directory and the listed files inside the target; runs no my-dev-kit command and never throws —
|
|
278
|
+
* an uninterpretable index yields an explicit `unavailable` snapshot instead of a guessed one.
|
|
279
|
+
* Performs no comparison and assigns no freshness.
|
|
280
|
+
*/
|
|
281
|
+
export async function captureIndexSnapshot(options) {
|
|
282
|
+
const { indexDir, targetRoot, sourceRoots, command } = options;
|
|
283
|
+
const tool = options.tool ?? TOOL_VERSION_NOT_PROBED;
|
|
284
|
+
try {
|
|
285
|
+
const manifestRead = await readJson(path.join(indexDir, MANIFEST_FILE));
|
|
286
|
+
if (!manifestRead.ok) {
|
|
287
|
+
return unavailableSnapshot(command, {
|
|
288
|
+
ok: false,
|
|
289
|
+
code: manifestRead.missing ? "manifest-missing" : "manifest-unreadable",
|
|
290
|
+
message: `Index manifest ${manifestRead.message}.`,
|
|
291
|
+
}, tool);
|
|
292
|
+
}
|
|
293
|
+
const manifest = interpretIndexManifest(manifestRead.value, { sourceRoots });
|
|
294
|
+
if (!manifest.ok) {
|
|
295
|
+
return unavailableSnapshot(command, manifest, tool);
|
|
296
|
+
}
|
|
297
|
+
const { contract } = manifest;
|
|
298
|
+
const realTargetRoot = await realpath(targetRoot);
|
|
299
|
+
let manifestRoot;
|
|
300
|
+
try {
|
|
301
|
+
manifestRoot = await realpath(contract.projectRoot);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
manifestRoot = path.resolve(contract.projectRoot);
|
|
305
|
+
}
|
|
306
|
+
if (path.relative(realTargetRoot, manifestRoot) !== "") {
|
|
307
|
+
return unavailableSnapshot(command, {
|
|
308
|
+
ok: false,
|
|
309
|
+
code: "manifest-target-mismatch",
|
|
310
|
+
message: "Index manifest projectRoot does not match the target root.",
|
|
311
|
+
}, tool);
|
|
312
|
+
}
|
|
313
|
+
let symbolIndexFile;
|
|
314
|
+
try {
|
|
315
|
+
symbolIndexFile = resolveWithinRoot(indexDir, contract.symbolIndexRelativePath);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return unavailableSnapshot(command, {
|
|
319
|
+
ok: false,
|
|
320
|
+
code: "manifest-unsupported",
|
|
321
|
+
message: "Index manifest symbolIndex path escapes the index directory.",
|
|
322
|
+
}, tool);
|
|
323
|
+
}
|
|
324
|
+
const symbolIndexRead = await readJson(symbolIndexFile);
|
|
325
|
+
if (!symbolIndexRead.ok) {
|
|
326
|
+
return unavailableSnapshot(command, {
|
|
327
|
+
ok: false,
|
|
328
|
+
code: symbolIndexRead.missing ? "symbol-index-missing" : "symbol-index-unreadable",
|
|
329
|
+
message: `Symbol index ${symbolIndexRead.message}.`,
|
|
330
|
+
}, tool);
|
|
331
|
+
}
|
|
332
|
+
const symbolIndex = interpretSymbolIndexFiles(symbolIndexRead.value, contract.manifestFileCount);
|
|
333
|
+
const manifestSummary = {
|
|
334
|
+
path: MANIFEST_FILE,
|
|
335
|
+
artifactKind: contract.manifestArtifactKind,
|
|
336
|
+
schemaVersion: contract.manifestVersion,
|
|
337
|
+
createdAt: contract.createdAt,
|
|
338
|
+
symbolIndexPath: relativeWithinRoot(indexDir, symbolIndexFile),
|
|
339
|
+
symbolIndexSchemaVersion: symbolIndex.ok ? symbolIndex.schemaVersion : "unknown",
|
|
340
|
+
};
|
|
341
|
+
if (!symbolIndex.ok) {
|
|
342
|
+
return unavailableSnapshot(command, symbolIndex, tool, manifestSummary);
|
|
343
|
+
}
|
|
344
|
+
const resolved = symbolIndex.paths.map((listed) => ({ listed, resolution: resolveIndexedFilePath(targetRoot, sourceRoots, listed) }));
|
|
345
|
+
const unresolved = [];
|
|
346
|
+
const toHash = [];
|
|
347
|
+
for (const { listed, resolution } of resolved) {
|
|
348
|
+
if (resolution.ok) {
|
|
349
|
+
toHash.push(resolution.relativePath);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
unresolved.push({ path: boundedText(listed), reason: resolution.reason });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const files = [];
|
|
356
|
+
for (let start = 0; start < toHash.length; start += HASH_CONCURRENCY) {
|
|
357
|
+
const batch = toHash.slice(start, start + HASH_CONCURRENCY);
|
|
358
|
+
const results = await Promise.all(batch.map((relativePath) => snapshotIndexedFile(targetRoot, realTargetRoot, relativePath)));
|
|
359
|
+
results.forEach((result, position) => {
|
|
360
|
+
if (result.ok) {
|
|
361
|
+
files.push(result.file);
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
unresolved.push({ path: boundedText(batch[position]), reason: result.reason });
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
files.sort((left, right) => compareCodeUnits(left.path, right.path));
|
|
369
|
+
unresolved.sort((left, right) => compareCodeUnits(left.path, right.path) || compareCodeUnits(left.reason, right.reason));
|
|
370
|
+
const inventory = await enumerateIndexArtifacts(indexDir);
|
|
371
|
+
return {
|
|
372
|
+
schemaVersion: INDEX_SNAPSHOT_SCHEMA_VERSION,
|
|
373
|
+
status: unresolved.length === 0 ? "complete" : "partial",
|
|
374
|
+
unavailable: null,
|
|
375
|
+
manifest: manifestSummary,
|
|
376
|
+
tool: { ...tool },
|
|
377
|
+
indexCommand: summarizeIndexCommand(command),
|
|
378
|
+
indexedFileCount: symbolIndex.paths.length,
|
|
379
|
+
files,
|
|
380
|
+
unresolvedFileCount: unresolved.length,
|
|
381
|
+
unresolvedFiles: unresolved.slice(0, MAX_UNRESOLVED_ENTRIES),
|
|
382
|
+
artifacts: inventory.artifacts,
|
|
383
|
+
artifactsTruncated: inventory.truncated,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
return unavailableSnapshot(command, {
|
|
388
|
+
ok: false,
|
|
389
|
+
code: "snapshot-capture-failed",
|
|
390
|
+
message: `Index snapshot capture failed: ${error.message}`,
|
|
391
|
+
}, tool);
|
|
392
|
+
}
|
|
393
|
+
}
|