doctrina-cli 0.14.0 → 0.15.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.
@@ -25,7 +25,10 @@ const STARTER = {
25
25
  "doctrina verify runs each check below, in order, through your shell. " +
26
26
  "Replace the example with your real typecheck/test/build commands. A " +
27
27
  "non-zero exit fails the gate. This file is the project's executable " +
28
- "definition of 'the code actually works'.",
28
+ "definition of 'the code actually works'. A check may add an \"expect\" " +
29
+ "block — {\"fail_if_output_matches\": \"<regex>\"} and/or " +
30
+ "{\"require_output_matches\": \"<regex>\"} — so that a run which exits 0 " +
31
+ "having executed NOTHING fails the gate instead of passing it.",
29
32
  checks: [
30
33
  { name: "example", run: "exit 1" },
31
34
  ],
@@ -99,6 +102,15 @@ export async function run(_positional, flags) {
99
102
  console.error(c.red("error:") + ` every check in ${CONFIG_REL} needs a non-empty "run" string (or "type": "manual")`);
100
103
  return 1;
101
104
  }
105
+ // An uncompilable expectation must fail LOUDLY at config time. Skipping
106
+ // it would leave a check that looks fail-closed and is not — the
107
+ // failure mode this whole feature exists to remove.
108
+ try {
109
+ parseExpectation(ch);
110
+ } catch (err) {
111
+ console.error(c.red("error:") + ` check "${ch.name ?? "?"}" declares an "expect" pattern that is not a valid regular expression: ${err.message}`);
112
+ return EXIT.USAGE;
113
+ }
102
114
  }
103
115
  if (checks.length === 0) {
104
116
  console.error(c.red("error:") + ` ${CONFIG_REL} declares zero checks — a verify gate with no checks proves nothing`);
@@ -141,6 +153,15 @@ export async function run(_positional, flags) {
141
153
  checks.forEach((ch, i) => {
142
154
  const what = ch.type === "manual" ? c.gray(`manual${ch.rubric ? " — " + ch.rubric : ""}`) : c.gray(ch.run);
143
155
  console.log(` ${i + 1}. ${c.cyan((ch.name ?? "check").padEnd(16))} ${what}`);
156
+ // A fail-closed check must be visible as one: a reader auditing the
157
+ // gate needs to see which checks judge their own output.
158
+ const exp = parseExpectation(ch);
159
+ if (exp) {
160
+ const parts = [];
161
+ if (exp.failIfSource) parts.push(`fail if output matches /${exp.failIfSource}/`);
162
+ if (exp.requireSource) parts.push(`require output matches /${exp.requireSource}/`);
163
+ console.log(` ${" ".repeat(16)} ${c.gray(parts.join("; "))}`);
164
+ }
144
165
  });
145
166
  return 0;
146
167
  }
@@ -177,14 +198,46 @@ export async function run(_positional, flags) {
177
198
  // monorepo target a sub-package, e.g. { run: "node --test", cwd:
178
199
  // "packages/api" } — the multi-service shape the contract artifact
179
200
  // also serves.
201
+ // A check that declares OUTPUT EXPECTATIONS must have its output read,
202
+ // so it runs piped and is echoed straight through — the operator still
203
+ // sees everything, just at the end of the check rather than streaming.
204
+ // Only checks that opt in pay that cost.
205
+ const expectation = parseExpectation(ch);
180
206
  const res = spawnSync(ch.run, {
181
207
  cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
182
208
  shell: true,
183
- stdio: "inherit",
209
+ stdio: expectation ? "pipe" : "inherit",
210
+ encoding: expectation ? "utf8" : undefined,
211
+ // A suite's output can be large; the default 1MB cap would truncate
212
+ // the very line an expectation is looking for.
213
+ maxBuffer: expectation ? 64 * 1024 * 1024 : undefined,
184
214
  });
185
- const ok = !res.error && res.status === 0;
215
+ if (expectation) {
216
+ if (res.stdout) process.stdout.write(res.stdout);
217
+ if (res.stderr) process.stderr.write(res.stderr);
218
+ }
219
+
220
+ let ok = !res.error && res.status === 0;
221
+ /** @type {string | null} */
222
+ let expectationNote = null;
223
+ if (ok && expectation) {
224
+ // THE FAIL-CLOSED RULE. A green exit code answers "did the runner
225
+ // crash", never "did the runner do anything". A suite whose filter
226
+ // matched nothing prints "0 scenarios" and exits 0, and every gate
227
+ // above this one calls that a pass. An expectation makes the run's
228
+ // own output part of the verdict.
229
+ const output = `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
230
+ const verdict = judgeOutput(expectation, output);
231
+ if (!verdict.ok) {
232
+ ok = false;
233
+ expectationNote = verdict.reason;
234
+ }
235
+ }
236
+
186
237
  if (res.error) {
187
238
  console.log(c.red(`✗ ${name}`) + c.gray(` — could not run: ${res.error.message}`));
239
+ } else if (expectationNote) {
240
+ console.log(c.red(`✗ ${name}`) + c.gray(` — exit 0, but ${expectationNote}`));
188
241
  } else {
189
242
  console.log(ok ? c.green(`✓ ${name}`) : c.red(`✗ ${name} (exit ${res.status})`));
190
243
  }
@@ -213,6 +266,47 @@ export async function run(_positional, flags) {
213
266
  return 1;
214
267
  }
215
268
 
269
+ // An `expect` block, normalised and compiled, or null when the check does
270
+ // not declare one. Runner-agnostic by construction: Doctrina supplies no
271
+ // patterns of its own and knows nothing about what the output means — the
272
+ // project declares the line that proves its run was real.
273
+ //
274
+ // "expect": {
275
+ // "fail_if_output_matches": "0 scenarios|0 features",
276
+ // "require_output_matches": "\\d+ scenarios? passed"
277
+ // }
278
+ export function parseExpectation(check) {
279
+ const raw = check?.expect;
280
+ if (!raw || typeof raw !== "object") return null;
281
+ const out = {};
282
+ if (typeof raw.fail_if_output_matches === "string" && raw.fail_if_output_matches !== "") {
283
+ out.failIf = new RegExp(raw.fail_if_output_matches, "im");
284
+ out.failIfSource = raw.fail_if_output_matches;
285
+ }
286
+ if (typeof raw.require_output_matches === "string" && raw.require_output_matches !== "") {
287
+ out.requireMatch = new RegExp(raw.require_output_matches, "im");
288
+ out.requireSource = raw.require_output_matches;
289
+ }
290
+ return out.failIf || out.requireMatch ? out : null;
291
+ }
292
+
293
+ /** @returns {{ok: boolean, reason?: string}} */
294
+ export function judgeOutput(expectation, output) {
295
+ if (expectation.failIf && expectation.failIf.test(output)) {
296
+ return {
297
+ ok: false,
298
+ reason: `its output matched /${expectation.failIfSource}/ — the check declares that shape a failure (a run that executed nothing exits 0 too)`,
299
+ };
300
+ }
301
+ if (expectation.requireMatch && !expectation.requireMatch.test(output)) {
302
+ return {
303
+ ok: false,
304
+ reason: `its output never matched /${expectation.requireSource}/ — the check requires that proof of a real run`,
305
+ };
306
+ }
307
+ return { ok: true };
308
+ }
309
+
216
310
  // Static reproducibility lint: walk the project's package.json files and
217
311
  // report the two "works on my machine" footguns the review hit — a package
218
312
  // whose entry points live in a build-output dir with nothing that builds it
@@ -370,6 +464,23 @@ A manual check passes when it has been signed off and is otherwise reported
370
464
  as "pending" — non-blocking by default, failing only under --strict (so CI
371
465
  can require the sign-off). Sign-offs are stored in ${SIGNOFF_REL}.
372
466
 
467
+ OUTPUT EXPECTATIONS — fail-closed on a run that did nothing. An exit code
468
+ answers "did the runner crash", never "did the runner run anything": a suite
469
+ whose filter matched no cases prints "0 scenarios" and exits 0, and every
470
+ gate calls it a pass. Add an "expect" block to make the output part of the
471
+ verdict:
472
+
473
+ { "name": "e2e", "run": "behave --tags @smoke",
474
+ "expect": { "fail_if_output_matches": "0 scenarios",
475
+ "require_output_matches": "\\\\d+ scenarios? passed" } }
476
+
477
+ Doctrina supplies no patterns of its own and knows nothing about what the
478
+ output means — the project declares the line that proves its run was real,
479
+ so this works for any runner in any language. A check with an "expect"
480
+ block runs piped (its output is echoed through when it finishes, rather
481
+ than streaming); every other check still streams. An "expect" pattern that
482
+ is not a valid regular expression fails at config time, not silently.
483
+
373
484
  Flags:
374
485
  --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
375
486
  --list Print the configured checks without running them
@@ -9,8 +9,9 @@ import * as idx from "../lib/index-json.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { assessBrief } from "../lib/clarity.js";
11
11
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
12
- import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
13
  import { changeNew } from "./change.js";
14
+ import { classify } from "./triage.js";
14
15
 
15
16
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
16
17
  // prompt ("add login") becomes a fully scaffolded change plus a playbook
@@ -72,6 +73,31 @@ export async function run(positional, flags) {
72
73
  }
73
74
  }
74
75
 
76
+ // LANE CHECK (change 0029). `work` was the default answer to every
77
+ // request, so a broken workflow or a suite that ran nothing became a
78
+ // change with a proposal, tasks and a spec delta — ceremony spent on a
79
+ // diagnosis, and a close that attested to nothing. A runtime-shaped
80
+ // prompt is stopped here, once, with the diagnosis path named. It is a
81
+ // deterministic term match and it can be wrong, so --force proceeds and
82
+ // the message says so.
83
+ if (!fromDiff && !chore && !flagBool(flags, "force", false)) {
84
+ const verdict = classify(prompt);
85
+ if (verdict.lane === "runtime" && verdict.confident) {
86
+ console.error(c.yellow("hold:") + " this reads as a RUNTIME problem, not a change of behaviour");
87
+ console.error(c.gray(`signals: ${[...new Set(verdict.scores.runtime.hits)].slice(0, 6).join(", ")}`));
88
+ console.error("");
89
+ console.error("Nothing here says the spec is wrong — diagnose the running system first:");
90
+ console.error(` ${c.cyan(`doctrina triage "${prompt.length > 60 ? `${prompt.slice(0, 59)}…` : prompt}"`)}`);
91
+ console.error("");
92
+ console.error(c.gray("If the requirement really is missing, open the change anyway:"));
93
+ console.error(c.gray(" doctrina work --force \"<prompt>\" · or --chore for wiring already specified"));
94
+ // PRECONDITION: the work may be perfectly valid; this project has
95
+ // simply not been diagnosed yet. A gate code (1) would read as
96
+ // "the prompt is bad" and invite rewording it forever.
97
+ return EXIT.PRECONDITION;
98
+ }
99
+ }
100
+
75
101
  const pinned = flagString(flags, "capability");
76
102
  if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
77
103
  console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
@@ -545,6 +571,14 @@ Options:
545
571
  prompt needed) and print a code-first playbook (F8)
546
572
  --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
547
573
  playbook that skips the spec-delta steps (F9)
548
- --force Open a new change even when one is open, and overwrite
549
- an existing change folder
574
+ --force Open a new change even when one is open, overwrite an
575
+ existing change folder, and proceed past the lane hold
576
+ below
577
+
578
+ A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
579
+ that executed nothing — is HELD before anything is scaffolded, and pointed
580
+ at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
581
+ The classifier is deterministic term matching and can be wrong; --force
582
+ opens the change anyway, and --chore is the lane for wiring the spec
583
+ already covers.
550
584
  `;
package/src/index.js CHANGED
@@ -47,6 +47,7 @@ import * as completion from "./commands/completion.js";
47
47
  import * as intent from "./commands/intent.js";
48
48
  import * as upgrade from "./commands/upgrade.js";
49
49
  import * as adapter from "./commands/adapter.js";
50
+ import * as triage from "./commands/triage.js";
50
51
 
51
52
  const COMMANDS = {
52
53
  init, spec, change, decision, validate, hooks, analyze, clarify,
@@ -54,7 +55,7 @@ const COMMANDS = {
54
55
  intake, work, coverage, verify, contract, trace,
55
56
  status, close, review, watch, why, constitution,
56
57
  prime, handoff, show, doctor, report, completion,
57
- intent, upgrade, adapter,
58
+ intent, upgrade, adapter, triage,
58
59
  };
59
60
 
60
61
  const TOP_HELP = `
@@ -12,7 +12,7 @@
12
12
  // Keep this sorted by the workflow order used in `--help`, not alphabetically.
13
13
  export const COMMAND_NAMES = [
14
14
  // bootstrap / day-to-day
15
- "init", "intake", "work",
15
+ "init", "intake", "triage", "work",
16
16
  // authoring
17
17
  "spec", "change", "contract", "decision", "skill", "intent", "adapter",
18
18
  // read / orient
@@ -36,6 +36,7 @@ export const COMMAND_NAMES = [
36
36
  export const OPERATIONS = [
37
37
  ["init", "Scaffold AGENTS.md and .doctrina/ in the current directory"],
38
38
  ["intake", "Store the full project description; print the bootstrap playbook"],
39
+ ["triage", "Classify a request into a lane, and check the runtime surface"],
39
40
  ["work", "Brief prompt -> scaffolded change + guided work playbook"],
40
41
  ["spec new", "Create a new capability spec (--bug for bug-shape)"],
41
42
  ["spec list", "List specs with version, status, and size"],
@@ -127,7 +128,8 @@ export const COMMAND_META = {
127
128
  constitution: { moment: "Orient", when: "you need the standing rules before deciding something", purpose: "accepted ADRs and product non-goals" },
128
129
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
129
130
 
130
- work: { moment: "Change", when: "any request arrives that changes behaviour", purpose: "scaffold a change and print the playbook to execute" },
131
+ triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
132
+ work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
131
133
  spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
132
134
  change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "new / apply / archive / check / tick / diff / abandon" },
133
135
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
@@ -232,9 +234,12 @@ export function surfaceMarkdown() {
232
234
  const lines = [
233
235
  "## Doctrina command surface (generated — reach for these, don't hand-author)",
234
236
  "",
237
+ // Two lines, not three. This block is always-loaded context inside a
238
+ // file with a hard 150-line budget, so its own preamble competes with
239
+ // the commands it introduces — and a command an agent never discovers
240
+ // costs more than a sentence of prose ever saves.
235
241
  "Every command, with the moment you reach for it. The CLI scaffolds from",
236
- "canonical templates and keeps `index.json` in sync — prefer it over",
237
- "hand-authoring. Flags and detail: `doctrina <command> --help`.",
242
+ "canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.",
238
243
  ];
239
244
  for (const moment of MOMENTS) {
240
245
  const cmds = COMMAND_NAMES.filter((n) => COMMAND_META[n]?.moment === moment);
@@ -397,6 +402,13 @@ export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
397
402
  // introduced it. Only entries that alter what an agent should DO belong
398
403
  // here — a bug fix nobody's behaviour depends on does not.
399
404
  export const AGENT_CHANGELOG = {
405
+ "0.15.0": [
406
+ "`doctrina triage \"<prompt>\"` — classify a request as PRODUCT / RUNTIME / CHORE BEFORE scaffolding. `work` now holds a runtime-shaped prompt with exit 3 and points here; `--force` opens the change anyway.",
407
+ "`contract check` now holds the declared wiring to the implementation: a `vars`/`secrets` variable no workflow exports, a default an empty CI value never triggers, an unvalidated enum, a selector matching zero targets (RT01-RT05).",
408
+ "A `verify` check may declare `expect`, so a run that exits 0 having executed NOTHING fails the gate; an `[orchestration]` acceptance criterion is proven by citing such a guarded check (`verify:<name>`), not by a citation that merely resolves.",
409
+ "A spec may declare an ordered `### Pipeline`; `validate` refuses a step that requires what a later step produces. `validate --runtime` adds the runtime gate.",
410
+ "`skill suggest --from-error <text|file>` drafts a skill from the failure on screen; `context --for` now ranks skills by their trigger.",
411
+ ],
400
412
  "0.14.0": [
401
413
  "`doctrina adapter add|remove|list` — add an agent without re-scaffolding; `init --force` no longer overwrites authored AGENTS.md/product.md.",
402
414
  "`doctrina change apply` now refuses what `analyze` refuses; `change check` previews a close, `change tick` checks boxes in bulk.",
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+ import { getSection } from "./doc-model.js";
3
+
4
+ // ORDERED requirements: what EARS cannot say.
5
+ //
6
+ // EARS event-driven requirements are individually true and mutually
7
+ // unordered. "When the run finishes, the system shall append a summary" and
8
+ // "When the analysis completes, the system shall publish the dashboard" both
9
+ // pass every gate, and neither says the dashboard needs the analysis to have
10
+ // run first. So "the dashboard includes this run's analysis" gets read as
11
+ // "call the renderer at the end" without anyone checking whether the thing
12
+ // it renders exists yet — and the failure is a published dashboard that is
13
+ // silently one run stale, which no structural check can see.
14
+ //
15
+ // A Pipeline block states the order and the artifact each step hands on:
16
+ //
17
+ // ### Pipeline
18
+ //
19
+ // 1. run-suite — produces `reports/results.json`
20
+ // 2. analyse — requires `reports/results.json`, produces `reports/analysis.md`
21
+ // 3. publish — requires `reports/analysis.md`
22
+ //
23
+ // The invariant is the one an ordered list cannot enforce on its own: a step
24
+ // may only require what an EARLIER step produced. Requiring something a
25
+ // later step produces is the inversion; requiring something no step produces
26
+ // is the missing producer. Both are errors, and both are exactly the bug
27
+ // that ships as "green, and a run behind".
28
+ //
29
+ // Artifacts that genuinely come from outside are marked `(external)`.
30
+
31
+ /**
32
+ * @typedef {object} PipelineStep
33
+ * @property {number} number The declared ordinal.
34
+ * @property {string} name
35
+ * @property {string[]} requires
36
+ * @property {string[]} produces
37
+ * @property {string[]} external Requirements declared as coming from outside.
38
+ * @property {number} line 1-based line within the spec.
39
+ */
40
+
41
+ /**
42
+ * Parse the `### Pipeline` block of a spec. Returns null when the spec
43
+ * declares none — the block is opt-in, so every spec written before it
44
+ * existed parses to null and is never checked.
45
+ *
46
+ * @returns {PipelineStep[] | null}
47
+ */
48
+ export function parsePipeline(text) {
49
+ const section = getSection(text, "Pipeline", { level: 3 });
50
+ if (!section || !section.trim()) return null;
51
+
52
+ const all = String(text).split(/\r?\n/);
53
+ const offset = all.findIndex((l) => /^###\s+Pipeline\s*$/.test(l));
54
+
55
+ const steps = [];
56
+ const lines = section.split(/\r?\n/);
57
+ for (let i = 0; i < lines.length; i++) {
58
+ const m = lines[i].match(/^\s*(\d+)\.\s+(.*\S)\s*$/);
59
+ if (!m) continue;
60
+ const number = Number.parseInt(m[1], 10);
61
+ const body = m[2];
62
+
63
+ // The step name is everything up to the first clause separator.
64
+ const name = body.split(/\s+[—-]\s+|\s*,\s*(?=requires|produces)/)[0].trim();
65
+
66
+ steps.push({
67
+ number,
68
+ name,
69
+ requires: clause(body, "requires").filter((a) => !a.external).map((a) => a.name),
70
+ external: clause(body, "requires").filter((a) => a.external).map((a) => a.name),
71
+ produces: clause(body, "produces").map((a) => a.name),
72
+ line: offset >= 0 ? offset + 1 + i + 1 : i + 1,
73
+ });
74
+ }
75
+ return steps;
76
+ }
77
+
78
+ // The artifacts named by one clause: `requires \`a\`, \`b\` (external)`.
79
+ // Backticks are how a spec already marks a path, so they are the anchor.
80
+ function clause(body, keyword) {
81
+ const re = new RegExp(`${keyword}\\s+([^;]*?)(?=(?:\\brequires\\b|\\bproduces\\b|$))`, "i");
82
+ const m = body.match(re);
83
+ if (!m) return [];
84
+ const out = [];
85
+ for (const artifact of m[1].matchAll(/`([^`]+)`(\s*\(external\))?/g)) {
86
+ out.push({ name: artifact[1].trim(), external: Boolean(artifact[2]) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Check the ordering invariant.
93
+ * @param {PipelineStep[]} steps
94
+ * @returns {Array<{code: string, level: "error"|"warn", message: string, remedy: string, line: number}>}
95
+ */
96
+ export function checkPipeline(steps) {
97
+ /** @type {Array<{code: string, level: "error"|"warn", message: string, remedy: string, line: number}>} */
98
+ const findings = [];
99
+ if (!steps || steps.length === 0) return findings;
100
+
101
+ // Numbering first: an out-of-order list is not a pipeline, and every
102
+ // check below reads the declared order as the real one.
103
+ for (let i = 0; i < steps.length; i++) {
104
+ if (steps[i].number !== i + 1) {
105
+ findings.push({
106
+ code: "PL01",
107
+ level: "error",
108
+ message: `pipeline step ${i + 1} is numbered ${steps[i].number} ("${steps[i].name}") — the order a pipeline declares must be the order it reads in`,
109
+ remedy: "renumber the Pipeline steps 1..n in execution order",
110
+ line: steps[i].line,
111
+ });
112
+ }
113
+ }
114
+
115
+ // Everything produced at or before each index, which is what "earlier"
116
+ // means. Built as we walk, so a step can never satisfy itself.
117
+ const producedBefore = new Set();
118
+ const producedBy = new Map();
119
+ for (const step of steps) {
120
+ for (const artifact of step.produces) {
121
+ if (!producedBy.has(artifact)) producedBy.set(artifact, step);
122
+ }
123
+ }
124
+
125
+ for (const step of steps) {
126
+ for (const artifact of step.requires) {
127
+ if (producedBefore.has(artifact)) continue;
128
+
129
+ const producer = producedBy.get(artifact);
130
+ if (producer && producer.number > step.number) {
131
+ // The inversion: the consumer runs first and reads last run's file.
132
+ findings.push({
133
+ code: "PL02",
134
+ level: "error",
135
+ message: `step ${step.number} ("${step.name}") requires \`${artifact}\`, which step ${producer.number} ("${producer.name}") produces LATER — this step can only ever read the previous run's copy`,
136
+ remedy: `move "${producer.name}" before "${step.name}", or make the dependency explicit and drop the requirement`,
137
+ line: step.line,
138
+ });
139
+ } else if (producer && producer.number === step.number) {
140
+ findings.push({
141
+ code: "PL02",
142
+ level: "error",
143
+ message: `step ${step.number} ("${step.name}") both requires and produces \`${artifact}\``,
144
+ remedy: `split the step, or mark the input \`${artifact}\` (external) if it comes from outside the pipeline`,
145
+ line: step.line,
146
+ });
147
+ } else {
148
+ findings.push({
149
+ code: "PL03",
150
+ level: "error",
151
+ message: `step ${step.number} ("${step.name}") requires \`${artifact}\`, which no step produces — the step runs against a file nothing in this pipeline creates`,
152
+ remedy: `add the producing step, or mark it \`${artifact}\` (external) to declare it comes from outside`,
153
+ line: step.line,
154
+ });
155
+ }
156
+ }
157
+ for (const artifact of step.produces) producedBefore.add(artifact);
158
+ }
159
+
160
+ return findings;
161
+ }