doctrina-cli 0.15.0 → 0.15.1

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > CLI for the Doctrina framework. The package is published as
4
4
  > `doctrina-cli`; the executable it installs is `doctrina`.
5
5
 
6
- **Status:** v0.15.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.15.1 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -146,7 +146,7 @@ export function collectAnalysis(projectRoot, changeDir) {
146
146
  // untouched `spec new` scaffold is the canonical flow (spec new →
147
147
  // ADDED delta), so it passes as a replacement; only real content fails.
148
148
  if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
149
- results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`));
149
+ results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, "pre-apply"));
150
150
  } else if (exists(targetSpec)) {
151
151
  results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
152
152
  } else {
@@ -154,13 +154,13 @@ export function collectAnalysis(projectRoot, changeDir) {
154
154
  }
155
155
  } else if (op === "MODIFIED") {
156
156
  if (!exists(targetSpec)) {
157
- results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`));
157
+ results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, "pre-apply"));
158
158
  } else {
159
159
  results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
160
160
  }
161
161
  } else {
162
162
  if (!exists(targetSpec)) {
163
- results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`));
163
+ results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, "pre-apply"));
164
164
  } else {
165
165
  results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
166
166
  }
@@ -229,8 +229,21 @@ function escapeRe(s) {
229
229
  function pass(msg) {
230
230
  return { kind: "pass", line: c.green("✓ ") + msg };
231
231
  }
232
- function fail(msg) {
233
- return { kind: "fail", line: c.red("✗ ") + msg };
232
+ // `scope` says WHEN a failure is a real answer.
233
+ //
234
+ // Most of these questions are true at every point in a change's life: a
235
+ // proposal with an unwritten `## What` is hollow before an apply and just as
236
+ // hollow after one. A few are only meaningful BEFORE the apply — "an ADDED
237
+ // delta's target must not already hold real content" is proof of a problem
238
+ // beforehand and proof the apply WORKED afterwards.
239
+ //
240
+ // The gate map used to exclude the whole structure gate from `archive` to
241
+ // dodge that handful, which also dropped the hollow-proposal check — the
242
+ // exact defect this repository's own history records as having "shipped six
243
+ // hollow proposals past every gate". Tagging the pre-apply ones lets
244
+ // `archive` keep the rest.
245
+ function fail(msg, scope = "always") {
246
+ return { kind: "fail", scope, line: c.red("✗ ") + msg };
234
247
  }
235
248
  function info(msg) {
236
249
  return { kind: "info", line: c.gray("- ") + msg };
@@ -146,10 +146,10 @@ export async function run(_positional, _flags) {
146
146
  row("fail", "runtime", `${errs.length} declaration${errs.length === 1 ? "" : "s"} do not hold`, "doctrina triage (each finding names its own fix)");
147
147
  for (const e of errs.slice(0, 3)) console.log(` ${" ".repeat(16)} ${c.red("·")} ${e.code} ${e.message}`);
148
148
  } else if (warns > 0) {
149
- row("warn", "runtime", `${runtime.declared} declared rows hold; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
149
+ row("warn", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}; ${warns} advisory finding${warns === 1 ? "" : "s"}`, "doctrina triage");
150
150
  warningsTotal += 1;
151
151
  } else {
152
- row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} hold`);
152
+ row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}`);
153
153
  }
154
154
  }
155
155
 
@@ -60,6 +60,16 @@ const CHORE_SIGNALS = [
60
60
  const PRODUCT_SIGNALS = [
61
61
  [3, /\b(add|introduce|support|allow|enable|implement|build)\b/i],
62
62
  [3, /\b(should|shall|must|requirement|acceptance|criteri)\b/i],
63
+ // AUTHORING VERBS. Both lanes above match domain NOUNS, which turned out
64
+ // to misread work ON the machinery as an incident IN it: "declare the
65
+ // release workflow wiring in a contract" scored RUNTIME on "workflow" and
66
+ // "wiring", and "so an intentional rename stops warning" scored CHORE on
67
+ // "rename" — a noun, not the act. Neither prompt was a diagnosis; both
68
+ // were someone sitting down to write an artifact.
69
+ //
70
+ // These are the verbs of authoring. A prompt whose verb is "declare" or
71
+ // "document" is describing work on the tree, whatever nouns surround it.
72
+ [3, /\b(declare|declaring|document|documenting|record|recording|specify|specifying|define|defining)\b/i],
63
73
  [2, /\b(new (feature|capability|command|endpoint|screen|flow))\b/i],
64
74
  [2, /\b(users? (can|should|want)|so that|in order to)\b/i],
65
75
  [1, /\b(change|behaviour|behavior|spec)\b/i],
@@ -202,7 +212,7 @@ function renderRuntime(runtime, envFindings, withEnv, hadLane) {
202
212
 
203
213
  const all = [...runtime.findings, ...envFindings];
204
214
  if (all.length === 0) {
205
- console.log(c.green(" ok") + ` ${runtime.declared} declared row${runtime.declared === 1 ? "" : "s"} across ${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"} hold` + (withEnv ? " (including local .env)" : ""));
215
+ console.log(c.green(" ok") + ` ${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"} across ${runtime.contracts} contract${runtime.contracts === 1 ? "" : "s"}` + (withEnv ? " (including local .env)" : ""));
206
216
  console.log("");
207
217
  return;
208
218
  }
@@ -2,7 +2,7 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
7
7
  import { flagBool, flagString } from "../lib/args.js";
8
8
  import { today } from "../lib/dates.js";
@@ -198,24 +198,20 @@ export async function run(_positional, flags) {
198
198
  // monorepo target a sub-package, e.g. { run: "node --test", cwd:
199
199
  // "packages/api" } — the multi-service shape the contract artifact
200
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.
201
+ // A check that declares OUTPUT EXPECTATIONS must have its output READ,
202
+ // which is not the same as withholding it. The first cut ran such a
203
+ // check piped and echoed the whole buffer at the end — forty seconds of
204
+ // blank terminal on this project's own suite, and worse on anything
205
+ // slower. Reading and showing are independent: the tee streams every
206
+ // chunk as it arrives AND accumulates it for the match.
205
207
  const expectation = parseExpectation(ch);
206
- const res = spawnSync(ch.run, {
207
- cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
208
- shell: true,
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,
214
- });
215
- if (expectation) {
216
- if (res.stdout) process.stdout.write(res.stdout);
217
- if (res.stderr) process.stderr.write(res.stderr);
218
- }
208
+ const res = expectation
209
+ ? await runTee(ch.run, ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot)
210
+ : spawnSync(ch.run, {
211
+ cwd: ch.cwd ? path.resolve(projectRoot, ch.cwd) : projectRoot,
212
+ shell: true,
213
+ stdio: "inherit",
214
+ });
219
215
 
220
216
  let ok = !res.error && res.status === 0;
221
217
  /** @type {string | null} */
@@ -226,7 +222,9 @@ export async function run(_positional, flags) {
226
222
  // matched nothing prints "0 scenarios" and exits 0, and every gate
227
223
  // above this one calls that a pass. An expectation makes the run's
228
224
  // own output part of the verdict.
229
- const output = `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
225
+ // The tee already interleaves both streams in the order they arrived,
226
+ // which is the order a human read them in.
227
+ const output = res.output ?? `${res.stdout ?? ""}\n${res.stderr ?? ""}`;
230
228
  const verdict = judgeOutput(expectation, output);
231
229
  if (!verdict.ok) {
232
230
  ok = false;
@@ -266,6 +264,47 @@ export async function run(_positional, flags) {
266
264
  return 1;
267
265
  }
268
266
 
267
+ // Run a command, streaming its output to the terminal AS IT ARRIVES while
268
+ // accumulating a copy for an expectation to match against.
269
+ //
270
+ // `spawnSync` cannot do this: it either inherits the streams (visible,
271
+ // unreadable) or pipes them (readable, invisible until it exits). The async
272
+ // spawn gives both, at the cost of this function existing.
273
+ //
274
+ // Both streams accumulate into ONE buffer in arrival order, so a pattern
275
+ // spanning them matches the same text the operator saw, and stderr is
276
+ // echoed to stderr so redirection still behaves.
277
+ //
278
+ // @returns {Promise<{status: number|null, output: string, error?: Error}>}
279
+ function runTee(command, cwd) {
280
+ return new Promise((resolve) => {
281
+ let child;
282
+ try {
283
+ child = spawn(command, { cwd, shell: true, stdio: ["inherit", "pipe", "pipe"] });
284
+ } catch (err) {
285
+ resolve({ status: null, output: "", error: /** @type {Error} */ (err) });
286
+ return;
287
+ }
288
+
289
+ let output = "";
290
+ const tee = (stream, sink) => {
291
+ if (!stream) return;
292
+ stream.setEncoding("utf8");
293
+ stream.on("data", (chunk) => {
294
+ output += chunk;
295
+ sink.write(chunk);
296
+ });
297
+ };
298
+ tee(child.stdout, process.stdout);
299
+ tee(child.stderr, process.stderr);
300
+
301
+ child.on("error", (err) => resolve({ status: null, output, error: err }));
302
+ // "close" rather than "exit": it fires once the streams are drained, so
303
+ // no trailing output is dropped from the match or the terminal.
304
+ child.on("close", (status) => resolve({ status, output }));
305
+ });
306
+ }
307
+
269
308
  // An `expect` block, normalised and compiled, or null when the check does
270
309
  // not declare one. Runner-agnostic by construction: Doctrina supplies no
271
310
  // patterns of its own and knows nothing about what the output means — the
@@ -476,10 +515,10 @@ verdict:
476
515
 
477
516
  Doctrina supplies no patterns of its own and knows nothing about what the
478
517
  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.
518
+ so this works for any runner in any language. An expect-carrying check
519
+ still streams: its output is teed to the terminal as it arrives while a
520
+ copy accumulates for the match. An "expect" pattern that is not a valid
521
+ regular expression fails at config time, not silently.
483
522
 
484
523
  Flags:
485
524
  --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
@@ -402,6 +402,12 @@ export const AGENT_CHANGELOG_END = "<!-- doctrina:changed:end -->";
402
402
  // introduced it. Only entries that alter what an agent should DO belong
403
403
  // here — a bug fix nobody's behaviour depends on does not.
404
404
  export const AGENT_CHANGELOG = {
405
+ // Each entry lists only ITS OWN delta. The series carries the rest
406
+ // forward (see agentChangelogEntries), so a patch never has to restate
407
+ // the minor that introduced the commands, and never silently erases it.
408
+ "0.15.1": [
409
+ "Exporting a variable under a name that is not its source's is routine — declare it in the Wiring row's Origin cell as `<origin>:<source>` (e.g. `secrets:NPM_TOKEN`) and RT02 stays silent until either side moves.",
410
+ ],
405
411
  "0.15.0": [
406
412
  "`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
413
  "`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).",
@@ -420,9 +426,54 @@ export const AGENT_CHANGELOG = {
420
426
 
421
427
  // The block written into AGENTS.md for a given version. Empty string when
422
428
  // that version has no agent-visible changes.
429
+ // How many bullets the block may carry. Not a style preference: AGENTS.md
430
+ // is always-loaded context under a hard 150-line budget, and at the current
431
+ // template size five bullets is exactly what fits. Raising this means
432
+ // cutting elsewhere — see the `agents-md-lines` budget in the contract.
433
+ export const AGENT_CHANGELOG_MAX_BULLETS = 5;
434
+
435
+ /**
436
+ * The bullets an agent arriving at `version` needs, newest first.
437
+ *
438
+ * Keyed per version, but rendered per MINOR SERIES. The block REPLACES its
439
+ * predecessor in AGENTS.md rather than accumulating, so rendering only the
440
+ * current version's own entry meant every patch silently erased the minor
441
+ * that introduced the commands: an agent upgrading 0.14.0 -> 0.15.1 would
442
+ * read one bug fix and never learn `triage` exists. The alternative —
443
+ * making each patch author restate the whole minor by hand — worked once
444
+ * and does not scale, because the restating is invisible and the third
445
+ * patch quietly drops a line to fit the cap.
446
+ *
447
+ * So the series carries itself, newest first, and the cap truncates the
448
+ * OLDEST. A fact that must outlive its series does not belong here: it
449
+ * belongs in the surface block or the docs, which is the honest place for
450
+ * something permanent.
451
+ */
452
+ export function agentChangelogEntries(version) {
453
+ const series = String(version).split(".").slice(0, 2).join(".");
454
+ const versions = Object.keys(AGENT_CHANGELOG)
455
+ .filter((v) => v.split(".").slice(0, 2).join(".") === series && compareVersions(v, version) <= 0)
456
+ .sort((a, b) => compareVersions(b, a));
457
+ const out = [];
458
+ for (const v of versions) out.push(...(AGENT_CHANGELOG[v] ?? []));
459
+ return out.slice(0, AGENT_CHANGELOG_MAX_BULLETS);
460
+ }
461
+
462
+ // Numeric semver comparison — "0.15.10" must sort above "0.15.9", which a
463
+ // string compare gets wrong.
464
+ function compareVersions(a, b) {
465
+ const pa = String(a).split(".").map((n) => Number.parseInt(n, 10) || 0);
466
+ const pb = String(b).split(".").map((n) => Number.parseInt(n, 10) || 0);
467
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
468
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
469
+ if (d !== 0) return d;
470
+ }
471
+ return 0;
472
+ }
473
+
423
474
  export function agentChangelogMarkdown(version) {
424
- const entries = AGENT_CHANGELOG[version];
425
- if (!entries || entries.length === 0) return "";
475
+ const entries = agentChangelogEntries(version);
476
+ if (entries.length === 0) return "";
426
477
  return [
427
478
  `## What changed in ${version}`,
428
479
  "",
package/src/lib/gates.js CHANGED
@@ -34,6 +34,28 @@ export const GATES = {
34
34
  },
35
35
  },
36
36
 
37
+ // Integrity: the subset of the structural questions that are true at
38
+ // EVERY point in a change's life — the proposal says why and what, the
39
+ // tasks are real, the deltas parse. Everything `structure` asks except
40
+ // the handful that only mean something before an apply.
41
+ //
42
+ // This exists because excluding `structure` wholesale from `archive` (to
43
+ // dodge the post-apply false positive documented below) also excluded the
44
+ // hollow-proposal check, which has no such problem. A change could then be
45
+ // archived — and stamped "applied" — with `## What` still holding nothing
46
+ // but its scaffold comment, after `apply` had refused it for exactly that.
47
+ // Observed on change 0030 of this repository, one release after the
48
+ // history recorded six hollow proposals reaching the archive.
49
+ integrity: {
50
+ label: "integrity",
51
+ rerun: (id) => `doctrina analyze ${id}`,
52
+ blockers(projectRoot, changeDir) {
53
+ return collectAnalysis(projectRoot, changeDir)
54
+ .filter((r) => r.kind === "fail" && r.scope !== "pre-apply")
55
+ .map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
56
+ },
57
+ },
58
+
37
59
  // Verification: the work is claimed complete. Every checkbox in
38
60
  // tasks.md (closing steps included) and in the proposal's
39
61
  // "## Verification" section.
@@ -72,9 +94,15 @@ export const GATES = {
72
94
  // content the delta just wrote, so re-asking at archive time reports a
73
95
  // conflict that is the proof the apply worked. A gate must be asked at the
74
96
  // point its question is meaningful.
97
+ //
98
+ // `archive` therefore requires INTEGRITY — that same structural question
99
+ // set minus the pre-apply-only checks — rather than nothing structural at
100
+ // all. Excluding the whole gate to dodge three checks also excluded the
101
+ // hollow-proposal check, and a change refused by `apply` for an unwritten
102
+ // `## What` could still be archived and stamped "applied".
75
103
  export const TRANSITIONS = {
76
104
  apply: { label: "change apply", gates: ["structure"] },
77
- archive: { label: "change archive", gates: ["verification"] },
105
+ archive: { label: "change archive", gates: ["integrity", "verification"] },
78
106
  };
79
107
 
80
108
  // Evaluate a transition's gates. Returns { ok, blockers, gates } where
@@ -38,6 +38,31 @@ import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
38
38
  /** Origins whose value CI can inject as an EMPTY STRING rather than unset. */
39
39
  const INJECTABLE_ORIGINS = new Set(["vars", "secrets"]);
40
40
 
41
+ /**
42
+ * Split an Origin cell into its origin and the OPTIONAL source name it
43
+ * declares: `secrets` -> {origin: "secrets", source: null}, and
44
+ * `secrets:NPM_TOKEN` -> {origin: "secrets", source: "NPM_TOKEN"}.
45
+ *
46
+ * Exporting a variable under a name that is not the source's is routine and
47
+ * often unavoidable — npm reads its credential from `NODE_AUTH_TOKEN`, so a
48
+ * repository whose secret is `NPM_TOKEN` has no choice but to rename in the
49
+ * `env:` line. RT02 is right to flag that (rename the secret and the
50
+ * expression resolves to the empty string, so the publish fails on a blank
51
+ * credential with nothing pointing at the cause) and was wrong to make it
52
+ * unanswerable: its remedy said "record the intentional rename in the Wiring
53
+ * row" while the row had nowhere to put it. A permanent warning nobody can
54
+ * clear teaches people to ignore warnings.
55
+ */
56
+ export function parseOrigin(cell) {
57
+ const raw = String(cell ?? "").trim();
58
+ const colon = raw.indexOf(":");
59
+ if (colon < 0) return { origin: raw.toLowerCase(), source: null };
60
+ return {
61
+ origin: raw.slice(0, colon).trim().toLowerCase(),
62
+ source: raw.slice(colon + 1).trim() || null,
63
+ };
64
+ }
65
+
41
66
  // ---------------------------------------------------------------------------
42
67
  // Declaration parsing
43
68
  // ---------------------------------------------------------------------------
@@ -255,7 +280,10 @@ export function checkWiring(projectRoot, decl) {
255
280
  const cache = new Map();
256
281
 
257
282
  for (const row of decl.wiring) {
258
- const origin = row.origin.toLowerCase();
283
+ const { origin, source } = parseOrigin(row.origin);
284
+ // The name the workflow is expected to read FROM: the declared source
285
+ // when the row gives one, otherwise the variable's own name.
286
+ const expectedSource = source ?? row.name;
259
287
  if (!INJECTABLE_ORIGINS.has(origin)) continue;
260
288
  if (!row.workflow) {
261
289
  findings.push({
@@ -303,7 +331,9 @@ export function checkWiring(projectRoot, decl) {
303
331
  code: "RT01",
304
332
  level: "error",
305
333
  message: `${row.name} is declared with origin "${origin}" but no env: block in ${row.workflow} exports it${where} — the value exists in CI and never reaches the process`,
306
- remedy: `add "${row.name}: \${{ ${origin}.${row.name} }}" to the env: block of the ${row.job || "relevant"} job in ${row.workflow}`,
334
+ // Quote the DECLARED source, so the line this tells you to paste is
335
+ // the one that actually works when the names differ.
336
+ remedy: `add "${row.name}: \${{ ${origin}.${expectedSource} }}" to the env: block of the ${row.job || "relevant"} job in ${row.workflow}`,
307
337
  file: row.workflow,
308
338
  });
309
339
  continue;
@@ -320,12 +350,19 @@ export function checkWiring(projectRoot, decl) {
320
350
  remedy: `align the Origin column with ${row.workflow}:${e.line} (a value set as ${origin} is not visible as ${ref.origin})`,
321
351
  file: row.workflow,
322
352
  });
323
- } else if (ref.name !== row.name && ref.origin === origin) {
353
+ } else if (ref.name !== expectedSource && ref.origin === origin) {
354
+ // Silent when the workflow reads exactly the source the row
355
+ // declares: the rename is documented, and documentation is the
356
+ // answer this warning was asking for.
324
357
  findings.push({
325
358
  code: "RT02",
326
359
  level: "warn",
327
- message: `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name} — the names differ, so renaming one silently empties the other`,
328
- remedy: `use \${{ ${origin}.${row.name} }}, or record the intentional rename in the Wiring row`,
360
+ message: source
361
+ ? `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name}, but the contract declares it comes from ${origin}.${source} — one of the two moved`
362
+ : `${row.workflow}:${e.line} exports ${row.name} from ${ref.origin}.${ref.name} — the names differ, so renaming one silently empties the other`,
363
+ remedy: source
364
+ ? `align them: either export \${{ ${origin}.${source} }}, or update the Origin cell to "${origin}:${ref.name}"`
365
+ : `use \${{ ${origin}.${row.name} }}, or declare the rename in the Origin cell as "${origin}:${ref.name}"`,
329
366
  file: row.workflow,
330
367
  });
331
368
  }
@@ -371,7 +408,11 @@ export function checkEmptySemantics(projectRoot, decl) {
371
408
  const cache = new Map();
372
409
 
373
410
  for (const row of decl.wiring) {
374
- const origin = row.origin.toLowerCase();
411
+ // Through parseOrigin, so a row declaring its source (`secrets:NPM_TOKEN`)
412
+ // is still recognised as CI-injectable. Reading the cell raw here would
413
+ // make declaring a source silently switch this check off — the same
414
+ // class of defect being fixed, wearing a different hat.
415
+ const { origin } = parseOrigin(row.origin);
375
416
  if (!INJECTABLE_ORIGINS.has(origin) || !row.consumer) continue;
376
417
  const full = path.join(projectRoot, row.consumer);
377
418
  if (!isFile(full)) {
@@ -56,6 +56,13 @@ program, because no `env:` line ever exports it.
56
56
  vars and secrets are CI-injected, and CI injects the EMPTY
57
57
  STRING for one that is unset — not "absent". That is why a
58
58
  `getenv(NAME, default)` default silently never applies.
59
+
60
+ Exporting under a name that is not the source's is routine
61
+ (npm reads its credential from NODE_AUTH_TOKEN whatever your
62
+ secret is called). Declare it as `<origin>:<source>` — e.g.
63
+ `secrets:NPM_TOKEN` — and RT02 stays silent while the two
64
+ agree, and speaks the moment either side moves. A bare origin
65
+ keeps warning on any rename, which is the right default.
59
66
  Workflow repo-relative path to the workflow/pipeline file.
60
67
  Job/Step the job (or step) whose env: block must export it.
61
68
  Consumer the file that reads it.