@pmelab/gtd 23.2.0 → 24.1.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 +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import type { ListItem, Root, RootContent } from "mdast"
|
|
2
|
+
import { blockNodesOfRun, blockRunInline } from "./Blocks.js"
|
|
3
|
+
import type { FootnoteAnchor } from "./Footnotes.js"
|
|
4
|
+
import {
|
|
5
|
+
collapseImages,
|
|
6
|
+
definitionsOf,
|
|
7
|
+
joinInlineRuns,
|
|
8
|
+
projectInline,
|
|
9
|
+
type InlineNode,
|
|
10
|
+
} from "./Inline.js"
|
|
11
|
+
import {
|
|
12
|
+
FOOTNOTE_ACTION_TITLE,
|
|
13
|
+
footnoteAdditionEdits,
|
|
14
|
+
footnoteAttachEdits,
|
|
15
|
+
footnotePointerAt,
|
|
16
|
+
isOnExistingFootnote,
|
|
17
|
+
openThreadsOf,
|
|
18
|
+
threadOutlineNodes,
|
|
19
|
+
threadReplyActions,
|
|
20
|
+
noteLookup,
|
|
21
|
+
parseFootnotes,
|
|
22
|
+
parseThreads,
|
|
23
|
+
} from "./Footnotes.js"
|
|
24
|
+
import {
|
|
25
|
+
blockEndLine,
|
|
26
|
+
blockNodeAt,
|
|
27
|
+
footnoteLeaf,
|
|
28
|
+
headingText,
|
|
29
|
+
lineRange,
|
|
30
|
+
parseMarkdown,
|
|
31
|
+
sourceText,
|
|
32
|
+
spanRange,
|
|
33
|
+
taskItems,
|
|
34
|
+
toLspPosition,
|
|
35
|
+
toLspPositionFromOffset,
|
|
36
|
+
type HeadingBlock,
|
|
37
|
+
} from "./MarkdownTree.js"
|
|
38
|
+
import type {
|
|
39
|
+
SteeringAnchor,
|
|
40
|
+
SteeringAnnotateResult,
|
|
41
|
+
SteeringEdit,
|
|
42
|
+
SteeringFinding,
|
|
43
|
+
SteeringFormat,
|
|
44
|
+
SteeringLink,
|
|
45
|
+
SteeringOutlineNode,
|
|
46
|
+
SteeringView,
|
|
47
|
+
SteeringViewNode,
|
|
48
|
+
} from "./SteeringFormat.js"
|
|
49
|
+
|
|
50
|
+
interface ReviewFile {
|
|
51
|
+
readonly path: string
|
|
52
|
+
/** 1-based post-image line — the range's own start when the pointer carries one, absent entirely for a bare `./path` with no `#` at all. */
|
|
53
|
+
readonly line?: number
|
|
54
|
+
/** 1-based post-image end line of a `#<start>-<end>` range — `line` holds the start. Absent for a bare `#<line>` pointer or a `#`-less one. */
|
|
55
|
+
readonly rangeEnd?: number
|
|
56
|
+
readonly checked: boolean
|
|
57
|
+
/** The pointer's explanation: same-line text (if any) first, then the lines gathered from BELOW it, joined with " ". */
|
|
58
|
+
readonly note?: string
|
|
59
|
+
/** `note`'s own inline structure, image-collapsed (R2) — feeds the hunk node's `detailInline`. Absent exactly where `note` is. */
|
|
60
|
+
readonly noteInline?: readonly InlineNode[]
|
|
61
|
+
/** 0-based line index of this file pointer's own `- [ ]`/`- [x]` line in REVIEW.md, for editor tooling. */
|
|
62
|
+
readonly sourceLine: number
|
|
63
|
+
/** 0-based index of the last line of this pointer's OWN span (excluding any nested hunk's span); equals `sourceLine` when it has no explanation. */
|
|
64
|
+
readonly endLine: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface Changeset {
|
|
68
|
+
readonly title: string
|
|
69
|
+
readonly description: string
|
|
70
|
+
/** The same leading run collapsed into `description`, projected as block nodes (`Blocks.ts#blockNodesOfRun`) instead of flattened text — `Review.tsx`'s chunk screen renders these through the shared prose-block rendering. */
|
|
71
|
+
readonly descriptionNodes: readonly SteeringViewNode[]
|
|
72
|
+
/** `description`'s own inline structure, image-collapsed (R2) — feeds the chunk node's `detailInline`. */
|
|
73
|
+
readonly descriptionInline: readonly InlineNode[]
|
|
74
|
+
readonly files: readonly ReviewFile[]
|
|
75
|
+
/** 0-based index of this chunk's `##` heading. */
|
|
76
|
+
readonly headingLine: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ReviewDoc {
|
|
80
|
+
readonly shortHash?: string
|
|
81
|
+
readonly fullHash?: string
|
|
82
|
+
readonly changesets: readonly Changeset[]
|
|
83
|
+
readonly findings: readonly SteeringFinding[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The canonical sample. Every detail here is load-bearing for the formatter
|
|
88
|
+
* round-trip: both footnote bodies exceed 80 characters so a reflow actually
|
|
89
|
+
* happens, the chunk's is `na`-prefixed (server-attached, not hand-authored)
|
|
90
|
+
* and carries a multi-word inline code span, and the whole document is
|
|
91
|
+
* already an oxfmt fixed point.
|
|
92
|
+
*/
|
|
93
|
+
const REVIEW_SAMPLE = `# Review: sample123
|
|
94
|
+
|
|
95
|
+
<!-- base: 0000000000000000000000000000000000000000 -->
|
|
96
|
+
|
|
97
|
+
## Sample chunk[^naduiqc4]
|
|
98
|
+
|
|
99
|
+
- [ ] ./sample.ts#1-1 what this hunk does[^fn1]
|
|
100
|
+
|
|
101
|
+
[^fn1]:
|
|
102
|
+
This note explains why the hunk exists in more detail than fits on one line
|
|
103
|
+
for a reviewer.
|
|
104
|
+
|
|
105
|
+
[^naduiqc4]:
|
|
106
|
+
Attached via the phone UI, this note demonstrates a chunk-level comment with
|
|
107
|
+
a \`multi word code span\` that exceeds eighty characters in total length
|
|
108
|
+
here.
|
|
109
|
+
`
|
|
110
|
+
|
|
111
|
+
/** The `# Review: <hash>` header, once a depth-1 heading's own inline text has been extracted. */
|
|
112
|
+
const HEADER_TEXT_RE = /^Review:\s*(\S+)$/
|
|
113
|
+
/** The `<!-- base: <hash> -->` comment, matched against an `html` node's own raw `value`. */
|
|
114
|
+
const BASE_COMMENT_RE = /^<!--\s*base:\s*(\S+)\s*-->$/
|
|
115
|
+
/** A pointer token's trailing `#<start>-<end>` range (greedy, so a `#` inside the path stays in it, and the LAST `#` wins). */
|
|
116
|
+
const POINTER_RANGE_RE = /^(.*)#(\d+)-(\d+)$/
|
|
117
|
+
/** A pointer token's trailing bare `#<line>` (greedy, same rules as `POINTER_RANGE_RE`) — tried only once the range form has failed to match, so a range token is never misread as a bare line whose path happens to end in digits after a dash. */
|
|
118
|
+
const POINTER_LINE_RE = /^(.*)#(\d+)$/
|
|
119
|
+
/** The optional dash that may lead the inline note segment or a continuation block — em dash, en dash, or hyphens. */
|
|
120
|
+
const NOTE_SEPARATOR_RE = /^[—–-]+\s*/
|
|
121
|
+
|
|
122
|
+
/** A node's own span as a `SteeringFinding.range` — the NODE a heading- or block-shaped finding is about. */
|
|
123
|
+
const nodeRange = (node: RootContent) => ({
|
|
124
|
+
start: toLspPosition(node.position!.start),
|
|
125
|
+
end: toLspPosition(node.position!.end),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
/** The `# Review: <hash>` header, required as the document's first top-level node — never a heading found merely somewhere inside a fenced example. */
|
|
129
|
+
const parseHeader = (tree: Root, content: string): string | undefined => {
|
|
130
|
+
const first = tree.children[0]
|
|
131
|
+
if (!first || first.type !== "heading" || first.depth !== 1) return undefined
|
|
132
|
+
return HEADER_TEXT_RE.exec(headingText(content, first))?.[1]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A node shape wide enough to walk for an `html` node's raw text, at any depth. */
|
|
136
|
+
interface WalkableNode {
|
|
137
|
+
readonly type: string
|
|
138
|
+
readonly value?: string
|
|
139
|
+
readonly children?: readonly WalkableNode[]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The `<!-- base: <hash> -->` comment, wherever in the tree it appears — a real `html` node, never a string search over raw lines (which would also match one quoted inside a fence, since that content never becomes an `html` node at all). */
|
|
143
|
+
const parseBaseComment = (tree: Root): string | undefined => {
|
|
144
|
+
let found: string | undefined
|
|
145
|
+
const walk = (node: WalkableNode): void => {
|
|
146
|
+
if (found !== undefined) return
|
|
147
|
+
if (node.type === "html") {
|
|
148
|
+
const match = BASE_COMMENT_RE.exec((node.value ?? "").trim())
|
|
149
|
+
if (match) {
|
|
150
|
+
found = match[1]
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
node.children?.forEach(walk)
|
|
155
|
+
}
|
|
156
|
+
walk(tree as unknown as WalkableNode)
|
|
157
|
+
return found
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The single source of truth for splitting a pointer token, so no caller can
|
|
162
|
+
* parse the same token two different ways. Range is tried FIRST — the
|
|
163
|
+
* bare-line regex could not match `#42-70` anyway, but the explicit order
|
|
164
|
+
* beats relying on that accident.
|
|
165
|
+
*/
|
|
166
|
+
const parsePointerToken = (
|
|
167
|
+
token: string,
|
|
168
|
+
): {
|
|
169
|
+
readonly path: string
|
|
170
|
+
readonly rangeEnd?: number
|
|
171
|
+
readonly line?: number
|
|
172
|
+
} => {
|
|
173
|
+
const rangeMatch = POINTER_RANGE_RE.exec(token)
|
|
174
|
+
if (rangeMatch) {
|
|
175
|
+
return {
|
|
176
|
+
path: rangeMatch[1]!,
|
|
177
|
+
line: Number(rangeMatch[2]),
|
|
178
|
+
rangeEnd: Number(rangeMatch[3]),
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const lineMatch = POINTER_LINE_RE.exec(token)
|
|
182
|
+
if (lineMatch) return { path: lineMatch[1]!, line: Number(lineMatch[2]) }
|
|
183
|
+
return { path: token }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The pointer's own display form — `path#start-end` when it carries a range, `path#line` for a bare line, else the bare path. Shared by the second-pointer finding's target and `reviewView`'s hunk title so the two can never render a range pointer differently. */
|
|
187
|
+
const pointerDisplay = (file: {
|
|
188
|
+
readonly path: string
|
|
189
|
+
readonly rangeEnd?: number
|
|
190
|
+
readonly line?: number
|
|
191
|
+
}): string => {
|
|
192
|
+
if (file.line !== undefined && file.rangeEnd !== undefined) {
|
|
193
|
+
return `${file.path}#${file.line}-${file.rangeEnd}`
|
|
194
|
+
}
|
|
195
|
+
return file.line !== undefined ? `${file.path}#${file.line}` : file.path
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** True when `token` parses as a file pointer's path token — leading `./`, and a path longer than two characters once an optional `#<line>`/`#<start>-<end>` suffix is stripped. The single source of truth for "is this a pointer token", shared by `parseHunk`, the second-pointer finding below, and `reviewClearTicks` so all three can never drift apart. */
|
|
199
|
+
const isPointerToken = (token: string): boolean => {
|
|
200
|
+
if (!token.startsWith("./")) return false
|
|
201
|
+
return parsePointerToken(token).path.length > 2 // `./` with nothing after it is not a path
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The second-pointer validation error: a note whose first token is itself a
|
|
206
|
+
* pointer token. The relaxed same-line form would otherwise silently read
|
|
207
|
+
* `- [ ] ./a.ts#1 ./b.ts#2` (or with a separator, `- [ ] ./a.ts#1 — ./b.ts#2`)
|
|
208
|
+
* as ONE pointer whose note is `./b.ts#2`, dropping the second hunk entirely —
|
|
209
|
+
* this is the one case the relaxed parser must still refuse, and it points at
|
|
210
|
+
* the pointer's own `sourceLine` so it renders as a positioned finding.
|
|
211
|
+
*/
|
|
212
|
+
const secondPointerError = (
|
|
213
|
+
range: SteeringFinding["range"],
|
|
214
|
+
title: string,
|
|
215
|
+
file: ReviewFile,
|
|
216
|
+
secondToken: string,
|
|
217
|
+
): SteeringFinding => {
|
|
218
|
+
const target = pointerDisplay(file)
|
|
219
|
+
return {
|
|
220
|
+
message: `Chunk "${title}" hunk ${target}'s note starts with a second pointer (${secondToken}) — give it its own "- [ ]" line`,
|
|
221
|
+
line: file.sourceLine,
|
|
222
|
+
...(range ? { range } : {}),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The source OFFSET right after an item's `- [ ]`/`- [x]` marker — the first inline child of the item's first paragraph, never the paragraph node's own (possibly stale) position. `undefined` when the item has no paragraph, or an empty one. */
|
|
227
|
+
const firstParagraphContentOffset = (item: ListItem): number | undefined => {
|
|
228
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
229
|
+
return paragraph?.children[0]?.position?.start.offset
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The text trailing the pointer token on the item's OWN source line — never
|
|
234
|
+
* a later, merely wrapped-onto-the-item's-first-paragraph line. This is what
|
|
235
|
+
* the second-pointer check must scope itself to: a below-pointer explanation
|
|
236
|
+
* that happens to open with a path (`./src/foo.ts is the caller`, on the
|
|
237
|
+
* NEXT physical line) is not the same thing as `- [ ] ./a.ts#1 ./b.ts#2` (two
|
|
238
|
+
* pointers crammed onto ONE line), even though both collapse to the same
|
|
239
|
+
* joined `restOfParagraph` string once whitespace is normalized.
|
|
240
|
+
*/
|
|
241
|
+
const hunkInlineSegment = (
|
|
242
|
+
content: string,
|
|
243
|
+
lines: readonly string[],
|
|
244
|
+
item: ListItem,
|
|
245
|
+
token: string,
|
|
246
|
+
sourceLine: number,
|
|
247
|
+
): string => {
|
|
248
|
+
const contentOffset = firstParagraphContentOffset(item)
|
|
249
|
+
const contentPos =
|
|
250
|
+
contentOffset !== undefined ? toLspPositionFromOffset(content, contentOffset) : undefined
|
|
251
|
+
const sameLineRaw =
|
|
252
|
+
contentPos && contentPos.line === sourceLine
|
|
253
|
+
? (lines[sourceLine] ?? "").slice(contentPos.character).trim()
|
|
254
|
+
: ""
|
|
255
|
+
return sameLineRaw.startsWith(token)
|
|
256
|
+
? sameLineRaw.slice(token.length).trim().replace(NOTE_SEPARATOR_RE, "")
|
|
257
|
+
: ""
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A hunk's composed note: the paragraph's own trailing text (same line and/or lazily wrapped, already joined and whitespace-collapsed by `sourceText`) first, then every further sibling block of the item — EXCLUDING a nested `list` (a nested hunk's own span) and a `footnoteDefinition` — each with its own leading dash separator stripped, joined by a single space. */
|
|
261
|
+
const hunkNote = (
|
|
262
|
+
content: string,
|
|
263
|
+
item: ListItem,
|
|
264
|
+
paragraph: RootContent,
|
|
265
|
+
restOfParagraph: string,
|
|
266
|
+
): string => {
|
|
267
|
+
const otherChildren = item.children.filter(
|
|
268
|
+
(c) => c !== paragraph && c.type !== "list" && c.type !== "footnoteDefinition",
|
|
269
|
+
)
|
|
270
|
+
const segments = [
|
|
271
|
+
restOfParagraph.replace(NOTE_SEPARATOR_RE, ""),
|
|
272
|
+
...otherChildren.map((c) => sourceText(content, c).replace(NOTE_SEPARATOR_RE, "")),
|
|
273
|
+
].filter((s) => s.length > 0)
|
|
274
|
+
return segments.join(" ").trim()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `nodes` with `NOTE_SEPARATOR_RE` stripped off its own FIRST text node's value, mirroring `hunkNote`'s identical string-side stripping — a no-op when `nodes` doesn't open with a `text` node. */
|
|
278
|
+
const stripLeadingSeparatorInline = (nodes: readonly InlineNode[]): readonly InlineNode[] => {
|
|
279
|
+
const first = nodes[0]
|
|
280
|
+
if (first === undefined || first.kind !== "text") return nodes
|
|
281
|
+
return [
|
|
282
|
+
{ kind: "text" as const, value: first.value.replace(NOTE_SEPARATOR_RE, "") },
|
|
283
|
+
...nodes.slice(1),
|
|
284
|
+
]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** `paragraph`'s own inline content strictly AFTER raw source offset `afterOffset` — the structural counterpart of `restOfParagraph`'s string slice: keeps every child entirely past the cut, trims the one child that straddles it (only when it's plain `text`; a straddling non-text child is dropped, the one edge case a token followed immediately by styled text hits), and trims the result's own leading whitespace. `undefined`/`[]` for anything that isn't a real `paragraph`. */
|
|
288
|
+
const trailingParagraphInline = (
|
|
289
|
+
content: string,
|
|
290
|
+
paragraph: RootContent,
|
|
291
|
+
afterOffset: number,
|
|
292
|
+
): readonly InlineNode[] => {
|
|
293
|
+
if (paragraph.type !== "paragraph") return []
|
|
294
|
+
const kept = paragraph.children.flatMap((child) => {
|
|
295
|
+
const start = child.position?.start.offset
|
|
296
|
+
const end = child.position?.end.offset
|
|
297
|
+
if (start === undefined || end === undefined || end <= afterOffset) return []
|
|
298
|
+
if (start >= afterOffset) return [child]
|
|
299
|
+
if (child.type !== "text") return []
|
|
300
|
+
return [{ ...child, value: child.value.slice(afterOffset - start) }]
|
|
301
|
+
})
|
|
302
|
+
const projected = projectInline(kept, definitionsOf(content))
|
|
303
|
+
const first = projected[0]
|
|
304
|
+
if (first === undefined || first.kind !== "text") return projected
|
|
305
|
+
return [{ kind: "text" as const, value: first.value.trimStart() }, ...projected.slice(1)]
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** `note`'s own inline counterpart, mirroring `hunkNote` structurally: `paragraph`'s own trailing inline content (past the pointer token) first, then every further sibling block's own `nodeInline`-equivalent projection, joined and image-collapsed (R2). `tokenEndOffset` is the raw source offset right after the pointer token itself. */
|
|
309
|
+
const hunkNoteInline = (
|
|
310
|
+
content: string,
|
|
311
|
+
item: ListItem,
|
|
312
|
+
paragraph: RootContent,
|
|
313
|
+
tokenEndOffset: number,
|
|
314
|
+
): readonly InlineNode[] => {
|
|
315
|
+
const otherChildren = item.children.filter(
|
|
316
|
+
(c) => c !== paragraph && c.type !== "list" && c.type !== "footnoteDefinition",
|
|
317
|
+
)
|
|
318
|
+
const trailing = stripLeadingSeparatorInline(
|
|
319
|
+
trailingParagraphInline(content, paragraph, tokenEndOffset),
|
|
320
|
+
)
|
|
321
|
+
const runs = [
|
|
322
|
+
trailing,
|
|
323
|
+
...otherChildren.map((c) => stripLeadingSeparatorInline(blockRunInline(content, [c]))),
|
|
324
|
+
]
|
|
325
|
+
return collapseImages(joinInlineRuns(runs))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The last line of a hunk's OWN span — every non-`list` child's own end line, at most — excluding a nested hunk's span entirely, so a parent's span never swallows it (for "add a footnote" placement, or for matching which hunk a cursor sits on). */
|
|
329
|
+
const hunkOwnEndLine = (item: ListItem, sourceLine: number): number => {
|
|
330
|
+
const nonListChildren = item.children.filter((c) => c.type !== "list")
|
|
331
|
+
if (nonListChildren.length === 0) return sourceLine
|
|
332
|
+
return Math.max(...nonListChildren.map((c) => toLspPosition(c.position!.end).line))
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Builds one hunk's `ReviewFile` from its already-located pointer `token` and first `paragraph`. */
|
|
336
|
+
const buildHunkFile = (
|
|
337
|
+
content: string,
|
|
338
|
+
item: ListItem,
|
|
339
|
+
paragraph: RootContent,
|
|
340
|
+
token: string,
|
|
341
|
+
sourceLine: number,
|
|
342
|
+
): ReviewFile => {
|
|
343
|
+
const parsed = parsePointerToken(token)
|
|
344
|
+
const restOfParagraph = sourceText(content, paragraph).slice(token.length).trim()
|
|
345
|
+
const note = hunkNote(content, item, paragraph, restOfParagraph)
|
|
346
|
+
const contentOffset = firstParagraphContentOffset(item)
|
|
347
|
+
const noteInline =
|
|
348
|
+
contentOffset !== undefined
|
|
349
|
+
? hunkNoteInline(content, item, paragraph, contentOffset + token.length)
|
|
350
|
+
: []
|
|
351
|
+
return {
|
|
352
|
+
path: parsed.path,
|
|
353
|
+
...(parsed.line !== undefined ? { line: parsed.line } : {}),
|
|
354
|
+
...(parsed.rangeEnd !== undefined ? { rangeEnd: parsed.rangeEnd } : {}),
|
|
355
|
+
checked: item.checked === true,
|
|
356
|
+
sourceLine,
|
|
357
|
+
endLine: hunkOwnEndLine(item, sourceLine),
|
|
358
|
+
...(note.length > 0 ? { note, noteInline } : {}),
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The pointer token's own `[start, end)` range on `sourceLine` — the token's first paragraph content offset, extended by its own length. `undefined` only when the item has no resolvable paragraph offset (kept total; never happens for a real hunk item). Shared by `hunkLinkFor`'s document-link range and the line-without-a-range finding's range so the two can never disagree about what the token spans. */
|
|
363
|
+
const pointerTokenRange = (
|
|
364
|
+
content: string,
|
|
365
|
+
item: ListItem,
|
|
366
|
+
token: string,
|
|
367
|
+
): SteeringFinding["range"] => {
|
|
368
|
+
const offset = firstParagraphContentOffset(item)
|
|
369
|
+
if (offset === undefined) return undefined
|
|
370
|
+
const start = toLspPositionFromOffset(content, offset)
|
|
371
|
+
const end = toLspPositionFromOffset(content, offset + token.length)
|
|
372
|
+
return { start, end }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The "line but no range" finding: a pointer whose token carries a bare
|
|
377
|
+
* `#<line>` (no `#<start>-<end>`) is a validation finding, never a silent
|
|
378
|
+
* whole-file pointer — the human chose enforcement over instruction. Absent
|
|
379
|
+
* for a range pointer (`file.rangeEnd` set) and for a `#`-less pointer
|
|
380
|
+
* (`file.line` absent entirely).
|
|
381
|
+
*/
|
|
382
|
+
const hunkLineWithoutRangeFinding = (
|
|
383
|
+
content: string,
|
|
384
|
+
item: ListItem,
|
|
385
|
+
token: string,
|
|
386
|
+
sourceLine: number,
|
|
387
|
+
title: string,
|
|
388
|
+
file: ReviewFile,
|
|
389
|
+
): SteeringFinding | undefined => {
|
|
390
|
+
if (file.rangeEnd !== undefined || file.line === undefined) return undefined
|
|
391
|
+
const target = pointerDisplay(file)
|
|
392
|
+
const range = pointerTokenRange(content, item, token)
|
|
393
|
+
return {
|
|
394
|
+
message: `Chunk "${title}" hunk ${target} carries a line but no range — write "#${file.line}-<end>"`,
|
|
395
|
+
line: sourceLine,
|
|
396
|
+
...(range ? { range } : {}),
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The second pointer token's own `[start, end)` character range on `sourceLine` — found by searching forward from right after the FIRST token, so an earlier, coincidentally-matching substring can never be picked up. `undefined` only if the token somehow isn't found (never happens for a real match; kept total). */
|
|
401
|
+
const secondTokenRange = (
|
|
402
|
+
lines: readonly string[],
|
|
403
|
+
sourceLine: number,
|
|
404
|
+
firstTokenEnd: number,
|
|
405
|
+
secondToken: string,
|
|
406
|
+
): SteeringFinding["range"] => {
|
|
407
|
+
const raw = lines[sourceLine] ?? ""
|
|
408
|
+
const start = raw.indexOf(secondToken, firstTokenEnd)
|
|
409
|
+
if (start === -1) return undefined
|
|
410
|
+
return {
|
|
411
|
+
start: { line: sourceLine, character: start },
|
|
412
|
+
end: { line: sourceLine, character: start + secondToken.length },
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The second-pointer finding for one hunk, or `undefined` when its inline (same-line) segment doesn't itself open with a pointer token. */
|
|
417
|
+
const hunkSecondPointerFinding = (
|
|
418
|
+
content: string,
|
|
419
|
+
lines: readonly string[],
|
|
420
|
+
item: ListItem,
|
|
421
|
+
token: string,
|
|
422
|
+
sourceLine: number,
|
|
423
|
+
title: string,
|
|
424
|
+
file: ReviewFile,
|
|
425
|
+
): SteeringFinding | undefined => {
|
|
426
|
+
const inlineSegment = hunkInlineSegment(content, lines, item, token, sourceLine)
|
|
427
|
+
const secondToken = inlineSegment.split(/\s+/)[0] ?? ""
|
|
428
|
+
if (!isPointerToken(secondToken)) return undefined
|
|
429
|
+
const contentOffset = firstParagraphContentOffset(item)
|
|
430
|
+
const contentPos =
|
|
431
|
+
contentOffset !== undefined ? toLspPositionFromOffset(content, contentOffset) : undefined
|
|
432
|
+
const firstTokenEnd = (contentPos?.character ?? 0) + token.length
|
|
433
|
+
const range = secondTokenRange(lines, sourceLine, firstTokenEnd, secondToken)
|
|
434
|
+
return secondPointerError(range, title, file, secondToken)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The first word of `item`'s first paragraph, whichever comes first. `undefined` when `item` has no paragraph, or an empty one. */
|
|
438
|
+
const firstParagraphToken = (content: string, item: ListItem): string | undefined => {
|
|
439
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
440
|
+
if (!paragraph) return undefined
|
|
441
|
+
return sourceText(content, paragraph)
|
|
442
|
+
.split(/\s+/)
|
|
443
|
+
.find((w) => w.length > 0)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** True when `item`'s first paragraph's first word is a pointer token — the single "is this list item a hunk pointer" test, shared by `parseHunk` and `parseChunkBody` so the parse and the description can never disagree about what counts as a pointer. */
|
|
447
|
+
const hasPointerToken = (content: string, item: ListItem): boolean => {
|
|
448
|
+
const token = firstParagraphToken(content, item)
|
|
449
|
+
return token !== undefined && isPointerToken(token)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** One pointer's parse result: its `ReviewFile`, plus the second-pointer finding when its inline (same-line) segment itself opens with a pointer token. `undefined` when `item`'s first paragraph's first word isn't a pointer token at all — a real task-list item whose content isn't a hunk pointer. */
|
|
453
|
+
const parseHunk = (
|
|
454
|
+
content: string,
|
|
455
|
+
lines: readonly string[],
|
|
456
|
+
title: string,
|
|
457
|
+
item: ListItem,
|
|
458
|
+
): { readonly file: ReviewFile; readonly errors: readonly SteeringFinding[] } | undefined => {
|
|
459
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
460
|
+
if (!paragraph || !item.position) return undefined
|
|
461
|
+
if (!hasPointerToken(content, item)) return undefined
|
|
462
|
+
|
|
463
|
+
const token = firstParagraphToken(content, item)!
|
|
464
|
+
const sourceLine = toLspPosition(item.position.start).line
|
|
465
|
+
const file = buildHunkFile(content, item, paragraph, token, sourceLine)
|
|
466
|
+
const errors = [
|
|
467
|
+
hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file),
|
|
468
|
+
hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file),
|
|
469
|
+
].filter((e): e is SteeringFinding => e !== undefined)
|
|
470
|
+
return { file, errors }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** 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
|
+
const parseChunkBody = (
|
|
475
|
+
content: string,
|
|
476
|
+
lines: readonly string[],
|
|
477
|
+
title: string,
|
|
478
|
+
body: readonly RootContent[],
|
|
479
|
+
): {
|
|
480
|
+
readonly description: string
|
|
481
|
+
readonly descriptionNodes: readonly SteeringViewNode[]
|
|
482
|
+
readonly descriptionInline: readonly InlineNode[]
|
|
483
|
+
readonly files: readonly ReviewFile[]
|
|
484
|
+
readonly errors: readonly SteeringFinding[]
|
|
485
|
+
} => {
|
|
486
|
+
const firstPointerIndex = body.findIndex((n) =>
|
|
487
|
+
taskItems(n).some((item) => hasPointerToken(content, item)),
|
|
488
|
+
)
|
|
489
|
+
const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)
|
|
490
|
+
const descriptionNodes = blockNodesOfRun(content, leadingRun)
|
|
491
|
+
const descriptionInline = collapseImages(blockRunInline(content, leadingRun))
|
|
492
|
+
const description = leadingRun
|
|
493
|
+
.filter((n) => n.type !== "footnoteDefinition")
|
|
494
|
+
.map((n) => sourceText(content, n))
|
|
495
|
+
.join(" ")
|
|
496
|
+
.trim()
|
|
497
|
+
|
|
498
|
+
const files: ReviewFile[] = []
|
|
499
|
+
const errors: SteeringFinding[] = []
|
|
500
|
+
for (const item of body.flatMap((n) => taskItems(n))) {
|
|
501
|
+
const parsed = parseHunk(content, lines, title, item)
|
|
502
|
+
if (!parsed) continue
|
|
503
|
+
files.push(parsed.file)
|
|
504
|
+
errors.push(...parsed.errors)
|
|
505
|
+
}
|
|
506
|
+
return { description, descriptionNodes, descriptionInline, files, errors }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** One `##` chunk heading node with its raw body block nodes (up to the next `##`-or-shallower heading). */
|
|
510
|
+
interface ChunkBlock extends HeadingBlock {
|
|
511
|
+
readonly headingLine: number
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Splits the tree's top-level nodes into consecutive `##` chunk blocks — a `## ` line found only inside a fence never becomes a heading node at all, so it's never mistaken for a chunk. */
|
|
515
|
+
const splitChunks = (tree: Root): readonly ChunkBlock[] => {
|
|
516
|
+
const blocks: ChunkBlock[] = []
|
|
517
|
+
let i = 0
|
|
518
|
+
while (i < tree.children.length) {
|
|
519
|
+
const node = tree.children[i]!
|
|
520
|
+
if (node.type !== "heading" || node.depth !== 2) {
|
|
521
|
+
i += 1
|
|
522
|
+
continue
|
|
523
|
+
}
|
|
524
|
+
const heading = node
|
|
525
|
+
const headingLine = toLspPosition(heading.position!.start).line
|
|
526
|
+
i += 1
|
|
527
|
+
const body: RootContent[] = []
|
|
528
|
+
while (i < tree.children.length) {
|
|
529
|
+
const next = tree.children[i]!
|
|
530
|
+
if (next.type === "heading" && next.depth <= 2) break
|
|
531
|
+
body.push(next)
|
|
532
|
+
i += 1
|
|
533
|
+
}
|
|
534
|
+
blocks.push({ heading, headingLine, body })
|
|
535
|
+
}
|
|
536
|
+
return blocks
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const parseChangesets = (
|
|
540
|
+
tree: Root,
|
|
541
|
+
content: string,
|
|
542
|
+
lines: readonly string[],
|
|
543
|
+
): { readonly changesets: readonly Changeset[]; readonly errors: readonly SteeringFinding[] } => {
|
|
544
|
+
const changesets: Changeset[] = []
|
|
545
|
+
const errors: SteeringFinding[] = []
|
|
546
|
+
for (const { heading, headingLine, body } of splitChunks(tree)) {
|
|
547
|
+
const title = headingText(content, heading)
|
|
548
|
+
const {
|
|
549
|
+
description,
|
|
550
|
+
descriptionNodes,
|
|
551
|
+
descriptionInline,
|
|
552
|
+
files,
|
|
553
|
+
errors: bodyErrors,
|
|
554
|
+
} = parseChunkBody(content, lines, title, body)
|
|
555
|
+
errors.push(...bodyErrors)
|
|
556
|
+
if (files.length === 0) {
|
|
557
|
+
errors.push({
|
|
558
|
+
message: `Chunk "${title}" has no file pointers`,
|
|
559
|
+
line: headingLine,
|
|
560
|
+
range: nodeRange(heading),
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
changesets.push({ title, description, descriptionNodes, descriptionInline, files, headingLine })
|
|
564
|
+
}
|
|
565
|
+
if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
|
|
566
|
+
return { changesets, errors }
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* The missing-header finding: "the wrong first block node" — `tree.children[0]`
|
|
571
|
+
* — is the node this finding is ABOUT, so its range spans that node. An empty
|
|
572
|
+
* document (no first node at all) has no token to point at, so the finding
|
|
573
|
+
* stays positionless in that one case.
|
|
574
|
+
*/
|
|
575
|
+
const missingHeaderFinding = (tree: Root): SteeringFinding => {
|
|
576
|
+
const first = tree.children[0]
|
|
577
|
+
const message = "Missing or malformed '# Review: <hash>' header as the document's first line"
|
|
578
|
+
if (first === undefined || !first.position) return { message }
|
|
579
|
+
return { message, line: toLspPosition(first.position.start).line, range: nodeRange(first) }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Parses the review structure out of `content` (the raw text of
|
|
584
|
+
* `.gtd/REVIEW.md`). Total and side-effect-free: always returns a result,
|
|
585
|
+
* never throws — `parseMarkdown`'s `fromMarkdown` never does either.
|
|
586
|
+
* `findings` is non-empty exactly when the document violates the required
|
|
587
|
+
* structure — the caller decides what to do with that (`gtd validate` exits
|
|
588
|
+
* non-zero with them; the LSP publishes them as diagnostics). The one parse
|
|
589
|
+
* function: there is no second one routing around this return type.
|
|
590
|
+
*/
|
|
591
|
+
export const parseReviewDoc = (content: string): ReviewDoc => {
|
|
592
|
+
const tree = parseMarkdown(content)
|
|
593
|
+
const lines = content.split(/\r?\n/)
|
|
594
|
+
const shortHash = parseHeader(tree, content)
|
|
595
|
+
const fullHash = parseBaseComment(tree)
|
|
596
|
+
const { changesets, errors: chunkFindings } = parseChangesets(tree, content, lines)
|
|
597
|
+
|
|
598
|
+
const findings: SteeringFinding[] = [
|
|
599
|
+
...(shortHash ? [] : [missingHeaderFinding(tree)]),
|
|
600
|
+
// A missing base comment has no offending token to point at — stays positionless.
|
|
601
|
+
...(fullHash ? [] : [{ message: "Missing '<!-- base: <hash> -->' comment" }]),
|
|
602
|
+
...chunkFindings,
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
...(shortHash ? { shortHash } : {}),
|
|
607
|
+
...(fullHash ? { fullHash } : {}),
|
|
608
|
+
changesets,
|
|
609
|
+
findings,
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** The character offset of a checked task item's own box character (`x`/`X`) — the first `[` at or after the item's own start offset, bounded by (before) its content's start offset, mirroring how a checkbox toggle resolves the same offset. `undefined` when the item carries no resolvable box (never happens for a real GFM task item, but kept total). */
|
|
614
|
+
const checkboxOffset = (content: string, item: ListItem): number | undefined => {
|
|
615
|
+
if (!item.position) return undefined
|
|
616
|
+
const startOffset = item.position.start.offset
|
|
617
|
+
if (startOffset === undefined) return undefined
|
|
618
|
+
const boundOffset = firstParagraphContentOffset(item) ?? item.position.end.offset!
|
|
619
|
+
const bracketOffset = content.indexOf("[", startOffset)
|
|
620
|
+
if (bracketOffset === -1 || bracketOffset >= boundOffset) return undefined
|
|
621
|
+
return bracketOffset + 1
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** True when `item`'s first paragraph's first word is itself a hunk pointer token — the scope `reviewClearTicks` clears, so a `qa`-shaped checked option (never a pointer token) is untouched even if content in the wrong shape is fed to it. */
|
|
625
|
+
const isHunkPointerItem = (content: string, item: ListItem): boolean => {
|
|
626
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
627
|
+
if (!paragraph) return false
|
|
628
|
+
const token = sourceText(content, paragraph)
|
|
629
|
+
.split(/\s+/)
|
|
630
|
+
.find((w) => w.length > 0)
|
|
631
|
+
return token !== undefined && isPointerToken(token)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Resets every checked HUNK POINTER back to `- [ ]` — ticks are read-progress,
|
|
636
|
+
* never sign-off. Scoped to real hunk pointers, never "every checked task
|
|
637
|
+
* item": that looser scope would strip a `qa` answer's tick if this ever ran
|
|
638
|
+
* against the wrong file.
|
|
639
|
+
*
|
|
640
|
+
* A byte-preserving OFFSET SPLICE, never a reserialization — `split`/`join`
|
|
641
|
+
* normalizes CRLF and would turn a tick-only round into a whole-file diff.
|
|
642
|
+
* Total and idempotent, and returns `content` ITSELF when there is nothing to
|
|
643
|
+
* clear, so a caller can skip the write and leave the mtime untouched.
|
|
644
|
+
*/
|
|
645
|
+
const reviewClearTicks = (content: string): string => {
|
|
646
|
+
const tree = parseMarkdown(content)
|
|
647
|
+
const offsets = taskItems(tree)
|
|
648
|
+
.filter((item) => item.checked === true && isHunkPointerItem(content, item))
|
|
649
|
+
.map((item) => checkboxOffset(content, item))
|
|
650
|
+
.filter((o): o is number => o !== undefined)
|
|
651
|
+
.sort((a, b) => a - b)
|
|
652
|
+
if (offsets.length === 0) return content
|
|
653
|
+
|
|
654
|
+
let result = ""
|
|
655
|
+
let cursor = 0
|
|
656
|
+
for (const offset of offsets) {
|
|
657
|
+
result += content.slice(cursor, offset) + " "
|
|
658
|
+
cursor = offset + 1
|
|
659
|
+
}
|
|
660
|
+
result += content.slice(cursor)
|
|
661
|
+
return result
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Flips the `[ ]`/`[x]` box of the hunk pointer task item starting at `line`, preserving path/note text exactly. `undefined` when `line` isn't a real task item's own start line, or its content isn't a valid hunk pointer. */
|
|
665
|
+
const toggleFilePointer = (content: string, line: number): SteeringEdit | undefined => {
|
|
666
|
+
const tree = parseMarkdown(content)
|
|
667
|
+
const item = taskItems(tree).find((it) => toLspPosition(it.position!.start).line === line)
|
|
668
|
+
if (!item?.position) return undefined
|
|
669
|
+
|
|
670
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
671
|
+
const firstWord = paragraph ? sourceText(content, paragraph).split(/\s+/)[0] : undefined
|
|
672
|
+
if (!firstWord || !isPointerToken(firstWord)) return undefined
|
|
673
|
+
|
|
674
|
+
const offset = checkboxOffset(content, item)
|
|
675
|
+
if (offset === undefined) return undefined
|
|
676
|
+
const position = toLspPositionFromOffset(content, offset)
|
|
677
|
+
return {
|
|
678
|
+
range: { start: position, end: { line: position.line, character: position.character + 1 } },
|
|
679
|
+
newText: item.checked === true ? " " : "x",
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Every chunk heading's own line → its outline range's real end line (`blockEndLine`) — the node-boundary replacement for the old "next chunk's heading minus one" guess. */
|
|
684
|
+
const chunkEndLines = (content: string): ReadonlyMap<number, number> => {
|
|
685
|
+
const tree = parseMarkdown(content)
|
|
686
|
+
const map = new Map<number, number>()
|
|
687
|
+
for (const block of splitChunks(tree)) map.set(block.headingLine, blockEndLine(block))
|
|
688
|
+
return map
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Document outline for `.gtd/REVIEW.md`: the headlines of chunks (the
|
|
693
|
+
* user-facing "work packages") that still carry at least one unchecked hunk
|
|
694
|
+
* — the outline is the list of packages left to review — PLUS any chunk
|
|
695
|
+
* that carries at least one footnote, even when every hunk in it is ticked:
|
|
696
|
+
* `reviewOutline` otherwise drops a fully-checked chunk, which would make a
|
|
697
|
+
* human's own comment vanish the moment they tick the last box. A chunk's
|
|
698
|
+
* footnotes are `leaf: true` children; a chunk with none has no children.
|
|
699
|
+
*/
|
|
700
|
+
const reviewOutline = (content: string): readonly SteeringOutlineNode[] => {
|
|
701
|
+
const { changesets } = parseReviewDoc(content)
|
|
702
|
+
const { markers, definitions } = parseFootnotes(content)
|
|
703
|
+
const definitionByName = new Map(definitions.map((d) => [d.name, d.body]))
|
|
704
|
+
const lines = content.split(/\r?\n/)
|
|
705
|
+
const endLines = chunkEndLines(content)
|
|
706
|
+
const threads = threadOutlineNodes(content)
|
|
707
|
+
const nested = new Set<SteeringOutlineNode>()
|
|
708
|
+
const nodes = changesets
|
|
709
|
+
.map((chunk) => {
|
|
710
|
+
const start = chunk.headingLine
|
|
711
|
+
const end = Math.max(start, endLines.get(start) ?? start)
|
|
712
|
+
const checkedCount = chunk.files.filter((file) => file.checked).length
|
|
713
|
+
const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end)
|
|
714
|
+
const chunkThreads = threads
|
|
715
|
+
.filter((t) => t.markerLine !== undefined && t.markerLine >= start && t.markerLine <= end)
|
|
716
|
+
.map((t) => t.node)
|
|
717
|
+
chunkThreads.forEach((n) => nested.add(n))
|
|
718
|
+
const children = [
|
|
719
|
+
...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
|
|
720
|
+
...chunkThreads,
|
|
721
|
+
]
|
|
722
|
+
return {
|
|
723
|
+
name: `${chunk.title} (${checkedCount}/${chunk.files.length})`,
|
|
724
|
+
range: spanRange(lines, start, end),
|
|
725
|
+
selectionRange: lineRange(lines, start),
|
|
726
|
+
...(children.length > 0 ? { children } : {}),
|
|
727
|
+
unchecked: checkedCount < chunk.files.length || chunkMarkers.length > 0,
|
|
728
|
+
}
|
|
729
|
+
})
|
|
730
|
+
.filter((node) => node.unchecked)
|
|
731
|
+
.map(({ unchecked: _unchecked, ...node }) => node)
|
|
732
|
+
return [...nodes, ...threads.map((t) => t.node).filter((n) => !nested.has(n))]
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* The target state a whole-chunk toggle drives every hunk to: `true` (check
|
|
737
|
+
* all) unless a strict majority are already checked, in which case `false`
|
|
738
|
+
* (uncheck all) — a chunk with no strict majority either way (including an
|
|
739
|
+
* even split) defaults to checking, so it's never a meaningless no-op on a
|
|
740
|
+
* chunk that's already uniform.
|
|
741
|
+
*/
|
|
742
|
+
const chunkToggleTarget = (checkedCount: number, total: number): boolean =>
|
|
743
|
+
checkedCount * 2 <= total
|
|
744
|
+
|
|
745
|
+
/** Toggles every hunk in the chunk headed at `headingLine` to a single target state (`chunkToggleTarget`) — one edit per hunk not already at the target state, none when the chunk is already uniform. */
|
|
746
|
+
const toggleChunkEdits = (content: string, headingLine: number): SteeringEdit[] => {
|
|
747
|
+
const { changesets } = parseReviewDoc(content)
|
|
748
|
+
const chunk = changesets.find((c) => c.headingLine === headingLine)
|
|
749
|
+
if (!chunk || chunk.files.length === 0) return []
|
|
750
|
+
const checkedCount = chunk.files.filter((file) => file.checked).length
|
|
751
|
+
const target = chunkToggleTarget(checkedCount, chunk.files.length)
|
|
752
|
+
const edits: SteeringEdit[] = []
|
|
753
|
+
for (const file of chunk.files) {
|
|
754
|
+
if (file.checked === target) continue
|
|
755
|
+
const edit = toggleFilePointer(content, file.sourceLine)
|
|
756
|
+
if (edit) edits.push(edit)
|
|
757
|
+
}
|
|
758
|
+
return edits
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The block a footnote lands after when "add a footnote" fires with the
|
|
763
|
+
* cursor at `cursorLine`: the containing hunk's own span (`ReviewFile.endLine`
|
|
764
|
+
* — never a nested hunk's own span, since a parent hunk's `endLine` excludes
|
|
765
|
+
* it) when the cursor sits in one, otherwise the surrounding top-level block
|
|
766
|
+
* node's own end line (`blockNodeAt`, from `MarkdownTree.ts`).
|
|
767
|
+
*/
|
|
768
|
+
const footnoteBlockEnd = (content: string, tree: Root, cursorLine: number): number => {
|
|
769
|
+
const { changesets } = parseReviewDoc(content)
|
|
770
|
+
const hunk = changesets
|
|
771
|
+
.flatMap((chunk) => chunk.files)
|
|
772
|
+
.find((file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine)
|
|
773
|
+
if (hunk) return hunk.endLine
|
|
774
|
+
const block = blockNodeAt(tree, cursorLine)
|
|
775
|
+
return block?.position ? toLspPosition(block.position.end).line : cursorLine
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Actions for `.gtd/REVIEW.md`: "check/uncheck this hunk" when `range` sits
|
|
780
|
+
* on a hunk line, "check/uncheck all hunks" when `range` sits anywhere in a
|
|
781
|
+
* chunk (heading or body), and "add a footnote" LAST, after every hunk/chunk
|
|
782
|
+
* action, everywhere EXCEPT inside an existing marker's span or on an
|
|
783
|
+
* existing definition's own line — planting a new marker/definition there
|
|
784
|
+
* would corrupt the footnote already written.
|
|
785
|
+
*/
|
|
786
|
+
const reviewActions: SteeringFormat["actions"] = (content, range) => {
|
|
787
|
+
const { changesets } = parseReviewDoc(content)
|
|
788
|
+
const tree = parseMarkdown(content)
|
|
789
|
+
const lines = content.split(/\r?\n/)
|
|
790
|
+
const cursorLine = range.start.line
|
|
791
|
+
const actions: Array<{ readonly title: string; readonly edits: readonly SteeringEdit[] }> = [
|
|
792
|
+
...threadReplyActions(content, range.start),
|
|
793
|
+
]
|
|
794
|
+
|
|
795
|
+
// fallow-ignore-next-line complexity
|
|
796
|
+
changesets.forEach((chunk, i) => {
|
|
797
|
+
const hunk = chunk.files.find(
|
|
798
|
+
(file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine,
|
|
799
|
+
)
|
|
800
|
+
if (hunk) {
|
|
801
|
+
const edit = toggleFilePointer(content, hunk.sourceLine)
|
|
802
|
+
if (edit) {
|
|
803
|
+
actions.push({
|
|
804
|
+
title: hunk.checked ? "gtd: uncheck this hunk" : "gtd: check this hunk",
|
|
805
|
+
edits: [edit],
|
|
806
|
+
})
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const chunkEnd = Math.max(
|
|
811
|
+
chunk.headingLine,
|
|
812
|
+
(changesets[i + 1]?.headingLine ?? lines.length) - 1,
|
|
813
|
+
)
|
|
814
|
+
if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
|
|
815
|
+
const edits = toggleChunkEdits(content, chunk.headingLine)
|
|
816
|
+
if (edits.length > 0) {
|
|
817
|
+
const checkedCount = chunk.files.filter((file) => file.checked).length
|
|
818
|
+
const willCheck = chunkToggleTarget(checkedCount, chunk.files.length)
|
|
819
|
+
actions.push({
|
|
820
|
+
title: willCheck
|
|
821
|
+
? `gtd: check all hunks in "${chunk.title}"`
|
|
822
|
+
: `gtd: uncheck all hunks in "${chunk.title}"`,
|
|
823
|
+
edits,
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
if (!isOnExistingFootnote(content, range.start)) {
|
|
830
|
+
actions.push({
|
|
831
|
+
title: FOOTNOTE_ACTION_TITLE,
|
|
832
|
+
edits: footnoteAdditionEdits(
|
|
833
|
+
content,
|
|
834
|
+
range.start,
|
|
835
|
+
footnoteBlockEnd(content, tree, cursorLine),
|
|
836
|
+
),
|
|
837
|
+
})
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return actions
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The file a hunk pointer points into. A pointer's `#line` is 1-based and maps to 0-based here; a bare `./path` lands at 0. Path RESOLUTION against a repo root is the LSP's concern — this returns the raw `./`-relative text. */
|
|
844
|
+
const hunkPointerAt = (
|
|
845
|
+
content: string,
|
|
846
|
+
line: number,
|
|
847
|
+
): { path: string; line: number } | undefined => {
|
|
848
|
+
const { changesets } = parseReviewDoc(content)
|
|
849
|
+
const hunk = changesets.flatMap((chunk) => chunk.files).find((file) => file.sourceLine === line)
|
|
850
|
+
if (!hunk) return undefined
|
|
851
|
+
return { path: hunk.path, line: hunk.line !== undefined ? hunk.line - 1 : 0 }
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* `.gtd/REVIEW.md`'s `pointerAt`: footnotes resolve FIRST (they're
|
|
856
|
+
* column-scoped, the hunk jump is line-scoped — a marker sitting in a hunk's
|
|
857
|
+
* inline note would otherwise be shadowed by it), the hunk-pointer jump to
|
|
858
|
+
* another file second. An orphan marker/definition resolves (via
|
|
859
|
+
* `footnotePointerAt`) to "handled, but no pointer" — so it returns
|
|
860
|
+
* `undefined` here too, rather than falling through to the hunk jump.
|
|
861
|
+
*/
|
|
862
|
+
const reviewPointerAt: SteeringFormat["pointerAt"] = (content, position) => {
|
|
863
|
+
const footnote = footnotePointerAt(content, position)
|
|
864
|
+
if (footnote) return footnote.pointer
|
|
865
|
+
return hunkPointerAt(content, position.line)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* One hunk's document link, or `undefined` when `item`'s first paragraph's
|
|
870
|
+
* first word isn't a pointer token — mirrors `parseHunk`'s own recognition so
|
|
871
|
+
* the two can never disagree about what counts as a hunk. The token's range
|
|
872
|
+
* is derived directly from its own source offset (`firstParagraphContentOffset`),
|
|
873
|
+
* never by calling `pointerAt` per line — that path is one call per line and
|
|
874
|
+
* cannot yield the token's own range in the first place.
|
|
875
|
+
*/
|
|
876
|
+
const hunkLinkFor = (content: string, item: ListItem): SteeringLink | undefined => {
|
|
877
|
+
const paragraph = item.children.find((c) => c.type === "paragraph")
|
|
878
|
+
if (!paragraph || !item.position) return undefined
|
|
879
|
+
const token = sourceText(content, paragraph)
|
|
880
|
+
.split(/\s+/)
|
|
881
|
+
.find((w) => w.length > 0)
|
|
882
|
+
if (!token || !isPointerToken(token)) return undefined
|
|
883
|
+
const range = pointerTokenRange(content, item, token)
|
|
884
|
+
if (!range) return undefined
|
|
885
|
+
const parsed = parsePointerToken(token)
|
|
886
|
+
return {
|
|
887
|
+
range,
|
|
888
|
+
path: parsed.path,
|
|
889
|
+
line: parsed.line !== undefined ? parsed.line - 1 : 0,
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Every hunk-pointer document link in `content`, at any nesting depth. */
|
|
894
|
+
const reviewDocumentLinks = (content: string): readonly SteeringLink[] => {
|
|
895
|
+
const tree = parseMarkdown(content)
|
|
896
|
+
const links: SteeringLink[] = []
|
|
897
|
+
for (const block of splitChunks(tree)) {
|
|
898
|
+
for (const item of block.body.flatMap((n) => taskItems(n))) {
|
|
899
|
+
const link = hunkLinkFor(content, item)
|
|
900
|
+
if (link) links.push(link)
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return links
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Every chunk as a container with its file pointers as children, from ONE parse — never one parse per chunk. Pointers nested at any depth are already flattened into `chunk.files`. A chunk's footnote sits at the end of its heading line; a hunk's at its source line. */
|
|
907
|
+
const reviewView = (content: string): SteeringView => {
|
|
908
|
+
const { shortHash, changesets } = parseReviewDoc(content)
|
|
909
|
+
const noteAt = noteLookup(content)
|
|
910
|
+
return {
|
|
911
|
+
...(shortHash ? { header: shortHash } : {}),
|
|
912
|
+
nodes: changesets.map((chunk, chunkIndex) => ({
|
|
913
|
+
title: chunk.title,
|
|
914
|
+
detail: chunk.description,
|
|
915
|
+
detailInline: chunk.descriptionInline,
|
|
916
|
+
body: chunk.descriptionNodes,
|
|
917
|
+
anchor: { kind: "chunk", index: chunkIndex },
|
|
918
|
+
...noteAt(chunk.headingLine),
|
|
919
|
+
children: chunk.files.map((file, index) => ({
|
|
920
|
+
title: pointerDisplay(file),
|
|
921
|
+
path: file.path,
|
|
922
|
+
...(file.line !== undefined ? { line: file.line } : {}),
|
|
923
|
+
...(file.rangeEnd !== undefined ? { endLine: file.rangeEnd } : {}),
|
|
924
|
+
checked: file.checked,
|
|
925
|
+
...(file.note !== undefined
|
|
926
|
+
? { detail: file.note, detailInline: file.noteInline ?? [] }
|
|
927
|
+
: {}),
|
|
928
|
+
...noteAt(file.sourceLine),
|
|
929
|
+
anchor: { kind: "hunk", chunkIndex, index },
|
|
930
|
+
})),
|
|
931
|
+
})),
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Resolves a `chunk` anchor: attaches at the end of the chunk's own heading line, the definition landing after the chunk's whole block (`chunkEndLines`). `undefined` for a stale `index`. */
|
|
936
|
+
const resolveChunkAnchor = (
|
|
937
|
+
content: string,
|
|
938
|
+
lines: readonly string[],
|
|
939
|
+
changesets: readonly Changeset[],
|
|
940
|
+
index: number,
|
|
941
|
+
): FootnoteAnchor | undefined => {
|
|
942
|
+
const chunk = changesets[index]
|
|
943
|
+
if (!chunk) return undefined
|
|
944
|
+
const end = Math.max(
|
|
945
|
+
chunk.headingLine,
|
|
946
|
+
chunkEndLines(content).get(chunk.headingLine) ?? chunk.headingLine,
|
|
947
|
+
)
|
|
948
|
+
return {
|
|
949
|
+
line: chunk.headingLine,
|
|
950
|
+
endCharacter: (lines[chunk.headingLine] ?? "").length,
|
|
951
|
+
blockEndLine: end,
|
|
952
|
+
key: `chunk:${chunk.headingLine}`,
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** Resolves a `hunk` anchor: attaches at the end of the hunk's own source line, the definition landing after the hunk's whole span (`file.endLine`). `undefined` for a stale `chunkIndex`/`index`. */
|
|
957
|
+
const resolveHunkAnchor = (
|
|
958
|
+
lines: readonly string[],
|
|
959
|
+
changesets: readonly Changeset[],
|
|
960
|
+
chunkIndex: number,
|
|
961
|
+
index: number,
|
|
962
|
+
): FootnoteAnchor | undefined => {
|
|
963
|
+
const file = changesets[chunkIndex]?.files[index]
|
|
964
|
+
if (!file) return undefined
|
|
965
|
+
return {
|
|
966
|
+
line: file.sourceLine,
|
|
967
|
+
endCharacter: (lines[file.sourceLine] ?? "").length,
|
|
968
|
+
blockEndLine: file.endLine,
|
|
969
|
+
key: `hunk:${file.sourceLine}`,
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** Resolves a `paragraph` anchor: attaches at the end of `line`'s own text, the definition landing after that line's containing top-level block node. `undefined` when `line` isn't inside a real block. */
|
|
974
|
+
const resolveParagraphAnchor = (
|
|
975
|
+
tree: Root,
|
|
976
|
+
lines: readonly string[],
|
|
977
|
+
line: number,
|
|
978
|
+
): FootnoteAnchor | undefined => {
|
|
979
|
+
const block = blockNodeAt(tree, line)
|
|
980
|
+
if (!block?.position) return undefined
|
|
981
|
+
return {
|
|
982
|
+
line,
|
|
983
|
+
endCharacter: (lines[line] ?? "").length,
|
|
984
|
+
blockEndLine: toLspPosition(block.position.end).line,
|
|
985
|
+
key: `paragraph:${line}`,
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Resolves a `chunk`/`hunk`/`paragraph` `SteeringAnchor` against `content`
|
|
991
|
+
* into the low-level `FootnoteAnchor` `Footnotes.ts#footnoteAttachEdits`
|
|
992
|
+
* wants — `undefined` for a `question`/`option` anchor (not this format's own
|
|
993
|
+
* kind) or an index/line that no longer resolves, so a stale anchor is
|
|
994
|
+
* REJECTED rather than silently attaching to the wrong node.
|
|
995
|
+
*/
|
|
996
|
+
const resolveReviewAnchor = (
|
|
997
|
+
content: string,
|
|
998
|
+
tree: Root,
|
|
999
|
+
lines: readonly string[],
|
|
1000
|
+
changesets: readonly Changeset[],
|
|
1001
|
+
anchor: SteeringAnchor,
|
|
1002
|
+
): FootnoteAnchor | undefined => {
|
|
1003
|
+
switch (anchor.kind) {
|
|
1004
|
+
case "chunk":
|
|
1005
|
+
return resolveChunkAnchor(content, lines, changesets, anchor.index)
|
|
1006
|
+
case "hunk":
|
|
1007
|
+
return resolveHunkAnchor(lines, changesets, anchor.chunkIndex, anchor.index)
|
|
1008
|
+
case "paragraph":
|
|
1009
|
+
return resolveParagraphAnchor(tree, lines, anchor.line)
|
|
1010
|
+
default:
|
|
1011
|
+
return undefined
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** `review`-mode's `annotate`: resolves `anchor` then delegates the two-edit mechanics (`text` carried through verbatim as the new definition's body) to `Footnotes.ts#footnoteAttachEdits`. */
|
|
1016
|
+
const reviewAnnotate = (
|
|
1017
|
+
content: string,
|
|
1018
|
+
anchor: SteeringAnchor,
|
|
1019
|
+
text: string,
|
|
1020
|
+
): SteeringAnnotateResult => {
|
|
1021
|
+
const { changesets } = parseReviewDoc(content)
|
|
1022
|
+
const tree = parseMarkdown(content)
|
|
1023
|
+
const lines = content.split(/\r?\n/)
|
|
1024
|
+
const resolved = resolveReviewAnchor(content, tree, lines, changesets, anchor)
|
|
1025
|
+
if (!resolved) return { ok: false, reason: "anchor-not-found" }
|
|
1026
|
+
const result = footnoteAttachEdits(content, resolved, text)
|
|
1027
|
+
if (!result.ok) return result
|
|
1028
|
+
return { ok: true, edits: result.edits }
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Edits that set every one of `files`' ticks to `checked` — one edit per file not already at that state, none when the set is already uniform. Shared by `reviewApply`'s `hunk` (single-element) and `chunk` (every hunk beneath it) cases, so the two can never diverge on how a tick is actually set. */
|
|
1032
|
+
const setFileTickEdits = (
|
|
1033
|
+
content: string,
|
|
1034
|
+
files: readonly ReviewFile[],
|
|
1035
|
+
checked: boolean,
|
|
1036
|
+
): SteeringEdit[] => {
|
|
1037
|
+
const edits: SteeringEdit[] = []
|
|
1038
|
+
for (const file of files) {
|
|
1039
|
+
if (file.checked === checked) continue
|
|
1040
|
+
const edit = toggleFilePointer(content, file.sourceLine)
|
|
1041
|
+
if (edit) edits.push(edit)
|
|
1042
|
+
}
|
|
1043
|
+
return edits
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** A `hunk` anchor sets one tick; a `chunk` anchor sets every hunk beneath it to the SAME target state — never `toggleChunkEdits`'s majority-flip heuristic, since the caller already knows the state it wants. */
|
|
1047
|
+
const reviewApply: SteeringFormat["apply"] = (content, anchor, opts) => {
|
|
1048
|
+
const { changesets } = parseReviewDoc(content)
|
|
1049
|
+
const checked = opts.checked ?? true
|
|
1050
|
+
if (anchor.kind === "hunk") {
|
|
1051
|
+
const file = changesets[anchor.chunkIndex]?.files[anchor.index]
|
|
1052
|
+
if (!file) return { ok: false, reason: "anchor-not-found" }
|
|
1053
|
+
return { ok: true, edits: setFileTickEdits(content, [file], checked) }
|
|
1054
|
+
}
|
|
1055
|
+
if (anchor.kind === "chunk") {
|
|
1056
|
+
const chunk = changesets[anchor.index]
|
|
1057
|
+
if (!chunk) return { ok: false, reason: "anchor-not-found" }
|
|
1058
|
+
return { ok: true, edits: setFileTickEdits(content, chunk.files, checked) }
|
|
1059
|
+
}
|
|
1060
|
+
return { ok: false, reason: "anchor-not-found" }
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** The `review` format: `.gtd/REVIEW.md`'s in-process format — validation, outline, code actions, footnote + hunk `pointerAt`/`documentLinks`, and a hunk-pointer-scoped `clearTicks` (never "every checked task item" — see `reviewClearTicks`). */
|
|
1064
|
+
export const reviewDescriptor: SteeringFormat = {
|
|
1065
|
+
sample: REVIEW_SAMPLE,
|
|
1066
|
+
validate: (content) => [...parseReviewDoc(content).findings, ...parseFootnotes(content).findings],
|
|
1067
|
+
outline: reviewOutline,
|
|
1068
|
+
actions: reviewActions,
|
|
1069
|
+
pointerAt: reviewPointerAt,
|
|
1070
|
+
documentLinks: reviewDocumentLinks,
|
|
1071
|
+
view: reviewView,
|
|
1072
|
+
annotate: reviewAnnotate,
|
|
1073
|
+
apply: reviewApply,
|
|
1074
|
+
clearTicks: reviewClearTicks,
|
|
1075
|
+
openThreads: openThreadsOf,
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** One thing the human added to a review document since the baseline. */
|
|
1079
|
+
export interface ReviewNote {
|
|
1080
|
+
readonly id: string
|
|
1081
|
+
readonly kind: "pointer" | "footnote" | "chunk"
|
|
1082
|
+
/** The chunk title plus the pointer (path + range), or the footnote's marker line. */
|
|
1083
|
+
readonly anchor: string
|
|
1084
|
+
/** The reviewer's text at this spot; empty when the note is new. */
|
|
1085
|
+
readonly before: string
|
|
1086
|
+
/** The human's text. */
|
|
1087
|
+
readonly text: string
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const pointerLabel = (f: ReviewFile): string =>
|
|
1091
|
+
`${f.path}${f.line === undefined ? "" : `#${f.line}${f.rangeEnd === undefined ? "" : `-${f.rangeEnd}`}`}`
|
|
1092
|
+
|
|
1093
|
+
type Found = { line: number; note: Omit<ReviewNote, "id"> }
|
|
1094
|
+
|
|
1095
|
+
const chunkNote = (chunk: Changeset, prior: Changeset | undefined): Found[] =>
|
|
1096
|
+
chunk.description === (prior?.description ?? "")
|
|
1097
|
+
? []
|
|
1098
|
+
: [
|
|
1099
|
+
{
|
|
1100
|
+
line: chunk.headingLine,
|
|
1101
|
+
note: {
|
|
1102
|
+
kind: "chunk",
|
|
1103
|
+
anchor: chunk.title,
|
|
1104
|
+
before: prior?.description ?? "",
|
|
1105
|
+
text: chunk.description,
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
]
|
|
1109
|
+
|
|
1110
|
+
const pointerNotes = (chunk: Changeset, prior: Changeset | undefined): Found[] =>
|
|
1111
|
+
chunk.files.flatMap((file): Found[] => {
|
|
1112
|
+
const label = pointerLabel(file)
|
|
1113
|
+
const was = prior?.files.find((f) => pointerLabel(f) === label)
|
|
1114
|
+
if (file.note === undefined || file.note === (was?.note ?? "")) return []
|
|
1115
|
+
return [
|
|
1116
|
+
{
|
|
1117
|
+
line: file.sourceLine,
|
|
1118
|
+
note: {
|
|
1119
|
+
kind: "pointer",
|
|
1120
|
+
anchor: `${chunk.title} ${label}`,
|
|
1121
|
+
before: was?.note ?? "",
|
|
1122
|
+
text: file.note,
|
|
1123
|
+
},
|
|
1124
|
+
},
|
|
1125
|
+
]
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
const footnoteNotes = (before: string, after: string): Found[] => {
|
|
1129
|
+
const lines = after.split(/\r?\n/)
|
|
1130
|
+
const threadNames = new Set(parseThreads(after).map((t) => t.name))
|
|
1131
|
+
const knownNames = new Set(parseFootnotes(before).definitions.map((d) => d.name))
|
|
1132
|
+
const parsed = parseFootnotes(after)
|
|
1133
|
+
const markerOf = new Map(parsed.markers.map((m) => [m.name, m.line]))
|
|
1134
|
+
return parsed.definitions
|
|
1135
|
+
.filter((def) => !threadNames.has(def.name) && !knownNames.has(def.name))
|
|
1136
|
+
.map((def) => {
|
|
1137
|
+
const at = markerOf.get(def.name)
|
|
1138
|
+
return {
|
|
1139
|
+
line: def.line,
|
|
1140
|
+
note: {
|
|
1141
|
+
kind: "footnote",
|
|
1142
|
+
anchor: at === undefined ? `[^${def.name}]` : (lines[at] ?? "").trim(),
|
|
1143
|
+
before: "",
|
|
1144
|
+
text: def.body,
|
|
1145
|
+
},
|
|
1146
|
+
}
|
|
1147
|
+
})
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Every note the human added to `after` relative to `before`, in document
|
|
1152
|
+
* order: pointer notes, one-shot footnotes (never `H:`/`A:` threads) and
|
|
1153
|
+
* chunk prose.
|
|
1154
|
+
*/
|
|
1155
|
+
export const reviewNotes = (before: string, after: string): readonly ReviewNote[] => {
|
|
1156
|
+
if (before === after) return []
|
|
1157
|
+
const oldChunks = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]))
|
|
1158
|
+
const found = [
|
|
1159
|
+
...parseReviewDoc(after).changesets.flatMap((chunk) => {
|
|
1160
|
+
const prior = oldChunks.get(chunk.title)
|
|
1161
|
+
return [...chunkNote(chunk, prior), ...pointerNotes(chunk, prior)]
|
|
1162
|
+
}),
|
|
1163
|
+
...footnoteNotes(before, after),
|
|
1164
|
+
]
|
|
1165
|
+
return found.sort((a, b) => a.line - b.line).map((f, i) => ({ id: `note-${i + 1}`, ...f.note }))
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Pointer notes the reviewer opened with `Risk:`, in document order — the ones the workflow fixes before the human gate. */
|
|
1169
|
+
export const reviewRisks = (
|
|
1170
|
+
content: string,
|
|
1171
|
+
): readonly { id: string; anchor: string; text: string }[] =>
|
|
1172
|
+
parseReviewDoc(content)
|
|
1173
|
+
.changesets.flatMap((chunk) =>
|
|
1174
|
+
chunk.files.flatMap((file) =>
|
|
1175
|
+
file.note?.startsWith("Risk:")
|
|
1176
|
+
? [{ anchor: `${chunk.title} ${pointerLabel(file)}`, text: file.note }]
|
|
1177
|
+
: [],
|
|
1178
|
+
),
|
|
1179
|
+
)
|
|
1180
|
+
.map((r, i) => ({ id: `risk-${i + 1}`, ...r }))
|