@functional-systems/srcx 0.1.0-dev.3 → 0.1.0-dev.31

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 (61) hide show
  1. package/CHANGELOG.md +137 -1
  2. package/DESIGN.md +193 -58
  3. package/README.md +553 -38
  4. package/RELEASING.md +30 -9
  5. package/VALIDATION.md +855 -25
  6. package/dist/build.d.ts +81 -1
  7. package/dist/build.js +187 -37
  8. package/dist/build.js.map +1 -1
  9. package/dist/chunk.d.ts +9 -3
  10. package/dist/chunk.js +341 -14
  11. package/dist/chunk.js.map +1 -1
  12. package/dist/cli.js +103 -19
  13. package/dist/cli.js.map +1 -1
  14. package/dist/file-policy.d.ts +20 -0
  15. package/dist/file-policy.js +96 -0
  16. package/dist/file-policy.js.map +1 -0
  17. package/dist/implementation.d.ts +11 -0
  18. package/dist/implementation.js +191 -0
  19. package/dist/implementation.js.map +1 -0
  20. package/dist/publish.d.ts +18 -0
  21. package/dist/publish.js +158 -34
  22. package/dist/publish.js.map +1 -1
  23. package/dist/releases.js +21 -3
  24. package/dist/releases.js.map +1 -1
  25. package/dist/remote.d.ts +1 -0
  26. package/dist/remote.js +50 -9
  27. package/dist/remote.js.map +1 -1
  28. package/dist/repository.d.ts +4 -1
  29. package/dist/repository.js +26 -11
  30. package/dist/repository.js.map +1 -1
  31. package/dist/rerank.d.ts +16 -0
  32. package/dist/rerank.js +87 -0
  33. package/dist/rerank.js.map +1 -0
  34. package/dist/search.d.ts +14 -4
  35. package/dist/search.js +119 -23
  36. package/dist/search.js.map +1 -1
  37. package/dist/skills.d.ts +24 -0
  38. package/dist/skills.js +155 -0
  39. package/dist/skills.js.map +1 -0
  40. package/package.json +16 -5
  41. package/runtime/grammars/README.md +50 -0
  42. package/runtime/grammars/tree-sitter-bash.LICENSE +21 -0
  43. package/runtime/grammars/tree-sitter-bash.wasm +0 -0
  44. package/runtime/grammars/tree-sitter-sql.LICENSE +21 -0
  45. package/runtime/grammars/tree-sitter-sql.wasm +0 -0
  46. package/runtime/grammars/tree-sitter-wasm.LICENSE +21 -0
  47. package/runtime/qwen.py +132 -0
  48. package/runtime/requirements.txt +34 -0
  49. package/skills/srcx-search/SKILL.md +85 -0
  50. package/src/build.ts +258 -47
  51. package/src/chunk.ts +360 -12
  52. package/src/cli.ts +212 -24
  53. package/src/file-policy.ts +157 -0
  54. package/src/implementation.ts +253 -0
  55. package/src/publish.ts +226 -34
  56. package/src/releases.ts +36 -3
  57. package/src/remote.ts +70 -13
  58. package/src/repository.ts +40 -11
  59. package/src/rerank.ts +131 -0
  60. package/src/search.ts +154 -28
  61. package/src/skills.ts +205 -0
@@ -0,0 +1,253 @@
1
+ import type { Node } from "web-tree-sitter";
2
+ import { CHUNKER, detectLanguage, tokens, withSyntaxTree } from "./chunk.js";
3
+ import { invariant, lineAt } from "./common.js";
4
+
5
+ export type Implementation = {
6
+ status:
7
+ "resolved" | "not-linked" | "unsupported" | "parse-error" | "too-large";
8
+ startLine?: number;
9
+ endLine?: number;
10
+ symbol?: string;
11
+ };
12
+ type Definition = {
13
+ node: Node;
14
+ name: string;
15
+ key: string;
16
+ declaration: boolean;
17
+ };
18
+ const children = (n: Node): Node[] =>
19
+ n.namedChildren.filter((c): c is Node => !!c);
20
+
21
+ /** Only imported typing overload aliases used exclusively as decorators are safe here. */
22
+ function overloadAliases(root: Node): Set<string> {
23
+ const aliases = new Set<string>(),
24
+ imports = new Set<number>();
25
+ for (const n of children(root)) {
26
+ if (
27
+ n.type !== "import_from_statement" ||
28
+ !["typing", "typing_extensions"].includes(
29
+ n.childForFieldName("module_name")?.text ?? "",
30
+ )
31
+ )
32
+ continue;
33
+ for (const name of n.childrenForFieldName("name")) {
34
+ if (!name) continue;
35
+ if (name.type === "dotted_name" && name.text === "overload") {
36
+ aliases.add("overload");
37
+ imports.add(name.id);
38
+ } else if (
39
+ name.type === "aliased_import" &&
40
+ name.childForFieldName("name")?.text === "overload"
41
+ ) {
42
+ const alias = name.childForFieldName("alias");
43
+ if (alias) {
44
+ aliases.add(alias.text);
45
+ imports.add(name.id);
46
+ }
47
+ }
48
+ }
49
+ }
50
+ function visit(n: Node): void {
51
+ if (imports.has(n.id)) return;
52
+ if (n.type === "wildcard_import") aliases.clear();
53
+ if (
54
+ n.type === "identifier" &&
55
+ aliases.has(n.text) &&
56
+ !(n.parent?.type === "decorator" && n.parent.text === `@${n.text}`)
57
+ )
58
+ aliases.delete(n.text);
59
+ for (const child of children(n)) visit(child);
60
+ }
61
+ visit(root);
62
+ return aliases;
63
+ }
64
+ function definition(
65
+ n: Node,
66
+ python: boolean,
67
+ aliases: Set<string>,
68
+ ): Definition | undefined {
69
+ const d = n.childForFieldName(python ? "definition" : "declaration") ?? n;
70
+ const name = d.childForFieldName("name");
71
+ if (
72
+ !name ||
73
+ ![
74
+ "identifier",
75
+ "property_identifier",
76
+ "private_property_identifier",
77
+ ].includes(name.type)
78
+ )
79
+ return;
80
+ if (python) {
81
+ if (d.type !== "function_definition") return;
82
+ const decorators = children(n).filter((c) => c.type === "decorator");
83
+ const declaration = decorators.some((c) => aliases.has(c.text.slice(1)));
84
+ const body = d.childForFieldName("body");
85
+ // Stubs without an overload marker do not supply an implementation.
86
+ const executable =
87
+ body &&
88
+ children(body).some(
89
+ (c) =>
90
+ c.type !== "comment" &&
91
+ c.type !== "pass_statement" &&
92
+ !(
93
+ c.type === "expression_statement" &&
94
+ ["ellipsis", "string"].includes(c.firstNamedChild?.type ?? "")
95
+ ),
96
+ );
97
+ if (!declaration && !executable) return;
98
+ // Other decorators may change dispatch (property, singledispatch, etc.).
99
+ if (
100
+ decorators.some(
101
+ (c) =>
102
+ !aliases.has(c.text.slice(1)) &&
103
+ !["@staticmethod", "@classmethod"].includes(c.text),
104
+ )
105
+ )
106
+ return;
107
+ const modifier = decorators
108
+ .filter((c) => ["@staticmethod", "@classmethod"].includes(c.text))
109
+ .map((c) => c.text)
110
+ .sort()
111
+ .join(":");
112
+ const async = d.children.some((c) => c?.type === "async");
113
+ return {
114
+ node: n,
115
+ name: name.text,
116
+ key: `${name.text}:${modifier}:${async}`,
117
+ declaration,
118
+ };
119
+ }
120
+ if (n !== d && n.type !== "export_statement") return;
121
+ if (
122
+ ![
123
+ "function_signature",
124
+ "function_declaration",
125
+ "method_signature",
126
+ "method_definition",
127
+ ].includes(d.type)
128
+ )
129
+ return;
130
+ const prefix = d.text.slice(0, name.startIndex - d.startIndex);
131
+ if (/\b(abstract|get|set|declare)\b/.test(prefix)) return;
132
+ const method = d.type.startsWith("method");
133
+ const declaration = d.type.endsWith("signature");
134
+ if (!declaration && !d.childForFieldName("body")) return;
135
+ return {
136
+ node: n,
137
+ name: name.text,
138
+ key: `${method}:${/\bstatic\b/.test(prefix)}:${name.text}`,
139
+ declaration,
140
+ };
141
+ }
142
+
143
+ /** Follow only a contiguous, unambiguous overload group in the same AST container. */
144
+ export async function implementationSpan(
145
+ source: string,
146
+ path: string,
147
+ span: { startByte: number; endByte: number },
148
+ ): Promise<Implementation> {
149
+ const raw = Buffer.from(source),
150
+ lang = detectLanguage(path);
151
+ invariant(
152
+ Number.isInteger(span.startByte) &&
153
+ Number.isInteger(span.endByte) &&
154
+ span.startByte >= 0 &&
155
+ span.endByte > span.startByte &&
156
+ span.endByte <= raw.length,
157
+ "Invalid implementation source range.",
158
+ );
159
+ if (
160
+ !["python", "typescript", "tsx"].includes(lang) ||
161
+ /\.(pyi|d\.ts|d\.tsx)$/i.test(path)
162
+ )
163
+ return { status: "unsupported" };
164
+ const start = raw.subarray(0, span.startByte).toString("utf8").length;
165
+ const end = raw.subarray(0, span.endByte).toString("utf8").length;
166
+ return withSyntaxTree(source, lang, (root) => {
167
+ if (root.hasError) return { status: "parse-error" };
168
+ const python = lang === "python",
169
+ aliases = python ? overloadAliases(root) : new Set<string>();
170
+ const targets: Definition[] = [];
171
+ function visit(container: Node): void {
172
+ const siblings = children(container).filter((n) => n.type !== "comment");
173
+ const definitions = siblings.map((n) => definition(n, python, aliases));
174
+ for (let i = 0; i < definitions.length; i++) {
175
+ const d = definitions[i];
176
+ // A class/container hit is not a declaration hit. Require the selected
177
+ // span to end before the implementation, and contain a whole declaration.
178
+ if (
179
+ !d?.declaration ||
180
+ d.node.startIndex < start ||
181
+ d.node.endIndex > end
182
+ )
183
+ continue;
184
+ let first = i;
185
+ while (
186
+ first > 0 &&
187
+ definitions[first - 1]?.declaration &&
188
+ definitions[first - 1]?.key === d.key
189
+ )
190
+ first--;
191
+ let next = first;
192
+ while (
193
+ definitions[next]?.declaration &&
194
+ definitions[next]?.key === d.key
195
+ )
196
+ next++;
197
+ const target = definitions[next];
198
+ if (
199
+ !target ||
200
+ target.declaration ||
201
+ target.key !== d.key ||
202
+ target.node.startIndex < end
203
+ )
204
+ continue;
205
+ const matches = siblings.filter((n) => {
206
+ const node =
207
+ n.childForFieldName(python ? "definition" : "declaration") ?? n;
208
+ // TypeScript namespaces augment a function without adding another body.
209
+ // Keep other same-name declarations in the ambiguity check.
210
+ if (!python && node.type === "internal_module") return false;
211
+ return node.childForFieldName("name")?.text === d.name;
212
+ });
213
+ if (matches.length !== next - first + 1) continue;
214
+ // Reject unrelated code included in the selected chunk, but allow trivia
215
+ // between declarations (the chunker can attach comments/whitespace).
216
+ const selected = siblings.filter(
217
+ (n) => n.startIndex < end && n.endIndex > start,
218
+ );
219
+ if (
220
+ selected.some(
221
+ (n) =>
222
+ !definitions.slice(first, next).some((x) => x?.node.id === n.id),
223
+ )
224
+ )
225
+ continue;
226
+ if (!targets.some((t) => t.node.id === target.node.id))
227
+ targets.push(target);
228
+ }
229
+ for (const child of siblings) visit(child);
230
+ }
231
+ visit(root);
232
+ if (targets.length !== 1) return { status: "not-linked" };
233
+ const target = targets[0]!;
234
+ const startLine = lineAt(
235
+ raw,
236
+ Buffer.byteLength(source.slice(0, target.node.startIndex)),
237
+ );
238
+ const endLine = lineAt(
239
+ raw,
240
+ Buffer.byteLength(source.slice(0, target.node.endIndex)) - 1,
241
+ );
242
+ const text = (source.match(/[^\n]*\n|[^\n]+$/g) ?? [])
243
+ .slice(startLine - 1, endLine)
244
+ .join("");
245
+ if (tokens(text) > CHUNKER.maxTokens) return { status: "too-large" };
246
+ return {
247
+ status: "resolved",
248
+ startLine,
249
+ endLine,
250
+ symbol: target.name,
251
+ };
252
+ });
253
+ }
package/src/publish.ts CHANGED
@@ -10,7 +10,14 @@ import {
10
10
  sleep,
11
11
  type Doc,
12
12
  } from "./common.js";
13
- import { corpusMetadata, records, validateBuild, type Build } from "./build.js";
13
+ import {
14
+ corpusMetadata,
15
+ records,
16
+ recordHash,
17
+ supportedPreset,
18
+ validateBuild,
19
+ type Build,
20
+ } from "./build.js";
14
21
  import { branch, tag, one, type CollectionStore } from "./remote.js";
15
22
  export type Binding = {
16
23
  repoId: string;
@@ -36,6 +43,8 @@ export type Journal = {
36
43
  attemptId: string;
37
44
  writer: string;
38
45
  source: string;
46
+ gitRef?: string;
47
+ reuseWriter?: boolean;
39
48
  baseline?: Published;
40
49
  phase: "branch" | "writing" | "waiting" | "publishing";
41
50
  batches: {
@@ -45,6 +54,43 @@ export type Journal = {
45
54
  }[];
46
55
  candidate?: { name: string; snapshotId: string };
47
56
  };
57
+ /** The applied snapshot may differ from the shared canonical commit Tag. */
58
+ export type TrackedBranch = Doc & {
59
+ gitRef: string;
60
+ writer: string;
61
+ applied?: Published;
62
+ publishedTagName?: string;
63
+ pending?: { attemptId: string; buildId: string; commitOid: string };
64
+ };
65
+ export function gitBranchName(ref: string): string {
66
+ invariant(
67
+ ref.startsWith("refs/heads/") && ref.length > 11,
68
+ "Expected a full Git branch ref.",
69
+ );
70
+ return `git-${hash(ref).slice(0, 40)}`;
71
+ }
72
+ export const gitBranchId = (ref: string): string => `branch-${hash(ref)}`;
73
+ export const lastBuildPath = (state: string, ref?: string): string =>
74
+ join(state, ref ? `last-build-${hash(ref)}.json` : "last-build.json");
75
+ export async function trackedBranch(
76
+ store: CollectionStore,
77
+ binding: Binding,
78
+ ref: string,
79
+ ): Promise<TrackedBranch | undefined> {
80
+ const doc = await one(store, branch("main"), gitBranchId(ref), true);
81
+ if (!doc) return undefined;
82
+ invariant(
83
+ doc.kind === "manifest" &&
84
+ doc.role === "git-branch" &&
85
+ doc.gitRef === ref &&
86
+ doc.writer === gitBranchName(ref) &&
87
+ doc.repoId === binding.repoId &&
88
+ doc.indexId === binding.indexId &&
89
+ doc.configHash === binding.configHash,
90
+ "Tracked branch identity mismatch.",
91
+ );
92
+ return doc as TrackedBranch;
93
+ }
48
94
  export function versionTag(oid: string): string {
49
95
  return `ver-${hash(oid).slice(0, 40)}`;
50
96
  }
@@ -115,7 +161,11 @@ export async function validateCandidate(
115
161
  for (const d of corpusMetadata(b, binding.repoId, binding.indexId, attemptId))
116
162
  expected.set(d.id, hash(d));
117
163
  for await (const d of store.list(tag(name))) {
118
- if (expected.get(d.id) !== hash(d)) return false;
164
+ try {
165
+ if (expected.get(d.id) !== recordHash(d, b.preset)) return false;
166
+ } catch {
167
+ return false;
168
+ }
119
169
  expected.delete(d.id);
120
170
  }
121
171
  if (expected.size) return false;
@@ -125,10 +175,8 @@ export async function validateCandidate(
125
175
  };
126
176
  for await (const d of records(b.directory)) {
127
177
  if (d.kind !== "chunk") continue;
128
- // Keep a complete surface token: the standard analyzer may retain dots
129
- // (e.g. code.ts), so extracting an alphanumeric substring is not a valid probe.
130
- const term = String(d.symbol ?? d.searchText).match(/\S+/u)?.[0];
131
- if (!term) continue;
178
+ // Probe an indexed chunk ID: a valid source may contain only stop words
179
+ // or punctuation, so text-query matches cannot establish readiness.
132
180
  query = {
133
181
  bool: [
134
182
  {
@@ -141,16 +189,8 @@ export async function validateCandidate(
141
189
  },
142
190
  {
143
191
  queryString: {
144
- query: d.fileId,
145
- defaultField: "fileId",
146
- skipSyntax: true,
147
- },
148
- occur: "filter",
149
- },
150
- {
151
- queryString: {
152
- query: term,
153
- defaultField: "searchText",
192
+ query: d.id,
193
+ defaultField: "id",
154
194
  skipSyntax: true,
155
195
  },
156
196
  occur: "must",
@@ -164,7 +204,8 @@ export async function validateCandidate(
164
204
  !found.length ||
165
205
  found.some(
166
206
  (h) =>
167
- h.doc.kind !== "chunk" || b.recordHashes[h.doc.id] !== hash(h.doc),
207
+ h.doc.kind !== "chunk" ||
208
+ b.recordHashes[h.doc.id] !== recordHash(h.doc, b.preset),
168
209
  )
169
210
  )
170
211
  return false;
@@ -245,8 +286,8 @@ export async function publish(args: {
245
286
  "Build does not belong to this index.",
246
287
  );
247
288
  invariant(
248
- !b.preset.embedding,
249
- "CLI publication currently supports only the embedding=none preset.",
289
+ !b.preset.embedding || supportedPreset(b.preset),
290
+ "Publication requires a supported lexical or managed embedding preset.",
250
291
  );
251
292
  await validateBuild(b);
252
293
  const journalPath = join(state, "pending.json");
@@ -255,7 +296,9 @@ export async function publish(args: {
255
296
  invariant(
256
297
  j.buildId === b.buildId &&
257
298
  j.recordsHash === b.recordsHash &&
258
- j.inventoryHash === b.inventoryHash,
299
+ j.inventoryHash === b.inventoryHash &&
300
+ (j.gitRef === b.branchRef ||
301
+ (j.reuseWriter === undefined && j.writer.startsWith("work-"))),
259
302
  "Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
260
303
  );
261
304
  invariant(
@@ -266,6 +309,22 @@ export async function publish(args: {
266
309
  const existing = (await published(store, binding)).find(
267
310
  (v) => v.commitOid === b.commitOid,
268
311
  );
312
+ const tracked = b.branchRef
313
+ ? await trackedBranch(store, binding, b.branchRef)
314
+ : undefined;
315
+ if (tracked?.pending)
316
+ invariant(
317
+ j?.attemptId === tracked.pending.attemptId,
318
+ "Git branch has a pending import; resume with its original retry journal.",
319
+ );
320
+ if (j?.gitRef && j.phase !== "branch")
321
+ invariant(
322
+ tracked?.pending?.attemptId === j.attemptId ||
323
+ (j.phase === "publishing" &&
324
+ !tracked?.pending &&
325
+ tracked?.applied?.attemptId === j.attemptId),
326
+ "Tracked branch import ownership changed; refusing to replay this journal.",
327
+ );
269
328
  if (existing) {
270
329
  invariant(
271
330
  await validateCandidate(
@@ -277,17 +336,79 @@ export async function publish(args: {
277
336
  ),
278
337
  "Existing published version differs from this build.",
279
338
  );
280
- await atomic(join(state, "last-build.json"), {
281
- artifact: b.directory,
282
- version: existing,
283
- });
284
- if (j) await rm(journalPath);
285
- return existing;
339
+ // A shared commit Tag does not mean this Git branch has applied that commit.
340
+ if (!j && (!b.branchRef || tracked?.applied?.commitOid === b.commitOid)) {
341
+ if (tracked?.applied) {
342
+ const writer = (await store.branches()).find(
343
+ (x) => x.name === tracked.writer,
344
+ );
345
+ invariant(
346
+ writer?.snapshotId === tracked.applied.snapshotId,
347
+ "Writer baseline has changed; refusing to update tracked branch.",
348
+ );
349
+ invariant(
350
+ await validateCandidate(
351
+ store,
352
+ tracked.applied.tagName,
353
+ b,
354
+ binding,
355
+ tracked.applied.attemptId,
356
+ ),
357
+ "Tracked branch baseline validation failed.",
358
+ );
359
+ }
360
+ await atomic(lastBuildPath(state, b.branchRef), {
361
+ artifact: b.directory,
362
+ version: existing,
363
+ });
364
+ return existing;
365
+ }
366
+ // Legacy manual imports can finish bookkeeping after publication on retry.
367
+ if (j && !j.gitRef) {
368
+ await atomic(lastBuildPath(state), {
369
+ artifact: b.directory,
370
+ version: existing,
371
+ });
372
+ await rm(journalPath);
373
+ return existing;
374
+ }
286
375
  }
287
376
  if (!j) {
288
377
  let source = "checkpoint-empty";
289
378
  let baseline: Published | undefined;
290
- if (args.baseline) {
379
+ if (b.branchRef) {
380
+ const writer = (await store.branches()).find(
381
+ (x) => x.name === gitBranchName(b.branchRef!),
382
+ );
383
+ if (tracked?.applied) {
384
+ baseline = tracked.applied;
385
+ invariant(
386
+ writer?.snapshotId === baseline.snapshotId,
387
+ "Writer baseline has changed; refusing to update tracked branch.",
388
+ );
389
+ const pin = (await store.tags()).find(
390
+ (t) => t.name === baseline!.tagName,
391
+ );
392
+ const root = await one(store, tag(baseline.tagName), "__manifest__");
393
+ invariant(
394
+ pin?.snapshotId === baseline.snapshotId &&
395
+ root?.repoId === binding.repoId &&
396
+ root.indexId === binding.indexId &&
397
+ root.configHash === binding.configHash &&
398
+ root.buildId === baseline.buildId &&
399
+ root.attemptId === baseline.attemptId &&
400
+ root.recordsHash === baseline.recordsHash &&
401
+ root.inventoryHash === baseline.inventoryHash &&
402
+ root.commitOid === baseline.commitOid,
403
+ "Tracked branch baseline Tag is missing or changed.",
404
+ );
405
+ } else {
406
+ invariant(
407
+ !writer && !tracked,
408
+ "Unowned or unfinished tracked branch; retain its retry journal.",
409
+ );
410
+ }
411
+ } else if (args.baseline?.version.writer.startsWith("work-")) {
291
412
  const prev = args.baseline;
292
413
  await validateBuild(prev.build);
293
414
  invariant(
@@ -322,7 +443,9 @@ export async function publish(args: {
322
443
  inventoryHash: b.inventoryHash,
323
444
  artifact: b.directory,
324
445
  attemptId,
325
- writer: `work-${attemptId}`,
446
+ writer: b.branchRef ? gitBranchName(b.branchRef) : `work-${attemptId}`,
447
+ gitRef: b.branchRef,
448
+ reuseWriter: !!tracked?.applied,
326
449
  source,
327
450
  baseline,
328
451
  phase: "branch",
@@ -332,15 +455,48 @@ export async function publish(args: {
332
455
  }
333
456
  const save = () => atomic(journalPath, j);
334
457
  if (j.phase === "branch") {
458
+ if (j.gitRef) {
459
+ // Record intent before any corpus mutation, including before branch creation.
460
+ // A fresh local state must not treat a lagging, partially written head as clean.
461
+ const current = await trackedBranch(store, binding, j.gitRef);
462
+ invariant(
463
+ (!current?.pending || current.pending.attemptId === j.attemptId) &&
464
+ current?.applied?.snapshotId === j.baseline?.snapshotId,
465
+ "Git branch ownership or baseline changed before import.",
466
+ );
467
+ await store.upsert("main", [
468
+ {
469
+ ...current,
470
+ id: gitBranchId(j.gitRef),
471
+ kind: "manifest",
472
+ role: "git-branch",
473
+ schemaVersion: 1,
474
+ repoId: binding.repoId,
475
+ indexId: binding.indexId,
476
+ configHash: binding.configHash,
477
+ gitRef: j.gitRef,
478
+ writer: j.writer,
479
+ pending: {
480
+ attemptId: j.attemptId,
481
+ buildId: j.buildId,
482
+ commitOid: b.commitOid,
483
+ },
484
+ },
485
+ ]);
486
+ }
335
487
  const branches = await store.branches();
336
488
  const found = branches.find((x) => x.name === j!.writer);
337
489
  if (found)
338
490
  invariant(
339
- found.parent === j.source &&
491
+ (j.reuseWriter || found.parent === j.source) &&
340
492
  found.snapshotId === (j.baseline?.snapshotId ?? null),
341
493
  "Partially created writer has an unexpected baseline.",
342
494
  );
343
495
  else {
496
+ invariant(
497
+ !j.reuseWriter,
498
+ "Tracked writer is missing; refusing to recreate a nonempty baseline.",
499
+ );
344
500
  const source = branches.find((x) => x.name === j!.source);
345
501
  invariant(
346
502
  source &&
@@ -368,7 +524,7 @@ export async function publish(args: {
368
524
  if (j.baseline)
369
525
  for await (const d of store.list(tag(j.baseline.tagName))) {
370
526
  oldIds.add(d.id);
371
- oldHashes.set(d.id, hash(d));
527
+ oldHashes.set(d.id, recordHash(d, b.preset));
372
528
  }
373
529
  const newIds = new Set([
374
530
  ...Object.keys(b.recordHashes),
@@ -454,10 +610,12 @@ export async function publish(args: {
454
610
  const found = (await store.tags()).find((t) => t.name === name);
455
611
  const final = found ?? (await store.tag(name, tag(j.candidate.name)));
456
612
  invariant(
457
- final.snapshotId === j.candidate.snapshotId,
613
+ existing
614
+ ? final.snapshotId === existing.snapshotId
615
+ : final.snapshotId === j.candidate.snapshotId,
458
616
  "Published Tag name already points to a different snapshot.",
459
617
  );
460
- const result: Published = {
618
+ const result: Published = existing ?? {
461
619
  id: versionId(b.commitOid),
462
620
  kind: "manifest",
463
621
  role: "published",
@@ -477,8 +635,42 @@ export async function publish(args: {
477
635
  counts: b.counts,
478
636
  validation: { inventory: true, content: true, query: true },
479
637
  };
480
- await store.upsert("main", [result]);
481
- await atomic(join(state, "last-build.json"), {
638
+ if (!existing) await store.upsert("main", [result]);
639
+ if (j.gitRef) {
640
+ const current = await trackedBranch(store, binding, j.gitRef);
641
+ invariant(
642
+ current?.pending?.attemptId === j.attemptId ||
643
+ (!current?.pending && current?.applied?.attemptId === j.attemptId),
644
+ "Tracked branch publication ownership changed.",
645
+ );
646
+ // Pin the exact writer baseline even when another branch published this commit first.
647
+ const applied: Published = {
648
+ ...result,
649
+ writer: j.writer,
650
+ snapshotId: j.candidate.snapshotId,
651
+ tagName:
652
+ final.snapshotId === j.candidate.snapshotId
653
+ ? final.name
654
+ : j.candidate.name,
655
+ attemptId: j.attemptId,
656
+ };
657
+ await store.upsert("main", [
658
+ {
659
+ id: gitBranchId(j.gitRef),
660
+ kind: "manifest",
661
+ role: "git-branch",
662
+ schemaVersion: 1,
663
+ repoId: binding.repoId,
664
+ indexId: binding.indexId,
665
+ configHash: binding.configHash,
666
+ gitRef: j.gitRef,
667
+ writer: j.writer,
668
+ applied,
669
+ publishedTagName: result.tagName,
670
+ },
671
+ ]);
672
+ }
673
+ await atomic(lastBuildPath(state, j.gitRef), {
482
674
  artifact: b.directory,
483
675
  version: result,
484
676
  });
package/src/releases.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { hash, invariant, type Doc } from "./common.js";
2
2
  import { gitTags } from "./git.js";
3
3
  import { one, branch, type CollectionStore } from "./remote.js";
4
- import { published, type Binding, type Published } from "./publish.js";
4
+ import {
5
+ published,
6
+ trackedBranch,
7
+ type Binding,
8
+ type Published,
9
+ } from "./publish.js";
5
10
  export const releaseAlias = (ref: string) => `rel-${hash(ref).slice(0, 40)}`;
6
11
  export const releaseId = (ref: string) => `ref-${hash(ref)}`;
7
12
  /** Only observed local tags. Absence is not deletion authority; this command never prunes. */
@@ -58,11 +63,39 @@ export async function resolveVersion(
58
63
  binding: Binding,
59
64
  selector: string,
60
65
  ): Promise<Published> {
61
- const versions = await published(store, binding);
62
66
  const ref = selector.startsWith("refs/tags/")
63
67
  ? selector
64
68
  : `refs/tags/${selector}`;
65
- const control = await one(store, branch("main"), releaseId(ref), true);
69
+ const control = selector.startsWith("refs/heads/")
70
+ ? undefined
71
+ : await one(store, branch("main"), releaseId(ref), true);
72
+ const headRef = selector.startsWith("refs/heads/")
73
+ ? selector
74
+ : `refs/heads/${selector}`;
75
+ const head =
76
+ !selector.startsWith("refs/") || selector.startsWith("refs/heads/")
77
+ ? await trackedBranch(store, binding, headRef)
78
+ : undefined;
79
+ invariant(
80
+ !(head && control),
81
+ "Ambiguous branch/tag name: use refs/heads/... or refs/tags/...",
82
+ );
83
+ // Pin moving ref control before listing versions. Otherwise publication can
84
+ // advance the ref to a version absent from the earlier list.
85
+ const versions = await published(store, binding);
86
+ if (head) {
87
+ invariant(
88
+ head.applied,
89
+ "Git branch has no published version yet; finish its import.",
90
+ );
91
+ const result = versions.find(
92
+ (v) =>
93
+ v.commitOid === head.applied!.commitOid &&
94
+ v.tagName === head.publishedTagName,
95
+ );
96
+ invariant(result, "Git branch target is not a published version.");
97
+ return result;
98
+ }
66
99
  // Like Git import, a known tag name takes precedence over an OID spelling.
67
100
  // Its pending or invalid state must not fall back to an unrelated commit.
68
101
  if (!control && !selector.startsWith("refs/")) {