@functional-systems/srcx 0.1.0-dev.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +54 -0
  3. package/DESIGN.md +1057 -0
  4. package/LICENSE +202 -0
  5. package/README.md +209 -0
  6. package/RELEASING.md +137 -0
  7. package/VALIDATION.md +181 -0
  8. package/dist/build.d.ts +94 -0
  9. package/dist/build.js +433 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/chunk.d.ts +30 -0
  12. package/dist/chunk.js +296 -0
  13. package/dist/chunk.js.map +1 -0
  14. package/dist/cli.d.ts +2 -0
  15. package/dist/cli.js +276 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/common.d.ts +17 -0
  18. package/dist/common.js +74 -0
  19. package/dist/common.js.map +1 -0
  20. package/dist/git.d.ts +38 -0
  21. package/dist/git.js +224 -0
  22. package/dist/git.js.map +1 -0
  23. package/dist/publish.d.ts +60 -0
  24. package/dist/publish.js +341 -0
  25. package/dist/publish.js.map +1 -0
  26. package/dist/releases.d.ts +8 -0
  27. package/dist/releases.js +79 -0
  28. package/dist/releases.js.map +1 -0
  29. package/dist/remote.d.ts +53 -0
  30. package/dist/remote.js +136 -0
  31. package/dist/remote.js.map +1 -0
  32. package/dist/repository.d.ts +29 -0
  33. package/dist/repository.js +180 -0
  34. package/dist/repository.js.map +1 -0
  35. package/dist/search.d.ts +46 -0
  36. package/dist/search.js +181 -0
  37. package/dist/search.js.map +1 -0
  38. package/dist/settings.d.ts +10 -0
  39. package/dist/settings.js +35 -0
  40. package/dist/settings.js.map +1 -0
  41. package/package.json +58 -0
  42. package/src/build.ts +587 -0
  43. package/src/chunk.ts +328 -0
  44. package/src/cli.ts +366 -0
  45. package/src/common.ts +71 -0
  46. package/src/git.ts +286 -0
  47. package/src/publish.ts +487 -0
  48. package/src/releases.ts +108 -0
  49. package/src/remote.ts +230 -0
  50. package/src/repository.ts +306 -0
  51. package/src/search.ts +282 -0
  52. package/src/settings.ts +59 -0
package/src/build.ts ADDED
@@ -0,0 +1,587 @@
1
+ import { mkdir, open, rm } from "node:fs/promises";
2
+ import { createReadStream } from "node:fs";
3
+ import { createInterface } from "node:readline";
4
+ import { join, resolve } from "node:path";
5
+ import { Blobs, inventory, resolveCommit, type Identity } from "./git.js";
6
+ import { chunk, CHUNKER, tokens } from "./chunk.js";
7
+ import {
8
+ atomic,
9
+ canonical,
10
+ hash,
11
+ invariant,
12
+ lineAt,
13
+ json,
14
+ utf8,
15
+ type Doc,
16
+ optionalJson,
17
+ } from "./common.js";
18
+ export const INDEX_CONFIGS = {
19
+ kind: { type: "keyword" },
20
+ path: { type: "keyword" },
21
+ language: { type: "keyword" },
22
+ fileId: { type: "keyword" },
23
+ symbol: { type: "keyword" },
24
+ searchText: { type: "text", analyzers: ["standard"] },
25
+ } as const;
26
+ export type Preset = {
27
+ schemaVersion: 1;
28
+ chunker: typeof CHUNKER;
29
+ mode: "syntax" | "window";
30
+ maxFileBytes: number;
31
+ embedding: { model: string; dimensions: number } | null;
32
+ };
33
+ export const PRESET: Preset = {
34
+ schemaVersion: 1,
35
+ chunker: CHUNKER,
36
+ mode: "syntax",
37
+ maxFileBytes: 1024 * 1024,
38
+ embedding: null,
39
+ };
40
+ export type InventoryItem = {
41
+ path: string;
42
+ pathBase64: string;
43
+ oid: string;
44
+ mode: string;
45
+ bytes: number;
46
+ status: "included" | "excluded";
47
+ reason?: string;
48
+ targetBase64?: string;
49
+ fileId?: string;
50
+ chunkIds?: string[];
51
+ contentHash?: string;
52
+ parseStatus?: string;
53
+ tokens?: number;
54
+ embedded?: number;
55
+ skipped?: number;
56
+ };
57
+ export type Build = {
58
+ format: 1;
59
+ directory: string;
60
+ repoKey: string;
61
+ configHash: string;
62
+ preset: Preset;
63
+ commitOid: string;
64
+ commitTime: string;
65
+ requestedRef: string;
66
+ branchRef?: string;
67
+ buildId: string;
68
+ inventory: InventoryItem[];
69
+ recordHashes: Record<string, string>;
70
+ recordsHash: string;
71
+ inventoryHash: string;
72
+ changes: {
73
+ added: string[];
74
+ modified: string[];
75
+ deleted: string[];
76
+ unchanged: string[];
77
+ };
78
+ obsoleteIds: string[];
79
+ counts: Record<string, number>;
80
+ };
81
+ export type Embedder = {
82
+ model: string;
83
+ dimensions: number;
84
+ embed: (text: string) => Promise<number[]>;
85
+ };
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
+ }
97
+ }
98
+ export async function loadBuild(directory: string): Promise<Build> {
99
+ const b = await json<Build>(join(directory, "build.json"));
100
+ invariant(
101
+ b.format === 1 && b.configHash === hash(b.preset),
102
+ "Invalid build configuration.",
103
+ );
104
+ b.directory = resolve(directory);
105
+ return b;
106
+ }
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
+ export async function materialize(args: {
124
+ identity: Identity;
125
+ ref: string;
126
+ output: string;
127
+ previous?: Build;
128
+ preset?: Preset;
129
+ embedder?: Embedder;
130
+ cache?: string;
131
+ }): Promise<Build> {
132
+ const preset = args.preset ?? PRESET,
133
+ configHash = hash(preset),
134
+ prev = args.previous;
135
+ invariant(
136
+ hash(preset.chunker) === hash(CHUNKER) &&
137
+ ["syntax", "window"].includes(preset.mode) &&
138
+ preset.maxFileBytes === PRESET.maxFileBytes,
139
+ "Unsupported build preset.",
140
+ );
141
+ if (prev) {
142
+ invariant(
143
+ prev.repoKey === args.identity.key && prev.configHash === configHash,
144
+ "Previous build repository/config mismatch.",
145
+ );
146
+ await validateBuild(prev);
147
+ }
148
+ invariant(
149
+ !preset.embedding ||
150
+ (args.embedder?.model === preset.embedding.model &&
151
+ args.embedder.dimensions === preset.embedding.dimensions),
152
+ "Embedding provider must match the pinned preset.",
153
+ );
154
+ const commit = await resolveCommit(args.identity.path, args.ref);
155
+ const directory = resolve(args.output);
156
+ 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");
169
+ }
170
+ const items: InventoryItem[] = [];
171
+ const blobs = new Blobs(args.identity.path);
172
+ const old = new Map(prev?.inventory.map((e) => [e.pathBase64, e]) ?? []);
173
+ const reuse = new Set<string>();
174
+ try {
175
+ const entries = await inventory(args.identity.path, commit.oid);
176
+ for (const e of entries) {
177
+ const item: InventoryItem = {
178
+ path: e.path,
179
+ pathBase64: e.pathBase64,
180
+ oid: e.oid,
181
+ mode: e.mode,
182
+ bytes: e.bytes,
183
+ status: "excluded",
184
+ };
185
+ items.push(item);
186
+ if (!e.validPath) {
187
+ item.reason = "invalid-utf8-path";
188
+ continue;
189
+ }
190
+ if (e.mode === "160000") {
191
+ item.reason = "submodule";
192
+ continue;
193
+ }
194
+ if (e.bytes > preset.maxFileBytes) {
195
+ item.reason = "oversized";
196
+ continue;
197
+ }
198
+ if (e.mode === "120000") {
199
+ item.reason = "symlink";
200
+ item.targetBase64 = (await blobs.read(e)).toString("base64");
201
+ continue;
202
+ }
203
+ const reason = exclude(e.path);
204
+ if (reason) {
205
+ item.reason = reason;
206
+ continue;
207
+ }
208
+ invariant(e.type === "blob", "Unsupported Git entry type.");
209
+ const former = old.get(e.pathBase64);
210
+ if (
211
+ former?.status === "included" &&
212
+ former.oid === e.oid &&
213
+ former.mode === e.mode
214
+ ) {
215
+ Object.assign(item, former);
216
+ for (const id of [former.fileId!, ...former.chunkIds!]) reuse.add(id);
217
+ continue;
218
+ }
219
+ const data = await blobs.read(e);
220
+ if (data.includes(0)) {
221
+ item.reason = "binary-content";
222
+ continue;
223
+ }
224
+ const source = utf8(data);
225
+ if (source === undefined) {
226
+ item.reason = "invalid-utf8-content";
227
+ continue;
228
+ }
229
+ if (
230
+ source.startsWith("version https://git-lfs.github.com/spec/v1\n") ||
231
+ source.startsWith("version https://git-lfs.github.com/spec/v1\r\n")
232
+ ) {
233
+ item.reason = "lfs-pointer";
234
+ continue;
235
+ }
236
+ const parsed = await chunk(source, e.path, preset.mode);
237
+ const contentHash = hash(data);
238
+ const fileId = `f-${hash([e.pathBase64, contentHash])}`;
239
+ const base = { schemaVersion: 1, configHash };
240
+ const file: Doc = {
241
+ ...base,
242
+ id: fileId,
243
+ kind: "file",
244
+ path: e.path,
245
+ blobOid: e.oid,
246
+ contentHash,
247
+ byteLength: data.length,
248
+ sourceText: source,
249
+ language: parsed.language,
250
+ parseStatus: parsed.parseStatus,
251
+ chunkCount: parsed.spans.length,
252
+ };
253
+ if (Buffer.byteLength(canonical(file)) > 4_800_000) {
254
+ item.reason = "serialized-source-too-large";
255
+ continue;
256
+ }
257
+ Object.assign(item, {
258
+ status: "included",
259
+ contentHash,
260
+ fileId,
261
+ chunkIds: [],
262
+ parseStatus: parsed.parseStatus,
263
+ tokens: 0,
264
+ embedded: 0,
265
+ skipped: 0,
266
+ });
267
+ await emit(file);
268
+ for (const [ordinal, span] of parsed.spans.entries()) {
269
+ const id = `c-${hash([fileId, configHash, span.startByte, span.endByte, ordinal])}`;
270
+ const doc: Doc = {
271
+ ...base,
272
+ id,
273
+ kind: "chunk",
274
+ fileId,
275
+ path: e.path,
276
+ contentHash,
277
+ language: parsed.language,
278
+ ...span,
279
+ ordinal,
280
+ embeddingStatus: "skipped",
281
+ embeddingSkipReason: preset.embedding
282
+ ? "structural-only"
283
+ : "embedding-disabled",
284
+ };
285
+ const eligible = !["imports", "structural"].includes(span.chunkKind);
286
+ if (preset.embedding && eligible) {
287
+ 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);
296
+ invariant(
297
+ vector.length === preset.embedding.dimensions &&
298
+ vector.every(Number.isFinite),
299
+ "Invalid embedding output.",
300
+ );
301
+ if (cachePath) await atomic(cachePath, vector);
302
+ }
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
+ } else {
314
+ doc.embeddingSkipReason = preset.embedding
315
+ ? span.chunkKind === "imports"
316
+ ? "imports-only"
317
+ : "structural-only"
318
+ : "embedding-disabled";
319
+ item.skipped!++;
320
+ }
321
+ item.chunkIds!.push(id);
322
+ item.tokens! += span.tokenCount;
323
+ await emit(doc);
324
+ }
325
+ }
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);
331
+ }
332
+ invariant(reuse.size === 0, "Previous artifact lacks retained records.");
333
+ } catch (e) {
334
+ writeError = true;
335
+ throw e;
336
+ } finally {
337
+ blobs.close();
338
+ await output.close();
339
+ if (writeError) await rm(directory, { recursive: true, force: true });
340
+ }
341
+ const changes: Build["changes"] = {
342
+ added: [],
343
+ modified: [],
344
+ deleted: [],
345
+ unchanged: [],
346
+ };
347
+ const now = new Map(items.map((e) => [e.pathBase64, e]));
348
+ for (const e of items) {
349
+ const o = old.get(e.pathBase64);
350
+ changes[
351
+ !o
352
+ ? "added"
353
+ : o.oid === e.oid && o.mode === e.mode && o.status === e.status
354
+ ? "unchanged"
355
+ : "modified"
356
+ ].push(e.path);
357
+ }
358
+ for (const e of old.values())
359
+ if (!now.has(e.pathBase64)) changes.deleted.push(e.path);
360
+ const build: Build = {
361
+ format: 1,
362
+ directory,
363
+ repoKey: args.identity.key,
364
+ configHash,
365
+ preset,
366
+ commitOid: commit.oid,
367
+ commitTime: commit.time,
368
+ requestedRef: args.ref,
369
+ branchRef: commit.branch,
370
+ buildId: hash([
371
+ args.identity.key,
372
+ commit.oid,
373
+ configHash,
374
+ "srcx-builder-v1",
375
+ ]),
376
+ inventory: items,
377
+ recordHashes,
378
+ recordsHash: hash(
379
+ Object.entries(recordHashes).sort(([a], [b]) => a.localeCompare(b)),
380
+ ),
381
+ inventoryHash: hash(items),
382
+ changes,
383
+ obsoleteIds: Object.keys(prev?.recordHashes ?? {}).filter(
384
+ (id) => !recordHashes[id],
385
+ ),
386
+ counts: {
387
+ files: items.filter((e) => e.status === "included").length,
388
+ excluded: items.filter((e) => e.status === "excluded").length,
389
+ chunks: items.reduce((n, e) => n + (e.chunkIds?.length ?? 0), 0),
390
+ tokens: items.reduce((n, e) => n + (e.tokens ?? 0), 0),
391
+ embedded: items.reduce((n, e) => n + (e.embedded ?? 0), 0),
392
+ skipped: items.reduce((n, e) => n + (e.skipped ?? 0), 0),
393
+ },
394
+ };
395
+ await atomic(join(directory, "build.json"), build);
396
+ await validateBuild(build);
397
+ return build;
398
+ }
399
+ export async function validateBuild(b: Build): Promise<void> {
400
+ invariant(
401
+ typeof b.commitOid === "string" &&
402
+ /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(b.commitOid) &&
403
+ b.buildId ===
404
+ hash([b.repoKey, b.commitOid, b.configHash, "srcx-builder-v1"]),
405
+ "Build commit identity mismatch; rebuild from the intended Git commit.",
406
+ );
407
+ invariant(
408
+ hash(b.preset) === b.configHash && hash(b.inventory) === b.inventoryHash,
409
+ "Build metadata hash mismatch.",
410
+ );
411
+ const seen = new Set<string>();
412
+ let activeFile: Doc | undefined;
413
+ let end = 0,
414
+ count = 0;
415
+ const expectedFile = new Map(
416
+ b.inventory
417
+ .filter((e) => e.status === "included")
418
+ .map((e) => [e.fileId!, e]),
419
+ );
420
+ const completedFiles = new Set<string>();
421
+ const finish = () => {
422
+ if (activeFile) {
423
+ const entry = expectedFile.get(activeFile.id)!;
424
+ invariant(
425
+ count === entry.chunkIds!.length &&
426
+ end === Buffer.byteLength(activeFile.sourceText as string),
427
+ "Incomplete source coverage.",
428
+ );
429
+ completedFiles.add(activeFile.id);
430
+ }
431
+ };
432
+ for await (const doc of records(b.directory)) {
433
+ invariant(
434
+ !seen.has(doc.id) && b.recordHashes[doc.id] === hash(doc),
435
+ "Corrupted or unexpected artifact record.",
436
+ );
437
+ seen.add(doc.id);
438
+ invariant(
439
+ doc.configHash === b.configHash && doc.schemaVersion === 1,
440
+ "Mixed configuration.",
441
+ );
442
+ if (doc.kind === "file") {
443
+ finish();
444
+ activeFile = doc;
445
+ end = 0;
446
+ count = 0;
447
+ const entry = expectedFile.get(doc.id);
448
+ const raw = Buffer.from(doc.sourceText as string);
449
+ invariant(
450
+ entry &&
451
+ hash(raw) === doc.contentHash &&
452
+ entry.contentHash === doc.contentHash &&
453
+ entry.path === doc.path &&
454
+ entry.oid === doc.blobOid &&
455
+ entry.bytes === raw.length &&
456
+ doc.byteLength === raw.length &&
457
+ doc.chunkCount === entry.chunkIds?.length &&
458
+ doc.id === `f-${hash([entry.pathBase64, doc.contentHash])}`,
459
+ "Invalid file content or inventory binding.",
460
+ );
461
+ } else {
462
+ invariant(
463
+ doc.kind === "chunk" && activeFile && doc.fileId === activeFile.id,
464
+ "Orphan chunk.",
465
+ );
466
+ const a = doc.startByte as number,
467
+ z = doc.endByte as number;
468
+ invariant(
469
+ Number.isInteger(a) &&
470
+ Number.isInteger(z) &&
471
+ a >= 0 &&
472
+ a <= end &&
473
+ z > a &&
474
+ z <= Buffer.byteLength(activeFile.sourceText as string),
475
+ "Invalid chunk range.",
476
+ );
477
+ invariant(
478
+ expectedFile.get(activeFile.id)!.chunkIds!.includes(doc.id),
479
+ "Unexpected chunk membership.",
480
+ );
481
+ const raw = Buffer.from(activeFile.sourceText as string);
482
+ const slice = raw.subarray(a, z);
483
+ invariant(
484
+ utf8(slice) !== undefined &&
485
+ doc.path === activeFile.path &&
486
+ doc.contentHash === activeFile.contentHash &&
487
+ doc.ordinal === count &&
488
+ doc.id === `c-${hash([activeFile.id, b.configHash, a, z, count])}` &&
489
+ doc.startLine === lineAt(raw, a) &&
490
+ doc.endLine === lineAt(raw, z - 1) &&
491
+ typeof doc.searchText === "string" &&
492
+ doc.searchText.endsWith(slice.toString("utf8")) &&
493
+ doc.tokenCount === tokens(doc.searchText) &&
494
+ Number(doc.tokenCount) <= CHUNKER.maxTokens,
495
+ "Invalid chunk source/position/token binding.",
496
+ );
497
+ if (doc.embeddingStatus === "embedded")
498
+ invariant(
499
+ b.preset.embedding &&
500
+ Array.isArray(doc.embedding) &&
501
+ doc.embedding.length === b.preset.embedding.dimensions &&
502
+ doc.embedding.every(Number.isFinite),
503
+ "Invalid embedded chunk.",
504
+ );
505
+ else
506
+ invariant(
507
+ doc.embeddingStatus === "skipped" &&
508
+ typeof doc.embeddingSkipReason === "string" &&
509
+ doc.embedding === undefined,
510
+ "Unaccounted embedding state.",
511
+ );
512
+ end = Math.max(end, z);
513
+ count++;
514
+ }
515
+ }
516
+ finish();
517
+ invariant(
518
+ completedFiles.size === expectedFile.size,
519
+ "Missing inventory file records.",
520
+ );
521
+ invariant(
522
+ seen.size === Object.keys(b.recordHashes).length,
523
+ "Missing artifact records.",
524
+ );
525
+ invariant(
526
+ hash(
527
+ Object.entries(b.recordHashes).sort(([a], [b]) => a.localeCompare(b)),
528
+ ) === b.recordsHash,
529
+ "Record inventory hash mismatch.",
530
+ );
531
+ }
532
+ export function corpusMetadata(
533
+ b: Build,
534
+ repoId: string,
535
+ indexId: string,
536
+ attemptId: string,
537
+ ): Doc[] {
538
+ const parts: Doc[] = [];
539
+ let entries: InventoryItem[] = [];
540
+ let bytes = 0;
541
+ const flush = () => {
542
+ if (entries.length) {
543
+ const n = parts.length;
544
+ parts.push({
545
+ id: `inventory-${n}`,
546
+ kind: "manifest",
547
+ role: "inventory",
548
+ schemaVersion: 1,
549
+ configHash: b.configHash,
550
+ partOrdinal: n,
551
+ partHash: hash(entries),
552
+ entries,
553
+ });
554
+ entries = [];
555
+ bytes = 0;
556
+ }
557
+ };
558
+ for (const entry of b.inventory) {
559
+ const size = Buffer.byteLength(canonical(entry));
560
+ invariant(size < 4_000_000, "Single inventory entry too large.");
561
+ if (bytes + size > 1_000_000) flush();
562
+ entries.push(entry);
563
+ bytes += size;
564
+ }
565
+ flush();
566
+ const root: Doc = {
567
+ id: "__manifest__",
568
+ kind: "manifest",
569
+ role: "corpus",
570
+ schemaVersion: 1,
571
+ configHash: b.configHash,
572
+ repoId,
573
+ indexId,
574
+ commitOid: b.commitOid,
575
+ commitTime: b.commitTime,
576
+ // Normalize the remote manifest so two ref spellings of one commit reuse it.
577
+ requestedRef: b.commitOid,
578
+ config: b.preset,
579
+ inventoryHash: b.inventoryHash,
580
+ recordsHash: b.recordsHash,
581
+ counts: b.counts,
582
+ buildId: b.buildId,
583
+ attemptId,
584
+ inventoryPartIds: parts.map((p) => p.id),
585
+ };
586
+ return [...parts, root];
587
+ }