@pmelab/gtd 64.0.0 → 64.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 +8 -8
- package/dist/chunks/{steering-DkA51qDa.mjs → steering-Cem5i6Xl.mjs} +421 -1
- package/dist/chunks/{testkit-zGjNeQNL.mjs → testkit-DfWZQCWQ.mjs} +63 -16
- package/dist/gtd.bundle.mjs +484 -18
- package/dist/gtd.core.mjs +117 -110
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/dist/workflow-kit.mjs +1 -1
- package/package.json +1 -1
- package/src/cli/Cli.ts +13 -1
- package/src/cli/Program.ts +23 -4
- package/src/edge/Edge.ts +23 -9
- package/src/emit/SteeringMode.ts +21 -1
- package/src/flows/index.ts +1 -0
- package/src/flows/runtime.test.ts +2 -1
- package/src/flows/runtime.ts +8 -2
- package/src/steering/Declared.test.ts +218 -0
- package/src/steering/Declared.ts +607 -0
- package/src/steering/FORMATS.md +3 -0
- package/src/steering/index.ts +12 -0
- package/src/steering/internal.ts +1 -0
- package/src/twins/EmittedScriptRecognizer.ts +5 -3
- package/src/twins/EmittedScripts.test.ts +19 -0
- package/src/workflow/SteeringFormats.ts +4 -0
- package/src/workflow/Workflow.ts +2 -0
- package/src/workflow/index.ts +5 -1
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// Formats a workflow declares with `listFormat`/`sectionFormat`. Every rule
|
|
2
|
+
// here applies to declared formats only: the built-in `qa`/`review`/`findings`
|
|
3
|
+
// formats are a replay contract and never read through this walker.
|
|
4
|
+
import { openThreadsOf, threadFindings } from "./Footnotes.js"
|
|
5
|
+
import { freeFormFormat } from "./freeform.js"
|
|
6
|
+
import type { SteeringFinding, SteeringFormat } from "./SteeringFormat.js"
|
|
7
|
+
|
|
8
|
+
export type ListMarker = "-" | "*" | "+" | "1."
|
|
9
|
+
|
|
10
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
|
|
11
|
+
|
|
12
|
+
export interface ListFormatSpec {
|
|
13
|
+
/** The mode name a step reports and `gtd check` prints — lowercase letters, digits and `-`. */
|
|
14
|
+
readonly name: string
|
|
15
|
+
/** The heading the list lives under. Absent: the whole document is the list. */
|
|
16
|
+
readonly section?: {
|
|
17
|
+
/** A string matches the heading text case-insensitively; a RegExp needs `example`. */
|
|
18
|
+
readonly title: string | RegExp
|
|
19
|
+
/** The heading text `instructions` and `sample` show for a RegExp `title`. */
|
|
20
|
+
readonly example?: string
|
|
21
|
+
readonly level: HeadingLevel
|
|
22
|
+
/** Default true: a document without the section is a problem. */
|
|
23
|
+
readonly required?: boolean
|
|
24
|
+
}
|
|
25
|
+
/** Items start with an ID, then `separator`, then the text: parsed into `{ id, text }`. */
|
|
26
|
+
readonly item?: {
|
|
27
|
+
readonly id: RegExp
|
|
28
|
+
readonly separator: string
|
|
29
|
+
/** An ID `id` matches, shown by `instructions` and `sample`. */
|
|
30
|
+
readonly example: string
|
|
31
|
+
}
|
|
32
|
+
/** Default `["-"]`. `"1."` stands for any `1.`/`1)` numbered item. */
|
|
33
|
+
readonly markers?: readonly ListMarker[]
|
|
34
|
+
/** `"join"` (default): an indented continuation line joins its item. `"forbid"`: one line per item. */
|
|
35
|
+
readonly wrap?: "join" | "forbid"
|
|
36
|
+
/** `"forbid"` (default), `"strip"` (read and drop `[ ]`/`[x]`), or `"require"`. */
|
|
37
|
+
readonly checkbox?: "forbid" | "strip" | "require"
|
|
38
|
+
readonly ids?: { readonly unique?: boolean }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SectionFormatSpec {
|
|
42
|
+
readonly name: string
|
|
43
|
+
/** The heading level every section uses. A shallower heading is a problem; deeper ones belong to a section's body. */
|
|
44
|
+
readonly level: HeadingLevel
|
|
45
|
+
/** `"unique"`: two sections may not share a title. Default `"any"`. */
|
|
46
|
+
readonly title?: "unique" | "any"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ListItem {
|
|
50
|
+
/** 0-based line of the item's marker. */
|
|
51
|
+
readonly line: number
|
|
52
|
+
readonly text: string
|
|
53
|
+
/** Set when the format declares `item.id`. */
|
|
54
|
+
readonly id?: string
|
|
55
|
+
/** Set when the format reads checkboxes (`"strip"`/`"require"`). */
|
|
56
|
+
readonly checked?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Section {
|
|
60
|
+
readonly line: number
|
|
61
|
+
readonly title: string
|
|
62
|
+
/** The section's text below its heading, trimmed. */
|
|
63
|
+
readonly body: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface DeclaredFormat<Parsed> extends SteeringFormat {
|
|
67
|
+
readonly name: string
|
|
68
|
+
/** JSON the engine hands `gtd check --spec`, so a validation script needs no workflow loaded. */
|
|
69
|
+
readonly spec: string
|
|
70
|
+
/** Prose describing the format, for an agent's prompt. */
|
|
71
|
+
readonly instructions: string
|
|
72
|
+
readonly parse: (content: string) => Parsed & { readonly problems: readonly SteeringFinding[] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ListFormat = DeclaredFormat<{ readonly items: readonly ListItem[] }>
|
|
76
|
+
export type SectionFormat = DeclaredFormat<{ readonly sections: readonly Section[] }>
|
|
77
|
+
|
|
78
|
+
// --- the shared line walker -------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const FENCE_RE = /^( *)(`{3,}|~{3,})/
|
|
81
|
+
const HEADING_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/
|
|
82
|
+
const ITEM_RE = /^( *)([-*+]|\d{1,9}[.)])(?:[ \t]+(.*))?$/
|
|
83
|
+
const CHECKBOX_RE = /^\[([ xX])\](?:[ \t]+|$)/
|
|
84
|
+
|
|
85
|
+
interface Line {
|
|
86
|
+
readonly index: number
|
|
87
|
+
readonly text: string
|
|
88
|
+
/** Inside a fenced code block, fence lines included. */
|
|
89
|
+
readonly fenced: boolean
|
|
90
|
+
readonly fenceIndent?: number
|
|
91
|
+
/** The line that opens a fence. */
|
|
92
|
+
readonly opens?: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface Walked {
|
|
96
|
+
readonly lines: readonly Line[]
|
|
97
|
+
readonly problems: readonly SteeringFinding[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Marks fenced lines; an unclosed fence is a problem rather than swallowing the rest of the document. */
|
|
101
|
+
const walk = (content: string): Walked => {
|
|
102
|
+
const raw = content.split(/\r?\n/)
|
|
103
|
+
const lines: Line[] = []
|
|
104
|
+
const problems: SteeringFinding[] = []
|
|
105
|
+
let open:
|
|
106
|
+
| {
|
|
107
|
+
readonly char: string
|
|
108
|
+
readonly length: number
|
|
109
|
+
readonly line: number
|
|
110
|
+
readonly indent: number
|
|
111
|
+
}
|
|
112
|
+
| undefined
|
|
113
|
+
for (const [index, text] of raw.entries()) {
|
|
114
|
+
const fence = FENCE_RE.exec(text)
|
|
115
|
+
if (open === undefined) {
|
|
116
|
+
if (fence !== null) {
|
|
117
|
+
open = {
|
|
118
|
+
char: fence[2]![0]!,
|
|
119
|
+
length: fence[2]!.length,
|
|
120
|
+
line: index,
|
|
121
|
+
indent: fence[1]!.length,
|
|
122
|
+
}
|
|
123
|
+
lines.push({ index, text, fenced: true, fenceIndent: open.indent, opens: true })
|
|
124
|
+
} else lines.push({ index, text, fenced: false })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
lines.push({ index, text, fenced: true, fenceIndent: open.indent })
|
|
128
|
+
const closes =
|
|
129
|
+
fence !== null &&
|
|
130
|
+
fence[2]![0] === open.char &&
|
|
131
|
+
fence[2]!.length >= open.length &&
|
|
132
|
+
text.slice(fence[0].length).trim() === ""
|
|
133
|
+
if (closes) open = undefined
|
|
134
|
+
}
|
|
135
|
+
if (open !== undefined) problems.push({ line: open.line, message: "code fence is never closed" })
|
|
136
|
+
return { lines, problems }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface Heading {
|
|
140
|
+
readonly line: number
|
|
141
|
+
readonly level: number
|
|
142
|
+
readonly title: string
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const headingOf = (line: Line): Heading | undefined => {
|
|
146
|
+
if (line.fenced) return undefined
|
|
147
|
+
const match = HEADING_RE.exec(line.text)
|
|
148
|
+
return match === null
|
|
149
|
+
? undefined
|
|
150
|
+
: { line: line.index, level: match[1]!.length, title: (match[2] ?? "").trim() }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const indentOf = (text: string): number => /^ */.exec(text)![0].length
|
|
154
|
+
|
|
155
|
+
// --- listFormat --------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
const markerKind = (marker: string): ListMarker =>
|
|
158
|
+
marker === "-" || marker === "*" || marker === "+" ? marker : "1."
|
|
159
|
+
|
|
160
|
+
const markerList = (markers: readonly ListMarker[]): string =>
|
|
161
|
+
markers.map((m) => `\`${m}\``).join(" or ")
|
|
162
|
+
|
|
163
|
+
const titleMatches = (spec: NonNullable<ListFormatSpec["section"]>, title: string): boolean =>
|
|
164
|
+
typeof spec.title === "string"
|
|
165
|
+
? spec.title.trim().toLowerCase() === title.toLowerCase()
|
|
166
|
+
: spec.title.test(title)
|
|
167
|
+
|
|
168
|
+
const titleShown = (spec: NonNullable<ListFormatSpec["section"]>): string =>
|
|
169
|
+
typeof spec.title === "string" ? spec.title : spec.example!
|
|
170
|
+
|
|
171
|
+
interface Region {
|
|
172
|
+
readonly start: number
|
|
173
|
+
readonly end: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The lines the list lives on, with the section-heading problems. */
|
|
177
|
+
const regionOf = (
|
|
178
|
+
spec: ListFormatSpec,
|
|
179
|
+
lines: readonly Line[],
|
|
180
|
+
): { readonly region?: Region; readonly problems: readonly SteeringFinding[] } => {
|
|
181
|
+
const section = spec.section
|
|
182
|
+
if (section === undefined) return { region: { start: 0, end: lines.length }, problems: [] }
|
|
183
|
+
const problems: SteeringFinding[] = []
|
|
184
|
+
const headings = lines.flatMap((line) => headingOf(line) ?? [])
|
|
185
|
+
const matching = headings.filter((h) => titleMatches(section, h.title))
|
|
186
|
+
const wanted = "#".repeat(section.level)
|
|
187
|
+
for (const h of matching.filter((h) => h.level !== section.level))
|
|
188
|
+
problems.push({
|
|
189
|
+
line: h.line,
|
|
190
|
+
message: `"${h.title}" is a level-${h.level} heading — write it as \`${wanted} ${h.title}\``,
|
|
191
|
+
})
|
|
192
|
+
const atLevel = matching.filter((h) => h.level === section.level)
|
|
193
|
+
for (const h of atLevel.slice(1))
|
|
194
|
+
problems.push({
|
|
195
|
+
line: h.line,
|
|
196
|
+
message: `"${h.title}" repeats the section on line ${atLevel[0]!.line + 1} — keep one`,
|
|
197
|
+
})
|
|
198
|
+
const first = atLevel[0]
|
|
199
|
+
if (first === undefined) {
|
|
200
|
+
if (section.required !== false && matching.length === 0)
|
|
201
|
+
problems.push({ message: `missing the \`${wanted} ${titleShown(section)}\` section` })
|
|
202
|
+
return { problems }
|
|
203
|
+
}
|
|
204
|
+
const next = headings.find((h) => h.line > first.line && h.level <= section.level)
|
|
205
|
+
return { region: { start: first.line + 1, end: next?.line ?? lines.length }, problems }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface Draft {
|
|
209
|
+
readonly line: number
|
|
210
|
+
parts: string[]
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** One pass over the region: items, their continuations, and every lint the format implies. */
|
|
214
|
+
// fallow-ignore-next-line complexity
|
|
215
|
+
const readItems = (
|
|
216
|
+
spec: ListFormatSpec,
|
|
217
|
+
lines: readonly Line[],
|
|
218
|
+
region: Region,
|
|
219
|
+
): { readonly drafts: readonly Draft[]; readonly problems: readonly SteeringFinding[] } => {
|
|
220
|
+
const markers = spec.markers ?? ["-"]
|
|
221
|
+
const wrap = spec.wrap ?? "join"
|
|
222
|
+
const problems: SteeringFinding[] = []
|
|
223
|
+
const drafts: Draft[] = []
|
|
224
|
+
let firstKind: ListMarker | undefined
|
|
225
|
+
let current: Draft | undefined
|
|
226
|
+
let blank = false
|
|
227
|
+
let prose: number[] = []
|
|
228
|
+
const continueWith = (line: Line, text: string): void => {
|
|
229
|
+
if (wrap === "forbid") {
|
|
230
|
+
problems.push({
|
|
231
|
+
line: line.index,
|
|
232
|
+
message: "one line per item — this line continues the one above",
|
|
233
|
+
})
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
current!.parts.push(text.trim())
|
|
237
|
+
}
|
|
238
|
+
for (const line of lines.slice(region.start, region.end)) {
|
|
239
|
+
const text = line.text
|
|
240
|
+
if (line.fenced) {
|
|
241
|
+
// A fence indented under an item is that item's code; any other fence ends the item.
|
|
242
|
+
if (current !== undefined && (line.fenceIndent ?? 0) >= 2) {
|
|
243
|
+
blank = false
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (line.opens === true && current !== undefined) {
|
|
247
|
+
prose.push(line.index)
|
|
248
|
+
current = undefined
|
|
249
|
+
} else if (line.opens !== true && ITEM_RE.test(text) && indentOf(text) < 2)
|
|
250
|
+
problems.push({
|
|
251
|
+
line: line.index,
|
|
252
|
+
message: "a list item inside a code fence is not read as an item",
|
|
253
|
+
})
|
|
254
|
+
blank = false
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
if (text.trim() === "") {
|
|
258
|
+
blank = true
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
const item = ITEM_RE.exec(text)
|
|
262
|
+
const indent = indentOf(text)
|
|
263
|
+
if (item !== null && indent < 2) {
|
|
264
|
+
for (const p of prose)
|
|
265
|
+
problems.push({
|
|
266
|
+
line: p,
|
|
267
|
+
message: "text between items — make it part of an item, or move it out of the list",
|
|
268
|
+
})
|
|
269
|
+
prose = []
|
|
270
|
+
const kind = markerKind(item[2]!)
|
|
271
|
+
if (!markers.includes(kind))
|
|
272
|
+
problems.push({
|
|
273
|
+
line: line.index,
|
|
274
|
+
message: `\`${item[2]}\` is not an allowed marker — use ${markerList(markers)}`,
|
|
275
|
+
})
|
|
276
|
+
else if (firstKind !== undefined && kind !== firstKind)
|
|
277
|
+
problems.push({
|
|
278
|
+
line: line.index,
|
|
279
|
+
message: `\`${item[2]}\` mixes markers in one list — the list started with \`${firstKind}\``,
|
|
280
|
+
})
|
|
281
|
+
firstKind ??= kind
|
|
282
|
+
current = { line: line.index, parts: [(item[3] ?? "").trim()] }
|
|
283
|
+
drafts.push(current)
|
|
284
|
+
blank = false
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (current === undefined) {
|
|
288
|
+
blank = false
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
if (item !== null) {
|
|
292
|
+
problems.push({
|
|
293
|
+
line: line.index,
|
|
294
|
+
message: "nested list — this format is flat, one level of items",
|
|
295
|
+
})
|
|
296
|
+
} else if (indent >= 2) {
|
|
297
|
+
continueWith(line, text)
|
|
298
|
+
} else if (!blank) {
|
|
299
|
+
problems.push({
|
|
300
|
+
line: line.index,
|
|
301
|
+
message:
|
|
302
|
+
"continuation line is not indented — indent it under its item, or separate it with a blank line",
|
|
303
|
+
})
|
|
304
|
+
continueWith(line, text)
|
|
305
|
+
} else {
|
|
306
|
+
prose.push(line.index)
|
|
307
|
+
current = undefined
|
|
308
|
+
}
|
|
309
|
+
blank = false
|
|
310
|
+
}
|
|
311
|
+
return { drafts, problems }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Strips a leading `[ ]`/`[x]`, reporting it where the format forbids or requires one. */
|
|
315
|
+
const readCheckbox = (
|
|
316
|
+
spec: ListFormatSpec,
|
|
317
|
+
draft: Draft,
|
|
318
|
+
problems: SteeringFinding[],
|
|
319
|
+
): { readonly text: string; readonly checked?: boolean } => {
|
|
320
|
+
const checkbox = spec.checkbox ?? "forbid"
|
|
321
|
+
const text = draft.parts.join(" ").trim()
|
|
322
|
+
const box = CHECKBOX_RE.exec(text)
|
|
323
|
+
if (box === null) {
|
|
324
|
+
if (checkbox === "require")
|
|
325
|
+
problems.push({
|
|
326
|
+
line: draft.line,
|
|
327
|
+
message: "every item starts with a checkbox — write `- [ ] …`",
|
|
328
|
+
})
|
|
329
|
+
return checkbox === "forbid" ? { text } : { text, checked: false }
|
|
330
|
+
}
|
|
331
|
+
if (checkbox === "forbid")
|
|
332
|
+
problems.push({ line: draft.line, message: "no checkboxes in this list — drop the `[ ]`" })
|
|
333
|
+
const rest = text.slice(box[0].length).trim()
|
|
334
|
+
return checkbox === "forbid" ? { text: rest } : { text: rest, checked: box[1] !== " " }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const finishItem = (
|
|
338
|
+
spec: ListFormatSpec,
|
|
339
|
+
draft: Draft,
|
|
340
|
+
problems: SteeringFinding[],
|
|
341
|
+
): ListItem | undefined => {
|
|
342
|
+
const { text, ...checked } = readCheckbox(spec, draft, problems)
|
|
343
|
+
const item = spec.item
|
|
344
|
+
if (text === "") {
|
|
345
|
+
problems.push({ line: draft.line, message: "empty item" })
|
|
346
|
+
return undefined
|
|
347
|
+
}
|
|
348
|
+
if (item === undefined) return { line: draft.line, text, ...checked }
|
|
349
|
+
const at = text.indexOf(item.separator)
|
|
350
|
+
const id = at < 0 ? "" : text.slice(0, at).trim()
|
|
351
|
+
if (at < 0 || !item.id.test(id)) {
|
|
352
|
+
problems.push({
|
|
353
|
+
line: draft.line,
|
|
354
|
+
message: `item has no ID — start it \`${item.example}${item.separator} …\``,
|
|
355
|
+
})
|
|
356
|
+
return undefined
|
|
357
|
+
}
|
|
358
|
+
const body = text.slice(at + item.separator.length).trim()
|
|
359
|
+
if (body === "") problems.push({ line: draft.line, message: `empty item — ${id} has no text` })
|
|
360
|
+
return { line: draft.line, text: body, id, ...checked }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const parseList = (spec: ListFormatSpec, content: string) => {
|
|
364
|
+
const walked = walk(content)
|
|
365
|
+
const { region, problems: sectionProblems } = regionOf(spec, walked.lines)
|
|
366
|
+
const problems: SteeringFinding[] = [...walked.problems, ...sectionProblems]
|
|
367
|
+
if (region === undefined) return { items: [], problems }
|
|
368
|
+
const read = readItems(spec, walked.lines, region)
|
|
369
|
+
problems.push(...read.problems)
|
|
370
|
+
const items = read.drafts.flatMap((draft) => finishItem(spec, draft, problems) ?? [])
|
|
371
|
+
if (spec.ids?.unique === true) {
|
|
372
|
+
const seen = new Map<string, number>()
|
|
373
|
+
for (const item of items) {
|
|
374
|
+
if (item.id === undefined) continue
|
|
375
|
+
const first = seen.get(item.id)
|
|
376
|
+
if (first === undefined) seen.set(item.id, item.line)
|
|
377
|
+
else
|
|
378
|
+
problems.push({
|
|
379
|
+
line: item.line,
|
|
380
|
+
message: `duplicate ID ${item.id} — first on line ${first + 1}`,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
problems.sort((a, b) => (a.line ?? -1) - (b.line ?? -1))
|
|
385
|
+
return { items, problems }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const sectionSentence = (spec: ListFormatSpec): string => {
|
|
389
|
+
if (spec.section === undefined) return "The whole document is the list."
|
|
390
|
+
const heading = `${"#".repeat(spec.section.level)} ${titleShown(spec.section)}`
|
|
391
|
+
return `Write the list under one \`${heading}\` heading; it ends at the next heading of the same or a higher level.`
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const CHECKBOX_SENTENCE = {
|
|
395
|
+
forbid: "Do not use checkboxes.",
|
|
396
|
+
strip: "",
|
|
397
|
+
require: "Start every item with a checkbox: `[ ]`, or `[x]` when done.",
|
|
398
|
+
} as const
|
|
399
|
+
|
|
400
|
+
const idSentence = (spec: ListFormatSpec): string => {
|
|
401
|
+
if (spec.item === undefined) return ""
|
|
402
|
+
const unique = spec.ids?.unique === true ? ", unique within the list" : ""
|
|
403
|
+
return `Start every item with its ID (like \`${spec.item.example}\`${unique}), then \`${spec.item.separator}\`, then the text.`
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const listInstructions = (spec: ListFormatSpec): string =>
|
|
407
|
+
[
|
|
408
|
+
sectionSentence(spec),
|
|
409
|
+
`Write each item as a ${markerList(spec.markers ?? ["-"])} list item, all with the same marker.`,
|
|
410
|
+
CHECKBOX_SENTENCE[spec.checkbox ?? "forbid"],
|
|
411
|
+
idSentence(spec),
|
|
412
|
+
(spec.wrap ?? "join") === "join"
|
|
413
|
+
? "Continue a long item on lines indented two spaces under it."
|
|
414
|
+
: "Keep every item on one line.",
|
|
415
|
+
"Keep the list flat: no nested items, and no text between items. Items inside a code fence are not read.",
|
|
416
|
+
]
|
|
417
|
+
.filter((sentence) => sentence !== "")
|
|
418
|
+
.join(" ")
|
|
419
|
+
|
|
420
|
+
const listSample = (spec: ListFormatSpec): string => {
|
|
421
|
+
const marker = (spec.markers ?? ["-"])[0] === "1." ? "1." : (spec.markers ?? ["-"])[0]!
|
|
422
|
+
const box = spec.checkbox === "require" ? "[ ] " : ""
|
|
423
|
+
const id = spec.item !== undefined ? `${spec.item.example}${spec.item.separator} ` : ""
|
|
424
|
+
const item = `${marker} ${box}${id}Describe the item.\n`
|
|
425
|
+
return spec.section === undefined
|
|
426
|
+
? item
|
|
427
|
+
: `${"#".repeat(spec.section.level)} ${titleShown(spec.section)}\n\n${item}`
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- sectionFormat -----------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
const bodyOf = (lines: readonly Line[], start: number, end: number): string =>
|
|
433
|
+
lines
|
|
434
|
+
.slice(start, end)
|
|
435
|
+
.map((l) => l.text)
|
|
436
|
+
.join("\n")
|
|
437
|
+
.trim()
|
|
438
|
+
|
|
439
|
+
/** The problem with heading `i` as a section, if any; `null` when it is not a section heading at all. */
|
|
440
|
+
const sectionHeadingProblem = (
|
|
441
|
+
spec: SectionFormatSpec,
|
|
442
|
+
headings: readonly Heading[],
|
|
443
|
+
i: number,
|
|
444
|
+
firstLine: ReadonlyMap<string, number>,
|
|
445
|
+
): string | null | undefined => {
|
|
446
|
+
const h = headings[i]!
|
|
447
|
+
if (h.level < spec.level && headings.slice(0, i).some((p) => p.level === spec.level))
|
|
448
|
+
return `"${h.title}" is a level-${h.level} heading — sections are \`${"#".repeat(spec.level)}\``
|
|
449
|
+
if (h.level !== spec.level) return null
|
|
450
|
+
if (h.title === "") return "section heading has no title"
|
|
451
|
+
const first = firstLine.get(h.title.toLowerCase())
|
|
452
|
+
return spec.title === "unique" && first !== undefined
|
|
453
|
+
? `"${h.title}" repeats the section on line ${first + 1} — titles are unique`
|
|
454
|
+
: undefined
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const parseSections = (spec: SectionFormatSpec, content: string) => {
|
|
458
|
+
const walked = walk(content)
|
|
459
|
+
const problems: SteeringFinding[] = [...walked.problems]
|
|
460
|
+
const headings = walked.lines.flatMap((line) => headingOf(line) ?? [])
|
|
461
|
+
const sections: Section[] = []
|
|
462
|
+
const firstLine = new Map<string, number>()
|
|
463
|
+
for (const [i, h] of headings.entries()) {
|
|
464
|
+
const problem = sectionHeadingProblem(spec, headings, i, firstLine)
|
|
465
|
+
if (problem === null) continue
|
|
466
|
+
if (problem !== undefined) problems.push({ line: h.line, message: problem })
|
|
467
|
+
if (h.level !== spec.level || h.title === "") continue
|
|
468
|
+
if (!firstLine.has(h.title.toLowerCase())) firstLine.set(h.title.toLowerCase(), h.line)
|
|
469
|
+
const end =
|
|
470
|
+
headings.slice(i + 1).find((n) => n.level <= spec.level)?.line ?? walked.lines.length
|
|
471
|
+
sections.push({ line: h.line, title: h.title, body: bodyOf(walked.lines, h.line + 1, end) })
|
|
472
|
+
}
|
|
473
|
+
problems.sort((a, b) => (a.line ?? -1) - (b.line ?? -1))
|
|
474
|
+
return { sections, problems }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const sectionInstructions = (spec: SectionFormatSpec): string => {
|
|
478
|
+
const hashes = "#".repeat(spec.level)
|
|
479
|
+
return [
|
|
480
|
+
`Write each section under a \`${hashes} Title\` heading.`,
|
|
481
|
+
spec.title === "unique" ? "No two sections share a title." : "",
|
|
482
|
+
spec.level > 1
|
|
483
|
+
? `Use deeper headings inside a section only; no heading above \`${hashes}\` after the first section.`
|
|
484
|
+
: "",
|
|
485
|
+
"Close every code fence.",
|
|
486
|
+
]
|
|
487
|
+
.filter((s) => s !== "")
|
|
488
|
+
.join(" ")
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// --- construction and the spec round-trip ------------------------------------
|
|
492
|
+
|
|
493
|
+
const NAME_RE = /^[a-z][a-z0-9-]*$/
|
|
494
|
+
|
|
495
|
+
type EncodedPattern = { readonly source: string; readonly flags: string }
|
|
496
|
+
|
|
497
|
+
// `g`/`y` make `test` stateful; a format matches each line afresh.
|
|
498
|
+
const pattern = (re: RegExp): RegExp => new RegExp(re.source, re.flags.replace(/[gy]/g, ""))
|
|
499
|
+
|
|
500
|
+
const encode = (value: unknown): unknown =>
|
|
501
|
+
value instanceof RegExp
|
|
502
|
+
? { source: value.source, flags: value.flags }
|
|
503
|
+
: Array.isArray(value)
|
|
504
|
+
? value.map(encode)
|
|
505
|
+
: typeof value === "object" && value !== null
|
|
506
|
+
? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, encode(v)]))
|
|
507
|
+
: value
|
|
508
|
+
|
|
509
|
+
const isPattern = (value: unknown): value is EncodedPattern =>
|
|
510
|
+
typeof value === "object" &&
|
|
511
|
+
value !== null &&
|
|
512
|
+
typeof (value as EncodedPattern).source === "string" &&
|
|
513
|
+
typeof (value as EncodedPattern).flags === "string"
|
|
514
|
+
|
|
515
|
+
const checkName = (name: unknown): string => {
|
|
516
|
+
if (typeof name !== "string" || !NAME_RE.test(name))
|
|
517
|
+
throw new Error(
|
|
518
|
+
`format name must be lowercase letters, digits and "-" — got ${JSON.stringify(name)}`,
|
|
519
|
+
)
|
|
520
|
+
return name
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const withThreads = <P extends { readonly problems: readonly SteeringFinding[] }>(
|
|
524
|
+
name: string,
|
|
525
|
+
spec: unknown,
|
|
526
|
+
kind: "list" | "section",
|
|
527
|
+
parse: (content: string) => P,
|
|
528
|
+
instructions: string,
|
|
529
|
+
sample: string,
|
|
530
|
+
): DeclaredFormat<Omit<P, "problems">> => ({
|
|
531
|
+
...freeFormFormat,
|
|
532
|
+
openThreads: openThreadsOf,
|
|
533
|
+
name,
|
|
534
|
+
spec: JSON.stringify({ kind, ...(encode(spec) as object) }),
|
|
535
|
+
instructions,
|
|
536
|
+
sample,
|
|
537
|
+
parse,
|
|
538
|
+
validate: (content) => [...parse(content).problems, ...threadFindings(content)],
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
/** Declares a list format: `parse`, `validate`, `instructions` and `sample` all derive from `spec`. Usable as a step's `mode`. */
|
|
542
|
+
export const listFormat = (spec: ListFormatSpec): ListFormat => {
|
|
543
|
+
const name = checkName(spec.name)
|
|
544
|
+
const normalized: ListFormatSpec = {
|
|
545
|
+
...spec,
|
|
546
|
+
...(spec.section !== undefined && typeof spec.section.title !== "string"
|
|
547
|
+
? { section: { ...spec.section, title: pattern(spec.section.title) } }
|
|
548
|
+
: {}),
|
|
549
|
+
...(spec.item !== undefined ? { item: { ...spec.item, id: pattern(spec.item.id) } } : {}),
|
|
550
|
+
}
|
|
551
|
+
if ((normalized.markers ?? ["-"]).length === 0)
|
|
552
|
+
throw new Error(`format "${name}": markers is empty`)
|
|
553
|
+
if (normalized.item !== undefined && !normalized.item.id.test(normalized.item.example))
|
|
554
|
+
throw new Error(
|
|
555
|
+
`format "${name}": item.example "${normalized.item.example}" does not match item.id`,
|
|
556
|
+
)
|
|
557
|
+
const section = normalized.section
|
|
558
|
+
if (
|
|
559
|
+
section !== undefined &&
|
|
560
|
+
typeof section.title !== "string" &&
|
|
561
|
+
(section.example === undefined || !section.title.test(section.example))
|
|
562
|
+
)
|
|
563
|
+
throw new Error(`format "${name}": a RegExp section.title needs a section.example it matches`)
|
|
564
|
+
const format = withThreads(
|
|
565
|
+
name,
|
|
566
|
+
normalized,
|
|
567
|
+
"list",
|
|
568
|
+
(content) => parseList(normalized, content),
|
|
569
|
+
listInstructions(normalized),
|
|
570
|
+
listSample(normalized),
|
|
571
|
+
)
|
|
572
|
+
if (format.parse(format.sample).problems.length > 0)
|
|
573
|
+
throw new Error(`format "${name}": its own sample does not validate`)
|
|
574
|
+
return format
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Declares a document of headed sections. Usable as a step's `mode`. */
|
|
578
|
+
export const sectionFormat = (spec: SectionFormatSpec): SectionFormat => {
|
|
579
|
+
const name = checkName(spec.name)
|
|
580
|
+
const hashes = "#".repeat(spec.level)
|
|
581
|
+
return withThreads(
|
|
582
|
+
name,
|
|
583
|
+
spec,
|
|
584
|
+
"section",
|
|
585
|
+
(content) => parseSections(spec, content),
|
|
586
|
+
sectionInstructions(spec),
|
|
587
|
+
`${hashes} First section\n\nWhat this section says.\n`,
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const decodePatterns = (value: unknown, key?: string): unknown =>
|
|
592
|
+
(key === "title" || key === "id") && isPattern(value)
|
|
593
|
+
? new RegExp(value.source, value.flags)
|
|
594
|
+
: Array.isArray(value)
|
|
595
|
+
? value.map((v) => decodePatterns(v))
|
|
596
|
+
: typeof value === "object" && value !== null
|
|
597
|
+
? Object.fromEntries(Object.entries(value).map(([k, v]) => [k, decodePatterns(v, k)]))
|
|
598
|
+
: value
|
|
599
|
+
|
|
600
|
+
/** The format a declared format's `spec` describes — what `gtd check --spec` validates with. Throws on a spec no declaration produces. */
|
|
601
|
+
export const formatFromSpec = (spec: string): ListFormat | SectionFormat => {
|
|
602
|
+
const decoded = decodePatterns(JSON.parse(spec)) as { readonly kind?: unknown }
|
|
603
|
+
const { kind, ...rest } = decoded as { kind?: unknown } & Record<string, unknown>
|
|
604
|
+
if (kind === "list") return listFormat(rest as unknown as ListFormatSpec)
|
|
605
|
+
if (kind === "section") return sectionFormat(rest as unknown as SectionFormatSpec)
|
|
606
|
+
throw new Error(`not a declared format spec: ${JSON.stringify(kind)}`)
|
|
607
|
+
}
|
package/src/steering/FORMATS.md
CHANGED
|
@@ -22,6 +22,9 @@ read them.
|
|
|
22
22
|
`# Dropped as noise` heading being the noise; each holds exactly one
|
|
23
23
|
`- [ ] fix` box. A ticked box fixes the finding; text below the box is the
|
|
24
24
|
human's guidance, valid only under a ticked one.
|
|
25
|
+
- **Declared formats**: a workflow's own `listFormat`/`sectionFormat`. Their
|
|
26
|
+
rules are not this contract: they apply to declared formats only, never to the
|
|
27
|
+
formats above.
|
|
25
28
|
- **Free-form**: any markdown, for a file with no mode or an unknown one. Only
|
|
26
29
|
its threads are validated.
|
|
27
30
|
- **Threads**, shared by all of them: a footnote whose definition is a list of
|
package/src/steering/index.ts
CHANGED
|
@@ -24,6 +24,18 @@ export type { InlineNode } from "./Inline.js"
|
|
|
24
24
|
|
|
25
25
|
export { freeFormFormat } from "./freeform.js"
|
|
26
26
|
|
|
27
|
+
export {
|
|
28
|
+
listFormat,
|
|
29
|
+
sectionFormat,
|
|
30
|
+
type ListFormat,
|
|
31
|
+
type ListFormatSpec,
|
|
32
|
+
type ListItem,
|
|
33
|
+
type ListMarker,
|
|
34
|
+
type Section,
|
|
35
|
+
type SectionFormat,
|
|
36
|
+
type SectionFormatSpec,
|
|
37
|
+
} from "./Declared.js"
|
|
38
|
+
|
|
27
39
|
export type {
|
|
28
40
|
BlockListItem,
|
|
29
41
|
SteeringAction,
|
package/src/steering/internal.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
BUILT_IN_MODE_NAMES,
|
|
34
34
|
formatFinding,
|
|
35
|
+
formatFromSpec,
|
|
35
36
|
steeringFormatFor,
|
|
36
37
|
type SteeringFormat,
|
|
37
38
|
} from "../steering/internal.js"
|
|
@@ -538,13 +539,14 @@ const recognizeModeContradictionCheck = (
|
|
|
538
539
|
|
|
539
540
|
const GTD_CHECK_RE = /^gtd check (\S+) (.+)$/
|
|
540
541
|
|
|
541
|
-
/** `gtd check <mode> <file
|
|
542
|
+
/** `gtd check <mode> <file> [--spec <json>]` — a non-empty findings array fails the script, mirroring a real invocation's non-zero exit under `set -e`. */
|
|
542
543
|
const recognizeGtdCheck = (repo: InMemRepo, block: string): BlockOutcome | undefined => {
|
|
543
544
|
const match = GTD_CHECK_RE.exec(block)
|
|
544
545
|
if (!match) return undefined
|
|
545
546
|
const mode = match[1]!
|
|
546
|
-
const [file] = extractQuotedTokens(match[2]!)
|
|
547
|
-
const
|
|
547
|
+
const [file, spec] = extractQuotedTokens(match[2]!)
|
|
548
|
+
const declared = / --spec '/.test(match[2]!)
|
|
549
|
+
const format = declared && spec !== undefined ? formatFromSpec(spec) : steeringFormatFor(mode)
|
|
548
550
|
if (format === undefined || file === undefined) {
|
|
549
551
|
const known = BUILT_IN_MODE_NAMES.join(", ")
|
|
550
552
|
return { kind: "failed", error: `gtd check: unknown mode "${mode}" — known modes: ${known}` }
|