@pmelab/gtd 65.0.0 → 66.0.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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/claude/hooks/drive.ts +10 -1
  4. package/claude/hooks/git.ts +2 -2
  5. package/claude/hooks/pr.ts +80 -20
  6. package/claude/hooks/register.tsx +19 -4
  7. package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
  8. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  9. package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
  10. package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
  11. package/dist/flows.mjs +1 -1
  12. package/dist/gtd.bundle.mjs +430 -100
  13. package/dist/gtd.core.mjs +147 -134
  14. package/dist/steering.mjs +1 -1
  15. package/dist/testkit/gherkin.mjs +1 -1
  16. package/dist/testkit.mjs +2 -2
  17. package/dist/web/main.css +1 -1
  18. package/dist/web/main.js +185 -18
  19. package/dist/wire.mjs +1 -1
  20. package/dist/workflow-kit.mjs +9 -3
  21. package/package.json +1 -1
  22. package/src/cli/Cli.ts +1 -1
  23. package/src/cli/Program.ts +18 -2
  24. package/src/edge/Edge.ts +93 -11
  25. package/src/flows/helpers.ts +36 -18
  26. package/src/flows/index.test.ts +3 -0
  27. package/src/flows/index.ts +5 -0
  28. package/src/flows/runtime.ts +123 -16
  29. package/src/install/Install.ts +9 -2
  30. package/src/platform/Git.test.ts +7 -11
  31. package/src/platform/NodeSync.ts +2 -0
  32. package/src/platform/SyncIo.ts +2 -0
  33. package/src/platform/Workspace.test.ts +37 -0
  34. package/src/platform/Workspace.ts +36 -0
  35. package/src/replay/Replay.ts +33 -1
  36. package/src/replay/Trailers.ts +3 -0
  37. package/src/replay/Tree.ts +5 -0
  38. package/src/steering/FORMATS.md +3 -1
  39. package/src/steering/SteeringFormat.ts +13 -0
  40. package/src/steering/fixtures/review/sectioned.md +1 -1
  41. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  42. package/src/steering/index.ts +1 -0
  43. package/src/steering/review.test.ts +181 -5
  44. package/src/steering/review.ts +160 -18
  45. package/src/step/RepoSnapshot.ts +4 -0
  46. package/src/step/planStep.ts +6 -6
  47. package/src/twins/EmittedScriptRecognizer.ts +1 -1
  48. package/src/twins/InMemRepo.ts +24 -3
  49. package/src/twins/Layers.ts +1 -0
  50. package/src/twins/SyncIo.test.ts +19 -4
  51. package/src/twins/SyncIo.ts +13 -1
  52. package/src/wire/BeatDocument.test.ts +15 -0
  53. package/src/wire/BeatDocument.ts +8 -2
  54. package/src/wire/Demand.ts +4 -2
  55. package/src/wire/golden/1/beat-capture.json +1 -0
  56. package/src/wire/golden/1/beat-message.json +1 -0
  57. package/src/wire/golden/1/beat-prompt.json +1 -0
  58. package/src/wire/golden/1/beat-script.json +1 -0
  59. package/src/wire/golden/1/beat-stalled.json +1 -0
  60. package/src/workflow/Workflow.ts +2 -0
  61. package/src/workflow-kit/index.test.ts +1 -0
  62. package/src/workflow-kit/index.ts +1 -0
  63. package/src/workflow-kit/scenarios.ts +8 -0
@@ -1,4 +1,5 @@
1
1
  import {
2
+ chmodSync,
2
3
  mkdirSync,
3
4
  mkdtempSync,
4
5
  readFileSync,
@@ -38,6 +39,7 @@ interface WorkspaceTier {
38
39
  readonly root: string
39
40
  readonly writeWorking: (path: string, content: string) => void
40
41
  readonly commit: (path: string, content: string) => void
42
+ readonly chmod: (path: string, executable: boolean) => void
41
43
  readonly provide: <A>(eff: Effect.Effect<A, Error, Workspace>) => Promise<A>
42
44
  readonly dispose: () => void
43
45
  }
@@ -70,6 +72,7 @@ const makeLiveTier = (): WorkspaceTier => {
70
72
  gitExecIn(root, "add", "-A")
71
73
  gitExecIn(root, "commit", "-m", `commit ${path}`)
72
74
  },
75
+ chmod: (path, executable) => chmodSync(join(root, path), executable ? 0o755 : 0o644),
73
76
  provide: (eff) => Effect.runPromise(eff.pipe(Effect.provide(workspaceLayer))),
74
77
  dispose: () => rmSync(root, { recursive: true, force: true }),
75
78
  }
@@ -86,6 +89,7 @@ const makeInMemTier = (): WorkspaceTier => {
86
89
  repo.writeFile(path, content)
87
90
  repo.seedCommit(`commit ${path}`)
88
91
  },
92
+ chmod: (path, executable) => repo.setExecutable(path, executable),
89
93
  provide: (eff) =>
90
94
  Effect.runPromise(eff.pipe(Effect.provide(Layer.succeed(Workspace, workspaceOps)))),
91
95
  dispose: () => {},
@@ -232,6 +236,39 @@ for (const { name, make } of tiers) {
232
236
  await expect(attempt).rejects.toThrow(/repo-relative paths only/)
233
237
  })
234
238
 
239
+ it("modeSync reports committed and working-tree modes, kept across an overwrite", async () => {
240
+ t = make()
241
+ t.commit("a.sh", "echo\n")
242
+ t.chmod("a.sh", true)
243
+ const modes = await t.provide(
244
+ Effect.gen(function* () {
245
+ const workspace = yield* Workspace
246
+ const committed = workspace.modeSync("HEAD").get("a.sh")
247
+ const chmodded = workspace.modeSync(undefined).get("a.sh")
248
+ t.writeWorking("a.sh", "echo 2\n")
249
+ return [committed, chmodded, workspace.modeSync(undefined).get("a.sh")]
250
+ }),
251
+ )
252
+ expect(modes).toEqual(["100644", "100755", "100755"])
253
+ })
254
+
255
+ it.runIf(name === "Live")(
256
+ "modeSync reads only the owner execute bit, as git does",
257
+ async () => {
258
+ t = make()
259
+ t.commit("a.sh", "echo\n")
260
+ t.writeWorking("a.sh", "echo 2\n")
261
+ chmodSync(join(t.root, "a.sh"), 0o654)
262
+ const mode = await t.provide(
263
+ Effect.gen(function* () {
264
+ return (yield* Workspace).modeSync(undefined).get("a.sh")
265
+ }),
266
+ )
267
+ gitExecIn(t.root, "add", "a.sh")
268
+ expect(mode).toBe(gitExecIn(t.root, "ls-files", "-s", "a.sh").split(" ")[0])
269
+ },
270
+ )
271
+
235
272
  it("atPath reads an absolute path INSIDE root, the same content readSync's relative form sees", async () => {
236
273
  t = make()
237
274
  t.writeWorking("nested/a.txt", "inside\n")
@@ -150,6 +150,16 @@ const applyRawDiff = (tree: Map<string, string>, entries: readonly RawDiffEntry[
150
150
  }
151
151
  }
152
152
 
153
+ /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → mode`. */
154
+ const parseLsTreeModes = (out: string): Map<string, string> => {
155
+ const modes = new Map<string, string>()
156
+ for (const record of out.split("\0")) {
157
+ const tab = record.indexOf("\t")
158
+ if (tab !== -1) modes.set(record.slice(tab + 1), record.slice(0, tab).split(" ")[0]!)
159
+ }
160
+ return modes
161
+ }
162
+
153
163
  /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
154
164
  const parseLsTree = (out: string): Map<string, string> => {
155
165
  const entries = new Map<string, string>()
@@ -227,6 +237,8 @@ export interface WorkspaceOps {
227
237
  * reader compares contents instead.
228
238
  */
229
239
  readonly worktreeSync: () => ReadonlyMap<string, string | undefined>
240
+ /** Every path's git file mode (`100644`, `100755`, …) at `ref`, or in the working tree as `git add -A` would store it when `ref` is `undefined`. */
241
+ readonly modeSync: (ref: string | undefined) => ReadonlyMap<string, string>
230
242
  /**
231
243
  * `hash → (path → blob id)` for `base` (when given) and every one of
232
244
  * `commits`, in ONE call regardless of how many commits that is: a single
@@ -528,6 +540,29 @@ const makeWorkspaceOps = (
528
540
  return new Map([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
529
541
  }
530
542
 
543
+ const modeSync = (ref: string | undefined): ReadonlyMap<string, string> => {
544
+ const modes = parseLsTreeModes(gitSync(["ls-tree", "-r", "-z", "--full-tree", ref ?? "HEAD"]))
545
+ if (ref !== undefined) return modes
546
+ const status = gitSync([
547
+ "status",
548
+ "--porcelain=v1",
549
+ "-z",
550
+ "--untracked-files=all",
551
+ "--no-renames",
552
+ "--ignore-submodules=none",
553
+ ])
554
+ for (const record of status.split("\0")) {
555
+ if (record.length < 4) continue
556
+ const path = record.slice(3).replace(/\/$/, "")
557
+ const stat = io.lstat(paths.join(root, path))
558
+ if (stat === undefined) modes.delete(path)
559
+ else if (stat.type === "symlink") modes.set(path, "120000")
560
+ else if (stat.type === "directory") modes.set(path, "160000")
561
+ else modes.set(path, stat.executable === true ? "100755" : "100644")
562
+ }
563
+ return modes
564
+ }
565
+
531
566
  /** A submodule's (or embedded repository's) checked-out commit — what `git add` records for it. */
532
567
  const gitlinkOf = (path: string): string | undefined => {
533
568
  try {
@@ -547,6 +582,7 @@ const makeWorkspaceOps = (
547
582
  return {
548
583
  treeSync,
549
584
  worktreeSync,
585
+ modeSync,
550
586
  episodeTrees,
551
587
  readSync,
552
588
  read: (path) => Effect.try({ try: () => readSync(path), catch: toError }),
@@ -322,13 +322,43 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
322
322
  // The option keys each step accepts. A gtd.config.ts is evaluated without a
323
323
  // type check, so a misspelt key would otherwise be silently ignored.
324
324
  const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
325
- agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
325
+ agent: new Set([
326
+ ...STEERING_OPTIONS,
327
+ "model",
328
+ "system",
329
+ "allowEmpty",
330
+ "keep",
331
+ "validate",
332
+ "cap",
333
+ "exhausted",
334
+ "correction",
335
+ ]),
326
336
  human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
327
337
  run: new Set([...STEERING_OPTIONS, "onFail"]),
328
338
  ship: new Set(["label", "base"]),
329
339
  judge: new Set([...STEERING_OPTIONS, "message"]),
330
340
  }
331
341
 
342
+ const validatorOptionFault = (
343
+ name: string,
344
+ options: Extract<StepRequest, { kind: "agent" }>["options"],
345
+ ): string | undefined => {
346
+ const { validate, cap, exhausted, correction } = options
347
+ const prefix = `gtd: step "${name}": `
348
+ const notFunction = Object.entries({ validate, exhausted, correction }).find(
349
+ ([, value]) => value !== undefined && typeof value !== "function",
350
+ )
351
+ if (notFunction !== undefined)
352
+ return `${prefix}${notFunction[0]} must be a function in agent() options`
353
+ if (cap !== undefined && !(Number.isInteger(cap) && cap > 0)) {
354
+ return `${prefix}cap must be a positive integer in agent() options`
355
+ }
356
+ if (validate !== undefined && options.keep === true) {
357
+ return `${prefix}validate cannot be combined with keep: true — a kept turn is set aside, so there is no landed result to validate`
358
+ }
359
+ return undefined
360
+ }
361
+
332
362
  const restsOnFail = (step: ReachedStep): boolean =>
333
363
  step.request.kind === "run" && step.request.options.onFail === "rest"
334
364
 
@@ -775,6 +805,8 @@ export const replay = async (raw: ReplayInput): Promise<ReplayOutcome> => {
775
805
  }
776
806
  if (request.kind !== "agent") return bodyFault(request)
777
807
  const name = scoped(request.name)
808
+ const optionFault = validatorOptionFault(name, request.options)
809
+ if (optionFault !== undefined) return optionFault
778
810
  if (request.options.keep === true) {
779
811
  // A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
780
812
  if ((request.options as { readonly allowEmpty?: unknown }).allowEmpty === true) {
@@ -296,6 +296,8 @@ export interface CommitSpec {
296
296
  readonly anchor?: string
297
297
  readonly branch?: string
298
298
  readonly version?: string
299
+ /** Written after `Gtd-Format`: a landing recording a ship an earlier, hook-rejected attempt already made. */
300
+ readonly shipped?: string
299
301
  }
300
302
 
301
303
  const sortedEntries = (record: Readonly<Record<string, string>> = {}): [string, string][] =>
@@ -323,6 +325,7 @@ export const formatCommitMessage = (spec: CommitSpec): string => {
323
325
  ...trailer("Branch", spec.branch),
324
326
  ...trailer("Version", spec.version),
325
327
  `Gtd-Format: ${HISTORY_FORMAT}`,
328
+ ...trailer("Shipped", spec.shipped),
326
329
  ]
327
330
  const subject = formatSubject(spec.actor, spec.to, spec.from)
328
331
  return `${subject}\n\n${lines.join("\n")}`
@@ -8,6 +8,8 @@ export interface TreeView {
8
8
  readonly paths: () => readonly string[]
9
9
  readonly read: (path: string) => string | undefined
10
10
  readonly id?: (path: string) => string | undefined
11
+ /** Git file mode; when both sides give one, a differing mode is a modification even with equal content. */
12
+ readonly mode?: (path: string) => string | undefined
11
13
  }
12
14
 
13
15
  export const treeFromRecord = (files: Readonly<Record<string, string>>): TreeView => ({
@@ -24,6 +26,9 @@ export interface TreeDiff {
24
26
  export const diffTrees = (before: TreeView, after: TreeView): TreeDiff => {
25
27
  // Blob ids only compare against blob ids; otherwise fall back to contents.
26
28
  const same = (path: string): boolean => {
29
+ if (before.mode !== undefined && after.mode !== undefined) {
30
+ if (before.mode(path) !== after.mode(path)) return false
31
+ }
27
32
  const a = before.id?.(path)
28
33
  const b = after.id?.(path)
29
34
  return a !== undefined && b !== undefined ? a === b : before.read(path) === after.read(path)
@@ -17,7 +17,9 @@ read them.
17
17
  pointer is a hunk the human has read. A chunk's `Tick exactly one:` list is
18
18
  its decision. A `## Findings` or `## Walkthrough` heading groups the `###`
19
19
  chunks under it; a walkthrough chunk takes no decision. Any other `##` is a
20
- chunk of its own.
20
+ chunk of its own. Under `## Findings` a location is a plain list item
21
+ (`- ./path`, `- ./path#start-end`) with no checkbox; a `- [ ]`/`- [x]` pointer
22
+ there is an error.
21
23
  - **`findings`**: the finding gate's file. `## ` entries, those under a
22
24
  `# Dropped as noise` heading being the noise; each holds exactly one
23
25
  `- [ ] fix` box. A ticked box fixes the finding; text below the box is the
@@ -17,6 +17,11 @@ export interface SteeringEdit {
17
17
  export interface SteeringAction {
18
18
  readonly title: string
19
19
  readonly edits: readonly SteeringEdit[]
20
+ /** A range the editor selects once the edits apply; absent means no reveal. */
21
+ readonly select?: {
22
+ readonly start: { readonly line: number; readonly character: number }
23
+ readonly end: { readonly line: number; readonly character: number }
24
+ }
20
25
  }
21
26
 
22
27
  /** A go-to-definition target. `path` absent means "this same document" (a footnote jump); `character` defaults to 0. */
@@ -105,6 +110,12 @@ export interface SteeringViewThread {
105
110
  readonly waitingOn: "human" | "agent"
106
111
  }
107
112
 
113
+ /** One labelled part of a node's drafted comment. */
114
+ export interface SteeringViewSection {
115
+ readonly label: string
116
+ readonly body: readonly SteeringViewNode[]
117
+ }
118
+
108
119
  /**
109
120
  * One node of a format's `view`: a generic container/item tree, the SAME
110
121
  * shape for every format. Deliberately never a closed per-format union — a
@@ -140,6 +151,8 @@ export interface SteeringViewNode {
140
151
  * (question); absent wherever `detail` itself is absent.
141
152
  */
142
153
  readonly detailInline?: readonly InlineNode[]
154
+ /** A review finding's drafted PR comment as labelled sections (What / Impact / Fix), when it has one. */
155
+ readonly comment?: readonly SteeringViewSection[]
143
156
  /** A file path this node points at, when it has one (a `review` hunk). */
144
157
  readonly path?: string
145
158
  /** A 1-based line in `path` this node points at, when it has one. */
@@ -12,7 +12,7 @@ The parser keeps a lone `\r` as content.
12
12
 
13
13
  In the code:
14
14
 
15
- - [x] ./src/parse.ts#30-31 — where it is kept
15
+ - ./src/parse.ts#30-31 — where it is kept
16
16
 
17
17
  Tick exactly one:
18
18
 
@@ -62,7 +62,6 @@
62
62
  "path": "./src/parse.ts",
63
63
  "line": 30,
64
64
  "endLine": 31,
65
- "checked": true,
66
65
  "detail": "where it is kept",
67
66
  "anchor": {
68
67
  "kind": "hunk",
@@ -47,6 +47,7 @@ export type {
47
47
  SteeringPointer,
48
48
  SteeringView,
49
49
  SteeringViewNode,
50
+ SteeringViewSection,
50
51
  SteeringViewThread,
51
52
  } from "./SteeringFormat.js"
52
53
 
@@ -27,6 +27,7 @@ describe("parseReviewDoc", () => {
27
27
  title: "Add calculator",
28
28
  headingLine: 4,
29
29
  description: "New add function for the calculator.",
30
+ detail: "New add function for the calculator.",
30
31
  descriptionInline: [{ kind: "text", value: "New add function for the calculator." }],
31
32
  descriptionNodes: [
32
33
  {
@@ -1557,6 +1558,23 @@ describe("review.apply", () => {
1557
1558
  ).toEqual({ ok: false, reason: "anchor-not-found" })
1558
1559
  })
1559
1560
 
1561
+ it("a chunk anchor on a findings chunk refuses anchor-not-found", () => {
1562
+ const doc = [
1563
+ "# Review: abc1234",
1564
+ "",
1565
+ "## Findings",
1566
+ "",
1567
+ "### Token check",
1568
+ "",
1569
+ "- ./src/auth.ts#1-2",
1570
+ "",
1571
+ ].join("\n")
1572
+ expect(review.apply(doc, { kind: "chunk", index: 0 }, { checked: true })).toEqual({
1573
+ ok: false,
1574
+ reason: "anchor-not-found",
1575
+ })
1576
+ })
1577
+
1560
1578
  it("a question anchor — not this format's own kind — refuses anchor-not-found", () => {
1561
1579
  expect(review.apply(NESTED_CONTENT, { kind: "question", index: 0 }, { checked: true })).toEqual(
1562
1580
  { ok: false, reason: "anchor-not-found" },
@@ -1574,7 +1592,7 @@ describe("review decisions — a finding's answer boxes", () => {
1574
1592
  "",
1575
1593
  "### Token check",
1576
1594
  "",
1577
- "- [ ] ./src/auth.ts#1-2",
1595
+ "- ./src/auth.ts#1-2",
1578
1596
  "",
1579
1597
  "Tick exactly one:",
1580
1598
  "",
@@ -2169,7 +2187,7 @@ describe("review — sectioned layout", async () => {
2169
2187
  "",
2170
2188
  "Leaks a token.",
2171
2189
  "",
2172
- "- [ ] ./src/a.ts#1-2 — here",
2190
+ "- ./src/a.ts#1-2 — here",
2173
2191
  "",
2174
2192
  ...boxes(ticked),
2175
2193
  "",
@@ -2228,16 +2246,16 @@ describe("review — sectioned layout", async () => {
2228
2246
  it("outlines the sections as containers, each finding's decision a node of its own", () => {
2229
2247
  const outline = viewOf(review, sectioned()).outline
2230
2248
  expect(outline.map((n) => n.name)).toEqual(["Findings", "Walkthrough", "Fixed automatically"])
2231
- expect(outline[0]!.children!.map((n) => n.name)).toEqual(["A finding (0/1)"])
2249
+ expect(outline[0]!.children!.map((n) => n.name)).toEqual(["A finding"])
2232
2250
  expect(outline[0]!.children![0]!.children!.map((n) => n.name)).toEqual(["Decision: open"])
2233
2251
  expect(outline[0]!.children![0]!.children![0]!.selectionRange.start.line).toBe(14)
2234
2252
  // A fully ticked walkthrough chunk is reviewed: its section is left empty.
2235
2253
  expect(outline[1]!.children).toEqual([])
2236
2254
  })
2237
2255
 
2238
- it("names the ticked decision, and keeps a decided finding while a hunk is unticked", () => {
2256
+ it("drops a decided finding from the outline: its location carries no read tick", () => {
2239
2257
  const outline = viewOf(review, sectioned(1)).outline
2240
- expect(outline[0]!.children![0]!.children!.map((n) => n.name)).toEqual(["Decision: won't fix"])
2258
+ expect(outline[0]!.children).toEqual([])
2241
2259
  })
2242
2260
 
2243
2261
  it("groups the view's chunks by section, anchors unchanged", () => {
@@ -2256,3 +2274,161 @@ describe("review — sectioned layout", async () => {
2256
2274
  expect(titles.some((t) => t.includes("A finding"))).toBe(false)
2257
2275
  })
2258
2276
  })
2277
+
2278
+ describe("review — finding locations and decision actions", () => {
2279
+ const finding = (pointer: string, ticked?: 0 | 1 | 2): string =>
2280
+ [
2281
+ "# Review: abc1234",
2282
+ "",
2283
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
2284
+ "",
2285
+ "## Findings",
2286
+ "",
2287
+ "### A finding",
2288
+ "",
2289
+ pointer,
2290
+ "",
2291
+ "Tick exactly one:",
2292
+ "",
2293
+ `- [${ticked === 0 ? "x" : " "}] fix as suggested`,
2294
+ `- [${ticked === 1 ? "x" : " "}] won't fix`,
2295
+ `- [${ticked === 2 ? "x" : " "}] _your answer_`,
2296
+ "",
2297
+ ].join("\n")
2298
+ const at = (content: string, line: number) =>
2299
+ review.actions!(content, { start: { line, character: 0 }, end: { line, character: 0 } })
2300
+
2301
+ it("links a plain location and resolves go-to-definition to its line", () => {
2302
+ const content = finding("- ./src/a.ts#5-6 — here")
2303
+ expect(review.validate(content)).toEqual([])
2304
+ expect(review.documentLinks!(content)).toEqual([
2305
+ expect.objectContaining({ path: "./src/a.ts", line: 4 }),
2306
+ ])
2307
+ expect(review.pointerAt!(content, { line: 8, character: 3 })).toEqual({
2308
+ path: "./src/a.ts",
2309
+ line: 4,
2310
+ })
2311
+ })
2312
+
2313
+ it("refuses a checkbox pointer in a finding at its own line", () => {
2314
+ for (const box of ["[ ]", "[x]"]) {
2315
+ const found = review.validate(finding(`- ${box} ./src/a.ts#5-6`))
2316
+ expect(found.some((f) => f.line === 8)).toBe(true)
2317
+ }
2318
+ })
2319
+
2320
+ it("offers no hunk tick and no read count for a finding location", () => {
2321
+ const content = finding("- ./src/a.ts#5-6")
2322
+ const titles = at(content, 8).map((a) => a.title)
2323
+ expect(titles.some((t) => t.includes("hunk"))).toBe(false)
2324
+ expect(viewOf(review, content).outline[0]!.children![0]!.name).toBe("A finding")
2325
+ const hunk = review.view(content).nodes[0]!.children![0]!
2326
+ expect(hunk.checked).toBeUndefined()
2327
+ expect(review.apply(content, hunk.anchor, { checked: true }).ok).toBe(false)
2328
+ })
2329
+
2330
+ it("accepts, rejects and answers from any line, leaving out the current tick's action", () => {
2331
+ const content = finding("- ./src/a.ts#5-6")
2332
+ for (const line of [6, 8, 12]) {
2333
+ expect(at(content, line).map((a) => a.title)).toEqual(
2334
+ expect.arrayContaining([
2335
+ "gtd: accept finding",
2336
+ "gtd: reject finding",
2337
+ "gtd: answer finding",
2338
+ ]),
2339
+ )
2340
+ }
2341
+ const run = (title: string, text: string) =>
2342
+ applySteeringEdits(text, at(text, 8).find((a) => a.title === title)!.edits)
2343
+ const accepted = run("gtd: accept finding", finding("- ./src/a.ts#5-6", 1))
2344
+ expect(accepted).toBe(finding("- ./src/a.ts#5-6", 0))
2345
+ expect(at(accepted, 8).map((a) => a.title)).not.toContain("gtd: accept finding")
2346
+ expect(run("gtd: reject finding", accepted)).toBe(finding("- ./src/a.ts#5-6", 1))
2347
+ expect(run("gtd: answer finding", accepted)).toBe(finding("- ./src/a.ts#5-6", 2))
2348
+ })
2349
+
2350
+ it("selects the whole free-text slot on answer", () => {
2351
+ const content = finding("- ./src/a.ts#5-6")
2352
+ const answer = at(content, 8).find((a) => a.title === "gtd: answer finding")!
2353
+ expect(answer.select).toEqual({
2354
+ start: { line: 14, character: 6 },
2355
+ end: { line: 14, character: 19 },
2356
+ })
2357
+ })
2358
+
2359
+ it("offers no finding actions on a top-level comments chunk", () => {
2360
+ const content = finding("- ./src/a.ts#5-6").replace(
2361
+ "## Findings\n\n### A finding",
2362
+ "## Your comments:",
2363
+ )
2364
+ expect(
2365
+ at(content, 6)
2366
+ .map((a) => a.title)
2367
+ .join(),
2368
+ ).not.toContain("finding")
2369
+ })
2370
+ })
2371
+
2372
+ describe("review view drafted comment", () => {
2373
+ const doc = (pointer: string) => `# Review
2374
+
2375
+ ## Findings
2376
+
2377
+ ### Token check
2378
+
2379
+ _correctness · judge: fix_
2380
+
2381
+ ${pointer}Draft PR comment:
2382
+
2383
+ > nit: Uses \`==\`.
2384
+ > Impact: Timing leak.
2385
+ > Fix: Use \`timingSafeEqual\`.
2386
+ >
2387
+ > \`\`\`suggestion
2388
+ > return ok
2389
+ > \`\`\`
2390
+
2391
+ Tick exactly one:
2392
+
2393
+ - [ ] fix as suggested
2394
+ - [ ] won't fix
2395
+ - [ ] _your answer_
2396
+ `
2397
+ for (const [name, pointer] of [
2398
+ ["with a pointer", "- [ ] ./src/auth.ts#1-3\n\n"],
2399
+ ["without a pointer", ""],
2400
+ ] as const) {
2401
+ it(`splits the comment into What, Impact and Fix ${name}`, () => {
2402
+ const node = steeringFormatFor("review")!.view(doc(pointer)).nodes[0]!
2403
+ expect(node.comment?.map((s) => s.label)).toEqual(["What", "Impact", "Fix"])
2404
+ const fix = node.comment![2]!.body
2405
+ expect(fix.some((n) => n.block?.kind === "code" && n.block.text === "return ok")).toBe(true)
2406
+ expect(node.detail).not.toContain("Draft PR comment")
2407
+ })
2408
+ }
2409
+
2410
+ it("keeps an unlabelled comment as one What", () => {
2411
+ const node = steeringFormatFor("review")!.view(
2412
+ doc("")
2413
+ .replace(/Impact:[^]*?```\n\nTick/, "Plain.\n\nTick")
2414
+ .replace("> nit: Uses `==`.\n> ", "> nit: Uses."),
2415
+ ).nodes[0]!
2416
+ expect(node.comment?.map((s) => s.label)).toEqual(["What"])
2417
+ })
2418
+
2419
+ it("keeps a Fix: line inside a suggestion fence in its code block", () => {
2420
+ const node = steeringFormatFor("review")!.view(
2421
+ doc("").replace("> return ok", "> Fix: inside\n> return ok"),
2422
+ ).nodes[0]!
2423
+ expect(node.comment?.map((s) => s.label)).toEqual(["What", "Impact", "Fix"])
2424
+ const code = node.comment![2]!.body.find((n) => n.block?.kind === "code")
2425
+ expect(code?.block?.text).toContain("Fix: inside")
2426
+ })
2427
+
2428
+ it("leaves the comment in the body of a chunk with no answer list", () => {
2429
+ const node = steeringFormatFor("review")!.view(doc("").replace(/Tick exactly one:[^]*$/, ""))
2430
+ .nodes[0]!
2431
+ expect(node.comment).toBeUndefined()
2432
+ expect(node.detail).toContain("Draft PR comment")
2433
+ })
2434
+ })