@brainervirus/workit-core 7.4.0 → 7.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +4 -3
- package/skills/workit-continue/SKILL.md +1 -1
- package/skills/workit-deslop/SKILL.md +3 -5
- package/skills/workit-fanout/SKILL.md +27 -27
- package/skills/workit-fanout/references/brief.md +7 -2
- package/skills/workit-implement/SKILL.md +10 -11
- package/skills/workit-retro/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +6 -3
- package/skills/workit-shape/SKILL.md +5 -5
- package/skills/workit-shape/references/knowledge.md +1 -1
- package/skills/workit-shape/references/mockups.md +1 -1
- package/skills/workit-shape/references/slicing.md +3 -1
- package/skills/workit-ship/SKILL.md +27 -27
- package/skills/workit-ship/references/pr-body.md +30 -0
- package/skills/workit-verify-app/SKILL.md +1 -2
- package/src/core/skill-manifests.ts +47 -1
- package/src/duration.ts +27 -0
- package/src/fanout-check.ts +178 -27
- package/src/fanout-landed.ts +313 -0
- package/src/fanout-status.ts +321 -0
- package/src/fanout-worktree.ts +520 -0
- package/src/fanout.ts +131 -17
package/src/fanout.ts
CHANGED
|
@@ -104,14 +104,41 @@ const unescape = (pattern: string): string => pattern.replace(/\\(.)/gu, "$1");
|
|
|
104
104
|
/** Has an unescaped `*`, `?` or `{`. */
|
|
105
105
|
const hasWildcard = (pattern: string): boolean => /[*?{]/u.test(pattern.replace(/\\./gu, ""));
|
|
106
106
|
|
|
107
|
+
/** Brace alternatives expanded (`src/{a,b}/x` -> `src/a/x`, `src/b/x`), capped. */
|
|
108
|
+
function expandBraces(pattern: string, limit = 64): string[] {
|
|
109
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
110
|
+
if (pattern[index] === "\\") {
|
|
111
|
+
index += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const close = pattern.indexOf("}", index);
|
|
115
|
+
if (pattern[index] !== "{" || close < index) continue;
|
|
116
|
+
const head = pattern.slice(0, index);
|
|
117
|
+
const tail = pattern.slice(close + 1);
|
|
118
|
+
const out: string[] = [];
|
|
119
|
+
for (const alternative of pattern.slice(index + 1, close).split(","))
|
|
120
|
+
for (const rest of expandBraces(tail, limit)) {
|
|
121
|
+
if (out.length >= limit) return out;
|
|
122
|
+
out.push(`${head}${alternative}${rest}`);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
return [pattern];
|
|
127
|
+
}
|
|
128
|
+
|
|
107
129
|
/**
|
|
108
|
-
*
|
|
109
|
-
* created yet: `src/new/**` -> `src/new/<any>`,
|
|
130
|
+
* Concrete paths the pattern matches, one per brace alternative, standing in
|
|
131
|
+
* for files nobody has created yet: `src/new/**` -> `src/new/<any>`,
|
|
132
|
+
* `src/{a,b}/*.ts` -> `src/a/<any>.ts`, `src/b/<any>.ts`.
|
|
110
133
|
*/
|
|
111
134
|
export function scopeSamples(pattern: string): string[] {
|
|
112
135
|
// A plain path covers what is below it too, so `src/new` and `src/new/**`
|
|
113
136
|
// overlap through `src/new/<any>` without a second sample.
|
|
114
137
|
if (!hasWildcard(pattern)) return [unescape(pattern)];
|
|
138
|
+
return [...new Set(expandBraces(pattern).map(sampleOne))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sampleOne(pattern: string): string {
|
|
115
142
|
let out = "";
|
|
116
143
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
117
144
|
const char = pattern[index];
|
|
@@ -127,13 +154,9 @@ export function scopeSamples(pattern: string): string[] {
|
|
|
127
154
|
}
|
|
128
155
|
} else if (char === "*") out += "<any>";
|
|
129
156
|
else if (char === "?") out += "x";
|
|
130
|
-
else
|
|
131
|
-
const close = pattern.indexOf("}", index);
|
|
132
|
-
out += pattern.slice(index + 1, close).split(",")[0];
|
|
133
|
-
index = close;
|
|
134
|
-
} else out += char;
|
|
157
|
+
else out += char;
|
|
135
158
|
}
|
|
136
|
-
return
|
|
159
|
+
return out;
|
|
137
160
|
}
|
|
138
161
|
|
|
139
162
|
function globSource(pattern: string): string {
|
|
@@ -190,9 +213,23 @@ const matchesAny = (patterns: readonly string[], file: string): boolean =>
|
|
|
190
213
|
|
|
191
214
|
/** A scope entry in canonical form, or why it is refused. */
|
|
192
215
|
function normalizePattern(raw: string): { pattern: string } | { error: string } {
|
|
193
|
-
//
|
|
194
|
-
// Windows separator.
|
|
195
|
-
let pattern = raw.trim()
|
|
216
|
+
// Only `\\[` and `\\]` are escapes (`app/\\[id\\]`); any other backslash is
|
|
217
|
+
// a Windows separator. Where both readings are possible, refuse.
|
|
218
|
+
let pattern = raw.trim();
|
|
219
|
+
if (pattern.includes("\\")) {
|
|
220
|
+
if (/\\[*?{}]/u.test(pattern))
|
|
221
|
+
return {
|
|
222
|
+
error:
|
|
223
|
+
"has \\*, \\?, \\{ or \\}: only \\[ and \\] are escapes; use / as the separator (src/**/*.ts)",
|
|
224
|
+
};
|
|
225
|
+
const brackets = /\\[[\]]/u.test(pattern);
|
|
226
|
+
if (brackets && pattern.replace(/\\[[\]]/gu, "").includes("\\"))
|
|
227
|
+
return {
|
|
228
|
+
error:
|
|
229
|
+
"mixes \\[ or \\] escapes with other backslashes, which is ambiguous: use / as the separator (app/\\[id\\]/page.tsx)",
|
|
230
|
+
};
|
|
231
|
+
if (!brackets) pattern = pattern.replaceAll("\\", "/");
|
|
232
|
+
}
|
|
196
233
|
while (pattern.startsWith("./")) pattern = pattern.slice(2);
|
|
197
234
|
if (pattern === "." || pattern === "") pattern = raw.trim() ? "**" : "";
|
|
198
235
|
if (pattern.endsWith("/")) pattern = `${pattern}**`;
|
|
@@ -244,6 +281,8 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
|
244
281
|
|
|
245
282
|
export type PlanDefaults = {
|
|
246
283
|
trunk: string;
|
|
284
|
+
/** The main checkout: a relative `worktree` is resolved against it at plan time. */
|
|
285
|
+
root: string;
|
|
247
286
|
/** Where default worktrees go: `<repo>-wt/` next to the main checkout. */
|
|
248
287
|
worktreeRoot: string;
|
|
249
288
|
};
|
|
@@ -335,7 +374,10 @@ export function parsePlan(raw: unknown, defaults: PlanDefaults, name?: string |
|
|
|
335
374
|
id,
|
|
336
375
|
branch,
|
|
337
376
|
base: optional("base") ?? "",
|
|
338
|
-
worktree:
|
|
377
|
+
worktree: path.resolve(
|
|
378
|
+
defaults.root,
|
|
379
|
+
optional("worktree") ?? path.join(defaults.worktreeRoot, id),
|
|
380
|
+
),
|
|
339
381
|
tier: tier as Tier,
|
|
340
382
|
dependsOn: [...new Set(dependsOn ?? [])],
|
|
341
383
|
scope,
|
|
@@ -582,16 +624,59 @@ export function analyzeOverlap(
|
|
|
582
624
|
return { overlaps, conflicts };
|
|
583
625
|
}
|
|
584
626
|
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
// case-only collisions with the trunk
|
|
629
|
+
|
|
630
|
+
export type CaseCollision = { slice: string; path: string; trunk: string };
|
|
631
|
+
|
|
632
|
+
/** Trunk paths (files and the directories above them) by lower case. */
|
|
633
|
+
export function caseIndex(files: Iterable<string>): Map<string, Set<string>> {
|
|
634
|
+
const index = new Map<string, Set<string>>();
|
|
635
|
+
for (const file of files) {
|
|
636
|
+
const parts = file.split("/");
|
|
637
|
+
for (let end = 1; end <= parts.length; end += 1) {
|
|
638
|
+
const prefix = parts.slice(0, end).join("/");
|
|
639
|
+
const key = prefix.toLowerCase();
|
|
640
|
+
(index.get(key) ?? index.set(key, new Set()).get(key))?.add(prefix);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
return index;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* The shallowest part of `file` (a directory or the file itself) that the
|
|
648
|
+
* trunk spells differently only in case: one path on macOS and Windows
|
|
649
|
+
* checkouts, two in git. Null when there is none.
|
|
650
|
+
*/
|
|
651
|
+
export function caseClash(
|
|
652
|
+
index: ReadonlyMap<string, ReadonlySet<string>>,
|
|
653
|
+
file: string,
|
|
654
|
+
): { path: string; trunk: string } | null {
|
|
655
|
+
const parts = file.split("/");
|
|
656
|
+
for (let end = 1; end <= parts.length; end += 1) {
|
|
657
|
+
const prefix = parts.slice(0, end).join("/");
|
|
658
|
+
if (prefix.includes("<any>")) return null;
|
|
659
|
+
const spellings = index.get(prefix.toLowerCase());
|
|
660
|
+
if (spellings && !spellings.has(prefix))
|
|
661
|
+
return { path: prefix, trunk: [...spellings].toSorted()[0] };
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
585
666
|
// ---------------------------------------------------------------------------
|
|
586
667
|
// git
|
|
587
668
|
|
|
588
669
|
type GitRun = { ok: boolean; status: number | null; stdout: string; stderr: string };
|
|
589
670
|
|
|
590
|
-
export const gitRun = (
|
|
671
|
+
export const gitRun = (
|
|
672
|
+
cwd: string,
|
|
673
|
+
args: string[],
|
|
674
|
+
timeoutMs: number = GIT_TIMEOUTS.local,
|
|
675
|
+
): GitRun => {
|
|
591
676
|
const run = spawnSync("git", args, {
|
|
592
677
|
cwd,
|
|
593
678
|
encoding: "utf8",
|
|
594
|
-
timeout:
|
|
679
|
+
timeout: timeoutMs,
|
|
595
680
|
killSignal: "SIGKILL",
|
|
596
681
|
windowsHide: true,
|
|
597
682
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -631,10 +716,15 @@ export function defaultTrunk(cwd: string): string {
|
|
|
631
716
|
return base ? base.replace(/^origin\//u, "") : "main";
|
|
632
717
|
}
|
|
633
718
|
|
|
719
|
+
/** The main checkout (beside `.git`), else `cwd`. */
|
|
720
|
+
export function mainCheckout(cwd: string): string {
|
|
721
|
+
const common = gitCommonDir(cwd);
|
|
722
|
+
return common && path.basename(common) === ".git" ? path.dirname(common) : path.resolve(cwd);
|
|
723
|
+
}
|
|
724
|
+
|
|
634
725
|
/** `<repo>-wt/` beside the main checkout. */
|
|
635
726
|
export function worktreeRoot(cwd: string): string {
|
|
636
|
-
const
|
|
637
|
-
const main = common && path.basename(common) === ".git" ? path.dirname(common) : cwd;
|
|
727
|
+
const main = mainCheckout(cwd);
|
|
638
728
|
return path.join(path.dirname(main), `${path.basename(main)}-wt`);
|
|
639
729
|
}
|
|
640
730
|
|
|
@@ -823,7 +913,11 @@ export function planFanout(
|
|
|
823
913
|
if (!dir.ok) return dir;
|
|
824
914
|
const parsed = parsePlan(
|
|
825
915
|
input.trunk && isRecord(input.raw) ? { ...input.raw, trunk: input.trunk } : input.raw,
|
|
826
|
-
{
|
|
916
|
+
{
|
|
917
|
+
trunk: input.trunk ?? defaultTrunk(cwd),
|
|
918
|
+
root: mainCheckout(cwd),
|
|
919
|
+
worktreeRoot: worktreeRoot(cwd),
|
|
920
|
+
},
|
|
827
921
|
input.name,
|
|
828
922
|
);
|
|
829
923
|
if (!parsed.ok)
|
|
@@ -858,6 +952,26 @@ export function planFanout(
|
|
|
858
952
|
{ name: plan.name, conflicts, overlaps },
|
|
859
953
|
);
|
|
860
954
|
|
|
955
|
+
const trunkCase = caseIndex(tracked);
|
|
956
|
+
const clashes: CaseCollision[] = [];
|
|
957
|
+
for (const slice of plan.slices) {
|
|
958
|
+
const seen = new Set<string>();
|
|
959
|
+
for (const sample of [...slice.scope, ...slice.owns].flatMap(scopeSamples)) {
|
|
960
|
+
const clash = caseClash(trunkCase, sample);
|
|
961
|
+
if (clash && !seen.has(clash.path)) {
|
|
962
|
+
seen.add(clash.path);
|
|
963
|
+
clashes.push({ slice: slice.id, ...clash });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (clashes.length)
|
|
968
|
+
return fanoutFail(
|
|
969
|
+
"blocked",
|
|
970
|
+
`slice ${clashes[0].slice} names ${clashes[0].path}, which differs only in case from ${plan.trunk}'s ${clashes[0].trunk} (one path on macOS and Windows)`,
|
|
971
|
+
`use ${plan.trunk}'s spelling ${clashes[0].trunk} in slice ${clashes[0].slice}'s scope, then workit fanout plan again`,
|
|
972
|
+
{ name: plan.name, caseCollisions: clashes, overlaps },
|
|
973
|
+
);
|
|
974
|
+
|
|
861
975
|
const existing = readFanout(cwd, plan.name);
|
|
862
976
|
const previous = existing.ok ? existing.data : null;
|
|
863
977
|
plan.createdAt = previous?.createdAt || now.toISOString();
|