@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,977 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import fc from "fast-check"
|
|
3
|
+
import {
|
|
4
|
+
footnoteAdditionEdits,
|
|
5
|
+
footnoteAttachEdits,
|
|
6
|
+
footnotePointerAt,
|
|
7
|
+
isOnExistingFootnote,
|
|
8
|
+
nextFootnoteName,
|
|
9
|
+
parseFootnotes,
|
|
10
|
+
openThreadFindings,
|
|
11
|
+
parseThreads,
|
|
12
|
+
threadReplyActions,
|
|
13
|
+
} from "./Footnotes.js"
|
|
14
|
+
|
|
15
|
+
/** Applies a list of `SteeringEdit`s to `content`, last-to-first so earlier offsets stay valid — used only by tests to assert the resulting document, never by production code (which always splices via LSP tooling). */
|
|
16
|
+
const applyEdits = (
|
|
17
|
+
content: string,
|
|
18
|
+
edits: readonly {
|
|
19
|
+
range: { start: { line: number; character: number }; end: { line: number; character: number } }
|
|
20
|
+
newText: string
|
|
21
|
+
}[],
|
|
22
|
+
): string => {
|
|
23
|
+
const lines = content.split("\n")
|
|
24
|
+
const toOffset = (pos: { line: number; character: number }): number => {
|
|
25
|
+
let offset = 0
|
|
26
|
+
for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
|
|
27
|
+
return offset + pos.character
|
|
28
|
+
}
|
|
29
|
+
const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start))
|
|
30
|
+
let result = content
|
|
31
|
+
for (const edit of sorted) {
|
|
32
|
+
const start = toOffset(edit.range.start)
|
|
33
|
+
const end = toOffset(edit.range.end)
|
|
34
|
+
result = result.slice(0, start) + edit.newText + result.slice(end)
|
|
35
|
+
}
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("parseFootnotes", () => {
|
|
40
|
+
it("is total on an empty string", () => {
|
|
41
|
+
expect(() => parseFootnotes("")).not.toThrow()
|
|
42
|
+
expect(parseFootnotes("")).toEqual({ markers: [], definitions: [], findings: [] })
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("never throws on arbitrary input", () => {
|
|
46
|
+
fc.assert(
|
|
47
|
+
fc.property(fc.string(), (content) => {
|
|
48
|
+
expect(() => parseFootnotes(content)).not.toThrow()
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("is total on a document of only backticks", () => {
|
|
54
|
+
expect(() => parseFootnotes("```````````")).not.toThrow()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("yields the exact 0-based column of a marker mid-sentence", () => {
|
|
58
|
+
const content = "Some prose, a claim[^fn1], continues.\n\n[^fn1]: the reason\n"
|
|
59
|
+
const { markers } = parseFootnotes(content)
|
|
60
|
+
expect(markers).toEqual([{ name: "fn1", line: 0, character: 19, endCharacter: 25 }])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("parses a single-line definition with endLine === line and its body", () => {
|
|
64
|
+
const content = "text[^fn1]\n\n[^fn1]: a short reason\n"
|
|
65
|
+
const { definitions } = parseFootnotes(content)
|
|
66
|
+
expect(definitions).toEqual([{ name: "fn1", line: 2, endLine: 2, body: "a short reason" }])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("joins four-space-indented continuation lines and tracks endLine", () => {
|
|
70
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " first line of body", " second line"].join(
|
|
71
|
+
"\n",
|
|
72
|
+
)
|
|
73
|
+
const { definitions } = parseFootnotes(content)
|
|
74
|
+
expect(definitions).toEqual([
|
|
75
|
+
{ name: "fn1", line: 2, endLine: 4, body: "first line of body second line" },
|
|
76
|
+
])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("does not treat a two-space indent as a continuation — GFM requires four spaces, unlike the old hand-rolled walker", () => {
|
|
80
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " first", " second"].join("\n")
|
|
81
|
+
const { definitions } = parseFootnotes(content)
|
|
82
|
+
expect(definitions).toEqual([{ name: "fn1", line: 2, endLine: 2, body: "" }])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("raises the indent finding (not the empty-body one) for a definition whose continuation is indented under 4 spaces, even though a human wrote real words there — the indent, not the words, decides membership", () => {
|
|
86
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " a human wrote this comment here"].join("\n")
|
|
87
|
+
const { definitions, findings } = parseFootnotes(content)
|
|
88
|
+
expect(definitions).toEqual([{ name: "fn1", line: 2, endLine: 2, body: "" }])
|
|
89
|
+
expect(findings).toContainEqual({
|
|
90
|
+
message:
|
|
91
|
+
'Footnote definition "[^fn1]": the text below it is indented too little to belong to it — indent continuation lines by 4 spaces',
|
|
92
|
+
line: 2,
|
|
93
|
+
range: { start: { line: 2, character: 0 }, end: { line: 3, character: 32 } },
|
|
94
|
+
})
|
|
95
|
+
expect(findings.some((f) => f.message.includes("has an empty body"))).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("raises the indent message and never the empty-body one, and exactly one finding, for a definition followed by words indented 1-3 spaces", () => {
|
|
99
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " stranded words here"].join("\n")
|
|
100
|
+
const { findings } = parseFootnotes(content)
|
|
101
|
+
expect(findings).toHaveLength(1)
|
|
102
|
+
expect(findings[0]!.message).toBe(
|
|
103
|
+
'Footnote definition "[^fn1]": the text below it is indented too little to belong to it — indent continuation lines by 4 spaces',
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("spans the indent finding's range through the stranded paragraph's own last line when it wraps several lines", () => {
|
|
108
|
+
const content = [
|
|
109
|
+
"text[^fn1]",
|
|
110
|
+
"",
|
|
111
|
+
"[^fn1]:",
|
|
112
|
+
" first stranded line",
|
|
113
|
+
" second stranded line",
|
|
114
|
+
].join("\n")
|
|
115
|
+
const { findings } = parseFootnotes(content)
|
|
116
|
+
expect(findings).toHaveLength(1)
|
|
117
|
+
expect(findings[0]!.range).toEqual({
|
|
118
|
+
start: { line: 2, character: 0 },
|
|
119
|
+
end: { line: 4, character: " second stranded line".length },
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("still raises has an empty body for a definition followed by a non-blank line at 0 indent", () => {
|
|
124
|
+
const content = ["text[^fn1]", "", "[^fn1]:", "not indented at all"].join("\n")
|
|
125
|
+
const { findings } = parseFootnotes(content)
|
|
126
|
+
expect(findings).toHaveLength(1)
|
|
127
|
+
expect(findings[0]!.message).toBe('Footnote definition "[^fn1]" has an empty body')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("raises has an empty body for [^a] (never the indent message) when the indented next line is actually [^b]'s own definition", () => {
|
|
131
|
+
const content = ["text[^fn1] text[^fn2]", "", "[^fn1]:", " [^fn2]: text"].join("\n")
|
|
132
|
+
const { findings } = parseFootnotes(content)
|
|
133
|
+
const fn1Findings = findings.filter((f) => f.message.includes('"[^fn1]"'))
|
|
134
|
+
expect(fn1Findings).toEqual([
|
|
135
|
+
{
|
|
136
|
+
message: 'Footnote definition "[^fn1]" has an empty body',
|
|
137
|
+
line: 2,
|
|
138
|
+
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 7 } },
|
|
139
|
+
},
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("raises neither finding for a definition followed by a tab-indented line — GFM joins it to the body", () => {
|
|
144
|
+
const content = ["text[^fn1]", "", "[^fn1]:", "\tstranded but joined by the tab"].join("\n")
|
|
145
|
+
const { definitions, findings } = parseFootnotes(content)
|
|
146
|
+
expect(definitions[0]!.body).toBe("stranded but joined by the tab")
|
|
147
|
+
expect(findings).toHaveLength(0)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("ends a definition at a blank line", () => {
|
|
151
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " first", "", "not part of it"].join("\n")
|
|
152
|
+
const { definitions } = parseFootnotes(content)
|
|
153
|
+
expect(definitions[0]!.endLine).toBe(3)
|
|
154
|
+
expect(definitions[0]!.body).toBe("first")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("keeps an unindented line as a LAZY continuation of the body paragraph, per real GFM paragraph continuation rules", () => {
|
|
158
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " first", "not indented, not part of it"].join(
|
|
159
|
+
"\n",
|
|
160
|
+
)
|
|
161
|
+
const { definitions } = parseFootnotes(content)
|
|
162
|
+
expect(definitions[0]!.endLine).toBe(4)
|
|
163
|
+
expect(definitions[0]!.body).toBe("first not indented, not part of it")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("produces no marker for [^x] inside a fenced code block", () => {
|
|
167
|
+
const content = ["```", "[^x]", "```", "", "[^y] real marker[^y]"].join("\n")
|
|
168
|
+
const { markers } = parseFootnotes(content)
|
|
169
|
+
expect(markers.map((m) => m.name)).toEqual(["y", "y"])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("produces no marker for [^x] inside an inline-code span", () => {
|
|
173
|
+
const content = "prose `[^x]` and [^y] real one"
|
|
174
|
+
const { markers } = parseFootnotes(content)
|
|
175
|
+
expect(markers.map((m) => m.name)).toEqual(["y"])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it("still finds an orphan [^y] written inside another definition's body — GFM applies the same reference-vs-text rule everywhere, no special-cased skip for a body's own prose", () => {
|
|
179
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " see also [^y] here"].join("\n")
|
|
180
|
+
const { markers } = parseFootnotes(content)
|
|
181
|
+
expect(markers.map((m) => m.name)).toEqual(["fn1", "y"])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("produces no finding for two markers of the same name with one definition", () => {
|
|
185
|
+
const content = ["a[^fn1] b[^fn1]", "", "[^fn1]: reason"].join("\n")
|
|
186
|
+
const { findings } = parseFootnotes(content)
|
|
187
|
+
expect(findings).toEqual([])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("reports a marker with no definition at the marker's line", () => {
|
|
191
|
+
const content = "orphan marker[^fn1] here"
|
|
192
|
+
const { findings } = parseFootnotes(content)
|
|
193
|
+
expect(findings).toEqual([
|
|
194
|
+
{
|
|
195
|
+
message: 'Footnote marker "[^fn1]" has no matching definition',
|
|
196
|
+
line: 0,
|
|
197
|
+
range: { start: { line: 0, character: 13 }, end: { line: 0, character: 19 } },
|
|
198
|
+
},
|
|
199
|
+
])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("reports a definition no marker references at the definition's line", () => {
|
|
203
|
+
const content = "no markers here\n\n[^fn1]: orphan definition"
|
|
204
|
+
const { findings } = parseFootnotes(content)
|
|
205
|
+
expect(findings).toEqual([
|
|
206
|
+
{
|
|
207
|
+
message: 'Footnote definition "[^fn1]" has no marker referencing it',
|
|
208
|
+
line: 2,
|
|
209
|
+
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 25 } },
|
|
210
|
+
},
|
|
211
|
+
])
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("reports a duplicate definition name at the second definition's line", () => {
|
|
215
|
+
const content = ["a[^fn1]", "", "[^fn1]: first", "[^fn1]: second"].join("\n")
|
|
216
|
+
const { findings } = parseFootnotes(content)
|
|
217
|
+
expect(findings).toContainEqual({
|
|
218
|
+
message: 'Duplicate footnote definition "[^fn1]"',
|
|
219
|
+
line: 3,
|
|
220
|
+
range: { start: { line: 3, character: 0 }, end: { line: 3, character: 14 } },
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("parses a body-less definition as a real footnoteDefinition, matched to its marker, not an orphan", () => {
|
|
225
|
+
const content = "a[^fn1]\n\n[^fn1]:\n"
|
|
226
|
+
const { definitions, markers, findings } = parseFootnotes(content)
|
|
227
|
+
expect(definitions).toEqual([{ name: "fn1", line: 2, endLine: 2, body: "" }])
|
|
228
|
+
expect(markers).toEqual([{ name: "fn1", line: 0, character: 1, endCharacter: 7 }])
|
|
229
|
+
expect(findings.some((f) => f.message.includes("has no matching definition"))).toBe(false)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it("reports a definition whose body is empty as unfilled", () => {
|
|
233
|
+
const content = ["a[^fn1]", "", "[^fn1]:"].join("\n")
|
|
234
|
+
const { findings } = parseFootnotes(content)
|
|
235
|
+
expect(findings).toContainEqual({
|
|
236
|
+
message: 'Footnote definition "[^fn1]" has an empty body',
|
|
237
|
+
line: 2,
|
|
238
|
+
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 7 } },
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("reports no finding for a definition holding the ordinary human text 'your comment' — it is no longer a placeholder", () => {
|
|
243
|
+
const content = ["a[^fn1]", "", "[^fn1]: your comment"].join("\n")
|
|
244
|
+
const { findings } = parseFootnotes(content)
|
|
245
|
+
expect(findings.some((f) => f.message.includes("has an empty body"))).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it("reports no finding of any kind for placement — a far-away definition is valid", () => {
|
|
249
|
+
const content = [
|
|
250
|
+
"a[^fn1] paragraph one",
|
|
251
|
+
"",
|
|
252
|
+
"paragraph two, unrelated",
|
|
253
|
+
"",
|
|
254
|
+
"paragraph three",
|
|
255
|
+
"",
|
|
256
|
+
"[^fn1]: the definition, far below",
|
|
257
|
+
].join("\n")
|
|
258
|
+
const { findings } = parseFootnotes(content)
|
|
259
|
+
expect(findings).toEqual([])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("does not recognize a definition below a fence opened above the slice it's handed — the tree sees the whole document, so a fence opened earlier still hides it", () => {
|
|
263
|
+
const content = ["```", "[^fn1]: not a real definition, still fenced", "```"].join("\n")
|
|
264
|
+
const { definitions, markers } = parseFootnotes(content)
|
|
265
|
+
expect(definitions).toEqual([])
|
|
266
|
+
expect(markers).toEqual([])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it("produces no marker for [^x] inside a four-space indented code block", () => {
|
|
270
|
+
const content = [" [^x] indented code", "", "real [^y]", "", "[^y]: d"].join("\n")
|
|
271
|
+
const { markers } = parseFootnotes(content)
|
|
272
|
+
expect(markers.map((m) => m.name)).toEqual(["y"])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("produces no marker for [^x] inside a ~~~ fence", () => {
|
|
276
|
+
const content = ["~~~", "[^x]", "~~~", "", "real [^y]", "", "[^y]: d"].join("\n")
|
|
277
|
+
const { markers } = parseFootnotes(content)
|
|
278
|
+
expect(markers.map((m) => m.name)).toEqual(["y"])
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("produces no marker for [^x] inside a double-backtick span", () => {
|
|
282
|
+
const content = "prose ``[^x]`` and [^y] real\n\n[^y]: d"
|
|
283
|
+
const { markers } = parseFootnotes(content)
|
|
284
|
+
expect(markers.map((m) => m.name)).toEqual(["y"])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it("does not recognize a [^name]: definition written inside an inline-code span", () => {
|
|
288
|
+
const content = "no ref here\n\n`[^fn1]: not a def`"
|
|
289
|
+
const { definitions } = parseFootnotes(content)
|
|
290
|
+
expect(definitions).toEqual([])
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it("carries all of a multi-paragraph definition's paragraphs in body", () => {
|
|
294
|
+
const content = ["text[^fn1]", "", "[^fn1]: first paragraph", "", " second paragraph"].join(
|
|
295
|
+
"\n",
|
|
296
|
+
)
|
|
297
|
+
const { definitions } = parseFootnotes(content)
|
|
298
|
+
expect(definitions).toEqual([
|
|
299
|
+
{ name: "fn1", line: 2, endLine: 4, body: "first paragraph second paragraph" },
|
|
300
|
+
])
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe("orphan markers", () => {
|
|
304
|
+
it("reports 'has no matching definition' for a marker with no definition anywhere", () => {
|
|
305
|
+
const content = "Some text[^fn1] here."
|
|
306
|
+
const { findings, markers } = parseFootnotes(content)
|
|
307
|
+
expect(markers).toEqual([{ name: "fn1", line: 0, character: 9, endCharacter: 15 }])
|
|
308
|
+
expect(findings).toEqual([
|
|
309
|
+
{
|
|
310
|
+
message: 'Footnote marker "[^fn1]" has no matching definition',
|
|
311
|
+
line: 0,
|
|
312
|
+
range: { start: { line: 0, character: 9 }, end: { line: 0, character: 15 } },
|
|
313
|
+
},
|
|
314
|
+
])
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it("reports the TRUE column of an orphan marker preceded on the same line by an entity reference, not node.value's shorter length", () => {
|
|
318
|
+
const content = "&[^fn1] after"
|
|
319
|
+
const { markers } = parseFootnotes(content)
|
|
320
|
+
// Source column is after the literal "&" (5 chars), not the decoded "&" (1 char).
|
|
321
|
+
expect(markers).toEqual([{ name: "fn1", line: 0, character: 5, endCharacter: 11 }])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("reports line two, not the text node's start line, for an orphan on the second line of a lazy list-item wrap", () => {
|
|
325
|
+
const content = ["- some text", " continues[^fn1] here"].join("\n")
|
|
326
|
+
const { markers } = parseFootnotes(content)
|
|
327
|
+
expect(markers).toEqual([{ name: "fn1", line: 1, character: 11, endCharacter: 17 }])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("reports nothing for an orphan inside a fence, an indented code block, or an inline-code span", () => {
|
|
331
|
+
const content = ["```", "[^a]", "```", "", " [^b] indented", "", "prose `[^c]` span"].join(
|
|
332
|
+
"\n",
|
|
333
|
+
)
|
|
334
|
+
const { markers } = parseFootnotes(content)
|
|
335
|
+
expect(markers).toEqual([])
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe("nextFootnoteName", () => {
|
|
341
|
+
it("is fn1 for a document with no footnotes at all", () => {
|
|
342
|
+
expect(nextFootnoteName("just prose")).toBe("fn1")
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("skips names already used by a marker or a definition, whichever is higher", () => {
|
|
346
|
+
expect(nextFootnoteName("a[^fn1] b[^fn3]\n\n[^fn2]: reason")).toBe("fn4")
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("is idempotent across two applications: fn1 then fn2, never a collision", () => {
|
|
350
|
+
const first = nextFootnoteName("prose")
|
|
351
|
+
expect(first).toBe("fn1")
|
|
352
|
+
const withFirst = `prose[^${first}]\n\n[^${first}]:\n`
|
|
353
|
+
expect(nextFootnoteName(withFirst)).toBe("fn2")
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it("counts an orphan marker (written but never given a definition) so it's never reused", () => {
|
|
357
|
+
// fn1 has no definition anywhere — it's an orphan, not a footnoteReference node.
|
|
358
|
+
expect(nextFootnoteName("prose[^fn1] more")).toBe("fn2")
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
describe("footnoteAdditionEdits", () => {
|
|
363
|
+
it("inserts the marker at exactly the cursor position — no scan, no +1 — and a seeded-empty definition anchored one line PAST blockEndLine, separated by blank lines", () => {
|
|
364
|
+
const content = ["Option A here", "", "next paragraph"].join("\n")
|
|
365
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: 7 }, 0)
|
|
366
|
+
expect(edits).toHaveLength(2)
|
|
367
|
+
expect(edits[0]).toEqual({
|
|
368
|
+
range: { start: { line: 0, character: 7 }, end: { line: 0, character: 7 } },
|
|
369
|
+
newText: "[^fn1]",
|
|
370
|
+
})
|
|
371
|
+
expect(edits[1]!.newText).toBe("\n[^fn1]:\n\n")
|
|
372
|
+
expect(edits[1]!.range.start).toEqual({ line: 1, character: 0 })
|
|
373
|
+
expect(edits[1]!.range.end).toEqual({ line: 2, character: 0 })
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it("plants the marker mid-word when the cursor sits inside one — the old word-end scan is gone", () => {
|
|
377
|
+
const content = "new add function"
|
|
378
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: 1 }, 0) // cursor on "e" of "new"
|
|
379
|
+
expect(edits[0]).toEqual({
|
|
380
|
+
range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } },
|
|
381
|
+
newText: "[^fn1]",
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
it("leaves the marker exactly where the cursor already sits just past a word", () => {
|
|
386
|
+
const content = "new add function"
|
|
387
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: 3 }, 0) // right after "new", before the space
|
|
388
|
+
expect(edits[0]!.range.start).toEqual({ line: 0, character: 3 })
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it("at end of file, seeds the definition with a single trailing newline, no extra blank line after", () => {
|
|
392
|
+
const content = "Option A here"
|
|
393
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: 7 }, 0)
|
|
394
|
+
expect(edits[1]!.newText).toBe("\n[^fn1]:\n")
|
|
395
|
+
expect(edits[1]!.range.start).toEqual({ line: 1, character: 0 })
|
|
396
|
+
expect(edits[1]!.range.end).toEqual({ line: 1, character: 0 })
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it("generates the next unused name, never colliding with an existing footnote", () => {
|
|
400
|
+
const content = ["a[^fn1]", "", "[^fn1]: reason"].join("\n")
|
|
401
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: 1 }, 0)
|
|
402
|
+
expect(edits[0]!.newText).toBe("[^fn2]")
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it("never shares a start position between the two edits, even when the cursor sits at the very end of blockEndLine itself — the collision that corrupted the marker (regression)", () => {
|
|
406
|
+
const content = ["some trailing prose", "", "next paragraph"].join("\n")
|
|
407
|
+
const cursorCharacter = "some trailing prose".length // end of blockEndLine's own line
|
|
408
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: cursorCharacter }, 0)
|
|
409
|
+
const [markerEdit, definitionEdit] = edits
|
|
410
|
+
// Same line as the marker would be a collision; one line later never is.
|
|
411
|
+
expect(definitionEdit!.range.start.line).toBeGreaterThan(markerEdit!.range.start.line)
|
|
412
|
+
expect(
|
|
413
|
+
definitionEdit!.range.start.line === markerEdit!.range.end.line &&
|
|
414
|
+
definitionEdit!.range.start.character === markerEdit!.range.end.character,
|
|
415
|
+
).toBe(false)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it("applying both edits raises exactly one finding — the empty-body one — for the definition it just seeded", () => {
|
|
419
|
+
const content = ["Option A here", "", "next paragraph"].join("\n")
|
|
420
|
+
const edits = footnoteAdditionEdits(content, { line: 0, character: 7 }, 0)
|
|
421
|
+
const applied = applyEdits(content, edits)
|
|
422
|
+
const { findings } = parseFootnotes(applied)
|
|
423
|
+
expect(findings).toHaveLength(1)
|
|
424
|
+
expect(findings[0]!.message).toBe('Footnote definition "[^fn1]" has an empty body')
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
describe("footnoteAttachEdits", () => {
|
|
429
|
+
it("produces exactly two edits and every byte outside their ranges is unchanged", () => {
|
|
430
|
+
const content = ["Some chunk text here", "", "next paragraph"].join("\n")
|
|
431
|
+
const result = footnoteAttachEdits(
|
|
432
|
+
content,
|
|
433
|
+
{
|
|
434
|
+
line: 0,
|
|
435
|
+
endCharacter: "Some chunk text here".length,
|
|
436
|
+
blockEndLine: 0,
|
|
437
|
+
key: "chunk:0",
|
|
438
|
+
},
|
|
439
|
+
"the human's own typed note",
|
|
440
|
+
)
|
|
441
|
+
expect(result.ok).toBe(true)
|
|
442
|
+
if (!result.ok) return
|
|
443
|
+
expect(result.edits).toHaveLength(2)
|
|
444
|
+
const applied = applyEdits(content, result.edits)
|
|
445
|
+
expect(applied).toBe(
|
|
446
|
+
`Some chunk text here[^${result.id}]\n\n[^${result.id}]:\n - H: the human's own typed note\n\nnext paragraph`,
|
|
447
|
+
)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it("lands the marker at the end of the anchor's own text, not on a new line", () => {
|
|
451
|
+
const content = "Option A here"
|
|
452
|
+
const result = footnoteAttachEdits(
|
|
453
|
+
content,
|
|
454
|
+
{
|
|
455
|
+
line: 0,
|
|
456
|
+
endCharacter: 9,
|
|
457
|
+
blockEndLine: 0,
|
|
458
|
+
key: "opt:a",
|
|
459
|
+
},
|
|
460
|
+
"a note",
|
|
461
|
+
)
|
|
462
|
+
expect(result.ok).toBe(true)
|
|
463
|
+
if (!result.ok) return
|
|
464
|
+
expect(result.edits[0]!.range).toEqual({
|
|
465
|
+
start: { line: 0, character: 9 },
|
|
466
|
+
end: { line: 0, character: 9 },
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it("two attaches at two different anchors in one document produce two distinct ids", () => {
|
|
471
|
+
const content = ["Chunk A", "", "Chunk B"].join("\n")
|
|
472
|
+
const first = footnoteAttachEdits(
|
|
473
|
+
content,
|
|
474
|
+
{
|
|
475
|
+
line: 0,
|
|
476
|
+
endCharacter: 7,
|
|
477
|
+
blockEndLine: 0,
|
|
478
|
+
key: "chunk:a",
|
|
479
|
+
},
|
|
480
|
+
"note a",
|
|
481
|
+
)
|
|
482
|
+
const second = footnoteAttachEdits(
|
|
483
|
+
content,
|
|
484
|
+
{
|
|
485
|
+
line: 2,
|
|
486
|
+
endCharacter: 7,
|
|
487
|
+
blockEndLine: 2,
|
|
488
|
+
key: "chunk:b",
|
|
489
|
+
},
|
|
490
|
+
"note b",
|
|
491
|
+
)
|
|
492
|
+
expect(first.ok && second.ok).toBe(true)
|
|
493
|
+
if (!first.ok || !second.ok) return
|
|
494
|
+
expect(first.id).not.toBe(second.id)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it("a second attach at the SAME anchor EDITS the existing note in place, rather than rejecting it (T6: 'offers editing it, not a second note')", () => {
|
|
498
|
+
const content = "Chunk A"
|
|
499
|
+
const first = footnoteAttachEdits(
|
|
500
|
+
content,
|
|
501
|
+
{
|
|
502
|
+
line: 0,
|
|
503
|
+
endCharacter: 7,
|
|
504
|
+
blockEndLine: 0,
|
|
505
|
+
key: "chunk:a",
|
|
506
|
+
},
|
|
507
|
+
"note a",
|
|
508
|
+
)
|
|
509
|
+
expect(first.ok).toBe(true)
|
|
510
|
+
if (!first.ok) return
|
|
511
|
+
const applied = applyEdits(content, first.edits)
|
|
512
|
+
const second = footnoteAttachEdits(
|
|
513
|
+
applied,
|
|
514
|
+
{
|
|
515
|
+
line: 0,
|
|
516
|
+
endCharacter: 7,
|
|
517
|
+
blockEndLine: 0,
|
|
518
|
+
key: "chunk:a",
|
|
519
|
+
},
|
|
520
|
+
"note a, again",
|
|
521
|
+
)
|
|
522
|
+
expect(second.ok).toBe(true)
|
|
523
|
+
if (!second.ok) return
|
|
524
|
+
// Same id, single replace edit — no second marker, no second definition.
|
|
525
|
+
expect(second.id).toBe(first.id)
|
|
526
|
+
expect(second.edits).toHaveLength(1)
|
|
527
|
+
const reapplied = applyEdits(applied, second.edits)
|
|
528
|
+
expect(reapplied).toContain(`[^${first.id}]:\n - H: note a, again`)
|
|
529
|
+
expect(reapplied).not.toContain("note a, again, again")
|
|
530
|
+
expect((reapplied.match(new RegExp(`\\[\\^${first.id}\\]`, "g")) ?? []).length).toBe(2) // one marker, one definition
|
|
531
|
+
expect(parseFootnotes(reapplied).definitions).toHaveLength(1)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
it("an id colliding with a definition NOT already attached at THIS anchor's own line is still rejected", () => {
|
|
535
|
+
const key = "chunk:a"
|
|
536
|
+
const generated = footnoteAttachEdits(
|
|
537
|
+
"x",
|
|
538
|
+
{ line: 0, endCharacter: 1, blockEndLine: 0, key },
|
|
539
|
+
"note",
|
|
540
|
+
)
|
|
541
|
+
expect(generated.ok).toBe(true)
|
|
542
|
+
if (!generated.ok) return
|
|
543
|
+
const upperId = generated.id.toUpperCase()
|
|
544
|
+
// The existing marker/definition sit on line 5 — a different line than
|
|
545
|
+
// where THIS anchor's own attach would land (line 0) — so this reads as
|
|
546
|
+
// a genuine collision with unrelated content, never a re-attach at the
|
|
547
|
+
// same spot.
|
|
548
|
+
const content = `text\n\n\n\n\nelsewhere[^${upperId}]\n\n[^${upperId}]: existing reason\n`
|
|
549
|
+
const result = footnoteAttachEdits(
|
|
550
|
+
content,
|
|
551
|
+
{
|
|
552
|
+
line: 0,
|
|
553
|
+
endCharacter: 4,
|
|
554
|
+
blockEndLine: 0,
|
|
555
|
+
key,
|
|
556
|
+
},
|
|
557
|
+
"note",
|
|
558
|
+
)
|
|
559
|
+
expect(result).toEqual({ ok: false, reason: "id-collision" })
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it("keeps CRLF line endings", () => {
|
|
563
|
+
const content = ["Chunk A", "", "next"].join("\r\n")
|
|
564
|
+
const result = footnoteAttachEdits(
|
|
565
|
+
content,
|
|
566
|
+
{
|
|
567
|
+
line: 0,
|
|
568
|
+
endCharacter: 7,
|
|
569
|
+
blockEndLine: 0,
|
|
570
|
+
key: "chunk:a",
|
|
571
|
+
},
|
|
572
|
+
"note",
|
|
573
|
+
)
|
|
574
|
+
expect(result.ok).toBe(true)
|
|
575
|
+
if (!result.ok) return
|
|
576
|
+
const applied = applyEdits(content, result.edits)
|
|
577
|
+
expect(applied).not.toContain("\n\n\n")
|
|
578
|
+
expect(applied.includes("\r\n")).toBe(true)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it("seeds the new definition with the given text verbatim, never empty — the resulting document never trips the empty-body finding", () => {
|
|
582
|
+
const content = "Some chunk text here"
|
|
583
|
+
const result = footnoteAttachEdits(
|
|
584
|
+
content,
|
|
585
|
+
{ line: 0, endCharacter: content.length, blockEndLine: 0, key: "chunk:0" },
|
|
586
|
+
"a real reason a human actually typed",
|
|
587
|
+
)
|
|
588
|
+
expect(result.ok).toBe(true)
|
|
589
|
+
if (!result.ok) return
|
|
590
|
+
const applied = applyEdits(content, result.edits)
|
|
591
|
+
const { findings } = parseFootnotes(applied)
|
|
592
|
+
expect(findings.some((f) => f.message.includes("has an empty body"))).toBe(false)
|
|
593
|
+
})
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
describe("footnotePointerAt", () => {
|
|
597
|
+
const doc = ["a[^fn1] b", "", "[^fn1]: the reason", ""].join("\n")
|
|
598
|
+
|
|
599
|
+
it("jumps from within a marker's span to its definition's line", () => {
|
|
600
|
+
const character = doc.split("\n")[0]!.indexOf("[^fn1]") + 2 // inside the name
|
|
601
|
+
expect(footnotePointerAt(doc, { line: 0, character })?.pointer).toEqual({ line: 2 })
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it("jumps from a definition line to its first marker's line AND exact column", () => {
|
|
605
|
+
const markerCol = doc.split("\n")[0]!.indexOf("[^fn1]")
|
|
606
|
+
expect(footnotePointerAt(doc, { line: 2, character: 0 })?.pointer).toEqual({
|
|
607
|
+
line: 0,
|
|
608
|
+
character: markerCol,
|
|
609
|
+
})
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
it("resolves an orphan marker to 'handled, but no pointer' rather than falling through", () => {
|
|
613
|
+
const orphan = "orphan[^missing]"
|
|
614
|
+
const character = orphan.indexOf("[^missing]") + 2
|
|
615
|
+
const result = footnotePointerAt(orphan, { line: 0, character })
|
|
616
|
+
expect(result).toEqual({ pointer: undefined })
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
it("resolves an orphan definition to 'handled, but no pointer'", () => {
|
|
620
|
+
const orphan = "[^missing]: nobody points here"
|
|
621
|
+
expect(footnotePointerAt(orphan, { line: 0, character: 0 })).toEqual({ pointer: undefined })
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
it("returns undefined (not applicable) for a position that is neither a marker nor a definition", () => {
|
|
625
|
+
expect(footnotePointerAt(doc, { line: 0, character: 8 })).toBeUndefined() // "b", not the marker
|
|
626
|
+
})
|
|
627
|
+
|
|
628
|
+
it("returns undefined — not 'resolved, no pointer' — for a two-space-indented line that LOOKS like a continuation but isn't one per real GFM (regression: a since-deleted line-based helper used to dead-end here on its own looser 'any indent continues' rule)", () => {
|
|
629
|
+
const content = ["text[^fn1]", "", "[^fn1]:", " the reason"].join("\n")
|
|
630
|
+
// The tree's real GFM continuation span ends at line 2 (four spaces are
|
|
631
|
+
// required; this is indented only two) — line 3 is NOT part of the
|
|
632
|
+
// definition, so the caller must be free to fall through instead of
|
|
633
|
+
// getting stuck on "handled, but no pointer".
|
|
634
|
+
expect(footnotePointerAt(content, { line: 3, character: 2 })).toBeUndefined()
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
describe("isOnExistingFootnote", () => {
|
|
639
|
+
const doc = ["a[^fn1] b", "", "[^fn1]: the reason", ""].join("\n")
|
|
640
|
+
|
|
641
|
+
it("is true inside an existing marker's [^name] span", () => {
|
|
642
|
+
const character = doc.split("\n")[0]!.indexOf("[^fn1]") + 2 // inside the name
|
|
643
|
+
expect(isOnExistingFootnote(doc, { line: 0, character })).toBe(true)
|
|
644
|
+
})
|
|
645
|
+
|
|
646
|
+
it("is true on a definition's own label line", () => {
|
|
647
|
+
expect(isOnExistingFootnote(doc, { line: 2, character: 3 })).toBe(true)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it("is true on a definition's indented continuation line", () => {
|
|
651
|
+
const content = ["a[^fn1]", "", "[^fn1]:", " continued body"].join("\n")
|
|
652
|
+
expect(isOnExistingFootnote(content, { line: 3, character: 4 })).toBe(true)
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it("is false for ordinary text, even right next to a marker", () => {
|
|
656
|
+
expect(isOnExistingFootnote(doc, { line: 0, character: 8 })).toBe(false) // "b"
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
it("is false for a document with no footnotes at all", () => {
|
|
660
|
+
expect(isOnExistingFootnote("just prose", { line: 0, character: 0 })).toBe(false)
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
it("refuses inside an orphan marker's span too — planting a name there would still corrupt existing `[^` syntax", () => {
|
|
664
|
+
const content = "orphan[^missing] text"
|
|
665
|
+
const character = content.indexOf("[^missing]") + 2
|
|
666
|
+
expect(isOnExistingFootnote(content, { line: 0, character })).toBe(true)
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
describe("case-insensitive definition matching", () => {
|
|
671
|
+
it("resolves [^FN1] against a [^fn1]: definition with zero findings", () => {
|
|
672
|
+
const content = "claim[^FN1]\n\n[^fn1]: the reason\n"
|
|
673
|
+
const { findings } = parseFootnotes(content)
|
|
674
|
+
expect(findings).toEqual([])
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it("jumps between a differently-cased marker and its definition in both directions", () => {
|
|
678
|
+
const content = "claim[^FN1]\n\n[^fn1]: the reason\n"
|
|
679
|
+
const markerChar = content.indexOf("[^FN1]") + 2
|
|
680
|
+
expect(footnotePointerAt(content, { line: 0, character: markerChar })?.pointer).toEqual({
|
|
681
|
+
line: 2,
|
|
682
|
+
})
|
|
683
|
+
expect(footnotePointerAt(content, { line: 2, character: 0 })?.pointer).toEqual({
|
|
684
|
+
line: 0,
|
|
685
|
+
character: content.indexOf("[^FN1]"),
|
|
686
|
+
})
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it("renders a finding's message with the author's own casing (label), not the normalized identifier", () => {
|
|
690
|
+
const content = "no marker for this one\n\n[^FN1]: orphan definition"
|
|
691
|
+
const { findings } = parseFootnotes(content)
|
|
692
|
+
expect(findings).toContainEqual({
|
|
693
|
+
message: 'Footnote definition "[^FN1]" has no marker referencing it',
|
|
694
|
+
line: 2,
|
|
695
|
+
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 25 } },
|
|
696
|
+
})
|
|
697
|
+
})
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
import { freeFormFormat, getParseCount, steeringFormatFor } from "./index.js"
|
|
701
|
+
|
|
702
|
+
const qaDescriptor = steeringFormatFor("qa")!
|
|
703
|
+
const reviewDescriptor = steeringFormatFor("review")!
|
|
704
|
+
|
|
705
|
+
const CACHE = [
|
|
706
|
+
"We cache the result per request.[^cache]",
|
|
707
|
+
"",
|
|
708
|
+
"[^cache]:",
|
|
709
|
+
" - H: why do we do that this way?",
|
|
710
|
+
" - A: because ...",
|
|
711
|
+
" - H: would this ... work instead?",
|
|
712
|
+
" - A: no, because ...",
|
|
713
|
+
" - H: and this ...?",
|
|
714
|
+
" - A: yes, that would work",
|
|
715
|
+
" - H: then do it this way",
|
|
716
|
+
"",
|
|
717
|
+
].join("\n")
|
|
718
|
+
|
|
719
|
+
describe("parseThreads", () => {
|
|
720
|
+
it("parses the requirement example into 7 alternating entries waiting on the agent", () => {
|
|
721
|
+
const [t] = parseThreads(CACHE)
|
|
722
|
+
expect(t!.name).toBe("cache")
|
|
723
|
+
expect(t!.entries).toHaveLength(7)
|
|
724
|
+
expect(t!.entries.map((e) => e.author)).toEqual([
|
|
725
|
+
"me",
|
|
726
|
+
"agent",
|
|
727
|
+
"me",
|
|
728
|
+
"agent",
|
|
729
|
+
"me",
|
|
730
|
+
"agent",
|
|
731
|
+
"me",
|
|
732
|
+
])
|
|
733
|
+
expect(t!.entries[0]).toEqual({
|
|
734
|
+
author: "me",
|
|
735
|
+
text: "why do we do that this way?",
|
|
736
|
+
line: 3,
|
|
737
|
+
endLine: 3,
|
|
738
|
+
column: 4,
|
|
739
|
+
})
|
|
740
|
+
expect(t!.waitingOn).toBe("agent")
|
|
741
|
+
expect(t!.markers).toHaveLength(1)
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it("parses identically when a long entry is wrapped with a 6-space continuation", () => {
|
|
745
|
+
const wrapped = CACHE.replace(
|
|
746
|
+
" - H: why do we do that this way?",
|
|
747
|
+
" - H: why do we do that\n this way?",
|
|
748
|
+
)
|
|
749
|
+
expect(parseThreads(wrapped)[0]!.entries.map((e) => e.text)).toEqual(
|
|
750
|
+
parseThreads(CACHE)[0]!.entries.map((e) => e.text),
|
|
751
|
+
)
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
it("is open when the last entry is the agent's", () => {
|
|
755
|
+
const open = CACHE.replace(" - H: then do it this way\n", "")
|
|
756
|
+
expect(parseThreads(open)[0]!.waitingOn).toBe("human")
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
it("is not a thread for a plain paragraph body or an unprefixed first list item", () => {
|
|
760
|
+
expect(parseThreads("a.[^x]\n\n[^x]: just a note\n")).toEqual([])
|
|
761
|
+
expect(parseThreads("a.[^x]\n\n[^x]:\n - item one\n - H: hi\n")).toEqual([])
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it("parses a definition whose list starts on the next line at 4 spaces' indent", () => {
|
|
765
|
+
expect(parseThreads("a.[^x]\n\n[^x]:\n - H: hi\n")).toHaveLength(1)
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it("a list opening with the retired `- Me:` prefix is a one-shot footnote, not a thread", () => {
|
|
769
|
+
expect(parseThreads("a.[^x]\n\n[^x]:\n - Me: hi\n")).toEqual([])
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it("costs one parseMarkdown parse for two calls on the same document", () => {
|
|
773
|
+
const doc = `${CACHE}\nunique ${Math.random()}\n`
|
|
774
|
+
const before = getParseCount()
|
|
775
|
+
parseThreads(doc)
|
|
776
|
+
parseThreads(doc)
|
|
777
|
+
expect(getParseCount() - before).toBe(1)
|
|
778
|
+
})
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
describe("thread syntax findings", () => {
|
|
782
|
+
const wrap = (body: string) => `a.[^x]\n\n[^x]:\n${body}\n`
|
|
783
|
+
const cases: [string, string, RegExp][] = [
|
|
784
|
+
["unprefixed item", " - H: hi\n - A: yo\n - stray", /must start with/],
|
|
785
|
+
["agent starts", " - A: hi\n - H: yo", /never starts/],
|
|
786
|
+
["same author twice", " - H: hi\n - H: again", /consecutive/],
|
|
787
|
+
["empty entry", " - H:", /write your own words/],
|
|
788
|
+
["stray paragraph", " - H: hi\n\n stray paragraph", /only one list/],
|
|
789
|
+
]
|
|
790
|
+
for (const [name, body, re] of cases) {
|
|
791
|
+
for (const [fmtName, fmt] of [
|
|
792
|
+
["qa", qaDescriptor],
|
|
793
|
+
["review", reviewDescriptor],
|
|
794
|
+
["freeform", freeFormFormat],
|
|
795
|
+
] as const) {
|
|
796
|
+
it(`${fmtName}: ${name} is a finding`, () => {
|
|
797
|
+
const messages = fmt.validate(wrap(body)).map((f) => f.message)
|
|
798
|
+
expect(messages.some((m) => re.test(m))).toBe(true)
|
|
799
|
+
expect(parseFootnotes(wrap(body)).findings.some((f) => re.test(f.message))).toBe(true)
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
it("an open thread produces no finding", () => {
|
|
805
|
+
const body = " - H: hi\n - A: yo"
|
|
806
|
+
expect(parseFootnotes(wrap(body)).findings).toEqual([])
|
|
807
|
+
expect(freeFormFormat.validate(wrap(body))).toEqual([])
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it("openThreads lists open threads in every format", () => {
|
|
811
|
+
const doc = wrap(" - H: hi\n - A: yo")
|
|
812
|
+
for (const fmt of [qaDescriptor, reviewDescriptor, freeFormFormat]) {
|
|
813
|
+
expect(fmt.openThreads!(doc)).toEqual([{ name: "x", line: 2, firstMe: "hi" }])
|
|
814
|
+
}
|
|
815
|
+
})
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
const QA = steeringFormatFor("qa")!
|
|
819
|
+
|
|
820
|
+
const QA_DOC = (agentLast: boolean): string =>
|
|
821
|
+
[
|
|
822
|
+
"## Open Questions",
|
|
823
|
+
"",
|
|
824
|
+
"### Which one?",
|
|
825
|
+
"",
|
|
826
|
+
"- [ ] A [^t1]",
|
|
827
|
+
"- [ ] B",
|
|
828
|
+
"",
|
|
829
|
+
"[^t1]:",
|
|
830
|
+
" - H: why A?",
|
|
831
|
+
...(agentLast ? [" - A: because", " wrapped on two lines"] : []),
|
|
832
|
+
"",
|
|
833
|
+
"[^once]: one-shot note",
|
|
834
|
+
"",
|
|
835
|
+
].join("\n")
|
|
836
|
+
|
|
837
|
+
const cursor = (line: number, character = 0) => ({
|
|
838
|
+
start: { line, character },
|
|
839
|
+
end: { line, character },
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
describe("open-thread findings", () => {
|
|
843
|
+
it("one finding on the last entry's full span", () => {
|
|
844
|
+
const f = openThreadFindings(QA_DOC(true))
|
|
845
|
+
expect(f).toHaveLength(1)
|
|
846
|
+
expect(f[0]!.range).toEqual({
|
|
847
|
+
start: { line: 9, character: 0 },
|
|
848
|
+
end: { line: 10, character: 26 },
|
|
849
|
+
})
|
|
850
|
+
})
|
|
851
|
+
it("none while waiting on the agent", () => {
|
|
852
|
+
expect(openThreadFindings(QA_DOC(false))).toEqual([])
|
|
853
|
+
})
|
|
854
|
+
})
|
|
855
|
+
|
|
856
|
+
describe("threadReplyActions", () => {
|
|
857
|
+
it("offered on the marker and anywhere in the definition", () => {
|
|
858
|
+
const doc = QA_DOC(true)
|
|
859
|
+
for (const [line, ch] of [
|
|
860
|
+
[4, 12],
|
|
861
|
+
[7, 0],
|
|
862
|
+
[9, 5],
|
|
863
|
+
[10, 3],
|
|
864
|
+
] as const) {
|
|
865
|
+
expect(threadReplyActions(doc, { line, character: ch }).map((a) => a.title)).toEqual([
|
|
866
|
+
"gtd: reply",
|
|
867
|
+
])
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
it("not offered while waiting on the agent, nor on a one-shot footnote", () => {
|
|
872
|
+
expect(threadReplyActions(QA_DOC(false), { line: 7, character: 0 })).toEqual([])
|
|
873
|
+
expect(threadReplyActions(QA_DOC(true), { line: 12, character: 0 })).toEqual([])
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
it("inserts after a wrapped last entry at the list indent", () => {
|
|
877
|
+
const [a] = threadReplyActions(QA_DOC(true), { line: 7, character: 0 })
|
|
878
|
+
expect(a!.edits).toEqual([
|
|
879
|
+
{
|
|
880
|
+
range: { start: { line: 10, character: 26 }, end: { line: 10, character: 26 } },
|
|
881
|
+
newText: "\n - H: ",
|
|
882
|
+
},
|
|
883
|
+
])
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
it("preserves CRLF", () => {
|
|
887
|
+
const doc = QA_DOC(true).replace(/\n/g, "\r\n")
|
|
888
|
+
const [a] = threadReplyActions(doc, { line: 7, character: 0 })
|
|
889
|
+
expect(a!.edits[0]!.newText).toBe("\r\n - H: ")
|
|
890
|
+
expect(a!.edits[0]!.range.start).toEqual({ line: 10, character: 26 })
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
it("qa/review actions keep add-a-footnote away from threads", () => {
|
|
894
|
+
const titles = QA.actions(QA_DOC(true), cursor(7)).map((a) => a.title)
|
|
895
|
+
expect(titles).toContain("gtd: reply")
|
|
896
|
+
expect(titles).not.toContain("gtd: add a footnote")
|
|
897
|
+
})
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
describe("footnoteAttachEdits — threads", () => {
|
|
901
|
+
const anchor = { line: 0, endCharacter: 7, blockEndLine: 0, key: "chunk:a" }
|
|
902
|
+
const id = (() => {
|
|
903
|
+
const r = footnoteAttachEdits("Chunk A", anchor, "x")
|
|
904
|
+
if (!r.ok) throw new Error("setup")
|
|
905
|
+
return r.id
|
|
906
|
+
})()
|
|
907
|
+
|
|
908
|
+
it("no footnote → a single-entry `- H:` thread", () => {
|
|
909
|
+
const content = "Chunk A\n\nnext"
|
|
910
|
+
const result = footnoteAttachEdits(content, anchor, "do it")
|
|
911
|
+
expect(result.ok).toBe(true)
|
|
912
|
+
if (!result.ok) return
|
|
913
|
+
expect(applyEdits(content, result.edits)).toBe(
|
|
914
|
+
`Chunk A[^${id}]\n\n[^${id}]:\n - H: do it\n\nnext`,
|
|
915
|
+
)
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
it("no footnote, CRLF → the thread uses the document's own EOL", () => {
|
|
919
|
+
const content = "Chunk A\r\n\r\nnext"
|
|
920
|
+
const result = footnoteAttachEdits(content, anchor, "do it")
|
|
921
|
+
expect(result.ok).toBe(true)
|
|
922
|
+
if (!result.ok) return
|
|
923
|
+
const applied = applyEdits(content, result.edits)
|
|
924
|
+
expect(applied).toContain(`[^${id}]:\r\n - H: do it\r\n`)
|
|
925
|
+
expect(applied.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
it("an open thread → appends a `- H:` entry", () => {
|
|
929
|
+
const content = `Chunk A[^${id}]\n\n[^${id}]:\n - H: first\n - A: question?\n`
|
|
930
|
+
const result = footnoteAttachEdits(content, anchor, "answer")
|
|
931
|
+
expect(result.ok).toBe(true)
|
|
932
|
+
if (!result.ok) return
|
|
933
|
+
const applied = applyEdits(content, result.edits)
|
|
934
|
+
expect(applied).toBe(
|
|
935
|
+
`Chunk A[^${id}]\n\n[^${id}]:\n - H: first\n - A: question?\n - H: answer\n`,
|
|
936
|
+
)
|
|
937
|
+
const thread = parseThreads(applied)[0]!
|
|
938
|
+
expect(thread.waitingOn).toBe("agent")
|
|
939
|
+
expect(thread.entries.map((e) => e.text)).toEqual(["first", "question?", "answer"])
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it("a thread waiting on the agent → replaces its last `- H:` entry", () => {
|
|
943
|
+
const content = `Chunk A[^${id}]\n\n[^${id}]:\n - H: first\n - A: q?\n - H: old\n`
|
|
944
|
+
const result = footnoteAttachEdits(content, anchor, "new")
|
|
945
|
+
expect(result.ok).toBe(true)
|
|
946
|
+
if (!result.ok) return
|
|
947
|
+
expect(applyEdits(content, result.edits)).toBe(
|
|
948
|
+
`Chunk A[^${id}]\n\n[^${id}]:\n - H: first\n - A: q?\n - H: new\n`,
|
|
949
|
+
)
|
|
950
|
+
})
|
|
951
|
+
|
|
952
|
+
it("a one-shot footnote → replaces its body", () => {
|
|
953
|
+
const content = `Chunk A[^${id}]\n\n[^${id}]: old\n`
|
|
954
|
+
const result = footnoteAttachEdits(content, anchor, "new")
|
|
955
|
+
expect(result.ok).toBe(true)
|
|
956
|
+
if (!result.ok) return
|
|
957
|
+
expect(applyEdits(content, result.edits)).toBe(`Chunk A[^${id}]\n\n[^${id}]: new\n`)
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
it("multi-line text keeps continuation lines inside the entry", () => {
|
|
961
|
+
const content = "Chunk A"
|
|
962
|
+
const result = footnoteAttachEdits(content, anchor, "a\nb")
|
|
963
|
+
expect(result.ok).toBe(true)
|
|
964
|
+
if (!result.ok) return
|
|
965
|
+
const thread = parseThreads(applyEdits(content, result.edits))[0]!
|
|
966
|
+
expect(thread.entries).toHaveLength(1)
|
|
967
|
+
expect(thread.entries[0]!.text).toBe("a b")
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
it("a taken id with no marker at the anchor still refuses", () => {
|
|
971
|
+
const content = `x\n\nelse[^${id}]\n\n[^${id}]:\n - H: a\n`
|
|
972
|
+
expect(footnoteAttachEdits(content, anchor, "n")).toEqual({
|
|
973
|
+
ok: false,
|
|
974
|
+
reason: "id-collision",
|
|
975
|
+
})
|
|
976
|
+
})
|
|
977
|
+
})
|