@pmelab/gtd 27.7.0 → 27.8.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.
@@ -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,139 @@ 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("keeps leftAt undefined when the process rests inside the slice", async () => {
1235
+ const wf = async () => {
1236
+ await slice("refactor", async () => {
1237
+ await human("a")
1238
+ })
1239
+ }
1240
+ const outcome = await replayOf(wf, new History())
1241
+ expect(spansOf(outcome)).toEqual([
1242
+ { name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
1243
+ ])
1244
+ })
1245
+
1246
+ it("records enteredAt === leftAt for a slice that landed no commit", async () => {
1247
+ const wf = async () => {
1248
+ await human("a")
1249
+ await slice("empty", async () => undefined)
1250
+ await human("b")
1251
+ }
1252
+ const h = new History().land("human", "a", 1, "b")
1253
+ expect(spansOf(await replayOf(wf, h))).toEqual([
1254
+ { name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
1255
+ ])
1256
+ })
1257
+
1258
+ it("prefixes no step name and ignores an enclosing scope", async () => {
1259
+ const wf = async () => {
1260
+ await scope("build", async () => {
1261
+ await slice("refactor", async () => {
1262
+ await human("gate")
1263
+ })
1264
+ })
1265
+ }
1266
+ const outcome = await replayOf(wf, new History())
1267
+ expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
1268
+ expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
1269
+ })
1270
+
1271
+ const failed = async (wf: Flow) => {
1272
+ const outcome = await replayOf(wf, new History())
1273
+ expect(outcome.kind).toBe("failed")
1274
+ return (outcome as { message: string }).message
1275
+ }
1276
+
1277
+ it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
1278
+ expect(await failed(async () => slice(name, async () => human("a")))).toBe(
1279
+ `gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
1280
+ )
1281
+ })
1282
+
1283
+ it("accepts refactor-2", async () => {
1284
+ const outcome = await replayOf(
1285
+ async () => slice("refactor-2", async () => human("a")),
1286
+ new History(),
1287
+ )
1288
+ expect(outcome.kind).toBe("rest")
1289
+ })
1290
+
1291
+ it("refuses the reserved name main", async () => {
1292
+ expect(await failed(async () => slice("main", async () => human("a")))).toBe(
1293
+ 'gtd: slice "main" is reserved for the implicit slice',
1294
+ )
1295
+ })
1296
+
1297
+ it("refuses nesting, also through scope()", async () => {
1298
+ const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
1299
+ expect(
1300
+ await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
1301
+ ).toBe(message)
1302
+ expect(
1303
+ await failed(async () =>
1304
+ slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
1305
+ ),
1306
+ ).toBe(message)
1307
+ })
1308
+
1309
+ it("checks the name rule before main and nesting", async () => {
1310
+ expect(
1311
+ await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
1312
+ ).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
1313
+ })
1314
+
1315
+ it("lets a flow that caught an error from a slice enter another", async () => {
1316
+ const wf = async () => {
1317
+ try {
1318
+ await slice("a", async () => {
1319
+ throw new Error("boom")
1320
+ })
1321
+ } catch {}
1322
+ await slice("b", async () => human("x"))
1323
+ }
1324
+ expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
1325
+ })
1326
+
1327
+ it("refuses a non-string name", async () => {
1328
+ expect(
1329
+ await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
1330
+ ).toContain("name must match")
1331
+ })
1332
+
1333
+ it("refuses a name entered twice, sequentially and from a loop", async () => {
1334
+ const message =
1335
+ 'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
1336
+ expect(
1337
+ await failed(async () => {
1338
+ await slice("again", async () => undefined)
1339
+ await slice("again", async () => human("a"))
1340
+ }),
1341
+ ).toBe(message)
1342
+ expect(
1343
+ await failed(async () => {
1344
+ for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
1345
+ await human("a")
1346
+ }),
1347
+ ).toBe(message)
1348
+ })
1349
+ })
@@ -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"
@@ -297,6 +299,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
297
299
  >()
298
300
  let landed: ReachedStep | undefined
299
301
  const marks: Mark[] = []
302
+ const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
300
303
  let outcome: ReplayOutcome | undefined
301
304
  let signal: (() => void) | undefined
302
305
  const settled = new Promise<void>((resolve) => {
@@ -558,7 +561,14 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
558
561
  const commit = commits[cursor]
559
562
  if (commit !== undefined) return consumeCommit(step, commit)
560
563
  if (input.pending === undefined || pendingUsed) {
561
- return finish({ kind: "rest", rest: step, trace, landed, marks })
564
+ return finish({
565
+ kind: "rest",
566
+ rest: step,
567
+ trace,
568
+ landed,
569
+ marks,
570
+ slices: slices.map((s) => ({ ...s })),
571
+ })
562
572
  }
563
573
  return landPending(step, input.pending)
564
574
  }
@@ -630,6 +640,34 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
630
640
  scopes.pop()
631
641
  scopeCallIds.pop()
632
642
  },
643
+ enterSlice: (name) => {
644
+ if (outcome !== undefined) return
645
+ const fail = (message: string): never => {
646
+ void finish({ kind: "failed", message })
647
+ throw new Stop()
648
+ }
649
+ if (typeof name !== "string" || !SLICE_NAME.test(name)) {
650
+ fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
651
+ }
652
+ if (name === IMPLICIT_SLICE) {
653
+ fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
654
+ }
655
+ const open = slices.find((s) => s.leftAt === undefined)
656
+ if (open !== undefined) {
657
+ fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
658
+ }
659
+ if (slices.some((s) => s.name === name)) {
660
+ fail(
661
+ `gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
662
+ )
663
+ }
664
+ slices.push({ name, enteredAt: position.hash, leftAt: undefined })
665
+ },
666
+ leaveSlice: () => {
667
+ if (outcome !== undefined) return
668
+ const open = slices.find((s) => s.leftAt === undefined)
669
+ if (open !== undefined) open.leftAt = position.hash
670
+ },
633
671
  read: (path) => position.tree.read(path),
634
672
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
635
673
  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
+ }
@@ -12,3 +12,4 @@ export {
12
12
  export { accessShapeFault, foldAccess } from "./Access.js"
13
13
  export { formatFault } from "./Format.js"
14
14
  export { refNameFault } from "./RefName.js"
15
+ export { sliceRanges, type SliceRange } from "./Slices.js"
@@ -509,13 +509,19 @@ describe("severity routing", () => {
509
509
  notes: ["add — quote", "sub", "mul"],
510
510
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
511
511
  })
512
- expect(run.gates).toEqual([false, true])
512
+ expect(run.gates).toEqual([true, true])
513
513
  expect(run.gateMessages[1]).toContain("NOT acted on")
514
514
  expect(run.gateMessages[1]).toContain("note-1")
515
515
  expect(run.gateMessages[0]).not.toContain("NOT acted on")
516
516
  })
517
517
 
518
- it("a later gate does not accept a clean land after a deferral round with a nit", async () => {
518
+ it("the first round's gate accepts a clean land", async () => {
519
+ const run = await drive({ notes: ["add", "sub", "mul"] })
520
+ expect(run.gates.length).toBeGreaterThan(0)
521
+ expect(run.gates.every((g) => g)).toBe(true)
522
+ })
523
+
524
+ it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
519
525
  const run = await drive({
520
526
  notes: ["add — quote", "sub — typo", "mul"],
521
527
  answers: {
@@ -527,7 +533,7 @@ describe("severity routing", () => {
527
533
  })
528
534
  expect(run.log).toContain("review.defer")
529
535
  expect(run.log.at(-1)).toBe("review.await-review")
530
- expect(run.gates).toEqual([false, false])
536
+ expect(run.gates).toEqual([true, true])
531
537
  })
532
538
 
533
539
  it("a question and a could note in one round: the gate's base is the answering commit", async () => {
@@ -167,8 +167,8 @@ export const review = (
167
167
  escalations: EscalationCount = { rounds: 0 },
168
168
  ): Promise<kit.ReviewOutcome> => {
169
169
  const severities = new Map<string, Severity>()
170
- // Armed by a defer-only round and spent at the very next gate; a fresh
171
- // review (`reviewing`) disarms it, so a later gate never accepts a clean land.
170
+ // Armed by a defer-only round to pick the next gate's message; a fresh
171
+ // review (`reviewing`) disarms it.
172
172
  let deferred: readonly t.NoteInput[] = []
173
173
  return kit.review(base, {
174
174
  file: REVIEW,
@@ -184,7 +184,7 @@ export const review = (
184
184
  const notes = deferred
185
185
  deferred = []
186
186
  return notes.length > 0
187
- ? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes), true)
187
+ ? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
188
188
  : awaitReview(b)
189
189
  },
190
190
  reviewMissing,
@@ -117,7 +117,6 @@ export const fixRisks = (notes: readonly t.NoteInput[]): Promise<void> =>
117
117
  export const awaitReview = (
118
118
  base: string,
119
119
  message = t.buildReviewAwaitReviewMessage(base),
120
- acceptClean = false,
121
120
  ): Promise<void> =>
122
121
  human("review.await-review", {
123
122
  message,
@@ -125,7 +124,7 @@ export const awaitReview = (
125
124
  file: REVIEW,
126
125
  mode: "review",
127
126
  base,
128
- ...(acceptClean ? { acceptClean } : {}),
127
+ acceptClean: true,
129
128
  })
130
129
 
131
130
  /** The round at commit `round` left no `.gtd/REVIEW.md`. */
@@ -38,8 +38,18 @@ const handOff = async (
38
38
  } = {},
39
39
  ) => {
40
40
  const log: string[] = []
41
+ const gates: boolean[] = []
41
42
  const scripts = new Map<string, string>()
42
43
  const files = new Map<string, string>()
44
+ const onReviewing = () => {
45
+ if (log.filter((n) => n === "review.reviewing").length > 1) throw new Error("re-review")
46
+ if (after !== "unwritten") files.set(REVIEW, reviewDoc("new add function"))
47
+ }
48
+ const onGate = (request: StepRequest) => {
49
+ if (request.kind === "human") gates.push(request.options.acceptClean === true)
50
+ if (after === "deleted") files.delete(REVIEW)
51
+ else if (after !== "unwritten") files.set(REVIEW, after)
52
+ }
43
53
  installContext(
44
54
  fixtureContext(
45
55
  { codeThreads: extra.codeThreads ?? [] },
@@ -48,14 +58,8 @@ const handOff = async (
48
58
  if (request.kind === "restart") return Promise.resolve()
49
59
  log.push(request.name)
50
60
  if (request.kind === "run") scripts.set(request.name, request.body)
51
- if (request.name === "review.reviewing") {
52
- if (log.filter((n) => n === "review.reviewing").length > 1) throw new Error("re-review")
53
- if (after !== "unwritten") files.set(REVIEW, reviewDoc("new add function"))
54
- }
55
- if (request.name === "review.await-review") {
56
- if (after === "deleted") files.delete(REVIEW)
57
- else if (after !== "unwritten") files.set(REVIEW, after)
58
- }
61
+ if (request.name === "review.reviewing") onReviewing()
62
+ if (request.name === "review.await-review") onGate(request)
59
63
  return Promise.resolve()
60
64
  },
61
65
  refuse: (message: string): never => {
@@ -96,7 +100,7 @@ const handOff = async (
96
100
  () => undefined,
97
101
  (e: unknown) => (e as Error).message,
98
102
  )
99
- return { log, scripts, refusal }
103
+ return { log, scripts, refusal, gates }
100
104
  }
101
105
 
102
106
  describe("the hand-off review", () => {
@@ -105,6 +109,11 @@ describe("the hand-off review", () => {
105
109
  expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.closing"])
106
110
  })
107
111
 
112
+ it("the review gate accepts a clean landing", async () => {
113
+ const run = await handOff(reviewDoc("new add function"))
114
+ expect(run.gates).toEqual([true])
115
+ })
116
+
108
117
  it("a note ends the round with the review kept and the reviewer's version copied beside it", async () => {
109
118
  const run = await handOff(reviewDoc("new add function — rename it to sum"))
110
119
  expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.handing-off"])