@brainervirus/workit-core 8.28.0 → 8.29.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/src/check-config.ts +20 -1
- package/src/core/branch.ts +12 -0
- package/src/core/operation-input.ts +128 -8
- package/src/fanout-worktree.ts +60 -3
package/package.json
CHANGED
package/src/check-config.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
// self-ignoring `.workit/`, never at the repo root. A legacy root
|
|
11
11
|
// `workit.checks.json` is still read when `.workit/checks.json` is absent,
|
|
12
12
|
// with a note that it moved.
|
|
13
|
+
// In a linked worktree (a fanout slice's, an agent's) that has neither
|
|
14
|
+
// file, the main checkout's is read: `.workit/` is git-ignored, so a new
|
|
15
|
+
// worktree never has one, and it shares the main checkout's checks
|
|
16
|
+
// (#426). Configured commands still run from the worktree's own top.
|
|
13
17
|
// 2. Built-in ecosystem defaults detected from the repo: package.json scripts
|
|
14
18
|
// `test`, `lint`, `typecheck`, `check` (`<pm> run <script>`, pm from the
|
|
15
19
|
// lockfile); go.mod → `go test ./...`; Cargo.toml → `cargo test`;
|
|
@@ -216,10 +220,25 @@ function detectedChecks(root: string): NamedCheck[] {
|
|
|
216
220
|
|
|
217
221
|
const GATES = ["testing", "verification"] as const;
|
|
218
222
|
|
|
223
|
+
/**
|
|
224
|
+
* The checks config file for `root`: its own, else (in a linked worktree
|
|
225
|
+
* without one) the main checkout's, else the path a new config belongs at.
|
|
226
|
+
*/
|
|
227
|
+
function configFileFor(root: string): string {
|
|
228
|
+
const own = checksConfigFile(root);
|
|
229
|
+
if (fs.existsSync(own)) return own;
|
|
230
|
+
const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
231
|
+
if (!common || path.basename(common) !== ".git") return own;
|
|
232
|
+
const main = path.dirname(common);
|
|
233
|
+
if (path.resolve(main) === path.resolve(root)) return own;
|
|
234
|
+
const shared = checksConfigFile(main);
|
|
235
|
+
return fs.existsSync(shared) ? shared : own;
|
|
236
|
+
}
|
|
237
|
+
|
|
219
238
|
/** The named checks for the repository containing `cwd` (see the header). */
|
|
220
239
|
export function loadCheckConfig(cwd: string): CheckConfig {
|
|
221
240
|
const root = checkRoot(cwd);
|
|
222
|
-
const file =
|
|
241
|
+
const file = configFileFor(root);
|
|
223
242
|
const legacy = path.basename(file) === LEGACY_CHECKS_FILE;
|
|
224
243
|
const label = legacy ? LEGACY_CHECKS_FILE : CHECKS_FILE;
|
|
225
244
|
const note = legacy
|
package/src/core/branch.ts
CHANGED
|
@@ -318,6 +318,16 @@ export const validateCommitMessagePolicy = (
|
|
|
318
318
|
};
|
|
319
319
|
};
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* A TypeError or ReferenceError while resolving a policy is a bug in this
|
|
323
|
+
* runtime (or a host too old for it), not a config problem the user can
|
|
324
|
+
* repair: it propagates so the hook's failure decision applies instead of a
|
|
325
|
+
* misleading "repair or select a valid workspace policy".
|
|
326
|
+
*/
|
|
327
|
+
const rethrowRuntimeBug = (error: unknown): void => {
|
|
328
|
+
if (error instanceof TypeError || error instanceof ReferenceError) throw error;
|
|
329
|
+
};
|
|
330
|
+
|
|
321
331
|
export const validateBranchNameFor = (
|
|
322
332
|
workspaceRoot: string,
|
|
323
333
|
name: string,
|
|
@@ -326,6 +336,7 @@ export const validateBranchNameFor = (
|
|
|
326
336
|
const resolved = branchPolicySnapshotFor(workspaceRoot);
|
|
327
337
|
return validateBranchNamePolicy(name, resolved.namePolicy, resolved.source);
|
|
328
338
|
} catch (error) {
|
|
339
|
+
rethrowRuntimeBug(error);
|
|
329
340
|
const correction = "repair or select a valid workspace policy before creating a branch";
|
|
330
341
|
return {
|
|
331
342
|
ok: false,
|
|
@@ -347,6 +358,7 @@ export const validateCommitMessageFor = (
|
|
|
347
358
|
const resolved = commitPolicySnapshotFor(workspaceRoot);
|
|
348
359
|
return validateCommitMessagePolicy(message, resolved.policy, resolved.source);
|
|
349
360
|
} catch (error) {
|
|
361
|
+
rethrowRuntimeBug(error);
|
|
350
362
|
const correction = "repair or select a valid workspace policy before committing";
|
|
351
363
|
return {
|
|
352
364
|
ok: false,
|
|
@@ -184,9 +184,11 @@ export function flatOperationJsonSchema(
|
|
|
184
184
|
// Reads advertise only what reads take: no revisions, and task reads only their filters.
|
|
185
185
|
const readField = (name: string) =>
|
|
186
186
|
name === "taskId" || (family === "task" ? READ_TASK_FIELDS.has(name) : family === "policy");
|
|
187
|
+
// Only advertise what some listed action takes (derived from the contract).
|
|
188
|
+
const taken = (name: string) =>
|
|
189
|
+
family === "policy" || actions.some((action) => actionKeys(family, action)?.accepted.has(name));
|
|
187
190
|
for (const [name, field] of Object.entries({ ...COMMON, ...spec.fields }))
|
|
188
|
-
if (options.readOnly ? readField(name) :
|
|
189
|
-
properties[name] = property(field);
|
|
191
|
+
if (options.readOnly ? readField(name) : taken(name)) properties[name] = property(field);
|
|
190
192
|
return {
|
|
191
193
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
192
194
|
type: "object",
|
|
@@ -482,6 +484,129 @@ export function normalizeOperationInput(
|
|
|
482
484
|
return request;
|
|
483
485
|
}
|
|
484
486
|
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// per-action keys, derived from the contract
|
|
489
|
+
|
|
490
|
+
type ZodLike = {
|
|
491
|
+
_zod: { def: { type: string; shape?: Record<string, ZodLike>; innerType?: ZodLike } };
|
|
492
|
+
};
|
|
493
|
+
const defOf = (node: ZodLike) => node._zod.def;
|
|
494
|
+
const unwrap = (node: ZodLike): ZodLike => {
|
|
495
|
+
let inner = node;
|
|
496
|
+
while (["optional", "nullable"].includes(defOf(inner).type) && defOf(inner).innerType)
|
|
497
|
+
inner = defOf(inner).innerType!;
|
|
498
|
+
return inner;
|
|
499
|
+
};
|
|
500
|
+
/** Contract keys the flat form renames. */
|
|
501
|
+
const FLAT_NAME: Record<string, string> = {
|
|
502
|
+
authorityRefs: "refs",
|
|
503
|
+
contentRefs: "refs",
|
|
504
|
+
statedChoice: "choice",
|
|
505
|
+
reviewContext: "reviewSession",
|
|
506
|
+
};
|
|
507
|
+
const NOT_FLAT = new Set(["schemaVersion", "action", "workspaceId"]);
|
|
508
|
+
|
|
509
|
+
export type ActionKeys = { accepted: Set<string>; required: string[] };
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* What one action of a family takes, read from the advertised contract:
|
|
513
|
+
* every top-level key, the keys of its nested objects (flat form, renamed per
|
|
514
|
+
* FLAT_NAME), and the required scalars. Lists, scopes and nullables are
|
|
515
|
+
* defaulted by normalization, so they are accepted but never required.
|
|
516
|
+
*/
|
|
517
|
+
export function actionKeys(family: OperationFamily, action: string): ActionKeys | null {
|
|
518
|
+
const options = (advertisedOperationSchemas[family] as unknown as { options: ZodLike[] }).options;
|
|
519
|
+
const option = options.find((item) => {
|
|
520
|
+
const literal = defOf(item).shape?.action as unknown as { _zod: { def: { values: string[] } } };
|
|
521
|
+
return literal._zod.def.values.includes(action);
|
|
522
|
+
});
|
|
523
|
+
if (!option) return null;
|
|
524
|
+
const out: ActionKeys = { accepted: new Set(), required: [] };
|
|
525
|
+
const walk = (shape: Record<string, ZodLike>, nested: boolean): void => {
|
|
526
|
+
for (const [key, node] of Object.entries(shape)) {
|
|
527
|
+
if (NOT_FLAT.has(key) || (nested && key === "taskId")) continue;
|
|
528
|
+
const inner = unwrap(node);
|
|
529
|
+
const type = defOf(inner).type;
|
|
530
|
+
const needed = !["optional", "nullable"].includes(defOf(node).type);
|
|
531
|
+
if (key === "scope")
|
|
532
|
+
for (const name of ["scope", "paths", "exclusions"]) out.accepted.add(name);
|
|
533
|
+
else if (type === "object" && key !== "bundle" && !(key in FLAT_NAME)) {
|
|
534
|
+
out.accepted.add(key);
|
|
535
|
+
walk(defOf(inner).shape!, true);
|
|
536
|
+
} else {
|
|
537
|
+
const name = FLAT_NAME[key] ?? key;
|
|
538
|
+
out.accepted.add(name);
|
|
539
|
+
if (needed && type !== "array" && type !== "record" && !out.required.includes(name))
|
|
540
|
+
out.required.push(name);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
walk(defOf(option).shape!, false);
|
|
545
|
+
return out;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** A minimal valid flat payload for one action: required scalars with placeholders. */
|
|
549
|
+
export function exampleOperation(family: OperationFamily, action: string): Record<string, unknown> {
|
|
550
|
+
const keys = actionKeys(family, action);
|
|
551
|
+
const example: Record<string, unknown> = { action };
|
|
552
|
+
for (const name of keys?.required ?? [])
|
|
553
|
+
example[name] = FLAT[family].fields[name]?.enum?.[0] ?? `<${name}>`;
|
|
554
|
+
if (family === "policy" && !keys?.required.length && action !== "explain")
|
|
555
|
+
example.riskTier = "normal";
|
|
556
|
+
return example;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const oneOf = (values: readonly string[]): string => values.join(" | ");
|
|
560
|
+
|
|
561
|
+
/** Every invalid_input the hosts return: the exact fix for this family and action. */
|
|
562
|
+
function invalidOperation(
|
|
563
|
+
family: OperationFamily,
|
|
564
|
+
input: unknown,
|
|
565
|
+
error: ZodErrorLike,
|
|
566
|
+
): Result<never> {
|
|
567
|
+
const request = isObject(input) ? input : {};
|
|
568
|
+
const action = typeof request.action === "string" ? request.action : "";
|
|
569
|
+
const actions = OPERATION_ACTIONS[family];
|
|
570
|
+
if (!actions.includes(action))
|
|
571
|
+
return failure("invalid_input", `${family}: action must be one of ${oneOf(actions)}`, {
|
|
572
|
+
fields: [{ path: "action", reason: `got ${JSON.stringify(request.action ?? null)}` }],
|
|
573
|
+
});
|
|
574
|
+
const own = actionKeys(family, action)!;
|
|
575
|
+
const fields: { path: string; reason: string }[] = [];
|
|
576
|
+
const sentence: string[] = [];
|
|
577
|
+
for (const issue of error.issues) {
|
|
578
|
+
if (issue.code === "unrecognized_keys") {
|
|
579
|
+
for (const key of issue.keys) {
|
|
580
|
+
const home = actions.find(
|
|
581
|
+
(other) => other !== action && actionKeys(family, other)!.accepted.has(key),
|
|
582
|
+
);
|
|
583
|
+
const reason = home
|
|
584
|
+
? `${key} belongs to action: ${home}`
|
|
585
|
+
: `${key} is not a field of ${family} ${action}; it takes ${[...own.accepted].join(", ")}`;
|
|
586
|
+
fields.push({ path: key, reason });
|
|
587
|
+
sentence.push(reason);
|
|
588
|
+
}
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const last = String(issue.path.at(-1) ?? "");
|
|
592
|
+
const name = Object.entries(FLAT_NAME).find(([contract]) => contract === last)?.[1] ?? last;
|
|
593
|
+
const missing = issue.code === "invalid_type" && /received undefined/.test(issue.message);
|
|
594
|
+
// A nested container (binding, intent, ...) is absent because its flat fields are.
|
|
595
|
+
const absent = own.required.filter((key) => !(key in request));
|
|
596
|
+
const needs = missing && issue.path.length === 1 && absent.length ? absent.join(", ") : name;
|
|
597
|
+
const reason = missing ? `${family} ${action} requires ${needs}` : `${name}: ${issue.message}`;
|
|
598
|
+
fields.push({ path: issue.path.join("."), reason });
|
|
599
|
+
sentence.push(reason);
|
|
600
|
+
}
|
|
601
|
+
const example = JSON.stringify(exampleOperation(family, action));
|
|
602
|
+
return failure("invalid_input", `${[...new Set(sentence)].join("; ")}. Example: ${example}`, {
|
|
603
|
+
fields,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
type ZodErrorLike = {
|
|
607
|
+
issues: { code: string; keys: string[]; path: PropertyKey[]; message: string }[];
|
|
608
|
+
};
|
|
609
|
+
|
|
485
610
|
/**
|
|
486
611
|
* Host-side validation before the engine runs: normalize, then parse against
|
|
487
612
|
* the advertised contract (taskId optional: the engine resolves the branch's
|
|
@@ -497,10 +622,5 @@ export function parseAdvertisedOperation(
|
|
|
497
622
|
return failure("unsupported_version", "unsupported schema version", { operation: family });
|
|
498
623
|
const parsed = advertisedOperationSchemas[family].safeParse(request);
|
|
499
624
|
if (parsed.success) return success(null, null, request);
|
|
500
|
-
return
|
|
501
|
-
fields: parsed.error.issues.map((issue) => ({
|
|
502
|
-
path: issue.code === "unrecognized_keys" ? issue.keys.join(".") : issue.path.join("."),
|
|
503
|
-
reason: issue.message,
|
|
504
|
-
})),
|
|
505
|
-
});
|
|
625
|
+
return invalidOperation(family, request, parsed.error as unknown as ZodErrorLike);
|
|
506
626
|
}
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -255,9 +255,56 @@ export type CreateOutcome = {
|
|
|
255
255
|
created: boolean;
|
|
256
256
|
/** node_modules linked from the main checkout (worktree-deps.ts); null when none were. */
|
|
257
257
|
dependencies: LinkedDependencies | null;
|
|
258
|
+
/** The install command to run in the worktree when its lockfile differs from the main checkout's. */
|
|
259
|
+
install: string | null;
|
|
258
260
|
notes: string[];
|
|
259
261
|
};
|
|
260
262
|
|
|
263
|
+
/** Lockfiles in the order a package manager is picked, with its frozen install for a directory. */
|
|
264
|
+
const LOCKFILES: ReadonlyArray<[string, (dir: string) => string]> = [
|
|
265
|
+
["bun.lock", (dir) => `bun install --frozen-lockfile --cwd ${dir}`],
|
|
266
|
+
["bun.lockb", (dir) => `bun install --frozen-lockfile --cwd ${dir}`],
|
|
267
|
+
["pnpm-lock.yaml", (dir) => `pnpm install --frozen-lockfile --dir ${dir}`],
|
|
268
|
+
["yarn.lock", (dir) => `yarn install --frozen-lockfile --cwd ${dir}`],
|
|
269
|
+
["package-lock.json", (dir) => `npm ci --prefix ${dir}`],
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
const readBytes = (file: string): Buffer | null => {
|
|
273
|
+
try {
|
|
274
|
+
return fs.readFileSync(file);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Link the main checkout's node_modules into `worktree` only when every
|
|
282
|
+
* lockfile is byte-identical in both: a stale main checkout would otherwise
|
|
283
|
+
* hand the worktree the wrong dependency versions (#426). When one differs,
|
|
284
|
+
* nothing is linked and `install` is the command the worker runs instead.
|
|
285
|
+
*/
|
|
286
|
+
function worktreeDependencies(
|
|
287
|
+
main: string,
|
|
288
|
+
worktree: string,
|
|
289
|
+
): { linked: LinkedDependencies | null; install: string | null; notes: string[] } {
|
|
290
|
+
for (const [name, install] of LOCKFILES) {
|
|
291
|
+
const ours = readBytes(path.join(main, name));
|
|
292
|
+
const theirs = readBytes(path.join(worktree, name));
|
|
293
|
+
if (ours === null && theirs === null) continue;
|
|
294
|
+
if (ours !== null && theirs !== null && ours.equals(theirs)) continue;
|
|
295
|
+
if (fs.existsSync(path.join(worktree, "node_modules"))) break;
|
|
296
|
+
const command = install(worktree);
|
|
297
|
+
return {
|
|
298
|
+
linked: null,
|
|
299
|
+
install: command,
|
|
300
|
+
notes: [
|
|
301
|
+
`${name} differs from the main checkout's (${main}), so its node_modules was not linked; install dependencies first: ${command}`,
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return { ...linkWorktreeDependencies(main, worktree), install: null };
|
|
306
|
+
}
|
|
307
|
+
|
|
261
308
|
function ensureScratch(worktree: string): string {
|
|
262
309
|
const scratch = path.join(worktree, SCRATCH_DIR);
|
|
263
310
|
fs.mkdirSync(scratch, { recursive: true });
|
|
@@ -303,7 +350,7 @@ export function createSliceWorktree(
|
|
|
303
350
|
`remove it yourself (git worktree remove ${target}) once you know what it holds`,
|
|
304
351
|
);
|
|
305
352
|
excludeScratch(cwd);
|
|
306
|
-
const deps =
|
|
353
|
+
const deps = worktreeDependencies(mainCheckout(cwd), target);
|
|
307
354
|
return {
|
|
308
355
|
ok: true,
|
|
309
356
|
data: {
|
|
@@ -313,6 +360,7 @@ export function createSliceWorktree(
|
|
|
313
360
|
mode: resolveRef(cwd, localRef) ? "resume" : "new",
|
|
314
361
|
created: false,
|
|
315
362
|
dependencies: deps.linked,
|
|
363
|
+
install: deps.install,
|
|
316
364
|
notes: [
|
|
317
365
|
deps.linked
|
|
318
366
|
? "the worktree already exists; only its missing dependencies were linked"
|
|
@@ -392,7 +440,7 @@ export function createSliceWorktree(
|
|
|
392
440
|
}
|
|
393
441
|
excludeScratch(cwd);
|
|
394
442
|
const scratch = ensureScratch(target);
|
|
395
|
-
const deps =
|
|
443
|
+
const deps = worktreeDependencies(mainCheckout(cwd), target);
|
|
396
444
|
notes.push(...deps.notes);
|
|
397
445
|
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
398
446
|
const baseSha = mode === "new" ? head : null;
|
|
@@ -415,7 +463,16 @@ export function createSliceWorktree(
|
|
|
415
463
|
});
|
|
416
464
|
return {
|
|
417
465
|
ok: true,
|
|
418
|
-
data: {
|
|
466
|
+
data: {
|
|
467
|
+
...base,
|
|
468
|
+
scratch,
|
|
469
|
+
head,
|
|
470
|
+
mode,
|
|
471
|
+
created: true,
|
|
472
|
+
dependencies: deps.linked,
|
|
473
|
+
install: deps.install,
|
|
474
|
+
notes,
|
|
475
|
+
},
|
|
419
476
|
};
|
|
420
477
|
}
|
|
421
478
|
|