dw-sdlc 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/report.d.ts CHANGED
@@ -4,10 +4,16 @@ export type Failure = {
4
4
  rule: string;
5
5
  message: string;
6
6
  remedy: string;
7
+ /** What the repository owner wrote to try, printed as `hint: <text>` under the failure line. */
8
+ hint?: string;
9
+ /** The Disable directive a `disable-directives` failure names, as written. */
10
+ directive?: string;
7
11
  };
12
+ /** `hint: <text>` where the failure carries a Hint, nothing otherwise. */
13
+ export declare function hintLines({ hint }: Failure): string[];
8
14
  /** `file:line: rule: message — remedy`, without the location where the failure has none. */
9
15
  export declare function failureLine({ file, line, rule, message, remedy }: Failure): string;
10
16
  /** `::error file=…,line=…,title=<rule>::<message> — <remedy>`, an annotation on the file where there is one. */
11
17
  export declare function annotation({ file, line, rule, message, remedy }: Failure): string;
12
- /** The failure as JSON: every key present, null where it has no file or line. */
13
- export declare function failureJson({ file, line, rule, message, remedy }: Failure): Record<string, unknown>;
18
+ /** The failure as JSON: every key present, null where it has no file or line, and `directive` where it names one. */
19
+ export declare function failureJson({ file, line, rule, message, remedy, directive }: Failure): Record<string, unknown>;
package/dist/report.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // A failure of the Gate as every consumer reads it: one line naming the file and line where there is
2
2
  // one, the rule, what is wrong and the remedy; the same as a GitHub Actions workflow annotation; the
3
3
  // same as JSON.
4
+ /** `hint: <text>` where the failure carries a Hint, nothing otherwise. */
5
+ export function hintLines({ hint }) {
6
+ return hint === undefined ? [] : [`hint: ${hint}`];
7
+ }
4
8
  /** `file:line: rule: message — remedy`, without the location where the failure has none. */
5
9
  export function failureLine({ file, line, rule, message, remedy }) {
6
10
  const where = file === undefined ? "" : `${file}${line === undefined ? "" : `:${line}`}: `;
@@ -23,7 +27,8 @@ export function annotation({ file, line, rule, message, remedy }) {
23
27
  ];
24
28
  return `::error ${properties.join(",")}::${escapeData(`${message} — ${remedy}`)}`;
25
29
  }
26
- /** The failure as JSON: every key present, null where it has no file or line. */
27
- export function failureJson({ file, line, rule, message, remedy }) {
28
- return { file: file ?? null, line: line ?? null, rule, message, remedy };
30
+ /** The failure as JSON: every key present, null where it has no file or line, and `directive` where it names one. */
31
+ export function failureJson({ file, line, rule, message, remedy, directive }) {
32
+ const json = { file: file ?? null, line: line ?? null, rule, message, remedy };
33
+ return directive === undefined ? json : { ...json, directive };
29
34
  }
@@ -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.16.1",
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": {