doctrina-cli 0.13.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.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/commands/adapter.js +205 -0
- package/src/commands/analyze.js +120 -17
- package/src/commands/change.js +100 -42
- package/src/commands/clarify.js +8 -1
- package/src/commands/close.js +57 -2
- package/src/commands/completion.js +6 -0
- package/src/commands/constitution.js +8 -1
- package/src/commands/context.js +391 -58
- package/src/commands/contract.js +76 -43
- package/src/commands/coverage.js +126 -12
- package/src/commands/decision.js +172 -40
- package/src/commands/doctor.js +92 -11
- package/src/commands/handoff.js +8 -1
- package/src/commands/hooks.js +6 -0
- package/src/commands/index-rebuild.js +8 -1
- package/src/commands/init.js +139 -6
- package/src/commands/intake.js +11 -2
- package/src/commands/intent.js +8 -1
- package/src/commands/metrics.js +80 -7
- package/src/commands/next.js +38 -3
- package/src/commands/prime.js +8 -1
- package/src/commands/report.js +8 -1
- package/src/commands/review.js +8 -1
- package/src/commands/search.js +8 -1
- package/src/commands/show.js +8 -1
- package/src/commands/skill.js +201 -8
- package/src/commands/spec.js +13 -6
- package/src/commands/status.js +14 -2
- package/src/commands/templates.js +193 -59
- package/src/commands/trace.js +15 -3
- package/src/commands/triage.js +248 -0
- package/src/commands/upgrade.js +8 -1
- package/src/commands/validate.js +148 -5
- package/src/commands/verify.js +126 -5
- package/src/commands/watch.js +8 -1
- package/src/commands/why.js +10 -24
- package/src/commands/work.js +44 -3
- package/src/index.js +61 -6
- package/src/lib/adapters.js +140 -0
- package/src/lib/adr-guard.js +1 -0
- package/src/lib/args.js +10 -0
- package/src/lib/clarity.js +1 -0
- package/src/lib/colors.js +1 -0
- package/src/lib/commands.js +252 -27
- package/src/lib/criteria.js +1 -0
- package/src/lib/dates.js +1 -0
- package/src/lib/diff.js +1 -0
- package/src/lib/doc-model.js +287 -0
- package/src/lib/docs-impact.js +167 -0
- package/src/lib/ears.js +1 -0
- package/src/lib/exit-codes.js +103 -0
- package/src/lib/flag-catalog.js +106 -0
- package/src/lib/fs-ops.js +1 -0
- package/src/lib/gates.js +123 -0
- package/src/lib/git.js +97 -0
- package/src/lib/index-json.js +14 -6
- package/src/lib/json-out.js +83 -0
- package/src/lib/pipeline.js +161 -0
- package/src/lib/prompt.js +18 -1
- package/src/lib/runtime.js +683 -0
- package/src/lib/scan.js +81 -33
- package/src/lib/spec-ops.js +94 -9
- package/src/lib/suggest.js +1 -0
- package/src/lib/templates.js +89 -0
- package/src/lib/usage.js +89 -0
- package/src/lib/version.js +1 -0
- package/templates/AGENTS.md.template +59 -53
- package/templates/contract.md.template +74 -3
- package/templates/spec.md.template +22 -0
package/src/lib/gates.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { getSection } from "./doc-model.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { exists, isFile, mkdirp, read } from "./fs-ops.js";
|
|
6
|
+
import { today } from "./dates.js";
|
|
7
|
+
import { collectAnalysis } from "../commands/analyze.js";
|
|
8
|
+
|
|
9
|
+
// The gate-to-transition map (audit item C6).
|
|
10
|
+
//
|
|
11
|
+
// Each command used to decide its own preconditions. `change archive` and
|
|
12
|
+
// `close` checked theirs; `change apply` checked nothing. So `analyze`
|
|
13
|
+
// could exit 1 on a change and `change apply` would mutate it anyway and
|
|
14
|
+
// exit 0 — and the README flowchart tells agents to follow exactly that
|
|
15
|
+
// analyze → apply path. An agent could reach, through one path, a state
|
|
16
|
+
// another path forbids.
|
|
17
|
+
//
|
|
18
|
+
// The fix is not "add a check to apply" but "declare once which gates
|
|
19
|
+
// guard which transition, and have every driver consult the declaration".
|
|
20
|
+
// Which specific gate guards which transition matters less than the
|
|
21
|
+
// guarantee that the answer does not depend on how you got there.
|
|
22
|
+
|
|
23
|
+
// A gate is a named precondition returning human-readable blockers.
|
|
24
|
+
export const GATES = {
|
|
25
|
+
// Structural validity: the change's own files parse and point at real
|
|
26
|
+
// targets. The same checks `doctrina analyze` reports.
|
|
27
|
+
structure: {
|
|
28
|
+
label: "structure",
|
|
29
|
+
rerun: (id) => `doctrina analyze ${id}`,
|
|
30
|
+
blockers(projectRoot, changeDir) {
|
|
31
|
+
return collectAnalysis(projectRoot, changeDir)
|
|
32
|
+
.filter((r) => r.kind === "fail")
|
|
33
|
+
.map((r) => stripAnsi(r.line).replace(/^✗\s*/, "").trim());
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Verification: the work is claimed complete. Every checkbox in
|
|
38
|
+
// tasks.md (closing steps included) and in the proposal's
|
|
39
|
+
// "## Verification" section.
|
|
40
|
+
verification: {
|
|
41
|
+
label: "verification",
|
|
42
|
+
rerun: (id) => `doctrina change tick ${id}`,
|
|
43
|
+
blockers(projectRoot, changeDir) {
|
|
44
|
+
const out = [];
|
|
45
|
+
const countUnchecked = (s) => (s.match(/^\s*-\s*\[ \]/gm) ?? []).length;
|
|
46
|
+
|
|
47
|
+
const tasksPath = path.join(changeDir, "tasks.md");
|
|
48
|
+
if (isFile(tasksPath)) {
|
|
49
|
+
const n = countUnchecked(read(tasksPath));
|
|
50
|
+
if (n > 0) out.push(`${n} unchecked task${n === 1 ? "" : "s"} in tasks.md (closing steps count)`);
|
|
51
|
+
}
|
|
52
|
+
const proposalPath = path.join(changeDir, "proposal.md");
|
|
53
|
+
if (isFile(proposalPath)) {
|
|
54
|
+
const n = countUnchecked(getSection(read(proposalPath), "Verification"));
|
|
55
|
+
if (n > 0) out.push(`${n} unmet verification item${n === 1 ? "" : "s"} in proposal.md (## Verification)`);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Which gates guard each lifecycle transition. The single declaration.
|
|
63
|
+
//
|
|
64
|
+
// `apply` requires structure: mutating specs from a change whose deltas do
|
|
65
|
+
// not parse is how a malformed delta reaches a spec file. This is the gate
|
|
66
|
+
// `analyze` reports and that `apply` used to ignore entirely (C6).
|
|
67
|
+
//
|
|
68
|
+
// `archive` requires verification, and deliberately NOT structure. The
|
|
69
|
+
// structure gate is a PRE-APPLY question — "is this safe to apply?" — and
|
|
70
|
+
// one of its checks is that an ADDED delta's target does not already hold
|
|
71
|
+
// real content. After a successful apply that target holds exactly the
|
|
72
|
+
// content the delta just wrote, so re-asking at archive time reports a
|
|
73
|
+
// conflict that is the proof the apply worked. A gate must be asked at the
|
|
74
|
+
// point its question is meaningful.
|
|
75
|
+
export const TRANSITIONS = {
|
|
76
|
+
apply: { label: "change apply", gates: ["structure"] },
|
|
77
|
+
archive: { label: "change archive", gates: ["verification"] },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Evaluate a transition's gates. Returns { ok, blockers, gates } where
|
|
81
|
+
// blockers is a flat list of human-readable reasons.
|
|
82
|
+
export function checkTransition(projectRoot, changeDir, transition) {
|
|
83
|
+
const t = TRANSITIONS[transition];
|
|
84
|
+
if (!t) throw new Error(`unknown transition "${transition}"`);
|
|
85
|
+
const blockers = [];
|
|
86
|
+
for (const name of t.gates) {
|
|
87
|
+
for (const b of GATES[name].blockers(projectRoot, changeDir)) {
|
|
88
|
+
blockers.push({ gate: name, message: b });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ok: blockers.length === 0, blockers, gates: t.gates };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Record a forced transition in the archive ledger, so a gap that was
|
|
95
|
+
// waved through is part of the visible history rather than nothing at all
|
|
96
|
+
// — the same posture `change archive --force` and `change abandon` take.
|
|
97
|
+
export function recordForcedGap(projectRoot, id, transition, blockers) {
|
|
98
|
+
const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
|
|
99
|
+
const ledgerPath = path.join(archiveDir, "LEDGER.md");
|
|
100
|
+
// Create the ledger if this is the first entry. A forced transition can
|
|
101
|
+
// happen before any change has ever been archived, and a gap that goes
|
|
102
|
+
// unrecorded because the file did not exist yet is exactly the silence
|
|
103
|
+
// this is meant to break.
|
|
104
|
+
if (!exists(ledgerPath)) {
|
|
105
|
+
mkdirp(archiveDir);
|
|
106
|
+
writeFileSync(ledgerPath,
|
|
107
|
+
"# Change ledger\n\n" +
|
|
108
|
+
"One line per archived change, newest last. Appended by\n" +
|
|
109
|
+
"`doctrina change archive`; edit freely, the CLI only appends.\n\n");
|
|
110
|
+
}
|
|
111
|
+
const summary = blockers.map((b) => `${b.gate}: ${b.message}`).join("; ");
|
|
112
|
+
appendFileSync(
|
|
113
|
+
ledgerPath,
|
|
114
|
+
` - ${today()} — ${id} — forced ${transition} past ${blockers.length} ` +
|
|
115
|
+
`blocker${blockers.length === 1 ? "" : "s"} (${summary})\n`,
|
|
116
|
+
);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stripAnsi(s) {
|
|
121
|
+
// eslint-disable-next-line no-control-regex
|
|
122
|
+
return String(s).replace(/\[[0-9;]*m/g, "");
|
|
123
|
+
}
|
package/src/lib/git.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
// One place that knows how to ask git a question and how to interpret not
|
|
5
|
+
// getting an answer (audit item C8).
|
|
6
|
+
//
|
|
7
|
+
// Nine call sites each ran `spawnSync("git", ...)` and decided for
|
|
8
|
+
// themselves what a non-zero status meant. `metrics` decided it meant
|
|
9
|
+
// failure, so on a brand-new project — the most common state in which
|
|
10
|
+
// someone explores the CLI — it printed:
|
|
11
|
+
//
|
|
12
|
+
// error: git log failed: fatal: your current branch 'master' does not
|
|
13
|
+
// have any commits yet
|
|
14
|
+
//
|
|
15
|
+
// A repository with no commits is a valid state, not an error. So is a
|
|
16
|
+
// directory that is not a repository at all. What is genuinely an error
|
|
17
|
+
// is git being absent when a command cannot work without it — and that is
|
|
18
|
+
// an ENVIRONMENT condition (exit 4), not a gate failure.
|
|
19
|
+
|
|
20
|
+
export const GIT_STATE = Object.freeze({
|
|
21
|
+
/** git ran and answered. */
|
|
22
|
+
OK: "ok",
|
|
23
|
+
/** A repository with no commits yet — a valid first-run state. */
|
|
24
|
+
EMPTY: "empty",
|
|
25
|
+
/** Not a git repository. */
|
|
26
|
+
NOT_A_REPO: "not-a-repo",
|
|
27
|
+
/** git is not installed or not on PATH. */
|
|
28
|
+
ABSENT: "absent",
|
|
29
|
+
/** git ran and refused, for a reason this module does not classify. */
|
|
30
|
+
FAILED: "failed",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Run a git command. Never throws. Returns
|
|
34
|
+
// { state, stdout, lines, stderr, code }.
|
|
35
|
+
export function git(cwd, args) {
|
|
36
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
37
|
+
if (r.error) {
|
|
38
|
+
// ENOENT here means the binary is missing, not that the command failed.
|
|
39
|
+
return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
|
|
40
|
+
}
|
|
41
|
+
const stderr = String(r.stderr ?? "");
|
|
42
|
+
if (r.status === 0) {
|
|
43
|
+
const stdout = String(r.stdout ?? "");
|
|
44
|
+
return { state: GIT_STATE.OK, stdout, lines: splitLines(stdout), stderr, code: 0 };
|
|
45
|
+
}
|
|
46
|
+
// git's own wording for the benign cases. Matched on the message because
|
|
47
|
+
// git reports them all with a generic non-zero status. "Needed a single
|
|
48
|
+
// revision" is what `rev-parse --verify HEAD` says on a repo with no
|
|
49
|
+
// commits, which is how the empty-history probe first fooled itself.
|
|
50
|
+
if (/does not have any commits yet|unknown revision or path not in the working tree|bad default revision|Needed a single revision|ambiguous argument 'HEAD'/i.test(stderr)) {
|
|
51
|
+
return { state: GIT_STATE.EMPTY, stdout: "", lines: [], stderr, code: r.status };
|
|
52
|
+
}
|
|
53
|
+
if (/not a git repository/i.test(stderr)) {
|
|
54
|
+
return { state: GIT_STATE.NOT_A_REPO, stdout: "", lines: [], stderr, code: r.status };
|
|
55
|
+
}
|
|
56
|
+
// Anything else that exited non-zero is a failure, NOT a success with an
|
|
57
|
+
// empty answer. Reporting it as OK is how a caller reads "no results"
|
|
58
|
+
// from a command that actually refused.
|
|
59
|
+
return { state: GIT_STATE.FAILED, stdout: "", lines: [], stderr, code: r.status };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Is this a git repository at all?
|
|
63
|
+
export function isRepo(cwd) {
|
|
64
|
+
return git(cwd, ["rev-parse", "--is-inside-work-tree"]).state === GIT_STATE.OK;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Does it have at least one commit? False for a fresh `git init`.
|
|
68
|
+
export function hasCommits(cwd) {
|
|
69
|
+
return git(cwd, ["rev-parse", "--verify", "HEAD"]).state === GIT_STATE.OK;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The state a command should report before trying to read history:
|
|
73
|
+
// { usable, state, reason } where `reason` is a sentence fit to print.
|
|
74
|
+
export function historyState(cwd) {
|
|
75
|
+
const probe = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
76
|
+
if (probe.state === GIT_STATE.ABSENT) {
|
|
77
|
+
return { usable: false, state: GIT_STATE.ABSENT, reason: "git is not installed or not on PATH" };
|
|
78
|
+
}
|
|
79
|
+
if (probe.state !== GIT_STATE.OK) {
|
|
80
|
+
return { usable: false, state: GIT_STATE.NOT_A_REPO, reason: "this is not a git repository" };
|
|
81
|
+
}
|
|
82
|
+
if (!hasCommits(cwd)) {
|
|
83
|
+
return { usable: false, state: GIT_STATE.EMPTY, reason: "this repository has no commits yet" };
|
|
84
|
+
}
|
|
85
|
+
return { usable: true, state: GIT_STATE.OK, reason: "" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Lines of output, trimmed and blank-filtered — what nearly every caller
|
|
89
|
+
// actually wanted from stdout.
|
|
90
|
+
export function gitLines(cwd, args) {
|
|
91
|
+
const r = git(cwd, args);
|
|
92
|
+
return r.state === GIT_STATE.OK ? r.lines : [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitLines(s) {
|
|
96
|
+
return s.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
|
|
97
|
+
}
|
package/src/lib/index-json.js
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { read, write, exists } from "./fs-ops.js";
|
|
3
4
|
import { cliVersion } from "./version.js";
|
|
4
5
|
|
|
5
6
|
export const SCHEMA_VERSION = "0.1.0";
|
|
6
7
|
|
|
8
|
+
// The artifact categories a well-formed index.json carries. ONE definition,
|
|
9
|
+
// consumed by `blank()` (what `init` writes) and by `templates check` /
|
|
10
|
+
// `templates update` (what a project is measured against).
|
|
11
|
+
//
|
|
12
|
+
// There used to be two: the shape `init` materialised from a template file
|
|
13
|
+
// and the shape `templates check` required. Nothing compared them, so when
|
|
14
|
+
// `contracts` was added to the expected shape and not to the template,
|
|
15
|
+
// every project was born needing a scaffold update (audit item C5).
|
|
16
|
+
export const ARTIFACT_CATEGORIES = Object.freeze([
|
|
17
|
+
"specs", "decisions", "changes", "changes_archive", "skills", "contracts",
|
|
18
|
+
]);
|
|
19
|
+
|
|
7
20
|
export function indexPath(projectRoot) {
|
|
8
21
|
return path.join(projectRoot, ".doctrina", "index.json");
|
|
9
22
|
}
|
|
@@ -46,12 +59,7 @@ export function blank(projectName, date) {
|
|
|
46
59
|
version: "0.1.0",
|
|
47
60
|
last_updated: date,
|
|
48
61
|
},
|
|
49
|
-
|
|
50
|
-
decisions: [],
|
|
51
|
-
changes: [],
|
|
52
|
-
changes_archive: [],
|
|
53
|
-
skills: [],
|
|
54
|
-
contracts: [],
|
|
62
|
+
...Object.fromEntries(ARTIFACT_CATEGORIES.map((cat) => [cat, []])),
|
|
55
63
|
},
|
|
56
64
|
};
|
|
57
65
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { EXIT } from "./exit-codes.js";
|
|
4
|
+
|
|
5
|
+
// `--json` on every command, with a versioned envelope (audit item M7).
|
|
6
|
+
//
|
|
7
|
+
// Five of thirty-five commands spoke JSON. Everything else emitted
|
|
8
|
+
// ANSI-coloured prose, while the primary consumer is a machine. Paired with
|
|
9
|
+
// the exit-code contract (ADR 0018), the two form the machine interface:
|
|
10
|
+
// structured output plus a meaningful status. Without both, an autonomous
|
|
11
|
+
// loop is parsing English.
|
|
12
|
+
//
|
|
13
|
+
// Two levels of support, and the difference is stated rather than hidden:
|
|
14
|
+
//
|
|
15
|
+
// STRUCTURED — the command builds a payload describing its result. These
|
|
16
|
+
// are the read and report commands, where a result has real shape.
|
|
17
|
+
// ENVELOPE — the command's human output is captured and returned inside
|
|
18
|
+
// a versioned envelope alongside `ok` and `exitCode`. Branch on those;
|
|
19
|
+
// the lines are there for completeness, not for parsing.
|
|
20
|
+
//
|
|
21
|
+
// A command with nothing better to say is still machine-consumable, which
|
|
22
|
+
// is what makes "--json on every command" a claim rather than an intention.
|
|
23
|
+
|
|
24
|
+
export const JSON_SCHEMA_VERSION = "1.0.0";
|
|
25
|
+
|
|
26
|
+
export function wantsJson(flags) {
|
|
27
|
+
return flags?.get?.("json") === true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Print a structured payload. `command` is the invocation, `data` whatever
|
|
31
|
+
// that command has to say.
|
|
32
|
+
export function emitJson(command, data, { ok = true, exitCode = EXIT.OK } = {}) {
|
|
33
|
+
process.stdout.write(JSON.stringify({
|
|
34
|
+
$schema_version: JSON_SCHEMA_VERSION,
|
|
35
|
+
command,
|
|
36
|
+
ok,
|
|
37
|
+
exit_code: exitCode,
|
|
38
|
+
...data,
|
|
39
|
+
}, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run `fn` with console output captured, so a command that has no
|
|
43
|
+
// structured payload can still answer in JSON. Returns { code, stdout,
|
|
44
|
+
// stderr } with the lines it would have printed.
|
|
45
|
+
export async function captureOutput(fn) {
|
|
46
|
+
const stdout = [];
|
|
47
|
+
const stderr = [];
|
|
48
|
+
const origLog = console.log;
|
|
49
|
+
const origError = console.error;
|
|
50
|
+
const origWarn = console.warn;
|
|
51
|
+
// Some commands write to the stream directly rather than through console
|
|
52
|
+
// — `completion` emits a shell script meant for redirection. Capturing
|
|
53
|
+
// only console.* let that output escape the envelope and corrupt the
|
|
54
|
+
// JSON on stdout, so the raw stream is captured too.
|
|
55
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
56
|
+
const pushLines = (target, chunk) => {
|
|
57
|
+
for (const line of String(chunk).replace(/\n$/, "").split("\n")) target.push(line);
|
|
58
|
+
};
|
|
59
|
+
console.log = (...args) => stdout.push(args.map(String).join(" "));
|
|
60
|
+
console.error = (...args) => stderr.push(args.map(String).join(" "));
|
|
61
|
+
console.warn = (...args) => stderr.push(args.map(String).join(" "));
|
|
62
|
+
process.stdout.write = (chunk, ...rest) => {
|
|
63
|
+
pushLines(stdout, chunk);
|
|
64
|
+
const cb = rest.find((r) => typeof r === "function");
|
|
65
|
+
if (cb) cb();
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
const code = await fn();
|
|
70
|
+
return { code: code ?? EXIT.OK, stdout, stderr };
|
|
71
|
+
} finally {
|
|
72
|
+
console.log = origLog;
|
|
73
|
+
console.error = origError;
|
|
74
|
+
console.warn = origWarn;
|
|
75
|
+
process.stdout.write = origWrite;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Strip ANSI so captured lines are usable as data even when colour was on.
|
|
80
|
+
export function stripAnsi(s) {
|
|
81
|
+
// eslint-disable-next-line no-control-regex
|
|
82
|
+
return String(s).replace(/\[[0-9;]*m/g, "");
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
package/src/lib/prompt.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import readline from "node:readline";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
// Is there a real person on the other end? Every prompt must ask this
|
|
6
|
+
// first. `init` checked it before the ADAPTER prompt but not before the
|
|
7
|
+
// project-description prompt, so a non-interactive caller that forgot
|
|
8
|
+
// --non-interactive got a project with a blank description and no warning
|
|
9
|
+
// (audit item C9).
|
|
10
|
+
export function isInteractive() {
|
|
11
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
12
|
+
}
|
|
2
13
|
|
|
3
14
|
export async function ask(question, { defaultValue = "" } = {}) {
|
|
4
15
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -11,7 +22,13 @@ export async function ask(question, { defaultValue = "" } = {}) {
|
|
|
11
22
|
}
|
|
12
23
|
}
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
// Ask for consent. Off a terminal there is nobody to ask, so the answer is
|
|
26
|
+
// `whenNonInteractive` — which callers set to false for destructive
|
|
27
|
+
// operations: silence is not consent.
|
|
28
|
+
export async function confirm(question, { defaultYes = false, whenNonInteractive = null } = {}) {
|
|
29
|
+
if (!isInteractive()) {
|
|
30
|
+
return whenNonInteractive === null ? defaultYes : whenNonInteractive;
|
|
31
|
+
}
|
|
15
32
|
const suffix = defaultYes ? "Y/n" : "y/N";
|
|
16
33
|
const ans = await ask(`${question} (${suffix})`);
|
|
17
34
|
if (!ans) return defaultYes;
|