@pmelab/gtd 59.0.1 → 60.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 +48 -32
- 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-BhTey1Do.mjs +30517 -0
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +11285 -19864
- package/dist/gtd.core.mjs +593 -0
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +246 -37
- 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 +7 -7
- package/skills/authoring/SKILL.md +66 -60
- package/src/cli/Cli.test.ts +185 -184
- package/src/cli/Cli.ts +219 -224
- 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 +34 -3
- package/src/cli/Program.test.ts +78 -84
- package/src/cli/Program.ts +192 -128
- package/src/cli/ToolCommands.ts +2 -2
- package/src/edge/Edge.test.ts +72 -62
- package/src/edge/Edge.ts +6 -6
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +189 -55
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/install/Version.ts +9 -3
- package/src/install/index.ts +1 -1
- package/src/platform/CommandRunner.ts +13 -11
- package/src/platform/Commentary.ts +2 -2
- package/src/platform/Git.test.ts +4 -5
- package/src/platform/Git.ts +42 -38
- package/src/platform/Host.ts +1 -1
- package/src/platform/NodeServices.ts +11 -0
- package/src/platform/Workspace.test.ts +4 -5
- package/src/platform/Workspace.ts +3 -3
- package/src/platform/WorktreeState.test.ts +2 -3
- package/src/platform/WorktreeState.ts +1 -1
- package/src/platform/index.ts +2 -1
- 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 +12 -12
- package/src/testkit/TestRepo.test.ts +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +2 -6
- package/src/twins/EmittedScripts.test.ts +5 -3
- package/src/twins/GitTiers.ts +6 -7
- package/src/twins/InMemRepo.ts +1 -0
- package/src/twins/Layers.test.ts +1 -1
- package/src/twins/Layers.ts +10 -3
- package/src/twins/cliIo.ts +9 -4
- 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 +27 -10
- package/src/workflow/discovery.ts +5 -6
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +316 -119
- package/src/workflow/load.ts +478 -305
- package/src/workflow/pin.test.ts +159 -0
- package/src/workflow/pin.ts +72 -0
- package/src/workflow/vars.ts +3 -3
- 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/dist/chunks/testkit-07Z_ENch.mjs +0 -32175
package/src/workflow/load.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { createRequire } from "node:module"
|
|
3
|
-
import { dirname, join } from "node:path"
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs"
|
|
2
|
+
import { createRequire, isBuiltin } from "node:module"
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path"
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
5
|
+
import { stripVTControlCharacters } from "node:util"
|
|
6
|
+
import { runInThisContext } from "node:vm"
|
|
7
|
+
import type { Plugin as RolldownPlugin } from "rolldown"
|
|
5
8
|
import { Context, Effect, Layer } from "effect"
|
|
6
9
|
import { GtdError, Narrator } from "../platform/index.js"
|
|
7
|
-
import { replay, type TreeView } from "../replay/index.js"
|
|
10
|
+
import { accessShapeFault, replay, type TreeView } from "../replay/index.js"
|
|
11
|
+
// The published facade: what a config's `@pmelab/gtd/flows` import is served.
|
|
8
12
|
import * as flows from "../flows/index.js"
|
|
13
|
+
import { configure, isFactoryWorkflow, markedOptions, type OptionIssue } from "../flows/internal.js"
|
|
9
14
|
import type { StateMode, WorkflowDefinition } from "./Workflow.js"
|
|
10
|
-
import type { Door, Flow } from "../flows/index.js"
|
|
15
|
+
import type { Door, Flow, ScopeAccess } from "../flows/index.js"
|
|
11
16
|
import { Host, Workspace, type WorkspaceOps } from "../platform/index.js"
|
|
12
17
|
import { compileConfig, type CompiledConfig } from "./compile.js"
|
|
13
18
|
import { SETTING_NAME_RULE, isSettingName } from "./vars.js"
|
|
14
19
|
import { ConfigDiscovery, WORKFLOW_MODULE, type WorkflowModule } from "./discovery.js"
|
|
15
20
|
import { configFingerprint, type ConfigFingerprint } from "./fingerprint.js"
|
|
21
|
+
import { PIN_KEY } from "../install/index.js"
|
|
16
22
|
import {
|
|
17
23
|
dedupeDiagnostics,
|
|
18
24
|
formatDiagnostic,
|
|
@@ -33,10 +39,10 @@ export interface ConfigOperations {
|
|
|
33
39
|
readonly workflow: WorkflowDefinition
|
|
34
40
|
/** The default, resolved like a named one; its name is what status shows: the named export it also is, else `"default"`. */
|
|
35
41
|
readonly defaultWorkflow: ResolvedWorkflow
|
|
36
|
-
/** Every name `gtd
|
|
42
|
+
/** Every workflow name `gtd start` accepts, sorted — never `"default"`. */
|
|
37
43
|
readonly workflowNames: readonly string[]
|
|
38
44
|
readonly workflowNamed: (name: string) => ResolvedWorkflow | undefined
|
|
39
|
-
/** Every door `gtd
|
|
45
|
+
/** Every door `gtd start` accepts. A door's `workflow` resolves through `workflowNamed`. */
|
|
40
46
|
readonly doors: ReadonlyMap<string, Door>
|
|
41
47
|
/** `config.ui` (absent when unconfigured) — `gtd ui` and its CLI flags read it. */
|
|
42
48
|
readonly ui?: flows.UiSettings
|
|
@@ -87,8 +93,8 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
|
|
|
87
93
|
"gtd: config:",
|
|
88
94
|
...found.map((file) => ` - ${file}: no longer read — delete it`),
|
|
89
95
|
" 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
|
|
96
|
+
" vars → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { defaults })`",
|
|
97
|
+
" env → an option of the workflow factory in gtd.config.ts, or `workflow(flow, { envDefaults })`",
|
|
92
98
|
" judge → ~/.config/gtd/judge.json, or GTD_JUDGE_PROVIDER / GTD_JUDGE_MODEL",
|
|
93
99
|
" modes, ui, skills, access → the `config` export of gtd.config.ts (`defineConfig`)",
|
|
94
100
|
" $schema → nothing; drop it",
|
|
@@ -98,9 +104,25 @@ export const refuseLegacyRc: Effect.Effect<void, GtdError, Host | ConfigDiscover
|
|
|
98
104
|
)
|
|
99
105
|
})
|
|
100
106
|
|
|
107
|
+
const configModule: Effect.Effect<WorkflowModule, Error, Host | ConfigDiscovery> = Effect.gen(
|
|
108
|
+
function* () {
|
|
109
|
+
const host = yield* Host
|
|
110
|
+
const module = yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)
|
|
111
|
+
if (module !== undefined) return module
|
|
112
|
+
const missing: Diagnostic = {
|
|
113
|
+
severity: "error",
|
|
114
|
+
path: [],
|
|
115
|
+
message: `no workflows configured: no ${WORKFLOW_MODULE} here or above — gtd ships no workflow; \`gtd guide\` says where to get one`,
|
|
116
|
+
origin: host.root,
|
|
117
|
+
}
|
|
118
|
+
return yield* Effect.fail(configError([missing]))
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
101
122
|
/**
|
|
102
|
-
* Find and evaluate `gtd.config.ts
|
|
103
|
-
*
|
|
123
|
+
* Find and evaluate `gtd.config.ts` — or, inside a pin, the workflow bundled
|
|
124
|
+
* into it — then check what it exports. Every finding is collected into ONE
|
|
125
|
+
* sorted report rather than failing on the first.
|
|
104
126
|
*/
|
|
105
127
|
export const load: Effect.Effect<
|
|
106
128
|
ConfigOperations,
|
|
@@ -108,25 +130,30 @@ export const load: Effect.Effect<
|
|
|
108
130
|
Narrator | Workspace | Host | ConfigDiscovery
|
|
109
131
|
> = Effect.gen(function* () {
|
|
110
132
|
const host = yield* Host
|
|
111
|
-
const discovery = yield* ConfigDiscovery
|
|
112
133
|
yield* refuseLegacyRc
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const loaded = yield* Effect.
|
|
129
|
-
try: () =>
|
|
134
|
+
const pinned = pinnedWorkflow()
|
|
135
|
+
const module: WorkflowModule =
|
|
136
|
+
pinned === undefined
|
|
137
|
+
? yield* configModule
|
|
138
|
+
: { filepath: resolvePath(host.root, pinned.origin), source: "" }
|
|
139
|
+
yield* (yield* Narrator).narrate(
|
|
140
|
+
`config: ${pinned === undefined ? "module" : "pinned module"} ${module.filepath}`,
|
|
141
|
+
)
|
|
142
|
+
const shipped =
|
|
143
|
+
pinned === undefined
|
|
144
|
+
? yield* Effect.tryPromise({
|
|
145
|
+
try: shippedModules,
|
|
146
|
+
catch: (e) => new GtdError(`gtd: ${e instanceof Error ? e.message : String(e)}`),
|
|
147
|
+
})
|
|
148
|
+
: undefined
|
|
149
|
+
const loaded = yield* Effect.tryPromise({
|
|
150
|
+
try: async () =>
|
|
151
|
+
loadWorkflows(
|
|
152
|
+
module.filepath,
|
|
153
|
+
shipped === undefined
|
|
154
|
+
? runPinned(pinned!, module.filepath, host.root)
|
|
155
|
+
: await evaluate(module, shipped),
|
|
156
|
+
),
|
|
130
157
|
catch: (e) =>
|
|
131
158
|
new GtdError(
|
|
132
159
|
`gtd: config:\n - ${module.filepath}: ${e instanceof Error ? e.message : String(e)}`,
|
|
@@ -142,17 +169,15 @@ export const load: Effect.Effect<
|
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
const initial = yield* firstStep(loaded.default, headTree(yield* Workspace))
|
|
145
|
-
const steering = loaded.file.steering as Record<string, StateMode>
|
|
146
172
|
const define = (entry: LoadedWorkflow): WorkflowDefinition => ({
|
|
147
173
|
flow: entry.flow,
|
|
148
|
-
summary: entry.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
steering,
|
|
174
|
+
summary: entry.summary,
|
|
175
|
+
base: (w, v) => entry.base?.(w, v),
|
|
176
|
+
steering: entry.steering as Record<string, StateMode>,
|
|
152
177
|
modes: compiled.modes,
|
|
153
|
-
skills: entry.
|
|
178
|
+
skills: entry.skills,
|
|
154
179
|
configuredSkills: compiled.skills,
|
|
155
|
-
access: entry.
|
|
180
|
+
access: entry.access,
|
|
156
181
|
configuredAccess: compiled.access,
|
|
157
182
|
skillsOrigin: entry.file.origin,
|
|
158
183
|
initial,
|
|
@@ -176,11 +201,9 @@ export const load: Effect.Effect<
|
|
|
176
201
|
},
|
|
177
202
|
...(compiled.ui !== undefined ? { ui: compiled.ui } : {}),
|
|
178
203
|
warnings: diagnostics.filter((d) => d.severity === "warning"),
|
|
179
|
-
fingerprint:
|
|
180
|
-
|
|
181
|
-
host.root,
|
|
182
|
-
module.read ?? readFromDisk,
|
|
183
|
-
),
|
|
204
|
+
fingerprint:
|
|
205
|
+
pinned?.fingerprint ??
|
|
206
|
+
configFingerprint(loaded.file.dependencyFiles, host.root, module.read ?? readFromDisk),
|
|
184
207
|
}
|
|
185
208
|
})
|
|
186
209
|
|
|
@@ -197,25 +220,21 @@ interface Settings {
|
|
|
197
220
|
readonly envDefaults: Readonly<Record<string, string>>
|
|
198
221
|
}
|
|
199
222
|
|
|
200
|
-
interface LoadedFile
|
|
201
|
-
extends
|
|
202
|
-
Pick<WorkflowDefinition, "summary" | "base" | "steering" | "skills" | "access">,
|
|
203
|
-
Settings {
|
|
223
|
+
interface LoadedFile {
|
|
204
224
|
readonly origin: string
|
|
205
225
|
/**
|
|
206
|
-
* Every
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* `gtd.config.ts` re-exporting a sibling module is a documented pattern,
|
|
212
|
-
* and jiti resolves that import off disk same as any other require.
|
|
226
|
+
* Every file bundled evaluating this module — `origin` itself plus every
|
|
227
|
+
* local module it (transitively) imports, in NO particular order. A caller
|
|
228
|
+
* that needs to know when a workflow's DEFINITION might have changed (the
|
|
229
|
+
* LSP's steering-map memo) must watch this whole set, not just `origin`: a
|
|
230
|
+
* `gtd.config.ts` re-exporting a sibling module is a documented pattern.
|
|
213
231
|
*/
|
|
214
232
|
readonly dependencyFiles: readonly string[]
|
|
215
233
|
}
|
|
216
234
|
|
|
217
|
-
/** A startable workflow
|
|
218
|
-
interface LoadedWorkflow
|
|
235
|
+
/** A startable workflow, built from a factory or a `workflow()` call's options. */
|
|
236
|
+
interface LoadedWorkflow
|
|
237
|
+
extends Settings, Pick<WorkflowDefinition, "summary" | "steering" | "skills" | "access"> {
|
|
219
238
|
readonly flow: Flow
|
|
220
239
|
readonly file: LoadedFile
|
|
221
240
|
readonly doors?: flows.WorkflowDoors | undefined
|
|
@@ -233,76 +252,8 @@ interface Loaded {
|
|
|
233
252
|
readonly diagnostics: readonly Diagnostic[]
|
|
234
253
|
}
|
|
235
254
|
|
|
236
|
-
type JitiInstance = {
|
|
237
|
-
evalModule: (
|
|
238
|
-
source: string,
|
|
239
|
-
options: { filename: string; async?: false; cache?: Record<string, unknown> },
|
|
240
|
-
) => unknown
|
|
241
|
-
}
|
|
242
|
-
type JitiModule = {
|
|
243
|
-
createJiti: (id: string, options: Record<string, unknown>) => JitiInstance
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
type Transform = (options: { readonly source: string }) => { code: string; error?: unknown }
|
|
247
|
-
|
|
248
|
-
// Transpiling is most of a load's cost, and a load re-transpiles
|
|
249
|
-
// gtd.config.ts and every shipped module it imports. The same options (the
|
|
250
|
-
// source included) always transpile to the same code, so an in-process memo
|
|
251
|
-
// is safe; evaluation stays fresh on every load.
|
|
252
|
-
const TRANSFORM_MEMO_SIZE = 512
|
|
253
|
-
const memoized = (transform: Transform): Transform => {
|
|
254
|
-
const memo = new Map<string, ReturnType<Transform>>()
|
|
255
|
-
return (options) => {
|
|
256
|
-
const key = JSON.stringify(options)
|
|
257
|
-
const hit = memo.get(key)
|
|
258
|
-
if (hit !== undefined) return hit
|
|
259
|
-
const result = transform(options)
|
|
260
|
-
if (result.error !== undefined) return result
|
|
261
|
-
if (memo.size >= TRANSFORM_MEMO_SIZE) memo.delete(memo.keys().next().value!)
|
|
262
|
-
memo.set(key, result)
|
|
263
|
-
return result
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
let jitiModule: JitiModule | undefined
|
|
268
|
-
let transform: Transform | undefined
|
|
269
|
-
/** Relative specifiers a module imports, served from somewhere other than the disk. */
|
|
270
|
-
interface LocalModules {
|
|
271
|
-
/** The path a specifier names, or nothing when it names no module. */
|
|
272
|
-
readonly resolve: (specifier: string) => string | undefined
|
|
273
|
-
readonly load: (path: string) => unknown
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const jiti = (shipped: ShippedModules, local?: LocalModules): JitiInstance => {
|
|
277
|
-
const require = createRequire(import.meta.url)
|
|
278
|
-
jitiModule ??= require("jiti") as JitiModule
|
|
279
|
-
// jiti's own Babel transform, which its package exports don't name.
|
|
280
|
-
transform ??= memoized(
|
|
281
|
-
require(join(dirname(require.resolve("jiti/package.json")), "dist", "babel.cjs")) as Transform,
|
|
282
|
-
)
|
|
283
|
-
return jitiModule.createJiti(import.meta.url, {
|
|
284
|
-
// Consulted when a module is required, so a local module is evaluated
|
|
285
|
-
// only once an import actually runs.
|
|
286
|
-
virtualModules: new Proxy(shipped.modules, {
|
|
287
|
-
has: (modules, id) =>
|
|
288
|
-
id in modules || (typeof id === "string" && local?.resolve(id) !== undefined),
|
|
289
|
-
get: (modules, id) => {
|
|
290
|
-
const path = typeof id === "string" ? local?.resolve(id) : undefined
|
|
291
|
-
return path === undefined ? modules[id as string] : local!.load(path)
|
|
292
|
-
},
|
|
293
|
-
}),
|
|
294
|
-
alias: shipped.missing,
|
|
295
|
-
// No transpile cache on disk, and a fresh module every load — nothing a
|
|
296
|
-
// later command could read back instead of the source.
|
|
297
|
-
fsCache: false,
|
|
298
|
-
moduleCache: false,
|
|
299
|
-
transform,
|
|
300
|
-
// The whole module, not just its default: the named exports are read too.
|
|
301
|
-
interopDefault: false,
|
|
302
|
-
})
|
|
303
|
-
}
|
|
304
|
-
|
|
305
255
|
const PACKAGE = "@pmelab/gtd"
|
|
256
|
+
const FLOWS = `${PACKAGE}/flows`
|
|
306
257
|
|
|
307
258
|
interface PackageJson {
|
|
308
259
|
readonly name?: string
|
|
@@ -311,7 +262,7 @@ interface PackageJson {
|
|
|
311
262
|
|
|
312
263
|
// Only gtd's own package.json: whatever other one sits nearest (a vendored
|
|
313
264
|
// bundle inside a project) must never get its exports executed.
|
|
314
|
-
const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
|
|
265
|
+
export const ownPackage = (): { readonly root: string; readonly pkg: PackageJson } => {
|
|
315
266
|
const start = dirname(fileURLToPath(import.meta.url))
|
|
316
267
|
for (let dir = start; ; dir = dirname(dir)) {
|
|
317
268
|
const file = join(dir, "package.json")
|
|
@@ -342,7 +293,7 @@ const shippedModules = (): Promise<ShippedModules> =>
|
|
|
342
293
|
(shippedMemo ??= (async () => {
|
|
343
294
|
const fromSource = import.meta.url.endsWith(".ts")
|
|
344
295
|
const files = subpathFiles(fromSource ? "source" : "default")
|
|
345
|
-
const modules: Record<string, unknown> = { [
|
|
296
|
+
const modules: Record<string, unknown> = { [FLOWS]: flows }
|
|
346
297
|
const missing: Record<string, string> = {}
|
|
347
298
|
if (process.features.require_module && !fromSource) {
|
|
348
299
|
return { modules: requiredOnUse(modules, files), missing }
|
|
@@ -384,75 +335,105 @@ const requiredOnUse = (
|
|
|
384
335
|
})
|
|
385
336
|
}
|
|
386
337
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
): Readonly<Record<string, string>> => {
|
|
391
|
-
const value = exports[name] ?? {}
|
|
338
|
+
/** `what` names the value in an error: `workflow "x"'s "steering"`. */
|
|
339
|
+
const stringRecord = (value: unknown, what: string): Readonly<Record<string, string>> => {
|
|
340
|
+
const record = value ?? {}
|
|
392
341
|
if (
|
|
393
|
-
typeof
|
|
394
|
-
|
|
395
|
-
Object.values(
|
|
342
|
+
typeof record !== "object" ||
|
|
343
|
+
record === null ||
|
|
344
|
+
Object.values(record).some((entry) => typeof entry !== "string")
|
|
396
345
|
) {
|
|
397
|
-
throw new Error(
|
|
346
|
+
throw new Error(`${what} is not a record of strings`)
|
|
398
347
|
}
|
|
399
|
-
return
|
|
348
|
+
return record as Readonly<Record<string, string>>
|
|
400
349
|
}
|
|
401
350
|
|
|
402
351
|
const isStringArray = (v: unknown): v is readonly string[] =>
|
|
403
352
|
Array.isArray(v) && v.every((entry) => typeof entry === "string")
|
|
404
353
|
|
|
405
|
-
const checkSkillsRecord = (
|
|
354
|
+
const checkSkillsRecord = (
|
|
355
|
+
value: unknown,
|
|
356
|
+
what: string,
|
|
357
|
+
): Readonly<Record<string, readonly string[]>> => {
|
|
406
358
|
if (
|
|
407
359
|
typeof value !== "object" ||
|
|
408
360
|
value === null ||
|
|
409
361
|
Object.values(value).some((entry) => !isStringArray(entry))
|
|
410
362
|
) {
|
|
411
|
-
throw new Error(
|
|
363
|
+
throw new Error(`${what} is not a record of skill-name arrays`)
|
|
412
364
|
}
|
|
413
365
|
return value as Readonly<Record<string, readonly string[]>>
|
|
414
366
|
}
|
|
415
367
|
|
|
416
|
-
/**
|
|
417
|
-
const
|
|
418
|
-
const value = exports["skills"] ?? {}
|
|
368
|
+
/** A workflow's `skills` option — a scope full name -> skill list record, or a function of the vars returning one. */
|
|
369
|
+
const skillsFrom = (value: unknown, what: string): WorkflowDefinition["skills"] => {
|
|
419
370
|
if (typeof value === "function") {
|
|
420
|
-
return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars))
|
|
371
|
+
return (vars) => checkSkillsRecord((value as (v: typeof vars) => unknown)(vars), what)
|
|
421
372
|
}
|
|
422
|
-
const record = checkSkillsRecord(value)
|
|
373
|
+
const record = checkSkillsRecord(value ?? {}, what)
|
|
423
374
|
return () => record
|
|
424
375
|
}
|
|
425
376
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
377
|
+
const checkAccessRecord = (value: unknown, what: string): Readonly<Record<string, ScopeAccess>> => {
|
|
378
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`${what} is not a record of scope access (scope -> { read?, write? }) or a function returning one`,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
for (const [scope, access] of Object.entries(value)) {
|
|
384
|
+
const fault = accessShapeFault(access)
|
|
385
|
+
if (fault !== undefined) throw new Error(`${what}: scope "${scope}": ${fault}`)
|
|
386
|
+
}
|
|
387
|
+
return value as Readonly<Record<string, ScopeAccess>>
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* A workflow's `access` option — a scope full name -> `{ read?, write? }`
|
|
392
|
+
* record, checked now, or a function of the vars returning one, checked when
|
|
393
|
+
* the process's settings are known.
|
|
394
|
+
*/
|
|
395
|
+
const accessFrom = (value: unknown, what: string): WorkflowDefinition["access"] => {
|
|
429
396
|
if (typeof value === "function") {
|
|
430
397
|
return (vars) => (value as (v: typeof vars) => never)(vars)
|
|
431
398
|
}
|
|
432
|
-
|
|
399
|
+
const record = checkAccessRecord(value ?? {}, what)
|
|
400
|
+
return () => record
|
|
433
401
|
}
|
|
434
402
|
|
|
435
|
-
const optionalFunction = <T>(
|
|
436
|
-
const value = exports[name]
|
|
403
|
+
const optionalFunction = <T>(value: unknown, what: string): T | undefined => {
|
|
437
404
|
if (value === undefined) return undefined
|
|
438
|
-
if (typeof value !== "function") throw new Error(
|
|
405
|
+
if (typeof value !== "function") throw new Error(`${what} is not a function`)
|
|
439
406
|
return value as T
|
|
440
407
|
}
|
|
441
408
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
"base",
|
|
409
|
+
// What each file-level export of earlier versions became: an option of the
|
|
410
|
+
// workflow it configured. Exporting one is a load error, never silently ignored.
|
|
411
|
+
const RETIRED_EXPORTS: ReadonlySet<string> = new Set([
|
|
446
412
|
"defaults",
|
|
447
413
|
"envDefaults",
|
|
448
414
|
"steering",
|
|
449
415
|
"skills",
|
|
450
416
|
"access",
|
|
451
417
|
"doors",
|
|
452
|
-
"
|
|
418
|
+
"summary",
|
|
419
|
+
"base",
|
|
453
420
|
])
|
|
454
421
|
|
|
455
|
-
/**
|
|
422
|
+
/** Names that are never a workflow. */
|
|
423
|
+
const RESERVED_EXPORTS: ReadonlySet<string> = new Set(["default", "config", ...RETIRED_EXPORTS])
|
|
424
|
+
|
|
425
|
+
const retiredExports = (exports: Record<string, unknown>, origin: string): Diagnostic[] =>
|
|
426
|
+
Object.keys(exports)
|
|
427
|
+
.filter((name) => RETIRED_EXPORTS.has(name))
|
|
428
|
+
.sort()
|
|
429
|
+
.map((name) => ({
|
|
430
|
+
severity: "error",
|
|
431
|
+
path: [name],
|
|
432
|
+
origin,
|
|
433
|
+
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`,
|
|
434
|
+
}))
|
|
435
|
+
|
|
436
|
+
/** The rules every declared setting meets. */
|
|
456
437
|
const checkSettings = (
|
|
457
438
|
defaults: Readonly<Record<string, unknown>>,
|
|
458
439
|
envDefaults: Readonly<Record<string, unknown>>,
|
|
@@ -481,28 +462,6 @@ const checkSettings = (
|
|
|
481
462
|
}
|
|
482
463
|
}
|
|
483
464
|
|
|
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
465
|
const DOOR_NAME_RULE = /^[a-z][a-z0-9-]*$/
|
|
507
466
|
|
|
508
467
|
/** Why one door's `args` are malformed, or `undefined` when they are well-formed. */
|
|
@@ -532,27 +491,12 @@ const doorProblem = (name: string, door: unknown): string | undefined => {
|
|
|
532
491
|
return argsProblem(args) ?? (varsOk ? undefined : `has "vars" that is not a function`)
|
|
533
492
|
}
|
|
534
493
|
|
|
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
494
|
/**
|
|
550
|
-
* Each exported workflow's own doors, onto the name it is exported under.
|
|
551
|
-
*
|
|
495
|
+
* Each exported workflow's own doors, onto the name it is exported under. A
|
|
496
|
+
* door several workflows build and none is exported under is a clash: no door.
|
|
552
497
|
*/
|
|
553
498
|
const derivedDoors = (
|
|
554
499
|
catalogue: ReadonlyMap<string, LoadedWorkflow>,
|
|
555
|
-
declared: ReadonlyMap<string, Door>,
|
|
556
500
|
): {
|
|
557
501
|
readonly doors: ReadonlyMap<string, Door>
|
|
558
502
|
readonly clashes: ReadonlyMap<string, readonly string[]>
|
|
@@ -572,53 +516,30 @@ const derivedDoors = (
|
|
|
572
516
|
for (const [name, built] of builders) {
|
|
573
517
|
const owner = ownerOf(name, built)
|
|
574
518
|
if (owner !== undefined) doors.set(name, owner)
|
|
575
|
-
else
|
|
519
|
+
else
|
|
576
520
|
clashes.set(
|
|
577
521
|
name,
|
|
578
522
|
built.map((door) => door.workflow),
|
|
579
523
|
)
|
|
580
524
|
}
|
|
581
|
-
return { doors
|
|
525
|
+
return { doors, clashes }
|
|
582
526
|
}
|
|
583
527
|
|
|
584
528
|
/** The export named after the door owns it, else the sole builder; a clash of several builds no door. */
|
|
585
529
|
const ownerOf = (name: string, built: readonly Door[]): Door | undefined =>
|
|
586
530
|
built.find((door) => door.workflow === name) ?? (built.length === 1 ? built[0] : undefined)
|
|
587
531
|
|
|
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
532
|
const namespaceOf = (exported: unknown): Record<string, unknown> =>
|
|
605
533
|
(typeof exported === "object" && exported !== null ? exported : {}) as Record<string, unknown>
|
|
606
534
|
|
|
607
535
|
/**
|
|
608
|
-
* Evaluate `gtd.config.ts`. Every
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* passing an empty object in has the side effect of collecting every real file
|
|
612
|
-
* jiti resolved evaluating this module, module AND every relative import it
|
|
613
|
-
* pulled in, transitively.
|
|
536
|
+
* Evaluate `gtd.config.ts`. Every export but the reserved names that went
|
|
537
|
+
* through `workflow()` or is a factory workflow is a workflow named by its
|
|
538
|
+
* export.
|
|
614
539
|
*/
|
|
615
|
-
const loadWorkflows = (
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
? evaluateFromDisk(module, shipped)
|
|
619
|
-
: evaluateRead(module, module.read, shipped)
|
|
620
|
-
const exports = namespaceOf(evaluated)
|
|
621
|
-
const file = fileFrom(exports, module.filepath, files)
|
|
540
|
+
const loadWorkflows = (origin: string, evaluated: Evaluated): Loaded => {
|
|
541
|
+
const exports = namespaceOf(evaluated.exports)
|
|
542
|
+
const file: LoadedFile = { origin, dependencyFiles: evaluated.files }
|
|
622
543
|
const exported = Object.entries(exports).filter(
|
|
623
544
|
([name, value]) => !RESERVED_EXPORTS.has(name) && isWorkflow(value),
|
|
624
545
|
)
|
|
@@ -643,20 +564,23 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
643
564
|
const derived =
|
|
644
565
|
rejections.length > 0
|
|
645
566
|
? { doors: new Map<string, Door>(), clashes: new Map<string, readonly string[]>() }
|
|
646
|
-
: derivedDoors(catalogue
|
|
567
|
+
: derivedDoors(catalogue)
|
|
568
|
+
const doors = new Map([...derived.doors].sort(([a], [b]) => a.localeCompare(b)))
|
|
647
569
|
return {
|
|
648
570
|
file,
|
|
649
571
|
catalogue,
|
|
650
|
-
doors
|
|
572
|
+
doors,
|
|
651
573
|
default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
|
|
652
|
-
config: compileConfig(exports["config"],
|
|
574
|
+
config: compileConfig(exports["config"], origin),
|
|
653
575
|
diagnostics: [
|
|
576
|
+
...retiredExports(exports, file.origin),
|
|
654
577
|
...nameRejections(rejections),
|
|
578
|
+
...shadowingDoors(doors, catalogue, file.origin),
|
|
655
579
|
...[...derived.clashes].map(([name, builders]): Diagnostic => {
|
|
656
580
|
const names = [...builders].sort().map((b) => JSON.stringify(b))
|
|
657
581
|
return {
|
|
658
582
|
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 —
|
|
583
|
+
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
584
|
path: ["doors", name],
|
|
661
585
|
origin: file.origin,
|
|
662
586
|
}
|
|
@@ -665,17 +589,23 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
|
|
|
665
589
|
}
|
|
666
590
|
}
|
|
667
591
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
592
|
+
/** `gtd start <name>` takes the door, so a door named like a workflow it does not start hides that workflow. */
|
|
593
|
+
const shadowingDoors = (
|
|
594
|
+
doors: ReadonlyMap<string, Door>,
|
|
595
|
+
catalogue: ReadonlyMap<string, unknown>,
|
|
596
|
+
origin: string,
|
|
597
|
+
): Diagnostic[] =>
|
|
598
|
+
[...doors]
|
|
599
|
+
.filter(([name, door]) => catalogue.has(name) && door.workflow !== name)
|
|
600
|
+
.map(([name, door]) => ({
|
|
601
|
+
severity: "warning",
|
|
602
|
+
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`,
|
|
603
|
+
path: ["doors", name],
|
|
604
|
+
origin,
|
|
605
|
+
}))
|
|
676
606
|
|
|
677
|
-
// How
|
|
678
|
-
// `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
|
|
607
|
+
// How a TypeScript module is found behind a relative specifier: as written,
|
|
608
|
+
// its `.js` spelling as `.ts`, with `.ts` appended, or as a directory index.
|
|
679
609
|
const moduleCandidates = (path: string): readonly string[] => [
|
|
680
610
|
path,
|
|
681
611
|
path.replace(/\.js$/, ".ts"),
|
|
@@ -684,97 +614,341 @@ const moduleCandidates = (path: string): readonly string[] => [
|
|
|
684
614
|
join(path, "index.ts"),
|
|
685
615
|
]
|
|
686
616
|
|
|
617
|
+
interface Evaluated {
|
|
618
|
+
readonly exports: unknown
|
|
619
|
+
readonly files: readonly string[]
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** A bundle's one chunk: CommonJS, so it runs as a plain function. */
|
|
623
|
+
type Chunk = (
|
|
624
|
+
exports: unknown,
|
|
625
|
+
require: (id: string) => unknown,
|
|
626
|
+
module: { exports: unknown },
|
|
627
|
+
__filename: string,
|
|
628
|
+
__dirname: string,
|
|
629
|
+
) => void
|
|
630
|
+
|
|
631
|
+
const execute = (chunk: Chunk, filepath: string, require: (id: string) => unknown): unknown => {
|
|
632
|
+
const cjs = { exports: {} as unknown }
|
|
633
|
+
chunk(cjs.exports, require, cjs, filepath, dirname(filepath))
|
|
634
|
+
return cjs.exports
|
|
635
|
+
}
|
|
636
|
+
|
|
687
637
|
/**
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
638
|
+
* Bundles the config and every local module it imports in memory, then
|
|
639
|
+
* evaluates the bundle. With `read`, a relative import `read` can serve is
|
|
640
|
+
* taken from it instead of the disk; one it cannot falls through to the disk,
|
|
641
|
+
* and fails the way a missing file does. An ES module or TypeScript package is
|
|
642
|
+
* bundled too; a CommonJS one is required from where its importer resolved
|
|
643
|
+
* it, as Node would. `@pmelab/gtd/*` is the running gtd's own copy, and
|
|
644
|
+
* `files` leaves out every package file.
|
|
693
645
|
*/
|
|
694
|
-
const
|
|
646
|
+
const evaluate = async (module: WorkflowModule, shipped: ShippedModules): Promise<Evaluated> => {
|
|
647
|
+
const { code, files } = await bundleConfig(module, { kind: "evaluate", shipped })
|
|
648
|
+
const fromConfig = createRequire(module.filepath)
|
|
649
|
+
const require = Object.assign(
|
|
650
|
+
(id: string): unknown =>
|
|
651
|
+
id in shipped.modules
|
|
652
|
+
? shipped.modules[id]
|
|
653
|
+
: fromConfig(id in shipped.missing ? shipped.missing[id]! : id),
|
|
654
|
+
{ resolve: fromConfig.resolve },
|
|
655
|
+
)
|
|
656
|
+
const chunk = runInThisContext(
|
|
657
|
+
`(function (exports, require, module, __filename, __dirname) {${code}\n})`,
|
|
658
|
+
{ filename: module.filepath },
|
|
659
|
+
) as Chunk
|
|
660
|
+
return { exports: execute(chunk, module.filepath, require), files }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* What a pin registers: its workflow bundled, its origin relative to the
|
|
665
|
+
* repository root, and the fingerprint of the files it was bundled from — the
|
|
666
|
+
* content it runs, whatever the disk holds now.
|
|
667
|
+
*/
|
|
668
|
+
interface PinnedWorkflow {
|
|
669
|
+
readonly version: string
|
|
670
|
+
readonly origin: string
|
|
671
|
+
readonly fingerprint: ConfigFingerprint
|
|
672
|
+
readonly run: Chunk
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const pinnedWorkflow = (): PinnedWorkflow | undefined =>
|
|
676
|
+
(globalThis as { [PIN_KEY]?: PinnedWorkflow })[PIN_KEY]
|
|
677
|
+
|
|
678
|
+
const runPinned = (pinned: PinnedWorkflow, filepath: string, root: string): Evaluated => ({
|
|
679
|
+
// Everything but the engine's own flows and Node's builtins is in the bundle.
|
|
680
|
+
exports: execute(pinned.run, filepath, (id) =>
|
|
681
|
+
id === FLOWS ? flows : createRequire(import.meta.url)(id),
|
|
682
|
+
),
|
|
683
|
+
files: Object.keys(pinned.fingerprint).map((file) => resolvePath(root, file)),
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The statement that registers `module`'s workflow for a pin: the config and
|
|
688
|
+
* everything it imports — npm packages and gtd's own subpaths included — in
|
|
689
|
+
* one function, so the engine runs it without a config, a package or rolldown.
|
|
690
|
+
*/
|
|
691
|
+
export const pinnedWorkflowSource = async (
|
|
695
692
|
module: WorkflowModule,
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
):
|
|
699
|
-
const
|
|
700
|
-
const
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
693
|
+
root: string,
|
|
694
|
+
version: string,
|
|
695
|
+
): Promise<string> => {
|
|
696
|
+
const gtd = realpathSync(ownPackage().root) + sep
|
|
697
|
+
const { code, files } = await bundleConfig(module, {
|
|
698
|
+
kind: "pin",
|
|
699
|
+
shipped: subpathFiles(import.meta.url.endsWith(".ts") ? "source" : "default"),
|
|
700
|
+
})
|
|
701
|
+
// gtd's own subpaths are bundled from its package, which only a config
|
|
702
|
+
// living in gtd's own repository may count among its files.
|
|
703
|
+
const own = (file: string) => file.startsWith(gtd) && !module.filepath.startsWith(gtd)
|
|
704
|
+
const registered = {
|
|
705
|
+
version,
|
|
706
|
+
origin: relative(root, module.filepath),
|
|
707
|
+
fingerprint: configFingerprint(
|
|
708
|
+
files.filter((file) => !own(file)),
|
|
709
|
+
root,
|
|
710
|
+
module.read ?? readFromDisk,
|
|
711
|
+
),
|
|
712
|
+
}
|
|
713
|
+
return `globalThis[Symbol.for(${JSON.stringify(PIN_KEY.description)})] = { ...${JSON.stringify(registered)}, run: function (exports, require, module, __filename, __dirname) {\n${code}\n} };\n`
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
type BundleMode =
|
|
717
|
+
| { readonly kind: "evaluate"; readonly shipped: ShippedModules }
|
|
718
|
+
// Every import goes into the bundle but flows, the engine's own.
|
|
719
|
+
| { readonly kind: "pin"; readonly shipped: ReadonlyMap<string, string> }
|
|
720
|
+
|
|
721
|
+
const shippedImport = (
|
|
722
|
+
source: string,
|
|
723
|
+
mode: BundleMode,
|
|
724
|
+
): string | { readonly id: string; readonly external: true } | undefined => {
|
|
725
|
+
if (mode.kind === "pin")
|
|
726
|
+
return source === FLOWS ? { id: source, external: true } : mode.shipped.get(source)
|
|
727
|
+
return source in mode.shipped.modules || source in mode.shipped.missing
|
|
728
|
+
? { id: source, external: true }
|
|
729
|
+
: undefined
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const bundleConfig = async (
|
|
733
|
+
module: WorkflowModule,
|
|
734
|
+
mode: BundleMode,
|
|
735
|
+
): Promise<{ readonly code: string; readonly files: readonly string[] }> => {
|
|
736
|
+
const { read } = module
|
|
737
|
+
const served = new Set<string>()
|
|
738
|
+
const fromRead = (path: string): string | null => {
|
|
739
|
+
const hit = moduleCandidates(path).find((candidate) => read?.(candidate) !== undefined)
|
|
740
|
+
if (hit === undefined) return null
|
|
741
|
+
served.add(hit)
|
|
742
|
+
return hit
|
|
743
|
+
}
|
|
744
|
+
const plugin: RolldownPlugin = {
|
|
745
|
+
name: "gtd-config",
|
|
746
|
+
async resolveId(source, importer) {
|
|
747
|
+
if (importer === undefined) return source === module.filepath ? source : null
|
|
748
|
+
const shipped = shippedImport(source, mode)
|
|
749
|
+
if (shipped !== undefined) return shipped
|
|
750
|
+
if (/^\.\.?(\/|$)/.test(source)) return fromRead(join(dirname(importer), source))
|
|
751
|
+
if (!isPackage(source)) return null
|
|
752
|
+
const resolved = await this.resolve(source, importer, { skipSelf: true })
|
|
753
|
+
return mode.kind === "pin" ? resolved : packageImport(source, resolved)
|
|
754
|
+
},
|
|
755
|
+
load(id) {
|
|
756
|
+
if (mode.kind === "pin" && isNativeAddon(id)) throw new Error(nativeAddon(id))
|
|
757
|
+
if (id === module.filepath) return module.source
|
|
758
|
+
return served.has(id) ? read!(id) : null
|
|
759
|
+
},
|
|
760
|
+
// One bundle has one file; each module keeps its own location.
|
|
761
|
+
transform(code, id) {
|
|
762
|
+
return code.replace(/\bimport\.meta\.(url|filename|dirname)\b/g, (_, key: string) =>
|
|
763
|
+
JSON.stringify(
|
|
764
|
+
key === "url" ? pathToFileURL(id).href : key === "filename" ? id : dirname(id),
|
|
765
|
+
),
|
|
766
|
+
)
|
|
767
|
+
},
|
|
768
|
+
}
|
|
769
|
+
const bundle = await (rolldownModule ??= requireRolldown()).rolldown({
|
|
770
|
+
input: module.filepath,
|
|
771
|
+
platform: "node",
|
|
772
|
+
plugins: [plugin],
|
|
773
|
+
external: (id) => isBuiltin(id),
|
|
774
|
+
resolve: { extensionAlias: { ".js": [".js", ".ts"], ".mjs": [".mjs", ".mts"] } },
|
|
775
|
+
// Every statement runs, as it would unbundled.
|
|
776
|
+
treeshake: false,
|
|
777
|
+
tsconfig: false,
|
|
778
|
+
logLevel: "silent",
|
|
779
|
+
})
|
|
780
|
+
try {
|
|
781
|
+
const { output } = await bundle
|
|
782
|
+
// One chunk, and no `import()` left: the bundle runs as a plain function.
|
|
783
|
+
.generate({
|
|
784
|
+
format: "cjs",
|
|
785
|
+
exports: "named",
|
|
786
|
+
codeSplitting: false,
|
|
787
|
+
dynamicImportInCjs: false,
|
|
788
|
+
})
|
|
789
|
+
.catch((e) => {
|
|
790
|
+
throw bundleError(e)
|
|
791
|
+
})
|
|
792
|
+
const chunk = output[0]
|
|
793
|
+
return {
|
|
794
|
+
code: chunk.code,
|
|
795
|
+
files: chunk.moduleIds.filter((id) => !id.startsWith("\0") && !inPackage(id)),
|
|
796
|
+
}
|
|
797
|
+
} finally {
|
|
798
|
+
await bundle.close()
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// A pin is plain JavaScript: a compiled addon stays a file on one machine.
|
|
803
|
+
// A package node-gyp builds loads its addon by a path no bundler sees.
|
|
804
|
+
const isNativeAddon = (id: string): boolean => {
|
|
805
|
+
if (id.endsWith(".node")) return true
|
|
806
|
+
const at = id.lastIndexOf(`node_modules${sep}`)
|
|
807
|
+
if (at < 0) return false
|
|
808
|
+
const rest = id.slice(at + `node_modules${sep}`.length).split(sep)
|
|
809
|
+
const root =
|
|
810
|
+
id.slice(0, at) + ["node_modules", ...rest.slice(0, rest[0]!.startsWith("@") ? 2 : 1)].join(sep)
|
|
811
|
+
return existsSync(join(root, "binding.gyp"))
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const nativeAddon = (path: string): string => {
|
|
815
|
+
const at = path.lastIndexOf(`node_modules${sep}`)
|
|
816
|
+
const rest = at < 0 ? [] : path.slice(at + `node_modules${sep}`.length).split(sep)
|
|
817
|
+
const name = rest[0]?.startsWith("@") ? rest.slice(0, 2).join("/") : rest[0]
|
|
818
|
+
return `${name === undefined ? path : `package "${name}"`} loads a native addon (${path}), which a pin cannot carry`
|
|
720
819
|
}
|
|
721
820
|
|
|
821
|
+
// Bundled, an ES module gets the shipped `@pmelab/gtd/*` like the config
|
|
822
|
+
// does. CommonJS stays Node's: it may read `__dirname`, which a bundle loses.
|
|
823
|
+
const packageImport = (
|
|
824
|
+
source: string,
|
|
825
|
+
resolved: { readonly id: string } | null,
|
|
826
|
+
): string | { readonly id: string; readonly external: true } =>
|
|
827
|
+
resolved !== null && isEsModule(resolved.id)
|
|
828
|
+
? resolved.id
|
|
829
|
+
: { id: resolved?.id ?? source, external: true }
|
|
830
|
+
|
|
831
|
+
const isEsModule = (path: string): boolean => {
|
|
832
|
+
if (/\.(mjs|[cm]?tsx?)$/.test(path)) return true
|
|
833
|
+
if (!/\.jsx?$/.test(path)) return false
|
|
834
|
+
for (let dir = dirname(path); dirname(dir) !== dir; dir = dirname(dir)) {
|
|
835
|
+
const file = join(dir, "package.json")
|
|
836
|
+
if (existsSync(file)) {
|
|
837
|
+
return (JSON.parse(readFileSync(file, "utf8")) as { type?: string }).type === "module"
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return false
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const inPackage = (path: string): boolean => /[\\/]node_modules[\\/]/.test(path)
|
|
844
|
+
|
|
845
|
+
const isPackage = (specifier: string): boolean => !isAbsolute(specifier) && !isBuiltin(specifier)
|
|
846
|
+
|
|
847
|
+
// rolldown reports each error with a colored code frame; a diagnostic takes
|
|
848
|
+
// its headline and location.
|
|
849
|
+
const bundleError = (e: unknown): unknown => {
|
|
850
|
+
const errors = (e as { errors?: readonly { message: string }[] } | null)?.errors
|
|
851
|
+
if (errors === undefined) return e
|
|
852
|
+
const lines = errors.map(({ message }) => {
|
|
853
|
+
const plain = stripVTControlCharacters(message)
|
|
854
|
+
const at = /\[ (.+:\d+:\d+) \]/.exec(plain)?.[1]
|
|
855
|
+
const headline = plain.split("\n")[0]!
|
|
856
|
+
return at === undefined ? headline : `${headline} (${at})`
|
|
857
|
+
})
|
|
858
|
+
return new Error(lines.join("\n"))
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
type Rolldown = typeof import("rolldown")
|
|
862
|
+
let rolldownModule: Rolldown | undefined
|
|
863
|
+
// Required at run time, never bundled: rolldown ships a native binding.
|
|
864
|
+
const requireRolldown = (): Rolldown => createRequire(import.meta.url)("rolldown") as Rolldown
|
|
865
|
+
|
|
722
866
|
const isWorkflow = (value: unknown): boolean =>
|
|
723
|
-
|
|
867
|
+
markedOptions(value) !== undefined || isFactoryWorkflow(value)
|
|
724
868
|
|
|
725
869
|
const defaultExport = (exports: Record<string, unknown>): unknown => {
|
|
726
870
|
const value = exports.default
|
|
727
871
|
if (value === undefined) {
|
|
728
872
|
throw new Error(
|
|
729
|
-
"no default export — export the workflow an ordinary start runs
|
|
873
|
+
"no default export — export the workflow an ordinary start runs; `gtd guide` says where to get one, or write your own",
|
|
730
874
|
)
|
|
731
875
|
}
|
|
732
|
-
if (
|
|
876
|
+
if (typeof value !== "function" && !isFactoryWorkflow(value)) {
|
|
877
|
+
throw new Error("the default export is not a function")
|
|
878
|
+
}
|
|
733
879
|
return value
|
|
734
880
|
}
|
|
735
881
|
|
|
736
|
-
/** One exported workflow: a
|
|
882
|
+
/** One exported workflow: a factory validated and built, a `workflow()` call's options checked alike. */
|
|
737
883
|
const buildWorkflow = (
|
|
738
884
|
name: string,
|
|
739
885
|
value: unknown,
|
|
740
886
|
file: LoadedFile,
|
|
741
887
|
rejections: Rejection[],
|
|
742
888
|
): LoadedWorkflow | undefined => {
|
|
743
|
-
const result =
|
|
744
|
-
if (result === undefined)
|
|
745
|
-
|
|
889
|
+
const result = configure(value)
|
|
890
|
+
if (result === undefined) {
|
|
891
|
+
// A plain function only gets here as the default export: a flow without options.
|
|
892
|
+
return checkedBuild(name, file, { ...markedOptions(value), flow: value as Flow })
|
|
893
|
+
}
|
|
746
894
|
if (!result.ok) {
|
|
747
895
|
for (const issue of result.issues) rejections.push(optionDiagnostic(name, issue, file))
|
|
748
896
|
return undefined
|
|
749
897
|
}
|
|
898
|
+
return checkedBuild(name, file, result.build)
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const BUILD_KEYS: ReadonlySet<string> = new Set([
|
|
902
|
+
"flow",
|
|
903
|
+
"defaults",
|
|
904
|
+
"envDefaults",
|
|
905
|
+
"doors",
|
|
906
|
+
"base",
|
|
907
|
+
"skills",
|
|
908
|
+
"access",
|
|
909
|
+
"steering",
|
|
910
|
+
"summary",
|
|
911
|
+
])
|
|
912
|
+
|
|
913
|
+
/** A build — a factory's, or a `workflow()` call's options with its flow — held to one set of rules. */
|
|
914
|
+
const checkedBuild = (
|
|
915
|
+
name: string,
|
|
916
|
+
file: LoadedFile,
|
|
917
|
+
build: flows.WorkflowBuild,
|
|
918
|
+
): LoadedWorkflow => {
|
|
919
|
+
const what = (key: string): string => `workflow "${name}"'s "${key}"`
|
|
920
|
+
const unknown = Object.keys(build).filter((key) => !BUILD_KEYS.has(key))
|
|
921
|
+
if (unknown.length > 0) {
|
|
922
|
+
throw new Error(
|
|
923
|
+
`workflow "${name}" has unknown option(s) ${unknown.map((key) => JSON.stringify(key)).join(", ")} — known: ${[...BUILD_KEYS].filter((key) => key !== "flow").join(", ")}`,
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
if (typeof build.flow !== "function") throw new Error(`${what("flow")} is not a function`)
|
|
927
|
+
const defaults = stringRecord(build.defaults, what("defaults"))
|
|
928
|
+
const envDefaults = stringRecord(build.envDefaults, what("envDefaults"))
|
|
929
|
+
checkSettings(defaults, envDefaults, (kind) => `workflow "${name}" declares ${kind}`)
|
|
930
|
+
if (
|
|
931
|
+
build.doors !== undefined &&
|
|
932
|
+
(typeof build.doors !== "object" || build.doors === null || Array.isArray(build.doors))
|
|
933
|
+
) {
|
|
934
|
+
throw new Error(`${what("doors")} is not a record of doors`)
|
|
935
|
+
}
|
|
750
936
|
return {
|
|
751
|
-
flow:
|
|
937
|
+
flow: build.flow,
|
|
752
938
|
file,
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
939
|
+
defaults,
|
|
940
|
+
envDefaults,
|
|
941
|
+
summary: optionalFunction<flows.Summary>(build.summary, what("summary")),
|
|
942
|
+
steering: stringRecord(build.steering, what("steering")),
|
|
943
|
+
skills: skillsFrom(build.skills, what("skills")),
|
|
944
|
+
access: accessFrom(build.access, what("access")),
|
|
945
|
+
doors: build.doors,
|
|
946
|
+
base: optionalFunction<flows.WorkflowBase>(build.base, what("base")),
|
|
756
947
|
}
|
|
757
948
|
}
|
|
758
949
|
|
|
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
950
|
/** A rejected factory option, reported at `gtd.config.ts`. */
|
|
773
|
-
const optionDiagnostic = (
|
|
774
|
-
workflow: string,
|
|
775
|
-
issue: flows.OptionIssue,
|
|
776
|
-
file: LoadedFile,
|
|
777
|
-
): Rejection => ({
|
|
951
|
+
const optionDiagnostic = (workflow: string, issue: OptionIssue, file: LoadedFile): Rejection => ({
|
|
778
952
|
workflow,
|
|
779
953
|
severity: "error",
|
|
780
954
|
path: issue.path,
|
|
@@ -905,7 +1079,7 @@ const firstStep = (
|
|
|
905
1079
|
const empty = watchedEmptyTree()
|
|
906
1080
|
const vars = watchedEmptySettings()
|
|
907
1081
|
const env = watchedEmptySettings()
|
|
908
|
-
const outcome = yield* Effect.
|
|
1082
|
+
const outcome = yield* Effect.result(
|
|
909
1083
|
replayToFirstStep(loaded, vars.settings, env.settings, empty.tree),
|
|
910
1084
|
)
|
|
911
1085
|
const reads = [...settingReads("process", vars), ...settingReads("environment", env)]
|
|
@@ -916,8 +1090,8 @@ const firstStep = (
|
|
|
916
1090
|
),
|
|
917
1091
|
)
|
|
918
1092
|
}
|
|
919
|
-
if (outcome._tag === "
|
|
920
|
-
const name = outcome.
|
|
1093
|
+
if (outcome._tag === "Failure") return yield* Effect.fail(outcome.failure)
|
|
1094
|
+
const name = outcome.success
|
|
921
1095
|
if (!empty.read()) return name
|
|
922
1096
|
const atHead = yield* replayToFirstStep(loaded, vars.settings, env.settings, head)
|
|
923
1097
|
if (atHead === name) return name
|
|
@@ -936,9 +1110,8 @@ interface ConfigServiceOperations {
|
|
|
936
1110
|
>
|
|
937
1111
|
}
|
|
938
1112
|
|
|
939
|
-
export class ConfigService extends Context.
|
|
940
|
-
ConfigService,
|
|
941
|
-
|
|
942
|
-
>() {
|
|
1113
|
+
export class ConfigService extends Context.Service<ConfigService, ConfigServiceOperations>()(
|
|
1114
|
+
"ConfigService",
|
|
1115
|
+
) {
|
|
943
1116
|
static Live = Layer.succeed(ConfigService, { load })
|
|
944
1117
|
}
|