@pmelab/gtd 42.0.0 → 44.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 +96 -48
- package/claude/hooks/sketch.ts +1 -1
- package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
- package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
- package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
- package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
- package/dist/gtd.bundle.mjs +488 -30
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +447 -27
- package/dist/web/main.js +42 -13
- package/dist/workflow-kit.mjs +61 -25
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +7 -290
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +27 -7
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +287 -161
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +33 -1
- package/src/cli/Cli.ts +70 -6
- package/src/cli/History.ts +15 -0
- package/src/cli/Judges.test.ts +373 -0
- package/src/cli/Judges.ts +624 -0
- package/src/cli/Metrics.ts +3 -16
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +9 -0
- package/src/steering/FORMATS.md +4 -4
- package/src/steering/index.ts +12 -1
- package/src/steering/review.test.ts +107 -0
- package/src/steering/review.ts +159 -9
- package/src/testkit/EmittedScriptRecognizer.ts +0 -14
- package/src/testkit/steps.ts +4 -0
- package/src/workflow-kit/review.test.ts +170 -26
- package/src/workflow-kit/review.ts +95 -19
- package/src/workflows/feature/coverage.ts +1 -1
- package/src/workflows/feature/feature.test.ts +8 -0
- package/src/workflows/feature/feature.ts +2 -2
- package/src/workflows/feature/index.ts +1 -1
- package/src/workflows/feature/lens-review.feature +16 -4
- package/src/workflows/feature/refactor.ts +1 -1
- package/src/workflows/feature/review-answers.feature +179 -0
- package/src/workflows/feature/seam.ts +1 -1
- package/src/workflows/feature/steps.test.ts +2 -76
- package/src/workflows/feature/steps.ts +5 -48
- package/src/workflows/fix/fix.feature +20 -21
- package/src/workflows/index.ts +1 -1
- package/src/workflows/review/review-door.feature +149 -0
- package/src/workflows/review/review.test.ts +1 -73
- package/src/workflows/review/review.ts +34 -6
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
- package/src/workflows/shared/access.test.ts +2 -11
- package/src/workflows/shared/access.ts +0 -2
- package/src/workflows/{feature → shared}/review.test.ts +9 -61
- package/src/workflows/{feature → shared}/review.ts +40 -48
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +2 -0
- package/src/workflows/shared/steps.test.ts +64 -2
- package/src/workflows/shared/steps.ts +40 -1
- package/src/workflows/shared/tail.ts +2 -2
- package/src/workflows/shared/text.test.ts +40 -89
- package/src/workflows/shared/text.ts +63 -54
package/src/cli/Program.ts
CHANGED
|
@@ -109,6 +109,7 @@ export {
|
|
|
109
109
|
} from "./Output.js"
|
|
110
110
|
import { runStraysCommand } from "./Strays.js"
|
|
111
111
|
import { runHistoryFetchCommand, runHistoryListCommand, runHistoryShowCommand } from "./History.js"
|
|
112
|
+
import { runHistoryJudgesCommand } from "./Judges.js"
|
|
112
113
|
import { runHistoryMetricsCommand } from "./Metrics.js"
|
|
113
114
|
|
|
114
115
|
/**
|
|
@@ -176,6 +177,12 @@ export type Command =
|
|
|
176
177
|
| { readonly kind: "historyList" }
|
|
177
178
|
| { readonly kind: "historyShow"; readonly ref: string }
|
|
178
179
|
| { readonly kind: "historyMetrics"; readonly refs: readonly string[] }
|
|
180
|
+
| {
|
|
181
|
+
readonly kind: "historyJudges"
|
|
182
|
+
readonly refs: readonly string[]
|
|
183
|
+
readonly vars: Readonly<Record<string, string>>
|
|
184
|
+
readonly jsonl: boolean
|
|
185
|
+
}
|
|
179
186
|
| { readonly kind: "historyFetch"; readonly remote: string }
|
|
180
187
|
| {
|
|
181
188
|
readonly kind: "strays"
|
|
@@ -1324,6 +1331,8 @@ const dispatchVoidCommand = (
|
|
|
1324
1331
|
return runHistoryShowCommand(command.ref, json, out)
|
|
1325
1332
|
case "historyMetrics":
|
|
1326
1333
|
return runHistoryMetricsCommand(command.refs, json, out)
|
|
1334
|
+
case "historyJudges":
|
|
1335
|
+
return runHistoryJudgesCommand(command.refs, command.vars, command.jsonl, json, out)
|
|
1327
1336
|
case "historyFetch":
|
|
1328
1337
|
return runHistoryFetchCommand(command.remote, out)
|
|
1329
1338
|
}
|
package/src/steering/FORMATS.md
CHANGED
|
@@ -24,11 +24,11 @@ read them.
|
|
|
24
24
|
its first line starts `H:`.
|
|
25
25
|
- **Review notes**: what a human added to a `review` document since the reviewer
|
|
26
26
|
wrote it — changed chunk prose, pointer notes, and new one-shot footnotes,
|
|
27
|
-
never threads. **This is the hand-off from
|
|
28
|
-
|
|
27
|
+
never threads. **This is the hand-off from a `fix` review to the next `fix`**:
|
|
28
|
+
the review leaves the annotated file beside the reviewer's own copy
|
|
29
29
|
(`.gtd/REVIEW.orig.md`), and `fix` reads its work from the notes between the
|
|
30
|
-
two and from the risks, so a file
|
|
31
|
-
|
|
30
|
+
two and from the risks, so a hand-off file must read the same in any `fix`
|
|
31
|
+
that consumes it.
|
|
32
32
|
|
|
33
33
|
## What may change
|
|
34
34
|
|
package/src/steering/index.ts
CHANGED
|
@@ -8,7 +8,18 @@ import type {
|
|
|
8
8
|
export { formatFinding } from "./SteeringFormat.js"
|
|
9
9
|
import { qaDescriptor } from "./qa.js"
|
|
10
10
|
import { reviewDescriptor } from "./review.js"
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
ANSWER_FIX,
|
|
13
|
+
ANSWER_LEAD,
|
|
14
|
+
ANSWER_WONT_FIX,
|
|
15
|
+
reviewDecisions,
|
|
16
|
+
reviewNotes,
|
|
17
|
+
reviewRisks,
|
|
18
|
+
unansweredNotes,
|
|
19
|
+
type ReviewChoice,
|
|
20
|
+
type ReviewDecision,
|
|
21
|
+
type ReviewNote,
|
|
22
|
+
} from "./review.js"
|
|
12
23
|
export { applySteeringEdits } from "./Edits.js"
|
|
13
24
|
|
|
14
25
|
export type { InlineNode } from "./Inline.js"
|
|
@@ -1839,3 +1839,110 @@ describe("reviewRisks", async () => {
|
|
|
1839
1839
|
expect(reviewRisks(doc("Prose.", ["- [ ] ./src/a.ts#1-3 — fine"]))).toEqual([])
|
|
1840
1840
|
})
|
|
1841
1841
|
})
|
|
1842
|
+
|
|
1843
|
+
describe("answer boxes", async () => {
|
|
1844
|
+
const { reviewDecisions, reviewNotes, unansweredNotes } = await import("./review.js")
|
|
1845
|
+
const note = (title: string, boxes: readonly [string, string, string]): string =>
|
|
1846
|
+
[
|
|
1847
|
+
`## ${title}`,
|
|
1848
|
+
"",
|
|
1849
|
+
"Should subtract round?",
|
|
1850
|
+
"",
|
|
1851
|
+
"Tick exactly one:",
|
|
1852
|
+
"",
|
|
1853
|
+
`- [${boxes[0]}] fix as suggested`,
|
|
1854
|
+
`- [${boxes[1]}] won't fix`,
|
|
1855
|
+
`- [${boxes[2] === "" ? " " : "x"}] ${boxes[2] === "" || boxes[2] === "x" ? "_your answer_" : boxes[2]}`,
|
|
1856
|
+
].join("\n")
|
|
1857
|
+
const doc = (...chunks: string[]): string =>
|
|
1858
|
+
["# Review: abc1234", "", "<!-- base: abc1234 -->", "", ...chunks, ""].join("\n")
|
|
1859
|
+
const open = doc(note("Rounding", [" ", " ", ""]))
|
|
1860
|
+
|
|
1861
|
+
it("are no file pointers, no finding and no part of the note's text", () => {
|
|
1862
|
+
expect(steeringFormatFor("review")!.validate(open)).toEqual([])
|
|
1863
|
+
expect(parseReviewDoc(open).changesets[0]).toMatchObject({
|
|
1864
|
+
description: "Should subtract round?",
|
|
1865
|
+
files: [],
|
|
1866
|
+
})
|
|
1867
|
+
})
|
|
1868
|
+
|
|
1869
|
+
it("survive the gate's tick reset", () => {
|
|
1870
|
+
const ticked = doc(note("Rounding", ["x", " ", ""]))
|
|
1871
|
+
expect(steeringFormatFor("review")!.clearTicks(ticked)).toBe(ticked)
|
|
1872
|
+
})
|
|
1873
|
+
|
|
1874
|
+
it("a tick is never a note", () => {
|
|
1875
|
+
expect(reviewNotes(open, doc(note("Rounding", [" ", "x", ""])))).toEqual([])
|
|
1876
|
+
})
|
|
1877
|
+
|
|
1878
|
+
it("an unticked note, or one with two ticks, is unanswered", () => {
|
|
1879
|
+
expect(unansweredNotes(open)).toEqual([
|
|
1880
|
+
{ title: "Rounding", line: 5, reason: "tick exactly one box" },
|
|
1881
|
+
])
|
|
1882
|
+
expect(unansweredNotes(doc(note("Rounding", ["x", "x", ""])))).toHaveLength(1)
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
it("a ticked free-text slot still holding the placeholder is unanswered", () => {
|
|
1886
|
+
expect(unansweredNotes(doc(note("Rounding", [" ", " ", "x"])))).toHaveLength(1)
|
|
1887
|
+
})
|
|
1888
|
+
|
|
1889
|
+
it("each box decides its note", () => {
|
|
1890
|
+
const after = doc(
|
|
1891
|
+
note("Fix it", ["x", " ", ""]),
|
|
1892
|
+
note("Leave it", [" ", "x", ""]),
|
|
1893
|
+
note("Answer it", [" ", " ", "round half up"]),
|
|
1894
|
+
)
|
|
1895
|
+
expect(unansweredNotes(after)).toEqual([])
|
|
1896
|
+
expect(reviewDecisions("", after)).toEqual([
|
|
1897
|
+
{ anchor: "Fix it", body: "Should subtract round?", choice: "fix", text: "" },
|
|
1898
|
+
{ anchor: "Leave it", body: "Should subtract round?", choice: "wontfix", text: "" },
|
|
1899
|
+
{
|
|
1900
|
+
anchor: "Answer it",
|
|
1901
|
+
body: "Should subtract round?",
|
|
1902
|
+
choice: "answer",
|
|
1903
|
+
text: "round half up",
|
|
1904
|
+
},
|
|
1905
|
+
])
|
|
1906
|
+
})
|
|
1907
|
+
|
|
1908
|
+
it("an answer the baseline already held is no new decision", () => {
|
|
1909
|
+
const answered = doc(note("Rounding", ["x", " ", ""]))
|
|
1910
|
+
expect(reviewDecisions(answered, answered)).toEqual([])
|
|
1911
|
+
expect(reviewDecisions(answered, doc(note("Rounding", [" ", "x", ""])))).toMatchObject([
|
|
1912
|
+
{ choice: "wontfix" },
|
|
1913
|
+
])
|
|
1914
|
+
})
|
|
1915
|
+
|
|
1916
|
+
it("are read by position: an edited label or a path-like answer still answers", () => {
|
|
1917
|
+
const edited = doc(
|
|
1918
|
+
note("Rounding", [" ", " ", ""]).replace("- [ ] fix as suggested", "- [x] fix it, but floor"),
|
|
1919
|
+
)
|
|
1920
|
+
expect(reviewDecisions("", edited)).toMatchObject([{ choice: "fix" }])
|
|
1921
|
+
const pathy = doc(note("Rounding", [" ", " ", "./src/calc.ts#1-1 round here"]))
|
|
1922
|
+
expect(steeringFormatFor("review")!.validate(pathy)).toEqual([])
|
|
1923
|
+
expect(reviewDecisions("", pathy)).toMatchObject([
|
|
1924
|
+
{ choice: "answer", text: "./src/calc.ts#1-1 round here" },
|
|
1925
|
+
])
|
|
1926
|
+
expect(steeringFormatFor("review")!.clearTicks(pathy)).toBe(pathy)
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
it("a note whose boxes were deleted is unanswered", () => {
|
|
1930
|
+
const gone = doc("## Rounding\n\nShould subtract round?")
|
|
1931
|
+
expect(unansweredNotes(gone, open)).toEqual([
|
|
1932
|
+
{ title: "Rounding", line: 0, reason: "its answer boxes were deleted — restore them" },
|
|
1933
|
+
])
|
|
1934
|
+
expect(unansweredNotes(doc(), open)).toHaveLength(1)
|
|
1935
|
+
})
|
|
1936
|
+
|
|
1937
|
+
it("a box added or removed leaves the note unanswered", () => {
|
|
1938
|
+
const four = doc(note("Rounding", ["x", " ", ""]).concat("\n- [ ] another"))
|
|
1939
|
+
expect(unansweredNotes(four)).toHaveLength(1)
|
|
1940
|
+
})
|
|
1941
|
+
|
|
1942
|
+
it("a box list other than gtd's three is a pointer gone wrong", () => {
|
|
1943
|
+
const stray = doc("## Chunk\n\n- [ ] fix as suggested\n- [ ] something else\n- [ ] third")
|
|
1944
|
+
expect(steeringFormatFor("review")!.validate(stray)).toEqual([
|
|
1945
|
+
expect.objectContaining({ message: 'Chunk "Chunk" has no file pointers' }),
|
|
1946
|
+
])
|
|
1947
|
+
})
|
|
1948
|
+
})
|
package/src/steering/review.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ListItem, Root, RootContent } from "mdast"
|
|
1
|
+
import type { List, ListItem, Root, RootContent } from "mdast"
|
|
2
2
|
import { blockNodesOfRun, blockRunInline } from "./Blocks.js"
|
|
3
3
|
import type { FootnoteAnchor } from "./Footnotes.js"
|
|
4
4
|
import {
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
toLspPositionFromOffset,
|
|
36
36
|
type HeadingBlock,
|
|
37
37
|
} from "./MarkdownTree.js"
|
|
38
|
+
import { FREE_TEXT_PLACEHOLDER } from "./qa.js"
|
|
38
39
|
import type {
|
|
39
40
|
SteeringAnchor,
|
|
40
41
|
SteeringAnnotateResult,
|
|
@@ -74,8 +75,20 @@ interface Changeset {
|
|
|
74
75
|
readonly files: readonly ReviewFile[]
|
|
75
76
|
/** 0-based index of this chunk's `##` heading. */
|
|
76
77
|
readonly headingLine: number
|
|
78
|
+
/** The chunk's answer boxes, when gtd gave it some. */
|
|
79
|
+
readonly answers?: readonly AnswerOption[]
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
interface AnswerOption {
|
|
83
|
+
readonly checked: boolean
|
|
84
|
+
/** The free-text slot's unfilled placeholder normalizes to `""`. */
|
|
85
|
+
readonly text: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const ANSWER_LEAD = "Tick exactly one:"
|
|
89
|
+
export const ANSWER_FIX = "fix as suggested"
|
|
90
|
+
export const ANSWER_WONT_FIX = "won't fix"
|
|
91
|
+
|
|
79
92
|
interface ReviewDoc {
|
|
80
93
|
readonly shortHash?: string
|
|
81
94
|
readonly fullHash?: string
|
|
@@ -470,6 +483,44 @@ const parseHunk = (
|
|
|
470
483
|
return { file, errors }
|
|
471
484
|
}
|
|
472
485
|
|
|
486
|
+
const itemText = (content: string, item: ListItem): string =>
|
|
487
|
+
item.children
|
|
488
|
+
.map((c) => sourceText(content, c))
|
|
489
|
+
.join(" ")
|
|
490
|
+
// A ticked item's first paragraph span starts at its `[x]`.
|
|
491
|
+
.replace(/^\[[ xX]\]\s*/, "")
|
|
492
|
+
.trim()
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The task list right after the `Tick exactly one:` paragraph, with that
|
|
496
|
+
* paragraph's index. Read by position, never by label, so an edited label or
|
|
497
|
+
* a free text opening with `./path` is still an answer, not a pointer. Any
|
|
498
|
+
* count but three boxes is kept, and reads as unanswered.
|
|
499
|
+
*/
|
|
500
|
+
const answerListOf = (
|
|
501
|
+
content: string,
|
|
502
|
+
body: readonly RootContent[],
|
|
503
|
+
): { readonly lead: number; readonly list: List; readonly options: AnswerOption[] } | undefined => {
|
|
504
|
+
const lead = body.findIndex(
|
|
505
|
+
(n, i) =>
|
|
506
|
+
n.type === "paragraph" &&
|
|
507
|
+
sourceText(content, n) === ANSWER_LEAD &&
|
|
508
|
+
body[i + 1]?.type === "list" &&
|
|
509
|
+
(body[i + 1] as List).children.every((item) => item.checked != null),
|
|
510
|
+
)
|
|
511
|
+
if (lead === -1) return undefined
|
|
512
|
+
const list = body[lead + 1] as List
|
|
513
|
+
const last = list.children.length - 1
|
|
514
|
+
const options = list.children.map((item, i) => {
|
|
515
|
+
const text = itemText(content, item)
|
|
516
|
+
return {
|
|
517
|
+
checked: item.checked === true,
|
|
518
|
+
text: i === last && text.toLowerCase() === FREE_TEXT_PLACEHOLDER.toLowerCase() ? "" : text,
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
return { lead, list, options }
|
|
522
|
+
}
|
|
523
|
+
|
|
473
524
|
/** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
|
|
474
525
|
const parseChunkBody = (
|
|
475
526
|
content: string,
|
|
@@ -482,11 +533,19 @@ const parseChunkBody = (
|
|
|
482
533
|
readonly descriptionInline: readonly InlineNode[]
|
|
483
534
|
readonly files: readonly ReviewFile[]
|
|
484
535
|
readonly errors: readonly SteeringFinding[]
|
|
536
|
+
readonly answers?: readonly AnswerOption[]
|
|
537
|
+
/** Whether a task box outside the answer list exists. */
|
|
538
|
+
readonly boxed: boolean
|
|
485
539
|
} => {
|
|
486
|
-
const
|
|
487
|
-
|
|
540
|
+
const answer = answerListOf(content, body)
|
|
541
|
+
const isAnswer = (n: RootContent): boolean =>
|
|
542
|
+
answer !== undefined && (n === answer.list || n === body[answer.lead])
|
|
543
|
+
const firstPointerIndex = body.findIndex(
|
|
544
|
+
(n) => !isAnswer(n) && taskItems(n).some((item) => hasPointerToken(content, item)),
|
|
545
|
+
)
|
|
546
|
+
const leadingRun = (firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)).filter(
|
|
547
|
+
(n) => !isAnswer(n),
|
|
488
548
|
)
|
|
489
|
-
const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)
|
|
490
549
|
const descriptionNodes = blockNodesOfRun(content, leadingRun)
|
|
491
550
|
const descriptionInline = collapseImages(blockRunInline(content, leadingRun))
|
|
492
551
|
const description = leadingRun
|
|
@@ -497,13 +556,21 @@ const parseChunkBody = (
|
|
|
497
556
|
|
|
498
557
|
const files: ReviewFile[] = []
|
|
499
558
|
const errors: SteeringFinding[] = []
|
|
500
|
-
for (const item of body.flatMap((n) => taskItems(n))) {
|
|
559
|
+
for (const item of body.filter((n) => !isAnswer(n)).flatMap((n) => taskItems(n))) {
|
|
501
560
|
const parsed = parseHunk(content, lines, title, item)
|
|
502
561
|
if (!parsed) continue
|
|
503
562
|
files.push(parsed.file)
|
|
504
563
|
errors.push(...parsed.errors)
|
|
505
564
|
}
|
|
506
|
-
return {
|
|
565
|
+
return {
|
|
566
|
+
description,
|
|
567
|
+
descriptionNodes,
|
|
568
|
+
descriptionInline,
|
|
569
|
+
files,
|
|
570
|
+
errors,
|
|
571
|
+
...(answer === undefined ? {} : { answers: answer.options }),
|
|
572
|
+
boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0),
|
|
573
|
+
}
|
|
507
574
|
}
|
|
508
575
|
|
|
509
576
|
/** One `##` chunk heading node with its raw body block nodes (up to the next `##`-or-shallower heading). */
|
|
@@ -550,18 +617,28 @@ const parseChangesets = (
|
|
|
550
617
|
descriptionNodes,
|
|
551
618
|
descriptionInline,
|
|
552
619
|
files,
|
|
620
|
+
answers,
|
|
621
|
+
boxed,
|
|
553
622
|
errors: bodyErrors,
|
|
554
623
|
} = parseChunkBody(content, lines, title, body)
|
|
555
624
|
errors.push(...bodyErrors)
|
|
556
625
|
// A prose-only chunk is a note gtd composed; a box with no pointer is a pointer gone wrong.
|
|
557
|
-
if (files.length === 0 &&
|
|
626
|
+
if (files.length === 0 && boxed) {
|
|
558
627
|
errors.push({
|
|
559
628
|
message: `Chunk "${title}" has no file pointers`,
|
|
560
629
|
line: headingLine,
|
|
561
630
|
range: nodeRange(heading),
|
|
562
631
|
})
|
|
563
632
|
}
|
|
564
|
-
changesets.push({
|
|
633
|
+
changesets.push({
|
|
634
|
+
title,
|
|
635
|
+
description,
|
|
636
|
+
descriptionNodes,
|
|
637
|
+
descriptionInline,
|
|
638
|
+
files,
|
|
639
|
+
headingLine,
|
|
640
|
+
...(answers === undefined ? {} : { answers }),
|
|
641
|
+
})
|
|
565
642
|
}
|
|
566
643
|
if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
|
|
567
644
|
return { changesets, errors }
|
|
@@ -645,8 +722,13 @@ const isHunkPointerItem = (content: string, item: ListItem): boolean => {
|
|
|
645
722
|
*/
|
|
646
723
|
const reviewClearTicks = (content: string): string => {
|
|
647
724
|
const tree = parseMarkdown(content)
|
|
725
|
+
const answers = new Set(
|
|
726
|
+
splitChunks(tree).flatMap(({ body }) => answerListOf(content, body)?.list.children ?? []),
|
|
727
|
+
)
|
|
648
728
|
const offsets = taskItems(tree)
|
|
649
|
-
.filter(
|
|
729
|
+
.filter(
|
|
730
|
+
(item) => item.checked === true && !answers.has(item) && isHunkPointerItem(content, item),
|
|
731
|
+
)
|
|
650
732
|
.map((item) => checkboxOffset(content, item))
|
|
651
733
|
.filter((o): o is number => o !== undefined)
|
|
652
734
|
.sort((a, b) => a - b)
|
|
@@ -1185,3 +1267,71 @@ export const reviewRisks = (
|
|
|
1185
1267
|
),
|
|
1186
1268
|
)
|
|
1187
1269
|
.map((r, i) => ({ id: `risk-${i + 1}`, ...r }))
|
|
1270
|
+
|
|
1271
|
+
export type ReviewChoice = "fix" | "wontfix" | "answer"
|
|
1272
|
+
|
|
1273
|
+
/** A note's answer: one box ticked, and a ticked free-text slot filled in. */
|
|
1274
|
+
export interface ReviewDecision {
|
|
1275
|
+
/** The note's chunk title. */
|
|
1276
|
+
readonly anchor: string
|
|
1277
|
+
/** The note's text as gtd wrote it. */
|
|
1278
|
+
readonly body: string
|
|
1279
|
+
readonly choice: ReviewChoice
|
|
1280
|
+
/** The human's free text; empty unless `choice` is `answer`. */
|
|
1281
|
+
readonly text: string
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const CHOICES: readonly ReviewChoice[] = ["fix", "wontfix", "answer"]
|
|
1285
|
+
|
|
1286
|
+
const decisionOf = (chunk: Changeset): ReviewDecision | undefined => {
|
|
1287
|
+
if (chunk.answers?.length !== 3) return undefined
|
|
1288
|
+
const ticked = chunk.answers.flatMap((o, i) => (o.checked ? [i] : []))
|
|
1289
|
+
if (ticked.length !== 1) return undefined
|
|
1290
|
+
const i = ticked[0]!
|
|
1291
|
+
const text = i === 2 ? chunk.answers![2]!.text : ""
|
|
1292
|
+
if (i === 2 && text === "") return undefined
|
|
1293
|
+
return { anchor: chunk.title, body: chunk.description, choice: CHOICES[i]!, text }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Notes whose answer boxes do not hold exactly one tick (a ticked free-text
|
|
1298
|
+
* slot filled in), with their 1-based heading line and why; and, given the
|
|
1299
|
+
* file `before` the edit, every note whose boxes were deleted.
|
|
1300
|
+
*/
|
|
1301
|
+
export const unansweredNotes = (
|
|
1302
|
+
content: string,
|
|
1303
|
+
before?: string,
|
|
1304
|
+
): readonly { title: string; line: number; reason: string }[] => {
|
|
1305
|
+
const now = parseReviewDoc(content).changesets
|
|
1306
|
+
const boxed = new Set(now.filter((c) => c.answers !== undefined).map((c) => c.title))
|
|
1307
|
+
const removed = (before === undefined ? [] : parseReviewDoc(before).changesets)
|
|
1308
|
+
.filter((c) => c.answers !== undefined && !boxed.has(c.title))
|
|
1309
|
+
.map((c) => ({
|
|
1310
|
+
title: c.title,
|
|
1311
|
+
line: 0,
|
|
1312
|
+
reason: "its answer boxes were deleted — restore them",
|
|
1313
|
+
}))
|
|
1314
|
+
const open = now
|
|
1315
|
+
.filter((c) => c.answers !== undefined && decisionOf(c) === undefined)
|
|
1316
|
+
.map((c) => ({
|
|
1317
|
+
title: c.title,
|
|
1318
|
+
line: c.headingLine + 1,
|
|
1319
|
+
reason:
|
|
1320
|
+
c.answers!.length === 3
|
|
1321
|
+
? "tick exactly one box"
|
|
1322
|
+
: `it has ${c.answers!.length} boxes — restore the three gtd wrote`,
|
|
1323
|
+
}))
|
|
1324
|
+
return [...open, ...removed]
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Every answer in `after` that `before` did not already hold for the same note, in document order. */
|
|
1328
|
+
export const reviewDecisions = (before: string, after: string): readonly ReviewDecision[] => {
|
|
1329
|
+
const prior = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]))
|
|
1330
|
+
return parseReviewDoc(after).changesets.flatMap((chunk) => {
|
|
1331
|
+
const decision = decisionOf(chunk)
|
|
1332
|
+
if (decision === undefined) return []
|
|
1333
|
+
const was = prior.get(chunk.title)
|
|
1334
|
+
const old = was === undefined ? undefined : decisionOf(was)
|
|
1335
|
+
return old?.choice === decision.choice && old.text === decision.text ? [] : [decision]
|
|
1336
|
+
})
|
|
1337
|
+
}
|
|
@@ -670,19 +670,6 @@ const splitBlocks = (script: string): readonly string[] => {
|
|
|
670
670
|
return blocks
|
|
671
671
|
}
|
|
672
672
|
|
|
673
|
-
/** `deferNotesScript`: append its one section to `.gtd/TODO.md`, reading the section's lines off the final `printf`. */
|
|
674
|
-
const DEFER_HEAD = "mkdir -p .gtd\nif [ -s .gtd/TODO.md ]; then"
|
|
675
|
-
const recognizeDeferNotes = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
676
|
-
if (!block.startsWith(DEFER_HEAD)) return undefined
|
|
677
|
-
const at = block.lastIndexOf("\nprintf '%s\\n' ")
|
|
678
|
-
if (at === -1) return undefined
|
|
679
|
-
const lines = extractQuotedTokens(block.slice(at + "\nprintf '%s\\n' ".length))
|
|
680
|
-
const old = repo.readFile(".gtd/TODO.md") ?? ""
|
|
681
|
-
const sep = old === "" ? "" : `${old.endsWith("\n") ? "" : "\n"}\n`
|
|
682
|
-
repo.writeFile(".gtd/TODO.md", `${old}${sep}${lines.join("\n")}\n`)
|
|
683
|
-
return { kind: "applied" }
|
|
684
|
-
}
|
|
685
|
-
|
|
686
673
|
/** `mkdir -p <dir>` then `printf '%s\n' <quoted lines> > <path>`: a flow's one-file write. Re-rendered from what was extracted, so any other shape stays unrecognized. */
|
|
687
674
|
const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
688
675
|
const [mkdir, printf, ...rest] = block.split("\n")
|
|
@@ -715,7 +702,6 @@ const recognizersFor = (
|
|
|
715
702
|
(block) => recognizeFileExistsGuard(repo, block),
|
|
716
703
|
(block) => recognizeBinaryGuard(commands, block),
|
|
717
704
|
(block) => (git ? recognizeGitBuilders(repo, block) : undefined),
|
|
718
|
-
(block) => recognizeDeferNotes(repo, block),
|
|
719
705
|
(block) => recognizeWriteLines(repo, block),
|
|
720
706
|
(block) => recognizeFailurePromptWrapper(repo, commands, block),
|
|
721
707
|
(block) => recognizePresentationSubshell(repo, commands, block, git),
|
package/src/testkit/steps.ts
CHANGED
|
@@ -267,6 +267,10 @@ Then(
|
|
|
267
267
|
},
|
|
268
268
|
)
|
|
269
269
|
|
|
270
|
+
Then("landing is refused with {string}", async (world: TestkitWorld, message: string) => {
|
|
271
|
+
await assert.rejects(world.repo().land(), (error: Error) => error.message.includes(message))
|
|
272
|
+
})
|
|
273
|
+
|
|
270
274
|
Then(
|
|
271
275
|
"driving the process is refused with {string}",
|
|
272
276
|
async (world: TestkitWorld, message: string) => {
|