@brainervirus/workit-core 1.2.0 → 1.3.0
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/package.json +1 -1
- package/skills/workit-babysit/SKILL.md +20 -26
- package/skills/workit-behavioral-tdd/SKILL.md +0 -5
- package/skills/workit-blast-radius/SKILL.md +0 -5
- package/skills/workit-challenge/SKILL.md +30 -47
- package/skills/workit-debug/SKILL.md +5 -27
- package/skills/workit-deslop/SKILL.md +0 -5
- package/skills/workit-diagram/SKILL.md +0 -5
- package/skills/workit-green-run/SKILL.md +0 -5
- package/skills/workit-handoff/SKILL.md +2 -8
- package/skills/workit-implement/SKILL.md +14 -20
- package/skills/workit-mockup/SKILL.md +0 -5
- package/skills/workit-plan/SKILL.md +30 -63
- package/skills/workit-review/SKILL.md +0 -5
- package/skills/workit-steer/SKILL.md +27 -27
- package/src/core/authority.ts +31 -8
- package/src/core/auto-approval.ts +44 -32
- package/src/core/branch.ts +293 -42
- package/src/core/config-conversion.ts +4 -4
- package/src/core/config-guard.ts +1 -7
- package/src/core/config.ts +1 -1
- package/src/core/cutover.ts +1207 -140
- package/src/core/doctor.ts +155 -82
- package/src/core/external-action-effects.ts +1102 -209
- package/src/core/external-action.ts +230 -103
- package/src/core/init.ts +25 -115
- package/src/core/legacy-ownership.ts +47 -0
- package/src/core/methods.ts +56 -52
- package/src/core/ports/pr-create.ts +23 -15
- package/src/core/pr-create.ts +374 -84
- package/src/core/registration.ts +0 -15
- package/src/core/repo-tools.ts +0 -1
- package/src/core/route-intent.ts +34 -82
- package/src/core/safe-write.ts +66 -1
- package/src/core/setup-state.ts +7 -0
- package/src/core/setup.ts +175 -82
- package/src/core/task-contract.ts +16 -3
- package/src/core/task-engine.ts +136 -31
- package/src/core/task-evaluation.ts +3 -5
- package/src/core/task-store.ts +159 -1
- package/src/core/tracker-issues.ts +55 -118
- package/src/core/vcs-config.ts +178 -187
- package/src/core/workers.ts +17 -0
- package/src/core/workspaces.ts +548 -46
- package/src/core.ts +1 -2
- package/src/core/ports/vcs-token-create-urls.ts +0 -4
package/src/core/workspaces.ts
CHANGED
|
@@ -1,35 +1,209 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import {
|
|
6
|
+
COMMIT_PRESETS,
|
|
7
|
+
PRESETS,
|
|
8
|
+
configDir,
|
|
9
|
+
isConfigObject,
|
|
10
|
+
resolveBranchPolicy as resolveConfiguredBranchPolicy,
|
|
11
|
+
resolveCommitPolicy as resolveConfiguredCommitPolicy,
|
|
12
|
+
type BranchPreset,
|
|
13
|
+
type ToolkitConfig,
|
|
14
|
+
} from "./config";
|
|
4
15
|
import type { CommitFlavorPreset } from "./commit-flavors";
|
|
5
16
|
|
|
6
17
|
export type VcsProvider = "gitlab" | "github";
|
|
7
18
|
|
|
8
19
|
export type IntegrationMode = "pr" | "merge";
|
|
9
20
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const nonBlank = z.string().refine((value) => value.trim().length > 0, "must not be blank");
|
|
22
|
+
const branchPreset = z.custom<BranchPreset>(
|
|
23
|
+
(value) => typeof value === "string" && Object.hasOwn(PRESETS, value),
|
|
24
|
+
"unsupported branch preset",
|
|
25
|
+
);
|
|
26
|
+
const commitPreset = z.custom<CommitFlavorPreset>(
|
|
27
|
+
(value) => typeof value === "string" && COMMIT_PRESETS.includes(value),
|
|
28
|
+
"unsupported commit preset",
|
|
29
|
+
);
|
|
30
|
+
const workspaceBranchPolicySchema = z
|
|
31
|
+
.object({
|
|
32
|
+
preset: branchPreset,
|
|
33
|
+
developBranch: nonBlank.optional(),
|
|
34
|
+
prefixes: z
|
|
35
|
+
.object({
|
|
36
|
+
feature: nonBlank,
|
|
37
|
+
bugfix: nonBlank,
|
|
38
|
+
release: nonBlank,
|
|
39
|
+
hotfix: nonBlank,
|
|
40
|
+
})
|
|
41
|
+
.passthrough()
|
|
42
|
+
.optional(),
|
|
43
|
+
allowed: z.array(nonBlank).optional(),
|
|
44
|
+
protected: z.array(nonBlank).optional(),
|
|
45
|
+
integration: z.enum(["pr", "merge"]).optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
const workspaceCommitPolicySchema = z
|
|
49
|
+
.object({ preset: commitPreset, pattern: z.string().optional() })
|
|
50
|
+
.strict();
|
|
51
|
+
const workspaceProfileSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
branchPolicy: workspaceBranchPolicySchema.optional(),
|
|
54
|
+
commitPolicy: workspaceCommitPolicySchema.optional(),
|
|
55
|
+
})
|
|
56
|
+
.strict()
|
|
57
|
+
.refine((profile) => profile.branchPolicy !== undefined || profile.commitPolicy !== undefined, {
|
|
58
|
+
message: "a profile must define branchPolicy or commitPolicy",
|
|
59
|
+
});
|
|
60
|
+
const versionSourceSchema = z.discriminatedUnion("kind", [
|
|
61
|
+
z
|
|
62
|
+
.object({
|
|
63
|
+
kind: z.literal("package-json"),
|
|
64
|
+
path: nonBlank,
|
|
65
|
+
field: nonBlank,
|
|
66
|
+
})
|
|
67
|
+
.strict(),
|
|
68
|
+
z.object({ kind: z.literal("git-tag") }).strict(),
|
|
69
|
+
z.object({ kind: z.literal("manual") }).strict(),
|
|
70
|
+
]);
|
|
71
|
+
const releaseTrackSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
strategy: branchPreset,
|
|
74
|
+
productionBranch: nonBlank,
|
|
75
|
+
integrationBranch: nonBlank,
|
|
76
|
+
naming: z.object({ feature: nonBlank, release: nonBlank, hotfix: nonBlank }).strict(),
|
|
77
|
+
baseBranch: nonBlank,
|
|
78
|
+
mergeBackBranches: z.array(nonBlank),
|
|
79
|
+
pullRequestTarget: nonBlank,
|
|
80
|
+
tagNamespace: z.string(),
|
|
81
|
+
versionSource: versionSourceSchema,
|
|
82
|
+
requiredChecks: z.array(nonBlank),
|
|
83
|
+
})
|
|
84
|
+
.strict()
|
|
85
|
+
.superRefine((track, context) => {
|
|
86
|
+
for (const [field, values] of [
|
|
87
|
+
["mergeBackBranches", track.mergeBackBranches],
|
|
88
|
+
["requiredChecks", track.requiredChecks],
|
|
89
|
+
] as const) {
|
|
90
|
+
if (new Set(values).size !== values.length) {
|
|
91
|
+
context.addIssue({ code: "custom", path: [field], message: "entries must be unique" });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
track.versionSource.kind === "package-json" &&
|
|
96
|
+
(path.posix.isAbsolute(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
97
|
+
/^[A-Za-z]:\//u.test(track.versionSource.path.replaceAll("\\", "/")) ||
|
|
98
|
+
track.versionSource.path.replaceAll("\\", "/").split("/").includes(".."))
|
|
99
|
+
) {
|
|
100
|
+
context.addIssue({
|
|
101
|
+
code: "custom",
|
|
102
|
+
path: ["versionSource", "path"],
|
|
103
|
+
message: "must stay inside the repository",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
const workspaceConfigSchema = z
|
|
108
|
+
.object({
|
|
109
|
+
name: nonBlank,
|
|
110
|
+
glob: nonBlank,
|
|
111
|
+
vcs: z
|
|
112
|
+
.object({
|
|
113
|
+
provider: z.enum(["gitlab", "github"]),
|
|
114
|
+
defaultTargetBranch: nonBlank.optional(),
|
|
115
|
+
tokenFile: nonBlank.optional(),
|
|
116
|
+
account: nonBlank.optional(),
|
|
117
|
+
})
|
|
118
|
+
.passthrough()
|
|
119
|
+
.optional(),
|
|
120
|
+
autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
|
|
121
|
+
youtrack: z
|
|
122
|
+
.object({ baseUrl: nonBlank.optional(), link_issues: z.boolean().optional() })
|
|
123
|
+
.passthrough()
|
|
124
|
+
.optional(),
|
|
125
|
+
issues: z
|
|
126
|
+
.object({ provider: z.literal("github").optional(), link_on_pr: z.boolean().optional() })
|
|
127
|
+
.passthrough()
|
|
128
|
+
.optional(),
|
|
129
|
+
branchPolicy: workspaceBranchPolicySchema.optional(),
|
|
130
|
+
commitPolicy: workspaceCommitPolicySchema.optional(),
|
|
131
|
+
defaultProfile: nonBlank.optional(),
|
|
132
|
+
profiles: z.record(nonBlank, workspaceProfileSchema).optional(),
|
|
133
|
+
releaseTracks: z.record(nonBlank, releaseTrackSchema).optional(),
|
|
134
|
+
})
|
|
135
|
+
.passthrough()
|
|
136
|
+
.superRefine((workspace, context) => {
|
|
137
|
+
if (workspace.youtrack && workspace.vcs?.provider !== "gitlab") {
|
|
138
|
+
context.addIssue({
|
|
139
|
+
code: "custom",
|
|
140
|
+
path: ["youtrack"],
|
|
141
|
+
message: `YouTrack issue linking requires the gitlab provider, got ${workspace.vcs?.provider ?? "unset"}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (workspace.issues && workspace.vcs?.provider !== "github") {
|
|
145
|
+
context.addIssue({
|
|
146
|
+
code: "custom",
|
|
147
|
+
path: ["issues"],
|
|
148
|
+
message: `GitHub issue linking requires the github provider, got ${workspace.vcs?.provider ?? "unset"}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
workspace.defaultProfile !== undefined &&
|
|
153
|
+
!Object.hasOwn(workspace.profiles ?? {}, workspace.defaultProfile)
|
|
154
|
+
) {
|
|
155
|
+
context.addIssue({
|
|
156
|
+
code: "custom",
|
|
157
|
+
path: ["defaultProfile"],
|
|
158
|
+
message: `does not name a configured profile`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
const workspacesFileSchema = z
|
|
163
|
+
.object({ workspaces: z.array(workspaceConfigSchema).optional() })
|
|
164
|
+
.passthrough()
|
|
165
|
+
.superRefine((file, context) => {
|
|
166
|
+
const names = new Set<string>();
|
|
167
|
+
for (const [index, workspace] of (file.workspaces ?? []).entries()) {
|
|
168
|
+
if (names.has(workspace.name)) {
|
|
169
|
+
context.addIssue({
|
|
170
|
+
code: "custom",
|
|
171
|
+
path: ["workspaces", index, "name"],
|
|
172
|
+
message: `duplicate workspace name ${JSON.stringify(workspace.name)}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
names.add(workspace.name);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
18
178
|
|
|
19
|
-
export type
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
179
|
+
export type WorkspaceBranchPolicy = z.infer<typeof workspaceBranchPolicySchema>;
|
|
180
|
+
export type WorkspaceCommitPolicy = z.infer<typeof workspaceCommitPolicySchema>;
|
|
181
|
+
export type WorkspaceProfile = z.infer<typeof workspaceProfileSchema>;
|
|
182
|
+
export type ReleaseTrack = z.infer<typeof releaseTrackSchema>;
|
|
183
|
+
export type WorkspaceConfig = z.infer<typeof workspaceConfigSchema>;
|
|
184
|
+
|
|
185
|
+
export type WorkspaceProfileResolution =
|
|
186
|
+
| {
|
|
187
|
+
status: "resolved";
|
|
188
|
+
profileName: string | null;
|
|
189
|
+
workspace: WorkspaceConfig;
|
|
190
|
+
provenance: { branchPolicy: string; commitPolicy: string };
|
|
191
|
+
}
|
|
192
|
+
| { status: "invalid"; error: string };
|
|
193
|
+
|
|
194
|
+
export type ReleaseTrackSelection =
|
|
195
|
+
| { status: "selected"; name: string; track: ReleaseTrack }
|
|
196
|
+
| { status: "not_configured" }
|
|
197
|
+
| { status: "choice_required"; choices: string[] }
|
|
198
|
+
| { status: "invalid"; error: string };
|
|
199
|
+
|
|
200
|
+
export type WorkspacePolicyResolution = {
|
|
201
|
+
status: "resolved";
|
|
202
|
+
workspace: WorkspaceConfig | null;
|
|
203
|
+
profileName: string | null;
|
|
204
|
+
branchPolicy: ReturnType<typeof resolveConfiguredBranchPolicy>;
|
|
205
|
+
commitPolicy: ReturnType<typeof resolveConfiguredCommitPolicy>;
|
|
206
|
+
provenance: { branchPolicy: string; commitPolicy: string };
|
|
33
207
|
};
|
|
34
208
|
|
|
35
209
|
export const workspacesPath = (): string => path.join(configDir(), "workspaces.json");
|
|
@@ -38,35 +212,89 @@ export const workspacesPath = (): string => path.join(configDir(), "workspaces.j
|
|
|
38
212
|
// (empty list); malformed JSON is reported with the exact path so risky
|
|
39
213
|
// consumers (wizard/installer/doctor) can block instead of silently resetting.
|
|
40
214
|
export type WorkspacesResult = {
|
|
41
|
-
status: "missing" | "valid" | "malformed";
|
|
215
|
+
status: "missing" | "valid" | "malformed" | "invalid";
|
|
42
216
|
path: string;
|
|
43
217
|
entries: WorkspaceConfig[];
|
|
218
|
+
document?: Record<string, unknown>;
|
|
219
|
+
revision?: string;
|
|
44
220
|
error?: string;
|
|
45
221
|
};
|
|
46
222
|
|
|
223
|
+
export const workspacesRevision = (contents: string | null): string =>
|
|
224
|
+
createHash("sha256")
|
|
225
|
+
.update(contents ?? "<missing>")
|
|
226
|
+
.digest("hex");
|
|
227
|
+
|
|
228
|
+
export const validateWorkspacesDocument = (parsed: unknown, file: string): WorkspacesResult => {
|
|
229
|
+
if (!isConfigObject(parsed)) {
|
|
230
|
+
return { status: "malformed", path: file, entries: [], error: `${file} is not a JSON object` };
|
|
231
|
+
}
|
|
232
|
+
const validated = workspacesFileSchema.safeParse(parsed, { reportInput: true });
|
|
233
|
+
if (!validated.success) {
|
|
234
|
+
const detail = validated.error.issues
|
|
235
|
+
.map((issue) => {
|
|
236
|
+
const field = String(issue.path[issue.path.length - 1] ?? "");
|
|
237
|
+
const attempted =
|
|
238
|
+
["provider", "preset", "strategy"].includes(field) && typeof issue.input === "string"
|
|
239
|
+
? ` (${JSON.stringify(issue.input)})`
|
|
240
|
+
: "";
|
|
241
|
+
return `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}${attempted}`;
|
|
242
|
+
})
|
|
243
|
+
.join("; ");
|
|
244
|
+
return {
|
|
245
|
+
status: "invalid",
|
|
246
|
+
path: file,
|
|
247
|
+
entries: [],
|
|
248
|
+
error: `${file} has invalid workspace configuration: ${detail}`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
for (const [index, workspace] of (validated.data.workspaces ?? []).entries()) {
|
|
252
|
+
const glob = validateWorkspaceGlob(workspace.glob);
|
|
253
|
+
if (!glob.ok) {
|
|
254
|
+
return {
|
|
255
|
+
status: "invalid",
|
|
256
|
+
path: file,
|
|
257
|
+
entries: [],
|
|
258
|
+
error: `${file} workspaces.${index}.glob: ${glob.error}`,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
status: "valid",
|
|
264
|
+
path: file,
|
|
265
|
+
entries: validated.data.workspaces ?? [],
|
|
266
|
+
document: parsed as Record<string, unknown>,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
47
270
|
export const readWorkspacesResult = (dir: string = configDir()): WorkspacesResult => {
|
|
48
271
|
const file = path.join(dir, "workspaces.json");
|
|
49
272
|
let raw: string;
|
|
50
273
|
try {
|
|
51
274
|
raw = readFileSync(file, "utf8");
|
|
52
|
-
} catch {
|
|
53
|
-
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
277
|
+
return { status: "missing", path: file, entries: [], revision: workspacesRevision(null) };
|
|
278
|
+
return {
|
|
279
|
+
status: "malformed",
|
|
280
|
+
path: file,
|
|
281
|
+
entries: [],
|
|
282
|
+
error: `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
283
|
+
};
|
|
54
284
|
}
|
|
55
285
|
let parsed: unknown;
|
|
56
286
|
try {
|
|
57
287
|
parsed = JSON.parse(raw);
|
|
58
288
|
} catch {
|
|
59
|
-
return {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
289
|
+
return {
|
|
290
|
+
status: "malformed",
|
|
291
|
+
path: file,
|
|
292
|
+
entries: [],
|
|
293
|
+
revision: workspacesRevision(raw),
|
|
294
|
+
error: `${file} is not valid JSON`,
|
|
295
|
+
};
|
|
63
296
|
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
status: "valid",
|
|
67
|
-
path: file,
|
|
68
|
-
entries: Array.isArray(list) ? (list as WorkspaceConfig[]) : [],
|
|
69
|
-
};
|
|
297
|
+
return { ...validateWorkspacesDocument(parsed, file), revision: workspacesRevision(raw) };
|
|
70
298
|
};
|
|
71
299
|
|
|
72
300
|
/** Parse the workspaces.json list under an explicit config dir; [] when missing/malformed. */
|
|
@@ -182,27 +410,301 @@ const canonicalGlob = (glob: string): string => {
|
|
|
182
410
|
};
|
|
183
411
|
|
|
184
412
|
/** Match a cwd against the workspaces.json under an explicit config dir. */
|
|
185
|
-
export const resolveWorkspaceFrom = (
|
|
413
|
+
export const resolveWorkspaceFrom = (
|
|
414
|
+
cwd: string,
|
|
415
|
+
dir: string,
|
|
416
|
+
workspaceName?: string,
|
|
417
|
+
): WorkspaceConfig | null => {
|
|
186
418
|
// macOS/Windows tmpdir symlinks (/var -> /private/var): git's
|
|
187
419
|
// --show-toplevel returns the realpath while config globs are usually
|
|
188
420
|
// written with the logical path, so a workspace would silently stop
|
|
189
421
|
// matching on macOS. Match both forms on each side — same class as the
|
|
190
422
|
// docs-migration escape-guard realpath comparison; on Linux both forms
|
|
191
423
|
// are identical so behavior is unchanged.
|
|
424
|
+
const result = readWorkspacesResult(dir);
|
|
425
|
+
if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
|
|
192
426
|
const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const ws = entry as WorkspaceConfig;
|
|
196
|
-
if (typeof ws.glob !== "string" || !ws.glob) continue;
|
|
427
|
+
const matches: WorkspaceConfig[] = [];
|
|
428
|
+
for (const ws of result.entries) {
|
|
197
429
|
const glob = ws.glob.replaceAll("\\", "/");
|
|
198
430
|
const canonical = canonicalGlob(glob);
|
|
431
|
+
let matched = false;
|
|
199
432
|
for (const target of targets) {
|
|
200
|
-
if (
|
|
201
|
-
|
|
433
|
+
if (
|
|
434
|
+
matchWorkspace(glob, target) ||
|
|
435
|
+
(canonical !== glob && matchWorkspace(canonical, target))
|
|
436
|
+
) {
|
|
437
|
+
matched = true;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
202
440
|
}
|
|
441
|
+
if (matched) matches.push(ws);
|
|
442
|
+
}
|
|
443
|
+
if (workspaceName !== undefined) {
|
|
444
|
+
const selected = matches.find((ws) => ws.name === workspaceName);
|
|
445
|
+
if (!selected && matches.length > 0) {
|
|
446
|
+
const matchingNames = matches.map((ws) => ws.name);
|
|
447
|
+
throw new Error(
|
|
448
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matchingNames.join(", ")}`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (selected) return selected;
|
|
452
|
+
throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
|
|
453
|
+
}
|
|
454
|
+
if (matches.length > 1) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`ambiguous workspace for ${cwd}; choose one of: ${matches.map((ws) => ws.name).join(", ")}`,
|
|
457
|
+
);
|
|
203
458
|
}
|
|
204
|
-
return null;
|
|
459
|
+
return matches[0] ?? null;
|
|
205
460
|
};
|
|
206
461
|
|
|
207
|
-
export const resolveWorkspace = (
|
|
208
|
-
|
|
462
|
+
export const resolveWorkspace = (
|
|
463
|
+
cwd: string,
|
|
464
|
+
workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined,
|
|
465
|
+
): WorkspaceConfig | null => resolveWorkspaceFrom(cwd, configDir(), workspaceName);
|
|
466
|
+
|
|
467
|
+
type RuntimeWorkspacePolicy = "branch" | "commit" | "vcs";
|
|
468
|
+
type RuntimeWorkspaceCandidate = {
|
|
469
|
+
name: string;
|
|
470
|
+
glob: string;
|
|
471
|
+
defaultProfile?: string;
|
|
472
|
+
profiles?: Record<string, Record<string, unknown>>;
|
|
473
|
+
branchPolicy?: WorkspaceBranchPolicy;
|
|
474
|
+
commitPolicy?: WorkspaceCommitPolicy;
|
|
475
|
+
vcs?: WorkspaceConfig["vcs"];
|
|
476
|
+
youtrack?: unknown;
|
|
477
|
+
issues?: unknown;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const runtimeWorkspacePolicySchema = (kind: RuntimeWorkspacePolicy) => {
|
|
481
|
+
const key = kind === "branch" ? "branchPolicy" : kind === "commit" ? "commitPolicy" : "vcs";
|
|
482
|
+
const policySchema =
|
|
483
|
+
kind === "branch"
|
|
484
|
+
? workspaceBranchPolicySchema
|
|
485
|
+
: kind === "commit"
|
|
486
|
+
? workspaceCommitPolicySchema
|
|
487
|
+
: z
|
|
488
|
+
.object({
|
|
489
|
+
provider: z.enum(["gitlab", "github"]),
|
|
490
|
+
defaultTargetBranch: nonBlank.optional(),
|
|
491
|
+
account: nonBlank.optional(),
|
|
492
|
+
})
|
|
493
|
+
.passthrough();
|
|
494
|
+
const profileSchema = z.object({ [key]: policySchema.optional() }).passthrough();
|
|
495
|
+
const entrySchema = z
|
|
496
|
+
.object({
|
|
497
|
+
name: nonBlank,
|
|
498
|
+
glob: nonBlank,
|
|
499
|
+
[key]: policySchema.optional(),
|
|
500
|
+
defaultProfile: nonBlank.optional(),
|
|
501
|
+
profiles: z.record(nonBlank, profileSchema).optional(),
|
|
502
|
+
})
|
|
503
|
+
.passthrough();
|
|
504
|
+
return entrySchema;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
export type RuntimeWorkspaceVcs = Pick<
|
|
508
|
+
RuntimeWorkspaceCandidate,
|
|
509
|
+
"name" | "branchPolicy" | "vcs" | "youtrack" | "issues"
|
|
510
|
+
>;
|
|
511
|
+
|
|
512
|
+
export const resolveRuntimeWorkspaceVcs = (cwd: string): RuntimeWorkspaceVcs | null =>
|
|
513
|
+
resolveRuntimeWorkspaceCandidate(cwd, "vcs");
|
|
514
|
+
|
|
515
|
+
const runtimeWorkspaceIndexSchema = z
|
|
516
|
+
.object({
|
|
517
|
+
workspaces: z.array(z.object({ name: nonBlank, glob: nonBlank }).passthrough()).optional(),
|
|
518
|
+
})
|
|
519
|
+
.passthrough();
|
|
520
|
+
|
|
521
|
+
const resolveRuntimeWorkspaceCandidate = (
|
|
522
|
+
cwd: string,
|
|
523
|
+
kind: RuntimeWorkspacePolicy,
|
|
524
|
+
): RuntimeWorkspaceCandidate | null => {
|
|
525
|
+
const file = path.join(configDir(), "workspaces.json");
|
|
526
|
+
let raw: string;
|
|
527
|
+
try {
|
|
528
|
+
raw = readFileSync(file, "utf8");
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
531
|
+
throw new Error(
|
|
532
|
+
`${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
let parsed: unknown;
|
|
536
|
+
try {
|
|
537
|
+
parsed = JSON.parse(raw);
|
|
538
|
+
} catch {
|
|
539
|
+
throw new Error(`${file} is not valid JSON`);
|
|
540
|
+
}
|
|
541
|
+
const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
|
|
542
|
+
if (!validated.success) {
|
|
543
|
+
const detail = validated.error.issues
|
|
544
|
+
.map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
|
|
545
|
+
.join("; ");
|
|
546
|
+
throw new Error(`${file} has invalid workspace matching data: ${detail}`);
|
|
547
|
+
}
|
|
548
|
+
const entries = validated.data.workspaces as RuntimeWorkspaceCandidate[] | undefined;
|
|
549
|
+
for (const entry of entries ?? []) {
|
|
550
|
+
const glob = validateWorkspaceGlob(entry.glob);
|
|
551
|
+
if (!glob.ok) throw new Error(`${file} workspace ${entry.name}: ${glob.error}`);
|
|
552
|
+
}
|
|
553
|
+
const targets = [cwd, realpathOf(cwd)].map((value) => value.replaceAll("\\", "/"));
|
|
554
|
+
const matches = (entries ?? []).filter((entry) => {
|
|
555
|
+
const glob = entry.glob.replaceAll("\\", "/");
|
|
556
|
+
const canonical = canonicalGlob(glob);
|
|
557
|
+
return targets.some(
|
|
558
|
+
(target) =>
|
|
559
|
+
matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
|
|
563
|
+
let selected: RuntimeWorkspaceCandidate | undefined;
|
|
564
|
+
if (workspaceName) {
|
|
565
|
+
const named = matches.filter((entry) => entry.name === workspaceName);
|
|
566
|
+
if (named.length === 1) selected = named[0];
|
|
567
|
+
else if (named.length > 1)
|
|
568
|
+
throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
|
|
569
|
+
else if (matches.length)
|
|
570
|
+
throw new Error(
|
|
571
|
+
`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
|
|
572
|
+
);
|
|
573
|
+
else throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
|
|
574
|
+
} else if (matches.length > 1)
|
|
575
|
+
throw new Error(
|
|
576
|
+
`ambiguous workspace for ${cwd}; choose one of: ${matches.map((entry) => entry.name).join(", ")}`,
|
|
577
|
+
);
|
|
578
|
+
else selected = matches[0];
|
|
579
|
+
if (!selected) return null;
|
|
580
|
+
const policy = runtimeWorkspacePolicySchema(kind).safeParse(selected);
|
|
581
|
+
if (!policy.success) {
|
|
582
|
+
const detail = policy.error.issues
|
|
583
|
+
.map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
|
|
584
|
+
.join("; ");
|
|
585
|
+
throw new Error(`${file} has invalid ${kind} policy configuration: ${detail}`);
|
|
586
|
+
}
|
|
587
|
+
return policy.data as RuntimeWorkspaceCandidate;
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
export const resolveRuntimeWorkspacePolicy = (
|
|
591
|
+
cwd: string,
|
|
592
|
+
kind: RuntimeWorkspacePolicy,
|
|
593
|
+
requestedProfile?: string,
|
|
594
|
+
): {
|
|
595
|
+
policy: WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
596
|
+
source: string;
|
|
597
|
+
} => {
|
|
598
|
+
const candidate = resolveRuntimeWorkspaceCandidate(cwd, kind);
|
|
599
|
+
if (!candidate) return { policy: undefined, source: "user-default" };
|
|
600
|
+
const key = kind === "branch" ? "branchPolicy" : "commitPolicy";
|
|
601
|
+
const profileName = requestedProfile?.trim() || candidate.defaultProfile;
|
|
602
|
+
if (
|
|
603
|
+
candidate.defaultProfile &&
|
|
604
|
+
!Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
|
|
605
|
+
)
|
|
606
|
+
throw new Error(
|
|
607
|
+
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
|
|
608
|
+
);
|
|
609
|
+
const profile = profileName ? candidate.profiles?.[profileName] : undefined;
|
|
610
|
+
if (profileName && !profile)
|
|
611
|
+
throw new Error(
|
|
612
|
+
`workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
613
|
+
);
|
|
614
|
+
const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
|
|
615
|
+
const workspacePolicy = candidate[key] as
|
|
616
|
+
| WorkspaceBranchPolicy
|
|
617
|
+
| WorkspaceCommitPolicy
|
|
618
|
+
| undefined;
|
|
619
|
+
return {
|
|
620
|
+
policy: profilePolicy ?? workspacePolicy,
|
|
621
|
+
source: profilePolicy
|
|
622
|
+
? `profile:${profileName}`
|
|
623
|
+
: workspacePolicy
|
|
624
|
+
? `workspace:${candidate.name}`
|
|
625
|
+
: "user-default",
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
export const resolveWorkspaceProfile = (
|
|
630
|
+
workspace: WorkspaceConfig,
|
|
631
|
+
requestedProfile?: string,
|
|
632
|
+
): WorkspaceProfileResolution => {
|
|
633
|
+
const profileName = requestedProfile ?? workspace.defaultProfile ?? null;
|
|
634
|
+
const profile = profileName ? workspace.profiles?.[profileName] : undefined;
|
|
635
|
+
if (profileName && !profile) {
|
|
636
|
+
return {
|
|
637
|
+
status: "invalid",
|
|
638
|
+
error: `workspace ${JSON.stringify(workspace.name)} has no profile ${JSON.stringify(profileName)}`,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
const effectiveWorkspace = profile
|
|
642
|
+
? {
|
|
643
|
+
...workspace,
|
|
644
|
+
...(profile.branchPolicy ? { branchPolicy: profile.branchPolicy } : {}),
|
|
645
|
+
...(profile.commitPolicy ? { commitPolicy: profile.commitPolicy } : {}),
|
|
646
|
+
}
|
|
647
|
+
: workspace;
|
|
648
|
+
return {
|
|
649
|
+
status: "resolved",
|
|
650
|
+
profileName,
|
|
651
|
+
workspace: effectiveWorkspace,
|
|
652
|
+
provenance: {
|
|
653
|
+
branchPolicy: profile?.branchPolicy
|
|
654
|
+
? `profile:${profileName}`
|
|
655
|
+
: workspace.branchPolicy
|
|
656
|
+
? `workspace:${workspace.name}`
|
|
657
|
+
: "user-default",
|
|
658
|
+
commitPolicy: profile?.commitPolicy
|
|
659
|
+
? `profile:${profileName}`
|
|
660
|
+
: workspace.commitPolicy
|
|
661
|
+
? `workspace:${workspace.name}`
|
|
662
|
+
: "user-default",
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
export const resolveWorkspacePolicy = (
|
|
668
|
+
config: ToolkitConfig,
|
|
669
|
+
workspace: WorkspaceConfig | null,
|
|
670
|
+
profileName?: string,
|
|
671
|
+
): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
|
|
672
|
+
const selected = workspace
|
|
673
|
+
? resolveWorkspaceProfile(workspace, profileName)
|
|
674
|
+
: {
|
|
675
|
+
status: "resolved" as const,
|
|
676
|
+
profileName: null,
|
|
677
|
+
workspace: null,
|
|
678
|
+
provenance: { branchPolicy: "user-default", commitPolicy: "user-default" },
|
|
679
|
+
};
|
|
680
|
+
if (selected.status === "invalid") return selected;
|
|
681
|
+
return {
|
|
682
|
+
status: "resolved",
|
|
683
|
+
workspace: selected.workspace,
|
|
684
|
+
profileName: selected.profileName,
|
|
685
|
+
branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
|
|
686
|
+
commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
|
|
687
|
+
provenance: selected.provenance,
|
|
688
|
+
};
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
export const selectReleaseTrack = (
|
|
692
|
+
workspace: WorkspaceConfig,
|
|
693
|
+
requestedTrack?: string,
|
|
694
|
+
): ReleaseTrackSelection => {
|
|
695
|
+
const tracks = workspace.releaseTracks ?? {};
|
|
696
|
+
const choices = Object.keys(tracks).sort();
|
|
697
|
+
if (requestedTrack === undefined) {
|
|
698
|
+
if (choices.length === 0) return { status: "not_configured" };
|
|
699
|
+
if (choices.length > 1) return { status: "choice_required", choices };
|
|
700
|
+
const name = choices[0];
|
|
701
|
+
return { status: "selected", name, track: tracks[name] };
|
|
702
|
+
}
|
|
703
|
+
if (!Object.hasOwn(tracks, requestedTrack)) {
|
|
704
|
+
return {
|
|
705
|
+
status: "invalid",
|
|
706
|
+
error: `workspace ${JSON.stringify(workspace.name)} has no release track ${JSON.stringify(requestedTrack)}`,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
return { status: "selected", name: requestedTrack, track: tracks[requestedTrack] };
|
|
710
|
+
};
|
package/src/core.ts
CHANGED
|
@@ -70,7 +70,7 @@ export type {
|
|
|
70
70
|
} from "./core/task-context";
|
|
71
71
|
export { METHODS, invariantBootstrap, selectMethods } from "./core/methods";
|
|
72
72
|
export type { MethodId, SelectedMethod } from "./core/methods";
|
|
73
|
-
export {
|
|
73
|
+
export { shellBranchPolicyViolation, shellBranchTarget } from "./core/route-intent";
|
|
74
74
|
export { classifyBranchDirt } from "./core/branch";
|
|
75
75
|
export type { BranchDirt } from "./core/branch";
|
|
76
76
|
export {
|
|
@@ -91,7 +91,6 @@ export type {
|
|
|
91
91
|
StandingReceipt,
|
|
92
92
|
} from "./core/auto-approval";
|
|
93
93
|
export { bundleHashOfFile, isEphemeralCachePath, sha256Hex } from "./core/runtime-identity";
|
|
94
|
-
export type { ShellRouteIntent } from "./core/route-intent";
|
|
95
94
|
export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";
|
|
96
95
|
export { normalizeChainSteps, chainStepKey } from "./core/authority";
|
|
97
96
|
export type { ChainStep } from "./core/authority";
|