@dailephd/my-dev-kit-lab 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ export 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
+ }
@@ -1,6 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { countEstimatedTokens, countTextChars, tokenCountMethod } from "../core/countTokens.js";
3
3
  import { runMeasuredCommand } from "../core/runMeasuredCommand.js";
4
+ import { interpretToolVersionOutput } from "./indexSnapshot.js";
5
+ const VERSION_PROBE_TIMEOUT_MS = 30_000;
4
6
  function parseJsonIfPossible(text) {
5
7
  try {
6
8
  return JSON.parse(text);
@@ -89,6 +91,38 @@ export async function buildMyDevKitIndex(options) {
89
91
  command
90
92
  };
91
93
  }
94
+ /**
95
+ * Runs `<kit-command> --version` once through the shared measured-command runner and returns
96
+ * bounded tool evidence. Kept separate from `buildMyDevKitIndex` so it never counts toward index
97
+ * duration. An unsupported, failing, slow, or empty probe is explicit `unavailable` evidence; it
98
+ * never throws and never fails the caller.
99
+ */
100
+ export async function probeMyDevKitVersion(options) {
101
+ try {
102
+ const command = await runMeasuredCommand({
103
+ commandId: "version",
104
+ commandString: options.kitCommand,
105
+ cwd: process.cwd(),
106
+ outDir: options.commandsDir,
107
+ extraArgs: ["--version"],
108
+ timeoutMs: VERSION_PROBE_TIMEOUT_MS
109
+ });
110
+ return interpretToolVersionOutput({
111
+ ok: command.ok,
112
+ exitCode: command.exitCode,
113
+ stdout: command.stdout,
114
+ error: command.error
115
+ });
116
+ }
117
+ catch (error) {
118
+ return interpretToolVersionOutput({
119
+ ok: false,
120
+ exitCode: null,
121
+ stdout: "",
122
+ error: error instanceof Error ? error.message : String(error)
123
+ });
124
+ }
125
+ }
92
126
  /**
93
127
  * Runs search -> lookup -> slice -> source against an already prepared index. Never invokes
94
128
  * `index`; `durationMs` covers retrieval only.