dw-sdlc 0.17.0 → 0.19.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
+ }
@@ -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) {
@@ -1,5 +1,7 @@
1
1
  /** `git <args>` at `root`, or a stop naming its problem. */
2
2
  export declare function must(root: string, args: string[]): string;
3
+ /** The local branches of the repository at `root`. */
4
+ export declare function branchesOf(root: string): string[];
3
5
  export type Tree = {
4
6
  path: string;
5
7
  head: string;
@@ -15,3 +17,11 @@ export declare function dirtyFiles(path: string): string[] | {
15
17
  };
16
18
  /** The operation paused in the worktree at `path`: its git dir holds the state git resumes it from. */
17
19
  export declare function pausedOperation(path: string): string | undefined;
20
+ /** The dev's way off `branch`, checked out in the main checkout at `root`: null until it is Landed and nothing there could be lost. */
21
+ export declare function switchOff(root: string, base: {
22
+ name: string;
23
+ ref: string;
24
+ }, branch: string): {
25
+ reason: string;
26
+ command: string;
27
+ } | null;
@@ -1,9 +1,11 @@
1
- // What a Sweep reads of a repository's linked worktrees: where each lies, what it has checked out, its lock,
2
- // the files that hold it and the operation paused in it.
1
+ // What a Sweep reads of a repository's branches and worktrees: where each worktree lies, what it has checked
2
+ // out, its lock, the files that hold it and the operation paused in it, and the way off the main checkout's.
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { exec } from "./exec.js";
6
6
  import { repositoryFreeEnv } from "./git-env.js";
7
+ import { isLanded } from "./landed.js";
8
+ import { shellLine } from "./pr/decide.js";
7
9
  import { CannotRun } from "./pr/read.js";
8
10
  import { asked, git } from "./working-tree.js";
9
11
  /** `git <args>` at `root`, or a stop naming its problem. */
@@ -14,6 +16,12 @@ export function must(root, args) {
14
16
  }
15
17
  return result.out;
16
18
  }
19
+ /** The local branches of the repository at `root`. */
20
+ export function branchesOf(root) {
21
+ return must(root, ["for-each-ref", "--format=%(refname:lstrip=2)", "refs/heads"])
22
+ .split("\n")
23
+ .filter((name) => name !== "");
24
+ }
17
25
  /** Every worktree of the repository, the main checkout first. */
18
26
  export function worktreesOf(root) {
19
27
  const trees = [];
@@ -70,3 +78,15 @@ export function pausedOperation(path) {
70
78
  const dir = asked(path, ["rev-parse", "--absolute-git-dir"]);
71
79
  return dir === undefined ? undefined : OPERATIONS.find(([file]) => existsSync(join(dir, file)))?.[1];
72
80
  }
81
+ /** The dev's way off `branch`, checked out in the main checkout at `root`: null until it is Landed and nothing there could be lost. */
82
+ export function switchOff(root, base, branch) {
83
+ const files = dirtyFiles(root);
84
+ if ("error" in files ||
85
+ files.length > 0 ||
86
+ pausedOperation(root) !== undefined ||
87
+ !isLanded(root, base.ref, branch)) {
88
+ return null;
89
+ }
90
+ const command = `${shellLine(["git", "switch", base.name])} && ${shellLine(["git", "pull", "--rebase"])}`;
91
+ return { reason: `checked out in the main checkout at ${root}, Landed on origin/${base.name}`, command };
92
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.17.0",
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.",
3
+ "version": "0.19.0",
4
+ "description": "The Gate and the agentic config behind the sdlc Claude Code plugin: a bin with the Gate and its Checks, the setup, the doctor, the Sync of References, the Ship script that drives a pull request to merged, the Sweep, and Lead and Worker sessions in herdr; the resolver of a repository's .ai/agentic.config.json layers and its JSON Schema.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",