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 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, with their summaries and documents |
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
@@ -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 at `file` under `root`, or why there is none to read. */
38
- export function readCatalogue(root, file) {
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.
@@ -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 === "") {
@@ -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[];
@@ -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, a known group, a one-line
2
- // summary, every code and docs path on disk with its anchor resolving, and every file under the roots
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,5 @@
1
+ import { type Check } from "./check.ts";
2
+ export type SkillSectionsOptions = {
3
+ files: string[];
4
+ };
5
+ export declare const skillSections: Check<SkillSectionsOptions>;
@@ -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
+ };
@@ -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 { CHECK_NAMES } from "../checks/index.js";
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
- return { help: values.help === true, json: values.json === true, root: values.root, name: positionals[0] };
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 report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
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, with their summaries and documents";
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 --paths <files>`: for each path, the Primitives of the catalogue that name it,
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
- export const summary = "name the Primitives that own each path, with their summaries and documents";
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
- Prints, for each path, the Primitives of the catalogue whose code names it, the file itself or the
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
- Exit 0 with an answer for every path, 2 when there is no catalogue to read or it is not one.`;
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
- return cli.help || cli.paths ? cli : { error: "name the paths with --paths" };
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") {
@@ -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, shipped) {
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 = shipped?.pr.base ?? defaultBranch();
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 linesOf(local, { shipped, askedGh, workers, next }, line) {
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
- const ahead = asksGh && !nothingToMerge ? aheadOf(root, branch, shipped) : null;
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
  }
@@ -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
+ }
@@ -46,8 +46,10 @@ export async function holdingSignals(body) {
46
46
  return await body();
47
47
  }
48
48
  finally {
49
- // One turn of the event loop hands a signal that landed after the last command to its handler.
50
- await new Promise((resolve) => setImmediate(resolve));
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.17.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": {