@pmelab/gtd 24.0.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 +31 -5
- 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 +43339 -55575
- 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 +8 -6
- package/package.json +69 -8
- 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/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/workflows/feature/feature.test.ts +27 -33
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +58 -78
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +2 -10
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +85 -29
- package/src/workflows/review/review.ts +3 -1
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/shared/workflows.steps.ts +7 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { List, ListItem, RootContent, Root } from "mdast"
|
|
2
|
+
import { noteLookup } from "./Footnotes.js"
|
|
3
|
+
import type { Footnotes } from "./Footnotes.js"
|
|
4
|
+
import { definitionsOf, joinInlineRuns, projectInline, type InlineNode } from "./Inline.js"
|
|
5
|
+
import { headingText, sourceText, toLspPosition } from "./MarkdownTree.js"
|
|
6
|
+
import type { BlockListItem, SteeringView, SteeringViewNode } from "./SteeringFormat.js"
|
|
7
|
+
|
|
8
|
+
/** A footnote marker as plain text, stripped out of every extracted title below. */
|
|
9
|
+
const MARKER_TEXT_RE = /\[\^([^\s\]]+)\]/g
|
|
10
|
+
|
|
11
|
+
const stripMarkerText = (text: string): string => text.replace(MARKER_TEXT_RE, "")
|
|
12
|
+
|
|
13
|
+
/** `child`'s raw source slice, UNCOLLAPSED — only `blockquoteChildrenText` needs the newlines intact, to tell a line-leading `> ` continuation marker apart from a literal `>` typed mid-prose. */
|
|
14
|
+
const rawChildText = (content: string, child: RootContent): string => {
|
|
15
|
+
const start = child.position?.start.offset
|
|
16
|
+
const end = child.position?.end.offset
|
|
17
|
+
return start === undefined || end === undefined ? "" : content.slice(start, end)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Strips a blockquote's `> ` continuation markers. Must run on the RAW slice: the newline is what distinguishes them from a literal `>` typed mid-prose. */
|
|
21
|
+
const stripContinuationMarker = (text: string): string => text.replace(/\n[ \t]*>[ \t]?/g, "\n")
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Flattened text of a run of sibling block nodes. Each child is sliced
|
|
25
|
+
* INDIVIDUALLY, never as one span from the first child's start to the last
|
|
26
|
+
* child's end: a filtered-out nested `list` still sits, raw markdown and all,
|
|
27
|
+
* between its neighbours' offsets.
|
|
28
|
+
*/
|
|
29
|
+
const childrenText = (content: string, children: readonly RootContent[]): string =>
|
|
30
|
+
children
|
|
31
|
+
.map((child) => stripMarkerText(sourceText(content, child)))
|
|
32
|
+
.filter((text) => text.length > 0)
|
|
33
|
+
.join(" ")
|
|
34
|
+
.replace(/\s+/g, " ")
|
|
35
|
+
.trim()
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `childrenText` scoped to blockquotes: their `> ` continuation markers sit in
|
|
39
|
+
* the raw bytes BETWEEN two children's positions, so they are stripped before
|
|
40
|
+
* the whitespace collapse. Never reused for `listItemText` — a list item's
|
|
41
|
+
* fenced code block can carry a real line-leading `>` that must survive.
|
|
42
|
+
*/
|
|
43
|
+
const blockquoteChildrenText = (content: string, children: readonly RootContent[]): string =>
|
|
44
|
+
children
|
|
45
|
+
.map((child) => stripMarkerText(stripContinuationMarker(rawChildText(content, child))))
|
|
46
|
+
.filter((text) => text.length > 0)
|
|
47
|
+
.join(" ")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim()
|
|
50
|
+
|
|
51
|
+
/** One list item's text, EXCLUDING any nested `list` child — that's a recursive `items` entry instead. */
|
|
52
|
+
const listItemText = (content: string, item: ListItem): string =>
|
|
53
|
+
childrenText(
|
|
54
|
+
content,
|
|
55
|
+
item.children.filter((c) => c.type !== "list"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* One list item's own inline structure, EXCLUDING any nested `list` child
|
|
60
|
+
* (mirrors `listItemText`'s identical exclusion) — recurses through each
|
|
61
|
+
* remaining non-list child via `nodeInline`, joined the same way
|
|
62
|
+
* `childrenText` joins its own text. Feeds `BlockListItem.inline` (the FULL
|
|
63
|
+
* block rendering) ONLY — a nested item renders as its own nested `<li>`
|
|
64
|
+
* there (`BlockListItem.items`), so folding its text back into the PARENT's
|
|
65
|
+
* own `inline` would duplicate it. `flattenListItemsInline` (below) is the
|
|
66
|
+
* separate, deliberately non-recursive-exclusion sibling `nodeInline`'s own
|
|
67
|
+
* `list` branch uses instead, for the flattened `detailInline` path.
|
|
68
|
+
*/
|
|
69
|
+
const listItemInline = (content: string, item: ListItem): readonly InlineNode[] =>
|
|
70
|
+
joinInlineRuns(item.children.filter((c) => c.type !== "list").map((c) => nodeInline(content, c)))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every list item's own inline run, AT ANY NESTING DEPTH, flattened and
|
|
74
|
+
* joined in document order — what `nodeInline`'s own `list` branch needs for
|
|
75
|
+
* `detailInline` (R2: "a list contributes its items' runs joined by
|
|
76
|
+
* spaces", explicitly INCLUDING a nested item, since block structure stays
|
|
77
|
+
* flattened there). Deliberately distinct from `listItemInline`/
|
|
78
|
+
* `BlockListItem.inline`, which keep a nested item's text OUT of its
|
|
79
|
+
* parent's own `inline` — that field feeds the FULL block's own recursive
|
|
80
|
+
* `<li>` tree, where a nested item already renders once, on its own.
|
|
81
|
+
*/
|
|
82
|
+
const flattenListItemsInline = (
|
|
83
|
+
content: string,
|
|
84
|
+
items: readonly ListItem[],
|
|
85
|
+
): readonly InlineNode[] =>
|
|
86
|
+
joinInlineRuns(
|
|
87
|
+
items.flatMap((item) => {
|
|
88
|
+
const ownRun = listItemInline(content, item)
|
|
89
|
+
const nested = item.children.filter((c): c is List => c.type === "list")
|
|
90
|
+
const nestedRuns = nested.map((list) => flattenListItemsInline(content, list.children))
|
|
91
|
+
return [ownRun, ...nestedRuns]
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One top-level (or list-item-child) node's own inline structure —
|
|
97
|
+
* `heading`/`paragraph` project their own `children` directly; `blockquote`
|
|
98
|
+
* joins each non-footnote-definition child's own inline run; `list` joins
|
|
99
|
+
* each of its visible items' own `listItemInline`; `code` has none of its
|
|
100
|
+
* own beyond its plain `value`. Mirrors `blockTitle`'s per-kind dispatch,
|
|
101
|
+
* structurally rather than as a flattened string.
|
|
102
|
+
*/
|
|
103
|
+
const nodeInline = (
|
|
104
|
+
content: string,
|
|
105
|
+
node: RootContent,
|
|
106
|
+
options?: BlockWalkOptions,
|
|
107
|
+
): readonly InlineNode[] => {
|
|
108
|
+
if (node.type === "heading" || node.type === "paragraph") {
|
|
109
|
+
return projectInline(node.children, definitionsOf(content))
|
|
110
|
+
}
|
|
111
|
+
if (node.type === "blockquote") {
|
|
112
|
+
return joinInlineRuns(
|
|
113
|
+
node.children
|
|
114
|
+
.filter((c) => c.type !== "footnoteDefinition")
|
|
115
|
+
.map((c) => nodeInline(content, c, options)),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
if (node.type === "list") {
|
|
119
|
+
return flattenListItemsInline(content, visibleListItems(node, options))
|
|
120
|
+
}
|
|
121
|
+
if (node.type === "code") return [{ kind: "text", value: node.value }]
|
|
122
|
+
// Every other kind (a block-level `html` node, a `thematicBreak`, …) has
|
|
123
|
+
// no inline structure of its own to project — but its own text must still
|
|
124
|
+
// survive as an inert `text` node (R4: "raw HTML in a description stays
|
|
125
|
+
// inert text", not nothing), mirroring `blockTitle`'s own identical
|
|
126
|
+
// `sourceText` fallback for the same set of kinds.
|
|
127
|
+
const title = blockTitle(content, node, options)
|
|
128
|
+
return title.length > 0 ? [{ kind: "text", value: title }] : []
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One list item as a `BlockListItem`, recursing into a nested `list` child (there is at most one, CommonMark's own shape) as its own `items`. */
|
|
132
|
+
const blockListItemOf = (content: string, item: ListItem): BlockListItem => {
|
|
133
|
+
const nested = item.children.filter((c): c is List => c.type === "list")
|
|
134
|
+
const items = nested.flatMap((list) => blockListItemsOf(content, list.children))
|
|
135
|
+
return {
|
|
136
|
+
text: listItemText(content, item),
|
|
137
|
+
inline: listItemInline(content, item),
|
|
138
|
+
...(item.checked === true || item.checked === false ? { checked: item.checked } : {}),
|
|
139
|
+
...(items.length > 0 ? { items } : {}),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every item of a `list` node's own `children`, as `BlockListItem`s, in document order. */
|
|
144
|
+
const blockListItemsOf = (content: string, items: readonly ListItem[]): readonly BlockListItem[] =>
|
|
145
|
+
items.map((item) => blockListItemOf(content, item))
|
|
146
|
+
|
|
147
|
+
/** The `title` an empty fenced code block (a `` ``` ``/`` ``` `` pair with nothing between them) falls back to — its real body is `""`, and every node still carries a non-empty title. */
|
|
148
|
+
const EMPTY_CODE_BLOCK_TITLE = "(empty code block)"
|
|
149
|
+
|
|
150
|
+
/** A `list`'s children with `options.skipListItem` removed. Filtering, never dropping the whole node: `qa`'s option items share a list with ordinary body bullets. */
|
|
151
|
+
const visibleListItems = (node: List, options?: BlockWalkOptions): readonly ListItem[] =>
|
|
152
|
+
options?.skipListItem
|
|
153
|
+
? node.children.filter((item) => !options.skipListItem!(item))
|
|
154
|
+
: node.children
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every block kind's `title`. Each branch reads the node's CHILDREN rather
|
|
158
|
+
* than its own span, because the span includes syntax the title must not
|
|
159
|
+
* carry: a heading's `#` run, a blockquote's `>`, a code fence, and — for a
|
|
160
|
+
* filtered list — every excluded item's markdown, checkbox syntax included.
|
|
161
|
+
*/
|
|
162
|
+
const blockTitle = (content: string, node: RootContent, options?: BlockWalkOptions): string => {
|
|
163
|
+
if (node.type === "heading") return headingText(content, node, stripMarkerText)
|
|
164
|
+
if (node.type === "blockquote") return blockquoteChildrenText(content, node.children)
|
|
165
|
+
if (node.type === "code") {
|
|
166
|
+
const text = stripMarkerText(node.value).replace(/\s+/g, " ").trim()
|
|
167
|
+
return text.length > 0 ? text : EMPTY_CODE_BLOCK_TITLE
|
|
168
|
+
}
|
|
169
|
+
if (node.type === "list") {
|
|
170
|
+
return visibleListItems(node, options)
|
|
171
|
+
.map((item) => listItemText(content, item))
|
|
172
|
+
.filter((text) => text.length > 0)
|
|
173
|
+
.join(" ")
|
|
174
|
+
}
|
|
175
|
+
return stripMarkerText(sourceText(content, node)).replace(/\s+/g, " ").trim()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `SteeringViewNode.block` for one top-level node — `undefined` for a kind
|
|
180
|
+
* with no projected structure (`thematicBreak`, `html`, …), which still
|
|
181
|
+
* renders via `title` alone. `code`'s `text` is `node.value` VERBATIM,
|
|
182
|
+
* leading whitespace intact, unlike every other kind's collapsed `title`.
|
|
183
|
+
*/
|
|
184
|
+
const blockOf = (
|
|
185
|
+
content: string,
|
|
186
|
+
node: RootContent,
|
|
187
|
+
options?: BlockWalkOptions,
|
|
188
|
+
): SteeringViewNode["block"] | undefined => {
|
|
189
|
+
switch (node.type) {
|
|
190
|
+
case "heading":
|
|
191
|
+
return {
|
|
192
|
+
kind: "heading",
|
|
193
|
+
depth: node.depth,
|
|
194
|
+
inline: projectInline(node.children, definitionsOf(content)),
|
|
195
|
+
}
|
|
196
|
+
case "list":
|
|
197
|
+
return {
|
|
198
|
+
kind: "list",
|
|
199
|
+
ordered: node.ordered === true,
|
|
200
|
+
items: blockListItemsOf(content, visibleListItems(node, options)),
|
|
201
|
+
}
|
|
202
|
+
case "code":
|
|
203
|
+
return {
|
|
204
|
+
kind: "code",
|
|
205
|
+
text: node.value,
|
|
206
|
+
...(node.lang !== null && node.lang !== undefined ? { language: node.lang } : {}),
|
|
207
|
+
}
|
|
208
|
+
case "blockquote":
|
|
209
|
+
return {
|
|
210
|
+
kind: "blockquote",
|
|
211
|
+
text: blockTitle(content, node),
|
|
212
|
+
inline: nodeInline(content, node),
|
|
213
|
+
}
|
|
214
|
+
case "paragraph":
|
|
215
|
+
return { kind: "paragraph", inline: projectInline(node.children, definitionsOf(content)) }
|
|
216
|
+
default:
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Lets a caller narrow the shared walk to its own document shape, so this
|
|
223
|
+
* module hardcodes no format's rules. `skipListItem` matches by NODE
|
|
224
|
+
* IDENTITY, never line arithmetic: `qa`'s option items share a source list
|
|
225
|
+
* with ordinary body bullets, so a partly-filtered list stays in the walk.
|
|
226
|
+
*/
|
|
227
|
+
export interface BlockWalkOptions {
|
|
228
|
+
readonly skipNode?: (content: string, node: RootContent) => boolean
|
|
229
|
+
readonly skipLine?: (line: number) => boolean
|
|
230
|
+
readonly skipListItem?: (item: ListItem) => boolean
|
|
231
|
+
/**
|
|
232
|
+
* Pre-parsed footnotes, for a caller that walks MULTIPLE runs of the SAME
|
|
233
|
+
* document (`qa.ts`'s one call per question, now one per option too) and
|
|
234
|
+
* must not pay `parseFootnotes`'s own whole-document walk again for each
|
|
235
|
+
* one. Absent means "parse it here" — every single-call caller (`review.ts`,
|
|
236
|
+
* `freeform.ts`, this module's own `blockNodesOf`) still gets that for
|
|
237
|
+
* free, unchanged.
|
|
238
|
+
*/
|
|
239
|
+
readonly footnotes?: Footnotes
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A `list` that `skipListItem` empties ENTIRELY — the one case excluded wholesale; one surviving item keeps the list in the walk. */
|
|
243
|
+
const isEmptiedList = (node: RootContent, options?: BlockWalkOptions): boolean =>
|
|
244
|
+
node.type === "list" &&
|
|
245
|
+
node.children.length > 0 &&
|
|
246
|
+
options?.skipListItem !== undefined &&
|
|
247
|
+
visibleListItems(node, options).length === 0
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The nodes of a run that `blockNodesOfRun`/`blockRunInline` both walk —
|
|
251
|
+
* a real position (so `position!` below is total), not a footnote
|
|
252
|
+
* definition, not `options`-skipped, not a list emptied down to nothing by
|
|
253
|
+
* `skipListItem`. Shared so the two walks can never drift apart on which
|
|
254
|
+
* nodes they see (see `blockRunInline`'s own doc comment).
|
|
255
|
+
*/
|
|
256
|
+
const visibleNodes = (
|
|
257
|
+
content: string,
|
|
258
|
+
nodes: readonly RootContent[],
|
|
259
|
+
options?: BlockWalkOptions,
|
|
260
|
+
): readonly RootContent[] =>
|
|
261
|
+
nodes
|
|
262
|
+
.filter((node) => node.position !== undefined)
|
|
263
|
+
.filter((node) => node.type !== "footnoteDefinition")
|
|
264
|
+
.filter((node) => !(options?.skipNode?.(content, node) ?? false))
|
|
265
|
+
.filter((node) => !(options?.skipLine?.(toLspPosition(node.position!.start).line) ?? false))
|
|
266
|
+
.filter((node) => !isEmptiedList(node, options))
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Every block of a RUN of sibling nodes as a view node, in document order. A
|
|
270
|
+
* `footnoteDefinition` is skipped unconditionally: it is the note ITSELF,
|
|
271
|
+
* surfaced below as a node's `note`, never document content of its own.
|
|
272
|
+
*
|
|
273
|
+
* A footnote marker on a node's start line becomes that node's `note`, so a
|
|
274
|
+
* block already carrying one offers editing it rather than a second note.
|
|
275
|
+
*
|
|
276
|
+
* `options.footnotes`, when given, replaces this call's own
|
|
277
|
+
* `parseFootnotes(content)` — see its own doc for why a caller walking many
|
|
278
|
+
* runs of one document needs it.
|
|
279
|
+
*/
|
|
280
|
+
export const blockNodesOfRun = (
|
|
281
|
+
content: string,
|
|
282
|
+
nodes: readonly RootContent[],
|
|
283
|
+
options?: BlockWalkOptions,
|
|
284
|
+
): readonly SteeringView["nodes"][number][] => {
|
|
285
|
+
const noteAt = noteLookup(content, options?.footnotes)
|
|
286
|
+
return visibleNodes(content, nodes, options).map((node) => {
|
|
287
|
+
const startLine = toLspPosition(node.position!.start).line
|
|
288
|
+
const block = blockOf(content, node, options)
|
|
289
|
+
return {
|
|
290
|
+
title: blockTitle(content, node, options),
|
|
291
|
+
anchor: { kind: "paragraph" as const, line: startLine },
|
|
292
|
+
...(block !== undefined ? { block } : {}),
|
|
293
|
+
...noteAt(startLine),
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Every top-level block of the WHOLE document, in document order — `blockNodesOfRun` over `tree.children`. See that function's own doc comment for the shared per-node logic. */
|
|
299
|
+
export const blockNodesOf = (
|
|
300
|
+
content: string,
|
|
301
|
+
tree: Root,
|
|
302
|
+
options?: BlockWalkOptions,
|
|
303
|
+
): readonly SteeringView["nodes"][number][] => blockNodesOfRun(content, tree.children, options)
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A run of sibling nodes' own inline structure, flattened and joined exactly
|
|
307
|
+
* the way `blockTitle`/`childrenText` flatten the SAME run into one string —
|
|
308
|
+
* a caller's `detailInline` (`review.ts`'s chunk, `qa.ts`'s question)
|
|
309
|
+
* builds this over the same node run its own `detail` string is built over,
|
|
310
|
+
* with the SAME `options` (so a skipped node/line/list-item is skipped in
|
|
311
|
+
* both). Never itself collapses an image to alt text — a caller wanting the
|
|
312
|
+
* compact-row rule (R2) applies `Inline.ts#collapseImages` to the result.
|
|
313
|
+
*/
|
|
314
|
+
export const blockRunInline = (
|
|
315
|
+
content: string,
|
|
316
|
+
nodes: readonly RootContent[],
|
|
317
|
+
options?: BlockWalkOptions,
|
|
318
|
+
): readonly InlineNode[] => {
|
|
319
|
+
const filtered = visibleNodes(content, nodes, options)
|
|
320
|
+
return joinInlineRuns(filtered.map((node) => nodeInline(content, node, options)))
|
|
321
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { parseCodeThreads, stripCodeThreads } from "./CodeThreads.js"
|
|
3
|
+
|
|
4
|
+
// Samples are built from string arrays: a template literal with a line-start
|
|
5
|
+
// `// H:` would be a live thread in this repo's own changed files.
|
|
6
|
+
const doc = (...lines: string[]): string => lines.join("\n")
|
|
7
|
+
|
|
8
|
+
describe("parseCodeThreads — tokens", () => {
|
|
9
|
+
it.each([
|
|
10
|
+
["a.ts", "//"],
|
|
11
|
+
["a.py", "#"],
|
|
12
|
+
["a.sql", "--"],
|
|
13
|
+
["a.ini", ";"],
|
|
14
|
+
])("%s accepts %s", (path, token) => {
|
|
15
|
+
const { threads } = parseCodeThreads(path, [token, "H: why?"].join(" "))
|
|
16
|
+
expect(threads).toHaveLength(1)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("recognises a token only in its own extensions", () => {
|
|
20
|
+
expect(parseCodeThreads("a.ts", "# H: why?").threads).toEqual([])
|
|
21
|
+
expect(parseCodeThreads("a.py", ["//", "H: why?"].join(" ")).threads).toEqual([])
|
|
22
|
+
expect(parseCodeThreads("a.sql", "; H: why?").threads).toEqual([])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("scans an unmapped file for all four tokens", () => {
|
|
26
|
+
for (const token of ["//", "#", "--", ";"]) {
|
|
27
|
+
expect(parseCodeThreads("Makefile", [token, "H: why?"].join(" ")).threads).toHaveLength(1)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("never scans markdown or .gtd files", () => {
|
|
32
|
+
const text = doc(["//", "H: why?"].join(" "), "# H: why?")
|
|
33
|
+
for (const path of ["a.md", "b.markdown", ".gtd/x.ts", ".gtd/sub/y.sh"]) {
|
|
34
|
+
expect(parseCodeThreads(path, text)).toEqual({ threads: [], findings: [] })
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("parseCodeThreads — runs", () => {
|
|
40
|
+
it("returns 0-based lines, entries and waitingOn", () => {
|
|
41
|
+
const text = doc("const x = 1", " // H: why?", " // A: because", "const y = 2")
|
|
42
|
+
const [thread] = parseCodeThreads("a.ts", text).threads
|
|
43
|
+
expect(thread).toMatchObject({ path: "a.ts", line: 1, endLine: 2, waitingOn: "human" })
|
|
44
|
+
expect(thread!.entries.map((e) => [e.author, e.text, e.line])).toEqual([
|
|
45
|
+
["me", "why?", 1],
|
|
46
|
+
["agent", "because", 2],
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("is waiting on the agent when the last entry is H:", () => {
|
|
51
|
+
expect(parseCodeThreads("a.ts", "// H: why?").threads[0]!.waitingOn).toBe("agent")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("joins unprefixed lines onto the entry above", () => {
|
|
55
|
+
const text = doc("// H: why", "// is this", "// so?", "// A: ok")
|
|
56
|
+
const [thread] = parseCodeThreads("a.ts", text).threads
|
|
57
|
+
expect(thread!.entries[0]!.text).toBe("why is this so?")
|
|
58
|
+
expect(thread!.entries).toHaveLength(2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("ends at a non-comment line or a token switch", () => {
|
|
62
|
+
const a = parseCodeThreads("Makefile", doc("# H: q", "code", "# A: orphan"))
|
|
63
|
+
expect(a.threads).toHaveLength(2)
|
|
64
|
+
expect(a.threads[0]!.entries).toHaveLength(1)
|
|
65
|
+
const b = parseCodeThreads("Makefile", doc("# H: q", "-- A: other"))
|
|
66
|
+
expect(b.threads[0]!.endLine).toBe(0)
|
|
67
|
+
expect(b.threads[0]!.entries).toHaveLength(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("ends at an unprefixed comment below an A: line", () => {
|
|
71
|
+
const text = doc("// H: why?", "// A: because", "// rename this")
|
|
72
|
+
const [thread] = parseCodeThreads("a.ts", text).threads
|
|
73
|
+
expect(thread!.endLine).toBe(1)
|
|
74
|
+
expect(thread!.entries.map((e) => e.text)).toEqual(["why?", "because"])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("treats a run not opening with a prefix as an ordinary comment", () => {
|
|
78
|
+
const text = doc("// note", "// H: why?")
|
|
79
|
+
expect(parseCodeThreads("a.ts", text)).toEqual({ threads: [], findings: [] })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("ignores trailing and block comments", () => {
|
|
83
|
+
const text = doc("foo() // H: why?", "/* H: why? */", "<!-- H: why? -->", " * H: why?")
|
|
84
|
+
expect(parseCodeThreads("Makefile", text).threads).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("parseCodeThreads — faults", () => {
|
|
89
|
+
it("reports an agent opening", () => {
|
|
90
|
+
const { findings } = parseCodeThreads("a.ts", "// A: hi")
|
|
91
|
+
expect(findings[0]!.message).toBe(
|
|
92
|
+
`Code thread at a.ts:1: the agent never starts a thread — the first entry must be "H:"`,
|
|
93
|
+
)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it("reports the same author twice", () => {
|
|
97
|
+
const { findings } = parseCodeThreads("a.ts", doc("// H: a", "// H: b"))
|
|
98
|
+
expect(findings).toHaveLength(1)
|
|
99
|
+
expect(findings[0]).toMatchObject({ line: 1 })
|
|
100
|
+
expect(findings[0]!.message).toMatch(/two consecutive "H:"/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("reports an empty entry", () => {
|
|
104
|
+
const { findings } = parseCodeThreads("a.ts", "// H:")
|
|
105
|
+
expect(findings[0]!.message).toMatch(/empty "H:" entry/)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it("reports nothing for a clean thread", () => {
|
|
109
|
+
expect(parseCodeThreads("a.ts", doc("// H: a", "// A: b")).findings).toEqual([])
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe("stripCodeThreads", () => {
|
|
114
|
+
it("removes thread runs and keeps ordinary comments and code", () => {
|
|
115
|
+
const text = doc("// note", "code()", "// H: why?", "// A: because", "more()", "// plain")
|
|
116
|
+
expect(stripCodeThreads("a.ts", text)).toBe(doc("// note", "code()", "more()", "// plain"))
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it("keeps an ordinary comment written right below a thread's A: line", () => {
|
|
120
|
+
const text = doc("// H: why?", "// A: because", "// rename this", "code()")
|
|
121
|
+
expect(stripCodeThreads("a.ts", text)).toBe(doc("// rename this", "code()"))
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("leaves unscanned paths alone", () => {
|
|
125
|
+
expect(stripCodeThreads("a.md", "// H: x")).toBe("// H: x")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { SteeringFinding } from "./SteeringFormat.js"
|
|
2
|
+
import type { ThreadEntry } from "./Threads.js"
|
|
3
|
+
|
|
4
|
+
export interface CodeThread {
|
|
5
|
+
readonly path: string
|
|
6
|
+
/** 0-based line of the run's first comment line. */
|
|
7
|
+
readonly line: number
|
|
8
|
+
readonly endLine: number
|
|
9
|
+
readonly entries: readonly Pick<ThreadEntry, "author" | "text" | "line">[]
|
|
10
|
+
/** `"human"` means the last entry is the agent's: the thread is open. */
|
|
11
|
+
readonly waitingOn: "human" | "agent"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TOKENS = ["//", "#", "--", ";"] as const
|
|
15
|
+
type Token = (typeof TOKENS)[number]
|
|
16
|
+
|
|
17
|
+
const BY_EXTENSION: Readonly<Record<Token, readonly string[]>> = {
|
|
18
|
+
"//": [
|
|
19
|
+
"ts",
|
|
20
|
+
"tsx",
|
|
21
|
+
"js",
|
|
22
|
+
"jsx",
|
|
23
|
+
"mjs",
|
|
24
|
+
"cjs",
|
|
25
|
+
"mts",
|
|
26
|
+
"cts",
|
|
27
|
+
"go",
|
|
28
|
+
"rs",
|
|
29
|
+
"c",
|
|
30
|
+
"h",
|
|
31
|
+
"cc",
|
|
32
|
+
"cpp",
|
|
33
|
+
"hpp",
|
|
34
|
+
"java",
|
|
35
|
+
"kt",
|
|
36
|
+
"swift",
|
|
37
|
+
"cs",
|
|
38
|
+
"scala",
|
|
39
|
+
"php",
|
|
40
|
+
"dart",
|
|
41
|
+
],
|
|
42
|
+
"#": [
|
|
43
|
+
"feature",
|
|
44
|
+
"py",
|
|
45
|
+
"sh",
|
|
46
|
+
"bash",
|
|
47
|
+
"zsh",
|
|
48
|
+
"fish",
|
|
49
|
+
"rb",
|
|
50
|
+
"yaml",
|
|
51
|
+
"yml",
|
|
52
|
+
"toml",
|
|
53
|
+
"pl",
|
|
54
|
+
"r",
|
|
55
|
+
"ex",
|
|
56
|
+
"exs",
|
|
57
|
+
],
|
|
58
|
+
"--": ["sql", "lua", "hs"],
|
|
59
|
+
";": ["lisp", "clj", "cljs", "ini", "asm", "el", "scm"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The comment tokens a path is scanned for; none for `.md`/`.markdown` and `.gtd/**`. A file with no mapped extension is scanned for all four. */
|
|
63
|
+
const codeThreadTokens = (path: string): readonly Token[] => {
|
|
64
|
+
if (path === ".gtd" || path.startsWith(".gtd/")) return []
|
|
65
|
+
const base = path.slice(path.lastIndexOf("/") + 1)
|
|
66
|
+
const dot = base.lastIndexOf(".")
|
|
67
|
+
const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : ""
|
|
68
|
+
if (ext === "md" || ext === "markdown") return []
|
|
69
|
+
const own = TOKENS.filter((token) => BY_EXTENSION[token].includes(ext))
|
|
70
|
+
return own.length > 0 ? own : TOKENS
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PREFIX_RE = /^(H|A):\s*/
|
|
74
|
+
|
|
75
|
+
interface CommentLine {
|
|
76
|
+
readonly token: Token
|
|
77
|
+
readonly text: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A comment line's FIRST non-blank text is the token, so a trailing comment
|
|
81
|
+
// after code never matches.
|
|
82
|
+
const commentOf = (line: string, tokens: readonly Token[]): CommentLine | undefined => {
|
|
83
|
+
const trimmed = line.trimStart()
|
|
84
|
+
const token = tokens.find((candidate) => trimmed.startsWith(candidate))
|
|
85
|
+
return token === undefined ? undefined : { token, text: trimmed.slice(token.length).trim() }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Run {
|
|
89
|
+
readonly start: number
|
|
90
|
+
readonly end: number
|
|
91
|
+
readonly texts: readonly string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The agent's entry is one line, so an unprefixed comment below an `A:` line
|
|
95
|
+
// is an ordinary comment: absorbing it would let a reviewer's note vanish
|
|
96
|
+
// into the thread (and out of a review's code edits) when the thread is
|
|
97
|
+
// stripped.
|
|
98
|
+
const endsThreadRun = (texts: readonly string[], next: string): boolean =>
|
|
99
|
+
PREFIX_RE.test(texts[0]!) && texts[texts.length - 1]!.startsWith("A:") && !PREFIX_RE.test(next)
|
|
100
|
+
|
|
101
|
+
const runsOf = (lines: readonly string[], tokens: readonly Token[]): readonly Run[] => {
|
|
102
|
+
const runs: Run[] = []
|
|
103
|
+
let current: { start: number; token: Token; texts: string[] } | undefined
|
|
104
|
+
const close = (end: number): void => {
|
|
105
|
+
if (current) runs.push({ start: current.start, end, texts: current.texts })
|
|
106
|
+
current = undefined
|
|
107
|
+
}
|
|
108
|
+
lines.forEach((line, i) => {
|
|
109
|
+
const comment = commentOf(line, tokens)
|
|
110
|
+
if (
|
|
111
|
+
!comment ||
|
|
112
|
+
(current && (current.token !== comment.token || endsThreadRun(current.texts, comment.text)))
|
|
113
|
+
) {
|
|
114
|
+
close(i - 1)
|
|
115
|
+
}
|
|
116
|
+
if (!comment) return
|
|
117
|
+
if (current) current.texts.push(comment.text)
|
|
118
|
+
else current = { start: i, token: comment.token, texts: [comment.text] }
|
|
119
|
+
})
|
|
120
|
+
close(lines.length - 1)
|
|
121
|
+
return runs
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const isThreadRun = (run: Run): boolean => PREFIX_RE.test(run.texts[0]!)
|
|
125
|
+
|
|
126
|
+
/** Every thread run of `content` (0-based lines), the scanner's one line-based walk. */
|
|
127
|
+
export const parseCodeThreads = (
|
|
128
|
+
path: string,
|
|
129
|
+
content: string,
|
|
130
|
+
): { readonly threads: readonly CodeThread[]; readonly findings: readonly SteeringFinding[] } => {
|
|
131
|
+
const tokens = codeThreadTokens(path)
|
|
132
|
+
if (tokens.length === 0) return { threads: [], findings: [] }
|
|
133
|
+
const lines = content.split(/\r?\n/)
|
|
134
|
+
const threads: CodeThread[] = []
|
|
135
|
+
const findings: SteeringFinding[] = []
|
|
136
|
+
for (const run of runsOf(lines, tokens).filter(isThreadRun)) {
|
|
137
|
+
const entries: { author: "me" | "agent"; text: string; line: number }[] = []
|
|
138
|
+
run.texts.forEach((text, offset) => {
|
|
139
|
+
const match = PREFIX_RE.exec(text)
|
|
140
|
+
const line = run.start + offset
|
|
141
|
+
if (match) {
|
|
142
|
+
entries.push({
|
|
143
|
+
author: match[1] === "H" ? "me" : "agent",
|
|
144
|
+
text: text.slice(match[0].length).trim(),
|
|
145
|
+
line,
|
|
146
|
+
})
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const last = entries[entries.length - 1]!
|
|
150
|
+
if (text !== "") last.text = last.text === "" ? text : `${last.text} ${text}`
|
|
151
|
+
})
|
|
152
|
+
const report = (message: string, entry: (typeof entries)[number]): void => {
|
|
153
|
+
findings.push({
|
|
154
|
+
message: `Code thread at ${path}:${run.start + 1}: ${message}`,
|
|
155
|
+
line: entry.line,
|
|
156
|
+
range: {
|
|
157
|
+
start: { line: entry.line, character: 0 },
|
|
158
|
+
end: { line: entry.line, character: (lines[entry.line] ?? "").length },
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
entries.forEach((entry, i) => {
|
|
163
|
+
const letter = entry.author === "me" ? "H" : "A"
|
|
164
|
+
if (entry.text === "") {
|
|
165
|
+
report(`empty "${letter}:" entry — write your own words there`, entry)
|
|
166
|
+
}
|
|
167
|
+
if (i === 0 && entry.author === "agent") {
|
|
168
|
+
report(`the agent never starts a thread — the first entry must be "H:"`, entry)
|
|
169
|
+
} else if (i > 0 && entries[i - 1]!.author === entry.author) {
|
|
170
|
+
report(`two consecutive "${letter}:" entries — entries must alternate`, entry)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
threads.push({
|
|
174
|
+
path,
|
|
175
|
+
line: run.start,
|
|
176
|
+
endLine: run.end,
|
|
177
|
+
entries,
|
|
178
|
+
waitingOn: entries[entries.length - 1]?.author === "agent" ? "human" : "agent",
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return { threads, findings }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `text` with every thread run removed; ordinary comments stay. */
|
|
185
|
+
export const stripCodeThreads = (path: string, text: string): string => {
|
|
186
|
+
const tokens = codeThreadTokens(path)
|
|
187
|
+
if (tokens.length === 0) return text
|
|
188
|
+
const lines = text.split("\n")
|
|
189
|
+
const drop = new Set<number>()
|
|
190
|
+
for (const run of runsOf(
|
|
191
|
+
lines.map((l) => l.replace(/\r$/, "")),
|
|
192
|
+
tokens,
|
|
193
|
+
).filter(isThreadRun)) {
|
|
194
|
+
for (let i = run.start; i <= run.end; i++) drop.add(i)
|
|
195
|
+
}
|
|
196
|
+
return lines.filter((_, i) => !drop.has(i)).join("\n")
|
|
197
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { applySteeringEdits } from "./Edits.js"
|
|
3
|
+
|
|
4
|
+
describe("applySteeringEdits", () => {
|
|
5
|
+
it("splices edits back-to-front so earlier offsets stay valid", () => {
|
|
6
|
+
const content = "abc\ndef\n"
|
|
7
|
+
const edits = [
|
|
8
|
+
{
|
|
9
|
+
range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } },
|
|
10
|
+
newText: "X",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } },
|
|
14
|
+
newText: "Y",
|
|
15
|
+
},
|
|
16
|
+
]
|
|
17
|
+
expect(applySteeringEdits(content, edits)).toBe("aXbc\nYdef\n")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SteeringEdit } from "./SteeringFormat.js"
|
|
2
|
+
|
|
3
|
+
/** Splices `edits` into `content`, sorted last-to-first so an earlier range's offset is never invalidated by a later edit. Positions are 0-based line/character over `\n`-split lines. */
|
|
4
|
+
export const applySteeringEdits = (content: string, edits: readonly SteeringEdit[]): string => {
|
|
5
|
+
const lines = content.split("\n")
|
|
6
|
+
const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
|
|
7
|
+
let offset = 0
|
|
8
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
9
|
+
return offset + pos.character
|
|
10
|
+
}
|
|
11
|
+
const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start))
|
|
12
|
+
let result = content
|
|
13
|
+
for (const edit of sorted) {
|
|
14
|
+
result =
|
|
15
|
+
result.slice(0, toOffset(edit.range.start)) +
|
|
16
|
+
edit.newText +
|
|
17
|
+
result.slice(toOffset(edit.range.end))
|
|
18
|
+
}
|
|
19
|
+
return result
|
|
20
|
+
}
|