dw-sdlc 0.7.0 → 0.9.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.
Files changed (45) hide show
  1. package/README.md +20 -12
  2. package/config/agentic.config.schema.json +56 -18
  3. package/dist/agentic-config.d.ts +23 -7
  4. package/dist/agentic-config.js +26 -12
  5. package/dist/checks/agents-map.d.ts +9 -1
  6. package/dist/checks/agents-map.js +60 -17
  7. package/dist/checks/decision-records.d.ts +5 -0
  8. package/dist/checks/decision-records.js +121 -16
  9. package/dist/checks/file-size-ratchet.js +7 -5
  10. package/dist/checks/index.js +2 -0
  11. package/dist/checks/markdown-budget.d.ts +2 -5
  12. package/dist/checks/markdown-budget.js +6 -15
  13. package/dist/checks/markdown-refs.js +1 -11
  14. package/dist/checks/routed-docs.d.ts +6 -0
  15. package/dist/checks/routed-docs.js +49 -0
  16. package/dist/checks/tree.d.ts +2 -0
  17. package/dist/checks/tree.js +13 -3
  18. package/dist/commands/check.d.ts +1 -1
  19. package/dist/commands/check.js +6 -4
  20. package/dist/commands/gate.d.ts +10 -4
  21. package/dist/commands/gate.js +202 -86
  22. package/dist/commands/pr-reviewed.js +5 -21
  23. package/dist/commands/pr-ship.js +37 -17
  24. package/dist/commands/references-sync.js +5 -6
  25. package/dist/drift.d.ts +17 -0
  26. package/dist/drift.js +68 -0
  27. package/dist/gate-cache.d.ts +13 -0
  28. package/dist/gate-cache.js +39 -0
  29. package/dist/git-env.d.ts +2 -0
  30. package/dist/git-env.js +10 -0
  31. package/dist/markdown.d.ts +19 -0
  32. package/dist/markdown.js +46 -2
  33. package/dist/package-version.d.ts +2 -0
  34. package/dist/package-version.js +13 -0
  35. package/dist/pr/decide.d.ts +31 -8
  36. package/dist/pr/decide.js +65 -31
  37. package/dist/router-block.d.ts +4 -1
  38. package/dist/router-block.js +31 -7
  39. package/dist/run-command.d.ts +24 -0
  40. package/dist/run-command.js +121 -0
  41. package/dist/setup-gate.js +3 -12
  42. package/dist/working-tree.d.ts +13 -0
  43. package/dist/working-tree.js +60 -0
  44. package/package.json +1 -1
  45. package/templates/router.md +1 -0
@@ -1,23 +1,42 @@
1
1
  // `decision-records`: the decisions directory stays a reliable index. Its records, the files named
2
- // `<number>-<slug>.md`, are numbered from 1 with no gap and no number taken twice, and each has a title
3
- // and a frontmatter status from the allowed set.
4
- import { readFileSync, statSync } from "node:fs";
5
- import { join } from "node:path";
6
- import { headings } from "../markdown.js";
2
+ // `<number>-<slug>.md`, are numbered from 1 with no gap and no number taken twice, and each has a title,
3
+ // a frontmatter status from the allowed set and the frontmatter fields the config turns on.
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
+ import { join, matchesGlob, posix } from "node:path";
6
+ import { frontmatter, frontmatterList, headings } from "../markdown.js";
7
7
  import { CannotRun } from "./check.js";
8
- import { treeFiles } from "./tree.js";
8
+ import { directoriesOf, treeFiles } from "./tree.js";
9
+ /** The frontmatter fields beyond `status` a record can be held to, in the order they are judged. */
10
+ const FIELDS = ["date", "touches", "superseded-by", "amended-by"];
9
11
  /** A record's name in its directory: its number, a slug, `.md`, and no directory between. */
10
12
  const RECORD = /^(\d+)-[^/]+\.md$/;
13
+ /** The first words of a status that put a record in force, so it governs the paths under its `touches`. */
14
+ const IN_FORCE = new Set(["accepted", "active"]);
15
+ /** A character that makes a path under `touches` a glob. */
16
+ const GLOB = /[*?[\]{}]/;
11
17
  const REMEDIES = {
12
18
  missing: "create the records directory, or point gate.checks.decision-records.dirs at it",
13
19
  gap: "restore the missing records, or renumber the ones after the gap",
14
20
  taken: "give each record a number of its own",
15
- title: "start it with a level-1 heading that names the decision"
21
+ title: "start it with a level-1 heading that names the decision",
22
+ noDate: "give its frontmatter the day the decision was made, as date: YYYY-MM-DD",
23
+ date: "write the day the decision was made as YYYY-MM-DD",
24
+ noTouches: "list under touches the paths it governs",
25
+ superseded: "drop touches: the record that replaces it governs those paths",
26
+ path: "fix the path or drop it from touches",
27
+ glob: "fix the glob or drop it from touches",
28
+ ignored: "name the paths git tracks that it governs, or drop it from touches",
29
+ noSuccessor: "name under superseded-by the number of each record that replaces it",
30
+ successor: "name the number of each record that replaces it",
31
+ amendment: "name the number of each record that amends it"
16
32
  };
17
33
  function isDirectory(path) {
18
34
  return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
19
35
  }
20
- /** The `status` of the frontmatter `text` opens with, its quotes dropped, and its 1-based line. */
36
+ /**
37
+ * The `status` of the frontmatter `text` opens with, its quotes dropped, and its 1-based line. It keeps a
38
+ * reader apart from `frontmatter`, which a status never goes through: a ` #` is part of it.
39
+ */
21
40
  function statusOf(text) {
22
41
  const lines = text.split("\n");
23
42
  if (lines[0]?.trim() !== "---") {
@@ -31,6 +50,76 @@ function statusOf(text) {
31
50
  }
32
51
  return undefined;
33
52
  }
53
+ /** Whether `value` is a day of the calendar written `YYYY-MM-DD`. */
54
+ function isDay(value) {
55
+ const date = new Date(`${value}T00:00:00Z`);
56
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
57
+ }
58
+ function judgeDate({ file, keys }) {
59
+ const date = keys.get("date");
60
+ if (date === undefined || date.value === "") {
61
+ return [{ file, message: "has no date", remedy: REMEDIES.noDate }];
62
+ }
63
+ if (!isDay(date.value)) {
64
+ const message = `the date ${JSON.stringify(date.value)} is not a YYYY-MM-DD day`;
65
+ return [{ file, line: date.line, message, remedy: REMEDIES.date }];
66
+ }
67
+ return [];
68
+ }
69
+ function judgeTouches({ file, state, keys }, { root, tree }) {
70
+ const touches = keys.get("touches");
71
+ const paths = frontmatterList(touches);
72
+ if (state === "superseded") {
73
+ if (touches === undefined || paths.length === 0) {
74
+ return [];
75
+ }
76
+ return [{ file, line: touches.line, message: "is superseded and still names touches", remedy: REMEDIES.superseded }];
77
+ }
78
+ if (paths.length === 0) {
79
+ return IN_FORCE.has(state) ? [{ file, message: "names no path under touches", remedy: REMEDIES.noTouches }] : [];
80
+ }
81
+ return paths.flatMap(({ value, line }) => {
82
+ const path = posix.normalize(value).replace(/(.)\/$/, "$1");
83
+ const glob = GLOB.test(path);
84
+ if (tree().paths.has(path) || (glob && tree().files.some((candidate) => matchesGlob(candidate, path)))) {
85
+ return [];
86
+ }
87
+ const quoted = JSON.stringify(value);
88
+ if (path !== ".." && !path.startsWith("../") && existsSync(join(root, path))) {
89
+ return [{ file, line, message: `touches ${quoted}, which git ignores`, remedy: REMEDIES.ignored }];
90
+ }
91
+ return glob
92
+ ? [{ file, line, message: `touches ${quoted}, which matches no file`, remedy: REMEDIES.glob }]
93
+ : [{ file, line, message: `touches ${quoted}, which does not exist`, remedy: REMEDIES.path }];
94
+ });
95
+ }
96
+ /** Each value under the list key `name` of a record that is not the number of a record of its directory. */
97
+ function judgeNumbers(name, remedy, { file, keys }, numbers) {
98
+ return frontmatterList(keys.get(name)).flatMap(({ value, line }) => {
99
+ if (/^\d+$/.test(value) && numbers.has(Number(value))) {
100
+ return [];
101
+ }
102
+ const message = `${name} names ${JSON.stringify(value)}, which is not the number of a record`;
103
+ return [{ file, line, message, remedy }];
104
+ });
105
+ }
106
+ function judgeSuccessors(record, { numbers }) {
107
+ if (record.state === "superseded" && frontmatterList(record.keys.get("superseded-by")).length === 0) {
108
+ const message = "is superseded but names no successor under superseded-by";
109
+ return [{ file: record.file, message, remedy: REMEDIES.noSuccessor }];
110
+ }
111
+ return judgeNumbers("superseded-by", REMEDIES.successor, record, numbers);
112
+ }
113
+ function judgeAmendments(record, { numbers }) {
114
+ return judgeNumbers("amended-by", REMEDIES.amendment, record, numbers);
115
+ }
116
+ /** What is wrong with each field of a record, when the config turns that field on. */
117
+ const RULES = {
118
+ date: judgeDate,
119
+ touches: judgeTouches,
120
+ "superseded-by": judgeSuccessors,
121
+ "amended-by": judgeAmendments
122
+ };
34
123
  /** Each run of numbers from 1 to the highest that no record takes, as `0004` or `0004 to 0006`. */
35
124
  function gaps(taken, pad) {
36
125
  const runs = [];
@@ -47,8 +136,8 @@ function gaps(taken, pad) {
47
136
  }
48
137
  return runs;
49
138
  }
50
- /** What is wrong with the records of `dir`: the numbers they skip, then each record's number, title and status. */
51
- function judge(root, dir, statuses) {
139
+ /** What is wrong with the records of `dir`: the numbers they skip, then each record's number, title, status and fields. */
140
+ function judge(root, dir, { statuses, fields }, tree) {
52
141
  const prefix = `${dir.replace(/\/+$/, "")}/`;
53
142
  const records = treeFiles(root, [dir])
54
143
  .map((file) => ({ file, name: file.slice(prefix.length) }))
@@ -68,6 +157,8 @@ function judge(root, dir, statuses) {
68
157
  remedy: REMEDIES.gap
69
158
  }));
70
159
  const allowed = `one of ${statuses.join(", ")}`;
160
+ const context = { root, tree, numbers: new Set(owners.keys()) };
161
+ const judgedFields = FIELDS.filter((field) => fields.includes(field));
71
162
  for (const { file, digits } of records) {
72
163
  const owner = owners.get(Number(digits));
73
164
  if (owner !== file) {
@@ -78,28 +169,42 @@ function judge(root, dir, statuses) {
78
169
  findings.push({ file, message: "has no title", remedy: REMEDIES.title });
79
170
  }
80
171
  const status = statusOf(text);
172
+ const state = status?.value.split(/\s+/)[0] ?? "";
81
173
  if (status === undefined) {
82
174
  findings.push({ file, message: "has no status", remedy: `give its frontmatter a status, ${allowed}` });
83
175
  }
84
- else if (!statuses.includes(status.value.split(/\s+/)[0] ?? "")) {
176
+ else if (!statuses.includes(state)) {
85
177
  const message = `the status ${JSON.stringify(status.value)} is not allowed`;
86
178
  findings.push({ file, line: status.line, message, remedy: `set it to ${allowed}` });
87
179
  }
180
+ if (judgedFields.length > 0) {
181
+ const record = { file, state, keys: frontmatter(text) };
182
+ findings.push(...judgedFields.flatMap((field) => RULES[field](record, context)));
183
+ }
88
184
  }
89
185
  return findings;
90
186
  }
91
- function run(root, { dirs, statuses }) {
92
- const present = dirs.filter((dir) => isDirectory(join(root, dir)));
187
+ function run(root, options) {
188
+ const present = options.dirs.filter((dir) => isDirectory(join(root, dir)));
93
189
  if (present.length === 0) {
94
- throw new CannotRun({ message: `none of ${dirs.join(", ")} exists`, remedy: REMEDIES.missing });
190
+ throw new CannotRun({ message: `none of ${options.dirs.join(", ")} exists`, remedy: REMEDIES.missing });
95
191
  }
96
- return present.flatMap((dir) => judge(root, dir, statuses));
192
+ let cached;
193
+ const tree = () => {
194
+ if (cached === undefined) {
195
+ const files = treeFiles(root);
196
+ cached = { files, paths: new Set([...files, ...directoriesOf(files)]) };
197
+ }
198
+ return cached;
199
+ };
200
+ return present.flatMap((dir) => judge(root, dir, options, tree));
97
201
  }
98
202
  export const decisionRecords = {
99
203
  name: "decision-records",
100
204
  defaults: {
101
205
  dirs: ["docs/adr", "docs/decisions"],
102
- statuses: ["proposed", "accepted", "active", "deprecated", "superseded"]
206
+ statuses: ["proposed", "accepted", "active", "deprecated", "superseded"],
207
+ fields: []
103
208
  },
104
209
  run
105
210
  };
@@ -1,10 +1,9 @@
1
- // `file-size-ratchet`: each file within the line limit of its group or, listed in the baseline, within
2
- // the count recorded there. A refresh only tightens the baseline: it lowers and drops, and adds no file.
1
+ // `file-size-ratchet`: each file of code within the line limit of its group or, listed in the baseline,
2
+ // within the count recorded there. A refresh only tightens the baseline: it lowers and drops, and adds no file.
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, matchesGlob } from "node:path";
6
6
  import { CannotRun } from "./check.js";
7
- import { lineCount } from "./markdown-budget.js";
8
7
  import { matching, treeFiles } from "./tree.js";
9
8
  const BASELINE = ".ai/file-size-ratchet.baseline.json";
10
9
  const REFRESH = "dw-sdlc gate --update-baseline";
@@ -34,6 +33,11 @@ function inBaseline(line) {
34
33
  function groupOf(path, groups) {
35
34
  return groups.find(({ files }) => files.some((glob) => matchesGlob(path, glob)));
36
35
  }
36
+ /** The lines of `text`, a last line without its newline counted, the empty line after a final newline not. */
37
+ function lineCount(text) {
38
+ const lines = text.split("\n").length;
39
+ return text.endsWith("\n") ? lines - 1 : lines;
40
+ }
37
41
  function measure(root, groups) {
38
42
  const measured = new Map();
39
43
  for (const path of matching(root, treeFiles(root), groups.flatMap(({ files }) => files))) {
@@ -178,14 +182,12 @@ function refresh(root, { groups }) {
178
182
  return `tightened — ${parts.join(", ")}`;
179
183
  }
180
184
  const TESTS = ["**/{test,tests,__tests__}/**", "**/*.{test,spec}.*"];
181
- const AGENT_DOCS = ["**/{AGENTS,CLAUDE,GLOSSARY}.md", "docs/agents/**/*.md"];
182
185
  const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,sh}"];
183
186
  export const fileSizeRatchet = {
184
187
  name: "file-size-ratchet",
185
188
  defaults: {
186
189
  groups: [
187
190
  { name: "tests", maxLines: 2000, files: TESTS },
188
- { name: "agent-docs", maxLines: 120, files: AGENT_DOCS },
189
191
  { name: "source", maxLines: 500, files: SOURCE }
190
192
  ]
191
193
  },
@@ -8,6 +8,7 @@ import { fileSizeRatchet } from "./file-size-ratchet.js";
8
8
  import { markdownBudget } from "./markdown-budget.js";
9
9
  import { markdownRefs } from "./markdown-refs.js";
10
10
  import { primitives } from "./primitives.js";
11
+ import { routedDocs } from "./routed-docs.js";
11
12
  import { temporalLanguage } from "./temporal-language.js";
12
13
  function ship(check) {
13
14
  return {
@@ -29,6 +30,7 @@ function ship(check) {
29
30
  const SHIPPED = [
30
31
  ship(primitives),
31
32
  ship(agentsMap),
33
+ ship(routedDocs),
32
34
  ship(markdownBudget),
33
35
  ship(markdownRefs),
34
36
  ship(temporalLanguage),
@@ -1,6 +1,5 @@
1
1
  import type { Check } from "./check.ts";
2
2
  export type Budget = {
3
- maxLines: number;
4
3
  maxBytes: number;
5
4
  };
6
5
  export type MarkdownBudgetOptions = {
@@ -8,8 +7,6 @@ export type MarkdownBudgetOptions = {
8
7
  glob: string;
9
8
  })[];
10
9
  };
11
- /** The lines of `text`, a last line without its newline counted, the empty line after a final newline not. */
12
- export declare function lineCount(text: string): number;
13
- /** What `text` spends past its budget: `156 lines, over the 150-line budget`, then the bytes the same way. */
14
- export declare function overBudget(text: string, { maxLines, maxBytes }: Budget): string[];
10
+ /** What `text` spends past its budget, `10651 bytes, over the 10240-byte budget`; nothing within it. */
11
+ export declare function overBudget(text: string, { maxBytes }: Budget): string[];
15
12
  export declare const markdownBudget: Check<MarkdownBudgetOptions>;
@@ -1,5 +1,5 @@
1
- // `markdown-budget`: each file a glob matches stays within that glob's line and byte budget, so the
2
- // docs an agent reads stay short enough to read whole.
1
+ // `markdown-budget`: each file a glob matches stays within that glob's byte budget, so the docs an
2
+ // agent reads stay short enough to read whole.
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { matching, treeFiles } from "./tree.js";
@@ -7,19 +7,10 @@ const REMEDIES = {
7
7
  budget: "cut it, or split a topic into a file of its own",
8
8
  glob: "fix the glob, or drop its budget"
9
9
  };
10
- /** The lines of `text`, a last line without its newline counted, the empty line after a final newline not. */
11
- export function lineCount(text) {
12
- const lines = text.split("\n").length;
13
- return text.endsWith("\n") ? lines - 1 : lines;
14
- }
15
- /** What `text` spends past its budget: `156 lines, over the 150-line budget`, then the bytes the same way. */
16
- export function overBudget(text, { maxLines, maxBytes }) {
17
- const lines = lineCount(text);
10
+ /** What `text` spends past its budget, `10651 bytes, over the 10240-byte budget`; nothing within it. */
11
+ export function overBudget(text, { maxBytes }) {
18
12
  const bytes = Buffer.byteLength(text);
19
- return [
20
- ...(lines > maxLines ? [`${lines} lines, over the ${maxLines}-line budget`] : []),
21
- ...(bytes > maxBytes ? [`${bytes} bytes, over the ${maxBytes}-byte budget`] : [])
22
- ];
13
+ return bytes > maxBytes ? [`${bytes} bytes, over the ${maxBytes}-byte budget`] : [];
23
14
  }
24
15
  function run(root, { budgets }) {
25
16
  const files = treeFiles(root);
@@ -37,6 +28,6 @@ function run(root, { budgets }) {
37
28
  }
38
29
  export const markdownBudget = {
39
30
  name: "markdown-budget",
40
- defaults: { budgets: [{ glob: "docs/agents/*.md", maxLines: 100, maxBytes: 6144 }] },
31
+ defaults: { budgets: [{ glob: "docs/agents/*.md", maxBytes: 6144 }] },
41
32
  run
42
33
  };
@@ -4,7 +4,7 @@
4
4
  import { readFileSync } from "node:fs";
5
5
  import { join, posix } from "node:path";
6
6
  import { anchorsOf, links } from "../markdown.js";
7
- import { matching, treeFiles } from "./tree.js";
7
+ import { directoriesOf, matching, treeFiles } from "./tree.js";
8
8
  const REMEDIES = {
9
9
  path: "fix the path or drop the link",
10
10
  outside: "point it at a file of the repository",
@@ -20,16 +20,6 @@ function decode(text) {
20
20
  return text;
21
21
  }
22
22
  }
23
- /** Every directory above a file of `files`. */
24
- function directoriesOf(files) {
25
- const directories = new Set();
26
- for (const file of files) {
27
- for (let dir = posix.dirname(file); dir !== "." && !directories.has(dir); dir = posix.dirname(dir)) {
28
- directories.add(dir);
29
- }
30
- }
31
- return directories;
32
- }
33
23
  /** The repository path `path` names from `file`, the root `.`; a path out of the repository starts with `..`. */
34
24
  function resolve(file, path) {
35
25
  if (path === "") {
@@ -0,0 +1,6 @@
1
+ import type { Check } from "./check.ts";
2
+ export type RoutedDocsOptions = {
3
+ files: string[];
4
+ exempt: string[];
5
+ };
6
+ export declare const routedDocs: Check<RoutedDocsOptions>;
@@ -0,0 +1,49 @@
1
+ // `routed-docs`: a task leads an agent to every document that explains it. Each document is reached by
2
+ // a Task Router row of the agent map, through a path, a directory above it or a glob, or by the docs of
3
+ // a Primitive in the catalogue. It reads the file list, the Task Router and the catalogue, never prose.
4
+ import { matchesGlob, posix } from "node:path";
5
+ import { resolveConfig } from "../agentic-config.js";
6
+ import { readCatalogue } from "../catalogue.js";
7
+ import { mapFile, readMap, routes } from "./agents-map.js";
8
+ import { catalogueFile } from "./primitives.js";
9
+ import { matching, treeFiles } from "./tree.js";
10
+ const REMEDIES = {
11
+ table: "add a Task Router section whose table names the docs",
12
+ unreached: "add a Task Router row, name it in a Primitive's docs, or exempt it"
13
+ };
14
+ /** `entry` as a path from the root: its heading anchor, a leading `./` or `/` and a trailing `/` dropped. */
15
+ function fromRoot(entry) {
16
+ const [path = ""] = entry.split("#", 1);
17
+ return posix.join(".", path).replace(/\/+$/, "");
18
+ }
19
+ /** Whether `target`, a path a row points at, names `doc`, a directory above it or a glob that matches it. */
20
+ function reaches(target, doc) {
21
+ return target.includes("*") ? matchesGlob(doc, target) : doc === target || doc.startsWith(`${target}/`);
22
+ }
23
+ /** The docs entries of the catalogue's Primitives; none when it is missing or not valid. */
24
+ function catalogueDocs(root, file) {
25
+ const read = readCatalogue(root, file);
26
+ return read.state === "valid" ? read.catalogue.primitives.flatMap(({ docs }) => docs) : [];
27
+ }
28
+ function run(root, { files, exempt }) {
29
+ const { checks } = resolveConfig(root).config.gate;
30
+ const map = mapFile(checks);
31
+ const rows = routes(readMap(root, map));
32
+ const findings = [];
33
+ if (rows.length === 0) {
34
+ findings.push({ file: map, message: "has no Task Router table to reach a doc from", remedy: REMEDIES.table });
35
+ }
36
+ const targets = rows.flatMap(({ paths }) => paths).map(fromRoot);
37
+ const catalogued = new Set(catalogueDocs(root, catalogueFile(checks)).map(fromRoot));
38
+ for (const doc of matching(root, treeFiles(root), files, exempt)) {
39
+ if (!catalogued.has(doc) && !targets.some((target) => reaches(target, doc))) {
40
+ findings.push({ file: doc, message: "no Task Router row or Primitive reaches it", remedy: REMEDIES.unreached });
41
+ }
42
+ }
43
+ return findings;
44
+ }
45
+ export const routedDocs = {
46
+ name: "routed-docs",
47
+ defaults: { files: ["docs/**/*.md"], exempt: ["docs/research/**"] },
48
+ run
49
+ };
@@ -1,5 +1,7 @@
1
1
  /** Every file under `pathspecs`, the whole tree when there are none, that git tracks or would track and that is on disk; sorted. */
2
2
  export declare function treeFiles(root: string, pathspecs?: string[]): string[];
3
+ /** Every directory above a file of `files`. */
4
+ export declare function directoriesOf(files: Iterable<string>): Set<string>;
3
5
  /**
4
6
  * The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
5
7
  * out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
@@ -1,8 +1,8 @@
1
- // The files of a repository as git sees them, and the ones a Check's globs pick out of them: what the
2
- // Checks that judge many files read, so a file git ignores is never judged.
1
+ // The files of a repository as git sees them, the directories above them, and the ones a Check's globs
2
+ // pick out of them: what the Checks that judge many files read, so a file git ignores is never judged.
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, lstatSync, realpathSync } from "node:fs";
5
- import { join, matchesGlob, relative } from "node:path";
5
+ import { join, matchesGlob, posix, relative } from "node:path";
6
6
  import { CannotRun } from "./check.js";
7
7
  /** Every file under `pathspecs`, the whole tree when there are none, that git tracks or would track and that is on disk; sorted. */
8
8
  export function treeFiles(root, pathspecs = []) {
@@ -17,6 +17,16 @@ export function treeFiles(root, pathspecs = []) {
17
17
  .filter((path) => existsSync(join(root, path)))
18
18
  .toSorted();
19
19
  }
20
+ /** Every directory above a file of `files`. */
21
+ export function directoriesOf(files) {
22
+ const directories = new Set();
23
+ for (const file of files) {
24
+ for (let dir = posix.dirname(file); dir !== "." && !directories.has(dir); dir = posix.dirname(dir)) {
25
+ directories.add(dir);
26
+ }
27
+ }
28
+ return directories;
29
+ }
20
30
  /**
21
31
  * The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
22
32
  * out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
@@ -1,2 +1,2 @@
1
1
  export declare const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
2
- export declare function run(argv: string[]): number;
2
+ export declare function run(argv: string[]): Promise<number>;
@@ -2,6 +2,7 @@
2
2
  import { parseArgs } from "node:util";
3
3
  import { repositoryRoot } from "../agentic-config.js";
4
4
  import { CHECK_NAMES } from "../checks/index.js";
5
+ import { TAIL_LINES } from "../run-command.js";
5
6
  import { printReport, runGate } from "./gate.js";
6
7
  export const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
7
8
  const USAGE = `Usage: dw-sdlc check <name> [--json] [--root <dir>]
@@ -10,8 +11,9 @@ Runs the Check named <name> in the Gate of the repository holding --root (defaul
10
11
  directory): a script under gate.checks.run, or a Check the package ships, one of
11
12
  ${CHECK_NAMES.join(", ")}. A name both carry runs both, as the Gate does. A
12
13
  shipped Check runs on the setting gate.checks gives it, and on its defaults where the config sets it
13
- false or leaves it out. No Step runs. Prints one line per failure, as the Gate does; --json prints
14
- the Gate's document, with no Step.
14
+ false or leaves it out. No Step runs. A run script prints nothing of its own unless it does not
15
+ pass: then its last ${TAIL_LINES} lines, both streams in the order written. Prints one line per failure,
16
+ as the Gate does; --json prints the Gate's document alone, with no Step.
15
17
 
16
18
  A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
17
19
  pass.
@@ -39,7 +41,7 @@ function parseCli(argv) {
39
41
  return { error: error instanceof Error ? error.message : String(error) };
40
42
  }
41
43
  }
42
- export function run(argv) {
44
+ export async function run(argv) {
43
45
  const cli = parseCli(argv);
44
46
  if ("error" in cli) {
45
47
  process.stderr.write(`dw-sdlc check: ${cli.error}\n\n${USAGE}\n`);
@@ -54,6 +56,6 @@ export function run(argv) {
54
56
  return 2;
55
57
  }
56
58
  const { json, name } = cli;
57
- const report = runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
59
+ const report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
58
60
  return printReport(report, json, `check ${name}`);
59
61
  }
@@ -1,9 +1,10 @@
1
1
  import { type Failure } from "../report.ts";
2
+ import { type Status } from "../run-command.ts";
2
3
  export declare const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
3
- type Status = "pass" | "fail" | "error";
4
4
  type Outcome = {
5
5
  status: Status | "skipped";
6
6
  exitCode: number | null;
7
+ tail: string[] | null;
7
8
  };
8
9
  type StepReport = Outcome & {
9
10
  command: string;
@@ -16,23 +17,28 @@ type CheckReport = Outcome & {
16
17
  type GateReport = {
17
18
  status: Status;
18
19
  command: string | null;
20
+ /** Present on a run the cache skipped. */
21
+ cached?: true;
19
22
  steps: StepReport[];
20
23
  checks: CheckReport[];
21
24
  failures: Failure[];
22
25
  };
26
+ /** How a run of the whole Gate goes: plain, refreshing each Ratchet's baseline, or cached on a green tree. */
27
+ type Mode = "plain" | "update-baseline" | "cache";
23
28
  /** A run of the whole Gate, or of the Checks named `only` alone: no Step, no progress line, no refresh. */
24
29
  type RunOptions = {
25
30
  json: boolean;
26
31
  } & ({
27
- updateBaseline: boolean;
32
+ mode: Mode;
33
+ verbose: boolean;
28
34
  } | {
29
35
  only: string;
30
36
  });
31
- export declare function runGate(root: string, options: RunOptions): GateReport;
37
+ export declare function runGate(root: string, options: RunOptions): Promise<GateReport>;
32
38
  /**
33
39
  * Prints `report`: a line per failure and the verdict under `label`, or one document under `json`; under
34
40
  * GitHub Actions each failure is also an annotation. Returns the exit code.
35
41
  */
36
42
  export declare function printReport(report: GateReport, json: boolean, label: string): number;
37
- export declare function run(argv: string[]): number;
43
+ export declare function run(argv: string[]): Promise<number>;
38
44
  export {};