Skip to content

Commit 7d3ec44

Browse files
committed
Emit cache evidence for dependency search packets
1 parent 256b15b commit 7d3ec44

6 files changed

Lines changed: 156 additions & 1 deletion

File tree

schemas/semantic-search-packet.v1.schema.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@
144144
"runtimeCost": {
145145
"$ref": "#/$defs/runtimeCost"
146146
},
147+
"cache": {
148+
"$ref": "#/$defs/cache"
149+
},
147150
"searchSynthesis": {
148151
"$ref": "#/$defs/searchSynthesis"
149152
},
@@ -1186,6 +1189,43 @@
11861189
}
11871190
}
11881191
},
1192+
"cache": {
1193+
"type": "object",
1194+
"description": "Provider-owned cache invalidation facts for replay-safe search packets. Providers decide which workspace files affect the packet; clients only validate and compare these hashes.",
1195+
"additionalProperties": false,
1196+
"required": [
1197+
"fileHashes"
1198+
],
1199+
"properties": {
1200+
"fileHashes": {
1201+
"type": "array",
1202+
"minItems": 1,
1203+
"items": {
1204+
"$ref": "#/$defs/fileHash"
1205+
}
1206+
},
1207+
"rawSourceStored": {
1208+
"const": false
1209+
}
1210+
}
1211+
},
1212+
"fileHash": {
1213+
"type": "object",
1214+
"additionalProperties": false,
1215+
"required": [
1216+
"path",
1217+
"sha256"
1218+
],
1219+
"properties": {
1220+
"path": {
1221+
"$ref": "#/$defs/projectPath"
1222+
},
1223+
"sha256": {
1224+
"type": "string",
1225+
"pattern": "^[a-f0-9]{64}$"
1226+
}
1227+
}
1228+
},
11891229
"queryScope": {
11901230
"type": "object",
11911231
"additionalProperties": false,

src/cli/semantic-search/dependency.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
* Packet payload builders for dependency and deps semantic-search views.
33
*/
44

5+
import crypto from "node:crypto";
6+
import fs from "node:fs";
7+
import path from "node:path";
8+
59
import type { TypeScriptHarnessReport } from "../../model.js";
610
import {
711
dependencyApiHit,
@@ -25,7 +29,12 @@ import {
2529
ownersForHits,
2630
packageRootFromSpecifier,
2731
} from "./hits.js";
28-
import type { SemanticSearchBuildOptions, SemanticSearchPacketPayload } from "./types.js";
32+
import type {
33+
SemanticSearchBuildOptions,
34+
SemanticSearchCache,
35+
SemanticSearchHit,
36+
SemanticSearchPacketPayload,
37+
} from "./types.js";
2938

3039
export function buildDependencyPacketPayload(
3140
report: TypeScriptHarnessReport,
@@ -45,6 +54,7 @@ export function buildDependencyPacketPayload(
4554
report,
4655
matches.map((match) => dependencyHit(report, match)),
4756
);
57+
const cache = dependencySearchCache(report, hits);
4858
return {
4959
header: {
5060
kind: "search-dependency",
@@ -64,6 +74,7 @@ export function buildDependencyPacketPayload(
6474
],
6575
edges,
6676
owners,
77+
...(cache ? { cache } : {}),
6778
hits,
6879
findings: [],
6980
nextActions: [
@@ -134,6 +145,11 @@ export function buildDepsPacketPayload(
134145
];
135146
const edges = matches.map((match) => dependencyEdge(report, match));
136147
const owners = ownersForHits(report, hits);
148+
const cache = dependencySearchCache(
149+
report,
150+
hits,
151+
dependencyWorkspaceVersionPaths(workspaceVersion.workspaceVersionSource),
152+
);
137153
return {
138154
header: {
139155
kind: "search-deps",
@@ -174,6 +190,7 @@ export function buildDepsPacketPayload(
174190
],
175191
edges,
176192
owners,
193+
...(cache ? { cache } : {}),
177194
hits,
178195
findings: [],
179196
nextActions:
@@ -222,3 +239,59 @@ export function buildDepsPacketPayload(
222239
],
223240
};
224241
}
242+
243+
function dependencySearchCache(
244+
report: TypeScriptHarnessReport,
245+
hits: readonly SemanticSearchHit[],
246+
extraPaths: readonly string[] = [],
247+
): SemanticSearchCache | undefined {
248+
const paths = new Set<string>();
249+
for (const hit of hits) {
250+
paths.add(hit.location.path);
251+
}
252+
for (const extraPath of extraPaths) {
253+
paths.add(extraPath);
254+
}
255+
const fileHashes = [...paths].sort().flatMap((relativePath) => {
256+
const fileHash = hashWorkspaceFile(report.reasoningTree.projectRoot, relativePath);
257+
return fileHash === undefined ? [] : [fileHash];
258+
});
259+
if (fileHashes.length === 0) return undefined;
260+
return { fileHashes, rawSourceStored: false };
261+
}
262+
263+
function dependencyWorkspaceVersionPaths(
264+
source: "package-lock" | "package-json" | undefined,
265+
): readonly string[] {
266+
switch (source) {
267+
case "package-lock":
268+
return ["package-lock.json"];
269+
case "package-json":
270+
return ["package.json"];
271+
default:
272+
return [];
273+
}
274+
}
275+
276+
function hashWorkspaceFile(
277+
projectRoot: string,
278+
relativePath: string,
279+
): { readonly path: string; readonly sha256: string } | undefined {
280+
const normalizedPath = relativePath.split(path.sep).join("/");
281+
if (!isSafeRelativePath(normalizedPath)) return undefined;
282+
const root = path.resolve(projectRoot);
283+
const absolutePath = path.resolve(root, normalizedPath);
284+
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
285+
return undefined;
286+
}
287+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
288+
return undefined;
289+
}
290+
const sha256 = crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
291+
return { path: normalizedPath, sha256 };
292+
}
293+
294+
function isSafeRelativePath(value: string): boolean {
295+
if (value === "" || value.startsWith("/") || value.includes("\0")) return false;
296+
return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
297+
}

src/cli/semantic-search/packet-base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function basePacket(
6565
...(packet.avoidNextActions ? { avoidNextActions: packet.avoidNextActions } : {}),
6666
...(options.runtimeCost ? { runtimeCost: options.runtimeCost } : {}),
6767
...(packet.runtimeCost ? { runtimeCost: packet.runtimeCost } : {}),
68+
...(packet.cache ? { cache: packet.cache } : {}),
6869
header: packet.header,
6970
...(packet.inputDetection ? { inputDetection: packet.inputDetection } : {}),
7071
...(packet.packages ? { packages: packet.packages } : {}),

src/cli/semantic-search/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface SemanticSearchPacket {
6868
readonly searchSynthesis?: SemanticSearchSynthesis;
6969
readonly avoidNextActions?: readonly SemanticSearchAvoidNextAction[];
7070
readonly runtimeCost?: SemanticSearchRuntimeCost;
71+
readonly cache?: SemanticSearchCache;
7172
readonly header: SemanticSearchHeader;
7273
readonly inputDetection?: SemanticSearchInputDetection;
7374
readonly packages?: readonly SemanticSearchFact[];
@@ -94,6 +95,7 @@ export interface SemanticSearchPacketPayload {
9495
readonly searchSynthesis?: SemanticSearchSynthesis;
9596
readonly avoidNextActions?: readonly SemanticSearchAvoidNextAction[];
9697
readonly runtimeCost?: SemanticSearchRuntimeCost;
98+
readonly cache?: SemanticSearchCache;
9799
readonly nodes: readonly SemanticSearchNode[];
98100
readonly edges: readonly SemanticSearchEdge[];
99101
readonly owners: readonly SemanticSearchOwner[];
@@ -254,6 +256,16 @@ export interface SemanticSearchRuntimeCost {
254256
readonly fields?: SemanticSearchFields;
255257
}
256258

259+
export interface SemanticSearchCache {
260+
readonly fileHashes: readonly SemanticSearchFileHash[];
261+
readonly rawSourceStored?: false;
262+
}
263+
264+
export interface SemanticSearchFileHash {
265+
readonly path: string;
266+
readonly sha256: string;
267+
}
268+
257269
export type SemanticSearchFieldValue =
258270
| string
259271
| number

tests/unit/cli_dependency_search.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,10 @@ test("CLI searches external dependency usage", () => {
219219
readonly versionScope?: string;
220220
};
221221
}[];
222+
readonly cache?: {
223+
readonly rawSourceStored?: false;
224+
readonly fileHashes: readonly { readonly path: string; readonly sha256: string }[];
225+
};
222226
};
223227
assert.equal(depsPacket.method, "search/deps");
224228
assert.equal(depsPacket.view, "deps");
@@ -245,6 +249,15 @@ test("CLI searches external dependency usage", () => {
245249
hit.fields.versionScope === "current",
246250
),
247251
);
252+
assert.equal(depsPacket.cache?.rawSourceStored, false);
253+
assert.deepEqual(depsPacket.cache?.fileHashes.map((fileHash) => fileHash.path).sort(), [
254+
"package-lock.json",
255+
"package.json",
256+
"src/index.ts",
257+
]);
258+
assert.ok(
259+
depsPacket.cache?.fileHashes.every((fileHash) => /^[a-f0-9]{64}$/u.test(fileHash.sha256)),
260+
);
248261

249262
const mismatch = runCliCapture(["search", "deps", "react@18.0.0::jsx", "--json", "."], root);
250263
assert.equal(mismatch.exitCode, 0);

tests/unit/semantic_search_schema_assertions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ export function assertSemanticSearchPacket(
7373
if (packet.searchSynthesis !== undefined) {
7474
assertSearchSynthesis(record(packet.searchSynthesis), record(defs.searchSynthesis));
7575
}
76+
if (packet.cache !== undefined) {
77+
assertCache(record(packet.cache), record(defs.cache));
78+
}
7679
assertArray(packet.packages, "packet.packages", (fact, context) =>
7780
assertFact(fact, record(defs.fact), context),
7881
);
@@ -179,6 +182,19 @@ function assertSearchSynthesis(searchSynthesis: JsonObject, schema: JsonObject):
179182
}
180183
}
181184

185+
function assertCache(cache: JsonObject, schema: JsonObject): void {
186+
assertSchemaObject(cache, schema, "cache");
187+
assertArray(cache.fileHashes, "cache.fileHashes", (fileHash, context) => {
188+
const entry = record(fileHash, context);
189+
assertString(entry.path, `${context}.path`);
190+
assertString(entry.sha256, `${context}.sha256`);
191+
assert.match(String(entry.sha256), /^[a-f0-9]{64}$/u, `${context}.sha256 must be sha256`);
192+
});
193+
if (cache.rawSourceStored !== undefined) {
194+
assert.equal(cache.rawSourceStored, false, "cache.rawSourceStored must be false");
195+
}
196+
}
197+
182198
function assertStringArray(value: unknown, context: string): void {
183199
if (value === undefined) return;
184200
assert.ok(Array.isArray(value), `${context} must be an array`);

0 commit comments

Comments
 (0)