dw-sdlc 0.1.0 → 0.2.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,194 @@
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.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, matchesGlob } from "node:path";
6
+ import { CannotRun } from "./check.js";
7
+ import { lineCount } from "./markdown-budget.js";
8
+ import { matching, treeFiles } from "./tree.js";
9
+ const BASELINE = ".ai/file-size-ratchet.baseline.json";
10
+ const REFRESH = "dw-sdlc gate --update-baseline";
11
+ const REMEDIES = {
12
+ record: `record it with ${REFRESH}`,
13
+ shape: `make it an object of file paths and line counts, as ${REFRESH} writes it`,
14
+ twice: "keep one entry for it, the lower count",
15
+ stale: `run ${REFRESH}, which drops it`,
16
+ unreadable: "fix the failures it names"
17
+ };
18
+ /** A committed baseline gone from the tree: recording a new one would let every grown file back in. */
19
+ const DELETED = {
20
+ file: BASELINE,
21
+ message: "the baseline is committed but gone from the tree",
22
+ remedy: `restore it with git restore ${BASELINE}`
23
+ };
24
+ function show(value) {
25
+ return JSON.stringify(value);
26
+ }
27
+ function plural(count, noun) {
28
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
29
+ }
30
+ function inBaseline(line) {
31
+ return line === undefined ? { file: BASELINE } : { file: BASELINE, line };
32
+ }
33
+ /** The first group with a glob that matches `path`. */
34
+ function groupOf(path, groups) {
35
+ return groups.find(({ files }) => files.some((glob) => matchesGlob(path, glob)));
36
+ }
37
+ function measure(root, groups) {
38
+ const measured = new Map();
39
+ for (const path of matching(root, treeFiles(root), groups.flatMap(({ files }) => files))) {
40
+ const group = groupOf(path, groups);
41
+ if (group !== undefined && statSync(join(root, path)).isFile()) {
42
+ measured.set(path, { group, lines: lineCount(readFileSync(join(root, path), "utf8")) });
43
+ }
44
+ }
45
+ return measured;
46
+ }
47
+ /** The line of each `"<path>":` key in `text`; JSON keeps only the last of a key it reads twice. */
48
+ function keyLines(text, path) {
49
+ const key = show(path).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
50
+ return Array.from(text.matchAll(new RegExp(`(?<!\\\\)${key}\\s*:`, "g")), ({ index }) => {
51
+ return text.slice(0, index).split("\n").length;
52
+ });
53
+ }
54
+ function readBaseline(root) {
55
+ const path = join(root, BASELINE);
56
+ if (!existsSync(path)) {
57
+ const committed = spawnSync("git", ["-C", root, "cat-file", "-e", `HEAD:${BASELINE}`]).status === 0;
58
+ return { state: "missing", committed };
59
+ }
60
+ const text = readFileSync(path, "utf8");
61
+ let parsed;
62
+ try {
63
+ parsed = JSON.parse(text);
64
+ }
65
+ catch (error) {
66
+ const message = `not JSON: ${error instanceof Error ? error.message : String(error)}`;
67
+ return { state: "invalid", findings: [{ file: BASELINE, message, remedy: REMEDIES.shape }] };
68
+ }
69
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
70
+ return { state: "invalid", findings: [{ file: BASELINE, message: "not an object", remedy: REMEDIES.shape }] };
71
+ }
72
+ const findings = [];
73
+ const entries = new Map();
74
+ for (const [file, count] of Object.entries(parsed)) {
75
+ const lines = keyLines(text, file);
76
+ if (lines.length > 1) {
77
+ const times = lines.length === 2 ? "twice" : `${lines.length} times`;
78
+ findings.push({ ...inBaseline(lines[1]), message: `lists ${show(file)} ${times}`, remedy: REMEDIES.twice });
79
+ }
80
+ if (typeof count === "number" && Number.isInteger(count) && count >= 1) {
81
+ entries.set(file, { count, line: lines[0] });
82
+ }
83
+ else {
84
+ const message = `${show(file)} is at ${show(count)}, not a line count`;
85
+ findings.push({ ...inBaseline(lines.at(-1)), message, remedy: REMEDIES.shape });
86
+ }
87
+ }
88
+ return findings.length > 0 ? { state: "invalid", findings } : { state: "valid", entries };
89
+ }
90
+ /** The entries that excuse nothing: a file no group holds, or a count within its group's limit. */
91
+ function contradictions(entries, groups) {
92
+ const findings = [];
93
+ for (const [file, { count, line }] of entries) {
94
+ const group = groupOf(file, groups);
95
+ if (group === undefined) {
96
+ const message = `lists ${show(file)}, which no group holds`;
97
+ findings.push({ ...inBaseline(line), message, remedy: REMEDIES.stale });
98
+ }
99
+ else if (count <= group.maxLines) {
100
+ const message = `lists ${show(file)} at ${count} lines, within the ${group.maxLines}-line limit of the ${group.name} group`;
101
+ findings.push({ ...inBaseline(line), message, remedy: REMEDIES.stale });
102
+ }
103
+ }
104
+ return findings;
105
+ }
106
+ function run(root, { groups }) {
107
+ const read = readBaseline(root);
108
+ if (read.state === "missing") {
109
+ const missing = { file: BASELINE, message: "the baseline does not exist", remedy: REMEDIES.record };
110
+ throw new CannotRun(read.committed ? DELETED : missing);
111
+ }
112
+ if (read.state === "invalid") {
113
+ return read.findings;
114
+ }
115
+ const findings = contradictions(read.entries, groups);
116
+ for (const [file, { group, lines }] of measure(root, groups)) {
117
+ const recorded = read.entries.get(file)?.count;
118
+ if (recorded !== undefined && lines > recorded) {
119
+ const message = `${lines} lines, over the ${recorded} the baseline holds it to`;
120
+ findings.push({ file, message, remedy: `cut it back to ${recorded} lines or fewer` });
121
+ }
122
+ else if (recorded === undefined && lines > group.maxLines) {
123
+ const message = `${lines} lines, over the ${group.maxLines}-line limit of the ${group.name} group`;
124
+ findings.push({ file, message, remedy: `split it, or cut it to ${group.maxLines} lines` });
125
+ }
126
+ }
127
+ return findings;
128
+ }
129
+ function write(root, entries) {
130
+ const sorted = entries.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
131
+ mkdirSync(dirname(join(root, BASELINE)), { recursive: true });
132
+ writeFileSync(join(root, BASELINE), `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`);
133
+ }
134
+ /**
135
+ * Records every file over its limit where there is no baseline; else lowers each count that shrank
136
+ * and drops each entry that excuses nothing, adding none.
137
+ */
138
+ function refresh(root, { groups }) {
139
+ const read = readBaseline(root);
140
+ if (read.state === "invalid") {
141
+ throw new CannotRun({
142
+ file: BASELINE,
143
+ message: "it is not a baseline the Ratchet can read",
144
+ remedy: REMEDIES.unreadable
145
+ });
146
+ }
147
+ if (read.state === "missing" && read.committed) {
148
+ throw new CannotRun(DELETED);
149
+ }
150
+ const measured = measure(root, groups);
151
+ if (read.state === "missing") {
152
+ const over = [...measured].filter(([, { group, lines }]) => lines > group.maxLines);
153
+ write(root, over.map(([file, { lines }]) => [file, lines]));
154
+ return `recorded — ${plural(over.length, "file")} over ${over.length === 1 ? "its" : "their"} limit`;
155
+ }
156
+ const kept = [];
157
+ let dropped = 0;
158
+ let lowered = 0;
159
+ for (const [file, { count }] of read.entries) {
160
+ const now = measured.get(file);
161
+ const tighter = now === undefined ? 0 : Math.min(now.lines, count);
162
+ if (now === undefined || tighter <= now.group.maxLines) {
163
+ dropped += 1;
164
+ }
165
+ else {
166
+ lowered += tighter < count ? 1 : 0;
167
+ kept.push([file, tighter]);
168
+ }
169
+ }
170
+ if (dropped === 0 && lowered === 0) {
171
+ return "unchanged";
172
+ }
173
+ write(root, kept);
174
+ const parts = [
175
+ ...(dropped > 0 ? [`${plural(dropped, "file")} dropped`] : []),
176
+ ...(lowered > 0 ? [`${plural(lowered, "count")} lowered`] : [])
177
+ ];
178
+ return `tightened — ${parts.join(", ")}`;
179
+ }
180
+ const TESTS = ["**/{test,tests,__tests__}/**", "**/*.{test,spec}.*"];
181
+ const AGENT_DOCS = ["**/{AGENTS,CLAUDE,GLOSSARY}.md", "docs/agents/**/*.md"];
182
+ const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,sh}"];
183
+ export const fileSizeRatchet = {
184
+ name: "file-size-ratchet",
185
+ defaults: {
186
+ groups: [
187
+ { name: "tests", maxLines: 2000, files: TESTS },
188
+ { name: "agent-docs", maxLines: 120, files: AGENT_DOCS },
189
+ { name: "source", maxLines: 500, files: SOURCE }
190
+ ]
191
+ },
192
+ run,
193
+ baseline: { file: BASELINE, refresh }
194
+ };
@@ -1,8 +1,12 @@
1
1
  import { type Finding } from "./check.ts";
2
- /** A Check turned on by its setting, ready to judge a tree. */
2
+ /** A Check turned on by its setting, ready to judge a tree, and to refresh its baseline if it is a Ratchet. */
3
3
  export type EnabledCheck = {
4
4
  name: string;
5
5
  run: (root: string) => Finding[];
6
+ baseline?: {
7
+ file: string;
8
+ refresh: (root: string) => string;
9
+ };
6
10
  };
7
11
  /** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
8
12
  export declare function enabledChecks(checks: Record<string, unknown>): EnabledCheck[];
@@ -2,17 +2,40 @@
2
2
  // file, one row below and one key under `gate.checks` in the schema, listed in the same order.
3
3
  import { agentsMap } from "./agents-map.js";
4
4
  import { optionsOf } from "./check.js";
5
+ import { commentBudget } from "./comment-budget.js";
6
+ import { decisionRecords } from "./decision-records.js";
7
+ import { fileSizeRatchet } from "./file-size-ratchet.js";
8
+ import { markdownBudget } from "./markdown-budget.js";
9
+ import { markdownRefs } from "./markdown-refs.js";
5
10
  import { primitives } from "./primitives.js";
11
+ import { temporalLanguage } from "./temporal-language.js";
6
12
  function ship(check) {
7
13
  return {
8
14
  name: check.name,
9
15
  enable: (setting) => {
10
16
  const options = optionsOf(check, setting);
11
- return options === undefined ? undefined : { name: check.name, run: (root) => check.run(root, options) };
17
+ if (options === undefined) {
18
+ return undefined;
19
+ }
20
+ const enabled = { name: check.name, run: (root) => check.run(root, options) };
21
+ const { baseline } = check;
22
+ if (baseline !== undefined) {
23
+ enabled.baseline = { file: baseline.file, refresh: (root) => baseline.refresh(root, options) };
24
+ }
25
+ return enabled;
12
26
  }
13
27
  };
14
28
  }
15
- const SHIPPED = [ship(primitives), ship(agentsMap)];
29
+ const SHIPPED = [
30
+ ship(primitives),
31
+ ship(agentsMap),
32
+ ship(markdownBudget),
33
+ ship(markdownRefs),
34
+ ship(temporalLanguage),
35
+ ship(decisionRecords),
36
+ ship(fileSizeRatchet),
37
+ ship(commentBudget)
38
+ ];
16
39
  /** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
17
40
  export function enabledChecks(checks) {
18
41
  return SHIPPED.flatMap(({ name, enable }) => enable(checks[name]) ?? []);
@@ -0,0 +1,15 @@
1
+ import type { Check } from "./check.ts";
2
+ export type Budget = {
3
+ maxLines: number;
4
+ maxBytes: number;
5
+ };
6
+ export type MarkdownBudgetOptions = {
7
+ budgets: (Budget & {
8
+ glob: string;
9
+ })[];
10
+ };
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[];
15
+ export declare const markdownBudget: Check<MarkdownBudgetOptions>;
@@ -0,0 +1,42 @@
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.
3
+ import { readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { matching, treeFiles } from "./tree.js";
6
+ const REMEDIES = {
7
+ budget: "cut it, or split a topic into a file of its own",
8
+ glob: "fix the glob, or drop its budget"
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);
18
+ 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
+ ];
23
+ }
24
+ function run(root, { budgets }) {
25
+ const files = treeFiles(root);
26
+ return budgets.flatMap(({ glob, ...budget }) => {
27
+ const matched = matching(root, files, [glob]);
28
+ if (matched.length === 0) {
29
+ return [{ message: `${JSON.stringify(glob)} matches no file`, remedy: REMEDIES.glob }];
30
+ }
31
+ return matched.flatMap((file) => overBudget(readFileSync(join(root, file), "utf8"), budget).map((over) => ({
32
+ file,
33
+ message: `${over} of ${glob}`,
34
+ remedy: REMEDIES.budget
35
+ })));
36
+ });
37
+ }
38
+ export const markdownBudget = {
39
+ name: "markdown-budget",
40
+ defaults: { budgets: [{ glob: "docs/agents/*.md", maxLines: 100, maxBytes: 6144 }] },
41
+ run
42
+ };
@@ -0,0 +1,6 @@
1
+ import type { Check } from "./check.ts";
2
+ export type MarkdownRefsOptions = {
3
+ files: string[];
4
+ exempt: string[];
5
+ };
6
+ export declare const markdownRefs: Check<MarkdownRefsOptions>;
@@ -0,0 +1,73 @@
1
+ // `markdown-refs`: every relative link and image in the markdown resolves, its path to a file or a
2
+ // directory git tracks or would track and its heading anchor to a heading of the file it names, so a
3
+ // link to an ignored file or to another case of a file's name fails here as it fails in CI.
4
+ import { readFileSync } from "node:fs";
5
+ import { join, posix } from "node:path";
6
+ import { anchorsOf, links } from "../markdown.js";
7
+ import { matching, treeFiles } from "./tree.js";
8
+ const REMEDIES = {
9
+ path: "fix the path or drop the link",
10
+ outside: "point it at a file of the repository",
11
+ anchor: "point it at a heading the file has"
12
+ };
13
+ /** A target with a scheme, such as `https:` or `mailto:`, or a host: no file of the tree answers it. */
14
+ const EXTERNAL = /^([a-z][a-z\d+.-]*:|\/\/)/i;
15
+ function decode(text) {
16
+ try {
17
+ return decodeURIComponent(text);
18
+ }
19
+ catch {
20
+ return text;
21
+ }
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
+ /** The repository path `path` names from `file`, the root `.`; a path out of the repository starts with `..`. */
34
+ function resolve(file, path) {
35
+ if (path === "") {
36
+ return file;
37
+ }
38
+ const joined = path.startsWith("/") ? path.slice(1) : posix.join(posix.dirname(file), path);
39
+ const normal = posix.normalize(joined === "" ? "." : joined);
40
+ return normal.length > 1 ? normal.replace(/\/$/, "") : normal;
41
+ }
42
+ /** What is wrong with one link of `file`: a path out of the repository or not in it, or an anchor with no heading. */
43
+ function judge(repository, file, { target, line }) {
44
+ if (EXTERNAL.test(target)) {
45
+ return [];
46
+ }
47
+ const [beforeAnchor = "", anchor = ""] = target.split("#", 2);
48
+ const resolved = resolve(file, decode(beforeAnchor.split("?", 1)[0] ?? ""));
49
+ const quoted = JSON.stringify(target);
50
+ if (resolved === ".." || resolved.startsWith("../")) {
51
+ return [{ file, line, message: `links to ${quoted}, which is outside the repository`, remedy: REMEDIES.outside }];
52
+ }
53
+ if (resolved !== "." && !repository.files.has(resolved) && !repository.directories.has(resolved)) {
54
+ return [{ file, line, message: `links to ${quoted}, which does not exist`, remedy: REMEDIES.path }];
55
+ }
56
+ const namesHeading = anchor !== "" && repository.files.has(resolved) && /\.md$/i.test(resolved);
57
+ if (namesHeading && !anchorsOf(repository.root, resolved, repository.anchors).has(decode(anchor))) {
58
+ const message = `links to ${quoted}, but ${resolved} has no heading with that anchor`;
59
+ return [{ file, line, message, remedy: REMEDIES.anchor }];
60
+ }
61
+ return [];
62
+ }
63
+ function run(root, { files, exempt }) {
64
+ const tree = treeFiles(root);
65
+ const known = new Set(tree);
66
+ const repository = { root, files: known, directories: directoriesOf(known), anchors: new Map() };
67
+ return matching(root, tree, files, exempt).flatMap((file) => links(readFileSync(join(root, file), "utf8")).flatMap((link) => judge(repository, file, link)));
68
+ }
69
+ export const markdownRefs = {
70
+ name: "markdown-refs",
71
+ defaults: { files: ["**/*.md"], exempt: [] },
72
+ run
73
+ };
@@ -1,12 +1,12 @@
1
1
  // `primitives`: the catalogue says what is true of the tree. Unique ids, a known group, a one-line
2
2
  // summary, every code and docs path on disk with its anchor resolving, and every file under the roots
3
3
  // named by a Primitive.
4
- import { spawnSync } from "node:child_process";
5
- import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { existsSync } from "node:fs";
6
5
  import { join } from "node:path";
7
6
  import { CATALOGUE_FILE, primitivesNaming, readCatalogue } from "../catalogue.js";
8
- import { headings } from "../markdown.js";
7
+ import { anchorsOf } from "../markdown.js";
9
8
  import { CannotRun, optionsOf } from "./check.js";
9
+ import { treeFiles } from "./tree.js";
10
10
  const MAX_SUMMARY = 120;
11
11
  /** A file a test runs, never shipped: under a test directory, or named as a test. */
12
12
  const TEST_FILE = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[^/]+$/;
@@ -17,8 +17,7 @@ const REMEDIES = {
17
17
  group: "add the group to groups, or move the Primitive into one listed there",
18
18
  summary: `keep the summary to one line of ${MAX_SUMMARY} characters or fewer`,
19
19
  path: "fix the path or drop it",
20
- anchor: "point the entry at a heading the file has",
21
- notGit: "run the Gate inside a git repository"
20
+ anchor: "point the entry at a heading the file has"
22
21
  };
23
22
  function show(value) {
24
23
  return JSON.stringify(value);
@@ -30,16 +29,6 @@ function lineOf(text, id, nth) {
30
29
  const match = Array.from(text.slice(start).matchAll(pattern))[nth];
31
30
  return match?.index === undefined ? undefined : text.slice(0, start + match.index).split("\n").length;
32
31
  }
33
- /** The anchors of the headings of `path`; none when it is not a file. */
34
- function anchorsOf(root, path, cache) {
35
- let anchors = cache.get(path);
36
- if (anchors === undefined) {
37
- const full = join(root, path);
38
- anchors = new Set(statSync(full).isFile() ? headings(readFileSync(full, "utf8")).map(({ anchor }) => anchor) : []);
39
- cache.set(path, anchors);
40
- }
41
- return anchors;
42
- }
43
32
  /** What is wrong with one Primitive, declared at `at`: its group, its summary, each code and docs entry. */
44
33
  function judge(root, catalogue, primitive, at, cache) {
45
34
  const { id, group, summary, code, docs } = primitive;
@@ -69,20 +58,6 @@ function judge(root, catalogue, primitive, at, cache) {
69
58
  }
70
59
  return findings;
71
60
  }
72
- /** Every file under `roots` that git tracks or would track, and that is on disk. */
73
- function filesUnder(root, roots) {
74
- const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...roots];
75
- const listed = spawnSync("git", args, { encoding: "utf8" });
76
- if (listed.status !== 0) {
77
- throw new CannotRun({
78
- message: `cannot list the files under ${roots.join(", ")}: not a git repository`,
79
- remedy: REMEDIES.notGit
80
- });
81
- }
82
- return [...new Set(listed.stdout.split("\0").filter((path) => path !== ""))]
83
- .filter((path) => existsSync(join(root, path)))
84
- .toSorted();
85
- }
86
61
  function run(root, { file, roots }) {
87
62
  const read = readCatalogue(root, file);
88
63
  if (read.state === "missing") {
@@ -109,7 +84,7 @@ function run(root, { file, roots }) {
109
84
  }
110
85
  findings.push(...judge(root, catalogue, primitive, at, cache));
111
86
  }
112
- for (const path of filesUnder(root, roots)) {
87
+ for (const path of treeFiles(root, roots)) {
113
88
  if (!TEST_FILE.test(path) && primitivesNaming(catalogue, path).length === 0) {
114
89
  const remedy = `name it, or a directory above it, in the code of a Primitive in ${file}`;
115
90
  findings.push({ file: path, message: "no Primitive names this file", remedy });
@@ -0,0 +1,6 @@
1
+ import type { Check } from "./check.ts";
2
+ export type TemporalLanguageOptions = {
3
+ files: string[];
4
+ exempt: string[];
5
+ };
6
+ export declare const temporalLanguage: Check<TemporalLanguageOptions>;
@@ -0,0 +1,58 @@
1
+ // `temporal-language`: a durable document says what is true, not what changed. A phrase of kody's list,
2
+ // as `docs/research/gate/kody.md` records it, fails it across a line break and never inside code;
3
+ // decision records, research notes and changelogs record a point in time and are exempt by default.
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { prose } from "../markdown.js";
7
+ import { matching, treeFiles } from "./tree.js";
8
+ const REMEDY = "describe what is true, in the present tense, as if written from scratch";
9
+ /** Between two words: one space, or a line break and the next line's indent. */
10
+ const GAP = String.raw `(?: |\n[ \t]*)`;
11
+ function phrase(...words) {
12
+ return new RegExp(String.raw `\b${words.join(GAP)}\b`, "gi");
13
+ }
14
+ const PHRASES = [
15
+ phrase("now", "we"),
16
+ phrase("we", "now"),
17
+ phrase("we", "no", "longer"),
18
+ phrase("previously", "we"),
19
+ phrase("as", "before"),
20
+ phrase("were", String.raw `\*{0,2}removed`),
21
+ phrase("(?:was|were)", String.raw `\*{0,2}retired`),
22
+ phrase("no", "longer", "(?:support|accept|require|use|fan|carry|replay|incur|authoritative|insert|needed)s?"),
23
+ phrase("is", "now", "(?:a|an|the)"),
24
+ phrase("are", "now"),
25
+ phrase("previously", "(?:a|the)"),
26
+ phrase("recently", "(?:changed|added|updated|removed|introduced)"),
27
+ phrase("used", "to", "(?:support|require)")
28
+ ];
29
+ /** Each phrase in `text` in the order it reads, one that overlaps an earlier hit, as `we no longer support` does, folded into it. */
30
+ function hits(text) {
31
+ let end = 0;
32
+ return PHRASES.flatMap((pattern) => Array.from(text.matchAll(pattern)))
33
+ .toSorted((a, b) => a.index - b.index)
34
+ .filter((hit) => {
35
+ const overlaps = hit.index < end;
36
+ end = Math.max(end, hit.index + hit[0].length);
37
+ return !overlaps;
38
+ });
39
+ }
40
+ function run(root, { files, exempt }) {
41
+ return matching(root, treeFiles(root), files, exempt).flatMap((file) => {
42
+ const text = prose(readFileSync(join(root, file), "utf8"));
43
+ return hits(text).map((hit) => ({
44
+ file,
45
+ line: text.slice(0, hit.index).split("\n").length,
46
+ message: `${JSON.stringify(hit[0].replace(/\s+/g, " "))} reads as a changelog`,
47
+ remedy: REMEDY
48
+ }));
49
+ });
50
+ }
51
+ export const temporalLanguage = {
52
+ name: "temporal-language",
53
+ defaults: {
54
+ files: ["**/*.md"],
55
+ exempt: ["docs/adr/**", "docs/decisions/**", "docs/research/**", "**/CHANGELOG.md"]
56
+ },
57
+ run
58
+ };
@@ -0,0 +1,8 @@
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
+ export declare function treeFiles(root: string, pathspecs?: string[]): string[];
3
+ /**
4
+ * The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
5
+ * out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
6
+ * that starts with a dot, as a shell does.
7
+ */
8
+ export declare function matching(root: string, paths: string[], globs: string[], exempt?: string[]): string[];
@@ -0,0 +1,30 @@
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.
3
+ import { spawnSync } from "node:child_process";
4
+ import { existsSync, lstatSync, realpathSync } from "node:fs";
5
+ import { join, matchesGlob, relative } from "node:path";
6
+ import { CannotRun } from "./check.js";
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
+ export function treeFiles(root, pathspecs = []) {
9
+ const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs];
10
+ const listed = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
11
+ if (listed.status !== 0) {
12
+ const under = pathspecs.length === 0 ? "" : ` under ${pathspecs.join(", ")}`;
13
+ const message = `cannot list the files${under}: not a git repository`;
14
+ throw new CannotRun({ message, remedy: "run the Gate inside a git repository" });
15
+ }
16
+ return [...new Set(listed.stdout.split("\0").filter((path) => path !== ""))]
17
+ .filter((path) => existsSync(join(root, path)))
18
+ .toSorted();
19
+ }
20
+ /**
21
+ * The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
22
+ * out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
23
+ * that starts with a dot, as a shell does.
24
+ */
25
+ export function matching(root, paths, globs, exempt = []) {
26
+ const matched = paths.filter((path) => globs.some((glob) => matchesGlob(path, glob)) && !exempt.some((glob) => matchesGlob(path, glob)));
27
+ const judged = new Set(matched);
28
+ const realRoot = realpathSync(root);
29
+ return matched.filter((path) => !lstatSync(join(root, path)).isSymbolicLink() || !judged.has(relative(realRoot, realpathSync(join(root, path)))));
30
+ }
@@ -1,10 +1,6 @@
1
- // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the guardrails rely on, read-only.
2
- // It diagnoses the repository holding --root and the user's home; it installs nothing. FAIL is a
3
- // guard that does not guard (no jq, plugin absent or disabled, an invalid config), WARN a layer that
4
- // is missing or loose (Safe Chain, the pnpm cooldown, signing, rtk excludes), INFO a check that does
5
- // not apply here. Every probe goes through one interface, so the tests fake each answer. The
6
- // machine-wide values are asked from the home directory: from a checkout, its own
7
- // pnpm-workspace.yaml would answer for the machine.
1
+ // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the guardrails rely on, for the
2
+ // repository holding --root and the user's home; it installs nothing. FAIL is a guard that does not guard
3
+ // (no jq, plugin absent or disabled, an invalid config), WARN a layer missing or loose, INFO one that does not apply.
8
4
  import { spawnSync } from "node:child_process";
9
5
  import { existsSync, readFileSync } from "node:fs";
10
6
  import { homedir } from "node:os";
@@ -232,6 +228,10 @@ function checkSafeChain(probe) {
232
228
  }
233
229
  return { name: "safe chain", level: "PASS", detail: `${binary}, npm goes through it` };
234
230
  }
231
+ /**
232
+ * pnpm's global `minimumReleaseAge`, asked from the home directory: from a checkout, its own
233
+ * pnpm-workspace.yaml would answer for the machine.
234
+ */
235
235
  function checkPnpmReleaseAge(probe, home) {
236
236
  if (probe.shell("command -v pnpm") === "") {
237
237
  return { name: "pnpm release age", level: "INFO", detail: "no pnpm in the shell" };