@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.
- package/CHANGELOG.md +137 -1
- package/DESIGN.md +193 -58
- package/README.md +553 -38
- package/RELEASING.md +30 -9
- package/VALIDATION.md +855 -25
- package/dist/build.d.ts +81 -1
- package/dist/build.js +187 -37
- package/dist/build.js.map +1 -1
- package/dist/chunk.d.ts +9 -3
- package/dist/chunk.js +341 -14
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +103 -19
- package/dist/cli.js.map +1 -1
- package/dist/file-policy.d.ts +20 -0
- package/dist/file-policy.js +96 -0
- package/dist/file-policy.js.map +1 -0
- package/dist/implementation.d.ts +11 -0
- package/dist/implementation.js +191 -0
- package/dist/implementation.js.map +1 -0
- package/dist/publish.d.ts +18 -0
- package/dist/publish.js +158 -34
- package/dist/publish.js.map +1 -1
- package/dist/releases.js +21 -3
- package/dist/releases.js.map +1 -1
- package/dist/remote.d.ts +1 -0
- package/dist/remote.js +50 -9
- package/dist/remote.js.map +1 -1
- package/dist/repository.d.ts +4 -1
- package/dist/repository.js +26 -11
- package/dist/repository.js.map +1 -1
- package/dist/rerank.d.ts +16 -0
- package/dist/rerank.js +87 -0
- package/dist/rerank.js.map +1 -0
- package/dist/search.d.ts +14 -4
- package/dist/search.js +119 -23
- package/dist/search.js.map +1 -1
- package/dist/skills.d.ts +24 -0
- package/dist/skills.js +155 -0
- package/dist/skills.js.map +1 -0
- package/package.json +16 -5
- package/runtime/grammars/README.md +50 -0
- package/runtime/grammars/tree-sitter-bash.LICENSE +21 -0
- package/runtime/grammars/tree-sitter-bash.wasm +0 -0
- package/runtime/grammars/tree-sitter-sql.LICENSE +21 -0
- package/runtime/grammars/tree-sitter-sql.wasm +0 -0
- package/runtime/grammars/tree-sitter-wasm.LICENSE +21 -0
- package/runtime/qwen.py +132 -0
- package/runtime/requirements.txt +34 -0
- package/skills/srcx-search/SKILL.md +85 -0
- package/src/build.ts +258 -47
- package/src/chunk.ts +360 -12
- package/src/cli.ts +212 -24
- package/src/file-policy.ts +157 -0
- package/src/implementation.ts +253 -0
- package/src/publish.ts +226 -34
- package/src/releases.ts +36 -3
- package/src/remote.ts +70 -13
- package/src/repository.ts +40 -11
- package/src/rerank.ts +131 -0
- package/src/search.ts +154 -28
- 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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
129
|
-
//
|
|
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.
|
|
145
|
-
defaultField: "
|
|
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" ||
|
|
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
|
-
"
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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/")) {
|