@sublang/slc 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +86 -14
  2. package/dist/app.d.ts +13 -2
  3. package/dist/app.d.ts.map +1 -1
  4. package/dist/app.js +65 -15
  5. package/dist/app.js.map +1 -1
  6. package/dist/build-history.d.ts +74 -0
  7. package/dist/build-history.d.ts.map +1 -0
  8. package/dist/build-history.js +309 -0
  9. package/dist/build-history.js.map +1 -0
  10. package/dist/cli.js +2 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/cligent-agent.d.ts +19 -0
  13. package/dist/cligent-agent.d.ts.map +1 -1
  14. package/dist/cligent-agent.js +125 -30
  15. package/dist/cligent-agent.js.map +1 -1
  16. package/dist/compiled-executor.d.ts +7 -1
  17. package/dist/compiled-executor.d.ts.map +1 -1
  18. package/dist/compiled-executor.js +11 -3
  19. package/dist/compiled-executor.js.map +1 -1
  20. package/dist/config-file.d.ts +9 -0
  21. package/dist/config-file.d.ts.map +1 -1
  22. package/dist/config-file.js +29 -5
  23. package/dist/config-file.js.map +1 -1
  24. package/dist/config.d.ts +8 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +9 -3
  27. package/dist/config.js.map +1 -1
  28. package/dist/errors.d.ts +14 -0
  29. package/dist/errors.d.ts.map +1 -0
  30. package/dist/errors.js +28 -0
  31. package/dist/errors.js.map +1 -0
  32. package/dist/execution.d.ts +42 -0
  33. package/dist/execution.d.ts.map +1 -1
  34. package/dist/execution.js +224 -29
  35. package/dist/execution.js.map +1 -1
  36. package/dist/hash.d.ts +22 -0
  37. package/dist/hash.d.ts.map +1 -1
  38. package/dist/hash.js +47 -0
  39. package/dist/hash.js.map +1 -1
  40. package/dist/index.d.ts +1 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/interpreter.d.ts +1 -1
  45. package/dist/interpreter.d.ts.map +1 -1
  46. package/dist/interpreter.js +9 -0
  47. package/dist/interpreter.js.map +1 -1
  48. package/dist/invocation.d.ts +5 -1
  49. package/dist/invocation.d.ts.map +1 -1
  50. package/dist/invocation.js +14 -3
  51. package/dist/invocation.js.map +1 -1
  52. package/dist/line-diff.d.ts +7 -0
  53. package/dist/line-diff.d.ts.map +1 -0
  54. package/dist/line-diff.js +66 -0
  55. package/dist/line-diff.js.map +1 -0
  56. package/dist/phase.d.ts +2 -0
  57. package/dist/phase.d.ts.map +1 -1
  58. package/dist/phase.js +19 -3
  59. package/dist/phase.js.map +1 -1
  60. package/dist/pin-closure.d.ts +2 -2
  61. package/dist/pin-closure.d.ts.map +1 -1
  62. package/dist/pin-closure.js +4 -3
  63. package/dist/pin-closure.js.map +1 -1
  64. package/dist/pin-currency.d.ts +6 -2
  65. package/dist/pin-currency.d.ts.map +1 -1
  66. package/dist/pin-currency.js +24 -43
  67. package/dist/pin-currency.js.map +1 -1
  68. package/dist/pin-generate.d.ts.map +1 -1
  69. package/dist/pin-generate.js +1 -3
  70. package/dist/pin-generate.js.map +1 -1
  71. package/dist/pin-paths.d.ts.map +1 -1
  72. package/dist/pin-paths.js +2 -13
  73. package/dist/pin-paths.js.map +1 -1
  74. package/dist/pins.d.ts.map +1 -1
  75. package/dist/pins.js +5 -15
  76. package/dist/pins.js.map +1 -1
  77. package/dist/playbook-ports.d.ts +17 -0
  78. package/dist/playbook-ports.d.ts.map +1 -1
  79. package/dist/playbook-ports.js +21 -6
  80. package/dist/playbook-ports.js.map +1 -1
  81. package/dist/progress.d.ts +71 -0
  82. package/dist/progress.d.ts.map +1 -0
  83. package/dist/progress.js +96 -0
  84. package/dist/progress.js.map +1 -0
  85. package/dist/runner.d.ts +8 -0
  86. package/dist/runner.d.ts.map +1 -1
  87. package/dist/runner.js +673 -89
  88. package/dist/runner.js.map +1 -1
  89. package/dist/runtime-package.d.ts.map +1 -1
  90. package/dist/runtime-package.js +3 -11
  91. package/dist/runtime-package.js.map +1 -1
  92. package/dist/slc.config.template.yaml +5 -1
  93. package/dist/verify-coverage.d.ts.map +1 -1
  94. package/dist/verify-coverage.js +2 -0
  95. package/dist/verify-coverage.js.map +1 -1
  96. package/dist/verify-support.d.ts +7 -0
  97. package/dist/verify-support.d.ts.map +1 -1
  98. package/dist/verify-support.js +13 -6
  99. package/dist/verify-support.js.map +1 -1
  100. package/dist/verify.d.ts.map +1 -1
  101. package/dist/verify.js +2 -0
  102. package/dist/verify.js.map +1 -1
  103. package/package.json +5 -3
package/dist/runner.js CHANGED
@@ -9,21 +9,26 @@
9
9
  * injected so a host wires the real pipeline resolution and Cligent agent while
10
10
  * tests supply fakes. See specs/dev/pipeline.md and specs/dev/phase-execution.md.
11
11
  */
12
- import { mkdir } from 'node:fs/promises';
13
- import { dirname, join } from 'node:path';
12
+ import { mkdir, readFile, stat } from 'node:fs/promises';
13
+ import { dirname, join, relative, resolve, sep } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { artifactDir, planArtifacts, parseSource } from './artifacts.js';
16
+ import { encodeLocator, invalidateBuildHistory, loadBuildHistory, recordBuild, verifiedInput, } from './build-history.js';
16
17
  import { emitEntryModule } from './entry-module.js';
17
18
  import { unresolvableRelativeImports } from './emitted-imports.js';
18
- import { formatFailureReport, runPhase, } from './execution.js';
19
+ import { messageOf } from './errors.js';
20
+ import { assertSafeTarget, formatFailureReport, pathsAlias, runPhase, } from './execution.js';
21
+ import { hashBytes, isHash } from './hash.js';
19
22
  import { parseInvocation } from './invocation.js';
23
+ import { unifiedLineDiff } from './line-diff.js';
20
24
  import { linkedArtifactPath, loadLinkFile } from './link.js';
21
- import { evaluatePin, evaluatePinFile } from './pin-currency.js';
22
- import { PinError, loadPinFile } from './pins.js';
25
+ import { deriveClosure } from './pin-closure.js';
26
+ import { evaluatePin, evaluatePinFile, hashTree } from './pin-currency.js';
27
+ import { PINS_FILE, PinError, loadPinFile, } from './pins.js';
23
28
  import { loadPipeline, resolvePipeline, } from './pipeline.js';
24
29
  import { defaultPlaybookLinkTarget, isReservedPipeline } from './resolver.js';
25
30
  import { emitFsmCoverageTest, emitFsmIntrospectionTest, emitGearsFsmConformanceTest, emitPromptContractTest, } from './verify.js';
26
- import { VERIFIER_SUPPORT_MODULE, emitVerifierSupport, } from './verify-support.js';
31
+ import { VERIFIER_SUPPORT_MODULE, emitVerifierSupport, verifierSupportFiles, } from './verify-support.js';
27
32
  /**
28
33
  * Parses argv and runs the requested pipeline, phase, or link end-to-end.
29
34
  * Never rejects: malformed invocations, refusals, and phase failures all return
@@ -65,47 +70,67 @@ export async function runSlc(argv, deps) {
65
70
  }
66
71
  }
67
72
  async function runFull(invocation, deps) {
73
+ const cwd = runCwd(deps);
68
74
  const pipeline = await loadPipeline(await resolvePipeline(invocation.pipeline, deps.resolver));
69
75
  const entry = pipeline.phases[0];
76
+ const source = resolve(cwd, invocation.source);
70
77
  const { basename, raw } = parseSource({
71
- path: invocation.source,
78
+ path: source,
72
79
  sourceFormat: entry.source.format,
73
80
  ext: entry.source.ext,
74
81
  entry: true,
75
82
  });
76
- const artDir = artifactDir(runCwd(deps), basename, invocation.pipeline);
83
+ const artDir = artifactDir(cwd, basename, invocation.pipeline);
77
84
  await mkdir(artDir, { recursive: true });
78
85
  const plan = planArtifacts({
79
86
  phases: pipeline.phases,
80
87
  basename,
81
88
  artDir,
82
- output: invocation.output ?? undefined,
89
+ output: invocation.output === null ? undefined : resolve(cwd, invocation.output),
83
90
  });
84
91
  const steps = buildCompileSteps({
85
92
  pipeline,
86
93
  plan,
87
- source: invocation.source,
94
+ source,
88
95
  artDir,
89
96
  basename,
90
97
  optimize: !invocation.noOptimize,
91
98
  normalize: invocation.normalize || raw,
92
99
  });
93
- const result = await executeSteps(steps, pipeline, deps);
94
- return emitVerification(result, {
100
+ const verification = {
95
101
  pipeline: invocation.pipeline,
96
102
  plan,
97
103
  artDir,
98
104
  basename,
105
+ };
106
+ return executeSteps(steps, pipeline, deps, {
107
+ incremental: incrementalRun(invocation, artDir, source),
108
+ hostTargets: verificationHostTargets(verification),
109
+ complete: (result, guardTarget) => emitVerification(result, verification, guardTarget),
99
110
  });
100
111
  }
112
+ /** History covers only canonical full/full-link runs outside the `slc` meta-pipeline. */
113
+ function incrementalRun(invocation, artDir, source) {
114
+ if (invocation.output !== null || invocation.pipeline === 'slc') {
115
+ return undefined;
116
+ }
117
+ return {
118
+ artDir,
119
+ source,
120
+ pipelineName: invocation.pipeline,
121
+ rebuild: invocation.rebuild === true,
122
+ };
123
+ }
101
124
  async function runSinglePhase(invocation, deps) {
125
+ const cwd = runCwd(deps);
102
126
  const pipeline = await loadPipeline(await resolvePipeline(invocation.pipeline, deps.resolver));
103
127
  const phase = [...pipeline.phases, ...pipeline.passes].find((candidate) => candidate.name === invocation.phase);
104
128
  if (phase === undefined) {
105
129
  return failure(`phase "${invocation.phase}" is not part of pipeline "${invocation.pipeline}"`);
106
130
  }
131
+ const source = resolve(cwd, invocation.source);
107
132
  const { basename, raw } = parseSource({
108
- path: invocation.source,
133
+ path: source,
109
134
  sourceFormat: phase.source.format,
110
135
  ext: phase.source.ext,
111
136
  entry: pipeline.phases[0] === phase,
@@ -115,14 +140,14 @@ async function runSinglePhase(invocation, deps) {
115
140
  // path into the phase's declared source format here.
116
141
  return failure(`source "${invocation.source}" is a raw input; run the full pipeline to normalize it`);
117
142
  }
118
- const artDir = artifactDir(runCwd(deps), basename, invocation.pipeline);
143
+ const artDir = artifactDir(cwd, basename, invocation.pipeline);
119
144
  await mkdir(artDir, { recursive: true });
120
145
  if (phase.pass) {
121
146
  // A standalone pass run cannot overwrite its own source: it writes the
122
147
  // `.opt` sibling unless `-o` relocates it (DR-013).
123
- const target = invocation.output ??
148
+ const target = (invocation.output === null ? null : resolve(cwd, invocation.output)) ??
124
149
  join(artDir, `${basename}.${phase.target.format}.opt${phase.target.ext}`);
125
- const step = compileStep(pipeline, phase, invocation.source, target);
150
+ const step = compileStep(pipeline, phase, source, target);
126
151
  return executeSteps([step], pipeline, deps);
127
152
  }
128
153
  // Plan over the whole chain so the named phase keeps its pipeline role: a
@@ -132,58 +157,62 @@ async function runSinglePhase(invocation, deps) {
132
157
  phases: pipeline.phases,
133
158
  basename,
134
159
  artDir,
135
- output: invocation.output ?? undefined,
160
+ output: invocation.output === null ? undefined : resolve(cwd, invocation.output),
136
161
  });
137
162
  const artifact = plan[pipeline.phases.indexOf(phase)];
138
- const step = compileStep(pipeline, phase, invocation.source, artifact.path);
163
+ const step = compileStep(pipeline, phase, source, artifact.path);
139
164
  return executeSteps([step], pipeline, deps);
140
165
  }
141
166
  async function runDirectLink(invocation, deps) {
167
+ const cwd = runCwd(deps);
142
168
  const pipeline = await loadPipeline(await resolvePipeline(invocation.pipeline, deps.resolver));
143
169
  const link = await requireLink(pipeline, invocation.pipeline);
144
170
  const linked = linkedArtifactPath({
145
171
  kind: 'link',
146
172
  pipeline: invocation.pipeline,
147
- objects: invocation.objects,
173
+ objects: invocation.objects.map((path) => resolve(cwd, path)),
148
174
  source: link.source,
149
175
  linked: link.target,
150
- output: invocation.output,
151
- cwd: runCwd(deps),
176
+ output: invocation.output === null ? null : resolve(cwd, invocation.output),
177
+ cwd,
152
178
  });
153
179
  await mkdir(dirname(linked), { recursive: true });
154
180
  const step = {
155
181
  request: {
156
182
  kind: 'link',
157
183
  definitionPath: pipeline.linkFile,
158
- objects: invocation.objects,
159
- linkTarget: invocation.linkTarget,
184
+ objects: invocation.objects.map((path) => resolve(cwd, path)),
185
+ linkTarget: resolve(cwd, invocation.linkTarget),
160
186
  options: invocation.options,
161
187
  linked,
162
188
  },
163
189
  phase: 'link',
190
+ pinKey: 'link',
164
191
  targetExt: link.target.ext,
165
192
  };
166
193
  return executeSteps([step], pipeline, deps);
167
194
  }
168
195
  async function runFullLink(invocation, deps) {
196
+ const cwd = runCwd(deps);
169
197
  const pipeline = await loadPipeline(await resolvePipeline(invocation.pipeline, deps.resolver));
170
198
  const link = await requireLink(pipeline, invocation.pipeline);
171
199
  const entry = pipeline.phases[0];
200
+ const source = resolve(cwd, invocation.source);
172
201
  const { basename, raw } = parseSource({
173
- path: invocation.source,
202
+ path: source,
174
203
  sourceFormat: entry.source.format,
175
204
  ext: entry.source.ext,
176
205
  entry: true,
177
206
  });
178
207
  const normalize = invocation.normalize || raw;
179
- const artDir = artifactDir(runCwd(deps), basename, invocation.pipeline);
208
+ const artDir = artifactDir(cwd, basename, invocation.pipeline);
180
209
  await mkdir(artDir, { recursive: true });
181
210
  // Compile chain: the exit artifact becomes the object artifact (PIPE-15).
182
211
  const plan = planArtifacts({ phases: pipeline.phases, basename, artDir });
183
212
  const compileSteps = buildCompileSteps({
184
213
  pipeline,
185
214
  plan,
186
- source: invocation.source,
215
+ source,
187
216
  artDir,
188
217
  basename,
189
218
  optimize: !invocation.noOptimize,
@@ -194,64 +223,88 @@ async function runFullLink(invocation, deps) {
194
223
  artDir,
195
224
  basename,
196
225
  linked: link.target,
197
- output: invocation.output,
226
+ output: invocation.output === null ? null : resolve(cwd, invocation.output),
198
227
  });
199
228
  const linkStep = {
200
229
  request: {
201
230
  kind: 'link',
202
231
  definitionPath: pipeline.linkFile,
203
232
  objects: [plan[plan.length - 1].path],
204
- linkTarget: invocation.linkTarget,
233
+ linkTarget: resolve(cwd, invocation.linkTarget),
205
234
  options: invocation.options,
206
235
  linked,
207
236
  },
208
237
  phase: 'link',
238
+ pinKey: 'link',
209
239
  targetExt: link.target.ext,
210
240
  };
211
- const result = await executeSteps([...compileSteps, linkStep], pipeline, deps);
212
- const verified = await emitVerification(result, {
241
+ const steps = [...compileSteps, linkStep];
242
+ const gearsPlan = plan.find((artifact) => artifact.phase.target.format === 'gears');
243
+ const entryCandidate = invocation.pipeline === 'playbook' &&
244
+ invocation.output === null &&
245
+ gearsPlan !== undefined
246
+ ? resolve(cwd, `${basename}.ts`)
247
+ : null;
248
+ const entryAliasesSource = entryCandidate !== null && (await pathsAlias(entryCandidate, source));
249
+ const entryPath = entryAliasesSource ? null : entryCandidate;
250
+ const verification = {
213
251
  pipeline: invocation.pipeline,
214
252
  plan,
215
253
  artDir,
216
254
  basename,
217
- });
218
- // Entry-module emission (DR-014, SELFHOST-15): only the playbook pipeline,
219
- // only with the linked artifact at its canonical path.
220
- if (verified.ok &&
221
- invocation.pipeline === 'playbook' &&
222
- invocation.output === null) {
223
- const gearsPlan = plan.find((artifact) => artifact.phase.target.format === 'gears');
224
- if (gearsPlan !== undefined) {
225
- const textPath = normalize
226
- ? join(artDir, `${basename}.${entry.source.format}${entry.source.ext}`)
227
- : invocation.source;
228
- const entryPath = await emitEntryModule({
229
- cwd: runCwd(deps),
230
- basename,
231
- pipeline: invocation.pipeline,
232
- gearsPath: gearsPlan.path,
233
- textPath,
234
- });
235
- const missing = await unresolvableRelativeImports(entryPath);
236
- if (missing.length > 0) {
255
+ };
256
+ return executeSteps(steps, pipeline, deps, {
257
+ incremental: incrementalRun(invocation, artDir, source),
258
+ hostTargets: [
259
+ ...verificationHostTargets(verification),
260
+ ...(entryPath === null ? [] : [{ path: entryPath }]),
261
+ ],
262
+ complete: async (result, guardTarget) => {
263
+ const verified = await emitVerification(result, verification, guardTarget);
264
+ if (verified.ok && entryCandidate !== null && entryAliasesSource) {
237
265
  return {
238
- ok: false,
239
- outputs: verified.outputs,
266
+ ...verified,
240
267
  diagnostics: [
241
268
  ...verified.diagnostics,
242
- `entry module ${entryPath} has unresolvable relative imports: ` +
243
- `${missing.join(', ')} (VERIFY-18)`,
269
+ `entry module ${entryCandidate} not emitted because it aliases the invocation source`,
244
270
  ],
245
271
  };
246
272
  }
247
- return { ...verified, outputs: [...verified.outputs, entryPath] };
248
- }
249
- }
250
- return verified;
273
+ // Entry-module emission (DR-014, SELFHOST-15): only the playbook
274
+ // pipeline, only with the linked artifact at its canonical path.
275
+ if (verified.ok && entryPath !== null && gearsPlan !== undefined) {
276
+ await guardTarget(entryPath);
277
+ const textPath = normalize
278
+ ? join(artDir, `${basename}.${entry.source.format}${entry.source.ext}`)
279
+ : source;
280
+ await emitEntryModule({
281
+ cwd,
282
+ basename,
283
+ pipeline: invocation.pipeline,
284
+ gearsPath: gearsPlan.path,
285
+ textPath,
286
+ });
287
+ const missing = await unresolvableRelativeImports(entryPath);
288
+ if (missing.length > 0) {
289
+ return {
290
+ ok: false,
291
+ outputs: verified.outputs,
292
+ diagnostics: [
293
+ ...verified.diagnostics,
294
+ `entry module ${entryPath} has unresolvable relative imports: ` +
295
+ `${missing.join(', ')} (VERIFY-18)`,
296
+ ],
297
+ };
298
+ }
299
+ return { ...verified, outputs: [...verified.outputs, entryPath] };
300
+ }
301
+ return verified;
302
+ },
303
+ });
251
304
  }
252
305
  /** The invocation working directory anchoring artifact placement (DR-014). */
253
306
  function runCwd(deps) {
254
- return deps.cwd ?? process.cwd();
307
+ return resolve(deps.cwd ?? process.cwd());
255
308
  }
256
309
  /**
257
310
  * After a reserved-pipeline full run produces a `gears` intermediate and an `fsm`
@@ -267,28 +320,35 @@ function runCwd(deps) {
267
320
  * to a diagnostic when the artifact cannot be loaded, leaving the run outcome
268
321
  * unchanged — the conformance test still fails at test time on a broken module.
269
322
  */
270
- async function emitVerification(result, ctx) {
271
- if (!result.ok || !isReservedPipeline(ctx.pipeline))
272
- return result;
273
- const fsm = ctx.plan.find((artifact) => artifact.phase.target.format === 'fsm');
274
- const hasGears = ctx.plan.some((artifact) => artifact.phase.target.format === 'gears');
275
- if (fsm === undefined || !hasGears)
323
+ async function emitVerification(result, ctx, guardTarget) {
324
+ if (!result.ok)
276
325
  return result;
277
- // The emitted tests import the canonical sibling FSM through the NodeNext
278
- // `./<basename>.fsm.js` specifier; skip when `-o` relocated the physical FSM
279
- // elsewhere (PIPE-8), so that edge never targets a missing sibling artifact.
280
- const canonicalFsm = join(ctx.artDir, `${ctx.basename}.fsm${fsm.phase.target.ext}`);
281
- if (fsm.path !== canonicalFsm)
326
+ const hostTargets = verificationHostTargets(ctx);
327
+ if (hostTargets.length === 0)
282
328
  return result;
329
+ const fsm = ctx.plan.find((artifact) => artifact.phase.target.format === 'fsm');
330
+ if (fsm === undefined)
331
+ throw new Error('verification plan lost its FSM');
283
332
  const outputs = [...result.outputs];
284
333
  const diagnostics = [...result.diagnostics];
334
+ if (guardTarget !== undefined) {
335
+ for (const target of verifierSupportFiles(ctx.artDir)) {
336
+ await guardTarget(target.target);
337
+ }
338
+ }
285
339
  outputs.push(...(await emitVerifierSupport(ctx.artDir)));
340
+ if (guardTarget !== undefined) {
341
+ await guardTarget(join(ctx.artDir, `${ctx.basename}.gears-fsm.test.ts`));
342
+ }
286
343
  outputs.push(await emitGearsFsmConformanceTest({
287
344
  artifactDir: ctx.artDir,
288
345
  basename: ctx.basename,
289
346
  verifyModule: VERIFIER_SUPPORT_MODULE,
290
347
  }));
291
348
  try {
349
+ if (guardTarget !== undefined) {
350
+ await guardTarget(join(ctx.artDir, `${ctx.basename}.fsm.introspect.test.ts`));
351
+ }
292
352
  outputs.push(await emitFsmIntrospectionTest({
293
353
  artifactDir: ctx.artDir,
294
354
  basename: ctx.basename,
@@ -299,6 +359,9 @@ async function emitVerification(result, ctx) {
299
359
  diagnostics.push(`verification: introspection test not emitted: ${messageOf(error)}`);
300
360
  }
301
361
  try {
362
+ if (guardTarget !== undefined) {
363
+ await guardTarget(join(ctx.artDir, `${ctx.basename}.prompt-contract.test.ts`));
364
+ }
302
365
  const promptContract = await emitPromptContractTest({
303
366
  artifactDir: ctx.artDir,
304
367
  basename: ctx.basename,
@@ -311,6 +374,9 @@ async function emitVerification(result, ctx) {
311
374
  diagnostics.push(`verification: prompt-contract test not emitted: ${messageOf(error)}`);
312
375
  }
313
376
  try {
377
+ if (guardTarget !== undefined) {
378
+ await guardTarget(join(ctx.artDir, `${ctx.basename}.fsm.coverage.test.ts`));
379
+ }
314
380
  const coverage = await emitFsmCoverageTest({
315
381
  artifactDir: ctx.artDir,
316
382
  basename: ctx.basename,
@@ -324,6 +390,38 @@ async function emitVerification(result, ctx) {
324
390
  }
325
391
  return { ...result, outputs, diagnostics };
326
392
  }
393
+ /** Exact deterministic writes attempted by {@link emitVerification}. */
394
+ function verificationHostTargets(ctx) {
395
+ if (!isReservedPipeline(ctx.pipeline))
396
+ return [];
397
+ const fsm = ctx.plan.find((artifact) => artifact.phase.target.format === 'fsm');
398
+ const hasGears = ctx.plan.some((artifact) => artifact.phase.target.format === 'gears');
399
+ if (fsm === undefined || !hasGears)
400
+ return [];
401
+ const canonicalFsm = join(ctx.artDir, `${ctx.basename}.fsm${fsm.phase.target.ext}`);
402
+ if (fsm.path !== canonicalFsm)
403
+ return [];
404
+ return [
405
+ ...verifierSupportFiles(ctx.artDir).map(({ source, target }) => ({
406
+ path: target,
407
+ protectedInputs: [source],
408
+ allowVerifierOutput: true,
409
+ })),
410
+ { path: join(ctx.artDir, `${ctx.basename}.gears-fsm.test.ts`) },
411
+ {
412
+ path: join(ctx.artDir, `${ctx.basename}.fsm.introspect.test.ts`),
413
+ required: false,
414
+ },
415
+ {
416
+ path: join(ctx.artDir, `${ctx.basename}.prompt-contract.test.ts`),
417
+ required: false,
418
+ },
419
+ {
420
+ path: join(ctx.artDir, `${ctx.basename}.fsm.coverage.test.ts`),
421
+ required: false,
422
+ },
423
+ ];
424
+ }
327
425
  /** Resolves the built-in pipeline-agnostic normalize definition (DR-013). */
328
426
  export function normalizeDefinitionPath() {
329
427
  return fileURLToPath(new URL('./normalize.md', import.meta.url));
@@ -389,6 +487,7 @@ function compileStep(pipeline, phase, source, target) {
389
487
  target,
390
488
  },
391
489
  phase: phase.name,
490
+ pinKey: phase.name,
392
491
  targetExt: phase.target.ext,
393
492
  };
394
493
  }
@@ -397,7 +496,9 @@ function compileStep(pipeline, phase, source, target) {
397
496
  * the pin index and stopping at the first failure with its report (PHEXEC-9,
398
497
  * PHEXEC-27). An unparseable pin file fails the run closed before any phase.
399
498
  */
400
- async function executeSteps(steps, pipeline, deps) {
499
+ async function executeSteps(steps, pipeline, deps, opts = {}) {
500
+ const complete = opts.complete ?? (async (result) => result);
501
+ const pinInputs = new Set([join(pipeline.dir, PINS_FILE)]);
401
502
  let pinFile;
402
503
  try {
403
504
  pinFile = (await loadPinFile(pipeline.dir)).file;
@@ -409,7 +510,9 @@ async function executeSteps(steps, pipeline, deps) {
409
510
  throw error;
410
511
  }
411
512
  if (pinFile !== undefined) {
412
- const verdicts = await evaluatePinFile(pipeline.dir, pinFile);
513
+ const verdicts = await evaluatePinFile(pipeline.dir, pinFile, {
514
+ observePath: (path) => pinInputs.add(resolve(path)),
515
+ });
413
516
  const malformed = Object.entries(verdicts).find(([, verdict]) => verdict.status === 'malformed');
414
517
  if (malformed !== undefined) {
415
518
  return {
@@ -423,15 +526,132 @@ async function executeSteps(steps, pipeline, deps) {
423
526
  };
424
527
  }
425
528
  }
426
- const definitions = chainDefinitions(pipeline);
529
+ const definitions = [
530
+ ...new Set([
531
+ ...chainDefinitions(pipeline),
532
+ ...steps.map((step) => step.request.definitionPath),
533
+ ]),
534
+ ];
427
535
  const outputs = [];
428
536
  const diagnostics = [];
429
- for (const step of steps) {
430
- const target = step.request.kind === 'compile'
431
- ? step.request.target
432
- : step.request.linked;
433
- const selection = await selectExecutor(step.phase, pipeline.dir, pinFile, deps);
537
+ const state = opts.incremental === undefined
538
+ ? undefined
539
+ : await loadIncremental(opts.incremental);
540
+ const invalidatedTargetDirs = new Set();
541
+ const targets = steps.map(stepTarget);
542
+ const declaredByStep = await Promise.all(steps.map((step) => stepDeclaredInputs(step, pipeline, pinFile)));
543
+ const declaredInputs = [
544
+ ...new Set(declaredByStep.flatMap((declared) => declared.paths)),
545
+ ];
546
+ const immutableInputs = externalInputPaths(steps);
547
+ const hostInputs = (opts.hostTargets ?? []).flatMap((target) => target.protectedInputs ?? []);
548
+ const protectedInputs = [
549
+ ...new Set([
550
+ ...immutableInputs,
551
+ ...definitions,
552
+ ...declaredInputs,
553
+ ...hostInputs,
554
+ ...pinInputs,
555
+ ]),
556
+ ];
557
+ const plannedTargets = opts.hostTargets ?? [];
558
+ for (let index = 0; index < plannedTargets.length; index++) {
559
+ const planned = plannedTargets[index];
560
+ try {
561
+ await assertSafeTarget(planned.path, [
562
+ ...protectedInputs,
563
+ ...targets,
564
+ ...(planned.protectedInputs ?? []),
565
+ ...plannedTargets
566
+ .filter((_, candidate) => candidate !== index)
567
+ .map((candidate) => candidate.path),
568
+ ], { allowVerifierOutput: planned.allowVerifierOutput });
569
+ }
570
+ catch (error) {
571
+ if (planned.required !== false)
572
+ return failure(messageOf(error));
573
+ }
574
+ }
575
+ const hostTargets = new Map((opts.hostTargets ?? []).map((target) => [resolve(target.path), target]));
576
+ const guardCompletionTarget = async (path) => {
577
+ const planned = hostTargets.get(resolve(path));
578
+ if (planned === undefined) {
579
+ throw new Error(`unplanned deterministic output: ${path}`);
580
+ }
581
+ await assertSafeTarget(planned.path, [...protectedInputs, ...targets, ...(planned.protectedInputs ?? [])], { allowVerifierOutput: planned.allowVerifierOutput });
582
+ };
583
+ for (let index = 0; index < steps.length; index++) {
584
+ const step = steps[index];
585
+ const target = targets[index];
586
+ const stepDeclared = declaredByStep[index];
587
+ const identity = state === undefined
588
+ ? null
589
+ : await stepIdentity(step, state, index, stepDeclared.complete ? stepDeclared.paths : null);
590
+ const mode = state === undefined
591
+ ? { mode: 'ordinary' }
592
+ : await selectStepMode(step, index, target, identity, state);
593
+ // Reuse still honors pin currency, but it constructs no executor because
594
+ // no phase runs. A stale/malformed pin can never be bypassed by history.
595
+ if (mode.mode === 'reuse') {
596
+ if (state === undefined) {
597
+ throw new Error('internal error: reuse selected without history state');
598
+ }
599
+ let reasons;
600
+ try {
601
+ reasons = await reusePinFailure(step.pinKey, pipeline.dir, pinFile);
602
+ }
603
+ catch (error) {
604
+ reasons = [messageOf(error)];
605
+ }
606
+ if (reasons !== null) {
607
+ const startedAt = Date.now();
608
+ deps.progress?.({ kind: 'phase-start', phase: step.phase, target });
609
+ deps.progress?.({
610
+ kind: 'phase-fail',
611
+ phase: step.phase,
612
+ target,
613
+ elapsedMs: Date.now() - startedAt,
614
+ });
615
+ diagnostics.push(formatFailureReport({ phase: step.phase, target, reasons }));
616
+ return { ok: false, outputs, diagnostics };
617
+ }
618
+ deps.progress?.({
619
+ kind: 'status',
620
+ text: `reusing ${step.phase} → ${target}`,
621
+ });
622
+ state.accepted.push({
623
+ step,
624
+ target,
625
+ inputs: identity?.inputs ?? null,
626
+ output: hashBytes(mode.bytes),
627
+ });
628
+ continue;
629
+ }
630
+ // In-run progress (DR-019, CLI-32): announce the phase, then report its
631
+ // outcome with the elapsed time.
632
+ const startedAt = Date.now();
633
+ deps.progress?.({ kind: 'phase-start', phase: step.phase, target });
634
+ const fail = () => deps.progress?.({
635
+ kind: 'phase-fail',
636
+ phase: step.phase,
637
+ target,
638
+ elapsedMs: Date.now() - startedAt,
639
+ });
640
+ // Selecting a compiled executor can throw rather than return a verdict —
641
+ // notably an unmapped pinned Playbook provenance, which the host factory
642
+ // rejects (PHEXEC-30). That is the same fail-closed family as a stale pin,
643
+ // so it is reported through the phase-failure path; letting it unwind
644
+ // would strand the phase-start line with no terminal event and drop the
645
+ // phase and target from the report (CLI-4, CLI-32, PHEXEC-27).
646
+ let selection;
647
+ try {
648
+ selection = await selectExecutor(step.phase, step.pinKey, pipeline.dir, pinFile, deps);
649
+ }
650
+ catch (error) {
651
+ selection = { kind: 'fail', reasons: [messageOf(error)] };
652
+ }
434
653
  if (selection.kind === 'fail') {
654
+ fail();
435
655
  diagnostics.push(formatFailureReport({
436
656
  phase: step.phase,
437
657
  target,
@@ -439,16 +659,38 @@ async function executeSteps(steps, pipeline, deps) {
439
659
  }));
440
660
  return { ok: false, outputs, diagnostics };
441
661
  }
662
+ if (mode.mode === 'update') {
663
+ deps.progress?.({
664
+ kind: 'status',
665
+ text: `updating ${step.phase} → ${target}`,
666
+ });
667
+ }
668
+ const request = mode.mode === 'update' && step.request.kind === 'compile'
669
+ ? {
670
+ ...step.request,
671
+ update: { priorInput: mode.priorInput, diff: mode.diff },
672
+ }
673
+ : step.request;
442
674
  const result = await runPhase({
443
- request: step.request,
675
+ request,
444
676
  phase: step.phase,
445
677
  targetExt: step.targetExt,
446
678
  executor: selection.executor,
447
679
  definitions,
680
+ protectedInputs: stepDeclared.paths,
681
+ aliasInputs: [
682
+ ...immutableInputs,
683
+ ...declaredInputs,
684
+ ...hostInputs,
685
+ ...pinInputs,
686
+ ...targets.filter((_, candidate) => candidate !== index),
687
+ ],
688
+ beforeExecute: () => invalidateForExecution(state, target, invalidatedTargetDirs),
448
689
  revalidate: () => revalidateChain(pipeline.dir),
449
690
  signal: deps.signal,
450
691
  });
451
692
  if (!result.ok) {
693
+ fail();
452
694
  diagnostics.push(formatFailureReport(result.report));
453
695
  return { ok: false, outputs, diagnostics };
454
696
  }
@@ -458,17 +700,355 @@ async function executeSteps(steps, pipeline, deps) {
458
700
  // for a dead artifact (VERIFY-18).
459
701
  if (step.phase === 'link' &&
460
702
  (step.targetExt === '.ts' || step.targetExt === '.js')) {
461
- const missing = await unresolvableRelativeImports(step.request.kind === 'link' ? step.request.linked : target);
703
+ let missing;
704
+ try {
705
+ missing = await unresolvableRelativeImports(step.request.kind === 'link' ? step.request.linked : target);
706
+ }
707
+ catch (error) {
708
+ // A target that cannot even be read is a dead artifact too — a
709
+ // directory at the linked path passes the DR-003 existence and
710
+ // extension checks and then fails the read. Fail the link here so
711
+ // every started phase still reaches a terminal event (CLI-32).
712
+ fail();
713
+ diagnostics.push(`linked module ${target} could not be read: ${messageOf(error)} — ` +
714
+ 'an emitted module that cannot load fails the link (VERIFY-18)');
715
+ return { ok: false, outputs, diagnostics };
716
+ }
462
717
  if (missing.length > 0) {
718
+ fail();
463
719
  diagnostics.push(`linked module ${target} has unresolvable relative imports: ` +
464
720
  `${missing.join(', ')} — an emitted module that cannot load ` +
465
721
  'fails the link (VERIFY-18)');
466
722
  return { ok: false, outputs, diagnostics };
467
723
  }
468
724
  }
725
+ deps.progress?.({
726
+ kind: 'phase-finish',
727
+ phase: step.phase,
728
+ target,
729
+ elapsedMs: Date.now() - startedAt,
730
+ });
469
731
  outputs.push(target);
732
+ if (state !== undefined) {
733
+ let output = null;
734
+ try {
735
+ output = hashBytes(await readFile(target));
736
+ }
737
+ catch {
738
+ // A successful compile remains successful when only history capture is
739
+ // unavailable; final publication reports the advisory diagnostic.
740
+ }
741
+ state.executed++;
742
+ state.accepted.push({
743
+ step,
744
+ target,
745
+ inputs: identity?.inputs ?? null,
746
+ output,
747
+ });
748
+ }
470
749
  }
471
- return { ok: true, outputs, diagnostics };
750
+ const selected = {
751
+ ok: true,
752
+ outputs,
753
+ diagnostics,
754
+ ...(state !== undefined &&
755
+ state.executed === 0 &&
756
+ steps.length > 0 &&
757
+ state.accepted.length === steps.length
758
+ ? { outcome: 'up-to-date' }
759
+ : {}),
760
+ };
761
+ const completed = await complete(selected, guardCompletionTarget);
762
+ return state === undefined
763
+ ? completed
764
+ : publishIncremental(completed, state, steps);
765
+ }
766
+ function stepTarget(step) {
767
+ return step.request.kind === 'compile'
768
+ ? step.request.target
769
+ : step.request.linked;
770
+ }
771
+ /**
772
+ * Inputs supplied from outside this schedule. A normal predecessor target may
773
+ * feed its successor, but source/reference/link operands supplied by the user
774
+ * remain protected even when they collide with some scheduled output.
775
+ */
776
+ function externalInputPaths(steps) {
777
+ const produced = new Set();
778
+ const external = new Set();
779
+ for (const step of steps) {
780
+ if (step.request.kind === 'compile') {
781
+ const source = resolve(step.request.source);
782
+ if (!produced.has(source))
783
+ external.add(source);
784
+ for (const reference of step.request.references ?? []) {
785
+ external.add(resolve(reference));
786
+ }
787
+ }
788
+ else {
789
+ for (const object of step.request.objects) {
790
+ const path = resolve(object);
791
+ if (!produced.has(path))
792
+ external.add(path);
793
+ }
794
+ external.add(resolve(step.request.linkTarget));
795
+ }
796
+ produced.add(resolve(stepTarget(step)));
797
+ }
798
+ return [...external];
799
+ }
800
+ async function loadIncremental(run) {
801
+ const sourceBytes = await readFile(run.source);
802
+ const active = await loadBuildHistory(run.artDir);
803
+ const sourceLocator = encodeLocator(run.artDir, run.source);
804
+ const history = !run.rebuild &&
805
+ active?.manifest.pipeline === run.pipelineName &&
806
+ active.manifest.source.path === sourceLocator
807
+ ? active
808
+ : null;
809
+ return {
810
+ ...run,
811
+ active: active !== null,
812
+ history,
813
+ sourceBytes,
814
+ invalidated: false,
815
+ executed: 0,
816
+ accepted: [],
817
+ };
818
+ }
819
+ async function stepIdentity(step, state, index, declared) {
820
+ if (declared === null)
821
+ return null;
822
+ try {
823
+ if (step.request.kind === 'compile') {
824
+ const chainedInput = index === 0 ? state.sourceBytes : await readFile(step.request.source);
825
+ const roots = [
826
+ step.request.definitionPath,
827
+ ...(step.request.references ?? []),
828
+ ];
829
+ const rootHashes = await Promise.all(roots.map(async (path) => hashBytes(await readFile(path))));
830
+ return {
831
+ chainedInput,
832
+ inputs: [
833
+ hashBytes(chainedInput),
834
+ ...rootHashes,
835
+ ...(await Promise.all(declared.map(async (path) => hashBytes(await readFile(path))))),
836
+ ],
837
+ };
838
+ }
839
+ const inputs = [];
840
+ for (const object of step.request.objects) {
841
+ const path = resolve(object);
842
+ inputs.push(framedHash('object', encodeLocator(state.artDir, path), hashBytes(await readFile(path))));
843
+ }
844
+ inputs.push(hashBytes(await readFile(step.request.definitionPath)));
845
+ inputs.push(...(await Promise.all(declared.map(async (path) => hashBytes(await readFile(path))))));
846
+ const linkTarget = resolve(step.request.linkTarget);
847
+ const targetInfo = await stat(linkTarget);
848
+ let targetKind;
849
+ let targetIdentity;
850
+ if (targetInfo.isFile()) {
851
+ targetKind = 'file';
852
+ targetIdentity = hashBytes(await readFile(linkTarget));
853
+ }
854
+ else if (targetInfo.isDirectory()) {
855
+ targetKind = 'directory';
856
+ const identity = await hashTree(linkTarget);
857
+ if (!isHash(identity))
858
+ throw new Error('invalid link-target tree hash');
859
+ targetIdentity = identity;
860
+ }
861
+ else {
862
+ throw new Error('link target is neither a file nor directory');
863
+ }
864
+ inputs.push(framedHash('link-target', encodeLocator(state.artDir, linkTarget), targetKind, targetIdentity));
865
+ for (const option of step.request.options) {
866
+ inputs.push(framedHash('option', option.name, option.value));
867
+ }
868
+ return { inputs };
869
+ }
870
+ catch {
871
+ return null;
872
+ }
873
+ }
874
+ async function stepDeclaredInputs(step, pipeline, pinFile) {
875
+ const roots = step.request.kind === 'compile'
876
+ ? [step.request.definitionPath, ...(step.request.references ?? [])]
877
+ : [step.request.definitionPath];
878
+ return declaredInputPaths(pipeline, pinFile, roots);
879
+ }
880
+ /** Current local semantic-input closure for definitions and references. */
881
+ async function declaredInputPaths(pipeline, pinFile, roots) {
882
+ const boundary = pinFile?.pathBoundary.path ?? '.';
883
+ const base = new Set(roots.map((path) => resolve(path)));
884
+ const seen = new Set(base);
885
+ const inputs = [];
886
+ let complete = true;
887
+ const normalize = resolve(normalizeDefinitionPath());
888
+ for (const root of roots) {
889
+ const absolute = resolve(root);
890
+ if (!absolute.toLowerCase().endsWith('.md') || absolute === normalize) {
891
+ continue;
892
+ }
893
+ const locator = relative(pipeline.dir, absolute).split(sep).join('/');
894
+ const closure = new Set();
895
+ try {
896
+ await deriveClosure(pipeline.dir, boundary, locator, (path) => closure.add(path));
897
+ }
898
+ catch {
899
+ // This derivation exists only for incremental identity and best-effort
900
+ // target protection. Pin generation/currency remain fail-closed, while an
901
+ // otherwise interpretable phase degrades to Ordinary execution.
902
+ complete = false;
903
+ }
904
+ for (const path of closure) {
905
+ const absolutePath = resolve(path);
906
+ if (seen.has(absolutePath))
907
+ continue;
908
+ seen.add(absolutePath);
909
+ inputs.push(absolutePath);
910
+ }
911
+ }
912
+ return { paths: inputs, complete };
913
+ }
914
+ async function selectStepMode(step, index, target, identity, state) {
915
+ if (state.rebuild || state.history === null || identity === null) {
916
+ return { mode: 'ordinary' };
917
+ }
918
+ const record = state.history.manifest.steps[index];
919
+ const kind = step.request.kind;
920
+ if (record === undefined ||
921
+ record.kind !== kind ||
922
+ record.name !== step.phase ||
923
+ record.target !== encodeLocator(state.artDir, resolve(target))) {
924
+ return { mode: 'ordinary' };
925
+ }
926
+ let targetBytes;
927
+ try {
928
+ targetBytes = await readFile(target);
929
+ }
930
+ catch {
931
+ return { mode: 'ordinary' };
932
+ }
933
+ if (sameHashes(record.inputs, identity.inputs)) {
934
+ return { mode: 'reuse', bytes: targetBytes };
935
+ }
936
+ if (step.request.kind !== 'compile' || identity.chainedInput === undefined) {
937
+ return { mode: 'ordinary' };
938
+ }
939
+ const prior = await verifiedInput(state.history, index, record.inputs[0]);
940
+ if (prior === null)
941
+ return { mode: 'ordinary' };
942
+ const diff = record.inputs[0] === identity.inputs[0]
943
+ ? ''
944
+ : renderInputDiff(prior.bytes, identity.chainedInput);
945
+ return { mode: 'update', priorInput: prior.path, diff };
946
+ }
947
+ function renderInputDiff(prior, current) {
948
+ try {
949
+ const decoder = new TextDecoder('utf-8', { fatal: true });
950
+ return unifiedLineDiff(decoder.decode(prior), decoder.decode(current));
951
+ }
952
+ catch {
953
+ return null;
954
+ }
955
+ }
956
+ function framedHash(tag, ...fields) {
957
+ return hashBytes(Buffer.from(JSON.stringify([tag, ...fields]), 'utf8'));
958
+ }
959
+ function sameHashes(left, right) {
960
+ return (left.length === right.length &&
961
+ left.every((hash, index) => hash === right[index]));
962
+ }
963
+ async function reusePinFailure(pinKey, pipelineDir, pinFile) {
964
+ const record = ownPin(pinFile, pinKey);
965
+ if (pinFile === undefined || pinKey === undefined || record === undefined) {
966
+ return null;
967
+ }
968
+ const verdict = await evaluatePin(pipelineDir, pinFile, pinKey, record);
969
+ return verdict.status === 'current'
970
+ ? null
971
+ : [`pin is ${verdict.status}: ${verdict.reason}`];
972
+ }
973
+ /**
974
+ * Removes any complete snapshot that could otherwise bless this target after
975
+ * executor work. Eligible runs already know their artifact directory; excluded
976
+ * forms perform only this safety invalidation and never select from or publish
977
+ * history.
978
+ */
979
+ async function invalidateForExecution(state, target, invalidatedTargetDirs) {
980
+ if (state !== undefined) {
981
+ if (state.invalidated)
982
+ return;
983
+ try {
984
+ await invalidateBuildHistory(state.artDir, state.active);
985
+ state.invalidated = true;
986
+ return;
987
+ }
988
+ catch (error) {
989
+ throw new Error(`active build history could not be invalidated: ${messageOf(error)}`, { cause: error });
990
+ }
991
+ }
992
+ const targetDir = dirname(resolve(target));
993
+ if (invalidatedTargetDirs.has(targetDir))
994
+ return;
995
+ const active = await loadBuildHistory(targetDir);
996
+ const targetLocator = encodeLocator(targetDir, resolve(target));
997
+ if (active !== null &&
998
+ active.manifest.steps.some((step) => step.target === targetLocator)) {
999
+ try {
1000
+ await invalidateBuildHistory(targetDir, true);
1001
+ }
1002
+ catch (error) {
1003
+ throw new Error(`active build history could not be invalidated: ${messageOf(error)}`, { cause: error });
1004
+ }
1005
+ invalidatedTargetDirs.add(targetDir);
1006
+ }
1007
+ }
1008
+ async function publishIncremental(result, state, steps) {
1009
+ if (!result.ok || state.executed === 0)
1010
+ return result;
1011
+ if (state.accepted.length !== steps.length ||
1012
+ state.accepted.some((entry) => entry.inputs === null || entry.output === null)) {
1013
+ return historyDiagnostic(result, 'one or more phase identities or output bytes were unavailable');
1014
+ }
1015
+ const records = [];
1016
+ try {
1017
+ for (const accepted of state.accepted) {
1018
+ const bytes = await readFile(accepted.target);
1019
+ if (hashBytes(bytes) !== accepted.output) {
1020
+ return historyDiagnostic(result, `accepted output changed before publication: ${accepted.target}`);
1021
+ }
1022
+ records.push({
1023
+ kind: accepted.step.request.kind,
1024
+ name: accepted.step.phase,
1025
+ target: accepted.target,
1026
+ inputs: accepted.inputs,
1027
+ output: accepted.output,
1028
+ bytes,
1029
+ });
1030
+ }
1031
+ await recordBuild({
1032
+ artDir: state.artDir,
1033
+ pipeline: state.pipelineName,
1034
+ sourcePath: state.source,
1035
+ sourceBytes: state.sourceBytes,
1036
+ steps: records,
1037
+ });
1038
+ return result;
1039
+ }
1040
+ catch (error) {
1041
+ return historyDiagnostic(result, messageOf(error));
1042
+ }
1043
+ }
1044
+ function historyDiagnostic(result, reason) {
1045
+ return {
1046
+ ...result,
1047
+ diagnostics: [
1048
+ ...result.diagnostics,
1049
+ `slc: build history not recorded: ${reason}`,
1050
+ ],
1051
+ };
472
1052
  }
473
1053
  /**
474
1054
  * Selects a phase's execution strategy from the pin index (PHEXEC-27; DR-005,
@@ -476,12 +1056,12 @@ async function executeSteps(steps, pipeline, deps) {
476
1056
  * artifact, and a stale or malformed pin fails closed and is never silently
477
1057
  * interpreted.
478
1058
  */
479
- async function selectExecutor(phase, pipelineDir, pinFile, deps) {
480
- const record = pinFile?.pins[phase];
481
- if (pinFile === undefined || record === undefined) {
1059
+ async function selectExecutor(phase, pinKey, pipelineDir, pinFile, deps) {
1060
+ const record = ownPin(pinFile, pinKey);
1061
+ if (pinFile === undefined || pinKey === undefined || record === undefined) {
482
1062
  return { kind: 'run', executor: deps.executor };
483
1063
  }
484
- const verdict = await evaluatePin(pipelineDir, pinFile, phase, record);
1064
+ const verdict = await evaluatePin(pipelineDir, pinFile, pinKey, record);
485
1065
  if (verdict.status === 'current') {
486
1066
  if (deps.compiled === undefined) {
487
1067
  return {
@@ -493,7 +1073,7 @@ async function selectExecutor(phase, pipelineDir, pinFile, deps) {
493
1073
  }
494
1074
  return {
495
1075
  kind: 'run',
496
- executor: deps.compiled({ phase, pipelineDir, record }),
1076
+ executor: deps.compiled({ phase: pinKey, pipelineDir, record }),
497
1077
  };
498
1078
  }
499
1079
  return {
@@ -501,6 +1081,13 @@ async function selectExecutor(phase, pipelineDir, pinFile, deps) {
501
1081
  reasons: [`pin is ${verdict.status}: ${verdict.reason}`],
502
1082
  };
503
1083
  }
1084
+ function ownPin(pinFile, pinKey) {
1085
+ return pinFile !== undefined &&
1086
+ pinKey !== undefined &&
1087
+ Object.hasOwn(pinFile.pins, pinKey)
1088
+ ? pinFile.pins[pinKey]
1089
+ : undefined;
1090
+ }
504
1091
  function phaseDefinition(pipeline, name) {
505
1092
  return join(pipeline.dir, `${name}.md`);
506
1093
  }
@@ -526,7 +1113,4 @@ async function revalidateChain(dir) {
526
1113
  function failure(message) {
527
1114
  return { ok: false, outputs: [], diagnostics: [message] };
528
1115
  }
529
- function messageOf(error) {
530
- return error instanceof Error ? error.message : String(error);
531
- }
532
1116
  //# sourceMappingURL=runner.js.map