@pmelab/gtd 59.0.0 → 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 +42 -38
- 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-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
- package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +1101 -1025
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +371 -82
- 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 +68 -61
- package/src/cli/Cli.test.ts +311 -229
- package/src/cli/Cli.ts +486 -585
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- 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 +131 -82
- package/src/cli/Program.ts +197 -111
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +4 -4
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +25 -6
- 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 +481 -98
- package/src/gherkin/messages.test.ts +124 -0
- package/src/gherkin/messages.ts +101 -0
- 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 +289 -6
- package/src/testkit/TestRepo.ts +317 -41
- 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 +7 -14
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Land.ts +1 -1
- 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 +175 -152
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
- 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
package/src/workflow/load.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { dirname, join } from "node:path"
|
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
5
|
import { Context, Effect, Layer } from "effect"
|
|
6
6
|
import { GtdError, Narrator } from "../platform/index.js"
|
|
7
|
-
import { replay, type TreeView } from "../replay/index.js"
|
|
7
|
+
import { accessShapeFault, replay, type TreeView } from "../replay/index.js"
|
|
8
|
+
// The published facade: what a config's `@pmelab/gtd/flows` import is served.
|
|
8
9
|
import * as flows from "../flows/index.js"
|
|
10
|
+
import { configure, isFactoryWorkflow, markedOptions, type OptionIssue } from "../flows/internal.js"
|
|
9
11
|
import type { StateMode, WorkflowDefinition } from "./Workflow.js"
|
|
10
|
-
import type { Door, Flow } from "../flows/index.js"
|
|
12
|
+
import type { Door, Flow, ScopeAccess } from "../flows/index.js"
|
|
11
13
|
import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
|
|
12
14
|
import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
13
15
|
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
@@ -33,10 +35,10 @@ export interface ConfigOperations {
|
|
|
33
35
|
readonly workflow: WorkflowDefinition
|
|
34
36
|
/** The default, resolved like a named one; its name is what status shows: the named export it also is, else `"default"`. */
|
|
35
37
|
readonly defaultWorkflow: ResolvedWorkflow
|
|
36
|
-
/** Every name `gtd
|
|
38
|
+
/** Every workflow name `gtd start` accepts, sorted — never `"default"`. */
|
|
37
39
|
readonly workflowNames: readonly string[]
|
|
38
40
|
readonly workflowNamed: (name: string) => ResolvedWorkflow | undefined
|
|
39
|
-
/** Every door `gtd
|
|
41
|
+
/** Every door `gtd start` accepts. A door's `workflow` resolves through `workflowNamed`. */
|
|
40
42
|
readonly doors: ReadonlyMap<string, Door>
|
|
41
43
|
/** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
|
|
42
44
|
readonly ui?: flows.UiSettings
|
|
@@ -50,7 +52,7 @@ export interface ConfigOperations {
|
|
|
50
52
|
// prints `message` verbatim and would print a duplicated `detail` twice.
|
|
51
53
|
const configError = (diagnostics: readonly Diagnostic[]): GtdError =>
|
|
52
54
|
new GtdError(
|
|
53
|
-
`gtd config:\n${diagnostics
|
|
55
|
+
`gtd: config:\n${diagnostics
|
|
54
56
|
.filter((d) => d.severity === "error")
|
|
55
57
|
.map((d) => ` - ${formatDiagnostic(d)}`)
|
|
56
58
|
.join("\n")}`,
|
|
@@ -84,11 +86,11 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
|
|
|
84
86
|
return yield* Effect.fail(
|
|
85
87
|
new GtdError(
|
|
86
88
|
[
|
|
87
|
-
"gtd config:",
|
|
89
|
+
"gtd: config:",
|
|
88
90
|
...found.map((file) => ` - ${file}: no longer read — delete it`),
|
|
89
91
|
" gtd 23 removed the .gtdrc files; move each key they set:",
|
|
90
|
-
" vars → an option of the workflow factory in gtd.config.ts, or
|
|
91
|
-
" env → an option of the workflow factory in gtd.config.ts, or
|
|
92
|
+
" vars → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { defaults })`",
|
|
93
|
+
" env → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { envDefaults })`",
|
|
92
94
|
" judge → ~/.config/gtd/judge.json, or GTD_JUDGE_PROVIDER / GTD_JUDGE_MODEL",
|
|
93
95
|
" modes, ui, skills, access → the `config` export of gtd.config.ts (`defineConfig`)",
|
|
94
96
|
" $schema → nothing; drop it",
|
|
@@ -115,7 +117,7 @@ export const load: Effect.Effect<
|
|
|
115
117
|
const missing: Diagnostic = {
|
|
116
118
|
severity: "error",
|
|
117
119
|
path: [],
|
|
118
|
-
message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd
|
|
120
|
+
message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd guide\` says where to get one`,
|
|
119
121
|
origin: host.root,
|
|
120
122
|
}
|
|
121
123
|
return yield* Effect.fail(configError([missing]))
|
|
@@ -129,7 +131,7 @@ export const load: Effect.Effect<
|
|
|
129
131
|
try: () => loadWorkflows(module, shipped),
|
|
130
132
|
catch: (e) =>
|
|
131
133
|
new GtdError(
|
|
132
|
-
`gtd config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
134
|
+
`gtd: config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
133
135
|
),
|
|
134
136
|
})
|
|
135
137
|
const compiled = loaded.config
|
|
@@ -142,17 +144,15 @@ export const load: Effect.Effect<
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
const initial = yield* firstStep(loaded.default, headTree(yield* Workspace))
|
|
145
|
-
const steering = loaded.file.steering as Record<string, StateMode>
|
|
146
147
|
const define = (entry: LoadedWorkflow): WorkflowDefinition => ({
|
|
147
148
|
flow: entry.flow,
|
|
148
|
-
summary: entry.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
steering,
|
|
149
|
+
summary: entry.summary,
|
|
150
|
+
base: (w, v) => entry.base?.(w, v),
|
|
151
|
+
steering: entry.steering as Record<string, StateMode>,
|
|
152
152
|
modes: compiled.modes,
|
|
153
|
-
skills: entry.
|
|
153
|
+
skills: entry.skills,
|
|
154
154
|
configuredSkills: compiled.skills,
|
|
155
|
-
access: entry.
|
|
155
|
+
access: entry.access,
|
|
156
156
|
configuredAccess: compiled.access,
|
|
157
157
|
skillsOrigin: entry.file.origin,
|
|
158
158
|
initial,
|
|
@@ -197,10 +197,7 @@ interface Settings {
|
|
|
197
197
|
readonly envDefaults: Readonly<Record<string, string>>
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
interface LoadedFile
|
|
201
|
-
extends
|
|
202
|
-
Pick<WorkflowDefinition, "summary" | "base" | "steering" | "skills" | "access">,
|
|
203
|
-
Settings {
|
|
200
|
+
interface LoadedFile {
|
|
204
201
|
readonly origin: string
|
|
205
202
|
/**
|
|
206
203
|
* Every real file this module's evaluation touched — `origin` itself plus
|
|
@@ -214,8 +211,9 @@ interface LoadedFile
|
|
|
214
211
|
readonly dependencyFiles: readonly string[]
|
|
215
212
|
}
|
|
216
213
|
|
|
217
|
-
/** A startable workflow
|
|
218
|
-
interface LoadedWorkflow
|
|
214
|
+
/** A startable workflow, built from a factory or a `workflow()` call's options. */
|
|
215
|
+
interface LoadedWorkflow
|
|
216
|
+
extends Settings, Pick<WorkflowDefinition, "summary" | "steering" | "skills" | "access"> {
|
|
219
217
|
readonly flow: Flow
|
|
220
218
|
readonly file: LoadedFile
|
|
221
219
|
readonly doors?: flows.WorkflowDoors | undefined
|
|
@@ -384,75 +382,105 @@ const requiredOnUse = (
|
|
|
384
382
|
})
|
|
385
383
|
}
|
|
386
384
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
): Readonly<Record<string, string>> => {
|
|
391
|
-
const value = exports[name] ?? {}
|
|
385
|
+
/** `what` names the value in an error: `workflow "x"'s "steering"`. */
|
|
386
|
+
const stringRecord = (value: unknown, what: string): Readonly<Record<string, string>> => {
|
|
387
|
+
const record = value ?? {}
|
|
392
388
|
if (
|
|
393
|
-
typeof
|
|
394
|
-
|
|
395
|
-
Object.values(
|
|
389
|
+
typeof record !== "object" ||
|
|
390
|
+
record === null ||
|
|
391
|
+
Object.values(record).some((entry) => typeof entry !== "string")
|
|
396
392
|
) {
|
|
397
|
-
throw new Error(
|
|
393
|
+
throw new Error(`${what} is not a record of strings`)
|
|
398
394
|
}
|
|
399
|
-
return
|
|
395
|
+
return record as Readonly<Record<string, string>>
|
|
400
396
|
}
|
|
401
397
|
|
|
402
398
|
const isStringArray = (v: unknown): v is readonly string[] =>
|
|
403
399
|
Array.isArray(v) && v.every((entry) => typeof entry === "string")
|
|
404
400
|
|
|
405
|
-
const checkSkillsRecord = (
|
|
401
|
+
const checkSkillsRecord = (
|
|
402
|
+
value: unknown,
|
|
403
|
+
what: string,
|
|
404
|
+
): Readonly<Record<string, readonly string[]>> => {
|
|
406
405
|
if (
|
|
407
406
|
typeof value !== "object" ||
|
|
408
407
|
value === null ||
|
|
409
408
|
Object.values(value).some((entry) => !isStringArray(entry))
|
|
410
409
|
) {
|
|
411
|
-
throw new Error(
|
|
410
|
+
throw new Error(`${what} is not a record of skill-name arrays`)
|
|
412
411
|
}
|
|
413
412
|
return value as Readonly<Record<string, readonly string[]>>
|
|
414
413
|
}
|
|
415
414
|
|
|
416
|
-
/**
|
|
417
|
-
const
|
|
418
|
-
const value = exports["skills"] ?? {}
|
|
415
|
+
/** A workflow's `skills` option — a scope full name -> skill list record, or a function of the vars returning one. */
|
|
416
|
+
const skillsFrom = (value: unknown, what: string): WorkflowDefinition["skills"] => {
|
|
419
417
|
if (typeof value === "function") {
|
|
420
|
-
return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars))
|
|
418
|
+
return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars), what)
|
|
421
419
|
}
|
|
422
|
-
const record = checkSkillsRecord(value)
|
|
420
|
+
const record = checkSkillsRecord(value ?? {}, what)
|
|
423
421
|
return () => record
|
|
424
422
|
}
|
|
425
423
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
424
|
+
const checkAccessRecord = (value: unknown, what: string): Readonly<Record<string, ScopeAccess>> => {
|
|
425
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
426
|
+
throw new Error(
|
|
427
|
+
`${what} is not a record of scope access (scope -> { read?, write? }) or a function returning one`,
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
for (const [scope, access] of Object.entries(value)) {
|
|
431
|
+
const fault = accessShapeFault(access)
|
|
432
|
+
if (fault !== undefined) throw new Error(`${what}: scope "${scope}": ${fault}`)
|
|
433
|
+
}
|
|
434
|
+
return value as Readonly<Record<string, ScopeAccess>>
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* A workflow's `access` option — a scope full name -> `{ read?, write? }`
|
|
439
|
+
* record, checked now, or a function of the vars returning one, checked when
|
|
440
|
+
* the process's settings are known.
|
|
441
|
+
*/
|
|
442
|
+
const accessFrom = (value: unknown, what: string): WorkflowDefinition["access"] => {
|
|
429
443
|
if (typeof value === "function") {
|
|
430
444
|
return (vars) => (value as (v: typeof vars) => never)(vars)
|
|
431
445
|
}
|
|
432
|
-
|
|
446
|
+
const record = checkAccessRecord(value ?? {}, what)
|
|
447
|
+
return () => record
|
|
433
448
|
}
|
|
434
449
|
|
|
435
|
-
const optionalFunction = <T>(
|
|
436
|
-
const value = exports[name]
|
|
450
|
+
const optionalFunction = <T>(value: unknown, what: string): T | undefined => {
|
|
437
451
|
if (value === undefined) return undefined
|
|
438
|
-
if (typeof value !== "function") throw new Error(
|
|
452
|
+
if (typeof value !== "function") throw new Error(`${what} is not a function`)
|
|
439
453
|
return value as T
|
|
440
454
|
}
|
|
441
455
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
"base",
|
|
456
|
+
// What each file-level export of earlier versions became: an option of the
|
|
457
|
+
// workflow it configured. Exporting one is a load error, never silently ignored.
|
|
458
|
+
const RETIRED_EXPORTS: ReadonlySet<string> = new Set([
|
|
446
459
|
"defaults",
|
|
447
460
|
"envDefaults",
|
|
448
461
|
"steering",
|
|
449
462
|
"skills",
|
|
450
463
|
"access",
|
|
451
464
|
"doors",
|
|
452
|
-
"
|
|
465
|
+
"summary",
|
|
466
|
+
"base",
|
|
453
467
|
])
|
|
454
468
|
|
|
455
|
-
/**
|
|
469
|
+
/** Names that are never a workflow. */
|
|
470
|
+
const RESERVED_EXPORTS: ReadonlySet<string> = new Set(["default", "config", ...RETIRED_EXPORTS])
|
|
471
|
+
|
|
472
|
+
const retiredExports = (exports: Record<string, unknown>, origin: string): Diagnostic[] =>
|
|
473
|
+
Object.keys(exports)
|
|
474
|
+
.filter((name) => RETIRED_EXPORTS.has(name))
|
|
475
|
+
.sort()
|
|
476
|
+
.map((name) => ({
|
|
477
|
+
severity: "error",
|
|
478
|
+
path: [name],
|
|
479
|
+
origin,
|
|
480
|
+
message: `the "${name}" export is no longer read — pass it to the workflow it configures: \`workflow(flow, { ${name} })\`, or the build a workflow factory returns`,
|
|
481
|
+
}))
|
|
482
|
+
|
|
483
|
+
/** The rules every declared setting meets. */
|
|
456
484
|
const checkSettings = (
|
|
457
485
|
defaults: Readonly<Record<string, unknown>>,
|
|
458
486
|
envDefaults: Readonly<Record<string, unknown>>,
|
|
@@ -481,28 +509,6 @@ const checkSettings = (
|
|
|
481
509
|
}
|
|
482
510
|
}
|
|
483
511
|
|
|
484
|
-
/** A module's per-file exports: `defaults`, `envDefaults`, `steering`, `skills`, `access`, `summary`, `base`. */
|
|
485
|
-
const fileFrom = (
|
|
486
|
-
exports: Record<string, unknown>,
|
|
487
|
-
origin: string,
|
|
488
|
-
dependencyFiles: readonly string[],
|
|
489
|
-
): LoadedFile => {
|
|
490
|
-
const defaults = stringRecord(exports, "defaults")
|
|
491
|
-
const envDefaults = stringRecord(exports, "envDefaults")
|
|
492
|
-
checkSettings(defaults, envDefaults, (kind) => `the "${kind}" export declares`)
|
|
493
|
-
return {
|
|
494
|
-
defaults,
|
|
495
|
-
envDefaults,
|
|
496
|
-
steering: stringRecord(exports, "steering"),
|
|
497
|
-
skills: skillsExport(exports),
|
|
498
|
-
access: accessExport(exports),
|
|
499
|
-
summary: optionalFunction(exports, "summary"),
|
|
500
|
-
base: optionalFunction(exports, "base"),
|
|
501
|
-
origin,
|
|
502
|
-
dependencyFiles,
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
512
|
const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/
|
|
507
513
|
|
|
508
514
|
/** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
|
|
@@ -532,27 +538,12 @@ const doorProblem = (name: string, door: unknown): string | undefined => {
|
|
|
532
538
|
return argsProblem(args) ?? (varsOk ? undefined : `has "vars" that is not a function`)
|
|
533
539
|
}
|
|
534
540
|
|
|
535
|
-
/** A module's `doors` export, shape-checked; the workflow names are checked later, against the catalogue. */
|
|
536
|
-
const doorsFrom = (exports: Record<string, unknown>): ReadonlyMap<string, Door> => {
|
|
537
|
-
const value = exports["doors"]
|
|
538
|
-
if (value === undefined) return new Map()
|
|
539
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
540
|
-
throw new Error(`the "doors" export is not a record of doors`)
|
|
541
|
-
}
|
|
542
|
-
for (const [name, door] of Object.entries(value)) {
|
|
543
|
-
const problem = doorProblem(name, door)
|
|
544
|
-
if (problem !== undefined) throw new Error(`door "${name}" ${problem}`)
|
|
545
|
-
}
|
|
546
|
-
return new Map(Object.entries(value) as [string, Door][])
|
|
547
|
-
}
|
|
548
|
-
|
|
549
541
|
/**
|
|
550
|
-
* Each exported workflow's own doors, onto the name it is exported under.
|
|
551
|
-
*
|
|
542
|
+
* Each exported workflow's own doors, onto the name it is exported under. A
|
|
543
|
+
* door several workflows build and none is exported under is a clash: no door.
|
|
552
544
|
*/
|
|
553
545
|
const derivedDoors = (
|
|
554
546
|
catalogue: ReadonlyMap<string, LoadedWorkflow>,
|
|
555
|
-
declared: ReadonlyMap<string, Door>,
|
|
556
547
|
): {
|
|
557
548
|
readonly doors: ReadonlyMap<string, Door>
|
|
558
549
|
readonly clashes: ReadonlyMap<string, readonly string[]>
|
|
@@ -572,45 +563,29 @@ const derivedDoors = (
|
|
|
572
563
|
for (const [name, built] of builders) {
|
|
573
564
|
const owner = ownerOf(name, built)
|
|
574
565
|
if (owner !== undefined) doors.set(name, owner)
|
|
575
|
-
else
|
|
566
|
+
else
|
|
576
567
|
clashes.set(
|
|
577
568
|
name,
|
|
578
569
|
built.map((door) => door.workflow),
|
|
579
570
|
)
|
|
580
571
|
}
|
|
581
|
-
return { doors
|
|
572
|
+
return { doors, clashes }
|
|
582
573
|
}
|
|
583
574
|
|
|
584
575
|
/** The export named after the door owns it, else the sole builder; a clash of several builds no door. */
|
|
585
576
|
const ownerOf = (name: string, built: readonly Door[]): Door | undefined =>
|
|
586
577
|
built.find((door) => door.workflow === name) ?? (built.length === 1 ? built[0] : undefined)
|
|
587
578
|
|
|
588
|
-
/** Sort the doors and check each names a startable workflow. */
|
|
589
|
-
const checkDoors = (
|
|
590
|
-
declared: ReadonlyMap<string, Door>,
|
|
591
|
-
catalogue: ReadonlyMap<string, LoadedWorkflow>,
|
|
592
|
-
): ReadonlyMap<string, Door> => {
|
|
593
|
-
const doors = new Map([...declared].sort(([a], [b]) => a.localeCompare(b)))
|
|
594
|
-
for (const [name, door] of doors) {
|
|
595
|
-
if (!catalogue.has(door.workflow)) {
|
|
596
|
-
throw new Error(
|
|
597
|
-
`door "${name}" names workflow "${door.workflow}", which is not startable — known: ${[...catalogue.keys()].sort().join(", ")}`,
|
|
598
|
-
)
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
return doors
|
|
602
|
-
}
|
|
603
|
-
|
|
604
579
|
const namespaceOf = (exported: unknown): Record<string, unknown> =>
|
|
605
580
|
(typeof exported === "object" && exported !== null ? exported : {}) as Record<string, unknown>
|
|
606
581
|
|
|
607
582
|
/**
|
|
608
|
-
* Evaluate `gtd.config.ts`. Every
|
|
609
|
-
*
|
|
610
|
-
* per-call module cache (unrelated to the
|
|
611
|
-
* passing an empty object in has the side
|
|
612
|
-
* jiti resolved evaluating this module,
|
|
613
|
-
* pulled in, transitively.
|
|
583
|
+
* Evaluate `gtd.config.ts`. Every export but the reserved names that went
|
|
584
|
+
* through `workflow()` or is a factory workflow is a workflow named by its
|
|
585
|
+
* export. `cache` is jiti's OWN per-call module cache (unrelated to the
|
|
586
|
+
* instance's `moduleCache: false`) — passing an empty object in has the side
|
|
587
|
+
* effect of collecting every real file jiti resolved evaluating this module,
|
|
588
|
+
* module AND every relative import it pulled in, transitively.
|
|
614
589
|
*/
|
|
615
590
|
const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
|
|
616
591
|
const { exports: evaluated, files } =
|
|
@@ -618,7 +593,7 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
618
593
|
? evaluateFromDisk(module, shipped)
|
|
619
594
|
: evaluateRead(module, module.read, shipped)
|
|
620
595
|
const exports = namespaceOf(evaluated)
|
|
621
|
-
const file =
|
|
596
|
+
const file: LoadedFile = { origin: module.filepath, dependencyFiles: files }
|
|
622
597
|
const exported = Object.entries(exports).filter(
|
|
623
598
|
([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
|
|
624
599
|
)
|
|
@@ -643,20 +618,23 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
643
618
|
const derived =
|
|
644
619
|
rejections.length > 0
|
|
645
620
|
? { doors: new Map<string, Door>(), clashes: new Map<string, readonly string[]>() }
|
|
646
|
-
: derivedDoors(catalogue
|
|
621
|
+
: derivedDoors(catalogue)
|
|
622
|
+
const doors = new Map([...derived.doors].sort(([a], [b]) => a.localeCompare(b)))
|
|
647
623
|
return {
|
|
648
624
|
file,
|
|
649
625
|
catalogue,
|
|
650
|
-
doors
|
|
626
|
+
doors,
|
|
651
627
|
default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
|
|
652
628
|
config: compileConfig(exports["config"], module.filepath),
|
|
653
629
|
diagnostics: [
|
|
630
|
+
...retiredExports(exports, file.origin),
|
|
654
631
|
...nameRejections(rejections),
|
|
632
|
+
...shadowingDoors(doors, catalogue, file.origin),
|
|
655
633
|
...[...derived.clashes].map(([name, builders]): Diagnostic => {
|
|
656
634
|
const names = [...builders].sort().map((b) => JSON.stringify(b))
|
|
657
635
|
return {
|
|
658
636
|
severity: "warning",
|
|
659
|
-
message: `door "${name}" is built by workflows ${names.slice(0, -1).join(", ")} and ${names.at(-1)} and none is exported under that name —
|
|
637
|
+
message: `door "${name}" is built by workflows ${names.slice(0, -1).join(", ")} and ${names.at(-1)} and none is exported under that name — rename the export that should own it to "${name}"`,
|
|
660
638
|
path: ["doors", name],
|
|
661
639
|
origin: file.origin,
|
|
662
640
|
}
|
|
@@ -665,6 +643,21 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
665
643
|
}
|
|
666
644
|
}
|
|
667
645
|
|
|
646
|
+
/** `gtd start <name>` takes the door, so a door named like a workflow it does not start hides that workflow. */
|
|
647
|
+
const shadowingDoors = (
|
|
648
|
+
doors: ReadonlyMap<string, Door>,
|
|
649
|
+
catalogue: ReadonlyMap<string, unknown>,
|
|
650
|
+
origin: string,
|
|
651
|
+
): Diagnostic[] =>
|
|
652
|
+
[...doors]
|
|
653
|
+
.filter(([name, door]) => catalogue.has(name) && door.workflow !== name)
|
|
654
|
+
.map(([name, door]) => ({
|
|
655
|
+
severity: "warning",
|
|
656
|
+
message: `door "${name}" starts workflow ${JSON.stringify(door.workflow)} and hides the workflow of the same name from \`gtd start ${name}\` — rename one of them`,
|
|
657
|
+
path: ["doors", name],
|
|
658
|
+
origin,
|
|
659
|
+
}))
|
|
660
|
+
|
|
668
661
|
const evaluateFromDisk = (
|
|
669
662
|
module: WorkflowModule,
|
|
670
663
|
shipped: ShippedModules,
|
|
@@ -720,61 +713,91 @@ const evaluateRead = (
|
|
|
720
713
|
}
|
|
721
714
|
|
|
722
715
|
const isWorkflow = (value: unknown): boolean =>
|
|
723
|
-
|
|
716
|
+
markedOptions(value) !== undefined || isFactoryWorkflow(value)
|
|
724
717
|
|
|
725
718
|
const defaultExport = (exports: Record<string, unknown>): unknown => {
|
|
726
719
|
const value = exports.default
|
|
727
720
|
if (value === undefined) {
|
|
728
721
|
throw new Error(
|
|
729
|
-
"no default export — export the workflow an ordinary start runs
|
|
722
|
+
"no default export — export the workflow an ordinary start runs; `gtd guide` says where to get one, or write your own",
|
|
730
723
|
)
|
|
731
724
|
}
|
|
732
|
-
if (
|
|
725
|
+
if (typeof value !== "function" && !isFactoryWorkflow(value)) {
|
|
726
|
+
throw new Error("the default export is not a function")
|
|
727
|
+
}
|
|
733
728
|
return value
|
|
734
729
|
}
|
|
735
730
|
|
|
736
|
-
/** One exported workflow: a
|
|
731
|
+
/** One exported workflow: a factory validated and built, a `workflow()` call's options checked alike. */
|
|
737
732
|
const buildWorkflow = (
|
|
738
733
|
name: string,
|
|
739
734
|
value: unknown,
|
|
740
735
|
file: LoadedFile,
|
|
741
736
|
rejections: Rejection[],
|
|
742
737
|
): LoadedWorkflow | undefined => {
|
|
743
|
-
const result =
|
|
744
|
-
if (result === undefined)
|
|
745
|
-
|
|
738
|
+
const result = configure(value)
|
|
739
|
+
if (result === undefined) {
|
|
740
|
+
// A plain function only gets here as the default export: a flow without options.
|
|
741
|
+
return checkedBuild(name, file, { ...markedOptions(value), flow: value as Flow })
|
|
742
|
+
}
|
|
746
743
|
if (!result.ok) {
|
|
747
744
|
for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, file))
|
|
748
745
|
return undefined
|
|
749
746
|
}
|
|
747
|
+
return checkedBuild(name, file, result.build)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const BUILD_KEYS: ReadonlySet<string> = new Set([
|
|
751
|
+
"flow",
|
|
752
|
+
"defaults",
|
|
753
|
+
"envDefaults",
|
|
754
|
+
"doors",
|
|
755
|
+
"base",
|
|
756
|
+
"skills",
|
|
757
|
+
"access",
|
|
758
|
+
"steering",
|
|
759
|
+
"summary",
|
|
760
|
+
])
|
|
761
|
+
|
|
762
|
+
/** A build — a factory's, or a `workflow()` call's options with its flow — held to one set of rules. */
|
|
763
|
+
const checkedBuild = (
|
|
764
|
+
name: string,
|
|
765
|
+
file: LoadedFile,
|
|
766
|
+
build: flows.WorkflowBuild,
|
|
767
|
+
): LoadedWorkflow => {
|
|
768
|
+
const what = (key: string): string => `workflow "${name}"'s "${key}"`
|
|
769
|
+
const unknown = Object.keys(build).filter((key) => !BUILD_KEYS.has(key))
|
|
770
|
+
if (unknown.length > 0) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`workflow "${name}" has unknown option(s) ${unknown.map((key) => JSON.stringify(key)).join(", ")} — known: ${[...BUILD_KEYS].filter((key) => key !== "flow").join(", ")}`,
|
|
773
|
+
)
|
|
774
|
+
}
|
|
775
|
+
if (typeof build.flow !== "function") throw new Error(`${what("flow")} is not a function`)
|
|
776
|
+
const defaults = stringRecord(build.defaults, what("defaults"))
|
|
777
|
+
const envDefaults = stringRecord(build.envDefaults, what("envDefaults"))
|
|
778
|
+
checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" declares ${kind}`)
|
|
779
|
+
if (
|
|
780
|
+
build.doors !== undefined &&
|
|
781
|
+
(typeof build.doors !== "object" || build.doors === null || Array.isArray(build.doors))
|
|
782
|
+
) {
|
|
783
|
+
throw new Error(`${what("doors")} is not a record of doors`)
|
|
784
|
+
}
|
|
750
785
|
return {
|
|
751
|
-
flow:
|
|
786
|
+
flow: build.flow,
|
|
752
787
|
file,
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
788
|
+
defaults,
|
|
789
|
+
envDefaults,
|
|
790
|
+
summary: optionalFunction<flows.Summary>(build.summary, what("summary")),
|
|
791
|
+
steering: stringRecord(build.steering, what("steering")),
|
|
792
|
+
skills: skillsFrom(build.skills, what("skills")),
|
|
793
|
+
access: accessFrom(build.access, what("access")),
|
|
794
|
+
doors: build.doors,
|
|
795
|
+
base: optionalFunction<flows.WorkflowBase>(build.base, what("base")),
|
|
756
796
|
}
|
|
757
797
|
}
|
|
758
798
|
|
|
759
|
-
/** A factory's settings over its file's, held to the rules a file's own exports are. */
|
|
760
|
-
const builtSettings = (name: string, file: LoadedFile, build: flows.WorkflowBuild): Settings => {
|
|
761
|
-
checkSettings(
|
|
762
|
-
build.defaults ?? {},
|
|
763
|
-
build.envDefaults ?? {},
|
|
764
|
-
(kind) => `workflow "${name}" builds ${kind}`,
|
|
765
|
-
)
|
|
766
|
-
const defaults = { ...file.defaults, ...build.defaults }
|
|
767
|
-
const envDefaults = { ...file.envDefaults, ...build.envDefaults }
|
|
768
|
-
checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" builds ${kind}`)
|
|
769
|
-
return { defaults, envDefaults }
|
|
770
|
-
}
|
|
771
|
-
|
|
772
799
|
/** A rejected factory option, reported at `gtd.config.ts`. */
|
|
773
|
-
const optionDiagnostic = (
|
|
774
|
-
workflow: string,
|
|
775
|
-
issue: flows.OptionIssue,
|
|
776
|
-
file: LoadedFile,
|
|
777
|
-
): Rejection => ({
|
|
800
|
+
const optionDiagnostic = (workflow: string, issue: OptionIssue, file: LoadedFile): Rejection => ({
|
|
778
801
|
workflow,
|
|
779
802
|
severity: "error",
|
|
780
803
|
path: issue.path,
|
|
@@ -885,7 +908,7 @@ const replayToFirstStep = (
|
|
|
885
908
|
outcome.kind === "failed" || outcome.kind === "refused"
|
|
886
909
|
? outcome.message.replace(/^gtd: /, "")
|
|
887
910
|
: "the default entry must begin at a step — that step is where a finished process waits"
|
|
888
|
-
return Effect.fail(new GtdError(`gtd config:\n - ${loaded.file.origin}: ${problem}`))
|
|
911
|
+
return Effect.fail(new GtdError(`gtd: config:\n - ${loaded.file.origin}: ${problem}`))
|
|
889
912
|
},
|
|
890
913
|
)
|
|
891
914
|
|
|
@@ -912,7 +935,7 @@ const firstStep = (
|
|
|
912
935
|
if (reads.length > 0) {
|
|
913
936
|
return yield* Effect.fail(
|
|
914
937
|
new GtdError(
|
|
915
|
-
`gtd config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start reads ${reads.join(" and ")} — that step is where a finished process waits, so it must not depend on a setting`,
|
|
938
|
+
`gtd: config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start reads ${reads.join(" and ")} — that step is where a finished process waits, so it must not depend on a setting`,
|
|
916
939
|
),
|
|
917
940
|
)
|
|
918
941
|
}
|
|
@@ -923,7 +946,7 @@ const firstStep = (
|
|
|
923
946
|
if (atHead === name) return name
|
|
924
947
|
return yield* Effect.fail(
|
|
925
948
|
new GtdError(
|
|
926
|
-
`gtd config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start depends on the repository's files ("${name}" without them, "${atHead}" at HEAD) — that step is where a finished process waits, so it must not change as files do`,
|
|
949
|
+
`gtd: config:\n - ${loaded.file.origin}: the flow's first step on an ordinary start depends on the repository's files ("${name}" without them, "${atHead}" at HEAD) — that step is where a finished process waits, so it must not change as files do`,
|
|
927
950
|
),
|
|
928
951
|
)
|
|
929
952
|
})
|
|
@@ -17,24 +17,24 @@ describe("resolveScopeSkills", () => {
|
|
|
17
17
|
const message = await failure(() => {
|
|
18
18
|
throw new Error("boom")
|
|
19
19
|
})
|
|
20
|
-
expect(message).toContain(`gtd config:\n - ${FILE}: boom`)
|
|
20
|
+
expect(message).toContain(`gtd: config:\n - ${FILE}: boom`)
|
|
21
21
|
})
|
|
22
22
|
|
|
23
23
|
it("rejects a non-object return", async () => {
|
|
24
24
|
expect(await failure(() => ["a"])).toContain(
|
|
25
|
-
`gtd config:\n - ${FILE}: skills export must return an object of scope -> skill names`,
|
|
25
|
+
`gtd: config:\n - ${FILE}: skills export must return an object of scope -> skill names`,
|
|
26
26
|
)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
it("rejects a non-array value", async () => {
|
|
30
30
|
expect(await failure(() => ({ a: "x" }))).toContain(
|
|
31
|
-
`gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
|
|
31
|
+
`gtd: config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
|
|
32
32
|
)
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
it("rejects a non-string entry", async () => {
|
|
36
36
|
expect(await failure(() => ({ a: ["x", 1] }))).toContain(
|
|
37
|
-
`gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
|
|
37
|
+
`gtd: config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
|
|
38
38
|
)
|
|
39
39
|
})
|
|
40
40
|
|
|
@@ -80,7 +80,7 @@ describe("resolveScopeAccess", () => {
|
|
|
80
80
|
|
|
81
81
|
it("rejects a malformed export with the file", async () => {
|
|
82
82
|
expect(message(await run({ access: () => ["a"] }))).toContain(
|
|
83
|
-
`gtd config:\n - ${FILE}: access export must return an object`,
|
|
83
|
+
`gtd: config:\n - ${FILE}: access export must return an object`,
|
|
84
84
|
)
|
|
85
85
|
expect(message(await run({ access: () => ({ a: { write: "x" } }) }))).toContain(
|
|
86
86
|
`${FILE}: access export: "a": access.write must be an array`,
|
package/src/workflow/skills.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Diagnostic } from "./Diagnostic.js"
|
|
|
7
7
|
import { failOnErrors } from "./load.js"
|
|
8
8
|
|
|
9
9
|
const fail = (origin: string, message: string): GtdError =>
|
|
10
|
-
new GtdError(`gtd config:\n - ${origin}: ${message}`)
|
|
10
|
+
new GtdError(`gtd: config:\n - ${origin}: ${message}`)
|
|
11
11
|
|
|
12
12
|
const checkShape = (value: unknown): Record<string, readonly string[]> => {
|
|
13
13
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import * as kit from "./index.js"
|
|
3
|
+
|
|
4
|
+
describe("@pmelab/gtd/workflow-kit", () => {
|
|
5
|
+
it("publishes its helpers by name", () => {
|
|
6
|
+
expect(Object.keys(kit).sort()).toEqual(
|
|
7
|
+
[
|
|
8
|
+
"assertionLines",
|
|
9
|
+
"escalation",
|
|
10
|
+
"fixFindings",
|
|
11
|
+
"gate",
|
|
12
|
+
"healthy",
|
|
13
|
+
"lensEntriesOf",
|
|
14
|
+
"lensOf",
|
|
15
|
+
"lensScopes",
|
|
16
|
+
"lensesOf",
|
|
17
|
+
"qualityLap",
|
|
18
|
+
"removedAssertions",
|
|
19
|
+
"review",
|
|
20
|
+
"reviewToHandOff",
|
|
21
|
+
"untilSettled",
|
|
22
|
+
].sort(),
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
})
|