@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.
Files changed (83) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +37 -33
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/{testkit-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
  7. package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +751 -645
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +245 -36
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +66 -60
  18. package/src/cli/Cli.test.ts +183 -182
  19. package/src/cli/Cli.ts +164 -186
  20. package/src/cli/History.ts +9 -4
  21. package/src/cli/Judges.ts +1 -1
  22. package/src/cli/Output.test.ts +35 -0
  23. package/src/cli/Output.ts +31 -1
  24. package/src/cli/Program.test.ts +65 -79
  25. package/src/cli/Program.ts +157 -118
  26. package/src/edge/Edge.test.ts +64 -56
  27. package/src/edge/Edge.ts +3 -3
  28. package/src/flows/factory.test.ts +14 -0
  29. package/src/flows/factory.ts +22 -5
  30. package/src/flows/helpers.test.ts +35 -0
  31. package/src/flows/helpers.ts +19 -10
  32. package/src/flows/index.test.ts +66 -0
  33. package/src/flows/index.ts +109 -5
  34. package/src/flows/internal.ts +7 -0
  35. package/src/flows/runtime.test.ts +192 -1
  36. package/src/flows/runtime.ts +188 -54
  37. package/src/gherkin/git-control.feature +6 -6
  38. package/src/gherkin/index.test.ts +127 -0
  39. package/src/gherkin/index.ts +370 -51
  40. package/src/gherkin/messages.test.ts +38 -4
  41. package/src/gherkin/messages.ts +21 -4
  42. package/src/gherkin/more-steps.feature +170 -0
  43. package/src/gherkin/scenario-end.feature +101 -0
  44. package/src/gherkin/testkit.feature +83 -7
  45. package/src/install/Install.test.ts +7 -7
  46. package/src/install/Install.ts +8 -8
  47. package/src/replay/Replay.test.ts +13 -2
  48. package/src/replay/Replay.ts +19 -30
  49. package/src/replay/judgeDiffShape.test.ts +1 -1
  50. package/src/steering/Footnotes.test.ts +1 -1
  51. package/src/steering/SteeringFormats.test.ts +1 -1
  52. package/src/steering/freeform.test.ts +1 -1
  53. package/src/steering/index.test.ts +27 -1
  54. package/src/steering/index.ts +6 -104
  55. package/src/steering/internal.ts +11 -0
  56. package/src/steering/qa.test.ts +7 -2
  57. package/src/steering/reads.test.ts +42 -0
  58. package/src/steering/reads.ts +43 -0
  59. package/src/steering/registry.ts +93 -0
  60. package/src/steering/review.test.ts +2 -2
  61. package/src/step/planStart.test.ts +18 -16
  62. package/src/step/planStart.ts +9 -9
  63. package/src/testkit/TestRepo.test.ts +245 -7
  64. package/src/testkit/TestRepo.ts +271 -40
  65. package/src/testkit/imports.test.ts +58 -0
  66. package/src/testkit/imports.ts +102 -0
  67. package/src/testkit/index.test.ts +28 -0
  68. package/src/testkit/index.ts +14 -2
  69. package/src/twins/EmittedScriptRecognizer.ts +2 -6
  70. package/src/twins/EmittedScripts.test.ts +4 -2
  71. package/src/twins/InMemRepo.ts +1 -0
  72. package/src/wire/Script.ts +11 -0
  73. package/src/wire/WireSchema.test.ts +3 -0
  74. package/src/wire/golden/1/script.json +4 -0
  75. package/src/wire/index.ts +1 -0
  76. package/src/workflow/SteeringFormats.test.ts +1 -1
  77. package/src/workflow/compile.ts +2 -2
  78. package/src/workflow/discovery.test.ts +3 -3
  79. package/src/workflow/load.test.ts +256 -116
  80. package/src/workflow/load.ts +169 -146
  81. package/src/workflow-kit/index.test.ts +25 -0
  82. package/src/workflow-kit/index.ts +37 -4
  83. 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
- export default async () => {
78
- await human("idle", { message: "hi" })
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 install", async () => {
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 install` says where to get one/,
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
- `${minimalWorkflow("first")}export const defaults = { x: "1" }\nexport const envDefaults = { x: "2" }\n`,
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 the module and ignores its other exports", async () => {
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
- minimalWorkflow("first"),
383
- `export const defaults = { greeting: "hi" }`,
384
- `export const summary = () => "sum"`,
385
- `export const base = () => undefined`,
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
- [minimalWorkflow("first"), `export { steering } from "./steps.js"`, ``].join("\n"),
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
- [`export const defaults = { n: 1 }`, /"defaults" export is not a record of strings/],
425
- [`export const summary = "text"`, /"summary" export is not a function/],
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
- `export const steering = { ".gtd/PLAN.md": 1 }`,
428
- /"steering" export is not a record of strings/,
456
+ `{ steering: { ".gtd/PLAN.md": 1 } }`,
457
+ `workflow "default"'s "steering" is not a record of strings`,
429
458
  ],
430
- ])("rejects a malformed named export: %s", async (line, message) => {
431
- writeFileSync(join(projectDir, "gtd.config.ts"), `${minimalWorkflow("first")}${line}\n`)
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
- 'the "defaults" export declares "a=b", not a valid setting name — a setting name is a letter or "_", then letters, digits or "_"',
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
- [minimalWorkflow("first"), `export const envDefaults = { "c d": "1" }`].join("\n"),
523
+ optionedWorkflow("first", `{ envDefaults: { "c d": "1" } }`),
497
524
  )
498
- await expect(getConfig()).rejects.toThrow('the "envDefaults" export declares "c d"')
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("every exported function but the reserved names is a workflow, named by its export", async () => {
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 defaults = { greeting: "hi" }`,
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(/no default export.*`gtd install`/)
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
- [header, `export const hotfix = ${flow("hot")}`, `export default hotfix`, ``].join("\n"),
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 becomes a startable workflow", async () => {
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 config's own export", async () => {
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 config's own base export wins over the review factory's", async () => {
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 — declare it in the "doors" export`,
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, or doors declares it", async () => {
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 merge derived doors (exported workflows) with declared doors (doors export)", async () => {
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("the reserved `doors` export is never a workflow", async () => {
783
- withDoors(`{}`)
784
- expect((await getConfig()).workflowNames).toEqual(["hotfix"])
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: { workflow: "fix" } }`)
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: { workflow: "fix", args: [{ name: "a", optional: true }, { name: "b" }] } }`)
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: { workflow: "fix", args: [{ name: "a" }, { name: "a" }] } }`)
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 a `doors` export that is not a record", async () => {
873
+ it("rejects doors that are not a record", async () => {
808
874
  withDoors(`[1]`)
809
- await expect(getConfig()).rejects.toThrow(/the "doors" export is not a record/)
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 export given as a record or as a function of the vars", async () => {
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
- `${flow}export const skills = { a: ["x"] }\n`,
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
- `${flow}export const skills = (vars) => ({ [vars.k ?? "none"]: ["x"] })\n`,
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` export", async () => {
1010
+ it("never interpolates the workflow's own `defaults`", async () => {
949
1011
  writeFileSync(
950
1012
  join(projectDir, "gtd.config.ts"),
951
- [minimalWorkflow("first"), `export const defaults = { greeting: "$LITERAL" }`, ``].join("\n"),
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 door naming a rejected factory reports the option error alone", async () => {
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 a file's defaults", async () => {
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" builds defaults "x=y", not a valid setting name/,
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(/builds defaults "x", whose value is not a string/)
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
  })