@pmelab/gtd 23.2.0 → 24.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { createRequire } from "node:module"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
|
+
import { Context, Effect, Layer } from "effect"
|
|
6
|
+
import { GtdError, Narrator } from "../platform/index.js"
|
|
7
|
+
import { replay, type TreeView } from "../replay/index.js"
|
|
8
|
+
import * as flows from "../flows/index.js"
|
|
9
|
+
import type { StateMode, WorkflowDefinition } from "./Workflow.js"
|
|
10
|
+
import type { Door, Flow } from "../flows/index.js"
|
|
11
|
+
import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
|
|
12
|
+
import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
13
|
+
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
14
|
+
import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
|
|
15
|
+
import {
|
|
16
|
+
dedupeDiagnostics,
|
|
17
|
+
formatDiagnostic,
|
|
18
|
+
sortDiagnostics,
|
|
19
|
+
type Diagnostic,
|
|
20
|
+
} from "./Diagnostic.js"
|
|
21
|
+
|
|
22
|
+
/** A startable workflow, resolved with its own file's setting defaults. */
|
|
23
|
+
export interface ResolvedWorkflow {
|
|
24
|
+
readonly name: string
|
|
25
|
+
readonly def: WorkflowDefinition
|
|
26
|
+
readonly vars: Record<string, string>
|
|
27
|
+
readonly env: Record<string, string>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ConfigOperations {
|
|
31
|
+
/** The default workflow: what an ordinary start runs. */
|
|
32
|
+
readonly workflow: WorkflowDefinition
|
|
33
|
+
/** The default, resolved like a named one; its name is what status shows: the named export it also is, else `"default"`. */
|
|
34
|
+
readonly defaultWorkflow: ResolvedWorkflow
|
|
35
|
+
/** Every name `gtd --workflow` accepts, sorted — never `"default"`. */
|
|
36
|
+
readonly workflowNames: readonly string[]
|
|
37
|
+
readonly workflowNamed: (name: string) => ResolvedWorkflow | undefined
|
|
38
|
+
/** Every door `gtd door` accepts. A door's `workflow` resolves through `workflowNamed`. */
|
|
39
|
+
readonly doors: ReadonlyMap<string, Door>
|
|
40
|
+
/** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
|
|
41
|
+
readonly ui?: flows.UiSettings
|
|
42
|
+
/** Non-fatal findings, formatted through `formatDiagnostic` like an error. */
|
|
43
|
+
readonly warnings: readonly Diagnostic[]
|
|
44
|
+
/** Every real file `gtd.config.ts`'s evaluation touched (itself plus any relative import). A caller that must know when `workflow` might have changed (the LSP's steering-map memo) watches all of them; watching `gtd.config.ts`'s own path alone misses an edit to a module it imports. */
|
|
45
|
+
readonly workflowFiles: readonly string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Everything lives in `message` (not `GtdError.detail`) — `renderFailure`
|
|
49
|
+
// prints `message` verbatim and would print a duplicated `detail` twice.
|
|
50
|
+
const configError = (diagnostics: readonly Diagnostic[]): GtdError =>
|
|
51
|
+
new GtdError(
|
|
52
|
+
`gtd config:\n${diagnostics
|
|
53
|
+
.filter((d) => d.severity === "error")
|
|
54
|
+
.map((d) => ` - ${formatDiagnostic(d)}`)
|
|
55
|
+
.join("\n")}`,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
export const failOnErrors = (diagnostics: readonly Diagnostic[]): Effect.Effect<void, GtdError> =>
|
|
59
|
+
diagnostics.some((d) => d.severity === "error")
|
|
60
|
+
? Effect.fail(configError(diagnostics))
|
|
61
|
+
: Effect.void
|
|
62
|
+
|
|
63
|
+
const MIGRATION_DOC =
|
|
64
|
+
"https://github.com/pmelab/gtd/blob/main/docs/configuration.md#migrating-from-gtdrc"
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `.gtdrc` is gone, but a leftover one — a repository's or the user's own —
|
|
68
|
+
* must fail loudly rather than be silently ignored. One the user cannot delete
|
|
69
|
+
* (another account's, in a shared ancestor) only warns: failing would lock them
|
|
70
|
+
* out of gtd with no way to fix it.
|
|
71
|
+
*/
|
|
72
|
+
export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscovery | Narrator> =
|
|
73
|
+
Effect.gen(function* () {
|
|
74
|
+
const host = yield* Host
|
|
75
|
+
const files = yield* Effect.orDie((yield* ConfigDiscovery).legacyRcFiles(host.root, host.home))
|
|
76
|
+
for (const file of files.filter((f) => !f.owned)) {
|
|
77
|
+
yield* (yield* Narrator).warn(
|
|
78
|
+
`gtd: warning: ${file.path} is a removed .gtdrc file owned by another user — ignored`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const found = files.filter((f) => f.owned).map((f) => f.path)
|
|
82
|
+
if (found.length === 0) return
|
|
83
|
+
return yield* Effect.fail(
|
|
84
|
+
new GtdError(
|
|
85
|
+
[
|
|
86
|
+
"gtd config:",
|
|
87
|
+
...found.map((file) => ` - ${file}: no longer read — delete it`),
|
|
88
|
+
" gtd 23 removed the .gtdrc files; move each key they set:",
|
|
89
|
+
" vars → an option of the workflow factory in gtd.config.ts, or its `defaults` export",
|
|
90
|
+
" env → an option of the workflow factory in gtd.config.ts, or its `envDefaults` export",
|
|
91
|
+
" judge → ~/.config/gtd/judge.json, or GTD_JUDGE_PROVIDER / GTD_JUDGE_MODEL",
|
|
92
|
+
" modes, ui, skills, access → the `config` export of gtd.config.ts (`defineConfig`)",
|
|
93
|
+
" $schema → nothing; drop it",
|
|
94
|
+
` see ${MIGRATION_DOC}`,
|
|
95
|
+
].join("\n"),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Find and evaluate `gtd.config.ts`, then check what it exports. Every finding
|
|
102
|
+
* is collected into ONE sorted report rather than failing on the first.
|
|
103
|
+
*/
|
|
104
|
+
export const load: Effect.Effect<
|
|
105
|
+
ConfigOperations,
|
|
106
|
+
Error,
|
|
107
|
+
Narrator | Workspace | Host | ConfigDiscovery
|
|
108
|
+
> = Effect.gen(function* () {
|
|
109
|
+
const host = yield* Host
|
|
110
|
+
const discovery = yield* ConfigDiscovery
|
|
111
|
+
yield* refuseLegacyRc
|
|
112
|
+
const module = yield* discovery.workflowModule(host.root, host.home)
|
|
113
|
+
if (module === undefined) {
|
|
114
|
+
const missing: Diagnostic = {
|
|
115
|
+
severity: "error",
|
|
116
|
+
path: [],
|
|
117
|
+
message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — run \`gtd install\` to scaffold one`,
|
|
118
|
+
origin: host.root,
|
|
119
|
+
}
|
|
120
|
+
return yield* Effect.fail(configError([missing]))
|
|
121
|
+
}
|
|
122
|
+
yield* (yield* Narrator).narrate(`config: module ${module.filepath}`)
|
|
123
|
+
const shipped = yield* Effect.tryPromise({
|
|
124
|
+
try: shippedModules,
|
|
125
|
+
catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
|
|
126
|
+
})
|
|
127
|
+
const loaded = yield* Effect.try({
|
|
128
|
+
try: () => loadWorkflows(module, shipped),
|
|
129
|
+
catch: (e) =>
|
|
130
|
+
new GtdError(
|
|
131
|
+
`gtd config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
132
|
+
),
|
|
133
|
+
})
|
|
134
|
+
const compiled = loaded.config
|
|
135
|
+
const diagnostics = dedupeDiagnostics(
|
|
136
|
+
sortDiagnostics([...compiled.diagnostics, ...loaded.diagnostics]),
|
|
137
|
+
)
|
|
138
|
+
yield* failOnErrors(diagnostics)
|
|
139
|
+
if (loaded.default === undefined) {
|
|
140
|
+
return yield* Effect.die("unreachable: a default fails to build only with an error reported")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const initial = yield* firstStep(loaded.default, headTree(yield* Workspace))
|
|
144
|
+
const steering = loaded.file.steering as Record<string, StateMode>
|
|
145
|
+
const define = (entry: LoadedWorkflow): WorkflowDefinition => ({
|
|
146
|
+
flow: entry.flow,
|
|
147
|
+
summary: entry.file.summary,
|
|
148
|
+
// A config's own `base` export wins when it answers; else the factory's.
|
|
149
|
+
base: (w, v) => entry.file.base?.(w, v) ?? entry.base?.(w, v),
|
|
150
|
+
steering,
|
|
151
|
+
modes: compiled.modes,
|
|
152
|
+
skills: entry.file.skills,
|
|
153
|
+
configuredSkills: compiled.skills,
|
|
154
|
+
access: entry.file.access,
|
|
155
|
+
configuredAccess: compiled.access,
|
|
156
|
+
skillsOrigin: entry.file.origin,
|
|
157
|
+
initial,
|
|
158
|
+
})
|
|
159
|
+
const names = [...loaded.catalogue.keys()].sort()
|
|
160
|
+
const resolve = (name: string, entry: LoadedWorkflow): ResolvedWorkflow => ({
|
|
161
|
+
name,
|
|
162
|
+
def: define(entry),
|
|
163
|
+
vars: { ...entry.defaults },
|
|
164
|
+
env: { ...entry.envDefaults },
|
|
165
|
+
})
|
|
166
|
+
const defaultWorkflow = resolve(loaded.default.name, loaded.default)
|
|
167
|
+
return {
|
|
168
|
+
workflow: defaultWorkflow.def,
|
|
169
|
+
defaultWorkflow,
|
|
170
|
+
workflowNames: names,
|
|
171
|
+
doors: loaded.doors,
|
|
172
|
+
workflowNamed: (name) => {
|
|
173
|
+
const entry = loaded.catalogue.get(name)
|
|
174
|
+
return entry === undefined ? undefined : resolve(name, entry)
|
|
175
|
+
},
|
|
176
|
+
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
177
|
+
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
178
|
+
workflowFiles: loaded.file.dependencyFiles,
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
interface Settings {
|
|
183
|
+
readonly defaults: Readonly<Record<string, string>>
|
|
184
|
+
readonly envDefaults: Readonly<Record<string, string>>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface LoadedFile
|
|
188
|
+
extends
|
|
189
|
+
Pick<WorkflowDefinition, "summary" | "base" | "steering" | "skills" | "access">,
|
|
190
|
+
Settings {
|
|
191
|
+
readonly origin: string
|
|
192
|
+
/**
|
|
193
|
+
* Every real file this module's evaluation touched — `origin` itself plus
|
|
194
|
+
* any relative import it (transitively) resolved on disk, in NO particular
|
|
195
|
+
* order. A caller that
|
|
196
|
+
* needs to know when a workflow's DEFINITION might have changed (the LSP's
|
|
197
|
+
* steering-map memo) must watch this whole set, not just `origin`: a
|
|
198
|
+
* `gtd.config.ts` re-exporting a sibling module is a documented pattern,
|
|
199
|
+
* and jiti resolves that import off disk same as any other require.
|
|
200
|
+
*/
|
|
201
|
+
readonly dependencyFiles: readonly string[]
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A startable workflow; its settings are its file's, under whatever its factory declared. */
|
|
205
|
+
interface LoadedWorkflow extends Settings {
|
|
206
|
+
readonly flow: Flow
|
|
207
|
+
readonly file: LoadedFile
|
|
208
|
+
readonly doors?: flows.WorkflowDoors | undefined
|
|
209
|
+
readonly base?: flows.WorkflowBase | undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface Loaded {
|
|
213
|
+
readonly file: LoadedFile
|
|
214
|
+
/** Every startable workflow. */
|
|
215
|
+
readonly catalogue: ReadonlyMap<string, LoadedWorkflow>
|
|
216
|
+
readonly doors: ReadonlyMap<string, Door>
|
|
217
|
+
/** What an ordinary start runs; absent only alongside an error in `diagnostics`. */
|
|
218
|
+
readonly default: (LoadedWorkflow & { readonly name: string }) | undefined
|
|
219
|
+
readonly config: CompiledConfig
|
|
220
|
+
readonly diagnostics: readonly Diagnostic[]
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type JitiInstance = {
|
|
224
|
+
evalModule: (
|
|
225
|
+
source: string,
|
|
226
|
+
options: { filename: string; async?: false; cache?: Record<string, unknown> },
|
|
227
|
+
) => unknown
|
|
228
|
+
}
|
|
229
|
+
type JitiModule = {
|
|
230
|
+
createJiti: (id: string, options: Record<string, unknown>) => JitiInstance
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type Transform = (options: { readonly source: string }) => { code: string; error?: unknown }
|
|
234
|
+
|
|
235
|
+
// Transpiling is most of a load's cost, and a load re-transpiles
|
|
236
|
+
// gtd.config.ts and every shipped module it imports. The same options (the
|
|
237
|
+
// source included) always transpile to the same code, so an in-process memo
|
|
238
|
+
// is safe; evaluation stays fresh on every load.
|
|
239
|
+
const TRANSFORM_MEMO_SIZE = 512
|
|
240
|
+
const memoized = (transform: Transform): Transform => {
|
|
241
|
+
const memo = new Map<string, ReturnType<Transform>>()
|
|
242
|
+
return (options) => {
|
|
243
|
+
const key = JSON.stringify(options)
|
|
244
|
+
const hit = memo.get(key)
|
|
245
|
+
if (hit !== undefined) return hit
|
|
246
|
+
const result = transform(options)
|
|
247
|
+
if (result.error !== undefined) return result
|
|
248
|
+
if (memo.size >= TRANSFORM_MEMO_SIZE) memo.delete(memo.keys().next().value!)
|
|
249
|
+
memo.set(key, result)
|
|
250
|
+
return result
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let jitiModule: JitiModule | undefined
|
|
255
|
+
let transform: Transform | undefined
|
|
256
|
+
const jiti = (shipped: ShippedModules): JitiInstance => {
|
|
257
|
+
const require = createRequire(import.meta.url)
|
|
258
|
+
jitiModule ??= require("jiti") as JitiModule
|
|
259
|
+
// jiti's own Babel transform, which its package exports don't name.
|
|
260
|
+
transform ??= memoized(
|
|
261
|
+
require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
|
|
262
|
+
)
|
|
263
|
+
return jitiModule.createJiti(import.meta.url, {
|
|
264
|
+
virtualModules: shipped.modules,
|
|
265
|
+
alias: shipped.missing,
|
|
266
|
+
// No transpile cache on disk, and a fresh module every load — nothing a
|
|
267
|
+
// later command could read back instead of the source.
|
|
268
|
+
fsCache: false,
|
|
269
|
+
moduleCache: false,
|
|
270
|
+
transform,
|
|
271
|
+
// The whole module, not just its default: the named exports are read too.
|
|
272
|
+
interopDefault: false,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const PACKAGE = "@pmelab/gtd"
|
|
277
|
+
|
|
278
|
+
interface PackageJson {
|
|
279
|
+
readonly name?: string
|
|
280
|
+
readonly exports: Readonly<Record<string, string | Readonly<Record<string, string>>>>
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Only gtd's own package.json: whatever other one sits nearest (a vendored
|
|
284
|
+
// bundle inside a project) must never get its exports executed.
|
|
285
|
+
const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
|
|
286
|
+
const start = dirname(fileURLToPath(import.meta.url))
|
|
287
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
288
|
+
const file = join(dir, "package.json")
|
|
289
|
+
if (existsSync(file)) {
|
|
290
|
+
const pkg = JSON.parse(readFileSync(file, "utf8")) as PackageJson
|
|
291
|
+
if (pkg.name === PACKAGE) return { root: dir, pkg }
|
|
292
|
+
}
|
|
293
|
+
if (dirname(dir) === dir) throw new Error(`no ${PACKAGE} package.json above ${start}`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
interface ShippedModules {
|
|
298
|
+
readonly modules: Readonly<Record<string, unknown>>
|
|
299
|
+
/** Subpaths whose file failed to load, aliased to that file so only a config importing one fails. */
|
|
300
|
+
readonly missing: Readonly<Record<string, string>>
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Every subpath gtd's package exports resolves to the running gtd's own copy,
|
|
304
|
+
// so a config needs no install and never mixes versions. `flows` is the module
|
|
305
|
+
// already loaded here: a config runs against the very runtime replay installs
|
|
306
|
+
// its context for. Run from source (tests), a subpath's `source` is served
|
|
307
|
+
// instead of its build. Shipped code never changes in a process, so it is
|
|
308
|
+
// imported once, natively: where Node can `require` ESM, the first time a
|
|
309
|
+
// config — or a workflow as it runs — requires it, so a command loads only the
|
|
310
|
+
// subpaths its config uses; elsewhere, and from source, all of them up front.
|
|
311
|
+
let shippedMemo: Promise<ShippedModules> | undefined
|
|
312
|
+
const shippedModules = (): Promise<ShippedModules> =>
|
|
313
|
+
(shippedMemo ??= (async () => {
|
|
314
|
+
const fromSource = import.meta.url.endsWith(".ts")
|
|
315
|
+
const files = subpathFiles(fromSource ? "source" : "default")
|
|
316
|
+
const modules: Record<string, unknown> = { [`${PACKAGE}/flows`]: flows }
|
|
317
|
+
const missing: Record<string, string> = {}
|
|
318
|
+
if (process.features.require_module && !fromSource) {
|
|
319
|
+
return { modules: requiredOnUse(modules, files), missing }
|
|
320
|
+
}
|
|
321
|
+
for (const [id, path] of files) {
|
|
322
|
+
try {
|
|
323
|
+
modules[id] = await import(pathToFileURL(path).href)
|
|
324
|
+
} catch {
|
|
325
|
+
missing[id] = path
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { modules, missing }
|
|
329
|
+
})())
|
|
330
|
+
|
|
331
|
+
/** The file each subpath a config may import serves: all but `flows`, the engine's own, and `testkit`. */
|
|
332
|
+
const subpathFiles = (condition: string): ReadonlyMap<string, string> => {
|
|
333
|
+
const { root, pkg } = ownPackage()
|
|
334
|
+
const files = new Map<string, string>()
|
|
335
|
+
for (const [subpath, target] of Object.entries(pkg.exports)) {
|
|
336
|
+
const file = typeof target === "string" ? target : target[condition]
|
|
337
|
+
// testkit imports the consumer's test runner, and no config runs one.
|
|
338
|
+
if (subpath === "./flows" || subpath === "./testkit" || file === undefined) continue
|
|
339
|
+
files.set(`${PACKAGE}/${subpath.replace(/^\.\//, "")}`, join(root, file))
|
|
340
|
+
}
|
|
341
|
+
return files
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const requiredOnUse = (
|
|
345
|
+
modules: Record<string, unknown>,
|
|
346
|
+
files: ReadonlyMap<string, string>,
|
|
347
|
+
): Record<string, unknown> => {
|
|
348
|
+
const require = createRequire(import.meta.url)
|
|
349
|
+
return new Proxy(modules, {
|
|
350
|
+
has: (loaded, id) => id in loaded || (typeof id === "string" && files.has(id)),
|
|
351
|
+
get: (loaded, id) =>
|
|
352
|
+
typeof id !== "string" || id in loaded
|
|
353
|
+
? loaded[id as string]
|
|
354
|
+
: (loaded[id] = require(files.get(id)!)),
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const stringRecord = (
|
|
359
|
+
exports: Record<string, unknown>,
|
|
360
|
+
name: string,
|
|
361
|
+
): Readonly<Record<string, string>> => {
|
|
362
|
+
const value = exports[name] ?? {}
|
|
363
|
+
if (
|
|
364
|
+
typeof value !== "object" ||
|
|
365
|
+
value === null ||
|
|
366
|
+
Object.values(value).some((entry) => typeof entry !== "string")
|
|
367
|
+
) {
|
|
368
|
+
throw new Error(`the "${name}" export is not a record of strings`)
|
|
369
|
+
}
|
|
370
|
+
return value as Readonly<Record<string, string>>
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const isStringArray = (v: unknown): v is readonly string[] =>
|
|
374
|
+
Array.isArray(v) && v.every((entry) => typeof entry === "string")
|
|
375
|
+
|
|
376
|
+
const checkSkillsRecord = (value: unknown): Readonly<Record<string, readonly string[]>> => {
|
|
377
|
+
if (
|
|
378
|
+
typeof value !== "object" ||
|
|
379
|
+
value === null ||
|
|
380
|
+
Object.values(value).some((entry) => !isStringArray(entry))
|
|
381
|
+
) {
|
|
382
|
+
throw new Error(`the "skills" export is not a record of skill-name arrays`)
|
|
383
|
+
}
|
|
384
|
+
return value as Readonly<Record<string, readonly string[]>>
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Read a module's optional `skills` export — a scope full name -> skill list record, or a function of the vars returning one. */
|
|
388
|
+
const skillsExport = (exports: Record<string, unknown>): WorkflowDefinition["skills"] => {
|
|
389
|
+
const value = exports["skills"] ?? {}
|
|
390
|
+
if (typeof value === "function") {
|
|
391
|
+
return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars))
|
|
392
|
+
}
|
|
393
|
+
const record = checkSkillsRecord(value)
|
|
394
|
+
return () => record
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Read a module's optional `access` export — a scope full name -> `{ read?, write? }` record, or a function of the vars returning one. */
|
|
398
|
+
const accessExport = (exports: Record<string, unknown>): WorkflowDefinition["access"] => {
|
|
399
|
+
const value = exports["access"] ?? {}
|
|
400
|
+
if (typeof value === "function") {
|
|
401
|
+
return (vars) => (value as (v: typeof vars) => never)(vars)
|
|
402
|
+
}
|
|
403
|
+
return () => value as never
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const optionalFunction = <T>(exports: Record<string, unknown>, name: string): T | undefined => {
|
|
407
|
+
const value = exports[name]
|
|
408
|
+
if (value === undefined) return undefined
|
|
409
|
+
if (typeof value !== "function") throw new Error(`the "${name}" export is not a function`)
|
|
410
|
+
return value as T
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const RESERVED_EXPORTS: ReadonlySet<string> = new Set([
|
|
414
|
+
"default",
|
|
415
|
+
"summary",
|
|
416
|
+
"base",
|
|
417
|
+
"defaults",
|
|
418
|
+
"envDefaults",
|
|
419
|
+
"steering",
|
|
420
|
+
"skills",
|
|
421
|
+
"access",
|
|
422
|
+
"doors",
|
|
423
|
+
"config",
|
|
424
|
+
])
|
|
425
|
+
|
|
426
|
+
/** The rules every declared setting meets, whether a file's export or a factory's build. */
|
|
427
|
+
const checkSettings = (
|
|
428
|
+
defaults: Readonly<Record<string, unknown>>,
|
|
429
|
+
envDefaults: Readonly<Record<string, unknown>>,
|
|
430
|
+
declares: (kind: "defaults" | "envDefaults") => string,
|
|
431
|
+
): void => {
|
|
432
|
+
for (const [kind, record] of [
|
|
433
|
+
["defaults", defaults],
|
|
434
|
+
["envDefaults", envDefaults],
|
|
435
|
+
] as const) {
|
|
436
|
+
for (const [name, setting] of Object.entries(record)) {
|
|
437
|
+
if (!isSettingName(name)) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`${declares(kind)} ${JSON.stringify(name)}, not a valid setting name — ${SETTING_NAME_RULE}`,
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
if (typeof setting !== "string") {
|
|
443
|
+
throw new Error(`${declares(kind)} ${JSON.stringify(name)}, whose value is not a string`)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const both = Object.keys(defaults).filter((name) => Object.hasOwn(envDefaults, name))
|
|
448
|
+
if (both.length > 0) {
|
|
449
|
+
throw new Error(
|
|
450
|
+
`${both.map((name) => `"${name}"`).join(", ")} declared in both "defaults" and "envDefaults" — a setting is a process setting or an environment setting, not both`,
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** A module's per-file exports: `defaults`, `envDefaults`, `steering`, `skills`, `access`, `summary`, `base`. */
|
|
456
|
+
const fileFrom = (
|
|
457
|
+
exports: Record<string, unknown>,
|
|
458
|
+
origin: string,
|
|
459
|
+
dependencyFiles: readonly string[],
|
|
460
|
+
): LoadedFile => {
|
|
461
|
+
const defaults = stringRecord(exports, "defaults")
|
|
462
|
+
const envDefaults = stringRecord(exports, "envDefaults")
|
|
463
|
+
checkSettings(defaults, envDefaults, (kind) => `the "${kind}" export declares`)
|
|
464
|
+
return {
|
|
465
|
+
defaults,
|
|
466
|
+
envDefaults,
|
|
467
|
+
steering: stringRecord(exports, "steering"),
|
|
468
|
+
skills: skillsExport(exports),
|
|
469
|
+
access: accessExport(exports),
|
|
470
|
+
summary: optionalFunction(exports, "summary"),
|
|
471
|
+
base: optionalFunction(exports, "base"),
|
|
472
|
+
origin,
|
|
473
|
+
dependencyFiles,
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/
|
|
478
|
+
|
|
479
|
+
/** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
|
|
480
|
+
const argsProblem = (args: unknown): string | undefined => {
|
|
481
|
+
if (args === undefined) return undefined
|
|
482
|
+
if (!Array.isArray(args)) return `has "args" that is not an array`
|
|
483
|
+
const seen = new Set<string>()
|
|
484
|
+
let optional = false
|
|
485
|
+
for (const arg of args as readonly { name?: unknown; optional?: unknown }[]) {
|
|
486
|
+
if (typeof arg?.name !== "string") return `has an arg without a "name"`
|
|
487
|
+
if (seen.has(arg.name)) return `declares duplicate arg "${arg.name}"`
|
|
488
|
+
seen.add(arg.name)
|
|
489
|
+
if (arg.optional === true) optional = true
|
|
490
|
+
else if (optional) return `declares required arg "${arg.name}" after an optional one`
|
|
491
|
+
}
|
|
492
|
+
return undefined
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Why one door is malformed, or `undefined` when it is well-formed. */
|
|
496
|
+
const doorProblem = (name: string, door: unknown): string | undefined => {
|
|
497
|
+
if (!DOOR_NAME_RULE.test(name)) {
|
|
498
|
+
return `is not a valid door name — it must match ${DOOR_NAME_RULE}`
|
|
499
|
+
}
|
|
500
|
+
const { workflow, args, vars } = (door ?? {}) as Partial<Door>
|
|
501
|
+
if (typeof workflow !== "string") return `must be an object with a "workflow" name`
|
|
502
|
+
const varsOk = vars === undefined || typeof vars === "function"
|
|
503
|
+
return argsProblem(args) ?? (varsOk ? undefined : `has "vars" that is not a function`)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** A module's `doors` export, shape-checked; the workflow names are checked later, against the catalogue. */
|
|
507
|
+
const doorsFrom = (exports: Record<string, unknown>): ReadonlyMap<string, Door> => {
|
|
508
|
+
const value = exports["doors"]
|
|
509
|
+
if (value === undefined) return new Map()
|
|
510
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
511
|
+
throw new Error(`the "doors" export is not a record of doors`)
|
|
512
|
+
}
|
|
513
|
+
for (const [name, door] of Object.entries(value)) {
|
|
514
|
+
const problem = doorProblem(name, door)
|
|
515
|
+
if (problem !== undefined) throw new Error(`door "${name}" ${problem}`)
|
|
516
|
+
}
|
|
517
|
+
return new Map(Object.entries(value) as [string, Door][])
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Each exported workflow's own doors, onto the name it is exported under. The
|
|
522
|
+
* `doors` export adds to them and settles a name two workflows both declare.
|
|
523
|
+
*/
|
|
524
|
+
const derivedDoors = (
|
|
525
|
+
catalogue: ReadonlyMap<string, LoadedWorkflow>,
|
|
526
|
+
declared: ReadonlyMap<string, Door>,
|
|
527
|
+
): ReadonlyMap<string, Door> => {
|
|
528
|
+
const doors = new Map<string, Door>()
|
|
529
|
+
for (const [workflow, loaded] of catalogue) {
|
|
530
|
+
for (const [name, spec] of Object.entries(loaded.doors ?? {})) {
|
|
531
|
+
const door: Door = { ...spec, workflow }
|
|
532
|
+
const problem = doorProblem(name, door)
|
|
533
|
+
if (problem !== undefined)
|
|
534
|
+
throw new Error(`workflow "${workflow}" builds door "${name}", which ${problem}`)
|
|
535
|
+
const other = doors.get(name)
|
|
536
|
+
if (other !== undefined && !declared.has(name)) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`door "${name}" is built by workflows "${other.workflow}" and "${workflow}" — declare it in the "doors" export to choose one (a declared door replaces the derived one, so restate its args and vars)`,
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
doors.set(name, door)
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return new Map([...doors, ...declared])
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Sort the doors and check each names a startable workflow. */
|
|
548
|
+
const checkDoors = (
|
|
549
|
+
declared: ReadonlyMap<string, Door>,
|
|
550
|
+
catalogue: ReadonlyMap<string, LoadedWorkflow>,
|
|
551
|
+
): ReadonlyMap<string, Door> => {
|
|
552
|
+
const doors = new Map([...declared].sort(([a], [b]) => a.localeCompare(b)))
|
|
553
|
+
for (const [name, door] of doors) {
|
|
554
|
+
if (!catalogue.has(door.workflow)) {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`door "${name}" names workflow "${door.workflow}", which is not startable — known: ${[...catalogue.keys()].sort().join(", ")}`,
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return doors
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const namespaceOf = (exported: unknown): Record<string, unknown> =>
|
|
564
|
+
(typeof exported === "object" && exported !== null ? exported : {}) as Record<string, unknown>
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Evaluate `gtd.config.ts`. Every exported function or factory workflow but
|
|
568
|
+
* the reserved names is a workflow named by its export. `cache` is jiti's OWN
|
|
569
|
+
* per-call module cache (unrelated to the instance's `moduleCache: false`) —
|
|
570
|
+
* passing an empty object in has the side effect of collecting every real file
|
|
571
|
+
* jiti resolved evaluating this module, module AND every relative import it
|
|
572
|
+
* pulled in, transitively.
|
|
573
|
+
*/
|
|
574
|
+
const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded => {
|
|
575
|
+
const cache: Record<string, unknown> = {}
|
|
576
|
+
const exports = namespaceOf(
|
|
577
|
+
jiti(shipped).evalModule(module.source, { filename: module.filepath, cache }),
|
|
578
|
+
)
|
|
579
|
+
const file = fileFrom(exports, module.filepath, Object.keys(cache))
|
|
580
|
+
const exported = Object.entries(exports).filter(
|
|
581
|
+
([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
|
|
582
|
+
)
|
|
583
|
+
const value = defaultExport(exports)
|
|
584
|
+
// A default that is also a named export goes by that name.
|
|
585
|
+
const defaultName = exported.find(([, other]) => other === value)?.[0] ?? "default"
|
|
586
|
+
|
|
587
|
+
// Built once per exported value, so a default that is also a named export
|
|
588
|
+
// stays the same workflow.
|
|
589
|
+
const built = new Map<unknown, LoadedWorkflow | undefined>()
|
|
590
|
+
const rejections: Rejection[] = []
|
|
591
|
+
for (const [name, entry] of [...exported, [defaultName, value] as const]) {
|
|
592
|
+
if (!built.has(entry)) built.set(entry, buildWorkflow(name, entry, file, rejections))
|
|
593
|
+
}
|
|
594
|
+
const catalogue = new Map(
|
|
595
|
+
exported.flatMap(([name, entry]) => {
|
|
596
|
+
const workflow = built.get(entry)
|
|
597
|
+
return workflow === undefined ? [] : [[name, workflow] as const]
|
|
598
|
+
}),
|
|
599
|
+
)
|
|
600
|
+
const defaultWorkflow = built.get(value)
|
|
601
|
+
return {
|
|
602
|
+
file,
|
|
603
|
+
catalogue,
|
|
604
|
+
doors:
|
|
605
|
+
rejections.length > 0
|
|
606
|
+
? new Map()
|
|
607
|
+
: checkDoors(derivedDoors(catalogue, doorsFrom(exports)), catalogue),
|
|
608
|
+
default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
|
|
609
|
+
config: compileConfig(exports["config"], module.filepath),
|
|
610
|
+
diagnostics: nameRejections(rejections),
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const isWorkflow = (value: unknown): boolean =>
|
|
615
|
+
typeof value === "function" || flows.isFactoryWorkflow(value)
|
|
616
|
+
|
|
617
|
+
const defaultExport = (exports: Record<string, unknown>): unknown => {
|
|
618
|
+
const value = exports.default
|
|
619
|
+
if (value === undefined) {
|
|
620
|
+
throw new Error(
|
|
621
|
+
"no default export — export the workflow an ordinary start runs (`gtd install` scaffolds one)",
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
if (!isWorkflow(value)) throw new Error("the default export is not a function")
|
|
625
|
+
return value
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** One exported workflow: a plain flow as-is, a factory validated and built. */
|
|
629
|
+
const buildWorkflow = (
|
|
630
|
+
name: string,
|
|
631
|
+
value: unknown,
|
|
632
|
+
file: LoadedFile,
|
|
633
|
+
rejections: Rejection[],
|
|
634
|
+
): LoadedWorkflow | undefined => {
|
|
635
|
+
const result = flows.configure(value)
|
|
636
|
+
if (result === undefined)
|
|
637
|
+
return { flow: value as Flow, file, defaults: file.defaults, envDefaults: file.envDefaults }
|
|
638
|
+
if (!result.ok) {
|
|
639
|
+
for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, file))
|
|
640
|
+
return undefined
|
|
641
|
+
}
|
|
642
|
+
return {
|
|
643
|
+
flow: result.build.flow,
|
|
644
|
+
file,
|
|
645
|
+
...builtSettings(name, file, result.build),
|
|
646
|
+
doors: result.build.doors,
|
|
647
|
+
base: result.build.base,
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** A factory's settings over its file's, held to the rules a file's own exports are. */
|
|
652
|
+
const builtSettings = (name: string, file: LoadedFile, build: flows.WorkflowBuild): Settings => {
|
|
653
|
+
checkSettings(
|
|
654
|
+
build.defaults ?? {},
|
|
655
|
+
build.envDefaults ?? {},
|
|
656
|
+
(kind) => `workflow "${name}" builds ${kind}`,
|
|
657
|
+
)
|
|
658
|
+
const defaults = { ...file.defaults, ...build.defaults }
|
|
659
|
+
const envDefaults = { ...file.envDefaults, ...build.envDefaults }
|
|
660
|
+
checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" builds ${kind}`)
|
|
661
|
+
return { defaults, envDefaults }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** A rejected factory option, reported at `gtd.config.ts`. */
|
|
665
|
+
const optionDiagnostic = (
|
|
666
|
+
workflow: string,
|
|
667
|
+
issue: flows.OptionIssue,
|
|
668
|
+
file: LoadedFile,
|
|
669
|
+
): Rejection => ({
|
|
670
|
+
workflow,
|
|
671
|
+
severity: "error",
|
|
672
|
+
path: issue.path,
|
|
673
|
+
message: `${issue.path.length > 0 ? `option "${issue.path.join(".")}"` : "options"}: ${issue.message}`,
|
|
674
|
+
origin: file.origin,
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
/** One rejected option, before the workflows rejecting it alike are named together. */
|
|
678
|
+
interface Rejection extends Diagnostic {
|
|
679
|
+
readonly workflow: string
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const nameRejections = (rejections: readonly Rejection[]): Diagnostic[] => {
|
|
683
|
+
const grouped = new Map<string, { readonly first: Rejection; readonly workflows: string[] }>()
|
|
684
|
+
for (const rejection of rejections) {
|
|
685
|
+
const key = JSON.stringify([rejection.origin, rejection.path, rejection.message])
|
|
686
|
+
const group = grouped.get(key)
|
|
687
|
+
if (group === undefined) grouped.set(key, { first: rejection, workflows: [rejection.workflow] })
|
|
688
|
+
else group.workflows.push(rejection.workflow)
|
|
689
|
+
}
|
|
690
|
+
return [...grouped.values()].map(({ first: { workflow: _, ...diagnostic }, workflows }) => ({
|
|
691
|
+
...diagnostic,
|
|
692
|
+
message: `${workflows.length > 1 ? "workflows" : "workflow"} ${workflows.map((w) => `"${w}"`).join(", ")} ${diagnostic.message}`,
|
|
693
|
+
}))
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The repository's HEAD, read lazily.
|
|
697
|
+
const headTree = (workspace: WorkspaceOps): TreeView => {
|
|
698
|
+
let entries: ReadonlyMap<string, string> | undefined
|
|
699
|
+
const list = () => (entries ??= workspace.treeSync("HEAD"))
|
|
700
|
+
return {
|
|
701
|
+
paths: () => [...list().keys()].sort(),
|
|
702
|
+
read: (path) => (list().has(path) ? workspace.readCommittedSync(path, "HEAD") : undefined),
|
|
703
|
+
id: (path) => list().get(path),
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/** An empty tree that records whether the flow looked at it. */
|
|
708
|
+
const watchedEmptyTree = (): { readonly tree: TreeView; readonly read: () => boolean } => {
|
|
709
|
+
let read = false
|
|
710
|
+
return {
|
|
711
|
+
tree: {
|
|
712
|
+
paths: () => ((read = true), []),
|
|
713
|
+
read: () => ((read = true), undefined),
|
|
714
|
+
},
|
|
715
|
+
read: () => read,
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** An empty settings record that logs every read: a named `get`/`has`, or an enumeration. */
|
|
720
|
+
const watchedEmptySettings = (): {
|
|
721
|
+
readonly settings: Readonly<Record<string, string>>
|
|
722
|
+
readonly names: ReadonlySet<string>
|
|
723
|
+
readonly enumerated: () => boolean
|
|
724
|
+
} => {
|
|
725
|
+
const names = new Set<string>()
|
|
726
|
+
let enumerated = false
|
|
727
|
+
const settings = new Proxy<Record<string, string>>(
|
|
728
|
+
{},
|
|
729
|
+
{
|
|
730
|
+
get: (_target, key) => {
|
|
731
|
+
if (typeof key === "string") names.add(key)
|
|
732
|
+
return undefined
|
|
733
|
+
},
|
|
734
|
+
has: (_target, key) => {
|
|
735
|
+
if (typeof key === "string") names.add(key)
|
|
736
|
+
return false
|
|
737
|
+
},
|
|
738
|
+
ownKeys: () => {
|
|
739
|
+
enumerated = true
|
|
740
|
+
return []
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
)
|
|
744
|
+
return { settings, names, enumerated: () => enumerated }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const settingReads = (kind: string, watch: ReturnType<typeof watchedEmptySettings>): string[] => {
|
|
748
|
+
const reads: string[] = []
|
|
749
|
+
if (watch.names.size > 0) {
|
|
750
|
+
const sorted = [...watch.names].sort().map((name) => JSON.stringify(name))
|
|
751
|
+
reads.push(`the ${kind} setting${sorted.length > 1 ? "s" : ""} ${sorted.join(", ")}`)
|
|
752
|
+
}
|
|
753
|
+
if (watch.enumerated()) reads.push(`every ${kind} setting`)
|
|
754
|
+
return reads
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const replayToFirstStep = (
|
|
758
|
+
loaded: NonNullable<Loaded["default"]>,
|
|
759
|
+
vars: Readonly<Record<string, string>>,
|
|
760
|
+
env: Readonly<Record<string, string>>,
|
|
761
|
+
tree: TreeView,
|
|
762
|
+
): Effect.Effect<string, GtdError> =>
|
|
763
|
+
Effect.flatMap(
|
|
764
|
+
Effect.promise(() =>
|
|
765
|
+
replay({
|
|
766
|
+
flow: loaded.flow,
|
|
767
|
+
episode: { base: { hash: "", tree }, commits: [] },
|
|
768
|
+
vars,
|
|
769
|
+
env,
|
|
770
|
+
start: "",
|
|
771
|
+
budgetBytes: Number.MAX_SAFE_INTEGER,
|
|
772
|
+
}),
|
|
773
|
+
),
|
|
774
|
+
(outcome) => {
|
|
775
|
+
if (outcome.kind === "rest") return Effect.succeed(outcome.rest.name)
|
|
776
|
+
const problem =
|
|
777
|
+
outcome.kind === "failed" || outcome.kind === "refused"
|
|
778
|
+
? outcome.message.replace(/^gtd: /, "")
|
|
779
|
+
: "the default entry must begin at a step — that step is where a finished process waits"
|
|
780
|
+
return Effect.fail(new GtdError(`gtd config:\n - ${loaded.file.origin}: ${problem}`))
|
|
781
|
+
},
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The default entry's first step — where a finished process waits — found the
|
|
786
|
+
* only way a flow can be read: by replaying it over an empty history. Episode
|
|
787
|
+
* boundaries are that step's commits, so it must depend on neither a setting
|
|
788
|
+
* (edited mid-process, it would move the boundary before pinned values are
|
|
789
|
+
* read) nor the tree: a flow that reads the repository before reaching it is
|
|
790
|
+
* replayed again over HEAD, and must reach the same step there.
|
|
791
|
+
*/
|
|
792
|
+
const firstStep = (
|
|
793
|
+
loaded: NonNullable<Loaded["default"]>,
|
|
794
|
+
head: TreeView,
|
|
795
|
+
): Effect.Effect<string, GtdError> =>
|
|
796
|
+
Effect.gen(function* () {
|
|
797
|
+
const empty = watchedEmptyTree()
|
|
798
|
+
const vars = watchedEmptySettings()
|
|
799
|
+
const env = watchedEmptySettings()
|
|
800
|
+
const outcome = yield* Effect.either(
|
|
801
|
+
replayToFirstStep(loaded, vars.settings, env.settings, empty.tree),
|
|
802
|
+
)
|
|
803
|
+
const reads = [...settingReads("process", vars), ...settingReads("environment", env)]
|
|
804
|
+
if (reads.length > 0) {
|
|
805
|
+
return yield* Effect.fail(
|
|
806
|
+
new GtdError(
|
|
807
|
+
`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`,
|
|
808
|
+
),
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
if (outcome._tag === "Left") return yield* Effect.fail(outcome.left)
|
|
812
|
+
const name = outcome.right
|
|
813
|
+
if (!empty.read()) return name
|
|
814
|
+
const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head)
|
|
815
|
+
if (atHead === name) return name
|
|
816
|
+
return yield* Effect.fail(
|
|
817
|
+
new GtdError(
|
|
818
|
+
`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`,
|
|
819
|
+
),
|
|
820
|
+
)
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
interface ConfigServiceOperations {
|
|
824
|
+
readonly load: Effect.Effect<
|
|
825
|
+
ConfigOperations,
|
|
826
|
+
Error,
|
|
827
|
+
Narrator | Workspace | Host | ConfigDiscovery
|
|
828
|
+
>
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export class ConfigService extends Context.Tag("ConfigService")<
|
|
832
|
+
ConfigService,
|
|
833
|
+
ConfigServiceOperations
|
|
834
|
+
>() {
|
|
835
|
+
static Live = Layer.succeed(ConfigService, { load })
|
|
836
|
+
}
|