@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -2
- package/claude/hooks/drive.ts +10 -1
- package/claude/hooks/git.ts +2 -2
- package/claude/hooks/pr.ts +80 -20
- package/claude/hooks/register.tsx +19 -4
- package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
- package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
- package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
- package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +430 -100
- package/dist/gtd.core.mjs +147 -134
- package/dist/steering.mjs +1 -1
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +2 -2
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +185 -18
- package/dist/wire.mjs +1 -1
- package/dist/workflow-kit.mjs +9 -3
- package/package.json +1 -1
- package/src/cli/Cli.ts +1 -1
- package/src/cli/Program.ts +18 -2
- package/src/edge/Edge.ts +93 -11
- package/src/flows/helpers.ts +36 -18
- package/src/flows/index.test.ts +3 -0
- package/src/flows/index.ts +5 -0
- package/src/flows/runtime.ts +123 -16
- package/src/install/Install.ts +9 -2
- package/src/platform/Git.test.ts +7 -11
- package/src/platform/NodeSync.ts +2 -0
- package/src/platform/SyncIo.ts +2 -0
- package/src/platform/Workspace.test.ts +37 -0
- package/src/platform/Workspace.ts +36 -0
- package/src/replay/Replay.ts +33 -1
- package/src/replay/Trailers.ts +3 -0
- package/src/replay/Tree.ts +5 -0
- package/src/steering/FORMATS.md +3 -1
- package/src/steering/SteeringFormat.ts +13 -0
- package/src/steering/fixtures/review/sectioned.md +1 -1
- package/src/steering/fixtures/review/sectioned.read.json +0 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +181 -5
- package/src/steering/review.ts +160 -18
- package/src/step/RepoSnapshot.ts +4 -0
- package/src/step/planStep.ts +6 -6
- package/src/twins/EmittedScriptRecognizer.ts +1 -1
- package/src/twins/InMemRepo.ts +24 -3
- package/src/twins/Layers.ts +1 -0
- package/src/twins/SyncIo.test.ts +19 -4
- package/src/twins/SyncIo.ts +13 -1
- package/src/wire/BeatDocument.test.ts +15 -0
- package/src/wire/BeatDocument.ts +8 -2
- package/src/wire/Demand.ts +4 -2
- package/src/wire/golden/1/beat-capture.json +1 -0
- package/src/wire/golden/1/beat-message.json +1 -0
- package/src/wire/golden/1/beat-prompt.json +1 -0
- package/src/wire/golden/1/beat-script.json +1 -0
- package/src/wire/golden/1/beat-stalled.json +1 -0
- package/src/workflow/Workflow.ts +2 -0
- package/src/workflow-kit/index.test.ts +1 -0
- package/src/workflow-kit/index.ts +1 -0
- 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 }),
|
package/src/replay/Replay.ts
CHANGED
|
@@ -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([
|
|
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) {
|
package/src/replay/Trailers.ts
CHANGED
|
@@ -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")}`
|
package/src/replay/Tree.ts
CHANGED
|
@@ -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)
|
package/src/steering/FORMATS.md
CHANGED
|
@@ -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. */
|
package/src/steering/index.ts
CHANGED
|
@@ -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
|
-
"-
|
|
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
|
-
"-
|
|
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
|
|
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("
|
|
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
|
|
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
|
+
})
|