@pmelab/gtd 59.0.1 → 60.0.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 +37 -33
- package/claude/hooks/doors.ts +8 -4
- package/dist/chunks/flows-DENt0uWV.mjs +707 -0
- package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
- package/dist/chunks/{testkit-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +751 -645
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +245 -36
- package/dist/testkit.mjs +3 -2
- package/dist/web/main.js +234 -232
- package/dist/wire.mjs +2 -2
- package/dist/workflow-kit.mjs +3 -3
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +66 -60
- package/src/cli/Cli.test.ts +183 -182
- package/src/cli/Cli.ts +164 -186
- package/src/cli/History.ts +9 -4
- package/src/cli/Judges.ts +1 -1
- package/src/cli/Output.test.ts +35 -0
- package/src/cli/Output.ts +31 -1
- package/src/cli/Program.test.ts +65 -79
- package/src/cli/Program.ts +157 -118
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +3 -3
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +188 -54
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/replay/Replay.test.ts +13 -2
- package/src/replay/Replay.ts +19 -30
- package/src/replay/judgeDiffShape.test.ts +1 -1
- package/src/steering/Footnotes.test.ts +1 -1
- package/src/steering/SteeringFormats.test.ts +1 -1
- package/src/steering/freeform.test.ts +1 -1
- package/src/steering/index.test.ts +27 -1
- package/src/steering/index.ts +6 -104
- package/src/steering/internal.ts +11 -0
- package/src/steering/qa.test.ts +7 -2
- package/src/steering/reads.test.ts +42 -0
- package/src/steering/reads.ts +43 -0
- package/src/steering/registry.ts +93 -0
- package/src/steering/review.test.ts +2 -2
- package/src/step/planStart.test.ts +18 -16
- package/src/step/planStart.ts +9 -9
- package/src/testkit/TestRepo.test.ts +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +2 -6
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Script.ts +11 -0
- package/src/wire/WireSchema.test.ts +3 -0
- package/src/wire/golden/1/script.json +4 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/SteeringFormats.test.ts +1 -1
- package/src/workflow/compile.ts +2 -2
- package/src/workflow/discovery.test.ts +3 -3
- package/src/workflow/load.test.ts +256 -116
- package/src/workflow/load.ts +169 -146
- package/src/workflow-kit/index.test.ts +25 -0
- package/src/workflow-kit/index.ts +37 -4
- package/src/workflow-kit/review.ts +2 -2
|
@@ -72,16 +72,11 @@ let projectDir: string
|
|
|
72
72
|
|
|
73
73
|
// A config whose default workflow rests at `idle`, declaring a process
|
|
74
74
|
// setting, an environment setting and the scopes its turns run under.
|
|
75
|
-
const BASE_CONFIG = `import { human } from "@pmelab/gtd/flows"
|
|
75
|
+
const BASE_CONFIG = `import { human, workflow } from "@pmelab/gtd/flows"
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
export const defaults = { qualityReviews: "" }
|
|
82
|
-
export const envDefaults = { testCommand: "npm test" }
|
|
83
|
-
|
|
84
|
-
export const skills = (vars) => ({
|
|
77
|
+
const defaults = { qualityReviews: "" }
|
|
78
|
+
const envDefaults = { testCommand: "npm test" }
|
|
79
|
+
const skills = (vars) => ({
|
|
85
80
|
build: [],
|
|
86
81
|
"build.fix.quality": [],
|
|
87
82
|
"build.review": [],
|
|
@@ -93,6 +88,11 @@ export const skills = (vars) => ({
|
|
|
93
88
|
.map((lens) => [\`build.quality.\${lens}\`, []]),
|
|
94
89
|
),
|
|
95
90
|
})
|
|
91
|
+
|
|
92
|
+
export default workflow(async () => {
|
|
93
|
+
await human("idle", { message: "hi" })
|
|
94
|
+
}, { defaults, envDefaults, skills })
|
|
95
|
+
|
|
96
96
|
`
|
|
97
97
|
|
|
98
98
|
// Workflow factories a config imports from a sibling module: string options
|
|
@@ -190,6 +190,17 @@ const minimalWorkflow = (first: string) =>
|
|
|
190
190
|
``,
|
|
191
191
|
].join("\n")
|
|
192
192
|
|
|
193
|
+
// The same one-step workflow, carrying `options` (source text) as its build options.
|
|
194
|
+
const optionedWorkflow = (first: string, options: string) =>
|
|
195
|
+
[
|
|
196
|
+
`import { human, workflow } from "@pmelab/gtd/flows"`,
|
|
197
|
+
``,
|
|
198
|
+
`export default workflow(async () => {`,
|
|
199
|
+
` await human("${first}")`,
|
|
200
|
+
`}, ${options})`,
|
|
201
|
+
``,
|
|
202
|
+
].join("\n")
|
|
203
|
+
|
|
193
204
|
const withConfig = (config: string) =>
|
|
194
205
|
writeFileSync(
|
|
195
206
|
join(projectDir, "gtd.config.ts"),
|
|
@@ -205,11 +216,11 @@ describe("ConfigService", () => {
|
|
|
205
216
|
expect(cfg.defaultWorkflow.vars["testCommand"]).toBeUndefined()
|
|
206
217
|
})
|
|
207
218
|
|
|
208
|
-
it("with no gtd.config.ts anywhere: fails, pointing at gtd
|
|
219
|
+
it("with no gtd.config.ts anywhere: fails, pointing at gtd guide", async () => {
|
|
209
220
|
rmSync(join(projectDir, "gtd.config.ts"))
|
|
210
221
|
|
|
211
222
|
await expect(getConfig()).rejects.toThrow(
|
|
212
|
-
/no workflows configured: no gtd\.config\.ts here or above — gtd ships no workflow; `gtd
|
|
223
|
+
/no workflows configured: no gtd\.config\.ts here or above — gtd ships no workflow; `gtd guide` says where to get one/,
|
|
213
224
|
)
|
|
214
225
|
})
|
|
215
226
|
|
|
@@ -307,7 +318,7 @@ describe("ConfigService", () => {
|
|
|
307
318
|
it("a workflow declaring a name in both defaults and envDefaults fails to load", async () => {
|
|
308
319
|
writeFileSync(
|
|
309
320
|
join(projectDir, "gtd.config.ts"),
|
|
310
|
-
|
|
321
|
+
optionedWorkflow("first", `{ defaults: { x: "1" }, envDefaults: { x: "2" } }`),
|
|
311
322
|
)
|
|
312
323
|
|
|
313
324
|
await expect(getConfig()).rejects.toThrow(`"x" declared in both "defaults" and "envDefaults"`)
|
|
@@ -375,16 +386,16 @@ describe("ConfigService", () => {
|
|
|
375
386
|
expect(cfg.workflow.initial).toBe("custom-idle")
|
|
376
387
|
})
|
|
377
388
|
|
|
378
|
-
it("reads `defaults`, `summary`, `base` and `steering` off
|
|
389
|
+
it("reads `defaults`, `summary`, `base` and `steering` off workflow()'s options and ignores other exports", async () => {
|
|
379
390
|
writeFileSync(
|
|
380
391
|
join(projectDir, "gtd.config.ts"),
|
|
381
392
|
[
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`export const steering = { ".gtd/PLAN.md": "qa" }`,
|
|
393
|
+
optionedWorkflow(
|
|
394
|
+
"first",
|
|
395
|
+
`{ defaults: { greeting: "hi" }, summary: () => "sum", base: () => undefined, steering: { ".gtd/PLAN.md": "qa" } }`,
|
|
396
|
+
),
|
|
387
397
|
`export const helper = 42`,
|
|
398
|
+
`export const plain = async () => {}`,
|
|
388
399
|
``,
|
|
389
400
|
].join("\n"),
|
|
390
401
|
)
|
|
@@ -396,8 +407,23 @@ describe("ConfigService", () => {
|
|
|
396
407
|
expect(typeof cfg.workflow.summary).toBe("function")
|
|
397
408
|
expect(typeof cfg.workflow.base).toBe("function")
|
|
398
409
|
expect(cfg.workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
|
|
410
|
+
expect(cfg.workflowNames).toEqual([])
|
|
399
411
|
})
|
|
400
412
|
|
|
413
|
+
it.each(["defaults", "envDefaults", "steering", "skills", "access", "doors", "summary", "base"])(
|
|
414
|
+
"refuses a file-level %s export, naming its replacement",
|
|
415
|
+
async (name) => {
|
|
416
|
+
writeFileSync(
|
|
417
|
+
join(projectDir, "gtd.config.ts"),
|
|
418
|
+
`${minimalWorkflow("first")}export const ${name} = {}\n`,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
await expect(getConfig()).rejects.toThrow(
|
|
422
|
+
`gtd.config.ts: ${name}: the "${name}" export is no longer read — pass it to the workflow it configures: \`workflow(flow, { ${name} })\`, or the build a workflow factory returns`,
|
|
423
|
+
)
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
401
427
|
it("the fingerprint covers gtd.config.ts AND a sibling module it imports from", async () => {
|
|
402
428
|
writeFileSync(
|
|
403
429
|
join(projectDir, "steps.ts"),
|
|
@@ -405,7 +431,9 @@ describe("ConfigService", () => {
|
|
|
405
431
|
)
|
|
406
432
|
writeFileSync(
|
|
407
433
|
join(projectDir, "gtd.config.ts"),
|
|
408
|
-
[
|
|
434
|
+
[`import { steering } from "./steps.js"`, optionedWorkflow("first", "{ steering }")].join(
|
|
435
|
+
"\n",
|
|
436
|
+
),
|
|
409
437
|
)
|
|
410
438
|
|
|
411
439
|
const cfg = await getConfig()
|
|
@@ -421,21 +449,24 @@ describe("ConfigService", () => {
|
|
|
421
449
|
})
|
|
422
450
|
|
|
423
451
|
it.each([
|
|
424
|
-
[`
|
|
425
|
-
[`
|
|
452
|
+
[`{ defaults: { n: 1 } }`, `workflow "default"'s "defaults" is not a record of strings`],
|
|
453
|
+
[`{ summary: "text" }`, `workflow "default"'s "summary" is not a function`],
|
|
454
|
+
[`{ base: "main" }`, `workflow "default"'s "base" is not a function`],
|
|
426
455
|
[
|
|
427
|
-
`
|
|
428
|
-
|
|
456
|
+
`{ steering: { ".gtd/PLAN.md": 1 } }`,
|
|
457
|
+
`workflow "default"'s "steering" is not a record of strings`,
|
|
429
458
|
],
|
|
430
|
-
|
|
431
|
-
|
|
459
|
+
[`{ doors: [] }`, `workflow "default"'s "doors" is not a record of doors`],
|
|
460
|
+
[`{ defualts: {} }`, `workflow "default" has unknown option(s) "defualts" — known: defaults,`],
|
|
461
|
+
])("rejects malformed workflow() options: %s", async (options, message) => {
|
|
462
|
+
writeFileSync(join(projectDir, "gtd.config.ts"), optionedWorkflow("first", options))
|
|
432
463
|
|
|
433
464
|
await expect(getConfig()).rejects.toThrow(message)
|
|
434
465
|
})
|
|
435
466
|
|
|
436
467
|
const firstStepWorkflow = (body: string) =>
|
|
437
468
|
[
|
|
438
|
-
`import { human, vars, env } from "@pmelab/gtd/flows"`,
|
|
469
|
+
`import { human, settings as vars, envSettings as env } from "@pmelab/gtd/flows"`,
|
|
439
470
|
`export default async () => {`,
|
|
440
471
|
` ${body}`,
|
|
441
472
|
`}`,
|
|
@@ -479,23 +510,19 @@ describe("ConfigService", () => {
|
|
|
479
510
|
it("refuses a workflow default whose name is not a valid setting name", async () => {
|
|
480
511
|
writeFileSync(
|
|
481
512
|
join(projectDir, "gtd.config.ts"),
|
|
482
|
-
|
|
483
|
-
minimalWorkflow("first"),
|
|
484
|
-
`export const defaults = { "a=b": "1" }`,
|
|
485
|
-
`export const envDefaults = { "c d": "1" }`,
|
|
486
|
-
].join("\n"),
|
|
513
|
+
optionedWorkflow("first", `{ defaults: { "a=b": "1" }, envDefaults: { "c d": "1" } }`),
|
|
487
514
|
)
|
|
488
515
|
await expect(getConfig()).rejects.toThrow(
|
|
489
|
-
'
|
|
516
|
+
'workflow "default" declares defaults "a=b", not a valid setting name — a setting name is a letter or "_", then letters, digits or "_"',
|
|
490
517
|
)
|
|
491
518
|
})
|
|
492
519
|
|
|
493
520
|
it("refuses an envDefaults name that is not a valid setting name", async () => {
|
|
494
521
|
writeFileSync(
|
|
495
522
|
join(projectDir, "gtd.config.ts"),
|
|
496
|
-
|
|
523
|
+
optionedWorkflow("first", `{ envDefaults: { "c d": "1" } }`),
|
|
497
524
|
)
|
|
498
|
-
await expect(getConfig()).rejects.toThrow('
|
|
525
|
+
await expect(getConfig()).rejects.toThrow('workflow "default" declares envDefaults "c d"')
|
|
499
526
|
})
|
|
500
527
|
|
|
501
528
|
it("refuses a flow whose first step depends on the repository's files", async () => {
|
|
@@ -559,7 +586,7 @@ describe("ConfigService", () => {
|
|
|
559
586
|
|
|
560
587
|
describe("named workflows", () => {
|
|
561
588
|
const flow = (first: string) => `async () => {\n await human("${first}")\n}`
|
|
562
|
-
const header = `import { human } from "@pmelab/gtd/flows"\n`
|
|
589
|
+
const header = `import { human, workflow } from "@pmelab/gtd/flows"\n`
|
|
563
590
|
|
|
564
591
|
it("re-exported factories are startable under their names, default is not", async () => {
|
|
565
592
|
withFactories(`export { feature as default, feature, review, fix } from "./workflows"\n`)
|
|
@@ -570,48 +597,93 @@ describe("ConfigService", () => {
|
|
|
570
597
|
expect(cfg.workflowNamed("fix")?.def.initial).toBe("idle")
|
|
571
598
|
})
|
|
572
599
|
|
|
573
|
-
it("
|
|
600
|
+
it("only workflow() exports and factories are workflows, named by their export; other functions are helpers", async () => {
|
|
574
601
|
writeFileSync(
|
|
575
602
|
join(projectDir, "gtd.config.ts"),
|
|
576
603
|
[
|
|
577
604
|
header,
|
|
578
605
|
`export default ${flow("main")}`,
|
|
579
|
-
`export const hotfix = ${flow("hot")}`,
|
|
580
|
-
`export const
|
|
581
|
-
`export const summary = () => "sum"`,
|
|
582
|
-
`export const base = () => undefined`,
|
|
583
|
-
`export const steering = {}`,
|
|
584
|
-
`export const skills = {}`,
|
|
606
|
+
`export const hotfix = workflow(${flow("hot")}, { defaults: { greeting: "hi" } })`,
|
|
607
|
+
`export const helper = ${flow("help")}`,
|
|
585
608
|
`export const notAFlow = 42`,
|
|
586
609
|
``,
|
|
587
610
|
].join("\n"),
|
|
588
611
|
)
|
|
589
612
|
const cfg = await getConfig()
|
|
590
613
|
expect(cfg.workflowNames).toEqual(["hotfix"])
|
|
614
|
+
expect(cfg.warnings).toEqual([])
|
|
591
615
|
expect(cfg.defaultWorkflow.name).toBe("default")
|
|
616
|
+
expect(cfg.defaultWorkflow.vars).toEqual({})
|
|
592
617
|
expect(cfg.workflow.initial).toBe("main")
|
|
593
618
|
expect(cfg.workflowNamed("hotfix")?.vars).toEqual({ greeting: "hi" })
|
|
594
619
|
})
|
|
595
620
|
|
|
621
|
+
it("an exported workflow re-wrapped with options of its own is a second workflow with only those", async () => {
|
|
622
|
+
writeFileSync(
|
|
623
|
+
join(projectDir, "gtd.config.ts"),
|
|
624
|
+
[
|
|
625
|
+
header,
|
|
626
|
+
`export const library = workflow(${flow("main")}, { defaults: { greeting: "hi" } })`,
|
|
627
|
+
`export const mine = workflow(library, { defaults: { tone: "dry" } })`,
|
|
628
|
+
`export default mine`,
|
|
629
|
+
``,
|
|
630
|
+
].join("\n"),
|
|
631
|
+
)
|
|
632
|
+
const cfg = await getConfig()
|
|
633
|
+
expect(cfg.workflowNames).toEqual(["library", "mine"])
|
|
634
|
+
expect(cfg.workflowNamed("library")?.vars).toEqual({ greeting: "hi" })
|
|
635
|
+
expect(cfg.workflowNamed("mine")?.vars).toEqual({ tone: "dry" })
|
|
636
|
+
expect(cfg.workflowNamed("mine")?.def.initial).toBe("main")
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
it("a plain default export is a workflow without options", async () => {
|
|
640
|
+
writeFileSync(
|
|
641
|
+
join(projectDir, "gtd.config.ts"),
|
|
642
|
+
[header, `export default ${flow("main")}`, ``].join("\n"),
|
|
643
|
+
)
|
|
644
|
+
const cfg = await getConfig()
|
|
645
|
+
expect(cfg.workflow.initial).toBe("main")
|
|
646
|
+
expect(cfg.defaultWorkflow.vars).toEqual({})
|
|
647
|
+
expect(cfg.workflow.summary).toBeUndefined()
|
|
648
|
+
})
|
|
649
|
+
|
|
596
650
|
it("a config with no default export fails, saying what to export", async () => {
|
|
597
651
|
writeFileSync(
|
|
598
652
|
join(projectDir, "gtd.config.ts"),
|
|
599
653
|
[header, `export const hotfix = ${flow("hot")}`, ``].join("\n"),
|
|
600
654
|
)
|
|
601
|
-
await expect(getConfig()).rejects.toThrow(
|
|
655
|
+
await expect(getConfig()).rejects.toThrow(
|
|
656
|
+
/no default export — export the workflow an ordinary start runs; `gtd guide` says where to get one, or write your own/,
|
|
657
|
+
)
|
|
602
658
|
})
|
|
603
659
|
|
|
604
660
|
it("a default that is also a named export goes by that name", async () => {
|
|
605
661
|
writeFileSync(
|
|
606
662
|
join(projectDir, "gtd.config.ts"),
|
|
607
|
-
[
|
|
663
|
+
[
|
|
664
|
+
header,
|
|
665
|
+
`export const hotfix = workflow(${flow("hot")})`,
|
|
666
|
+
`export default hotfix`,
|
|
667
|
+
``,
|
|
668
|
+
].join("\n"),
|
|
608
669
|
)
|
|
609
670
|
expect((await getConfig()).defaultWorkflow.name).toBe("hotfix")
|
|
610
671
|
})
|
|
611
672
|
|
|
612
|
-
it("a helper re-exported from a workflow module
|
|
673
|
+
it("a helper re-exported from a workflow module stays a helper; a marked one is a workflow", async () => {
|
|
613
674
|
withFactories(`export { feature as default, unwind } from "./workflows"\n`)
|
|
614
|
-
expect((await getConfig()).workflowNames).toContain("unwind")
|
|
675
|
+
expect((await getConfig()).workflowNames).not.toContain("unwind")
|
|
676
|
+
writeFileSync(
|
|
677
|
+
join(projectDir, "gtd.config.ts"),
|
|
678
|
+
[
|
|
679
|
+
`import { workflow } from "@pmelab/gtd/flows"`,
|
|
680
|
+
`import { unwind } from "./workflows"`,
|
|
681
|
+
`export { feature as default } from "./workflows"`,
|
|
682
|
+
`export const unwinding = workflow(unwind)`,
|
|
683
|
+
``,
|
|
684
|
+
].join("\n"),
|
|
685
|
+
)
|
|
686
|
+
expect((await getConfig()).workflowNames).toContain("unwinding")
|
|
615
687
|
})
|
|
616
688
|
|
|
617
689
|
it("a gtd subpath only a helper file imports resolves", async () => {
|
|
@@ -628,21 +700,19 @@ describe("ConfigService", () => {
|
|
|
628
700
|
[
|
|
629
701
|
header,
|
|
630
702
|
`import { kit } from "./kit"`,
|
|
631
|
-
`export default ${flow("main")}`,
|
|
632
|
-
'export const steering = { [`.gtd/${kit}.md`]: "qa" }',
|
|
703
|
+
`export default workflow(${flow("main")}, { steering: { [\`.gtd/\${kit}.md\`]: "qa" } })`,
|
|
633
704
|
``,
|
|
634
705
|
].join("\n"),
|
|
635
706
|
)
|
|
636
707
|
expect((await getConfig()).workflow.steering[".gtd/function.md"]).toBe("qa")
|
|
637
708
|
})
|
|
638
709
|
|
|
639
|
-
it("steering is the
|
|
710
|
+
it("steering is the workflow's own option", async () => {
|
|
640
711
|
writeFileSync(
|
|
641
712
|
join(projectDir, "gtd.config.ts"),
|
|
642
713
|
[
|
|
643
714
|
header,
|
|
644
|
-
`export default ${flow("main")}`,
|
|
645
|
-
`export const steering = { ".gtd/REVIEW.md": "qa", ".gtd/X.md": "qa" }`,
|
|
715
|
+
`export default workflow(${flow("main")}, { steering: { ".gtd/REVIEW.md": "qa", ".gtd/X.md": "qa" } })`,
|
|
646
716
|
``,
|
|
647
717
|
].join("\n"),
|
|
648
718
|
)
|
|
@@ -655,16 +725,16 @@ describe("ConfigService", () => {
|
|
|
655
725
|
|
|
656
726
|
describe("doors", () => {
|
|
657
727
|
const flow = (first: string) => `async () => {\n await human("${first}")\n}`
|
|
658
|
-
const header = `import { human } from "@pmelab/gtd/flows"\n`
|
|
728
|
+
const header = `import { human, workflow } from "@pmelab/gtd/flows"\n`
|
|
729
|
+
/** `hotfix` builds `doors`, a record of doors without their `workflow`. */
|
|
659
730
|
const withDoors = (doors: string, extra = ""): void =>
|
|
660
731
|
writeFileSync(
|
|
661
732
|
join(projectDir, "gtd.config.ts"),
|
|
662
733
|
[
|
|
663
734
|
header,
|
|
664
735
|
`export default ${flow("main")}`,
|
|
665
|
-
`export const hotfix = ${flow("hot")}`,
|
|
736
|
+
`export const hotfix = workflow(${flow("hot")}, { doors: ${doors} })`,
|
|
666
737
|
extra,
|
|
667
|
-
`export const doors = ${doors}`,
|
|
668
738
|
``,
|
|
669
739
|
].join("\n"),
|
|
670
740
|
)
|
|
@@ -701,26 +771,9 @@ describe("ConfigService", () => {
|
|
|
701
771
|
expect(audit?.def.base?.("audit", { reviewBase: "v1" })).toBe("v1")
|
|
702
772
|
})
|
|
703
773
|
|
|
704
|
-
it("a
|
|
705
|
-
withFactories(
|
|
706
|
-
[
|
|
707
|
-
`export { feature as default, review } from "./workflows"`,
|
|
708
|
-
`export const base = (w: string) => (w === "review" ? "develop" : undefined)`,
|
|
709
|
-
``,
|
|
710
|
-
].join("\n"),
|
|
711
|
-
)
|
|
712
|
-
expect((await getConfig()).workflowNamed("review")?.def.base?.("review", {})).toBe("develop")
|
|
713
|
-
})
|
|
714
|
-
|
|
715
|
-
it("a door several workflows build gets no door when none is exported under its name, until the doors export declares it", async () => {
|
|
716
|
-
const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
|
|
717
|
-
withFactories(`${both}\n`)
|
|
774
|
+
it("a door several workflows build gets no door when none is exported under its name", async () => {
|
|
775
|
+
withFactories(`export { feature as default, fix as fix2, fix as fix3 } from "./workflows"\n`)
|
|
718
776
|
expect((await getConfig()).doors.get("fix")).toBeUndefined()
|
|
719
|
-
writeFileSync(
|
|
720
|
-
join(projectDir, "gtd.config.ts"),
|
|
721
|
-
`${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
|
|
722
|
-
)
|
|
723
|
-
expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix3")
|
|
724
777
|
})
|
|
725
778
|
|
|
726
779
|
it("a door clash warns once at doors.<name>, builders sorted", async () => {
|
|
@@ -734,17 +787,15 @@ describe("ConfigService", () => {
|
|
|
734
787
|
severity: "warning",
|
|
735
788
|
path: ["doors", "fix"],
|
|
736
789
|
origin: join(projectDir, "gtd.config.ts"),
|
|
737
|
-
message: `door "fix" is built by workflows "fix2", "fix3" and "fix4" and none is exported under that name —
|
|
790
|
+
message: `door "fix" is built by workflows "fix2", "fix3" and "fix4" and none is exported under that name — rename the export that should own it to "fix"`,
|
|
738
791
|
},
|
|
739
792
|
])
|
|
740
793
|
})
|
|
741
794
|
|
|
742
|
-
it("no clash warning when an export is named after the door, one workflow builds it
|
|
743
|
-
const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
|
|
795
|
+
it("no clash warning when an export is named after the door, or one workflow builds it", async () => {
|
|
744
796
|
for (const body of [
|
|
745
797
|
`export { feature as default, fix as fix2, fix } from "./workflows"\n`,
|
|
746
798
|
`export { feature as default, fix } from "./workflows"\n`,
|
|
747
|
-
`${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
|
|
748
799
|
]) {
|
|
749
800
|
withFactories(body)
|
|
750
801
|
expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
|
|
@@ -766,11 +817,8 @@ describe("ConfigService", () => {
|
|
|
766
817
|
}
|
|
767
818
|
})
|
|
768
819
|
|
|
769
|
-
it("a config's doors
|
|
770
|
-
withDoors(
|
|
771
|
-
`{ hot: { workflow: "hotfix", args: [{ name: "ticket" }] } }`,
|
|
772
|
-
'export { fix } from "./workflows"',
|
|
773
|
-
)
|
|
820
|
+
it("a config's doors are those of every workflow it exports, factory or workflow()", async () => {
|
|
821
|
+
withDoors(`{ hot: { args: [{ name: "ticket" }] } }`, 'export { fix } from "./workflows"')
|
|
774
822
|
writeFileSync(join(projectDir, "workflows.ts"), FACTORIES)
|
|
775
823
|
const { doors } = await getConfig()
|
|
776
824
|
expect([...doors.keys()].sort()).toEqual(["fix", "hot"])
|
|
@@ -779,39 +827,54 @@ describe("ConfigService", () => {
|
|
|
779
827
|
expect(doors.get("hot")?.args).toEqual([{ name: "ticket" }])
|
|
780
828
|
})
|
|
781
829
|
|
|
782
|
-
it("
|
|
783
|
-
|
|
784
|
-
|
|
830
|
+
it("a door named like a workflow it does not start warns that it hides that workflow", async () => {
|
|
831
|
+
const config = (fixDoors: string): void =>
|
|
832
|
+
writeFileSync(
|
|
833
|
+
join(projectDir, "gtd.config.ts"),
|
|
834
|
+
[
|
|
835
|
+
`import { human, workflow } from "@pmelab/gtd/flows"`,
|
|
836
|
+
`export default workflow(${flow("main")})`,
|
|
837
|
+
`export const fix = workflow(${flow("fix")}${fixDoors})`,
|
|
838
|
+
`export const hotfix = workflow(${flow("hot")}, { doors: { fix: {} } })`,
|
|
839
|
+
``,
|
|
840
|
+
].join("\n"),
|
|
841
|
+
)
|
|
842
|
+
config("")
|
|
843
|
+
const cfg = await getConfig()
|
|
844
|
+
expect(cfg.doors.get("fix")?.workflow).toBe("hotfix")
|
|
845
|
+
expect(cfg.warnings.filter((w) => w.path[0] === "doors")).toEqual([
|
|
846
|
+
{
|
|
847
|
+
severity: "warning",
|
|
848
|
+
path: ["doors", "fix"],
|
|
849
|
+
origin: join(projectDir, "gtd.config.ts"),
|
|
850
|
+
message:
|
|
851
|
+
'door "fix" starts workflow "hotfix" and hides the workflow of the same name from `gtd start fix` — rename one of them',
|
|
852
|
+
},
|
|
853
|
+
])
|
|
854
|
+
config(", { doors: { fix: {} } }")
|
|
855
|
+
expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
|
|
785
856
|
})
|
|
786
857
|
|
|
787
858
|
it("rejects a door name that is not lowercase-kebab", async () => {
|
|
788
|
-
withDoors(`{ Bad_Name: {
|
|
859
|
+
withDoors(`{ Bad_Name: {} }`)
|
|
789
860
|
await expect(getConfig()).rejects.toThrow(/door "Bad_Name".*\^\[a-z\]\[a-z0-9-\]\*\$/)
|
|
790
861
|
})
|
|
791
862
|
|
|
792
|
-
it("rejects a door whose workflow is not startable", async () => {
|
|
793
|
-
withDoors(`{ go: { workflow: "nope" } }`)
|
|
794
|
-
await expect(getConfig()).rejects.toThrow(/door "go".*workflow "nope"/)
|
|
795
|
-
})
|
|
796
|
-
|
|
797
863
|
it("rejects a required arg after an optional one", async () => {
|
|
798
|
-
withDoors(`{ go: {
|
|
864
|
+
withDoors(`{ go: { args: [{ name: "a", optional: true }, { name: "b" }] } }`)
|
|
799
865
|
await expect(getConfig()).rejects.toThrow(/door "go".*"b".*after an optional/)
|
|
800
866
|
})
|
|
801
867
|
|
|
802
868
|
it("rejects duplicate arg names", async () => {
|
|
803
|
-
withDoors(`{ go: {
|
|
869
|
+
withDoors(`{ go: { args: [{ name: "a" }, { name: "a" }] } }`)
|
|
804
870
|
await expect(getConfig()).rejects.toThrow(/door "go".*duplicate arg "a"/)
|
|
805
871
|
})
|
|
806
872
|
|
|
807
|
-
it("rejects
|
|
873
|
+
it("rejects doors that are not a record", async () => {
|
|
808
874
|
withDoors(`[1]`)
|
|
809
|
-
await expect(getConfig()).rejects.toThrow(
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
it("rejects a door that is not an object with a workflow name", async () => {
|
|
813
|
-
withDoors(`{ go: 3 }`)
|
|
814
|
-
await expect(getConfig()).rejects.toThrow(/door "go".*workflow/)
|
|
875
|
+
await expect(getConfig()).rejects.toThrow(
|
|
876
|
+
`workflow "hotfix"'s "doors" is not a record of doors`,
|
|
877
|
+
)
|
|
815
878
|
})
|
|
816
879
|
})
|
|
817
880
|
|
|
@@ -907,16 +970,15 @@ describe("ConfigService", () => {
|
|
|
907
970
|
)
|
|
908
971
|
})
|
|
909
972
|
|
|
910
|
-
it("loads a skills
|
|
911
|
-
const flow = minimalWorkflow("first")
|
|
973
|
+
it("loads a skills option given as a record or as a function of the vars", async () => {
|
|
912
974
|
writeFileSync(
|
|
913
975
|
join(projectDir, "gtd.config.ts"),
|
|
914
|
-
|
|
976
|
+
optionedWorkflow("first", `{ skills: { a: ["x"] } }`),
|
|
915
977
|
)
|
|
916
978
|
expect(Object.keys((await getConfig()).workflow.skills({}))).toEqual(["a"])
|
|
917
979
|
writeFileSync(
|
|
918
980
|
join(projectDir, "gtd.config.ts"),
|
|
919
|
-
|
|
981
|
+
optionedWorkflow("first", `{ skills: (vars) => ({ [vars.k ?? "none"]: ["x"] }) }`),
|
|
920
982
|
)
|
|
921
983
|
expect(Object.keys((await getConfig()).workflow.skills({ k: "b" }))).toEqual(["b"])
|
|
922
984
|
})
|
|
@@ -945,10 +1007,10 @@ describe("ConfigService", () => {
|
|
|
945
1007
|
expect(cfg.ui).toEqual({ format: "oxfmt --write $GTD_FILE" })
|
|
946
1008
|
})
|
|
947
1009
|
|
|
948
|
-
it("never interpolates the workflow's own `defaults`
|
|
1010
|
+
it("never interpolates the workflow's own `defaults`", async () => {
|
|
949
1011
|
writeFileSync(
|
|
950
1012
|
join(projectDir, "gtd.config.ts"),
|
|
951
|
-
|
|
1013
|
+
optionedWorkflow("first", `{ defaults: { greeting: "$LITERAL" } }`),
|
|
952
1014
|
)
|
|
953
1015
|
|
|
954
1016
|
const cfg = await getConfig(undefined, { LITERAL: "should-never-appear" })
|
|
@@ -1015,13 +1077,8 @@ describe("ConfigService — workflow factory options", () => {
|
|
|
1015
1077
|
expect(error).toMatch(/gtd\.config\.ts: testComand: workflow "default" option "testComand"/)
|
|
1016
1078
|
})
|
|
1017
1079
|
|
|
1018
|
-
it("a
|
|
1019
|
-
configWith(
|
|
1020
|
-
[
|
|
1021
|
-
`export default feature({ testComand: "x" } as never)`,
|
|
1022
|
-
`export const doors = { go: { workflow: "default" } }`,
|
|
1023
|
-
].join("\n"),
|
|
1024
|
-
)
|
|
1080
|
+
it("a rejected factory reports the option error, not the doors it would build", async () => {
|
|
1081
|
+
configWith(`export default feature({ testComand: "x" } as never)`)
|
|
1025
1082
|
|
|
1026
1083
|
const error = await getConfig().catch((e: Error) => e.message)
|
|
1027
1084
|
|
|
@@ -1049,7 +1106,7 @@ describe("ConfigService — a factory's built settings", () => {
|
|
|
1049
1106
|
await expect(getConfig()).rejects.toThrow(/workflow "audit" builds door "Bad_Name", which/)
|
|
1050
1107
|
})
|
|
1051
1108
|
|
|
1052
|
-
it("are held to the setting-name rule, like
|
|
1109
|
+
it("are held to the setting-name rule, like workflow()'s", async () => {
|
|
1053
1110
|
writeFileSync(
|
|
1054
1111
|
join(projectDir, "gtd.config.ts"),
|
|
1055
1112
|
[
|
|
@@ -1064,7 +1121,7 @@ describe("ConfigService — a factory's built settings", () => {
|
|
|
1064
1121
|
)
|
|
1065
1122
|
|
|
1066
1123
|
await expect(getConfig()).rejects.toThrow(
|
|
1067
|
-
/workflow "default"
|
|
1124
|
+
/workflow "default" declares defaults "x=y", not a valid setting name/,
|
|
1068
1125
|
)
|
|
1069
1126
|
})
|
|
1070
1127
|
|
|
@@ -1100,6 +1157,89 @@ describe("ConfigService — a factory's built settings", () => {
|
|
|
1100
1157
|
].join("\n"),
|
|
1101
1158
|
)
|
|
1102
1159
|
|
|
1103
|
-
await expect(getConfig()).rejects.toThrow(
|
|
1160
|
+
await expect(getConfig()).rejects.toThrow(
|
|
1161
|
+
`workflow "default"'s "defaults" is not a record of strings`,
|
|
1162
|
+
)
|
|
1163
|
+
})
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
describe("ConfigService — what a factory's build carries", () => {
|
|
1167
|
+
const factoryConfig = (build: string): void =>
|
|
1168
|
+
writeFileSync(
|
|
1169
|
+
join(projectDir, "gtd.config.ts"),
|
|
1170
|
+
[
|
|
1171
|
+
`import { defineWorkflow, human } from "@pmelab/gtd/flows"`,
|
|
1172
|
+
`const any = { "~standard": { version: 1, vendor: "t", validate: (value) => ({ value }) } }`,
|
|
1173
|
+
`export default defineWorkflow(any, () => ({`,
|
|
1174
|
+
` flow: async () => { await human("idle") },`,
|
|
1175
|
+
build,
|
|
1176
|
+
`}))`,
|
|
1177
|
+
``,
|
|
1178
|
+
].join("\n"),
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
it("skills, access, steering and summary reach the workflow", async () => {
|
|
1182
|
+
factoryConfig(
|
|
1183
|
+
[
|
|
1184
|
+
` skills: (vars) => ({ work: [vars.lens ?? "none"] }),`,
|
|
1185
|
+
` access: { work: { write: ["out/**"] } },`,
|
|
1186
|
+
` steering: { ".gtd/PLAN.md": "qa" },`,
|
|
1187
|
+
` summary: () => "built",`,
|
|
1188
|
+
].join("\n"),
|
|
1189
|
+
)
|
|
1190
|
+
const { workflow } = await getConfig()
|
|
1191
|
+
expect(workflow.skills({ lens: "x" })).toEqual({ work: ["x"] })
|
|
1192
|
+
expect(workflow.access({})).toEqual({ work: { write: ["out/**"] } })
|
|
1193
|
+
expect(workflow.steering).toEqual({ ".gtd/PLAN.md": "qa" })
|
|
1194
|
+
expect(workflow.summary?.({} as never)).toBe("built")
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
it("checks them as it checks workflow()'s options", async () => {
|
|
1198
|
+
factoryConfig(` skills: { work: "one" },`)
|
|
1199
|
+
await expect(getConfig()).rejects.toThrow(
|
|
1200
|
+
`workflow "default"'s "skills" is not a record of skill-name arrays`,
|
|
1201
|
+
)
|
|
1202
|
+
factoryConfig(` summary: "text",`)
|
|
1203
|
+
await expect(getConfig()).rejects.toThrow(`workflow "default"'s "summary" is not a function`)
|
|
1204
|
+
factoryConfig(` access: { work: { read: "src/**" } },`)
|
|
1205
|
+
await expect(getConfig()).rejects.toThrow(
|
|
1206
|
+
`workflow "default"'s "access": scope "work": access.read must be an array of glob strings`,
|
|
1207
|
+
)
|
|
1208
|
+
})
|
|
1209
|
+
})
|
|
1210
|
+
|
|
1211
|
+
describe("ConfigService — workflow()'s access option", () => {
|
|
1212
|
+
const withAccess = (access: string): void =>
|
|
1213
|
+
writeFileSync(
|
|
1214
|
+
join(projectDir, "gtd.config.ts"),
|
|
1215
|
+
optionedWorkflow("first", `{ access: ${access} }`),
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
it("accepts a record of { read?, write? } glob arrays, and a function", async () => {
|
|
1219
|
+
withAccess(`{ a: { read: ["x/**"], write: [] }, b: {}, c: { read: undefined } }`)
|
|
1220
|
+
expect((await getConfig()).workflow.access({})).toEqual({
|
|
1221
|
+
a: { read: ["x/**"], write: [] },
|
|
1222
|
+
b: {},
|
|
1223
|
+
c: { read: undefined },
|
|
1224
|
+
})
|
|
1225
|
+
withAccess(`(vars) => ({ [vars.k ?? "none"]: { write: "unchecked until resolved" } })`)
|
|
1226
|
+
expect(Object.keys((await getConfig()).workflow.access({ k: "b" }))).toEqual(["b"])
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
it.each([
|
|
1230
|
+
[
|
|
1231
|
+
`{ a: { wirte: [] } }`,
|
|
1232
|
+
`workflow "default"'s "access": scope "a": unknown access key "wirte"`,
|
|
1233
|
+
],
|
|
1234
|
+
[
|
|
1235
|
+
`{ a: { read: [1] } }`,
|
|
1236
|
+
`workflow "default"'s "access": scope "a": access.read must be an array`,
|
|
1237
|
+
],
|
|
1238
|
+
[`{ a: ["x/**"] }`, `workflow "default"'s "access": scope "a": access must be an object`],
|
|
1239
|
+
[`["x/**"]`, `workflow "default"'s "access" is not a record of scope access`],
|
|
1240
|
+
[`"x/**"`, `workflow "default"'s "access" is not a record of scope access`],
|
|
1241
|
+
])("refuses %s at load", async (access, message) => {
|
|
1242
|
+
withAccess(access)
|
|
1243
|
+
await expect(getConfig()).rejects.toThrow(message)
|
|
1104
1244
|
})
|
|
1105
1245
|
})
|