dw-sdlc 0.17.0 → 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 +15 -1
- package/config/agentic.config.schema.json +16 -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/decision-records.d.ts +15 -0
- package/dist/checks/decision-records.js +15 -1
- package/dist/checks/index.d.ts +1 -0
- package/dist/checks/index.js +7 -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/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/run-command.js +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,20 @@ its `roots` to `maxProseLines` lines of prose, 3 by default.
|
|
|
50
50
|
`eslint-disable*`, `@ts-expect-error`, `@ts-ignore`, `rubocop:disable` and `noqa`. A reason follows `--`
|
|
51
51
|
on the directive's line, follows an `@ts-` directive, or is a comment on the line above.
|
|
52
52
|
|
|
53
|
+
`skill-sections` holds each README section a skill copies to its source. A part sits between two
|
|
54
|
+
markers, each on its own line, and `dw-sdlc check skill-sections --write` fills an empty pair:
|
|
55
|
+
|
|
56
|
+
```md
|
|
57
|
+
<!-- README.md#errors -->
|
|
58
|
+
<!-- /README.md#errors -->
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The anchor is GitHub's, and the section runs from its heading to the next heading of its level or
|
|
62
|
+
above, in the `README.md` beside the `skills` directory that holds the file, or beside the file
|
|
63
|
+
outside one. The Check fails a part that differs from its section, a part whose anchor names no
|
|
64
|
+
heading of the README, a part from a section no Primitive in `.ai/primitives.json` names among its
|
|
65
|
+
docs, a marker without its pair, and a code fence that never closes above a marker, which hides it.
|
|
66
|
+
|
|
53
67
|
## The Ship script
|
|
54
68
|
|
|
55
69
|
`dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
|
|
@@ -94,7 +108,7 @@ buys one more Review past it.
|
|
|
94
108
|
| ------------------ | ------------------------------------------------------------------------------------------ |
|
|
95
109
|
| `gate` | the Steps of `gate.steps` in order, then every Check under `gate.checks` |
|
|
96
110
|
| `check <name>` | one Check of the Gate by its name, without the Steps and the other Checks |
|
|
97
|
-
| `primitives` | the Primitives that own each path,
|
|
111
|
+
| `primitives` | the Primitives that own each path, or that a change removes, adds or extends |
|
|
98
112
|
| `setup` | this machine's local config layer, the git excludes and the permission rules |
|
|
99
113
|
| `doctor` | one PASS / WARN / FAIL line per machine check the guardrails rely on |
|
|
100
114
|
| `config` | the resolved agentic config, or one file validated against the schema |
|
|
@@ -428,6 +428,22 @@
|
|
|
428
428
|
}
|
|
429
429
|
},
|
|
430
430
|
"additionalProperties": false
|
|
431
|
+
},
|
|
432
|
+
"skill-sections": {
|
|
433
|
+
"description": "Every README section a skill copies matches its source. A part is the section of the README under the heading with GitHub's anchor `<anchor>`, up to the next heading of its level or above, copied whole between `<!-- README.md#<anchor> -->` and `<!-- /README.md#<anchor> -->`, each on its own line, with a blank line inside each. The README is the `README.md` of the directory that holds the `skills` directory the file is in, or of the file's own directory outside one. A part fails when it differs from its section, when the README has no heading with its anchor, and when no Primitive in the catalogue `primitives` reads names `<README>#<anchor>` among its docs; a marker without its pair fails too. A marker inside code is none, and a code fence that never closes above a marker fails, since it hides that marker. `dw-sdlc check skill-sections --write` writes each part from its section. A repository with no part passes without reading the catalogue; one with a part and no valid catalogue cannot run.",
|
|
434
|
+
"type": ["boolean", "object"],
|
|
435
|
+
"properties": {
|
|
436
|
+
"files": {
|
|
437
|
+
"description": "Globs of the skill files, against the files git tracks or would track; `*` and `**` skip a name that starts with a dot. Default `**/skills/**/*.md`.",
|
|
438
|
+
"type": "array",
|
|
439
|
+
"items": {
|
|
440
|
+
"type": "string",
|
|
441
|
+
"minLength": 1
|
|
442
|
+
},
|
|
443
|
+
"uniqueItems": true
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
"additionalProperties": false
|
|
431
447
|
}
|
|
432
448
|
},
|
|
433
449
|
"additionalProperties": false
|
package/dist/catalogue.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export type CatalogueRead = {
|
|
|
27
27
|
catalogue: Catalogue;
|
|
28
28
|
text: string;
|
|
29
29
|
};
|
|
30
|
+
/** The catalogue `text` holds, or why it is not one. */
|
|
31
|
+
export declare function parseCatalogue(text: string): Exclude<CatalogueRead, {
|
|
32
|
+
state: "missing";
|
|
33
|
+
}>;
|
|
30
34
|
/** The catalogue at `file` under `root`, or why there is none to read. */
|
|
31
35
|
export declare function readCatalogue(root: string, file: string): CatalogueRead;
|
|
32
36
|
/**
|
package/dist/catalogue.js
CHANGED
|
@@ -34,13 +34,8 @@ const SCHEMA = {
|
|
|
34
34
|
},
|
|
35
35
|
additionalProperties: false
|
|
36
36
|
};
|
|
37
|
-
/** The catalogue
|
|
38
|
-
export function
|
|
39
|
-
const path = join(root, file);
|
|
40
|
-
if (!existsSync(path)) {
|
|
41
|
-
return { state: "missing" };
|
|
42
|
-
}
|
|
43
|
-
const text = readFileSync(path, "utf8");
|
|
37
|
+
/** The catalogue `text` holds, or why it is not one. */
|
|
38
|
+
export function parseCatalogue(text) {
|
|
44
39
|
let parsed;
|
|
45
40
|
try {
|
|
46
41
|
parsed = JSON.parse(text);
|
|
@@ -55,6 +50,11 @@ export function readCatalogue(root, file) {
|
|
|
55
50
|
// The schema above is the runtime twin of `Catalogue`: a document it accepts has this shape.
|
|
56
51
|
return { state: "valid", catalogue: parsed, text };
|
|
57
52
|
}
|
|
53
|
+
/** The catalogue at `file` under `root`, or why there is none to read. */
|
|
54
|
+
export function readCatalogue(root, file) {
|
|
55
|
+
const path = join(root, file);
|
|
56
|
+
return existsSync(path) ? parseCatalogue(readFileSync(path, "utf8")) : { state: "missing" };
|
|
57
|
+
}
|
|
58
58
|
/**
|
|
59
59
|
* How near the `code` entry `entry`, a file or a directory, names `path`: the length of the entry when
|
|
60
60
|
* it is the path or a directory above it, -1 when it is neither.
|
package/dist/checks/check.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type Check<Options extends object> = {
|
|
|
22
22
|
run: (root: string, options: Options) => Finding[];
|
|
23
23
|
/** Present on a Ratchet. */
|
|
24
24
|
baseline?: Baseline<Options>;
|
|
25
|
+
/** Present on a Check that derives files from their sources: writes them, says what it wrote. */
|
|
26
|
+
write?: (root: string, options: Options) => string;
|
|
25
27
|
};
|
|
26
28
|
/** The options `setting` turns the Check on with: the defaults under `true`, an object over them, else off. */
|
|
27
29
|
export declare function optionsOf<Options extends object>(check: Check<Options>, setting: unknown): Options | undefined;
|
|
@@ -7,5 +7,20 @@ export type DecisionRecordsOptions = {
|
|
|
7
7
|
statuses: string[];
|
|
8
8
|
fields: DecisionField[];
|
|
9
9
|
};
|
|
10
|
+
/** The first words of a status that say a record once governed and no longer does. */
|
|
11
|
+
export declare const OUT_OF_FORCE: Set<string>;
|
|
12
|
+
/**
|
|
13
|
+
* The `status` of the frontmatter `text` opens with, its quotes dropped, and its 1-based line. It keeps a
|
|
14
|
+
* reader apart from `frontmatter`, which a status never goes through: a ` #` is part of it.
|
|
15
|
+
*/
|
|
16
|
+
export declare function statusOf(text: string): {
|
|
17
|
+
value: string;
|
|
18
|
+
line: number;
|
|
19
|
+
} | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The status of the decision record `path` names, whole, or "" when it has none; undefined when `path` is
|
|
22
|
+
* no record of `dirs` or is not on disk. A record is a file `<number>-<slug>.md` directly under a directory.
|
|
23
|
+
*/
|
|
24
|
+
export declare function recordStatus(root: string, dirs: string[], path: string): string | undefined;
|
|
10
25
|
export declare const decisionRecords: Check<DecisionRecordsOptions>;
|
|
11
26
|
export {};
|
|
@@ -12,6 +12,8 @@ const FIELDS = ["date", "touches", "superseded-by", "amended-by"];
|
|
|
12
12
|
const RECORD = /^(\d+)-[^/]+\.md$/;
|
|
13
13
|
/** The first words of a status that put a record in force, so it governs the paths under its `touches`. */
|
|
14
14
|
const IN_FORCE = new Set(["accepted", "active"]);
|
|
15
|
+
/** The first words of a status that say a record once governed and no longer does. */
|
|
16
|
+
export const OUT_OF_FORCE = new Set(["deprecated", "superseded"]);
|
|
15
17
|
/** A character that makes a path under `touches` a glob. */
|
|
16
18
|
const GLOB = /[*?[\]{}]/;
|
|
17
19
|
const REMEDIES = {
|
|
@@ -37,7 +39,7 @@ function isDirectory(path) {
|
|
|
37
39
|
* The `status` of the frontmatter `text` opens with, its quotes dropped, and its 1-based line. It keeps a
|
|
38
40
|
* reader apart from `frontmatter`, which a status never goes through: a ` #` is part of it.
|
|
39
41
|
*/
|
|
40
|
-
function statusOf(text) {
|
|
42
|
+
export function statusOf(text) {
|
|
41
43
|
const lines = text.split("\n");
|
|
42
44
|
if (lines[0]?.trim() !== "---") {
|
|
43
45
|
return undefined;
|
|
@@ -55,6 +57,18 @@ function isDay(value) {
|
|
|
55
57
|
const date = new Date(`${value}T00:00:00Z`);
|
|
56
58
|
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The status of the decision record `path` names, whole, or "" when it has none; undefined when `path` is
|
|
62
|
+
* no record of `dirs` or is not on disk. A record is a file `<number>-<slug>.md` directly under a directory.
|
|
63
|
+
*/
|
|
64
|
+
export function recordStatus(root, dirs, path) {
|
|
65
|
+
const inDirs = dirs.some((dir) => {
|
|
66
|
+
const prefix = `${dir.replace(/\/+$/, "")}/`;
|
|
67
|
+
return path.startsWith(prefix) && RECORD.test(path.slice(prefix.length));
|
|
68
|
+
});
|
|
69
|
+
const file = join(root, path);
|
|
70
|
+
return inDirs && existsSync(file) ? (statusOf(readFileSync(file, "utf8"))?.value ?? "") : undefined;
|
|
71
|
+
}
|
|
58
72
|
function judgeDate({ file, keys }) {
|
|
59
73
|
const date = keys.get("date");
|
|
60
74
|
if (date === undefined || date.value === "") {
|
package/dist/checks/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type EnabledCheck = {
|
|
|
7
7
|
file: string;
|
|
8
8
|
refresh: (root: string) => string;
|
|
9
9
|
};
|
|
10
|
+
write?: (root: string) => string;
|
|
10
11
|
};
|
|
11
12
|
/** The name of every Check the package ships, in the order the Gate runs them. */
|
|
12
13
|
export declare const CHECK_NAMES: readonly string[];
|
package/dist/checks/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { markdownBudget } from "./markdown-budget.js";
|
|
|
10
10
|
import { markdownRefs } from "./markdown-refs.js";
|
|
11
11
|
import { primitives } from "./primitives.js";
|
|
12
12
|
import { routedDocs } from "./routed-docs.js";
|
|
13
|
+
import { skillSections } from "./skill-sections.js";
|
|
13
14
|
import { temporalLanguage } from "./temporal-language.js";
|
|
14
15
|
function ship(check) {
|
|
15
16
|
return {
|
|
@@ -24,6 +25,10 @@ function ship(check) {
|
|
|
24
25
|
if (baseline !== undefined) {
|
|
25
26
|
enabled.baseline = { file: baseline.file, refresh: (root) => baseline.refresh(root, options) };
|
|
26
27
|
}
|
|
28
|
+
const { write } = check;
|
|
29
|
+
if (write !== undefined) {
|
|
30
|
+
enabled.write = (root) => write(root, options);
|
|
31
|
+
}
|
|
27
32
|
return enabled;
|
|
28
33
|
}
|
|
29
34
|
};
|
|
@@ -38,7 +43,8 @@ const SHIPPED = [
|
|
|
38
43
|
ship(decisionRecords),
|
|
39
44
|
ship(fileSizeRatchet),
|
|
40
45
|
ship(commentBudget),
|
|
41
|
-
ship(disableDirectives)
|
|
46
|
+
ship(disableDirectives),
|
|
47
|
+
ship(skillSections)
|
|
42
48
|
];
|
|
43
49
|
/** The name of every Check the package ships, in the order the Gate runs them. */
|
|
44
50
|
export const CHECK_NAMES = SHIPPED.map(({ name }) => name);
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// `primitives`: the catalogue says what is true of the tree. Unique ids
|
|
2
|
-
// summary,
|
|
3
|
-
// named by a Primitive.
|
|
1
|
+
// `primitives`: the catalogue says what is true of the tree. Unique ids that start with their group, a
|
|
2
|
+
// one-line summary, real code and docs paths, no decision record out of force, every file named.
|
|
4
3
|
import { existsSync } from "node:fs";
|
|
5
4
|
import { join } from "node:path";
|
|
5
|
+
import { resolveConfig } from "../agentic-config.js";
|
|
6
6
|
import { CATALOGUE_FILE, primitivesNaming, readCatalogue } from "../catalogue.js";
|
|
7
7
|
import { anchorsOf } from "../markdown.js";
|
|
8
8
|
import { CannotRun, optionsOf } from "./check.js";
|
|
9
|
+
import { decisionRecords, OUT_OF_FORCE, recordStatus } from "./decision-records.js";
|
|
9
10
|
import { treeFiles } from "./tree.js";
|
|
10
11
|
const MAX_SUMMARY = 120;
|
|
11
12
|
/** A file a test runs, never shipped: under a test directory, or named as a test. */
|
|
@@ -14,6 +15,8 @@ const REMEDIES = {
|
|
|
14
15
|
missing: "create it, or point gate.checks.primitives.file at the catalogue",
|
|
15
16
|
shape: "give it the catalogue's shape: version 1, groups of id and name, primitives of id, name, group, summary, code and docs",
|
|
16
17
|
duplicate: "give each Primitive an id of its own",
|
|
18
|
+
prefix: "start the id with its group and a . or a -, or move the Primitive into the group its id starts with",
|
|
19
|
+
record: "name the record that replaces it, or drop the entry",
|
|
17
20
|
group: "add the group to groups, or move the Primitive into one listed there",
|
|
18
21
|
summary: `keep the summary to one line of ${MAX_SUMMARY} characters or fewer`,
|
|
19
22
|
path: "fix the path or drop it",
|
|
@@ -30,10 +33,13 @@ function lineOf(text, id, nth) {
|
|
|
30
33
|
return match?.index === undefined ? undefined : text.slice(0, start + match.index).split("\n").length;
|
|
31
34
|
}
|
|
32
35
|
/** What is wrong with one Primitive, declared at `at`: its group, its summary, each code and docs entry. */
|
|
33
|
-
function judge(root, catalogue, primitive, at, cache) {
|
|
36
|
+
function judge(root, catalogue, primitive, at, cache, records) {
|
|
34
37
|
const { id, group, summary, code, docs } = primitive;
|
|
35
38
|
const findings = [];
|
|
36
39
|
const fail = (message, remedy) => findings.push({ ...at, message, remedy });
|
|
40
|
+
if (!id.startsWith(`${group}.`) && !id.startsWith(`${group}-`)) {
|
|
41
|
+
fail(`the id ${show(id)} does not start with its group ${show(group)} and a "." or a "-"`, REMEDIES.prefix);
|
|
42
|
+
}
|
|
37
43
|
if (!catalogue.groups.some((known) => known.id === group)) {
|
|
38
44
|
fail(`${show(id)} is in group ${show(group)}, which groups does not list`, REMEDIES.group);
|
|
39
45
|
}
|
|
@@ -55,6 +61,10 @@ function judge(root, catalogue, primitive, at, cache) {
|
|
|
55
61
|
else if (anchor !== undefined && !anchorsOf(root, path, cache).has(anchor)) {
|
|
56
62
|
fail(`${show(id)} names the doc ${show(entry)}, which has no heading with that anchor`, REMEDIES.anchor);
|
|
57
63
|
}
|
|
64
|
+
const status = recordStatus(root, records, path);
|
|
65
|
+
if (status !== undefined && OUT_OF_FORCE.has(status.split(/\s+/)[0] ?? "")) {
|
|
66
|
+
fail(`${show(id)} names the decision record ${show(entry)}, whose status ${show(status)} is no longer in force`, REMEDIES.record);
|
|
67
|
+
}
|
|
58
68
|
}
|
|
59
69
|
return findings;
|
|
60
70
|
}
|
|
@@ -67,6 +77,7 @@ function run(root, { file, roots }) {
|
|
|
67
77
|
return read.problems.map((message) => ({ file, message, remedy: REMEDIES.shape }));
|
|
68
78
|
}
|
|
69
79
|
const { catalogue, text } = read;
|
|
80
|
+
const records = (optionsOf(decisionRecords, resolveConfig(root).config.gate.checks["decision-records"]) ?? decisionRecords.defaults).dirs;
|
|
70
81
|
const findings = [];
|
|
71
82
|
const seen = new Map();
|
|
72
83
|
const cache = new Map();
|
|
@@ -82,7 +93,7 @@ function run(root, { file, roots }) {
|
|
|
82
93
|
remedy: REMEDIES.duplicate
|
|
83
94
|
});
|
|
84
95
|
}
|
|
85
|
-
findings.push(...judge(root, catalogue, primitive, at, cache));
|
|
96
|
+
findings.push(...judge(root, catalogue, primitive, at, cache, records));
|
|
86
97
|
}
|
|
87
98
|
for (const path of treeFiles(root, roots)) {
|
|
88
99
|
if (!TEST_FILE.test(path) && primitivesNaming(catalogue, path).length === 0) {
|
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The Primitives a change removes, adds or extends, decided from the paths it changes and the catalogue
|
|
2
|
+
// at both ends of `base...HEAD`: nothing is judged. `dw-sdlc primitives --diff` prints it and `recap`
|
|
3
|
+
// rolls it up for the session's branch.
|
|
4
|
+
import { resolveConfig } from "./agentic-config.js";
|
|
5
|
+
import { parseCatalogue, primitivesNaming } from "./catalogue.js";
|
|
6
|
+
import { catalogueFile } from "./checks/primitives.js";
|
|
7
|
+
import { asked, git } from "./working-tree.js";
|
|
8
|
+
const CLASSES = ["removes", "adds", "extends"];
|
|
9
|
+
const REMEDIES = {
|
|
10
|
+
missing: "create it, or point gate.checks.primitives.file at the catalogue",
|
|
11
|
+
invalid: "fix the catalogue; dw-sdlc gate names each problem under the primitives Check",
|
|
12
|
+
base: "fetch it, or name a ref that exists",
|
|
13
|
+
git: "check the repository with git status"
|
|
14
|
+
};
|
|
15
|
+
function refuse(problem, remedy) {
|
|
16
|
+
return { problems: [`${problem} — ${remedy}`] };
|
|
17
|
+
}
|
|
18
|
+
/** The catalogue `file` holds at `rev`, `missing` when `rev` has no such file. */
|
|
19
|
+
function readAt(root, rev, file) {
|
|
20
|
+
if (asked(root, ["cat-file", "-e", `${rev}:${file}`]) === undefined) {
|
|
21
|
+
return { state: "missing" };
|
|
22
|
+
}
|
|
23
|
+
const shown = git(root, ["show", `${rev}:${file}`]);
|
|
24
|
+
return "error" in shown ? { state: "invalid", problems: [shown.error] } : parseCatalogue(shown.out);
|
|
25
|
+
}
|
|
26
|
+
/** The merge-base of `base` and `HEAD`, and the files `base...HEAD` changes: a deleted one and both ends of a rename too. */
|
|
27
|
+
function changeSince(root, base) {
|
|
28
|
+
const resolved = asked(root, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]);
|
|
29
|
+
if (resolved === undefined) {
|
|
30
|
+
return refuse(`the base ${JSON.stringify(base)} does not resolve to a commit`, REMEDIES.base);
|
|
31
|
+
}
|
|
32
|
+
const mergeBase = asked(root, ["merge-base", resolved, "HEAD"]);
|
|
33
|
+
if (mergeBase === undefined) {
|
|
34
|
+
return refuse(`the base ${JSON.stringify(base)} shares no history with HEAD`, REMEDIES.base);
|
|
35
|
+
}
|
|
36
|
+
const diff = git(root, ["diff", "--name-only", "-z", "--no-renames", mergeBase, "HEAD"]);
|
|
37
|
+
if ("error" in diff) {
|
|
38
|
+
return refuse(diff.error, REMEDIES.git);
|
|
39
|
+
}
|
|
40
|
+
return { mergeBase, files: diff.out.split("\0").filter((path) => path !== "") };
|
|
41
|
+
}
|
|
42
|
+
/** How many of `files` the Primitive `id` owns in any of `catalogues`. */
|
|
43
|
+
function owned(catalogues, files, id) {
|
|
44
|
+
return files.filter((file) => catalogues.some((catalogue) => primitivesNaming(catalogue, file).some((primitive) => primitive.id === id))).length;
|
|
45
|
+
}
|
|
46
|
+
function describe(primitive, kind, files, changed) {
|
|
47
|
+
const docs = primitive.docs.map((entry) => ({ entry, touched: changed.has(entry.split("#")[0] ?? entry) }));
|
|
48
|
+
return { id: primitive.id, name: primitive.name, class: kind, files, summary: primitive.summary, docs };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The Primitives `base...HEAD` removes, adds and extends. The catalogue's path comes from the config of
|
|
52
|
+
* the checkout; the catalogue is read at `HEAD` and at the merge-base, from the commits and not the tree.
|
|
53
|
+
* A file's owners are the nearest entries of the catalogue it is read against, as `--paths` answers.
|
|
54
|
+
*/
|
|
55
|
+
export function primitivesDiff(root, base) {
|
|
56
|
+
const file = catalogueFile(resolveConfig(root).config.gate.checks);
|
|
57
|
+
const head = readAt(root, "HEAD", file);
|
|
58
|
+
if (head.state === "missing") {
|
|
59
|
+
return refuse(`${file}: the catalogue does not exist at HEAD`, REMEDIES.missing);
|
|
60
|
+
}
|
|
61
|
+
if (head.state === "invalid") {
|
|
62
|
+
return { problems: head.problems.map((problem) => `${file}: ${problem} — ${REMEDIES.invalid}`) };
|
|
63
|
+
}
|
|
64
|
+
const since = changeSince(root, base);
|
|
65
|
+
if ("problems" in since) {
|
|
66
|
+
return since;
|
|
67
|
+
}
|
|
68
|
+
const { mergeBase, files } = since;
|
|
69
|
+
const before = readAt(root, mergeBase, file);
|
|
70
|
+
if (before.state === "invalid") {
|
|
71
|
+
const at = `${file} at ${JSON.stringify(base)}`;
|
|
72
|
+
return { problems: before.problems.map((problem) => `${at}: ${problem} — ${REMEDIES.invalid}`) };
|
|
73
|
+
}
|
|
74
|
+
const atBase = before.state === "valid" ? before.catalogue : { version: 1, groups: [], primitives: [] };
|
|
75
|
+
const atHead = head.catalogue;
|
|
76
|
+
const changed = new Set(files);
|
|
77
|
+
const inBase = new Set(atBase.primitives.map(({ id }) => id));
|
|
78
|
+
const inHead = new Set(atHead.primitives.map(({ id }) => id));
|
|
79
|
+
const changes = [
|
|
80
|
+
...atBase.primitives
|
|
81
|
+
.filter(({ id }) => !inHead.has(id))
|
|
82
|
+
.map((primitive) => describe(primitive, "removes", owned([atBase], files, primitive.id), changed)),
|
|
83
|
+
...atHead.primitives.flatMap((primitive) => {
|
|
84
|
+
const kind = inBase.has(primitive.id) ? "extends" : "adds";
|
|
85
|
+
const count = owned(kind === "adds" ? [atHead] : [atBase, atHead], files, primitive.id);
|
|
86
|
+
return kind === "adds" || count > 0 ? [describe(primitive, kind, count, changed)] : [];
|
|
87
|
+
})
|
|
88
|
+
];
|
|
89
|
+
const outside = files.filter((path) => primitivesNaming(atHead, path).length + primitivesNaming(atBase, path).length === 0);
|
|
90
|
+
const rank = ({ class: kind }) => CLASSES.indexOf(kind);
|
|
91
|
+
changes.sort((a, b) => rank(a) - rank(b) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
92
|
+
return { base, primitives: changes, outside: outside.length };
|
|
93
|
+
}
|
|
94
|
+
function plural(howMany, word) {
|
|
95
|
+
return `${howMany} ${word}${howMany === 1 ? "" : "s"}`;
|
|
96
|
+
}
|
|
97
|
+
function cell(text) {
|
|
98
|
+
return text.replaceAll("|", "\\|");
|
|
99
|
+
}
|
|
100
|
+
/** The markdown block between its markers, which a later run replaces whole. */
|
|
101
|
+
export function blockOf({ primitives, outside }) {
|
|
102
|
+
const start = "<!-- primitives:start -->";
|
|
103
|
+
const end = "<!-- primitives:end -->";
|
|
104
|
+
if (primitives.length === 0) {
|
|
105
|
+
return [start, "No Primitive changes.", end].join("\n");
|
|
106
|
+
}
|
|
107
|
+
const counts = CLASSES.map((kind) => ({ kind, howMany: primitives.filter((change) => change.class === kind).length }));
|
|
108
|
+
const clauses = counts
|
|
109
|
+
.filter(({ howMany }) => howMany > 0)
|
|
110
|
+
.map(({ kind, howMany }, position) => position === 0
|
|
111
|
+
? `${kind[0]?.toUpperCase()}${kind.slice(1)} ${plural(howMany, "Primitive")}`
|
|
112
|
+
: `${kind} ${howMany}`);
|
|
113
|
+
const rows = primitives.map((change) => `| \`${cell(change.id)}\` (${cell(change.name)}) | ${change.class} | ${change.files} |`);
|
|
114
|
+
return [
|
|
115
|
+
start,
|
|
116
|
+
`**${clauses.join(", ")}.** ${plural(outside, "file")} outside the catalogue.`,
|
|
117
|
+
"",
|
|
118
|
+
"| Primitive | Class | Files |",
|
|
119
|
+
"| --- | --- | --- |",
|
|
120
|
+
...rows,
|
|
121
|
+
end
|
|
122
|
+
].join("\n");
|
|
123
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type PrimitiveClass } from "./primitives-diff.ts";
|
|
2
|
+
export type Rolled = {
|
|
3
|
+
id: string;
|
|
4
|
+
class: PrimitiveClass;
|
|
5
|
+
};
|
|
6
|
+
/** What the rollup read: the Primitives, and why it could not read them when it could not. */
|
|
7
|
+
export type Rollup = {
|
|
8
|
+
primitives: Rolled[];
|
|
9
|
+
unread?: string;
|
|
10
|
+
};
|
|
11
|
+
/** `defaultOf` names the default branch and is asked only once the repository has a catalogue and `branch` is no base. */
|
|
12
|
+
export declare function rollup(root: string, branch: string, defaultOf: () => string): Rollup;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The Primitives a session's branch changed, for `dw-sdlc recap`: the rollup of `primitives --diff` from
|
|
2
|
+
// the branch's merge-base with `origin/<default>`, ids and classes only. A branch the config protects,
|
|
3
|
+
// the default branch and a repository with no catalogue roll up to none.
|
|
4
|
+
import { resolveConfig } from "./agentic-config.js";
|
|
5
|
+
import { catalogueFile } from "./checks/primitives.js";
|
|
6
|
+
import { primitivesDiff } from "./primitives-diff.js";
|
|
7
|
+
import { asked } from "./working-tree.js";
|
|
8
|
+
/** `defaultOf` names the default branch and is asked only once the repository has a catalogue and `branch` is no base. */
|
|
9
|
+
export function rollup(root, branch, defaultOf) {
|
|
10
|
+
const { guardrails, gate } = resolveConfig(root).config;
|
|
11
|
+
if (guardrails.protectedBranches.includes(branch)) {
|
|
12
|
+
return { primitives: [] };
|
|
13
|
+
}
|
|
14
|
+
if (asked(root, ["cat-file", "-e", `HEAD:${catalogueFile(gate.checks)}`]) === undefined) {
|
|
15
|
+
return { primitives: [] };
|
|
16
|
+
}
|
|
17
|
+
const base = defaultOf();
|
|
18
|
+
if (branch === base) {
|
|
19
|
+
return { primitives: [] };
|
|
20
|
+
}
|
|
21
|
+
const answer = primitivesDiff(root, `origin/${base}`);
|
|
22
|
+
if ("problems" in answer) {
|
|
23
|
+
return { primitives: [], unread: answer.problems.join("; ") };
|
|
24
|
+
}
|
|
25
|
+
return { primitives: answer.primitives.map(({ id, class: kind }) => ({ id, class: kind })) };
|
|
26
|
+
}
|
package/dist/run-command.js
CHANGED
|
@@ -46,8 +46,10 @@ export async function holdingSignals(body) {
|
|
|
46
46
|
return await body();
|
|
47
47
|
}
|
|
48
48
|
finally {
|
|
49
|
-
//
|
|
50
|
-
|
|
49
|
+
// libuv takes a signal at once but hands it to its handler only when the loop next polls, and a
|
|
50
|
+
// handler released before that poll loses it. The last command's exit is read in a poll and the
|
|
51
|
+
// first immediate runs right after it, so the second waits for one more poll.
|
|
52
|
+
await new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
|
|
51
53
|
// The Gate's own signal may still be on its way after a command it ended: the handlers stay, so
|
|
52
54
|
// that signal does not end the Gate before its report.
|
|
53
55
|
if (commandEndedBy === undefined || received !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|