@pmelab/gtd 55.0.0 → 56.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 +60 -61
- package/dist/chunks/{reserved-DHafcW7t.mjs → reserved-B8EC-WE8.mjs} +77 -203
- package/dist/chunks/{review-BJGKwlYv.mjs → review-HdrX0FJ7.mjs} +30 -125
- package/dist/chunks/{steering-CLhu8C7q.mjs → steering-CCNvngSH.mjs} +256 -65
- package/dist/chunks/{tail-B9Hw2AFB.mjs → tail-BSLah2mA.mjs} +144 -56
- package/dist/gtd.bundle.mjs +259 -22
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +6 -6
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +551 -66
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +14 -16
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +522 -453
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -3
- package/src/cli/Cli.ts +5 -5
- package/src/edge/Edge.test.ts +1 -1
- package/src/steering/FORMATS.md +5 -1
- package/src/steering/SteeringFormats.test.ts +2 -2
- package/src/steering/contract.test.ts +1 -0
- package/src/steering/findings.test.ts +47 -0
- package/src/steering/findings.ts +350 -0
- package/src/steering/fixtures/findings/gate.md +34 -0
- package/src/steering/fixtures/findings/gate.read.json +87 -0
- package/src/steering/index.test.ts +2 -2
- package/src/steering/index.ts +4 -3
- package/src/steering/review.test.ts +1 -75
- package/src/steering/review.ts +1 -74
- package/src/workflow/compile.test.ts +1 -0
- package/src/workflows/feature/criteria-coverage.feature +1 -6
- package/src/workflows/feature/feature.test.ts +1 -1
- package/src/workflows/feature/feature.ts +12 -20
- package/src/workflows/feature/lens-review.feature +323 -171
- package/src/workflows/feature/refactor-absorbs.feature +1 -7
- package/src/workflows/feature/refactor-first.feature +2 -10
- package/src/workflows/feature/refactor-rebuild.feature +4 -24
- package/src/workflows/feature/refactor-review.feature +33 -117
- package/src/workflows/feature/refactor.test.ts +0 -2
- package/src/workflows/feature/refactor.ts +0 -1
- package/src/workflows/feature/review-gate.feature +6 -22
- package/src/workflows/feature/reviewer-walkthrough.feature +16 -103
- package/src/workflows/feature/standards.feature +75 -34
- package/src/workflows/fix/fix-lens-origin.feature +20 -7
- package/src/workflows/fix/fix-quality.feature +102 -23
- package/src/workflows/review/review-door.feature +62 -59
- package/src/workflows/shared/learn.test.ts +0 -14
- package/src/workflows/shared/learn.ts +1 -6
- package/src/workflows/shared/lenses.ts +149 -43
- package/src/workflows/shared/review.test.ts +27 -26
- package/src/workflows/shared/review.ts +29 -196
- package/src/workflows/shared/tail.ts +18 -9
- package/src/workflows/shared/text.test.ts +103 -288
- package/src/workflows/shared/text.ts +131 -329
- package/src/workflows/feature/review-answers.feature +0 -324
- package/src/workflows/feature/severity-budget.feature +0 -116
- package/src/workflows/feature/severity-sorted-review.feature +0 -198
package/package.json
CHANGED
|
@@ -109,9 +109,9 @@ workflow that fails to load breaks every gtd command in the repository.
|
|
|
109
109
|
step you add must fit one of them; there is no sixth.
|
|
110
110
|
|
|
111
111
|
Options (all optional): `label`, `file` (a `.gtd/` path), `mode` (needs `file`;
|
|
112
|
-
`qa`, `review`, or a `config.modes` name), `message` (human/judge),
|
|
113
|
-
`system` (agent), `allowEmpty` (agent), `acceptClean` (human), `base`
|
|
114
|
-
commit the step reviews since — what `gtd base` prints).
|
|
112
|
+
`qa`, `review`, `findings`, or a `config.modes` name), `message` (human/judge),
|
|
113
|
+
`model`, `system` (agent), `allowEmpty` (agent), `acceptClean` (human), `base`
|
|
114
|
+
(the commit the step reviews since — what `gtd base` prints).
|
|
115
115
|
|
|
116
116
|
Helpers — pure reads of the commit replay stands on (the tree the last step
|
|
117
117
|
left, never the live working tree): `read(path)`, `glob(pattern)`,
|
package/src/cli/Cli.ts
CHANGED
|
@@ -539,11 +539,11 @@ const COMMAND_ROWS: readonly CommandRow[] = [
|
|
|
539
539
|
arity: { names: ["mode", "file"] },
|
|
540
540
|
details: [
|
|
541
541
|
"Read <file> and run the built-in steering format named",
|
|
542
|
-
"<mode> (see `gtd validate`'s modes: qa, review)
|
|
543
|
-
"contents, printing each finding one per line and
|
|
544
|
-
"non-zero when there are any. Resolves no workflow
|
|
545
|
-
"reads no config — standalone, runnable from any
|
|
546
|
-
"with <mode>/<file> given explicitly. This is what a",
|
|
542
|
+
"<mode> (see `gtd validate`'s modes: qa, review, findings)",
|
|
543
|
+
"over its contents, printing each finding one per line and",
|
|
544
|
+
"exiting non-zero when there are any. Resolves no workflow",
|
|
545
|
+
"state and reads no config — standalone, runnable from any",
|
|
546
|
+
"directory with <mode>/<file> given explicitly. This is what a",
|
|
547
547
|
"workflow's emitted validation script invokes as a leaf step.",
|
|
548
548
|
"--open-questions runs the qa unanswered-questions predicate",
|
|
549
549
|
"instead (see --help); --open-threads lists open threads.",
|
package/src/edge/Edge.test.ts
CHANGED
|
@@ -754,7 +754,7 @@ export default async () => {
|
|
|
754
754
|
`)
|
|
755
755
|
const exit = await provideExit(currentRest, repo)
|
|
756
756
|
expect(Exit.isFailure(exit) && String(exit.cause)).toContain(
|
|
757
|
-
'step "idle": mode "adr" is not a mode this workflow knows (qa, review)',
|
|
757
|
+
'step "idle": mode "adr" is not a mode this workflow knows (qa, review, findings)',
|
|
758
758
|
)
|
|
759
759
|
})
|
|
760
760
|
|
package/src/steering/FORMATS.md
CHANGED
|
@@ -18,9 +18,13 @@ read them.
|
|
|
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
20
|
chunk of its own.
|
|
21
|
+
- **`findings`**: the finding gate's file. `## ` entries, those under a
|
|
22
|
+
`# Dropped as noise` heading being the noise; each holds exactly one
|
|
23
|
+
`- [ ] fix` box. A ticked box fixes the finding; text below the box is the
|
|
24
|
+
human's guidance, valid only under a ticked one.
|
|
21
25
|
- **Free-form**: any markdown, for a file with no mode or an unknown one. Only
|
|
22
26
|
its threads are validated.
|
|
23
|
-
- **Threads**, shared by all
|
|
27
|
+
- **Threads**, shared by all of them: a footnote whose definition is a list of
|
|
24
28
|
alternating `- H:` / `- A:` entries. Any other footnote is a one-shot note.
|
|
25
29
|
- **Code threads**: `H:` / `A:` line comments (`//`, `#`, `--`, `;`, chosen by
|
|
26
30
|
file extension) in a changed code file. A comment run is a thread only when
|
|
@@ -11,8 +11,8 @@ const QA_FORMAT = steeringFormatFor("qa")!
|
|
|
11
11
|
const REVIEW_FORMAT = steeringFormatFor("review")!
|
|
12
12
|
|
|
13
13
|
describe("BUILT_IN_MODE_NAMES", () => {
|
|
14
|
-
it("lists the
|
|
15
|
-
expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
|
|
14
|
+
it("lists the built-in format names", () => {
|
|
15
|
+
expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review", "findings"])
|
|
16
16
|
})
|
|
17
17
|
})
|
|
18
18
|
|
|
@@ -34,6 +34,7 @@ const fixture = (path: string): string => {
|
|
|
34
34
|
const FORMATS: readonly (readonly [dir: string, format: SteeringFormat, ticks: boolean])[] = [
|
|
35
35
|
["qa", steeringFormatFor("qa")!, true],
|
|
36
36
|
["review", steeringFormatFor("review")!, true],
|
|
37
|
+
["findings", steeringFormatFor("findings")!, true],
|
|
37
38
|
["free-form", freeFormFormat, false],
|
|
38
39
|
]
|
|
39
40
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { applySteeringEdits, findingEntries, steeringFormatFor } from "./index.js"
|
|
3
|
+
|
|
4
|
+
const format = steeringFormatFor("findings")!
|
|
5
|
+
|
|
6
|
+
const gate = (box: string, guidance = ""): string =>
|
|
7
|
+
`# Quality\n\n## a\n\nBody.\n\n${box}\n${guidance === "" ? "" : `\n${guidance}\n`}`
|
|
8
|
+
|
|
9
|
+
const messages = (content: string): string[] => format.validate(content).map((f) => f.message)
|
|
10
|
+
|
|
11
|
+
describe("the findings format", () => {
|
|
12
|
+
it("reads a ticked box and the guidance below it", () => {
|
|
13
|
+
expect(findingEntries(gate("- [x] fix", "Round half up."))).toEqual([
|
|
14
|
+
expect.objectContaining({ title: "a", body: "Body.", fix: true, guidance: "Round half up." }),
|
|
15
|
+
])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reads guidance a formatter folded into the box's own line", () => {
|
|
19
|
+
expect(findingEntries(gate("- [x] fix use a map"))[0]).toMatchObject({
|
|
20
|
+
fix: true,
|
|
21
|
+
guidance: "use a map",
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("refuses guidance under an unticked box", () => {
|
|
26
|
+
expect(messages(gate("- [ ] fix", "Round half up."))).toEqual([
|
|
27
|
+
'"a" has guidance under an unticked box — tick it to fix the finding, or delete the text to drop it',
|
|
28
|
+
])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("refuses a finding with no box, or with two", () => {
|
|
32
|
+
expect(messages(gate("- [ ] maybe"))[0]).toContain("has no `- [ ] fix` box")
|
|
33
|
+
expect(messages(gate("- [ ] fix\n- [x] fix"))[0]).toContain("has 2 `fix` boxes")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("ticks with guidance, and unticking drops the guidance with the tick", () => {
|
|
37
|
+
const write = (content: string, opts: { checked?: boolean; text?: string }): string => {
|
|
38
|
+
const result = format.apply(content, { kind: "chunk", index: 0 }, opts)
|
|
39
|
+
if (!result.ok) throw new Error(result.reason)
|
|
40
|
+
return applySteeringEdits(content, result.edits)
|
|
41
|
+
}
|
|
42
|
+
const ticked = write(gate("- [ ] fix"), { checked: true, text: "Round half up." })
|
|
43
|
+
expect(ticked).toBe(gate("- [x] fix", "Round half up."))
|
|
44
|
+
expect(write(ticked, { checked: false })).toBe(gate("- [ ] fix"))
|
|
45
|
+
expect(write(ticked, { checked: true, text: "Round half up." })).toBe(ticked)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { List, ListItem, Node, Root, RootContent } from "mdast"
|
|
2
|
+
import { blockNodesOfRun } from "./Blocks.js"
|
|
3
|
+
import { eolOf } from "./Eol.js"
|
|
4
|
+
import {
|
|
5
|
+
footnoteAttachEdits,
|
|
6
|
+
noteLookup,
|
|
7
|
+
openThreadsOf,
|
|
8
|
+
threadFindings,
|
|
9
|
+
threadOutlineNodes,
|
|
10
|
+
threadReplyActions,
|
|
11
|
+
} from "./Footnotes.js"
|
|
12
|
+
import { headingText, lineRange, parseMarkdown, spanRange, toLspPosition } from "./MarkdownTree.js"
|
|
13
|
+
import type {
|
|
14
|
+
SteeringAnnotateResult,
|
|
15
|
+
SteeringFinding,
|
|
16
|
+
SteeringFormat,
|
|
17
|
+
SteeringOutlineNode,
|
|
18
|
+
SteeringView,
|
|
19
|
+
SteeringViewNode,
|
|
20
|
+
} from "./SteeringFormat.js"
|
|
21
|
+
|
|
22
|
+
/** The `# ` heading the findings a judge called noise sit under. */
|
|
23
|
+
export const NOISE_HEADING = "Dropped as noise"
|
|
24
|
+
/** The label of the one box under each finding. */
|
|
25
|
+
export const FIX_BOX = "fix"
|
|
26
|
+
|
|
27
|
+
const OPEN_GROUP = "Open"
|
|
28
|
+
|
|
29
|
+
/** One `## ` finding of a finding-gate file. */
|
|
30
|
+
export interface FindingEntry {
|
|
31
|
+
readonly title: string
|
|
32
|
+
/** The finding's markdown between its heading and its box. */
|
|
33
|
+
readonly body: string
|
|
34
|
+
/** Under `# Dropped as noise`. */
|
|
35
|
+
readonly noise: boolean
|
|
36
|
+
/** Its box is ticked. */
|
|
37
|
+
readonly fix: boolean
|
|
38
|
+
/** The human's text below the box; `""` when none. */
|
|
39
|
+
readonly guidance: string
|
|
40
|
+
/** How many `fix` boxes it holds; valid only with exactly one. */
|
|
41
|
+
readonly boxes: number
|
|
42
|
+
readonly headingLine: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Parsed extends FindingEntry {
|
|
46
|
+
readonly bodyNodes: readonly RootContent[]
|
|
47
|
+
readonly box?: ListItem
|
|
48
|
+
/** The box and the items after it, kept out of the body when its list starts in the body. */
|
|
49
|
+
readonly notBody: ReadonlySet<ListItem>
|
|
50
|
+
/** The guidance's last line, or the box's when there is none. */
|
|
51
|
+
readonly lastLine: number
|
|
52
|
+
readonly endLine: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MARKER = /\[\^[^\]\s]+\]/g
|
|
56
|
+
|
|
57
|
+
const isBox = (item: ListItem, content: string): boolean => {
|
|
58
|
+
if (typeof item.checked !== "boolean") return false
|
|
59
|
+
const first = item.children[0]
|
|
60
|
+
if (first?.type !== "paragraph" || first.position === undefined) return false
|
|
61
|
+
const text = content.slice(first.position.start.offset, first.position.end.offset)
|
|
62
|
+
return new RegExp(`^${FIX_BOX}(?![\\w-])`).test(text)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const slice = (content: string, node: Node): string =>
|
|
66
|
+
content.slice(node.position!.start.offset, node.position!.end.offset)
|
|
67
|
+
|
|
68
|
+
const lineOf = (node: Node, end = false): number =>
|
|
69
|
+
toLspPosition(end ? node.position!.end : node.position!.start).line
|
|
70
|
+
|
|
71
|
+
/** The box item's own text past its label: a formatter may fold a guidance line into it. */
|
|
72
|
+
const inlineGuidance = (content: string, item: ListItem): string => {
|
|
73
|
+
const first = item.children[0]!
|
|
74
|
+
const text = content.slice(first.position!.start.offset, item.position!.end.offset)
|
|
75
|
+
return text
|
|
76
|
+
.slice(FIX_BOX.length)
|
|
77
|
+
.replace(/^[\s:—–-]+/, "")
|
|
78
|
+
.trim()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Split {
|
|
82
|
+
readonly bodyNodes: readonly RootContent[]
|
|
83
|
+
readonly body: string
|
|
84
|
+
readonly notBody: readonly ListItem[]
|
|
85
|
+
readonly guidance: string
|
|
86
|
+
readonly last?: Node
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const guidanceOf = (parts: readonly string[]): string =>
|
|
90
|
+
parts
|
|
91
|
+
.map((p) => p.replace(MARKER, "").trim())
|
|
92
|
+
.filter((p) => p !== "")
|
|
93
|
+
.join("\n\n")
|
|
94
|
+
|
|
95
|
+
/** `nodes` split at `box`: the body before it, the guidance after it. */
|
|
96
|
+
const splitAtBox = (content: string, nodes: readonly RootContent[], box: ListItem): Split => {
|
|
97
|
+
const at = nodes.findIndex((n) => n.type === "list" && n.children.includes(box))
|
|
98
|
+
const list = nodes[at] as List
|
|
99
|
+
const boxAt = list.children.indexOf(box)
|
|
100
|
+
// A body ending in a list runs on into the box's own list: its items before the box are body.
|
|
101
|
+
const bodyNodes = nodes.slice(0, boxAt > 0 ? at + 1 : at)
|
|
102
|
+
const trailing = list.children.slice(boxAt + 1)
|
|
103
|
+
const after = nodes.slice(at + 1)
|
|
104
|
+
return {
|
|
105
|
+
bodyNodes,
|
|
106
|
+
body: bodyNodes
|
|
107
|
+
.map((n) =>
|
|
108
|
+
n === list
|
|
109
|
+
? content.slice(n.position!.start.offset, box.position!.start.offset).trimEnd()
|
|
110
|
+
: slice(content, n),
|
|
111
|
+
)
|
|
112
|
+
.join("\n\n"),
|
|
113
|
+
notBody: list.children.slice(boxAt),
|
|
114
|
+
guidance: guidanceOf([
|
|
115
|
+
inlineGuidance(content, box),
|
|
116
|
+
...[...trailing, ...after].map((n) => slice(content, n)),
|
|
117
|
+
]),
|
|
118
|
+
last: after.at(-1) ?? trailing.at(-1) ?? box,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const entryOf = (
|
|
123
|
+
content: string,
|
|
124
|
+
title: string,
|
|
125
|
+
noise: boolean,
|
|
126
|
+
headingLine: number,
|
|
127
|
+
nodes: readonly RootContent[],
|
|
128
|
+
endLine: number,
|
|
129
|
+
): Parsed => {
|
|
130
|
+
const notes = nodes.filter((n) => n.type !== "footnoteDefinition")
|
|
131
|
+
const boxes = notes.flatMap((n) =>
|
|
132
|
+
n.type === "list" ? n.children.filter((i) => isBox(i, content)) : [],
|
|
133
|
+
)
|
|
134
|
+
const box = boxes[0]
|
|
135
|
+
const split: Split =
|
|
136
|
+
box === undefined
|
|
137
|
+
? {
|
|
138
|
+
bodyNodes: notes,
|
|
139
|
+
body: notes.map((n) => slice(content, n)).join("\n\n"),
|
|
140
|
+
notBody: [],
|
|
141
|
+
guidance: "",
|
|
142
|
+
}
|
|
143
|
+
: splitAtBox(content, notes, box)
|
|
144
|
+
return {
|
|
145
|
+
title,
|
|
146
|
+
body: split.body,
|
|
147
|
+
noise,
|
|
148
|
+
fix: box?.checked === true,
|
|
149
|
+
guidance: split.guidance,
|
|
150
|
+
boxes: boxes.length,
|
|
151
|
+
headingLine,
|
|
152
|
+
bodyNodes: split.bodyNodes,
|
|
153
|
+
...(box === undefined ? {} : { box }),
|
|
154
|
+
notBody: new Set(split.notBody),
|
|
155
|
+
lastLine: split.last === undefined ? headingLine : lineOf(split.last, true),
|
|
156
|
+
endLine,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parse = (content: string): readonly Parsed[] => {
|
|
161
|
+
const tree: Root = parseMarkdown(content)
|
|
162
|
+
const entries: Parsed[] = []
|
|
163
|
+
let noise = false
|
|
164
|
+
let open: { title: string; line: number; nodes: RootContent[] } | undefined
|
|
165
|
+
const close = (endLine: number): void => {
|
|
166
|
+
if (open !== undefined)
|
|
167
|
+
entries.push(entryOf(content, open.title, noise, open.line, open.nodes, endLine))
|
|
168
|
+
open = undefined
|
|
169
|
+
}
|
|
170
|
+
for (const node of tree.children) {
|
|
171
|
+
if (node.type === "heading" && node.depth <= 2) {
|
|
172
|
+
close(lineOf(node) - 1)
|
|
173
|
+
const text = headingText(content, node, (t) => t.replace(MARKER, ""))
|
|
174
|
+
if (node.depth === 1) noise = text === NOISE_HEADING
|
|
175
|
+
else open = { title: text, line: lineOf(node), nodes: [] }
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
open?.nodes.push(node)
|
|
179
|
+
}
|
|
180
|
+
close(content.split(/\r?\n/).length - 1)
|
|
181
|
+
return entries
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Every `## ` finding in a finding-gate file, in file order. */
|
|
185
|
+
export const findingEntries = (content: string): readonly FindingEntry[] =>
|
|
186
|
+
parse(content).map(
|
|
187
|
+
({ title, body, noise, fix, guidance, boxes, headingLine }): FindingEntry => ({
|
|
188
|
+
title,
|
|
189
|
+
body,
|
|
190
|
+
noise,
|
|
191
|
+
fix,
|
|
192
|
+
guidance,
|
|
193
|
+
boxes,
|
|
194
|
+
headingLine,
|
|
195
|
+
}),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const entryFindings = (content: string): readonly SteeringFinding[] =>
|
|
199
|
+
parse(content).flatMap((e): SteeringFinding[] => {
|
|
200
|
+
if (e.boxes === 0)
|
|
201
|
+
return [
|
|
202
|
+
{
|
|
203
|
+
message: `"${e.title}" has no \`- [ ] ${FIX_BOX}\` box — restore it, ticked to fix the finding or unticked to drop it`,
|
|
204
|
+
line: e.headingLine,
|
|
205
|
+
},
|
|
206
|
+
]
|
|
207
|
+
if (e.boxes > 1)
|
|
208
|
+
return [
|
|
209
|
+
{
|
|
210
|
+
message: `"${e.title}" has ${e.boxes} \`${FIX_BOX}\` boxes — keep exactly one`,
|
|
211
|
+
line: e.headingLine,
|
|
212
|
+
},
|
|
213
|
+
]
|
|
214
|
+
if (!e.fix && e.guidance !== "")
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
message: `"${e.title}" has guidance under an unticked box — tick it to fix the finding, or delete the text to drop it`,
|
|
218
|
+
line: lineOf(e.box!),
|
|
219
|
+
},
|
|
220
|
+
]
|
|
221
|
+
return []
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const findingsView = (content: string): SteeringView => {
|
|
225
|
+
const noteAt = noteLookup(content)
|
|
226
|
+
return {
|
|
227
|
+
nodes: parse(content).map((e, index): SteeringViewNode => {
|
|
228
|
+
const note = noteAt(e.headingLine)
|
|
229
|
+
return {
|
|
230
|
+
title: e.title,
|
|
231
|
+
anchor: { kind: "chunk", index },
|
|
232
|
+
group: e.noise ? NOISE_HEADING : OPEN_GROUP,
|
|
233
|
+
...(e.boxes === 1 ? { checked: e.fix } : {}),
|
|
234
|
+
...(e.guidance === "" ? {} : { note: e.guidance }),
|
|
235
|
+
...(note?.thread !== undefined ? { thread: note.thread } : {}),
|
|
236
|
+
body: blockNodesOfRun(content, e.bodyNodes, { skipListItem: (i) => e.notBody.has(i) }),
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const findingsOutline = (content: string): readonly SteeringOutlineNode[] => {
|
|
243
|
+
const lines = content.split(/\r?\n/)
|
|
244
|
+
return [
|
|
245
|
+
...parse(content).map(
|
|
246
|
+
(e): SteeringOutlineNode => ({
|
|
247
|
+
name: e.title,
|
|
248
|
+
detail: e.noise ? "noise" : e.fix ? FIX_BOX : "drop",
|
|
249
|
+
range: spanRange(lines, e.headingLine, Math.max(e.headingLine, e.endLine)),
|
|
250
|
+
selectionRange: lineRange(lines, e.headingLine),
|
|
251
|
+
}),
|
|
252
|
+
),
|
|
253
|
+
...threadOutlineNodes(content).map((t) => t.node),
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const findingsAnnotate: SteeringFormat["annotate"] = (
|
|
258
|
+
content,
|
|
259
|
+
anchor,
|
|
260
|
+
text,
|
|
261
|
+
): SteeringAnnotateResult => {
|
|
262
|
+
const entries = parse(content)
|
|
263
|
+
const lines = content.split(/\r?\n/)
|
|
264
|
+
const resolved = ((): { line: number; blockEndLine: number; key: string } | undefined => {
|
|
265
|
+
if (anchor.kind === "chunk") {
|
|
266
|
+
const e = entries[anchor.index]
|
|
267
|
+
return e && { line: e.headingLine, blockEndLine: e.headingLine, key: `chunk:${anchor.index}` }
|
|
268
|
+
}
|
|
269
|
+
if (anchor.kind !== "paragraph") return undefined
|
|
270
|
+
const block = entries
|
|
271
|
+
.flatMap((e) => e.bodyNodes)
|
|
272
|
+
.find((n) => anchor.line >= lineOf(n) && anchor.line <= lineOf(n, true))
|
|
273
|
+
return (
|
|
274
|
+
block && {
|
|
275
|
+
line: anchor.line,
|
|
276
|
+
blockEndLine: lineOf(block, true),
|
|
277
|
+
key: `paragraph:${anchor.line}`,
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
})()
|
|
281
|
+
if (resolved === undefined) return { ok: false, reason: "anchor-not-found" }
|
|
282
|
+
const result = footnoteAttachEdits(
|
|
283
|
+
content,
|
|
284
|
+
{ ...resolved, endCharacter: (lines[resolved.line] ?? "").length },
|
|
285
|
+
text,
|
|
286
|
+
)
|
|
287
|
+
return result.ok ? { ok: true, edits: result.edits } : result
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Ticks or unticks a finding's box; `text` replaces its guidance, and unticking drops it. */
|
|
291
|
+
// fallow-ignore-next-line complexity
|
|
292
|
+
const findingsApply: SteeringFormat["apply"] = (content, anchor, opts): SteeringAnnotateResult => {
|
|
293
|
+
if (anchor.kind !== "chunk") return { ok: false, reason: "anchor-not-found" }
|
|
294
|
+
const e = parse(content)[anchor.index]
|
|
295
|
+
if (e?.box === undefined || e.boxes !== 1) return { ok: false, reason: "anchor-not-found" }
|
|
296
|
+
const fix = opts.checked ?? e.fix
|
|
297
|
+
const guidance = fix ? (opts.text ?? e.guidance).trim() : ""
|
|
298
|
+
if (fix === e.fix && guidance === e.guidance) return { ok: true, edits: [] }
|
|
299
|
+
const lines = content.split(/\r?\n/)
|
|
300
|
+
const eol = eolOf(content)
|
|
301
|
+
const start = lineOf(e.box)
|
|
302
|
+
const box = `${lines[start]!.match(/^\s*[-*+]\s+/)![0]}[${fix ? "x" : " "}] ${FIX_BOX}`
|
|
303
|
+
const text = guidance === "" ? box : `${box}${eol}${eol}${guidance.split(/\r?\n/).join(eol)}`
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
edits: [
|
|
307
|
+
{
|
|
308
|
+
range: { start: { line: start, character: 0 }, end: lineRange(lines, e.lastLine).end },
|
|
309
|
+
newText: text,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const FINDINGS_SAMPLE = `# Quality
|
|
316
|
+
|
|
317
|
+
## A finding a lens wrote[^nafind1]
|
|
318
|
+
|
|
319
|
+
[^nafind1]:
|
|
320
|
+
Attached via the phone UI, this note shows a comment on a finding with a
|
|
321
|
+
\`multi word code span\` that runs past eighty characters in total length.
|
|
322
|
+
|
|
323
|
+
What the lens found, and where.
|
|
324
|
+
|
|
325
|
+
- [x] fix
|
|
326
|
+
|
|
327
|
+
Fix it the simple way.
|
|
328
|
+
|
|
329
|
+
# Dropped as noise
|
|
330
|
+
|
|
331
|
+
## A finding the judge called noise
|
|
332
|
+
|
|
333
|
+
Why the judge dropped it.
|
|
334
|
+
|
|
335
|
+
- [ ] fix
|
|
336
|
+
`
|
|
337
|
+
|
|
338
|
+
/** The finding gate's file: one `## ` finding each, with one `fix` box and optional guidance below it. */
|
|
339
|
+
export const findingsDescriptor: SteeringFormat = {
|
|
340
|
+
sample: FINDINGS_SAMPLE,
|
|
341
|
+
validate: (content) => [...entryFindings(content), ...threadFindings(content)],
|
|
342
|
+
outline: findingsOutline,
|
|
343
|
+
actions: (content, range) => threadReplyActions(content, range.start),
|
|
344
|
+
view: findingsView,
|
|
345
|
+
annotate: findingsAnnotate,
|
|
346
|
+
apply: findingsApply,
|
|
347
|
+
// Its ticks are the human's decisions, never read-progress.
|
|
348
|
+
clearTicks: (content) => content,
|
|
349
|
+
openThreads: openThreadsOf,
|
|
350
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Quality
|
|
2
|
+
|
|
3
|
+
## AC-1 ignores floating point
|
|
4
|
+
|
|
5
|
+
_From lens correctness._
|
|
6
|
+
|
|
7
|
+
Should subtract round? The criterion does not say.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
// - [ ] fix, inside a fence, is no box
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- [x] fix
|
|
14
|
+
|
|
15
|
+
Round half up.
|
|
16
|
+
|
|
17
|
+
## add has no test
|
|
18
|
+
|
|
19
|
+
_From lens test-audit. The fixer left it unresolved._
|
|
20
|
+
|
|
21
|
+
- a list in the finding's body
|
|
22
|
+
- with two items
|
|
23
|
+
|
|
24
|
+
- [ ] fix
|
|
25
|
+
|
|
26
|
+
# Dropped as noise
|
|
27
|
+
|
|
28
|
+
## add could be a method
|
|
29
|
+
|
|
30
|
+
_From lens correctness. The judge called it noise (p=0.85)._
|
|
31
|
+
|
|
32
|
+
A class would read better.
|
|
33
|
+
|
|
34
|
+
- [ ] fix
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nodes": [
|
|
3
|
+
{
|
|
4
|
+
"title": "AC-1 ignores floating point",
|
|
5
|
+
"anchor": {
|
|
6
|
+
"kind": "chunk",
|
|
7
|
+
"index": 0
|
|
8
|
+
},
|
|
9
|
+
"group": "Open",
|
|
10
|
+
"checked": true,
|
|
11
|
+
"note": "Round half up.",
|
|
12
|
+
"body": [
|
|
13
|
+
{
|
|
14
|
+
"title": "_From lens correctness._",
|
|
15
|
+
"anchor": {
|
|
16
|
+
"kind": "paragraph",
|
|
17
|
+
"line": 4
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"title": "Should subtract round? The criterion does not say.",
|
|
22
|
+
"anchor": {
|
|
23
|
+
"kind": "paragraph",
|
|
24
|
+
"line": 6
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"title": "// - [ ] fix, inside a fence, is no box",
|
|
29
|
+
"anchor": {
|
|
30
|
+
"kind": "paragraph",
|
|
31
|
+
"line": 8
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"title": "add has no test",
|
|
38
|
+
"anchor": {
|
|
39
|
+
"kind": "chunk",
|
|
40
|
+
"index": 1
|
|
41
|
+
},
|
|
42
|
+
"group": "Open",
|
|
43
|
+
"checked": false,
|
|
44
|
+
"body": [
|
|
45
|
+
{
|
|
46
|
+
"title": "_From lens test-audit. The fixer left it unresolved._",
|
|
47
|
+
"anchor": {
|
|
48
|
+
"kind": "paragraph",
|
|
49
|
+
"line": 18
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"title": "a list in the finding's body with two items",
|
|
54
|
+
"anchor": {
|
|
55
|
+
"kind": "paragraph",
|
|
56
|
+
"line": 20
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"title": "add could be a method",
|
|
63
|
+
"anchor": {
|
|
64
|
+
"kind": "chunk",
|
|
65
|
+
"index": 2
|
|
66
|
+
},
|
|
67
|
+
"group": "Dropped as noise",
|
|
68
|
+
"checked": false,
|
|
69
|
+
"body": [
|
|
70
|
+
{
|
|
71
|
+
"title": "_From lens correctness. The judge called it noise (p=0.85)._",
|
|
72
|
+
"anchor": {
|
|
73
|
+
"kind": "paragraph",
|
|
74
|
+
"line": 29
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"title": "A class would read better.",
|
|
79
|
+
"anchor": {
|
|
80
|
+
"kind": "paragraph",
|
|
81
|
+
"line": 31
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -14,8 +14,8 @@ describe("steeringFormatFor / BUILT_IN_MODE_NAMES", () => {
|
|
|
14
14
|
expect(steeringFormatFor("bogus")).toBeUndefined()
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
it("lists
|
|
18
|
-
expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
|
|
17
|
+
it("lists the built-in mode names, in registry order", () => {
|
|
18
|
+
expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review", "findings"])
|
|
19
19
|
})
|
|
20
20
|
})
|
|
21
21
|
|
package/src/steering/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
export { formatFinding } from "./SteeringFormat.js"
|
|
9
9
|
import { qaDescriptor } from "./qa.js"
|
|
10
10
|
import { reviewDescriptor } from "./review.js"
|
|
11
|
+
import { findingsDescriptor } from "./findings.js"
|
|
12
|
+
export { findingEntries, FIX_BOX, NOISE_HEADING, type FindingEntry } from "./findings.js"
|
|
11
13
|
export {
|
|
12
14
|
ANSWER_FIX,
|
|
13
15
|
ANSWER_LEAD,
|
|
@@ -18,8 +20,6 @@ export {
|
|
|
18
20
|
reviewRisks,
|
|
19
21
|
reviewSections,
|
|
20
22
|
REVIEW_SECTIONS,
|
|
21
|
-
sectionedReview,
|
|
22
|
-
severityLine,
|
|
23
23
|
unansweredNotes,
|
|
24
24
|
type ReviewChoice,
|
|
25
25
|
type ReviewDecision,
|
|
@@ -50,10 +50,11 @@ export type {
|
|
|
50
50
|
SteeringViewThread,
|
|
51
51
|
} from "./SteeringFormat.js"
|
|
52
52
|
|
|
53
|
-
/** The built-in registry: mode name → its format, in registry order. The single place
|
|
53
|
+
/** The built-in registry: mode name → its format, in registry order. The single place the built-in formats are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
|
|
54
54
|
const REGISTRY: ReadonlyMap<string, SteeringFormat> = new Map([
|
|
55
55
|
["qa", qaDescriptor],
|
|
56
56
|
["review", reviewDescriptor],
|
|
57
|
+
["findings", findingsDescriptor],
|
|
57
58
|
])
|
|
58
59
|
|
|
59
60
|
/** Every built-in format name, in registry order. */
|