@pmelab/gtd 27.7.1 → 27.9.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/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -5
- package/dist/chunks/{wire-C12dahrH.mjs → wire-D3Rc5r22.mjs} +14 -1
- package/dist/flows.mjs +16 -1
- package/dist/gtd.bundle.mjs +203 -32
- package/dist/testkit.mjs +172 -33
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +39 -3
- package/src/cli/Cli.ts +23 -2
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +22 -0
- package/src/edge/Edge.test.ts +217 -34
- package/src/edge/Edge.ts +131 -39
- package/src/edge/index.ts +1 -0
- package/src/flows/runtime.test.ts +27 -0
- package/src/flows/runtime.ts +18 -0
- package/src/replay/Replay.test.ts +169 -0
- package/src/replay/Replay.ts +42 -2
- package/src/replay/Slices.test.ts +73 -0
- package/src/replay/Slices.ts +45 -0
- package/src/replay/Trailers.test.ts +11 -0
- package/src/replay/Trailers.ts +3 -1
- package/src/replay/index.ts +1 -0
- package/src/wire/Slices.ts +25 -0
- package/src/wire/WireSchema.test.ts +18 -0
- package/src/wire/golden/1/slices.json +11 -0
- package/src/wire/index.ts +1 -0
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
restart,
|
|
16
16
|
run,
|
|
17
17
|
scope,
|
|
18
|
+
slice,
|
|
18
19
|
threads,
|
|
19
20
|
type Flow,
|
|
20
21
|
type ScopeAccess,
|
|
@@ -1210,3 +1211,171 @@ describe("human({ approve })", () => {
|
|
|
1210
1211
|
expect(outcome.kind).toBe("rest")
|
|
1211
1212
|
})
|
|
1212
1213
|
})
|
|
1214
|
+
|
|
1215
|
+
describe("slice()", () => {
|
|
1216
|
+
const spansOf = (o: ReplayOutcome) => (o as { slices: unknown }).slices
|
|
1217
|
+
|
|
1218
|
+
it("records enteredAt and leftAt as the commit replay stood on", async () => {
|
|
1219
|
+
const wf = async () => {
|
|
1220
|
+
await human("a")
|
|
1221
|
+
await slice("refactor", async () => {
|
|
1222
|
+
await human("b")
|
|
1223
|
+
})
|
|
1224
|
+
await human("c")
|
|
1225
|
+
}
|
|
1226
|
+
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "c")
|
|
1227
|
+
const outcome = await replayOf(wf, h)
|
|
1228
|
+
expect(outcome).toMatchObject({ kind: "rest" })
|
|
1229
|
+
expect(spansOf(outcome)).toEqual([
|
|
1230
|
+
{ name: "refactor", enteredAt: hashOf(1), leftAt: hashOf(2) },
|
|
1231
|
+
])
|
|
1232
|
+
})
|
|
1233
|
+
|
|
1234
|
+
it("an ended outcome carries every span of the episode that just ended", async () => {
|
|
1235
|
+
const wf = async () => {
|
|
1236
|
+
await slice("one", async () => {
|
|
1237
|
+
await human("a")
|
|
1238
|
+
})
|
|
1239
|
+
await slice("two", async () => {
|
|
1240
|
+
await human("b")
|
|
1241
|
+
})
|
|
1242
|
+
}
|
|
1243
|
+
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
|
|
1244
|
+
const outcome = await replayOf(wf, h)
|
|
1245
|
+
expect(outcome).toMatchObject({ kind: "ended", via: "return" })
|
|
1246
|
+
expect(spansOf(outcome)).toEqual([
|
|
1247
|
+
{ name: "one", enteredAt: hashOf(0), leftAt: hashOf(1) },
|
|
1248
|
+
{ name: "two", enteredAt: hashOf(1), leftAt: hashOf(2) },
|
|
1249
|
+
])
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
it("restart() inside a slice ends via restart with that span's leftAt undefined", async () => {
|
|
1253
|
+
const wf = async () => {
|
|
1254
|
+
await human("a")
|
|
1255
|
+
await slice("risky", async () => {
|
|
1256
|
+
await human("b")
|
|
1257
|
+
await restart()
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
|
|
1261
|
+
const outcome = await replayOf(wf, h)
|
|
1262
|
+
expect(outcome).toMatchObject({ kind: "ended", via: "restart" })
|
|
1263
|
+
expect(spansOf(outcome)).toEqual([{ name: "risky", enteredAt: hashOf(1), leftAt: undefined }])
|
|
1264
|
+
})
|
|
1265
|
+
|
|
1266
|
+
it("keeps leftAt undefined when the process rests inside the slice", async () => {
|
|
1267
|
+
const wf = async () => {
|
|
1268
|
+
await slice("refactor", async () => {
|
|
1269
|
+
await human("a")
|
|
1270
|
+
})
|
|
1271
|
+
}
|
|
1272
|
+
const outcome = await replayOf(wf, new History())
|
|
1273
|
+
expect(spansOf(outcome)).toEqual([
|
|
1274
|
+
{ name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
|
|
1275
|
+
])
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it("records enteredAt === leftAt for a slice that landed no commit", async () => {
|
|
1279
|
+
const wf = async () => {
|
|
1280
|
+
await human("a")
|
|
1281
|
+
await slice("empty", async () => undefined)
|
|
1282
|
+
await human("b")
|
|
1283
|
+
}
|
|
1284
|
+
const h = new History().land("human", "a", 1, "b")
|
|
1285
|
+
expect(spansOf(await replayOf(wf, h))).toEqual([
|
|
1286
|
+
{ name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
|
|
1287
|
+
])
|
|
1288
|
+
})
|
|
1289
|
+
|
|
1290
|
+
it("prefixes no step name and ignores an enclosing scope", async () => {
|
|
1291
|
+
const wf = async () => {
|
|
1292
|
+
await scope("build", async () => {
|
|
1293
|
+
await slice("refactor", async () => {
|
|
1294
|
+
await human("gate")
|
|
1295
|
+
})
|
|
1296
|
+
})
|
|
1297
|
+
}
|
|
1298
|
+
const outcome = await replayOf(wf, new History())
|
|
1299
|
+
expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
|
|
1300
|
+
expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
const failed = async (wf: Flow) => {
|
|
1304
|
+
const outcome = await replayOf(wf, new History())
|
|
1305
|
+
expect(outcome.kind).toBe("failed")
|
|
1306
|
+
return (outcome as { message: string }).message
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
|
|
1310
|
+
expect(await failed(async () => slice(name, async () => human("a")))).toBe(
|
|
1311
|
+
`gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
|
|
1312
|
+
)
|
|
1313
|
+
})
|
|
1314
|
+
|
|
1315
|
+
it("accepts refactor-2", async () => {
|
|
1316
|
+
const outcome = await replayOf(
|
|
1317
|
+
async () => slice("refactor-2", async () => human("a")),
|
|
1318
|
+
new History(),
|
|
1319
|
+
)
|
|
1320
|
+
expect(outcome.kind).toBe("rest")
|
|
1321
|
+
})
|
|
1322
|
+
|
|
1323
|
+
it("refuses the reserved name main", async () => {
|
|
1324
|
+
expect(await failed(async () => slice("main", async () => human("a")))).toBe(
|
|
1325
|
+
'gtd: slice "main" is reserved for the implicit slice',
|
|
1326
|
+
)
|
|
1327
|
+
})
|
|
1328
|
+
|
|
1329
|
+
it("refuses nesting, also through scope()", async () => {
|
|
1330
|
+
const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
|
|
1331
|
+
expect(
|
|
1332
|
+
await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
|
|
1333
|
+
).toBe(message)
|
|
1334
|
+
expect(
|
|
1335
|
+
await failed(async () =>
|
|
1336
|
+
slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
|
|
1337
|
+
),
|
|
1338
|
+
).toBe(message)
|
|
1339
|
+
})
|
|
1340
|
+
|
|
1341
|
+
it("checks the name rule before main and nesting", async () => {
|
|
1342
|
+
expect(
|
|
1343
|
+
await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
|
|
1344
|
+
).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
|
|
1345
|
+
})
|
|
1346
|
+
|
|
1347
|
+
it("lets a flow that caught an error from a slice enter another", async () => {
|
|
1348
|
+
const wf = async () => {
|
|
1349
|
+
try {
|
|
1350
|
+
await slice("a", async () => {
|
|
1351
|
+
throw new Error("boom")
|
|
1352
|
+
})
|
|
1353
|
+
} catch {}
|
|
1354
|
+
await slice("b", async () => human("x"))
|
|
1355
|
+
}
|
|
1356
|
+
expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
it("refuses a non-string name", async () => {
|
|
1360
|
+
expect(
|
|
1361
|
+
await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
|
|
1362
|
+
).toContain("name must match")
|
|
1363
|
+
})
|
|
1364
|
+
|
|
1365
|
+
it("refuses a name entered twice, sequentially and from a loop", async () => {
|
|
1366
|
+
const message =
|
|
1367
|
+
'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
|
|
1368
|
+
expect(
|
|
1369
|
+
await failed(async () => {
|
|
1370
|
+
await slice("again", async () => undefined)
|
|
1371
|
+
await slice("again", async () => human("a"))
|
|
1372
|
+
}),
|
|
1373
|
+
).toBe(message)
|
|
1374
|
+
expect(
|
|
1375
|
+
await failed(async () => {
|
|
1376
|
+
for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
|
|
1377
|
+
await human("a")
|
|
1378
|
+
}),
|
|
1379
|
+
).toBe(message)
|
|
1380
|
+
})
|
|
1381
|
+
})
|
package/src/replay/Replay.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
24
24
|
import { globMatches } from "../wire/index.js"
|
|
25
25
|
import { refNameFault } from "./RefName.js"
|
|
26
|
+
import { IMPLICIT_SLICE, SLICE_NAME, type SliceSpan } from "./Slices.js"
|
|
26
27
|
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
27
28
|
import {
|
|
28
29
|
formatStepId,
|
|
@@ -117,6 +118,7 @@ export type ReplayOutcome =
|
|
|
117
118
|
readonly landed: ReachedStep | undefined
|
|
118
119
|
/** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
|
|
119
120
|
readonly marks: readonly Mark[]
|
|
121
|
+
readonly slices: readonly SliceSpan[]
|
|
120
122
|
}
|
|
121
123
|
| {
|
|
122
124
|
readonly kind: "ended"
|
|
@@ -124,6 +126,8 @@ export type ReplayOutcome =
|
|
|
124
126
|
readonly trace: readonly ReachedStep[]
|
|
125
127
|
readonly landed: ReachedStep | undefined
|
|
126
128
|
readonly marks: readonly Mark[]
|
|
129
|
+
/** Spans of the episode that just ended; a restart() inside a slice leaves its leftAt undefined. */
|
|
130
|
+
readonly slices: readonly SliceSpan[]
|
|
127
131
|
}
|
|
128
132
|
| { readonly kind: "divergence"; readonly message: string }
|
|
129
133
|
| { readonly kind: "refused"; readonly message: string }
|
|
@@ -297,6 +301,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
297
301
|
>()
|
|
298
302
|
let landed: ReachedStep | undefined
|
|
299
303
|
const marks: Mark[] = []
|
|
304
|
+
const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
|
|
300
305
|
let outcome: ReplayOutcome | undefined
|
|
301
306
|
let signal: (() => void) | undefined
|
|
302
307
|
const settled = new Promise<void>((resolve) => {
|
|
@@ -558,7 +563,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
558
563
|
const commit = commits[cursor]
|
|
559
564
|
if (commit !== undefined) return consumeCommit(step, commit)
|
|
560
565
|
if (input.pending === undefined || pendingUsed) {
|
|
561
|
-
return finish({
|
|
566
|
+
return finish({
|
|
567
|
+
kind: "rest",
|
|
568
|
+
rest: step,
|
|
569
|
+
trace,
|
|
570
|
+
landed,
|
|
571
|
+
marks,
|
|
572
|
+
slices: slices.map((s) => ({ ...s })),
|
|
573
|
+
})
|
|
562
574
|
}
|
|
563
575
|
return landPending(step, input.pending)
|
|
564
576
|
}
|
|
@@ -603,7 +615,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
603
615
|
kind: "divergence",
|
|
604
616
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
605
617
|
}
|
|
606
|
-
: { kind: "ended", via, trace, landed, marks }
|
|
618
|
+
: { kind: "ended", via, trace, landed, marks, slices: slices.map((s) => ({ ...s })) }
|
|
607
619
|
|
|
608
620
|
const changesSince = (hash: string): readonly Change[] => {
|
|
609
621
|
const tree = treeAt(hash)
|
|
@@ -630,6 +642,34 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
630
642
|
scopes.pop()
|
|
631
643
|
scopeCallIds.pop()
|
|
632
644
|
},
|
|
645
|
+
enterSlice: (name) => {
|
|
646
|
+
if (outcome !== undefined) return
|
|
647
|
+
const fail = (message: string): never => {
|
|
648
|
+
void finish({ kind: "failed", message })
|
|
649
|
+
throw new Stop()
|
|
650
|
+
}
|
|
651
|
+
if (typeof name !== "string" || !SLICE_NAME.test(name)) {
|
|
652
|
+
fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
|
|
653
|
+
}
|
|
654
|
+
if (name === IMPLICIT_SLICE) {
|
|
655
|
+
fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
|
|
656
|
+
}
|
|
657
|
+
const open = slices.find((s) => s.leftAt === undefined)
|
|
658
|
+
if (open !== undefined) {
|
|
659
|
+
fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
|
|
660
|
+
}
|
|
661
|
+
if (slices.some((s) => s.name === name)) {
|
|
662
|
+
fail(
|
|
663
|
+
`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
slices.push({ name, enteredAt: position.hash, leftAt: undefined })
|
|
667
|
+
},
|
|
668
|
+
leaveSlice: () => {
|
|
669
|
+
if (outcome !== undefined) return
|
|
670
|
+
const open = slices.find((s) => s.leftAt === undefined)
|
|
671
|
+
if (open !== undefined) open.leftAt = position.hash
|
|
672
|
+
},
|
|
633
673
|
read: (path) => position.tree.read(path),
|
|
634
674
|
glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
|
|
635
675
|
changes: () => changesBetween(previousPosition.tree, position.tree),
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { sliceRanges, type SliceSpan } from "./Slices.js"
|
|
3
|
+
|
|
4
|
+
const span = (name: string, enteredAt: string, leftAt?: string): SliceSpan => ({
|
|
5
|
+
name,
|
|
6
|
+
enteredAt,
|
|
7
|
+
leftAt,
|
|
8
|
+
})
|
|
9
|
+
const running = { base: "B", tip: "T" }
|
|
10
|
+
|
|
11
|
+
describe("sliceRanges", () => {
|
|
12
|
+
it("two adjacent spans: first from episode.base to its leftAt, second from there to episode.tip", () => {
|
|
13
|
+
expect(sliceRanges([span("a", "B", "c2"), span("b", "c2", "T")], running)).toEqual([
|
|
14
|
+
{ name: "a", base: "B", tip: "c2", open: false },
|
|
15
|
+
{ name: "b", base: "c2", tip: "T", open: true },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("a leading gap folds into the first range via episode.base", () => {
|
|
20
|
+
expect(sliceRanges([span("a", "c1", "c2")], running)).toEqual([
|
|
21
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
22
|
+
])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("an inner gap folds into the next range: its base is the previous leftAt", () => {
|
|
26
|
+
expect(sliceRanges([span("a", "B", "c1"), span("b", "c3", "T")], running)).toEqual([
|
|
27
|
+
{ name: "a", base: "B", tip: "c1", open: false },
|
|
28
|
+
{ name: "b", base: "c1", tip: "T", open: true },
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("a trailing gap folds back: the last range's tip is the episode tip, and it stays open", () => {
|
|
33
|
+
expect(sliceRanges([span("a", "B", "c1")], running)).toEqual([
|
|
34
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
35
|
+
])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("drops a closed empty span, first, middle and last, its neighbours absorbing the gap", () => {
|
|
39
|
+
expect(sliceRanges([span("e", "B", "B"), span("a", "B", "c1")], running)).toEqual([
|
|
40
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
41
|
+
])
|
|
42
|
+
expect(
|
|
43
|
+
sliceRanges([span("a", "B", "c1"), span("e", "c2", "c2"), span("b", "c2", "c4")], running),
|
|
44
|
+
).toEqual([
|
|
45
|
+
{ name: "a", base: "B", tip: "c1", open: false },
|
|
46
|
+
{ name: "b", base: "c1", tip: "T", open: true },
|
|
47
|
+
])
|
|
48
|
+
expect(sliceRanges([span("a", "B", "c1"), span("e", "c1", "c1")], running)).toEqual([
|
|
49
|
+
{ name: "a", base: "B", tip: "T", open: true },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("no spans, and only empty closed spans, each yield one open main range over the whole episode", () => {
|
|
54
|
+
const main = [{ name: "main", base: "B", tip: "T", open: true }]
|
|
55
|
+
expect(sliceRanges([], running)).toEqual(main)
|
|
56
|
+
expect(sliceRanges([span("e", "c1", "c1")], running)).toEqual(main)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("an unleft empty span is kept and open", () => {
|
|
60
|
+
expect(sliceRanges([span("a", "B", "c1"), span("b", "c1")], running)).toEqual([
|
|
61
|
+
{ name: "a", base: "B", tip: "c1", open: false },
|
|
62
|
+
{ name: "b", base: "c1", tip: "T", open: true },
|
|
63
|
+
])
|
|
64
|
+
expect(sliceRanges([span("b", "T")], running)).toEqual([
|
|
65
|
+
{ name: "b", base: "B", tip: "T", open: true },
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("rejects a pending turn's empty hash", () => {
|
|
70
|
+
expect(() => sliceRanges([span("a", "", "c1")], running)).toThrow("not a commit")
|
|
71
|
+
expect(() => sliceRanges([span("a", "c1", "")], running)).toThrow("not a commit")
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const IMPLICIT_SLICE = "main"
|
|
2
|
+
|
|
3
|
+
/** Same shape as mark()'s name rule; kept local so @pmelab/gtd/flows publishes no regex. */
|
|
4
|
+
export const SLICE_NAME = /^[a-z][a-z0-9-]*$/
|
|
5
|
+
|
|
6
|
+
/** One slice() call as replay saw it. Hashes are the commit replay stood on; "" is a pending turn. */
|
|
7
|
+
export interface SliceSpan {
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly enteredAt: string
|
|
10
|
+
/** undefined: the flow never left it — the process rests inside, or restart() fired inside. */
|
|
11
|
+
readonly leftAt: string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SliceRange {
|
|
15
|
+
readonly name: string
|
|
16
|
+
/** Exclusive: the range is base..tip in git's sense. */
|
|
17
|
+
readonly base: string
|
|
18
|
+
readonly tip: string
|
|
19
|
+
/** The tip can still move: only the last range, since a rest is never past the episode's end. */
|
|
20
|
+
readonly open: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Folds boundaries into ranges: each kept span's base is the previous kept
|
|
25
|
+
* span's `leftAt`, so a gap before a span belongs to it, and the last span
|
|
26
|
+
* runs to the episode tip. A closed span that landed nothing is dropped.
|
|
27
|
+
*/
|
|
28
|
+
export const sliceRanges = (
|
|
29
|
+
spans: readonly SliceSpan[],
|
|
30
|
+
episode: { readonly base: string; readonly tip: string },
|
|
31
|
+
): readonly SliceRange[] => {
|
|
32
|
+
if (spans.some((s) => s.enteredAt === "" || s.leftAt === "")) {
|
|
33
|
+
throw new Error('sliceRanges: a pending turn\'s "" hash is not a commit')
|
|
34
|
+
}
|
|
35
|
+
const kept = spans.filter((s) => s.leftAt === undefined || s.enteredAt !== s.leftAt)
|
|
36
|
+
if (kept.length === 0) {
|
|
37
|
+
return [{ name: IMPLICIT_SLICE, base: episode.base, tip: episode.tip, open: true }]
|
|
38
|
+
}
|
|
39
|
+
return kept.map((s, i) => ({
|
|
40
|
+
name: s.name,
|
|
41
|
+
base: i === 0 ? episode.base : (kept[i - 1]!.leftAt ?? episode.base),
|
|
42
|
+
tip: i === kept.length - 1 ? episode.tip : (s.leftAt ?? episode.tip),
|
|
43
|
+
open: i === kept.length - 1,
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
@@ -115,6 +115,17 @@ describe("the commit-message codec", () => {
|
|
|
115
115
|
expect(parsed.version).toBe("1.2.3")
|
|
116
116
|
})
|
|
117
117
|
|
|
118
|
+
it("reads a Gtd-Version carrying control bytes as unknown, and keeps spaces and parens", () => {
|
|
119
|
+
const withVersion = (value: string) =>
|
|
120
|
+
formatCommitMessage({ actor: "a", to: "b" }).replace(
|
|
121
|
+
"Gtd-Format: 1",
|
|
122
|
+
`Gtd-Version: ${value}\nGtd-Format: 1`,
|
|
123
|
+
)
|
|
124
|
+
expect(parseCommitMessage(withVersion("1.0\u001b]0;x\u0007")).version).toBeUndefined()
|
|
125
|
+
expect(parseCommitMessage(withVersion("1.0\u009b")).version).toBeUndefined()
|
|
126
|
+
expect(parseCommitMessage(withVersion("0.0.1 (dev)")).version).toBe("0.0.1 (dev)")
|
|
127
|
+
})
|
|
128
|
+
|
|
118
129
|
it("omits Gtd-Branch and Gtd-Version when unset, and reads them as undefined", () => {
|
|
119
130
|
const message = formatCommitMessage({ actor: "agent", to: "idle" })
|
|
120
131
|
expect(message).not.toContain("Gtd-Branch")
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -196,7 +196,9 @@ const TRAILER_READERS: Readonly<Record<string, (value: string, into: Trailers) =
|
|
|
196
196
|
if (value !== "") into.branch ??= value
|
|
197
197
|
},
|
|
198
198
|
"Gtd-Version": (value, into) => {
|
|
199
|
-
|
|
199
|
+
// Untrusted (fetched history): control bytes would reach the terminal; a rejected value reads as unknown.
|
|
200
|
+
// eslint-disable-next-line no-control-regex
|
|
201
|
+
if (value !== "" && !/[\u0000-\u001f\u007f-\u009f]/.test(value)) into.version ??= value
|
|
200
202
|
},
|
|
201
203
|
"Gtd-Kept": (value, into) => {
|
|
202
204
|
if (HASH_RE.test(value)) into.kept ??= value
|
package/src/replay/index.ts
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { WIRE_SCHEMA } from "./constants.js"
|
|
2
|
+
|
|
3
|
+
export interface SliceEntry {
|
|
4
|
+
readonly name: string
|
|
5
|
+
/** Full hash, exclusive: git's base..tip. */
|
|
6
|
+
readonly base: string
|
|
7
|
+
/** Full hash, inclusive; HEAD's hash for the open slice. */
|
|
8
|
+
readonly tip: string
|
|
9
|
+
/** false only for the last slice of an underway process. */
|
|
10
|
+
readonly closed: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SlicesDocument {
|
|
14
|
+
readonly schema: number
|
|
15
|
+
readonly slices: readonly SliceEntry[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The ONLY place `gtd slices --json`'s shape is built; copies exactly the four fields, in order. */
|
|
19
|
+
export const slicesDocument = (slices: readonly SliceEntry[]): SlicesDocument => ({
|
|
20
|
+
schema: WIRE_SCHEMA,
|
|
21
|
+
slices: slices.map(({ name, base, tip, closed }) => ({ name, base, tip, closed })),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
export const renderSlicesJson = (document: SlicesDocument): string =>
|
|
25
|
+
JSON.stringify(document) + "\n"
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
landFields,
|
|
10
10
|
renderBeatJson,
|
|
11
11
|
renderLandJson,
|
|
12
|
+
renderSlicesJson,
|
|
13
|
+
slicesDocument,
|
|
12
14
|
statusOf,
|
|
13
15
|
WIRE_SCHEMA,
|
|
14
16
|
type BeatKind,
|
|
@@ -116,6 +118,9 @@ const documents: Readonly<Record<string, string>> = {
|
|
|
116
118
|
}),
|
|
117
119
|
),
|
|
118
120
|
"judge.json": JUDGE,
|
|
121
|
+
"slices.json": renderSlicesJson(
|
|
122
|
+
slicesDocument([{ name: "build", base: "a".repeat(40), tip: "b".repeat(40), closed: true }]),
|
|
123
|
+
),
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
describe(`wire schema ${WIRE_SCHEMA}`, () => {
|
|
@@ -139,6 +144,19 @@ describe(`wire schema ${WIRE_SCHEMA}`, () => {
|
|
|
139
144
|
}
|
|
140
145
|
})
|
|
141
146
|
|
|
147
|
+
describe("slicesDocument", () => {
|
|
148
|
+
it("stamps WIRE_SCHEMA and drops any field beyond the four", () => {
|
|
149
|
+
const doc = slicesDocument([
|
|
150
|
+
{ name: "n", base: "b", tip: "t", closed: false, extra: 1 } as never,
|
|
151
|
+
])
|
|
152
|
+
expect(doc).toEqual({
|
|
153
|
+
schema: WIRE_SCHEMA,
|
|
154
|
+
slices: [{ name: "n", base: "b", tip: "t", closed: false }],
|
|
155
|
+
})
|
|
156
|
+
expect(Object.keys(doc.slices[0]!)).toEqual(["name", "base", "tip", "closed"])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
142
160
|
describe("the shape check", () => {
|
|
143
161
|
const land = JSON.parse(documents["land.json"]!) as Record<string, unknown>
|
|
144
162
|
const { subject, ...rest } = land
|
package/src/wire/index.ts
CHANGED