@functional-systems/srcx 0.1.0-dev.4 → 0.1.0-dev.40

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +179 -0
  2. package/CONTRIBUTING.md +4 -0
  3. package/DESIGN.md +378 -58
  4. package/README.md +628 -35
  5. package/RELEASING.md +15 -3
  6. package/VALIDATION.md +1120 -16
  7. package/dist/artifact.d.ts +31 -0
  8. package/dist/artifact.js +237 -0
  9. package/dist/artifact.js.map +1 -0
  10. package/dist/build.d.ts +86 -3
  11. package/dist/build.js +233 -71
  12. package/dist/build.js.map +1 -1
  13. package/dist/chunk.d.ts +9 -3
  14. package/dist/chunk.js +341 -14
  15. package/dist/chunk.js.map +1 -1
  16. package/dist/cli.js +130 -21
  17. package/dist/cli.js.map +1 -1
  18. package/dist/file-policy.d.ts +20 -0
  19. package/dist/file-policy.js +103 -0
  20. package/dist/file-policy.js.map +1 -0
  21. package/dist/implementation.d.ts +11 -0
  22. package/dist/implementation.js +191 -0
  23. package/dist/implementation.js.map +1 -0
  24. package/dist/publish.d.ts +18 -0
  25. package/dist/publish.js +224 -46
  26. package/dist/publish.js.map +1 -1
  27. package/dist/releases.js +21 -3
  28. package/dist/releases.js.map +1 -1
  29. package/dist/remote.d.ts +1 -0
  30. package/dist/remote.js +50 -9
  31. package/dist/remote.js.map +1 -1
  32. package/dist/repository.d.ts +4 -1
  33. package/dist/repository.js +26 -11
  34. package/dist/repository.js.map +1 -1
  35. package/dist/rerank.d.ts +16 -0
  36. package/dist/rerank.js +87 -0
  37. package/dist/rerank.js.map +1 -0
  38. package/dist/search.d.ts +18 -3
  39. package/dist/search.js +143 -23
  40. package/dist/search.js.map +1 -1
  41. package/dist/skills.d.ts +24 -0
  42. package/dist/skills.js +155 -0
  43. package/dist/skills.js.map +1 -0
  44. package/package.json +17 -6
  45. package/runtime/grammars/README.md +50 -0
  46. package/runtime/grammars/tree-sitter-bash.LICENSE +21 -0
  47. package/runtime/grammars/tree-sitter-bash.wasm +0 -0
  48. package/runtime/grammars/tree-sitter-sql.LICENSE +21 -0
  49. package/runtime/grammars/tree-sitter-sql.wasm +0 -0
  50. package/runtime/grammars/tree-sitter-wasm.LICENSE +21 -0
  51. package/runtime/qwen.py +132 -0
  52. package/runtime/requirements.txt +34 -0
  53. package/skills/srcx-search/SKILL.md +103 -0
  54. package/src/artifact.ts +303 -0
  55. package/src/build.ts +313 -84
  56. package/src/chunk.ts +360 -12
  57. package/src/cli.ts +263 -23
  58. package/src/file-policy.ts +166 -0
  59. package/src/implementation.ts +253 -0
  60. package/src/publish.ts +321 -47
  61. package/src/releases.ts +36 -3
  62. package/src/remote.ts +70 -13
  63. package/src/repository.ts +40 -11
  64. package/src/rerank.ts +131 -0
  65. package/src/search.ts +229 -28
  66. package/src/skills.ts +205 -0
package/src/build.ts CHANGED
@@ -1,9 +1,14 @@
1
- import { mkdir, open, rm } from "node:fs/promises";
2
- import { createReadStream } from "node:fs";
3
- import { createInterface } from "node:readline";
1
+ import { mkdir, rm } from "node:fs/promises";
4
2
  import { join, resolve } from "node:path";
5
3
  import { Blobs, inventory, resolveCommit, type Identity } from "./git.js";
6
4
  import { chunk, CHUNKER, tokens } from "./chunk.js";
5
+ import {
6
+ ArtifactWriter,
7
+ artifactRecords,
8
+ validateStorage,
9
+ type ArtifactStorage,
10
+ type RecordPart,
11
+ } from "./artifact.js";
7
12
  import {
8
13
  atomic,
9
14
  canonical,
@@ -15,6 +20,26 @@ import {
15
20
  type Doc,
16
21
  optionalJson,
17
22
  } from "./common.js";
23
+ import {
24
+ FILE_POLICY,
25
+ normalizeFilePolicy,
26
+ hardExclusion,
27
+ privateKeyContent,
28
+ fileDecision,
29
+ type FilePolicy,
30
+ type FileDecision,
31
+ } from "./file-policy.js";
32
+ export const ANALYZERS = ["english", "japanese", "korean", "standard"] as const;
33
+ export type Analyzer = (typeof ANALYZERS)[number];
34
+ export function normalizeAnalyzers(value: unknown): Analyzer[] {
35
+ invariant(
36
+ Array.isArray(value) &&
37
+ value.length > 0 &&
38
+ value.every((name) => ANALYZERS.includes(name)),
39
+ `Analyzers must be a nonempty list of: ${ANALYZERS.join(", ")}.`,
40
+ );
41
+ return [...new Set(value as Analyzer[])].sort();
42
+ }
18
43
  export const INDEX_CONFIGS = {
19
44
  kind: { type: "keyword" },
20
45
  path: { type: "keyword" },
@@ -25,18 +50,123 @@ export const INDEX_CONFIGS = {
25
50
  } as const;
26
51
  export type Preset = {
27
52
  schemaVersion: 1;
53
+ analyzers: readonly Analyzer[];
28
54
  chunker: typeof CHUNKER;
29
55
  mode: "syntax" | "window";
56
+ /** Internal evaluation override; the CLI keeps its original enrichment. */
57
+ enrichment?: "path-only-v1";
30
58
  maxFileBytes: number;
31
- embedding: { model: string; dimensions: number } | null;
59
+ filePolicy: FilePolicy;
60
+ embedding: {
61
+ model: string;
62
+ dimensions: number;
63
+ managed?: true;
64
+ provider?: "openai";
65
+ similarity?: "cosine";
66
+ sourceField?: "embeddingText";
67
+ } | null;
32
68
  };
33
69
  export const PRESET: Preset = {
34
70
  schemaVersion: 1,
71
+ analyzers: ["standard"],
35
72
  chunker: CHUNKER,
36
73
  mode: "syntax",
37
74
  maxFileBytes: 1024 * 1024,
75
+ filePolicy: FILE_POLICY,
38
76
  embedding: null,
39
77
  };
78
+ /** Embedding variants share the same chunking behavior. */
79
+ export const MANAGED_PRESET: Preset = {
80
+ ...PRESET,
81
+ embedding: {
82
+ managed: true,
83
+ provider: "openai",
84
+ model: "text-embedding-3-small",
85
+ dimensions: 1536,
86
+ similarity: "cosine",
87
+ sourceField: "embeddingText",
88
+ },
89
+ };
90
+ export const MANAGED_LARGE_PRESET: Preset = {
91
+ ...MANAGED_PRESET,
92
+ embedding: {
93
+ ...MANAGED_PRESET.embedding!,
94
+ model: "text-embedding-3-large",
95
+ dimensions: 3072,
96
+ },
97
+ };
98
+ export function presetFor(
99
+ embedding = "none",
100
+ analyzers: readonly string[] = PRESET.analyzers,
101
+ filePolicy: unknown = FILE_POLICY,
102
+ ): Preset {
103
+ invariant(
104
+ ["none", "text-embedding-3-small", "text-embedding-3-large"].includes(
105
+ embedding,
106
+ ),
107
+ "Embedding must be none, text-embedding-3-small or text-embedding-3-large.",
108
+ );
109
+ const base =
110
+ embedding === "none"
111
+ ? PRESET
112
+ : embedding === "text-embedding-3-large"
113
+ ? MANAGED_LARGE_PRESET
114
+ : MANAGED_PRESET;
115
+ const normalized = normalizeAnalyzers(analyzers);
116
+ const configured = {
117
+ ...base,
118
+ analyzers: normalized,
119
+ filePolicy: normalizeFilePolicy(filePolicy),
120
+ };
121
+ return hash(configured) === hash(base) ? base : configured;
122
+ }
123
+ export function supportedPreset(preset: unknown): preset is Preset {
124
+ if (!preset || typeof preset !== "object" || Array.isArray(preset))
125
+ return false;
126
+ try {
127
+ const analyzers = normalizeAnalyzers((preset as Preset).analyzers);
128
+ const filePolicy = normalizeFilePolicy((preset as Preset).filePolicy);
129
+ return [PRESET, MANAGED_PRESET, MANAGED_LARGE_PRESET].some(
130
+ (base) => hash({ ...base, analyzers, filePolicy }) === hash(preset),
131
+ );
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+ export function indexConfigs(preset: Preset = PRESET) {
137
+ const text = { type: "text" as const, analyzers: [...preset.analyzers] };
138
+ const indexes = { ...INDEX_CONFIGS, searchText: text };
139
+ if (!preset.embedding?.managed) return indexes;
140
+ const { managed: _, ...embedding } = preset.embedding;
141
+ return {
142
+ ...indexes,
143
+ embeddingText: { ...text, analyzers: [...preset.analyzers] },
144
+ embedding: { type: "vector", managedEmbedding: true, embedding },
145
+ };
146
+ }
147
+ /** Validate server enrichment separately; all other payload fields remain exact. */
148
+ export function recordHash(doc: Doc, preset: Preset): string {
149
+ if (!preset.embedding?.managed) return hash(doc);
150
+ const { embedding, ...source } = doc;
151
+ if (doc.kind === "chunk" && doc.embeddingStatus === "managed") {
152
+ invariant(
153
+ typeof doc.embeddingText === "string" &&
154
+ doc.embeddingText === doc.searchText &&
155
+ doc.embeddingInputHash ===
156
+ hash([preset.embedding, doc.embeddingText]) &&
157
+ Array.isArray(embedding) &&
158
+ embedding.length === preset.embedding.dimensions &&
159
+ embedding.every((v) => typeof v === "number" && Number.isFinite(v)) &&
160
+ embedding.some((v) => v !== 0),
161
+ "Missing or invalid managed embedding.",
162
+ );
163
+ } else
164
+ invariant(
165
+ embedding === undefined && doc.embeddingText === undefined,
166
+ "Unexpected managed embedding on an ineligible record.",
167
+ );
168
+ return hash(source);
169
+ }
40
170
  export type InventoryItem = {
41
171
  path: string;
42
172
  pathBase64: string;
@@ -45,6 +175,8 @@ export type InventoryItem = {
45
175
  bytes: number;
46
176
  status: "included" | "excluded";
47
177
  reason?: string;
178
+ retrieval?: "lexical" | "semantic";
179
+ policyReason?: string;
48
180
  targetBase64?: string;
49
181
  fileId?: string;
50
182
  chunkIds?: string[];
@@ -52,10 +184,13 @@ export type InventoryItem = {
52
184
  parseStatus?: string;
53
185
  tokens?: number;
54
186
  embedded?: number;
187
+ managed?: number;
188
+ managedTokens?: number;
55
189
  skipped?: number;
56
190
  };
57
191
  export type Build = {
58
- format: 1;
192
+ format: 1 | 2;
193
+ recordParts?: RecordPart[];
59
194
  directory: string;
60
195
  repoKey: string;
61
196
  configHash: string;
@@ -83,58 +218,46 @@ export type Embedder = {
83
218
  dimensions: number;
84
219
  embed: (text: string) => Promise<number[]>;
85
220
  };
86
- export async function* records(directory: string): AsyncGenerator<Doc> {
87
- const input = createReadStream(join(directory, "records.jsonl"), {
88
- encoding: "utf8",
89
- });
90
- const lines = createInterface({ input, crlfDelay: Infinity });
91
- try {
92
- for await (const line of lines) if (line) yield JSON.parse(line) as Doc;
93
- } finally {
94
- lines.close();
95
- input.destroy();
96
- }
221
+ export async function* records(build: Build | string): AsyncGenerator<Doc> {
222
+ const b = typeof build === "string" ? await loadBuild(build) : build;
223
+ yield* artifactRecords(b);
97
224
  }
98
225
  export async function loadBuild(directory: string): Promise<Build> {
99
226
  const b = await json<Build>(join(directory, "build.json"));
100
227
  invariant(
101
- b.format === 1 && b.configHash === hash(b.preset),
228
+ (b.format === 1 || b.format === 2) && b.configHash === hash(b.preset),
102
229
  "Invalid build configuration.",
103
230
  );
104
231
  b.directory = resolve(directory);
232
+ validateStorage(b);
105
233
  return b;
106
234
  }
107
- function exclude(path: string): string | undefined {
108
- if (
109
- /(^|\/)(node_modules|vendor|vendored|dist|build|target|coverage|\.git|\.next)(\/|$)/.test(
110
- path,
111
- )
112
- )
113
- return "dependency-or-build-output";
114
- if (
115
- /\.(png|jpe?g|gif|ico|pdf|zip|gz|jar|class|woff2?|mp[34]|exe|dll|so|dylib|wasm)$/i.test(
116
- path,
117
- )
118
- )
119
- return "binary-extension";
120
- if (/\.(min\.(js|css)|map)$/.test(path)) return "generated-output";
121
- return undefined;
122
- }
123
235
  export async function materialize(args: {
124
236
  identity: Identity;
125
237
  ref: string;
238
+ resolvedCommit?: Awaited<ReturnType<typeof resolveCommit>>;
126
239
  output: string;
127
240
  previous?: Build;
241
+ storage?: ArtifactStorage;
128
242
  preset?: Preset;
129
243
  embedder?: Embedder;
130
244
  cache?: string;
131
245
  }): Promise<Build> {
246
+ const storage = args.storage ?? "jsonl";
247
+ invariant(
248
+ ["jsonl", "segments"].includes(storage),
249
+ "Artifact storage must be jsonl or segments.",
250
+ );
132
251
  const preset = args.preset ?? PRESET,
133
252
  configHash = hash(preset),
134
253
  prev = args.previous;
135
254
  invariant(
136
- hash(preset.chunker) === hash(CHUNKER) &&
255
+ hash(preset.filePolicy) === hash(normalizeFilePolicy(preset.filePolicy)) &&
256
+ hash(preset.analyzers) === hash(normalizeAnalyzers(preset.analyzers)) &&
257
+ hash(preset.chunker) === hash(CHUNKER) &&
137
258
  ["syntax", "window"].includes(preset.mode) &&
259
+ (preset.enrichment === undefined ||
260
+ preset.enrichment === "path-only-v1") &&
138
261
  preset.maxFileBytes === PRESET.maxFileBytes,
139
262
  "Unsupported build preset.",
140
263
  );
@@ -147,26 +270,25 @@ export async function materialize(args: {
147
270
  }
148
271
  invariant(
149
272
  !preset.embedding ||
273
+ (preset.embedding.managed && supportedPreset(preset)) ||
150
274
  (args.embedder?.model === preset.embedding.model &&
151
275
  args.embedder.dimensions === preset.embedding.dimensions),
152
276
  "Embedding provider must match the pinned preset.",
153
277
  );
154
- const commit = await resolveCommit(args.identity.path, args.ref);
278
+ const commit =
279
+ args.resolvedCommit ?? (await resolveCommit(args.identity.path, args.ref));
155
280
  const directory = resolve(args.output);
156
281
  await mkdir(directory, { recursive: false, mode: 0o700 });
157
- const output = await open(join(directory, "records.jsonl"), "wx", 0o600);
158
- const recordHashes: Record<string, string> = {};
159
- let writeError = false;
160
- async function emit(doc: Doc) {
161
- const data = canonical(doc);
162
- invariant(
163
- Buffer.byteLength(data) <= 4_800_000,
164
- `Serialized document exceeds safe limit: ${doc.id}`,
165
- );
166
- invariant(!recordHashes[doc.id], "Duplicate document ID.");
167
- recordHashes[doc.id] = hash(doc);
168
- await output.write(data + "\n");
282
+ let writer: ArtifactWriter;
283
+ try {
284
+ writer = await ArtifactWriter.create(directory, storage);
285
+ } catch (error) {
286
+ await rm(directory, { recursive: true, force: true });
287
+ throw error;
169
288
  }
289
+ const recordHashes = writer.recordHashes;
290
+ let writeError = false;
291
+ const emit = (doc: Doc) => writer.emit(doc);
170
292
  const items: InventoryItem[] = [];
171
293
  const blobs = new Blobs(args.identity.path);
172
294
  const old = new Map(prev?.inventory.map((e) => [e.pathBase64, e]) ?? []);
@@ -200,7 +322,10 @@ export async function materialize(args: {
200
322
  item.targetBase64 = (await blobs.read(e)).toString("base64");
201
323
  continue;
202
324
  }
203
- const reason = exclude(e.path);
325
+ const pathDecision = fileDecision(e.path, preset.filePolicy);
326
+ const reason =
327
+ hardExclusion(e.path) ??
328
+ (pathDecision.action === "exclude" ? pathDecision.reason : undefined);
204
329
  if (reason) {
205
330
  item.reason = reason;
206
331
  continue;
@@ -233,7 +358,17 @@ export async function materialize(args: {
233
358
  item.reason = "lfs-pointer";
234
359
  continue;
235
360
  }
236
- const parsed = await chunk(source, e.path, preset.mode);
361
+ if (privateKeyContent(source)) {
362
+ item.reason = "private-key-content";
363
+ continue;
364
+ }
365
+ const decision = fileDecision(e.path, preset.filePolicy, source);
366
+ const parsed = await chunk(
367
+ source,
368
+ e.path,
369
+ preset.mode,
370
+ preset.enrichment,
371
+ );
237
372
  const contentHash = hash(data);
238
373
  const fileId = `f-${hash([e.pathBase64, contentHash])}`;
239
374
  const base = { schemaVersion: 1, configHash };
@@ -256,6 +391,8 @@ export async function materialize(args: {
256
391
  }
257
392
  Object.assign(item, {
258
393
  status: "included",
394
+ retrieval: decision.action,
395
+ policyReason: decision.reason,
259
396
  contentHash,
260
397
  fileId,
261
398
  chunkIds: [],
@@ -282,39 +419,52 @@ export async function materialize(args: {
282
419
  ? "structural-only"
283
420
  : "embedding-disabled",
284
421
  };
285
- const eligible = !["imports", "structural"].includes(span.chunkKind);
422
+ const eligible =
423
+ decision.action === "semantic" &&
424
+ !["imports", "structural"].includes(span.chunkKind);
286
425
  if (preset.embedding && eligible) {
287
426
  const inputHash = hash([preset.embedding, span.searchText]);
288
- const cachePath = args.cache
289
- ? join(args.cache, inputHash + ".json")
290
- : undefined;
291
- let vector = cachePath
292
- ? await optionalJson<number[]>(cachePath)
293
- : undefined;
294
- if (!vector) {
295
- vector = await args.embedder!.embed(span.searchText);
427
+ if (preset.embedding.managed) {
428
+ doc.embeddingText = span.searchText;
429
+ doc.embeddingInputHash = inputHash;
430
+ doc.embeddingStatus = "managed";
431
+ delete doc.embeddingSkipReason;
432
+ item.managed = (item.managed ?? 0) + 1;
433
+ item.managedTokens = (item.managedTokens ?? 0) + span.tokenCount;
434
+ } else {
435
+ const cachePath = args.cache
436
+ ? join(args.cache, inputHash + ".json")
437
+ : undefined;
438
+ let vector = cachePath
439
+ ? await optionalJson<number[]>(cachePath)
440
+ : undefined;
441
+ if (!vector) {
442
+ vector = await args.embedder!.embed(span.searchText);
443
+ invariant(
444
+ vector.length === preset.embedding.dimensions &&
445
+ vector.every(Number.isFinite),
446
+ "Invalid embedding output.",
447
+ );
448
+ if (cachePath) await atomic(cachePath, vector);
449
+ }
296
450
  invariant(
297
451
  vector.length === preset.embedding.dimensions &&
298
452
  vector.every(Number.isFinite),
299
- "Invalid embedding output.",
453
+ "Invalid cached vector.",
300
454
  );
301
- if (cachePath) await atomic(cachePath, vector);
455
+ doc.embedding = vector;
456
+ doc.embeddingInputHash = inputHash;
457
+ doc.embeddingStatus = "embedded";
458
+ delete doc.embeddingSkipReason;
459
+ item.embedded!++;
302
460
  }
303
- invariant(
304
- vector.length === preset.embedding.dimensions &&
305
- vector.every(Number.isFinite),
306
- "Invalid cached vector.",
307
- );
308
- doc.embedding = vector;
309
- doc.embeddingInputHash = inputHash;
310
- doc.embeddingStatus = "embedded";
311
- delete doc.embeddingSkipReason;
312
- item.embedded!++;
313
461
  } else {
314
462
  doc.embeddingSkipReason = preset.embedding
315
- ? span.chunkKind === "imports"
316
- ? "imports-only"
317
- : "structural-only"
463
+ ? decision.action === "lexical"
464
+ ? decision.reason
465
+ : span.chunkKind === "imports"
466
+ ? "imports-only"
467
+ : "structural-only"
318
468
  : "embedding-disabled";
319
469
  item.skipped!++;
320
470
  }
@@ -323,20 +473,34 @@ export async function materialize(args: {
323
473
  await emit(doc);
324
474
  }
325
475
  }
326
- if (prev && reuse.size)
327
- for await (const doc of records(prev.directory))
328
- if (reuse.has(doc.id)) {
329
- await emit(doc);
330
- reuse.delete(doc.id);
476
+ if (prev && reuse.size) {
477
+ if (storage === "segments" && prev.format === 2) {
478
+ const files = new Map(prev.inventory.map((e) => [e.fileId, e]));
479
+ for (const part of prev.recordParts!) {
480
+ if (!reuse.has(part.fileId)) continue;
481
+ const ids = [part.fileId, ...files.get(part.fileId)!.chunkIds!];
482
+ await writer.retain(prev, part, ids);
483
+ for (const id of ids) reuse.delete(id);
331
484
  }
485
+ } else
486
+ for await (const doc of records(prev))
487
+ if (reuse.has(doc.id)) {
488
+ await emit(doc);
489
+ reuse.delete(doc.id);
490
+ }
491
+ }
332
492
  invariant(reuse.size === 0, "Previous artifact lacks retained records.");
493
+ await writer.finish();
333
494
  } catch (e) {
334
495
  writeError = true;
335
496
  throw e;
336
497
  } finally {
337
498
  blobs.close();
338
- await output.close();
339
- if (writeError) await rm(directory, { recursive: true, force: true });
499
+ try {
500
+ await writer.close();
501
+ } finally {
502
+ if (writeError) await rm(directory, { recursive: true, force: true });
503
+ }
340
504
  }
341
505
  const changes: Build["changes"] = {
342
506
  added: [],
@@ -358,7 +522,8 @@ export async function materialize(args: {
358
522
  for (const e of old.values())
359
523
  if (!now.has(e.pathBase64)) changes.deleted.push(e.path);
360
524
  const build: Build = {
361
- format: 1,
525
+ format: storage === "segments" ? 2 : 1,
526
+ ...(storage === "segments" ? { recordParts: writer.parts } : {}),
362
527
  directory,
363
528
  repoKey: args.identity.key,
364
529
  configHash,
@@ -388,6 +553,15 @@ export async function materialize(args: {
388
553
  excluded: items.filter((e) => e.status === "excluded").length,
389
554
  chunks: items.reduce((n, e) => n + (e.chunkIds?.length ?? 0), 0),
390
555
  tokens: items.reduce((n, e) => n + (e.tokens ?? 0), 0),
556
+ ...(preset.embedding?.managed
557
+ ? {
558
+ managed: items.reduce((n, e) => n + (e.managed ?? 0), 0),
559
+ managedTokens: items.reduce(
560
+ (n, e) => n + (e.managedTokens ?? 0),
561
+ 0,
562
+ ),
563
+ }
564
+ : {}),
391
565
  embedded: items.reduce((n, e) => n + (e.embedded ?? 0), 0),
392
566
  skipped: items.reduce((n, e) => n + (e.skipped ?? 0), 0),
393
567
  },
@@ -405,9 +579,15 @@ export async function validateBuild(b: Build): Promise<void> {
405
579
  "Build commit identity mismatch; rebuild from the intended Git commit.",
406
580
  );
407
581
  invariant(
408
- hash(b.preset) === b.configHash && hash(b.inventory) === b.inventoryHash,
582
+ hash(b.preset.filePolicy) ===
583
+ hash(normalizeFilePolicy(b.preset.filePolicy)) &&
584
+ hash(b.preset.analyzers) ===
585
+ hash(normalizeAnalyzers(b.preset.analyzers)) &&
586
+ hash(b.preset) === b.configHash &&
587
+ hash(b.inventory) === b.inventoryHash,
409
588
  "Build metadata hash mismatch.",
410
589
  );
590
+ validateStorage(b);
411
591
  const seen = new Set<string>();
412
592
  let activeFile: Doc | undefined;
413
593
  let end = 0,
@@ -417,6 +597,7 @@ export async function validateBuild(b: Build): Promise<void> {
417
597
  .filter((e) => e.status === "included")
418
598
  .map((e) => [e.fileId!, e]),
419
599
  );
600
+ let activeDecision: FileDecision | undefined;
420
601
  const completedFiles = new Set<string>();
421
602
  const finish = () => {
422
603
  if (activeFile) {
@@ -429,7 +610,7 @@ export async function validateBuild(b: Build): Promise<void> {
429
610
  completedFiles.add(activeFile.id);
430
611
  }
431
612
  };
432
- for await (const doc of records(b.directory)) {
613
+ for await (const doc of records(b)) {
433
614
  invariant(
434
615
  !seen.has(doc.id) && b.recordHashes[doc.id] === hash(doc),
435
616
  "Corrupted or unexpected artifact record.",
@@ -439,12 +620,36 @@ export async function validateBuild(b: Build): Promise<void> {
439
620
  doc.configHash === b.configHash && doc.schemaVersion === 1,
440
621
  "Mixed configuration.",
441
622
  );
623
+ if (b.preset.embedding?.managed) {
624
+ invariant(
625
+ doc.embedding === undefined,
626
+ "Managed artifacts must not contain vectors.",
627
+ );
628
+ if (doc.kind !== "chunk")
629
+ invariant(
630
+ doc.embeddingText === undefined,
631
+ "Only eligible chunks can contain embedding input.",
632
+ );
633
+ }
442
634
  if (doc.kind === "file") {
443
635
  finish();
444
636
  activeFile = doc;
445
637
  end = 0;
446
638
  count = 0;
447
639
  const entry = expectedFile.get(doc.id);
640
+ activeDecision = fileDecision(
641
+ doc.path as string,
642
+ b.preset.filePolicy,
643
+ doc.sourceText as string,
644
+ );
645
+ invariant(
646
+ !hardExclusion(doc.path as string) &&
647
+ !privateKeyContent(doc.sourceText as string) &&
648
+ activeDecision.action !== "exclude" &&
649
+ entry?.retrieval === activeDecision.action &&
650
+ entry?.policyReason === activeDecision.reason,
651
+ "File policy or inventory decision mismatch.",
652
+ );
448
653
  const raw = Buffer.from(doc.sourceText as string);
449
654
  invariant(
450
655
  entry &&
@@ -494,9 +699,33 @@ export async function validateBuild(b: Build): Promise<void> {
494
699
  Number(doc.tokenCount) <= CHUNKER.maxTokens,
495
700
  "Invalid chunk source/position/token binding.",
496
701
  );
497
- if (doc.embeddingStatus === "embedded")
702
+ if (b.preset.embedding?.managed) {
703
+ const eligible =
704
+ activeDecision!.action === "semantic" &&
705
+ !["imports", "structural"].includes(String(doc.chunkKind));
706
+ invariant(
707
+ eligible
708
+ ? doc.embeddingStatus === "managed" &&
709
+ doc.embeddingText === doc.searchText &&
710
+ doc.embeddingInputHash ===
711
+ hash([b.preset.embedding, doc.searchText]) &&
712
+ doc.embeddingSkipReason === undefined
713
+ : doc.embeddingStatus === "skipped" &&
714
+ doc.embeddingText === undefined &&
715
+ doc.embeddingInputHash === undefined &&
716
+ doc.embeddingSkipReason ===
717
+ (activeDecision!.action === "lexical"
718
+ ? activeDecision!.reason
719
+ : doc.chunkKind === "imports"
720
+ ? "imports-only"
721
+ : "structural-only"),
722
+ "Invalid managed embedding input or eligibility.",
723
+ );
724
+ } else if (doc.embeddingStatus === "embedded")
498
725
  invariant(
499
726
  b.preset.embedding &&
727
+ activeDecision!.action === "semantic" &&
728
+ !["imports", "structural"].includes(String(doc.chunkKind)) &&
500
729
  Array.isArray(doc.embedding) &&
501
730
  doc.embedding.length === b.preset.embedding.dimensions &&
502
731
  doc.embedding.every(Number.isFinite),