@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
package/dist/steering.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
2
|
-
export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sections, steeringFormatFor, stripCodeThreads, threads, unansweredNotes };
|
|
1
|
+
import { A as findingEntries, C as reviewRisks, D as isAnswered, E as FREE_TEXT_PLACEHOLDER, I as formatFinding, M as parseFootnotes, N as parseThreads, O as FIX_BOX, P as parseThreadsWithFindings, S as reviewNotes, T as unansweredNotes, _ as ANSWER_LEAD, a as sections, b as reviewChunks, c as openThreads, f as listFormat, g as ANSWER_FIX, h as applySteeringEdits, i as sectionBodies, j as openThreadFindings, k as NOISE_HEADING, l as steeringFormatFor, m as freeFormFormat, n as stripCodeThreads, o as threads, p as sectionFormat, r as openQuestions, t as parseCodeThreads, v as ANSWER_WONT_FIX, w as reviewSections, x as reviewDecisions, y as REVIEW_SECTIONS } from "./chunks/steering-Cem5i6Xl.mjs";
|
|
2
|
+
export { ANSWER_FIX, ANSWER_LEAD, ANSWER_WONT_FIX, FIX_BOX, FREE_TEXT_PLACEHOLDER, NOISE_HEADING, REVIEW_SECTIONS, applySteeringEdits, findingEntries, formatFinding, freeFormFormat, isAnswered, listFormat, openQuestions, openThreadFindings, openThreads, parseCodeThreads, parseFootnotes, parseThreads, parseThreadsWithFindings, reviewChunks, reviewDecisions, reviewNotes, reviewRisks, reviewSections, sectionBodies, sectionFormat, sections, steeringFormatFor, stripCodeThreads, threads, unansweredNotes };
|
package/dist/testkit/gherkin.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-
|
|
1
|
+
import { n as runsItsScript, r as testRepo } from "../chunks/testkit-DfWZQCWQ.mjs";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { After, AfterAll, Given, QuickPickleWorld, Then, When, setWorldConstructor } from "quickpickle";
|
package/dist/testkit.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import { H as installContext } from "./chunks/flows-B7ZRCe-B.mjs";
|
|
2
|
-
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-
|
|
2
|
+
import { a as verdictWhen, i as verdict, n as runsItsScript, o as whenPrompted, r as testRepo, t as inTurn } from "./chunks/testkit-DfWZQCWQ.mjs";
|
|
3
3
|
export { inTurn, installContext, runsItsScript, testRepo, verdict, verdictWhen, whenPrompted };
|
package/dist/workflow-kit.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { B as head, F as changesSince, G as mark, P as changes, X as read, Z as refuse, d as hasThreadFor, et as scope, g as requireReplies, v as requireThreadsClosed } from "./chunks/flows-B7ZRCe-B.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { C as reviewRisks, N as parseThreads, S as reviewNotes, T as unansweredNotes, n as stripCodeThreads, x as reviewDecisions } from "./chunks/steering-Cem5i6Xl.mjs";
|
|
3
3
|
//#region src/workflow-kit/health.ts
|
|
4
4
|
/** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
|
|
5
5
|
const escalation = async (count, steps) => {
|
package/package.json
CHANGED
package/src/cli/Cli.ts
CHANGED
|
@@ -325,6 +325,16 @@ const FLAGS: readonly FlagRow[] = [
|
|
|
325
325
|
valueHint: "",
|
|
326
326
|
help: "Instead of <mode>'s findings, list the open threads in <file>; exits 1 when any are open. Alone, lists the open code-comment threads in the files the current process changed",
|
|
327
327
|
},
|
|
328
|
+
{
|
|
329
|
+
name: "--spec",
|
|
330
|
+
arity: 1,
|
|
331
|
+
repeatable: false,
|
|
332
|
+
scope: (kind) => kind === "check",
|
|
333
|
+
decode: ([raw]) => Result.succeed(raw ?? ""),
|
|
334
|
+
scopeError: "gtd: --spec is only valid for `gtd check`",
|
|
335
|
+
valueHint: "<json>",
|
|
336
|
+
help: "Check against the declared format this spec describes, named <mode>, instead of a built-in one; validation scripts pass it for a step whose mode is a declared format",
|
|
337
|
+
},
|
|
328
338
|
{
|
|
329
339
|
name: "--verbose",
|
|
330
340
|
arity: 0,
|
|
@@ -587,7 +597,7 @@ The fixed answerer reads --answers and leaves out the questions it does not cove
|
|
|
587
597
|
arity: { names: ["mode", "file"] },
|
|
588
598
|
group: "Driver plumbing",
|
|
589
599
|
summary: "Check a steering file's format",
|
|
590
|
-
details: `Run the steering format <mode> (qa, review or findings) over <file> and print each finding on a line of its own; exits 1 when there are any, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
|
|
600
|
+
details: `Run the steering format <mode> (qa, review or findings, or the declared format --spec describes) over <file> and print each finding on a line of its own; exits 1 when there are any, or when <file> does not exist. An unknown <mode> is a usage error (exit 2). Reads no workflow and runs from any directory — emitted validation scripts call it.`,
|
|
591
601
|
},
|
|
592
602
|
{
|
|
593
603
|
token: "uncheck",
|
|
@@ -1124,6 +1134,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1124
1134
|
readonly "--threshold"?: Readonly<Record<string, string>>
|
|
1125
1135
|
readonly "--open-questions"?: boolean
|
|
1126
1136
|
readonly "--open-threads"?: boolean
|
|
1137
|
+
readonly "--spec"?: string
|
|
1127
1138
|
readonly "--host"?: string
|
|
1128
1139
|
readonly "--self-signed"?: boolean
|
|
1129
1140
|
readonly "--dev"?: boolean
|
|
@@ -1199,6 +1210,7 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
|
1199
1210
|
? { openQuestions: bag["--open-questions"] }
|
|
1200
1211
|
: {}),
|
|
1201
1212
|
...(bag["--open-threads"] !== undefined ? { openThreads: bag["--open-threads"] } : {}),
|
|
1213
|
+
...(bag["--spec"] !== undefined ? { spec: bag["--spec"] } : {}),
|
|
1202
1214
|
},
|
|
1203
1215
|
json,
|
|
1204
1216
|
verbose,
|
package/src/cli/Program.ts
CHANGED
|
@@ -70,6 +70,7 @@ import { formatCommitMessage, PIN_STATES } from "../replay/index.js"
|
|
|
70
70
|
import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
|
|
71
71
|
import {
|
|
72
72
|
BUILT_IN_MODE_NAMES,
|
|
73
|
+
formatFromSpec,
|
|
73
74
|
steeringFormatFor,
|
|
74
75
|
openThreads,
|
|
75
76
|
parseCodeThreads,
|
|
@@ -169,6 +170,8 @@ export type Command =
|
|
|
169
170
|
readonly file?: string
|
|
170
171
|
readonly openQuestions?: boolean
|
|
171
172
|
readonly openThreads?: boolean
|
|
173
|
+
/** A declared format's spec: `mode` names it, and no built-in is looked up. */
|
|
174
|
+
readonly spec?: string
|
|
172
175
|
}
|
|
173
176
|
| { readonly kind: "uncheck"; readonly file: string }
|
|
174
177
|
| { readonly kind: "guide" }
|
|
@@ -903,8 +906,9 @@ const selfValidateCommand = (
|
|
|
903
906
|
state: string,
|
|
904
907
|
mode: StateMode,
|
|
905
908
|
file: string,
|
|
909
|
+
spec: string | undefined,
|
|
906
910
|
): string => {
|
|
907
|
-
const resolved = resolveMode(def, state, mode)
|
|
911
|
+
const resolved = resolveMode(def, state, mode, spec)
|
|
908
912
|
const validate = resolved.kind === "resolved" ? resolved.validate : undefined
|
|
909
913
|
const command = validate?.kind === "command" ? validate.command : seededValidateCommand(mode)
|
|
910
914
|
return commandForFile(command, file)
|
|
@@ -934,7 +938,7 @@ const resolveValidateScript = (
|
|
|
934
938
|
const mode = rest.stepDef.mode
|
|
935
939
|
if (file === undefined || mode === undefined) return undefined
|
|
936
940
|
|
|
937
|
-
const resolved = resolveMode(rest.def, rest.state, mode)
|
|
941
|
+
const resolved = resolveMode(rest.def, rest.state, mode, rest.stepDef.modeSpec)
|
|
938
942
|
if (resolved.kind === "unknown") {
|
|
939
943
|
return yield* Effect.fail(new Error(resolved.message))
|
|
940
944
|
}
|
|
@@ -976,7 +980,13 @@ const runNextCommand = (
|
|
|
976
980
|
yield* writeSelection(out, fields, json.path)
|
|
977
981
|
} else {
|
|
978
982
|
const validateCommand = emitsValidatablePrompt(rendered)
|
|
979
|
-
? selfValidateCommand(
|
|
983
|
+
? selfValidateCommand(
|
|
984
|
+
rest.def,
|
|
985
|
+
rest.state,
|
|
986
|
+
rendered.mode!,
|
|
987
|
+
rendered.file!,
|
|
988
|
+
rest.stepDef.modeSpec,
|
|
989
|
+
)
|
|
980
990
|
: undefined
|
|
981
991
|
if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
|
|
982
992
|
// A prompt's stdout is the agent's input, so its header goes to stderr;
|
|
@@ -1019,9 +1029,10 @@ const runCheckCommand = (
|
|
|
1019
1029
|
file: string,
|
|
1020
1030
|
openQuestions: boolean,
|
|
1021
1031
|
openThreadsFlag: boolean,
|
|
1032
|
+
spec: string | undefined,
|
|
1022
1033
|
): Effect.Effect<void, Error, Workspace> =>
|
|
1023
1034
|
Effect.gen(function* () {
|
|
1024
|
-
const format = steeringFormatFor(mode)
|
|
1035
|
+
const format = spec !== undefined ? yield* declaredFormat(spec) : steeringFormatFor(mode)
|
|
1025
1036
|
if (format === undefined) {
|
|
1026
1037
|
return yield* Effect.fail(
|
|
1027
1038
|
new GtdUsageError(
|
|
@@ -1071,6 +1082,13 @@ const runCheckCommand = (
|
|
|
1071
1082
|
)
|
|
1072
1083
|
})
|
|
1073
1084
|
|
|
1085
|
+
const declaredFormat = (spec: string): Effect.Effect<SteeringFormat, GtdUsageError> =>
|
|
1086
|
+
Effect.try({
|
|
1087
|
+
try: () => formatFromSpec(spec),
|
|
1088
|
+
catch: (error) =>
|
|
1089
|
+
new GtdUsageError(`gtd check: --spec is not a declared format: ${(error as Error).message}`),
|
|
1090
|
+
})
|
|
1091
|
+
|
|
1074
1092
|
const existingFile = (file: string): Effect.Effect<string, Error, Workspace> =>
|
|
1075
1093
|
Effect.flatMap(Workspace, (workspace) => {
|
|
1076
1094
|
const content = workspace.atPath(file)
|
|
@@ -1384,6 +1402,7 @@ const dispatchVoidCommand = (
|
|
|
1384
1402
|
command.file,
|
|
1385
1403
|
command.openQuestions ?? false,
|
|
1386
1404
|
command.openThreads ?? false,
|
|
1405
|
+
command.spec,
|
|
1387
1406
|
)
|
|
1388
1407
|
case "uncheck":
|
|
1389
1408
|
return runUncheckCommand(command.file)
|
package/src/edge/Edge.ts
CHANGED
|
@@ -705,7 +705,7 @@ const DEFAULT_JUDGE_MESSAGE =
|
|
|
705
705
|
const optional = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } =>
|
|
706
706
|
(value === undefined ? {} : { [key]: value }) as { [P in K]?: V }
|
|
707
707
|
|
|
708
|
-
type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode">
|
|
708
|
+
type StepCommon = Pick<StepDef, "actor" | "label" | "file" | "mode" | "modeSpec">
|
|
709
709
|
|
|
710
710
|
const commonOf = (step: ReachedStep): StepCommon => {
|
|
711
711
|
const options = step.request.options
|
|
@@ -713,7 +713,9 @@ const commonOf = (step: ReachedStep): StepCommon => {
|
|
|
713
713
|
actor: step.actor,
|
|
714
714
|
...optional("label", options.label),
|
|
715
715
|
...optional("file", options.file),
|
|
716
|
-
...
|
|
716
|
+
...(typeof options.mode === "object"
|
|
717
|
+
? { mode: options.mode.name, modeSpec: options.mode.spec }
|
|
718
|
+
: optional("mode", options.mode)),
|
|
717
719
|
}
|
|
718
720
|
}
|
|
719
721
|
|
|
@@ -833,6 +835,23 @@ const hintsOf = (def: StepDef): RestHints => ({
|
|
|
833
835
|
...optional("access", def.access),
|
|
834
836
|
})
|
|
835
837
|
|
|
838
|
+
/** A step's mode is a name the workflow knows, or a declared format named apart from all of them. */
|
|
839
|
+
const modeFaultOf = (
|
|
840
|
+
def: WorkflowDefinition,
|
|
841
|
+
stepName: string,
|
|
842
|
+
stepDef: StepDef,
|
|
843
|
+
): string | undefined => {
|
|
844
|
+
if (stepDef.mode === undefined) return undefined
|
|
845
|
+
const known = def.modes[stepDef.mode] !== undefined
|
|
846
|
+
if (stepDef.modeSpec !== undefined)
|
|
847
|
+
return known
|
|
848
|
+
? `gtd: config: step "${stepName}": declared format "${stepDef.mode}" has the name of a mode this workflow already knows — rename the format`
|
|
849
|
+
: undefined
|
|
850
|
+
return known
|
|
851
|
+
? undefined
|
|
852
|
+
: `gtd: config: step "${stepName}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`
|
|
853
|
+
}
|
|
854
|
+
|
|
836
855
|
const normalizeStatus = (raw: string): ChangeStatus => (raw === "A" ? "A" : raw === "D" ? "D" : "M")
|
|
837
856
|
|
|
838
857
|
/** The currently rested step and its description — enough for the viewer and the LSP. */
|
|
@@ -997,13 +1016,8 @@ export const restAt = (
|
|
|
997
1016
|
const { reached, step } = resting
|
|
998
1017
|
const { stepDef, state, memory } = restFacts(pin, step, reached.trace, run)
|
|
999
1018
|
yield* (yield* Narrator).narrate(`rest resolved: ${state} (awaits ${stepDef.actor})`)
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
new Error(
|
|
1003
|
-
`gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
|
|
1004
|
-
),
|
|
1005
|
-
)
|
|
1006
|
-
}
|
|
1019
|
+
const modeFault = modeFaultOf(def, step.name, stepDef)
|
|
1020
|
+
if (modeFault !== undefined) return yield* Effect.fail(new Error(modeFault))
|
|
1007
1021
|
const changes =
|
|
1008
1022
|
ref === undefined
|
|
1009
1023
|
? (yield* pendingChanges(git, stepDef.approve === true)).map((e) => ({
|
package/src/emit/SteeringMode.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { Effect, Path } from "effect"
|
|
2
2
|
import { Host, type HostOps } from "../platform/index.js"
|
|
3
3
|
import {
|
|
4
|
+
declaredValidateCommand,
|
|
4
5
|
isSeededValidateCommand,
|
|
5
6
|
knownModes,
|
|
6
7
|
type StateMode,
|
|
7
8
|
type WorkflowDefinition,
|
|
8
9
|
} from "../workflow/index.js"
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
formatFromSpec,
|
|
12
|
+
steeringFormatFor,
|
|
13
|
+
type SteeringFinding,
|
|
14
|
+
type SteeringFormat,
|
|
15
|
+
} from "../steering/internal.js"
|
|
10
16
|
import { buildModeContradictionCheck, modeContradictionSkipNotice } from "./ModeContradiction.js"
|
|
11
17
|
import {
|
|
12
18
|
binaryGuard,
|
|
@@ -101,12 +107,26 @@ const capabilitiesFor = (
|
|
|
101
107
|
* The `"unknown"` arm carries the whole refusal message, naming what IS known
|
|
102
108
|
* when `def` is given — this is the one place that message is built, so it
|
|
103
109
|
* can never drift from what actually resolved.
|
|
110
|
+
*
|
|
111
|
+
* `spec` is a declared format's: it alone decides, validating through
|
|
112
|
+
* `gtd check --spec` with no `format:` half.
|
|
104
113
|
*/
|
|
105
114
|
export const resolveMode = (
|
|
106
115
|
def: Pick<WorkflowDefinition, "modes"> | undefined,
|
|
107
116
|
state: string,
|
|
108
117
|
mode: StateMode,
|
|
118
|
+
spec?: string,
|
|
109
119
|
): ModeResolution => {
|
|
120
|
+
if (spec !== undefined) {
|
|
121
|
+
const format = formatFromSpec(spec)
|
|
122
|
+
return {
|
|
123
|
+
kind: "resolved",
|
|
124
|
+
mode,
|
|
125
|
+
format,
|
|
126
|
+
validate: { kind: "command", command: declaredValidateCommand(mode, spec) },
|
|
127
|
+
capabilities: { format, liveValidate: format.validate },
|
|
128
|
+
}
|
|
129
|
+
}
|
|
110
130
|
const declared = def?.modes?.[mode]
|
|
111
131
|
const format = steeringFormatFor(mode)
|
|
112
132
|
if (declared === undefined && format === undefined) {
|
package/src/flows/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type AgentOptions,
|
|
20
20
|
type HumanOptions,
|
|
21
21
|
type Judgment,
|
|
22
|
+
type DeclaredMode,
|
|
22
23
|
type ModeName,
|
|
23
24
|
type RunOptions,
|
|
24
25
|
type Settings,
|
|
@@ -315,6 +316,6 @@ describe("mode", () => {
|
|
|
315
316
|
expectTypeOf<"qa">().toMatchTypeOf<ModeName>()
|
|
316
317
|
expectTypeOf<string>().toMatchTypeOf<ModeName>()
|
|
317
318
|
expectTypeOf<ModeName>().toMatchTypeOf<string>()
|
|
318
|
-
expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | undefined>()
|
|
319
|
+
expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | DeclaredMode | undefined>()
|
|
319
320
|
})
|
|
320
321
|
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -8,6 +8,12 @@ import type { WorkflowBuild } from "./factory.js"
|
|
|
8
8
|
/** A steering-file mode: a built-in format's name or a `config.modes` entry. */
|
|
9
9
|
export type ModeName = "qa" | "review" | "findings" | (string & {})
|
|
10
10
|
|
|
11
|
+
/** A format declared with `listFormat`/`sectionFormat` from `@pmelab/gtd/steering`, used as a step's mode. */
|
|
12
|
+
export interface DeclaredMode {
|
|
13
|
+
readonly name: string
|
|
14
|
+
readonly spec: string
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
interface SteeringCommon {
|
|
12
18
|
/** Display name for drivers and viewers. */
|
|
13
19
|
readonly label?: string | undefined
|
|
@@ -24,8 +30,8 @@ type SteeringFile =
|
|
|
24
30
|
}
|
|
25
31
|
| {
|
|
26
32
|
readonly file: string
|
|
27
|
-
/** The steering file's mode — a built-in name
|
|
28
|
-
readonly mode: ModeName
|
|
33
|
+
/** The steering file's mode — a built-in name, a `modes:` entry, or a declared format. Requires `file`. */
|
|
34
|
+
readonly mode: ModeName | DeclaredMode
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { formatFinding, listFormat, sectionFormat } from "./index.js"
|
|
3
|
+
import { formatFromSpec, steeringFormatFor } from "./internal.js"
|
|
4
|
+
|
|
5
|
+
const criteria = listFormat({
|
|
6
|
+
name: "criteria",
|
|
7
|
+
section: { title: /^acceptance criteria$/i, example: "Acceptance criteria", level: 2 },
|
|
8
|
+
item: { id: /^AC-\d+$/, separator: ":", example: "AC-1" },
|
|
9
|
+
ids: { unique: true },
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const doc = (...lines: string[]): string => `${lines.join("\n")}\n`
|
|
13
|
+
|
|
14
|
+
const problems = (format: { validate: (c: string) => readonly unknown[] }, content: string) =>
|
|
15
|
+
format.validate(content).map((f) => formatFinding("F.md", f as never))
|
|
16
|
+
|
|
17
|
+
describe("listFormat", () => {
|
|
18
|
+
it("parses ID'd items, joining indented continuation lines", () => {
|
|
19
|
+
const parsed = criteria.parse(
|
|
20
|
+
doc(
|
|
21
|
+
"# Plan",
|
|
22
|
+
"",
|
|
23
|
+
"## Acceptance criteria",
|
|
24
|
+
"",
|
|
25
|
+
"- AC-1: first",
|
|
26
|
+
" wrapped",
|
|
27
|
+
"- AC-2: second",
|
|
28
|
+
"",
|
|
29
|
+
"## Next",
|
|
30
|
+
"",
|
|
31
|
+
"- not read",
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
expect(parsed.problems).toEqual([])
|
|
35
|
+
expect(parsed.items).toEqual([
|
|
36
|
+
{ line: 4, id: "AC-1", text: "first wrapped" },
|
|
37
|
+
{ line: 6, id: "AC-2", text: "second" },
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("validates its own sample, and describes itself", () => {
|
|
42
|
+
expect(criteria.validate(criteria.sample)).toEqual([])
|
|
43
|
+
expect(criteria.sample).toBe("## Acceptance criteria\n\n- AC-1: Describe the item.\n")
|
|
44
|
+
expect(criteria.instructions).toContain("`## Acceptance criteria`")
|
|
45
|
+
expect(criteria.instructions).toContain("`AC-1`")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("reports a missing, repeated or misleveled section", () => {
|
|
49
|
+
expect(problems(criteria, doc("# Plan"))).toEqual([
|
|
50
|
+
"missing the `## Acceptance criteria` section",
|
|
51
|
+
])
|
|
52
|
+
expect(problems(criteria, doc("### Acceptance criteria", "", "- AC-1: a"))).toEqual([
|
|
53
|
+
'F.md:1: "Acceptance criteria" is a level-3 heading — write it as `## Acceptance criteria`',
|
|
54
|
+
])
|
|
55
|
+
expect(
|
|
56
|
+
problems(
|
|
57
|
+
criteria,
|
|
58
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "", "## Acceptance criteria"),
|
|
59
|
+
),
|
|
60
|
+
).toEqual(['F.md:5: "Acceptance criteria" repeats the section on line 1 — keep one'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("reports markers it does not allow, and mixed markers", () => {
|
|
64
|
+
const numbered = listFormat({ name: "steps", markers: ["-", "1."] })
|
|
65
|
+
expect(problems(criteria, doc("## Acceptance criteria", "", "* AC-1: a"))).toEqual([
|
|
66
|
+
"F.md:3: `*` is not an allowed marker — use `-`",
|
|
67
|
+
])
|
|
68
|
+
expect(problems(numbered, doc("- a", "1. b"))).toEqual([
|
|
69
|
+
"F.md:2: `1.` mixes markers in one list — the list started with `-`",
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("reads a numbered list it allows rather than parsing it as empty", () => {
|
|
74
|
+
const numbered = listFormat({ name: "signals", markers: ["1."] })
|
|
75
|
+
expect(numbered.parse(doc("1. a", "2. b")).items.map((i) => i.text)).toEqual(["a", "b"])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("reports an unindented continuation line", () => {
|
|
79
|
+
expect(problems(criteria, doc("## Acceptance criteria", "", "- AC-1: a", "b"))).toEqual([
|
|
80
|
+
"F.md:4: continuation line is not indented — indent it under its item, or separate it with a blank line",
|
|
81
|
+
])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("never reads a fenced item, and reports an unclosed fence", () => {
|
|
85
|
+
const parsed = criteria.parse(
|
|
86
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "", "```", "- AC-2: b", "```"),
|
|
87
|
+
)
|
|
88
|
+
expect(parsed.items.map((i) => i.id)).toEqual(["AC-1"])
|
|
89
|
+
expect(parsed.problems.map((p) => p.message)).toContain(
|
|
90
|
+
"a list item inside a code fence is not read as an item",
|
|
91
|
+
)
|
|
92
|
+
expect(
|
|
93
|
+
problems(criteria, doc("## Acceptance criteria", "", "- AC-1: a", "", "```", "x")),
|
|
94
|
+
).toEqual(["F.md:5: code fence is never closed"])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("keeps a fence indented under its item as part of the item", () => {
|
|
98
|
+
expect(
|
|
99
|
+
problems(
|
|
100
|
+
criteria,
|
|
101
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "", " ```", " - x", " ```", "- AC-2: b"),
|
|
102
|
+
),
|
|
103
|
+
).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("reports duplicate and missing IDs, and empty items", () => {
|
|
107
|
+
expect(
|
|
108
|
+
problems(
|
|
109
|
+
criteria,
|
|
110
|
+
doc("## Acceptance criteria", "", "- AC-1: a", "- AC-1: b", "- c", "- AC-3:"),
|
|
111
|
+
),
|
|
112
|
+
).toEqual([
|
|
113
|
+
"F.md:4: duplicate ID AC-1 — first on line 3",
|
|
114
|
+
"F.md:5: item has no ID — start it `AC-1: …`",
|
|
115
|
+
"F.md:6: empty item — AC-3 has no text",
|
|
116
|
+
])
|
|
117
|
+
expect(problems(listFormat({ name: "plain" }), doc("- a", "-"))).toEqual(["F.md:2: empty item"])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("reports text between items and nesting", () => {
|
|
121
|
+
expect(
|
|
122
|
+
problems(
|
|
123
|
+
criteria,
|
|
124
|
+
doc(
|
|
125
|
+
"## Acceptance criteria",
|
|
126
|
+
"",
|
|
127
|
+
"- AC-1: a",
|
|
128
|
+
"",
|
|
129
|
+
"Aside.",
|
|
130
|
+
"",
|
|
131
|
+
"- AC-2: b",
|
|
132
|
+
" - AC-3: c",
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
).toEqual([
|
|
136
|
+
"F.md:5: text between items — make it part of an item, or move it out of the list",
|
|
137
|
+
"F.md:8: nested list — this format is flat, one level of items",
|
|
138
|
+
])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("forbids, strips or requires checkboxes", () => {
|
|
142
|
+
expect(problems(criteria, doc("## Acceptance criteria", "", "- [ ] AC-1: a"))).toEqual([
|
|
143
|
+
"F.md:3: no checkboxes in this list — drop the `[ ]`",
|
|
144
|
+
])
|
|
145
|
+
const todo = listFormat({ name: "todo", checkbox: "require" })
|
|
146
|
+
expect(todo.parse(doc("- [x] a")).items).toEqual([{ line: 0, text: "a", checked: true }])
|
|
147
|
+
expect(problems(todo, doc("- a"))).toEqual([
|
|
148
|
+
"F.md:1: every item starts with a checkbox — write `- [ ] …`",
|
|
149
|
+
])
|
|
150
|
+
const stripped = listFormat({ name: "stripped", checkbox: "strip" })
|
|
151
|
+
expect(stripped.parse(doc("- [ ] a", "- b")).items.map((i) => i.checked)).toEqual([
|
|
152
|
+
false,
|
|
153
|
+
false,
|
|
154
|
+
])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("forbids wrapping when told to", () => {
|
|
158
|
+
const flat = listFormat({ name: "flat", wrap: "forbid" })
|
|
159
|
+
expect(problems(flat, doc("- a", " b"))).toEqual([
|
|
160
|
+
"F.md:2: one line per item — this line continues the one above",
|
|
161
|
+
])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it("refuses an inconsistent declaration", () => {
|
|
165
|
+
expect(() => listFormat({ name: "Bad Name" })).toThrow("format name")
|
|
166
|
+
expect(() =>
|
|
167
|
+
listFormat({ name: "x", item: { id: /^A\d$/, separator: ":", example: "B1" } }),
|
|
168
|
+
).toThrow("does not match")
|
|
169
|
+
expect(() => listFormat({ name: "x", section: { title: /a/, level: 2 } })).toThrow("example")
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe("sectionFormat", () => {
|
|
174
|
+
const findings = sectionFormat({ name: "notes", level: 2, title: "unique" })
|
|
175
|
+
|
|
176
|
+
it("parses sections with their bodies", () => {
|
|
177
|
+
expect(
|
|
178
|
+
findings.parse(doc("# T", "", "## A", "", "one", "### deeper", "## B")).sections,
|
|
179
|
+
).toEqual([
|
|
180
|
+
{ line: 2, title: "A", body: "one\n### deeper" },
|
|
181
|
+
{ line: 6, title: "B", body: "" },
|
|
182
|
+
])
|
|
183
|
+
expect(findings.validate(findings.sample)).toEqual([])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("reports repeated titles, a shallower heading, and a heading in an unclosed fence", () => {
|
|
187
|
+
expect(problems(findings, doc("## A", "## a", "# Up", "```", "## C"))).toEqual([
|
|
188
|
+
'F.md:2: "a" repeats the section on line 1 — titles are unique',
|
|
189
|
+
'F.md:3: "Up" is a level-1 heading — sections are `##`',
|
|
190
|
+
"F.md:4: code fence is never closed",
|
|
191
|
+
])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe("formatFromSpec", () => {
|
|
196
|
+
it("rebuilds the same format from its spec, RegExps included", () => {
|
|
197
|
+
const rebuilt = formatFromSpec(criteria.spec)
|
|
198
|
+
const content = doc("## Acceptance criteria", "", "- AC-1: a", "- AC-1: b", "* x")
|
|
199
|
+
expect(rebuilt.name).toBe("criteria")
|
|
200
|
+
expect(rebuilt.validate(content)).toEqual(criteria.validate(content))
|
|
201
|
+
const notes = sectionFormat({ name: "notes", level: 2, title: "unique" })
|
|
202
|
+
expect(formatFromSpec(notes.spec).validate("## A\n## A\n")).toEqual(
|
|
203
|
+
notes.validate("## A\n## A\n"),
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it("throws on a spec no declaration produces", () => {
|
|
208
|
+
expect(() => formatFromSpec('{"kind":"nope"}')).toThrow("not a declared format spec")
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("the built-in formats", () => {
|
|
213
|
+
it("are not declared formats — none of these rules apply to them", () => {
|
|
214
|
+
expect(
|
|
215
|
+
steeringFormatFor("findings")!.validate("## A\n\n- [ ] fix\n\n## A\n\n- [ ] fix\n"),
|
|
216
|
+
).toEqual([])
|
|
217
|
+
})
|
|
218
|
+
})
|