@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.
Files changed (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43339 -55575
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,227 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Paragraph } from "mdast"
3
+ import { parseMarkdown } from "./Inline.fixture.js"
4
+ import { collapseImages, definitionsOf, joinInlineRuns, projectInline } from "./Inline.js"
5
+
6
+ const paragraphOf = (content: string): Paragraph => {
7
+ const tree = parseMarkdown(content)
8
+ return tree.children[0] as Paragraph
9
+ }
10
+
11
+ describe("projectInline", () => {
12
+ it("projects strong, emphasis, delete, code, link and image", () => {
13
+ const paragraph = paragraphOf(
14
+ "**bold** _em_ ~~gone~~ `code` [label](https://x.example/a) ![alt](https://x.example/b.png)\n",
15
+ )
16
+ expect(projectInline(paragraph.children)).toEqual([
17
+ { kind: "strong", children: [{ kind: "text", value: "bold" }] },
18
+ { kind: "text", value: " " },
19
+ { kind: "emphasis", children: [{ kind: "text", value: "em" }] },
20
+ { kind: "text", value: " " },
21
+ { kind: "delete", children: [{ kind: "text", value: "gone" }] },
22
+ { kind: "text", value: " " },
23
+ { kind: "code", value: "code" },
24
+ { kind: "text", value: " " },
25
+ {
26
+ kind: "link",
27
+ href: "https://x.example/a",
28
+ children: [{ kind: "text", value: "label" }],
29
+ },
30
+ { kind: "text", value: " " },
31
+ { kind: "image", src: "https://x.example/b.png", alt: "alt" },
32
+ ])
33
+ })
34
+
35
+ it("drops a footnote reference structurally", () => {
36
+ const paragraph = paragraphOf("See it[^note].\n\n[^note]: body\n")
37
+ const nodes = projectInline(paragraph.children)
38
+ expect(nodes).toEqual([
39
+ { kind: "text", value: "See it" },
40
+ { kind: "text", value: "." },
41
+ ])
42
+ })
43
+
44
+ it("projects a hard break as a single space", () => {
45
+ const paragraph = paragraphOf("one\\\ntwo\n")
46
+ expect(projectInline(paragraph.children)).toEqual([
47
+ { kind: "text", value: "one" },
48
+ { kind: "text", value: " " },
49
+ { kind: "text", value: "two" },
50
+ ])
51
+ })
52
+
53
+ it("pins 'markdown is never raw HTML': a real <script> tag projects as one text node holding the raw tag, and no inline kind can ever carry HTML", () => {
54
+ const paragraph = paragraphOf("before <script>window.__xss = true</script> after\n")
55
+ const nodes = projectInline(paragraph.children)
56
+ expect(nodes.every((n) => n.kind === "text")).toBe(true)
57
+ const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
58
+ expect(joined).toBe("before <script>window.__xss = true</script> after")
59
+ })
60
+
61
+ it("projects inline HTML as inert text, never a carrying kind", () => {
62
+ const paragraph = paragraphOf("before <script>window.__xss = true</script> after\n")
63
+ const nodes = projectInline(paragraph.children)
64
+ expect(nodes.some((n) => n.kind !== "text")).toBe(false)
65
+ const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
66
+ expect(joined).toContain("<script>window.__xss = true</script>")
67
+ })
68
+
69
+ it("rejects a javascript: link href, keeping the label's own styled children", () => {
70
+ const paragraph = paragraphOf("[click](javascript:alert(1))\n")
71
+ expect(projectInline(paragraph.children)).toEqual([{ kind: "text", value: "click" }])
72
+ })
73
+
74
+ it("rejects an entity-decoded javascript: link href too — mdast decodes link.url before this ever sees it", () => {
75
+ const paragraph = paragraphOf("[click](&#106;avascript:alert(1))\n")
76
+ const nodes = projectInline(paragraph.children)
77
+ expect(nodes.some((n) => n.kind === "link")).toBe(false)
78
+ })
79
+
80
+ it("degrades a rejected image to a text node carrying its alt", () => {
81
+ const paragraph = paragraphOf("![alt text](javascript:alert(1))\n")
82
+ expect(projectInline(paragraph.children)).toEqual([{ kind: "text", value: "alt text" }])
83
+ })
84
+
85
+ it("allows a scheme-less (relative) href/src, and mailto:", () => {
86
+ const paragraph = paragraphOf("[a](./src/x.ts#42) [b](mailto:x@example.com)\n")
87
+ const nodes = projectInline(paragraph.children)
88
+ expect(nodes.filter((n) => n.kind === "link")).toHaveLength(2)
89
+ })
90
+
91
+ // Spec feedback: the WHATWG URL parser strips every ASCII tab/CR/LF from
92
+ // the WHOLE input before it ever looks for a scheme, so a `javascript:`
93
+ // href broken up by one of those characters still executes on tap even
94
+ // though the raw string doesn't match `SCHEME_RE` at all.
95
+ it("rejects a javascript: href broken by an entity-decoded tab", () => {
96
+ const paragraph = paragraphOf("[a](java&#9;script:alert(1))\n")
97
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
98
+ })
99
+
100
+ it("rejects a javascript: href broken by a LEADING entity-decoded tab", () => {
101
+ const paragraph = paragraphOf("[a](&#9;javascript:alert(1))\n")
102
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
103
+ })
104
+
105
+ it("rejects a javascript: href broken by an entity-decoded newline", () => {
106
+ const paragraph = paragraphOf("[a](java&NewLine;script:alert(1))\n")
107
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
108
+ })
109
+
110
+ // Spec feedback: the WHATWG URL parser ALSO trims every leading (and
111
+ // trailing) C0 control or space off the whole input before it looks for a
112
+ // scheme — a second pass beyond the tab/CR/LF removal above. Missing it
113
+ // leaves a `javascript:` reachable via a leading space, a leading C0
114
+ // control other than tab/CR/LF, or an angle-bracket destination's own
115
+ // literal leading spaces.
116
+ it("rejects a javascript: href prefixed by an entity-decoded leading space", () => {
117
+ const paragraph = paragraphOf("[click](&#32;javascript:alert&#40;1&#41;)\n")
118
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
119
+ })
120
+
121
+ it("rejects a javascript: href prefixed by a literal C0 control (U+0001) inside an angle-bracket destination", () => {
122
+ // `&#1;` itself decodes to U+FFFD (the replacement character) in this
123
+ // parser, never surviving as literal U+0001 — a real C0 control reaches
124
+ // `link.url` intact only through an angle-bracket destination, which
125
+ // tolerates raw control bytes an entity reference here does not.
126
+ const paragraph = paragraphOf(`[click](<${String.fromCharCode(1)}javascript:alert(1)>)\n`)
127
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
128
+ })
129
+
130
+ it("rejects a javascript: href prefixed by an entity-decoded form feed (U+000C)", () => {
131
+ const paragraph = paragraphOf("[click](&#12;javascript:alert&#40;1&#41;)\n")
132
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
133
+ })
134
+
135
+ it("rejects a javascript: href prefixed by literal leading spaces inside an angle-bracket destination", () => {
136
+ const paragraph = paragraphOf("[click](< javascript:alert(1)>)\n")
137
+ expect(projectInline(paragraph.children).some((n) => n.kind === "link")).toBe(false)
138
+ })
139
+
140
+ it("the same leading-space/C0 hole is closed on image src too — one allowlist, both attributes", () => {
141
+ const paragraph = paragraphOf("![alt](&#32;javascript:alert&#40;1&#41;)\n")
142
+ expect(projectInline(paragraph.children)).toEqual([{ kind: "text", value: "alt" }])
143
+ })
144
+
145
+ // Spec feedback: R1 scopes in links/images with no reference-style
146
+ // exclusion, and a rejected/unresolved reference must degrade (keep its
147
+ // own label/alt) exactly like a rejected inline link/image — never vanish.
148
+ it("resolves a linkReference through its own definition, exactly like a bare link", () => {
149
+ const content = "See [the docs][1] here.\n\n[1]: https://example.com\n"
150
+ const paragraph = paragraphOf(content)
151
+ const nodes = projectInline(paragraph.children, definitionsOf(content))
152
+ expect(nodes).toContainEqual({
153
+ kind: "link",
154
+ href: "https://example.com",
155
+ children: [{ kind: "text", value: "the docs" }],
156
+ })
157
+ })
158
+
159
+ it("degrades an UNRESOLVED linkReference (a real node whose identifier the given definitions map doesn't carry) to its own label text, never dropping it", () => {
160
+ const content = "See [the docs][1] here.\n\n[1]: https://example.com\n"
161
+ const paragraph = paragraphOf(content)
162
+ // No `definitions` argument at all — the default empty map, mirroring a
163
+ // caller that forgot to thread `definitionsOf` through.
164
+ const nodes = projectInline(paragraph.children)
165
+ const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
166
+ expect(joined).toBe("See the docs here.")
167
+ })
168
+
169
+ it("resolves an imageReference through its own definition, exactly like a bare image", () => {
170
+ const content = "An ![pic][2] there.\n\n[2]: https://example.com/p.png\n"
171
+ const paragraph = paragraphOf(content)
172
+ const nodes = projectInline(paragraph.children, definitionsOf(content))
173
+ expect(nodes).toContainEqual({ kind: "image", src: "https://example.com/p.png", alt: "pic" })
174
+ })
175
+
176
+ it("degrades an UNRESOLVED imageReference (a real node whose identifier the given definitions map doesn't carry) to its own alt text, never dropping it", () => {
177
+ const content = "An ![pic][2] there.\n\n[2]: https://example.com/p.png\n"
178
+ const paragraph = paragraphOf(content)
179
+ const nodes = projectInline(paragraph.children)
180
+ const joined = nodes.map((n) => ("value" in n ? n.value : "")).join("")
181
+ expect(joined).toBe("An pic there.")
182
+ })
183
+
184
+ it("rejects a linkReference resolving to a javascript: url, degrading to its own label", () => {
185
+ const content = "See [click][x] here.\n\n[x]: javascript:alert(1)\n"
186
+ const paragraph = paragraphOf(content)
187
+ const nodes = projectInline(paragraph.children, definitionsOf(content))
188
+ expect(nodes.some((n) => n.kind === "link")).toBe(false)
189
+ expect(nodes.map((n) => ("value" in n ? n.value : "")).join("")).toBe("See click here.")
190
+ })
191
+
192
+ // Spec feedback: `stripMarkerText` (the string-flattening path's own
193
+ // regex) strips EVERY `[^name]`-shaped run, defined or not — a dangling
194
+ // marker with no matching definition never becomes a real
195
+ // `footnoteReference` node, so it must be stripped here too, or it leaks
196
+ // into `inline`/`detailInline` for the first time.
197
+ it("strips a DANGLING [^name] marker (no matching definition) from plain text, matching stripMarkerText's outcome", () => {
198
+ const paragraph = paragraphOf("Dangling marker[^n1] here.\n")
199
+ expect(projectInline(paragraph.children)).toEqual([
200
+ { kind: "text", value: "Dangling marker here." },
201
+ ])
202
+ })
203
+ })
204
+
205
+ describe("collapseImages", () => {
206
+ it("replaces an image, at any depth, with a text node carrying its alt", () => {
207
+ const nested = [
208
+ { kind: "strong" as const, children: [{ kind: "image" as const, src: "x", alt: "pic" }] },
209
+ ]
210
+ expect(collapseImages(nested)).toEqual([
211
+ { kind: "strong", children: [{ kind: "text", value: "pic" }] },
212
+ ])
213
+ })
214
+ })
215
+
216
+ describe("joinInlineRuns", () => {
217
+ it("joins non-empty runs with a single space text separator, dropping empty runs", () => {
218
+ const a = [{ kind: "text" as const, value: "a" }]
219
+ const b: readonly (typeof a)[number][] = []
220
+ const c = [{ kind: "text" as const, value: "c" }]
221
+ expect(joinInlineRuns([a, b, c])).toEqual([
222
+ { kind: "text", value: "a" },
223
+ { kind: "text", value: " " },
224
+ { kind: "text", value: "c" },
225
+ ])
226
+ })
227
+ })
@@ -0,0 +1,195 @@
1
+ import type { Definition, Node, PhrasingContent } from "mdast"
2
+ import { parseMarkdown } from "./MarkdownTree.js"
3
+
4
+ /**
5
+ * The server-side projection of a run of mdast inline (phrasing) content —
6
+ * the shape the client's shared renderer (`src/web/screens/InlineRun.tsx`)
7
+ * walks instead of a string. Deliberately closed to these seven kinds: a
8
+ * `footnoteReference` is dropped (see `projectInline`), a hard `break`
9
+ * collapses to a single space `text` node, and an inline `html` node
10
+ * projects as `text` carrying its raw source verbatim — no kind here can
11
+ * ever carry raw HTML, which is what pins "markdown is never raw HTML" at
12
+ * the type level as well as by test.
13
+ */
14
+ export type InlineNode =
15
+ | { readonly kind: "text"; readonly value: string }
16
+ | { readonly kind: "strong"; readonly children: readonly InlineNode[] }
17
+ | { readonly kind: "emphasis"; readonly children: readonly InlineNode[] }
18
+ | { readonly kind: "delete"; readonly children: readonly InlineNode[] }
19
+ | { readonly kind: "code"; readonly value: string }
20
+ | { readonly kind: "link"; readonly href: string; readonly children: readonly InlineNode[] }
21
+ | { readonly kind: "image"; readonly src: string; readonly alt: string }
22
+
23
+ /**
24
+ * A `link`'s `href`/an `image`'s `src` passes only when it carries NO scheme
25
+ * at all (relative, `./src/x.ts#42`, a bare fragment) or a scheme matching
26
+ * `https?`/`mailto` case-insensitively — one allowlist for both attributes,
27
+ * no per-attribute branch. `url` here is already entity-decoded by mdast
28
+ * (`link.url`/`image.url`), which is exactly what makes this check catch
29
+ * `&#106;avascript:` too — a client-side regex over raw source never sees
30
+ * the decoded form at all.
31
+ */
32
+ const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/
33
+ const ALLOWED_SCHEME_RE = /^(https?|mailto)$/i
34
+
35
+ /**
36
+ * Mirrors the two whitespace-removal passes the WHATWG URL parser — what a
37
+ * real `<a href>`/`<img src>` actually runs through — applies to the WHOLE
38
+ * input before it ever looks for a scheme: every ASCII tab/LF/CR is removed
39
+ * from anywhere in the string, and every LEADING (and trailing) C0 control
40
+ * (`U+0000`–`U+001F`) or space is trimmed off the ends. Skipping either pass
41
+ * leaves a `javascript:` reachable here: `java&#9;script:alert(1)` needs the
42
+ * first, a leading `&#32;`/`&#1;`/form-feed-prefixed `javascript:` (or an
43
+ * angle-bracket destination's own literal leading spaces) needs the second —
44
+ * both read as "no scheme, so allowed" here while the browser reads the
45
+ * stripped `javascript:alert(1)` and executes it.
46
+ */
47
+ const stripUrlWhitespace = (url: string): string =>
48
+ url
49
+ .replace(/[\t\n\r]/g, "")
50
+ // eslint-disable-next-line no-control-regex -- the C0 control range IS the point: mirrors the WHATWG URL parser's own leading/trailing trim.
51
+ .replace(/^[\x00-\x1f ]+|[\x00-\x1f ]+$/g, "")
52
+
53
+ const isAllowedUrl = (url: string): boolean => {
54
+ const match = SCHEME_RE.exec(stripUrlWhitespace(url))
55
+ return match === undefined || match === null || ALLOWED_SCHEME_RE.test(match[1]!)
56
+ }
57
+
58
+ /**
59
+ * A marker's shape once it's plain text — `[^name]`, no whitespace, no `]` —
60
+ * mirrors `Blocks.ts`'s own `MARKER_TEXT_RE` exactly (duplicated, not
61
+ * imported: that one is private to the string-flattening path this module
62
+ * doesn't otherwise touch). GFM only turns `[^name]` into a real
63
+ * `footnoteReference` NODE when a matching definition exists; a dangling
64
+ * marker with no definition never becomes one, so it survives as plain
65
+ * `text` — this strips it there too, so `inline`/`detailInline` drop a
66
+ * dangling marker the same way `stripMarkerText` already drops it from
67
+ * `title`/`detail`, instead of leaking it into the UI for the first time.
68
+ */
69
+ const MARKER_TEXT_RE = /\[\^([^\s\]]+)\]/g
70
+
71
+ /**
72
+ * One `identifier` (mdast's own normalized form: markdown whitespace
73
+ * collapsed, trimmed, case-folded) → the `definition` node it resolves to —
74
+ * what a `linkReference`/`imageReference` needs to become a real `link`/
75
+ * `image` instead of silently losing its own visible label/alt. Walks the
76
+ * WHOLE tree, not just `tree.children`: a definition can sit anywhere
77
+ * CommonMark allows one, not only at the document's top level.
78
+ */
79
+ const collectDefinitions = (node: Node, into: Map<string, Definition>): void => {
80
+ if (node.type === "definition") into.set((node as Definition).identifier, node as Definition)
81
+ const children = (node as { children?: readonly Node[] }).children
82
+ if (children) children.forEach((child) => collectDefinitions(child, into))
83
+ }
84
+
85
+ /** Every link/image reference definition in `content`, by normalized identifier — built off `parseMarkdown`'s own memoized tree, so calling this alongside another same-content call in the same pass never re-parses. */
86
+ export const definitionsOf = (content: string): ReadonlyMap<string, Definition> => {
87
+ const map = new Map<string, Definition>()
88
+ collectDefinitions(parseMarkdown(content), map)
89
+ return map
90
+ }
91
+
92
+ type Definitions = ReadonlyMap<string, Definition>
93
+
94
+ /** A resolved `link`/`linkReference` — real `href` (already allowlist-checked) plus its own label children — or `undefined` when there is none to resolve to (no url at all, or an unresolved reference). */
95
+ const resolveLink = (
96
+ url: string | undefined,
97
+ labelChildren: readonly PhrasingContent[],
98
+ definitions: Definitions,
99
+ ): readonly InlineNode[] => {
100
+ const children = projectInline(labelChildren, definitions)
101
+ if (url !== undefined && isAllowedUrl(url)) return [{ kind: "link", href: url, children }]
102
+ return children
103
+ }
104
+
105
+ /** A resolved `image`/`imageReference` — real `src` (already allowlist-checked) plus `alt` — or a degrade to plain `alt` text when there is nothing to resolve to. */
106
+ const resolveImage = (url: string | undefined, alt: string): readonly InlineNode[] =>
107
+ url !== undefined && isAllowedUrl(url)
108
+ ? [{ kind: "image", src: url, alt }]
109
+ : [{ kind: "text", value: alt }]
110
+
111
+ /** One recursive `children: readonly InlineNode[]` wrap — `strong`/`emphasis`/`delete` share this exact shape. */
112
+ const projectWrapped = (
113
+ kind: "strong" | "emphasis" | "delete",
114
+ children: readonly PhrasingContent[],
115
+ definitions: Definitions,
116
+ ): readonly InlineNode[] => [{ kind, children: projectInline(children, definitions) }]
117
+
118
+ /**
119
+ * One `PhrasingContent["type"]` → the `InlineNode`s it projects to (0, 1, or
120
+ * — for a rejected/unresolved link — its own several label children) — a
121
+ * lookup table, not a `switch`, so `projectInline` itself stays a single
122
+ * dispatch with no per-case branch of its own (mirrors `ProseBlock.tsx`'s
123
+ * own `BLOCK_RENDERERS`/`InlineRun.tsx`'s own `INLINE_RENDERERS`). A kind
124
+ * with no entry (there is none left; every `PhrasingContent` member is
125
+ * covered) projects to nothing.
126
+ */
127
+ const NODE_PROJECTORS: {
128
+ readonly [K in PhrasingContent["type"]]: (
129
+ child: Extract<PhrasingContent, { readonly type: K }>,
130
+ definitions: Definitions,
131
+ ) => readonly InlineNode[]
132
+ } = {
133
+ text: (child) => [{ kind: "text", value: child.value.replace(MARKER_TEXT_RE, "") }],
134
+ strong: (child, definitions) => projectWrapped("strong", child.children, definitions),
135
+ emphasis: (child, definitions) => projectWrapped("emphasis", child.children, definitions),
136
+ delete: (child, definitions) => projectWrapped("delete", child.children, definitions),
137
+ inlineCode: (child) => [{ kind: "code", value: child.value }],
138
+ break: () => [{ kind: "text", value: " " }],
139
+ html: (child) => [{ kind: "text", value: child.value }],
140
+ footnoteReference: () => [],
141
+ link: (child, definitions) => resolveLink(child.url, child.children, definitions),
142
+ image: (child) => resolveImage(child.url, child.alt ?? ""),
143
+ linkReference: (child, definitions) =>
144
+ resolveLink(definitions.get(child.identifier)?.url, child.children, definitions),
145
+ imageReference: (child, definitions) =>
146
+ resolveImage(definitions.get(child.identifier)?.url, child.alt ?? ""),
147
+ }
148
+
149
+ /**
150
+ * Projects one run of mdast `PhrasingContent` (a paragraph's/heading's own
151
+ * `children`, or any other phrasing-content array) into `InlineNode`s.
152
+ * `footnoteReference` is dropped outright — gtd's own review-note protocol,
153
+ * never document content — and a rejected `link`/`image` degrades rather
154
+ * than disappearing: a rejected link keeps its own (still-styled) label
155
+ * children, a rejected image keeps only its alt text. An inline `html` node
156
+ * (a real tag typed into prose) becomes a `text` node carrying its raw
157
+ * source verbatim — never parsed, never a carrier for markup in the model.
158
+ *
159
+ * `definitions` (`definitionsOf`) resolves a `linkReference`/`imageReference`
160
+ * (`[label][id]`/`![alt][id]`) to its own `link`/`image` the same way a
161
+ * bare `[label](url)` resolves — an UNRESOLVED reference (no matching
162
+ * definition, or the caller passed none) degrades exactly like a
163
+ * scheme-rejected one: the label's own children for a link, bare alt text
164
+ * for an image — never silently dropped.
165
+ */
166
+ export const projectInline = (
167
+ children: readonly PhrasingContent[],
168
+ definitions: Definitions = new Map(),
169
+ ): readonly InlineNode[] =>
170
+ children.flatMap((child) => {
171
+ const project = NODE_PROJECTORS[child.type] as (
172
+ child: PhrasingContent,
173
+ definitions: Definitions,
174
+ ) => readonly InlineNode[]
175
+ return project(child, definitions)
176
+ })
177
+
178
+ /** `nodes`, recursively, with every `image` replaced by a `text` node carrying its alt — the compact-row rule (R2): an image collapses to alt text server-side so a compact row can never fire a remote fetch. */
179
+ export const collapseImages = (nodes: readonly InlineNode[]): readonly InlineNode[] =>
180
+ nodes.map((node) => {
181
+ if (node.kind === "image") return { kind: "text" as const, value: node.alt }
182
+ if ("children" in node) return { ...node, children: collapseImages(node.children) }
183
+ return node
184
+ })
185
+
186
+ /** Joins several already-projected inline runs with a single `text: " "` separator, dropping empty runs entirely — mirrors `Blocks.ts`'s own `.join(" ")` flattening, structurally rather than on strings. */
187
+ export const joinInlineRuns = (runs: readonly (readonly InlineNode[])[]): readonly InlineNode[] => {
188
+ const nonEmpty = runs.filter((run) => run.length > 0)
189
+ const result: InlineNode[] = []
190
+ nonEmpty.forEach((run, index) => {
191
+ if (index > 0) result.push({ kind: "text", value: " " })
192
+ result.push(...run)
193
+ })
194
+ return result
195
+ }
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import fc from "fast-check"
3
+ import {
4
+ blockNodeAt,
5
+ getParseCount,
6
+ headingSectionBodies,
7
+ headingSections,
8
+ headingText,
9
+ parseMarkdown,
10
+ sourceText,
11
+ taskItems,
12
+ toLspPosition,
13
+ toLspPositionFromOffset,
14
+ } from "./MarkdownTree.js"
15
+ import { steeringFormatFor } from "./index.js"
16
+ import type { Heading, List, ListItem, Paragraph } from "mdast"
17
+
18
+ describe("parseMarkdown", () => {
19
+ it("calls both GFM extensions: a task checkbox parses to checked === true, not null", () => {
20
+ const tree = parseMarkdown("- [x] a\n")
21
+ const list = tree.children[0] as List
22
+ const item = list.children[0] as ListItem
23
+ expect(item.checked).toBe(true)
24
+ })
25
+
26
+ it("parses strikethrough and a bare-URL autolink into real nodes", () => {
27
+ const tree = parseMarkdown("~~gone~~ and https://example.com/x\n")
28
+ const paragraph = tree.children[0] as Paragraph
29
+ expect(paragraph.children.some((c) => c.type === "delete")).toBe(true)
30
+ expect(
31
+ paragraph.children.some((c) => c.type === "link" && c.url === "https://example.com/x"),
32
+ ).toBe(true)
33
+ })
34
+
35
+ it("wiring the two new extensions leaves every flattened string byte-identical — sourceText and headingText slice raw source, not the parsed tree", () => {
36
+ const content =
37
+ "# A ~~gone~~ heading https://example.com/x\n\nSome ~~gone~~ text https://example.com/x here.\n"
38
+ const tree = parseMarkdown(content)
39
+ const heading = tree.children[0] as Heading
40
+ const paragraph = tree.children[1]!
41
+ expect(headingText(content, heading)).toBe("A ~~gone~~ heading https://example.com/x")
42
+ expect(sourceText(content, paragraph)).toBe("Some ~~gone~~ text https://example.com/x here.")
43
+ })
44
+
45
+ it("never throws and always returns a root, for arbitrary strings", () => {
46
+ fc.assert(
47
+ fc.property(fc.string(), (content) => {
48
+ const tree = parseMarkdown(content)
49
+ expect(tree.type).toBe("root")
50
+ }),
51
+ )
52
+ })
53
+
54
+ it("converts a node starting at source line 1 column 1 to LSP line 0 character 0", () => {
55
+ const tree = parseMarkdown("hello\n")
56
+ const paragraph = tree.children[0]!
57
+ // Sanity: mdast itself is 1-based — if this ever isn't true, the real
58
+ // assertion below (going through `toLspPosition`) would trivially pass
59
+ // for the wrong reason.
60
+ expect(paragraph.position!.start).toEqual({ line: 1, column: 1, offset: 0 })
61
+ expect(toLspPosition(paragraph.position!.start)).toEqual({ line: 0, character: 0 })
62
+ })
63
+
64
+ describe("memo", () => {
65
+ it("returns the identical tree object for two calls with the same string, advancing the counter once", () => {
66
+ const content = `unique content ${Math.PI}`
67
+ const before = getParseCount()
68
+ const first = parseMarkdown(content)
69
+ const second = parseMarkdown(content)
70
+ expect(second).toBe(first)
71
+ expect(getParseCount()).toBe(before + 1)
72
+ })
73
+
74
+ it("evicts the previous entry for a different string, advancing the counter again", () => {
75
+ const a = `content a ${Math.PI}`
76
+ const b = `content b ${Math.PI}`
77
+ const treeA = parseMarkdown(a)
78
+ const before = getParseCount()
79
+ const treeB = parseMarkdown(b)
80
+ expect(treeB).not.toBe(treeA)
81
+ expect(getParseCount()).toBe(before + 1)
82
+ })
83
+
84
+ it("holds one entry, not two: re-parsing the evicted string reparses rather than serving a stale cache hit", () => {
85
+ const a = `content c ${Math.PI}`
86
+ const b = `content d ${Math.PI}`
87
+ const firstA = parseMarkdown(a)
88
+ parseMarkdown(b) // evicts `a`
89
+ const before = getParseCount()
90
+ const secondA = parseMarkdown(a)
91
+ expect(getParseCount()).toBe(before + 1) // reparsed, not a cache hit
92
+ expect(secondA).not.toBe(firstA) // a fresh tree object, not the old one
93
+ })
94
+
95
+ it("performs one parse for a 2000-line document, not one per line", () => {
96
+ const content = Array.from({ length: 2000 }, (_, i) => `line ${i}`).join("\n")
97
+ const before = getParseCount()
98
+ parseMarkdown(content)
99
+ expect(getParseCount()).toBe(before + 1)
100
+ })
101
+
102
+ it("performs exactly one parse across one qa validate() call — the structural pass and the footnote pass share it", () => {
103
+ const content = [
104
+ "Build a thing.",
105
+ ...Array.from({ length: 1990 }, (_, i) => `Line ${i} of padding prose.`),
106
+ "",
107
+ "## Open Questions",
108
+ "",
109
+ "### Which backend?",
110
+ "",
111
+ "- [x] SQLite",
112
+ "- [ ] Postgres",
113
+ "",
114
+ ].join("\n")
115
+ const before = getParseCount()
116
+ steeringFormatFor("qa")!.validate(content)
117
+ expect(getParseCount()).toBe(before + 1)
118
+ })
119
+ })
120
+ })
121
+
122
+ describe("toLspPositionFromOffset", () => {
123
+ it("is line 0 character 0 for offset 0", () => {
124
+ expect(toLspPositionFromOffset("abc\ndef", 0)).toEqual({ line: 0, character: 0 })
125
+ })
126
+
127
+ it("lands on the second line after a newline", () => {
128
+ const content = "abc\ndef"
129
+ expect(toLspPositionFromOffset(content, content.indexOf("def"))).toEqual({
130
+ line: 1,
131
+ character: 0,
132
+ })
133
+ })
134
+
135
+ it("counts characters within a later line correctly", () => {
136
+ const content = "abc\ndefgh"
137
+ expect(toLspPositionFromOffset(content, content.indexOf("gh"))).toEqual({
138
+ line: 1,
139
+ character: 3,
140
+ })
141
+ })
142
+ })
143
+
144
+ describe("sourceText", () => {
145
+ it("keeps inline code verbatim and drops only a real footnote reference", () => {
146
+ const content = "A claim `a[^fn1]b` and a real [^fn2] ref.\n\n[^fn2]: def\n"
147
+ const tree = parseMarkdown(content)
148
+ const paragraph = tree.children[0] as Paragraph
149
+ expect(sourceText(content, paragraph)).toBe("A claim `a[^fn1]b` and a real ref.")
150
+ })
151
+
152
+ it("collapses a multi-line paragraph's whitespace to single spaces", () => {
153
+ const content = "one\ntwo\nthree\n"
154
+ const tree = parseMarkdown(content)
155
+ expect(sourceText(content, tree.children[0]!)).toBe("one two three")
156
+ })
157
+
158
+ it("keeps a link and emphasis verbatim, alongside a dropped footnote reference", () => {
159
+ const content = "See [the docs](https://example.com) for *why*[^fn1].\n\n[^fn1]: def\n"
160
+ const tree = parseMarkdown(content)
161
+ const paragraph = tree.children[0] as Paragraph
162
+ expect(sourceText(content, paragraph)).toBe("See [the docs](https://example.com) for *why*.")
163
+ })
164
+ })
165
+
166
+ describe("blockNodeAt", () => {
167
+ it("finds the top-level block containing a 0-based line", () => {
168
+ const content = "# heading\n\nsome paragraph text\n"
169
+ const tree = parseMarkdown(content)
170
+ expect(blockNodeAt(tree, 0)?.type).toBe("heading")
171
+ expect(blockNodeAt(tree, 2)?.type).toBe("paragraph")
172
+ })
173
+
174
+ it("is undefined past the end of the document", () => {
175
+ const tree = parseMarkdown("one line\n")
176
+ expect(blockNodeAt(tree, 50)).toBeUndefined()
177
+ })
178
+ })
179
+
180
+ describe("taskItems", () => {
181
+ it("returns both items for a nested two-space-indented task list item", () => {
182
+ const content = "- [x] a\n - [x] b\n"
183
+ const tree = parseMarkdown(content)
184
+ const items = taskItems(tree)
185
+ expect(items).toHaveLength(2)
186
+ expect(items.every((i) => i.checked === true)).toBe(true)
187
+ })
188
+
189
+ it("excludes ordinary (non-task) list items", () => {
190
+ const content = "- a\n- [x] b\n"
191
+ const tree = parseMarkdown(content)
192
+ expect(taskItems(tree)).toHaveLength(1)
193
+ })
194
+ })
195
+
196
+ describe("headingSections", () => {
197
+ it("returns every top-level `## ` heading's text, in document order", () => {
198
+ const content = "# Title\n\n## First\n\nbody\n\n## Second\n\nmore body\n"
199
+ expect(headingSections(content)).toEqual(["First", "Second"])
200
+ })
201
+
202
+ it("excludes a `# ` (depth 1) and a `### ` (depth 3) heading — depth 2 only, by default", () => {
203
+ const content = "# Top\n\n## Section\n\n### Nested\n"
204
+ expect(headingSections(content)).toEqual(["Section"])
205
+ })
206
+
207
+ it("is empty for a package file with no `## ` sections — the fail-open case a dynamic-count judge template must detect", () => {
208
+ expect(headingSections("# Title\n\njust prose, no sections\n")).toEqual([])
209
+ })
210
+
211
+ it("takes an explicit depth", () => {
212
+ const content = "## Two\n\n### Three\n"
213
+ expect(headingSections(content, 3)).toEqual(["Three"])
214
+ })
215
+ })
216
+
217
+ describe("headingSectionBodies", () => {
218
+ it("finds headings the way the parser does — doubled spaces, a closing run, indentation", () => {
219
+ const content =
220
+ "## Spaced Title\nspaced body\n\n## Closed ##\nclosed body\n\n ## Indented\nindented body\n"
221
+ expect(headingSectionBodies(content)).toEqual([
222
+ { title: "Spaced Title", body: "## Spaced Title\nspaced body" },
223
+ { title: "Closed", body: "## Closed ##\nclosed body" },
224
+ { title: "Indented", body: " ## Indented\nindented body" },
225
+ ])
226
+ })
227
+
228
+ it("never ends a section at a plain line that happens to equal a title", () => {
229
+ const content = "## Plan\nPlan\nstill the plan\n\n## Next\nnext body\n"
230
+ expect(headingSectionBodies(content)[0]!.body).toBe("## Plan\nPlan\nstill the plan")
231
+ })
232
+
233
+ it("keeps two same-titled sections apart", () => {
234
+ const content = "## Note\nfirst\n\n## Note\nsecond\n"
235
+ expect(headingSectionBodies(content).map((s) => s.body)).toEqual([
236
+ "## Note\nfirst",
237
+ "## Note\nsecond",
238
+ ])
239
+ })
240
+
241
+ it("ends a section at a higher-level heading, and ignores a heading inside a code fence", () => {
242
+ const content = "## One\n```\n## not a heading\n```\n# Top\ntop body\n"
243
+ expect(headingSectionBodies(content)).toEqual([
244
+ { title: "One", body: "## One\n```\n## not a heading\n```" },
245
+ ])
246
+ })
247
+ })