dw-sdlc 0.16.1 → 0.18.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 +22 -1
- package/config/agentic.config.schema.json +68 -3
- package/dist/agentic-config.d.ts +13 -3
- package/dist/agentic-config.js +8 -0
- package/dist/catalogue.d.ts +4 -0
- package/dist/catalogue.js +7 -7
- package/dist/checks/check.d.ts +2 -0
- package/dist/checks/comment-budget.js +1 -252
- package/dist/checks/comments.d.ts +29 -0
- package/dist/checks/comments.js +255 -0
- package/dist/checks/decision-records.d.ts +15 -0
- package/dist/checks/decision-records.js +15 -1
- package/dist/checks/disable-directives.d.ts +6 -0
- package/dist/checks/disable-directives.js +91 -0
- package/dist/checks/hash-comments.d.ts +4 -0
- package/dist/checks/hash-comments.js +121 -0
- package/dist/checks/index.d.ts +1 -0
- package/dist/checks/index.js +9 -1
- package/dist/checks/primitives.js +16 -5
- package/dist/checks/skill-sections.d.ts +5 -0
- package/dist/checks/skill-sections.js +163 -0
- package/dist/commands/check.js +50 -5
- package/dist/commands/gate.d.ts +4 -1
- package/dist/commands/gate.js +22 -10
- package/dist/commands/primitives.d.ts +1 -1
- package/dist/commands/primitives.js +38 -7
- package/dist/commands/recap.js +22 -6
- package/dist/markdown.d.ts +2 -0
- package/dist/markdown.js +11 -2
- package/dist/primitives-diff.d.ts +31 -0
- package/dist/primitives-diff.js +123 -0
- package/dist/recap-primitives.d.ts +12 -0
- package/dist/recap-primitives.js +26 -0
- package/dist/report.d.ts +8 -2
- package/dist/report.js +8 -3
- package/dist/run-command.js +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `skill-sections`: each README section a skill copies, a part, matches its source. A part sits between
|
|
2
|
+
// `<!-- README.md#<anchor> -->` and `<!-- /README.md#<anchor> -->` and repeats only a section a Primitive's
|
|
3
|
+
// docs name. `dw-sdlc check skill-sections --write` writes each part from its section.
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join, posix } from "node:path";
|
|
6
|
+
import { resolveConfig } from "../agentic-config.js";
|
|
7
|
+
import { readCatalogue } from "../catalogue.js";
|
|
8
|
+
import { headings, openFence, prose } from "../markdown.js";
|
|
9
|
+
import { CannotRun } from "./check.js";
|
|
10
|
+
import { catalogueFile } from "./primitives.js";
|
|
11
|
+
import { matching, treeFiles } from "./tree.js";
|
|
12
|
+
/** Either marker of a part: `/` before `README.md` closes it. */
|
|
13
|
+
const MARKER = /<!-- (\/?)README\.md#(\S+) -->/g;
|
|
14
|
+
const WRITE = "dw-sdlc check skill-sections --write";
|
|
15
|
+
const REMEDIES = {
|
|
16
|
+
stale: `edit the README, not the part, then run ${WRITE}`,
|
|
17
|
+
readme: "write the README, or drop the part",
|
|
18
|
+
section: "point it at a heading the README has",
|
|
19
|
+
unpaired: "open a part with <!-- README.md#<anchor> --> and close it with <!-- /README.md#<anchor> -->, each on its own line",
|
|
20
|
+
missing: "create it, or point gate.checks.primitives.file at the catalogue",
|
|
21
|
+
invalid: "fix what dw-sdlc check primitives names",
|
|
22
|
+
fence: "close the fence"
|
|
23
|
+
};
|
|
24
|
+
/** The markers outside code in `text`, in order. */
|
|
25
|
+
function markers(text) {
|
|
26
|
+
return Array.from(prose(text).matchAll(MARKER), (match) => ({
|
|
27
|
+
close: match[1] === "/",
|
|
28
|
+
anchor: match[2] ?? "",
|
|
29
|
+
start: match.index,
|
|
30
|
+
end: match.index + match[0].length
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** The README a skill file copies from: the one beside the `skills` directory it is in, else beside it. */
|
|
34
|
+
function readmeOf(path) {
|
|
35
|
+
const directories = path.split("/").slice(0, -1);
|
|
36
|
+
const at = directories.lastIndexOf("skills");
|
|
37
|
+
const unit = at === -1 ? directories : directories.slice(0, at);
|
|
38
|
+
return posix.join(...unit, "README.md");
|
|
39
|
+
}
|
|
40
|
+
/** The section under the heading with `anchor`, up to the next heading of its level or above, its end trimmed. */
|
|
41
|
+
function sectionOf(markdown, anchor) {
|
|
42
|
+
const all = headings(markdown);
|
|
43
|
+
const start = all.find((heading) => heading.anchor === anchor);
|
|
44
|
+
if (start === undefined) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const end = all.find((heading) => heading.line > start.line && heading.level <= start.level);
|
|
48
|
+
return markdown
|
|
49
|
+
.split("\n")
|
|
50
|
+
.slice(start.line - 1, end === undefined ? undefined : end.line - 1)
|
|
51
|
+
.join("\n")
|
|
52
|
+
.trimEnd();
|
|
53
|
+
}
|
|
54
|
+
function lineAt(text, offset) {
|
|
55
|
+
return text.slice(0, offset).split("\n").length;
|
|
56
|
+
}
|
|
57
|
+
function readmeText(root, path, sources) {
|
|
58
|
+
if (!sources.readmes.has(path)) {
|
|
59
|
+
const full = join(root, path);
|
|
60
|
+
sources.readmes.set(path, existsSync(full) ? readFileSync(full, "utf8") : undefined);
|
|
61
|
+
}
|
|
62
|
+
return sources.readmes.get(path);
|
|
63
|
+
}
|
|
64
|
+
function render(root, path, sources) {
|
|
65
|
+
const text = readFileSync(join(root, path), "utf8");
|
|
66
|
+
const readme = readmeOf(path);
|
|
67
|
+
const found = markers(text);
|
|
68
|
+
const findings = [];
|
|
69
|
+
const pieces = [];
|
|
70
|
+
let copied = 0;
|
|
71
|
+
let rewritten = 0;
|
|
72
|
+
for (let index = 0, open = found[0]; open !== undefined; open = found[++index]) {
|
|
73
|
+
const close = found[index + 1];
|
|
74
|
+
const line = lineAt(text, open.start);
|
|
75
|
+
const fail = (message, remedy) => findings.push({ file: path, line, message, remedy });
|
|
76
|
+
if (open.close || close === undefined || !close.close || close.anchor !== open.anchor) {
|
|
77
|
+
const marker = `README.md#${open.anchor}`;
|
|
78
|
+
const message = open.close
|
|
79
|
+
? `the closing marker of ${marker} has no opening marker`
|
|
80
|
+
: `the opening marker of ${marker} has no closing marker`;
|
|
81
|
+
fail(message, REMEDIES.unpaired);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
index++;
|
|
85
|
+
const part = `the part from README.md#${open.anchor}`;
|
|
86
|
+
const source = readmeText(root, readme, sources);
|
|
87
|
+
const section = source === undefined ? undefined : sectionOf(source, open.anchor);
|
|
88
|
+
if (!sources.listed().has(`${readme}#${open.anchor}`)) {
|
|
89
|
+
const remedy = `name ${readme}#${open.anchor} in a Primitive's docs, or point the part at a section they name`;
|
|
90
|
+
fail(`${part} copies a section no Primitive's docs name`, remedy);
|
|
91
|
+
}
|
|
92
|
+
else if (source === undefined) {
|
|
93
|
+
fail(`${part} reads ${readme}, which does not exist`, REMEDIES.readme);
|
|
94
|
+
}
|
|
95
|
+
else if (section === undefined) {
|
|
96
|
+
fail(`${part} names no heading of ${readme}`, REMEDIES.section);
|
|
97
|
+
}
|
|
98
|
+
else if (text.slice(open.end, close.start) !== `\n\n${section}\n\n`) {
|
|
99
|
+
fail(`${part} differs from that section of ${readme}`, REMEDIES.stale);
|
|
100
|
+
pieces.push(text.slice(copied, open.end), `\n\n${section}\n\n`);
|
|
101
|
+
copied = close.start;
|
|
102
|
+
rewritten++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const fence = openFence(text);
|
|
106
|
+
const hidden = fence === undefined ? "" : text.split("\n").slice(fence).join("\n");
|
|
107
|
+
if (fence !== undefined && hidden.search(MARKER) !== -1) {
|
|
108
|
+
const message = "a code fence opens here and never closes, so the markers after it are not read";
|
|
109
|
+
findings.push({ file: path, line: fence, message, remedy: REMEDIES.fence });
|
|
110
|
+
}
|
|
111
|
+
pieces.push(text.slice(copied));
|
|
112
|
+
return { text: pieces.join(""), findings, rewritten };
|
|
113
|
+
}
|
|
114
|
+
/** The docs entries the catalogue names, each read from the root; the catalogue is read on the first call. */
|
|
115
|
+
function listedDocs(root) {
|
|
116
|
+
let listed;
|
|
117
|
+
return () => {
|
|
118
|
+
if (listed === undefined) {
|
|
119
|
+
const file = catalogueFile(resolveConfig(root).config.gate.checks);
|
|
120
|
+
const read = readCatalogue(root, file);
|
|
121
|
+
if (read.state === "missing") {
|
|
122
|
+
throw new CannotRun({
|
|
123
|
+
file,
|
|
124
|
+
message: "the catalogue does not exist, and a skill holds a part",
|
|
125
|
+
remedy: REMEDIES.missing
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (read.state === "invalid") {
|
|
129
|
+
throw new CannotRun({
|
|
130
|
+
file,
|
|
131
|
+
message: "the catalogue is not valid, and a skill holds a part",
|
|
132
|
+
remedy: REMEDIES.invalid
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
listed = new Set(read.catalogue.primitives.flatMap(({ docs }) => docs).map((entry) => posix.join(".", entry)));
|
|
136
|
+
}
|
|
137
|
+
return listed;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function renderAll(root, { files }) {
|
|
141
|
+
const sources = { listed: listedDocs(root), readmes: new Map() };
|
|
142
|
+
return matching(root, treeFiles(root), files).map((path) => ({ path, rendered: render(root, path, sources) }));
|
|
143
|
+
}
|
|
144
|
+
function plural(count, noun) {
|
|
145
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
146
|
+
}
|
|
147
|
+
function write(root, options) {
|
|
148
|
+
const changed = renderAll(root, options).filter(({ rendered }) => rendered.rewritten > 0);
|
|
149
|
+
for (const { path, rendered } of changed) {
|
|
150
|
+
writeFileSync(join(root, path), rendered.text);
|
|
151
|
+
}
|
|
152
|
+
if (changed.length === 0) {
|
|
153
|
+
return "every part matches its section, so nothing was written";
|
|
154
|
+
}
|
|
155
|
+
const parts = changed.reduce((sum, { rendered }) => sum + rendered.rewritten, 0);
|
|
156
|
+
return `wrote ${plural(parts, "part")} in ${plural(changed.length, "file")}`;
|
|
157
|
+
}
|
|
158
|
+
export const skillSections = {
|
|
159
|
+
name: "skill-sections",
|
|
160
|
+
defaults: { files: ["**/skills/**/*.md"] },
|
|
161
|
+
run: (root, options) => renderAll(root, options).flatMap(({ rendered }) => rendered.findings),
|
|
162
|
+
write
|
|
163
|
+
};
|
package/dist/commands/check.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// `dw-sdlc check <name>`: one Check of the Gate by its name, without the Steps and the other Checks.
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { repositoryRoot } from "../agentic-config.js";
|
|
4
|
-
import {
|
|
3
|
+
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
4
|
+
import { CannotRun } from "../checks/check.js";
|
|
5
|
+
import { CHECK_NAMES, shippedCheck } from "../checks/index.js";
|
|
5
6
|
import { TAIL_LINES } from "../run-command.js";
|
|
6
7
|
import { printReport, runGate } from "./gate.js";
|
|
7
8
|
export const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
|
|
8
|
-
const USAGE = `Usage: dw-sdlc check <name> [--json] [--root <dir>]
|
|
9
|
+
const USAGE = `Usage: dw-sdlc check <name> [--json] [--write] [--root <dir>]
|
|
9
10
|
|
|
10
11
|
Runs the Check named <name> in the Gate of the repository holding --root (default: the current
|
|
11
12
|
directory): a script under gate.checks.run, or a Check the package ships, one of
|
|
@@ -15,6 +16,10 @@ false or leaves it out. No Step runs. A run script prints nothing of its own unl
|
|
|
15
16
|
pass: then its last ${TAIL_LINES} lines, both streams in the order written. Prints one line per failure,
|
|
16
17
|
as the Gate does; --json prints the Gate's document alone, with no Step.
|
|
17
18
|
|
|
19
|
+
--write first writes what the Check derives from its sources, then judges the tree it wrote, and
|
|
20
|
+
prints a line on what it wrote, to stderr under --json. Only a Check that derives files takes it:
|
|
21
|
+
skill-sections writes each part of a skill from its README section.
|
|
22
|
+
|
|
18
23
|
A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
|
|
19
24
|
pass.
|
|
20
25
|
|
|
@@ -29,18 +34,50 @@ function parseCli(argv) {
|
|
|
29
34
|
options: {
|
|
30
35
|
help: { type: "boolean", short: "h" },
|
|
31
36
|
json: { type: "boolean" },
|
|
37
|
+
write: { type: "boolean" },
|
|
32
38
|
root: { type: "string" }
|
|
33
39
|
}
|
|
34
40
|
});
|
|
35
41
|
if (positionals.length > 1) {
|
|
36
42
|
return { error: `one Check at a time, not ${positionals.map((name) => JSON.stringify(name)).join(" and ")}` };
|
|
37
43
|
}
|
|
38
|
-
|
|
44
|
+
const flags = { help: values.help === true, json: values.json === true, write: values.write === true };
|
|
45
|
+
return { ...flags, root: values.root, name: positionals[0] };
|
|
39
46
|
}
|
|
40
47
|
catch (error) {
|
|
41
48
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
42
49
|
}
|
|
43
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Writes what the shipped Check `name` derives, on the setting the config gives it, and prints what it
|
|
53
|
+
* wrote; why it cannot when it takes no --write. An invalid config or a missing input writes nothing,
|
|
54
|
+
* and the run after it reports why.
|
|
55
|
+
*/
|
|
56
|
+
function writeDerived(root, name, json) {
|
|
57
|
+
const { config, problems } = resolveConfig(root);
|
|
58
|
+
if (problems.length > 0) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const shipped = shippedCheck(name, config.gate.checks[name]);
|
|
62
|
+
if (shipped === undefined && !config.gate.checks.run.some((check) => check.name === name)) {
|
|
63
|
+
// No Check of that name: the run after it says so, with the names there are.
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const write = shipped?.write;
|
|
67
|
+
if (write === undefined) {
|
|
68
|
+
return `${JSON.stringify(name)} takes no --write: it derives no file`;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const out = json ? process.stderr : process.stdout;
|
|
72
|
+
out.write(`check ${name}: ${write(root)}\n`);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (!(error instanceof CannotRun)) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
44
81
|
export async function run(argv) {
|
|
45
82
|
const cli = parseCli(argv);
|
|
46
83
|
if ("error" in cli) {
|
|
@@ -56,6 +93,14 @@ export async function run(argv) {
|
|
|
56
93
|
return 2;
|
|
57
94
|
}
|
|
58
95
|
const { json, name } = cli;
|
|
59
|
-
const
|
|
96
|
+
const root = repositoryRoot(cli.root ?? process.cwd());
|
|
97
|
+
if (cli.write) {
|
|
98
|
+
const refused = writeDerived(root, name, json);
|
|
99
|
+
if (refused !== undefined) {
|
|
100
|
+
process.stderr.write(`dw-sdlc check: ${refused}\n`);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const report = await runGate(root, { json, only: name });
|
|
60
105
|
return printReport(report, json, `check ${name}`);
|
|
61
106
|
}
|
package/dist/commands/gate.d.ts
CHANGED
|
@@ -6,13 +6,16 @@ type Outcome = {
|
|
|
6
6
|
exitCode: number | null;
|
|
7
7
|
tail: string[] | null;
|
|
8
8
|
};
|
|
9
|
+
/** `hint` is what the Step carries to try when it fails, null where it has none. */
|
|
9
10
|
type StepReport = Outcome & {
|
|
10
11
|
command: string;
|
|
12
|
+
hint: string | null;
|
|
11
13
|
};
|
|
12
|
-
/** A Check the package ships runs in this process: it has no command. */
|
|
14
|
+
/** A Check the package ships runs in this process: it has no command and no hint. */
|
|
13
15
|
type CheckReport = Outcome & {
|
|
14
16
|
name: string;
|
|
15
17
|
command: string | null;
|
|
18
|
+
hint: string | null;
|
|
16
19
|
};
|
|
17
20
|
type GateReport = {
|
|
18
21
|
status: Status;
|
package/dist/commands/gate.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
|
|
2
2
|
// run and the report serve `dw-sdlc check` too, which judges one Check of it.
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
4
|
+
import { repositoryRoot, resolveConfig, stepCommand, stepHint } from "../agentic-config.js";
|
|
5
5
|
import { CannotRun } from "../checks/check.js";
|
|
6
6
|
import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
|
|
7
7
|
import { watchDrift } from "../drift.js";
|
|
8
8
|
import { fingerprint, isGreen, stampGreen } from "../gate-cache.js";
|
|
9
|
-
import { annotation, failureJson, failureLine } from "../report.js";
|
|
9
|
+
import { annotation, failureJson, failureLine, hintLines } from "../report.js";
|
|
10
10
|
import { holdingSignals, runCommand, stoppedBy, TAIL_LINES } from "../run-command.js";
|
|
11
11
|
export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
12
12
|
const USAGE = `Usage: dw-sdlc gate [--json] [--verbose] [--root <dir>] [--update-baseline | --cache]
|
|
@@ -21,6 +21,9 @@ Actions it is also a workflow annotation. A Step or run script prints nothing of
|
|
|
21
21
|
does not pass: then its last ${TAIL_LINES} lines, both streams in the order written, under its own
|
|
22
22
|
line. In a terminal a Step's line shows when it starts and takes its result when it ends.
|
|
23
23
|
|
|
24
|
+
A Step written as { "command", "hint" }, and a run script with a hint, prints "hint: <text>" under its
|
|
25
|
+
failure line when it does not pass; a passing one prints nothing more.
|
|
26
|
+
|
|
24
27
|
Each Step runs between two records of every file git tracks or would track: a file the Step created,
|
|
25
28
|
changed or deleted is Drift, one failure per file, and the Steps after it still run. Ignored files
|
|
26
29
|
and edits made before the run never drift, and a Step a signal stopped is not compared. gate.drift
|
|
@@ -33,7 +36,7 @@ second signal, or one sent while the Checks the package ships run, ends the Gate
|
|
|
33
36
|
|
|
34
37
|
--verbose passes each command's output through as it runs instead, under a line naming the command,
|
|
35
38
|
and keeps no tail. --json prints the document alone; each Step and Check in it carries tail, the last
|
|
36
|
-
${TAIL_LINES} lines of a command that did not pass, null otherwise. With --verbose, under --json, the
|
|
39
|
+
${TAIL_LINES} lines of a command that did not pass, null otherwise, and hint, null where there is none. With --verbose, under --json, the
|
|
37
40
|
commands' output goes to stderr and every tail is null.
|
|
38
41
|
|
|
39
42
|
--update-baseline then rewrites the baseline of each Ratchet, only when every Step and every other
|
|
@@ -268,7 +271,7 @@ export async function runGate(root, options) {
|
|
|
268
271
|
const failures = [];
|
|
269
272
|
const watch = watchDrift(root, config.gate);
|
|
270
273
|
/** Runs one Step or Check, prints its progress and records its failure under `rule`, if it has one. */
|
|
271
|
-
const runOne = async (kind, subject, command) => {
|
|
274
|
+
const runOne = async (kind, subject, command, hint) => {
|
|
272
275
|
progress.start(kind, subject);
|
|
273
276
|
const ran = await runCommand(command, root, output);
|
|
274
277
|
progress.end(kind, subject, ran);
|
|
@@ -277,7 +280,8 @@ export async function runGate(root, options) {
|
|
|
277
280
|
failures.push({
|
|
278
281
|
rule: kind === "step" ? "step" : subject,
|
|
279
282
|
message: `${JSON.stringify(command)} ${ran.detail}`,
|
|
280
|
-
remedy
|
|
283
|
+
remedy,
|
|
284
|
+
...(hint === null ? {} : { hint })
|
|
281
285
|
});
|
|
282
286
|
}
|
|
283
287
|
return { status: ran.status, exitCode: ran.exitCode, tail: ran.tail };
|
|
@@ -289,15 +293,19 @@ export async function runGate(root, options) {
|
|
|
289
293
|
const halted = () => failedStep || stoppedBy() !== undefined;
|
|
290
294
|
const steps = [];
|
|
291
295
|
for (const step of selected.steps) {
|
|
292
|
-
const
|
|
296
|
+
const command = stepCommand(step);
|
|
297
|
+
const hint = stepHint(step);
|
|
298
|
+
const watched = halted() ? undefined : await watch.step(command, () => runOne("step", command, command, hint));
|
|
293
299
|
failures.push(...(watched?.failures ?? []));
|
|
294
300
|
const outcome = watched?.result ?? SKIPPED;
|
|
295
|
-
steps.push({ command
|
|
301
|
+
steps.push({ command, hint, ...outcome });
|
|
296
302
|
failedStep ||= outcome.status !== "pass";
|
|
297
303
|
}
|
|
298
304
|
const checks = [];
|
|
299
305
|
for (const check of selected.run) {
|
|
300
|
-
|
|
306
|
+
const hint = check.hint ?? null;
|
|
307
|
+
const outcome = halted() ? SKIPPED : await runOne("check", check.name, check.command, hint);
|
|
308
|
+
checks.push({ name: check.name, command: check.command, hint, ...outcome });
|
|
301
309
|
}
|
|
302
310
|
return { steps, checks, halted: halted() };
|
|
303
311
|
});
|
|
@@ -323,7 +331,7 @@ export async function runGate(root, options) {
|
|
|
323
331
|
progress.end("check", check.name, outcome);
|
|
324
332
|
}
|
|
325
333
|
failures.push(...found);
|
|
326
|
-
checks.push({ name: check.name, command: null, ...outcome });
|
|
334
|
+
checks.push({ name: check.name, command: null, hint: null, ...outcome });
|
|
327
335
|
}
|
|
328
336
|
for (const note of notes) {
|
|
329
337
|
log.write(`${note}\n`);
|
|
@@ -361,7 +369,11 @@ export function printReport(report, json, label) {
|
|
|
361
369
|
}
|
|
362
370
|
}
|
|
363
371
|
else {
|
|
364
|
-
const lines = report.failures.flatMap((failure) =>
|
|
372
|
+
const lines = report.failures.flatMap((failure) => [
|
|
373
|
+
failureLine(failure),
|
|
374
|
+
...(actions ? [annotation(failure)] : []),
|
|
375
|
+
...hintLines(failure)
|
|
376
|
+
]);
|
|
365
377
|
process.stdout.write(`${[...lines, verdict(label, report)].join("\n")}\n`);
|
|
366
378
|
}
|
|
367
379
|
return EXIT_CODES[report.status];
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const summary = "name the Primitives that own each path,
|
|
1
|
+
export declare const summary = "name the Primitives that own each path, or that a change removes, adds or extends";
|
|
2
2
|
export declare function run(argv: string[]): number;
|
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
// `dw-sdlc primitives
|
|
2
|
-
// with their summaries and the documents to read, or that none does
|
|
1
|
+
// `dw-sdlc primitives`: with `--paths <files>`, for each path the Primitives of the catalogue that name
|
|
2
|
+
// it, with their summaries and the documents to read, or that none does; with `--diff <base>`, the
|
|
3
|
+
// Primitives a change removes, adds or extends.
|
|
3
4
|
import { existsSync, realpathSync } from "node:fs";
|
|
4
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
5
6
|
import { parseArgs } from "node:util";
|
|
6
7
|
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
7
8
|
import { primitivesNaming, readCatalogue } from "../catalogue.js";
|
|
8
9
|
import { catalogueFile } from "../checks/primitives.js";
|
|
9
|
-
|
|
10
|
+
import { blockOf, primitivesDiff } from "../primitives-diff.js";
|
|
11
|
+
export const summary = "name the Primitives that own each path, or that a change removes, adds or extends";
|
|
10
12
|
const USAGE = `Usage: dw-sdlc primitives --paths <file>... [--json] [--root <dir>]
|
|
13
|
+
dw-sdlc primitives --diff <base> [--json] [--root <dir>]
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
nearest directory above it: the id, the name, the summary and the documents to read; or that no
|
|
15
|
+
--paths prints, for each path, the Primitives of the catalogue whose code names it, the file itself or
|
|
16
|
+
the nearest directory above it: the id, the name, the summary and the documents to read; or that no
|
|
14
17
|
Primitive names it. A path is read from the current directory. The catalogue is the one the
|
|
15
18
|
primitives Check reads, in the repository holding --root (default: the current directory). --json
|
|
16
19
|
prints one document instead.
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
--diff prints the Primitives the committed change from the merge-base of <base> and HEAD to HEAD
|
|
22
|
+
removes (an id gone from the catalogue), adds (a new id) and extends (a changed file in the code of a
|
|
23
|
+
Primitive that was there): a markdown block between <!-- primitives:start --> and <!-- primitives:end -->
|
|
24
|
+
for a pull request body, which a later run replaces. --json prints instead, per Primitive, its id, name,
|
|
25
|
+
class, file count, summary and docs, each doc with whether the change touches its file, and the count of
|
|
26
|
+
changed files outside every Primitive. It takes no --paths.
|
|
27
|
+
|
|
28
|
+
Exit 0 with an answer, 2 when there is no catalogue to read or it is not one, and for --diff when <base>
|
|
29
|
+
does not resolve.`;
|
|
19
30
|
const REMEDIES = {
|
|
20
31
|
missing: "create it, or point gate.checks.primitives.file at the catalogue",
|
|
21
32
|
invalid: "fix the catalogue; dw-sdlc gate names each problem under the primitives Check"
|
|
@@ -29,6 +40,7 @@ function parseCli(argv) {
|
|
|
29
40
|
help: { type: "boolean", short: "h" },
|
|
30
41
|
json: { type: "boolean" },
|
|
31
42
|
paths: { type: "boolean" },
|
|
43
|
+
diff: { type: "string" },
|
|
32
44
|
root: { type: "string" }
|
|
33
45
|
}
|
|
34
46
|
});
|
|
@@ -36,10 +48,17 @@ function parseCli(argv) {
|
|
|
36
48
|
help: values.help === true,
|
|
37
49
|
json: values.json === true,
|
|
38
50
|
paths: values.paths === true,
|
|
51
|
+
diff: values.diff,
|
|
39
52
|
files: positionals,
|
|
40
53
|
root: values.root
|
|
41
54
|
};
|
|
42
|
-
|
|
55
|
+
if (cli.help) {
|
|
56
|
+
return cli;
|
|
57
|
+
}
|
|
58
|
+
if (cli.diff !== undefined && (cli.paths || cli.files.length > 0)) {
|
|
59
|
+
return { error: "--diff and --paths exclude each other — ask one question at a time" };
|
|
60
|
+
}
|
|
61
|
+
return cli.paths || cli.diff !== undefined ? cli : { error: "name the paths with --paths, or a base with --diff" };
|
|
43
62
|
}
|
|
44
63
|
catch (error) {
|
|
45
64
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
@@ -68,6 +87,15 @@ function plain(path, primitives) {
|
|
|
68
87
|
` read: ${docs.join(", ")}`
|
|
69
88
|
]);
|
|
70
89
|
}
|
|
90
|
+
function runDiff(root, base, json) {
|
|
91
|
+
const answer = primitivesDiff(root, base);
|
|
92
|
+
if ("problems" in answer) {
|
|
93
|
+
process.stderr.write(answer.problems.map((problem) => `dw-sdlc primitives: ${problem}\n`).join(""));
|
|
94
|
+
return 2;
|
|
95
|
+
}
|
|
96
|
+
process.stdout.write(json ? `${JSON.stringify(answer, null, 2)}\n` : `${blockOf(answer)}\n`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
71
99
|
export function run(argv) {
|
|
72
100
|
const cli = parseCli(argv);
|
|
73
101
|
if ("error" in cli) {
|
|
@@ -79,6 +107,9 @@ export function run(argv) {
|
|
|
79
107
|
return 0;
|
|
80
108
|
}
|
|
81
109
|
const root = repositoryRoot(cli.root ?? process.cwd());
|
|
110
|
+
if (cli.diff !== undefined) {
|
|
111
|
+
return runDiff(root, cli.diff, cli.json);
|
|
112
|
+
}
|
|
82
113
|
const file = catalogueFile(resolveConfig(root).config.gate.checks);
|
|
83
114
|
const read = readCatalogue(root, file);
|
|
84
115
|
if (read.state !== "valid") {
|
package/dist/commands/recap.js
CHANGED
|
@@ -8,6 +8,7 @@ import { decide, declaredPolicy, labelOf, lineOf } from "../pr/decide.js";
|
|
|
8
8
|
import { CannotRun, defaultBranch, now, readBranch } from "../pr/read.js";
|
|
9
9
|
import { holds, upstreamName } from "../pr/upstream.js";
|
|
10
10
|
import { READY_LABEL, readNext } from "../recap-next.js";
|
|
11
|
+
import { rollup } from "../recap-primitives.js";
|
|
11
12
|
import { isLive, Refusal, workersOf, workingFolders } from "../worker/herdr.js";
|
|
12
13
|
import { asked, git } from "../working-tree.js";
|
|
13
14
|
export const summary = "the facts a Recap rests on, whether the session can be closed and what could come next";
|
|
@@ -32,6 +33,10 @@ A pull request closed without merging merges nothing, and a detached HEAD names
|
|
|
32
33
|
commit counts as pushed only on a remote branch, and the command asks the remote which branches still
|
|
33
34
|
stand. A branch force-pushed since the last fetch can still read as holding it, so fetch first to be exact.
|
|
34
35
|
|
|
36
|
+
Beside the facts it lists the Primitives the session's branch changed, as primitives --diff classes them
|
|
37
|
+
from its merge-base with origin/<default>: ids and classes, none on the default branch or in a repository
|
|
38
|
+
with no catalogue. A catalogue it cannot read lists none, with a warning.
|
|
39
|
+
|
|
35
40
|
A linked worktree is a reason beside the verdict, never against it: the session cannot remove the
|
|
36
41
|
directory it runs in.
|
|
37
42
|
|
|
@@ -192,11 +197,11 @@ function orphanedOf(root, read, shipped, held) {
|
|
|
192
197
|
* The commits on top of the base that no pull request holds. A branch the config protects takes changes
|
|
193
198
|
* through pull requests rather than making one, and so does the default branch: neither needs one.
|
|
194
199
|
*/
|
|
195
|
-
function aheadOf(root, branch,
|
|
200
|
+
function aheadOf(root, branch, baseOf) {
|
|
196
201
|
if (resolveConfig(root).config.guardrails.protectedBranches.includes(branch)) {
|
|
197
202
|
return null;
|
|
198
203
|
}
|
|
199
|
-
const base =
|
|
204
|
+
const base = baseOf();
|
|
200
205
|
if (branch === base) {
|
|
201
206
|
return null;
|
|
202
207
|
}
|
|
@@ -285,7 +290,11 @@ function nextLine(next) {
|
|
|
285
290
|
const named = next.candidates.map(({ number, parent }) => `#${number}${parent === null ? "" : ` (sub-issue of #${parent})`}`);
|
|
286
291
|
return named.length === 0 ? "none" : named.join(", ");
|
|
287
292
|
}
|
|
288
|
-
function
|
|
293
|
+
function primitivesLine(primitives) {
|
|
294
|
+
const named = primitives.map(({ id, class: kind }) => `${kind} ${id}`);
|
|
295
|
+
return named.length === 0 ? [] : [`primitives: ${named.join(", ")}`];
|
|
296
|
+
}
|
|
297
|
+
function linesOf(local, { shipped, askedGh, workers, next }, primitives, line) {
|
|
289
298
|
const none = askedGh ? "none" : "gh not asked";
|
|
290
299
|
return [
|
|
291
300
|
`branch: ${local.branch ?? "detached HEAD"}`,
|
|
@@ -295,6 +304,7 @@ function linesOf(local, { shipped, askedGh, workers, next }, line) {
|
|
|
295
304
|
`worktree: ${local.worktree.linked ? `linked at ${local.worktree.path}` : "main checkout"}`,
|
|
296
305
|
`workers: ${workersLine(workers)}`,
|
|
297
306
|
`next: ${nextLine(next)}`,
|
|
307
|
+
...primitivesLine(primitives),
|
|
298
308
|
line
|
|
299
309
|
].join("\n");
|
|
300
310
|
}
|
|
@@ -328,7 +338,9 @@ function report(json) {
|
|
|
328
338
|
const local = standing(root, read, shipped, held);
|
|
329
339
|
// An open pull request can still take the head, and a commit-less repository has nothing to merge.
|
|
330
340
|
const nothingToMerge = held || shipped?.pr.state === "OPEN" || head === null;
|
|
331
|
-
|
|
341
|
+
let named;
|
|
342
|
+
const baseOf = () => (named ??= shipped?.pr.base ?? defaultBranch());
|
|
343
|
+
const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, baseOf) : null;
|
|
332
344
|
const facts = {
|
|
333
345
|
shipped,
|
|
334
346
|
ahead,
|
|
@@ -340,14 +352,18 @@ function report(json) {
|
|
|
340
352
|
if (facts.next !== null && "unread" in facts.next) {
|
|
341
353
|
process.stderr.write(`dw-sdlc recap: warning: the backlog was not read: ${facts.next.unread}\n`);
|
|
342
354
|
}
|
|
355
|
+
const rolled = asksGh ? rollup(root, branch, defaultBranch) : { primitives: [] };
|
|
356
|
+
if ("unread" in rolled) {
|
|
357
|
+
process.stderr.write(`dw-sdlc recap: warning: the Primitives were not read: ${rolled.unread}\n`);
|
|
358
|
+
}
|
|
343
359
|
const next = facts.next !== null && "candidates" in facts.next ? facts.next.candidates : null;
|
|
344
360
|
const close = closeOf(reasonsOf(local, facts));
|
|
345
361
|
const pullRequest = shipped === null
|
|
346
362
|
? null
|
|
347
363
|
: { url: shipped.pr.url, number: shipped.pr.number, state: shipped.pr.state, ...shipped.answer };
|
|
348
364
|
const output = json
|
|
349
|
-
? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, close, next }, null, 2)
|
|
350
|
-
: linesOf(local, facts, close.line);
|
|
365
|
+
? JSON.stringify({ ...local, askedGh: asksGh, pullRequest, workers: facts.workers, primitives: rolled.primitives, close, next }, null, 2)
|
|
366
|
+
: linesOf(local, facts, rolled.primitives, close.line);
|
|
351
367
|
process.stdout.write(`${output}\n`);
|
|
352
368
|
return close.verdict === "yes" ? 0 : 1;
|
|
353
369
|
}
|
package/dist/markdown.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type Link = {
|
|
|
12
12
|
target: string;
|
|
13
13
|
line: number;
|
|
14
14
|
};
|
|
15
|
+
/** The 1-based line of the code fence `text` leaves open at its end; none when every fence closes. */
|
|
16
|
+
export declare function openFence(text: string): number | undefined;
|
|
15
17
|
/** Every ATX heading in `text`, a repeated anchor numbered `-1`, `-2` as GitHub numbers it. */
|
|
16
18
|
export declare function headings(text: string): Heading[];
|
|
17
19
|
/**
|
package/dist/markdown.js
CHANGED
|
@@ -13,15 +13,24 @@ function scan(text) {
|
|
|
13
13
|
const marker = FENCE.exec(line)?.[1];
|
|
14
14
|
if (fence === undefined && marker !== undefined) {
|
|
15
15
|
fence = marker;
|
|
16
|
-
return { text: line, line: index + 1, fenced: true };
|
|
16
|
+
return { text: line, line: index + 1, fenced: true, edge: "open" };
|
|
17
17
|
}
|
|
18
18
|
if (fence !== undefined && marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) {
|
|
19
19
|
fence = undefined;
|
|
20
|
-
return { text: line, line: index + 1, fenced: true };
|
|
20
|
+
return { text: line, line: index + 1, fenced: true, edge: "close" };
|
|
21
21
|
}
|
|
22
22
|
return { text: line, line: index + 1, fenced: fence !== undefined };
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
|
+
/** The 1-based line of the code fence `text` leaves open at its end; none when every fence closes. */
|
|
26
|
+
export function openFence(text) {
|
|
27
|
+
const lines = scan(text);
|
|
28
|
+
const last = lines.at(-1);
|
|
29
|
+
if (last === undefined || !last.fenced || last.edge === "close") {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return lines.findLast(({ edge }) => edge === "open")?.line;
|
|
33
|
+
}
|
|
25
34
|
/**
|
|
26
35
|
* GitHub's anchor for a heading: its text as rendered, lowercased, every character but a letter, a
|
|
27
36
|
* digit, a space, `-` and `_` dropped, each space a hyphen.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type PrimitiveClass = "removes" | "adds" | "extends";
|
|
2
|
+
export type Change = {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
class: PrimitiveClass;
|
|
6
|
+
/** The changed files the Primitive owns. */
|
|
7
|
+
files: number;
|
|
8
|
+
summary: string;
|
|
9
|
+
/** Each `docs` entry, and whether the diff changes its file, whatever its anchor. */
|
|
10
|
+
docs: {
|
|
11
|
+
entry: string;
|
|
12
|
+
touched: boolean;
|
|
13
|
+
}[];
|
|
14
|
+
};
|
|
15
|
+
export type PrimitivesDiff = {
|
|
16
|
+
base: string;
|
|
17
|
+
primitives: Change[];
|
|
18
|
+
outside: number;
|
|
19
|
+
};
|
|
20
|
+
/** Why a diff cannot be answered: each problem with its remedy. */
|
|
21
|
+
export type Refusal = {
|
|
22
|
+
problems: string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The Primitives `base...HEAD` removes, adds and extends. The catalogue's path comes from the config of
|
|
26
|
+
* the checkout; the catalogue is read at `HEAD` and at the merge-base, from the commits and not the tree.
|
|
27
|
+
* A file's owners are the nearest entries of the catalogue it is read against, as `--paths` answers.
|
|
28
|
+
*/
|
|
29
|
+
export declare function primitivesDiff(root: string, base: string): PrimitivesDiff | Refusal;
|
|
30
|
+
/** The markdown block between its markers, which a later run replaces whole. */
|
|
31
|
+
export declare function blockOf({ primitives, outside }: PrimitivesDiff): string;
|