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 +1 -1
- package/package.json +1 -1
- package/src/commands/analyze.js +18 -5
- package/src/commands/doctor.js +2 -2
- package/src/commands/triage.js +11 -1
- package/src/commands/verify.js +62 -23
- package/src/lib/commands.js +53 -2
- package/src/lib/gates.js +29 -1
- package/src/lib/runtime.js +47 -6
- package/templates/contract.md.template +7 -0
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.
|
|
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
package/src/commands/analyze.js
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
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 };
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
|
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"}
|
|
152
|
+
row("ok", "runtime", `${runtime.declared} declared row${runtime.declared === 1 ? " holds" : "s hold"}`);
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
package/src/commands/triage.js
CHANGED
|
@@ -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"}
|
|
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
|
}
|
package/src/commands/verify.js
CHANGED
|
@@ -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
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
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 =
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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)
|
package/src/lib/commands.js
CHANGED
|
@@ -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 =
|
|
425
|
-
if (
|
|
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
|
package/src/lib/runtime.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 !==
|
|
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:
|
|
328
|
-
|
|
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
|
-
|
|
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.
|