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,255 @@
1
+ // The comments of a source file: every comment in JavaScript, TypeScript or shell `text` with its lines,
2
+ // its text and whether it stands alone on its line, never one inside a string, a template literal, a
3
+ // regular expression, an expansion or a heredoc. `comment-budget` and `disable-directives` read them.
4
+ /** The characters after which a `/` opens a regular expression, not a division. */
5
+ const BEFORE_REGEX = new Set("(,=:[!&|?{};+-*%<>~^");
6
+ const KEYWORD_BEFORE_REGEX = /(^|[^\w$])(return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/;
7
+ /** Where the regular expression whose `/` is at `start` ends, or undefined when the line ends first. */
8
+ function regexEnd(text, start) {
9
+ let inClass = false;
10
+ for (let index = start + 1; index < text.length; index += 1) {
11
+ const char = text[index];
12
+ if (char === "\\") {
13
+ index += 1;
14
+ }
15
+ else if (char === "\n") {
16
+ return undefined;
17
+ }
18
+ else if (char === "[" || char === "]") {
19
+ inClass = char === "[";
20
+ }
21
+ else if (char === "/" && !inClass) {
22
+ return index + 1;
23
+ }
24
+ }
25
+ return undefined;
26
+ }
27
+ /** Whether the `/` at `index` opens a regular expression: the code before it ends where a value starts. */
28
+ function opensRegex(text, index) {
29
+ const before = text.slice(0, index).trimEnd();
30
+ const last = before.at(-1);
31
+ return last === undefined || BEFORE_REGEX.has(last) || KEYWORD_BEFORE_REGEX.test(before);
32
+ }
33
+ /** Every comment in JavaScript or TypeScript `text`, skipping strings, template literals and regular expressions. */
34
+ export function commentsOf(text) {
35
+ const comments = [];
36
+ // The open quotes, and a `{` for each brace opened inside a template literal's `${…}`.
37
+ const stack = [];
38
+ let line = 1;
39
+ let index = 0;
40
+ while (index < text.length) {
41
+ const char = text[index];
42
+ const next = text[index + 1];
43
+ const top = stack.at(-1);
44
+ if (top === "'" || top === '"' || top === "`") {
45
+ if (char === "\\") {
46
+ line += next === "\n" ? 1 : 0;
47
+ index += 2;
48
+ continue;
49
+ }
50
+ if (top === "`" && char === "$" && next === "{") {
51
+ stack.push("{");
52
+ index += 2;
53
+ continue;
54
+ }
55
+ if (char === top) {
56
+ stack.pop();
57
+ }
58
+ line += char === "\n" ? 1 : 0;
59
+ index += 1;
60
+ continue;
61
+ }
62
+ if (char === "'" || char === '"' || char === "`" || (char === "{" && top === "{")) {
63
+ stack.push(char);
64
+ }
65
+ else if (char === "}" && top === "{") {
66
+ stack.pop();
67
+ }
68
+ else if (char === "/" && next === "/") {
69
+ const stop = text.indexOf("\n", index) === -1 ? text.length : text.indexOf("\n", index);
70
+ const alone = text.slice(text.lastIndexOf("\n", index - 1) + 1, index).trim() === "";
71
+ comments.push({ kind: alone ? "line" : "trailing", start: line, end: line, text: text.slice(index, stop) });
72
+ index = stop;
73
+ continue;
74
+ }
75
+ else if (char === "/" && next === "*") {
76
+ const close = text.indexOf("*/", index + 2);
77
+ const body = text.slice(index, close === -1 ? text.length : close + 2);
78
+ const start = line;
79
+ line += body.split("\n").length - 1;
80
+ comments.push({ kind: "block", start, end: line, text: body });
81
+ index += body.length;
82
+ continue;
83
+ }
84
+ else if (char === "/" && opensRegex(text, index)) {
85
+ index = regexEnd(text, index) ?? index + 1;
86
+ continue;
87
+ }
88
+ line += char === "\n" ? 1 : 0;
89
+ index += 1;
90
+ }
91
+ return comments;
92
+ }
93
+ /** The characters that end a shell word, so a `#` after one opens a comment. */
94
+ const WORD_BREAK = new Set(" \t\n;&|()<>");
95
+ /** Where the line that holds `index` ends: at its newline, or at the end of `text`. */
96
+ export function lineEnd(text, index) {
97
+ const newline = text.indexOf("\n", index);
98
+ return newline === -1 ? text.length : newline;
99
+ }
100
+ /** Where a span that `closer` ends, read from `start`, stops, past the closer; `\` escapes a character when `escapes`. */
101
+ export function spanEnd(text, start, closer, escapes) {
102
+ for (let index = start; index < text.length; index += 1) {
103
+ if (escapes && text.charAt(index) === "\\") {
104
+ index += 1;
105
+ }
106
+ else if (text.charAt(index) === closer) {
107
+ return index + 1;
108
+ }
109
+ }
110
+ return text.length;
111
+ }
112
+ /** The heredoc whose `<<` is at `start`, its delimiter's quotes off, and where the delimiter's word ends. */
113
+ export function heredocAt(text, start) {
114
+ const strip = text.charAt(start + 2) === "-";
115
+ let index = start + (strip ? 3 : 2);
116
+ while (text.charAt(index) === " " || text.charAt(index) === "\t") {
117
+ index += 1;
118
+ }
119
+ let end = index;
120
+ while (end < text.length && !WORD_BREAK.has(text.charAt(end))) {
121
+ const char = text.charAt(end);
122
+ end = char === "'" || char === '"' ? spanEnd(text, end + 1, char, false) : end + (char === "\\" ? 2 : 1);
123
+ }
124
+ return { delimiter: text.slice(index, end).replace(/["'\\]/g, ""), strip, end };
125
+ }
126
+ /** Where the heredoc body that starts at `start` ends: past the line that is its delimiter. */
127
+ export function heredocEnd(text, start, { delimiter, strip }) {
128
+ for (let index = start; index < text.length;) {
129
+ const end = lineEnd(text, index);
130
+ const line = text.slice(index, end);
131
+ index = end + 1;
132
+ if ((strip ? line.replace(/^\t+/, "") : line) === delimiter) {
133
+ return index;
134
+ }
135
+ }
136
+ return text.length;
137
+ }
138
+ /** The character that opens a nesting inside each frame and the one that closes it; a double quote has none. */
139
+ const NESTING = {
140
+ code: ["(", ")"],
141
+ arithmetic: ["(", ")"],
142
+ expansion: ["{", "}"],
143
+ double: []
144
+ };
145
+ /**
146
+ * Every `#` comment in shell `text`: a `#` that starts a word of code, never one in a quote, an
147
+ * expansion, an arithmetic `((…))` or a heredoc body, nor the shebang on line 1.
148
+ */
149
+ export function shellCommentsOf(text) {
150
+ const comments = [];
151
+ const newlines = [...text.matchAll(/\n/g)].map((match) => match.index);
152
+ const outer = [];
153
+ let frame = { kind: "code", depth: 0 };
154
+ const heredocs = [];
155
+ let wordStart = true;
156
+ let index = text.startsWith("#!") ? lineEnd(text, 0) : 0;
157
+ const open = (kind, length) => {
158
+ outer.push(frame);
159
+ frame = { kind, depth: 0 };
160
+ index += length;
161
+ wordStart = true;
162
+ };
163
+ const close = (length) => {
164
+ frame = outer.pop() ?? frame;
165
+ index += length;
166
+ wordStart = false;
167
+ };
168
+ const skipTo = (end) => {
169
+ index = end;
170
+ wordStart = false;
171
+ };
172
+ while (index < text.length) {
173
+ const char = text.charAt(index);
174
+ const next = text.charAt(index + 1);
175
+ const { kind } = frame;
176
+ const [opener, closer] = NESTING[kind];
177
+ if (char === "\\" && next === "\n") {
178
+ index += 2;
179
+ }
180
+ else if (char === "\\") {
181
+ skipTo(index + 2);
182
+ }
183
+ else if (kind === "double" && char === '"') {
184
+ close(1);
185
+ }
186
+ else if (char === "$" && text.startsWith("((", index + 1)) {
187
+ open("arithmetic", 3);
188
+ }
189
+ else if (char === "$" && next === "(") {
190
+ open("code", 2);
191
+ }
192
+ else if (char === "$" && next === "{") {
193
+ open("expansion", 2);
194
+ }
195
+ else if (char === "`") {
196
+ skipTo(spanEnd(text, index + 1, "`", true));
197
+ }
198
+ else if (kind === "double") {
199
+ index += 1;
200
+ }
201
+ else if (char === "$" && next === "'") {
202
+ skipTo(spanEnd(text, index + 2, "'", true));
203
+ }
204
+ else if (char === "'") {
205
+ skipTo(spanEnd(text, index + 1, "'", false));
206
+ }
207
+ else if (char === '"') {
208
+ open("double", 1);
209
+ }
210
+ else if (kind === "code" && char === "#" && wordStart) {
211
+ const end = lineEnd(text, index);
212
+ const line = newlines.filter((newline) => newline < index).length + 1;
213
+ const alone = text.slice(text.lastIndexOf("\n", index - 1) + 1, index).trim() === "";
214
+ comments.push({ kind: alone ? "line" : "trailing", start: line, end: line, text: text.slice(index, end) });
215
+ index = end;
216
+ }
217
+ else if (kind === "code" && char === "\n") {
218
+ index += 1;
219
+ for (const heredoc of heredocs.splice(0)) {
220
+ index = heredocEnd(text, index, heredoc);
221
+ }
222
+ wordStart = true;
223
+ }
224
+ else if (kind === "code" && text.startsWith("<<<", index)) {
225
+ index += 3;
226
+ wordStart = true;
227
+ }
228
+ else if (kind === "code" && text.startsWith("<<", index)) {
229
+ const { end, ...heredoc } = heredocAt(text, index);
230
+ heredocs.push(heredoc);
231
+ skipTo(end);
232
+ }
233
+ else if (kind === "code" && text.startsWith("((", index)) {
234
+ open("arithmetic", 2);
235
+ }
236
+ else if (char === opener) {
237
+ frame.depth += 1;
238
+ index += 1;
239
+ wordStart = true;
240
+ }
241
+ else if (char === closer && frame.depth > 0) {
242
+ frame.depth -= 1;
243
+ index += 1;
244
+ wordStart = true;
245
+ }
246
+ else if (char === closer && outer.length > 0) {
247
+ close(kind === "arithmetic" && next === ")" ? 2 : 1);
248
+ }
249
+ else {
250
+ wordStart = WORD_BREAK.has(char);
251
+ index += 1;
252
+ }
253
+ }
254
+ return comments;
255
+ }
@@ -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 === "") {
@@ -0,0 +1,6 @@
1
+ import type { Check } from "./check.ts";
2
+ export type DisableDirectivesOptions = {
3
+ files: string[];
4
+ exempt: string[];
5
+ };
6
+ export declare const disableDirectives: Check<DisableDirectivesOptions>;
@@ -0,0 +1,91 @@
1
+ // `disable-directives`: a lint rule or a type check switched off says why. A Disable directive in a comment
2
+ // fails unless a reason follows `--` on its line, follows an `@ts-` directive or is a comment above it.
3
+ // The language of a file picks the comments read and the directives looked for.
4
+ import { readFileSync } from "node:fs";
5
+ import { basename, extname, join } from "node:path";
6
+ import { commentsOf } from "./comments.js";
7
+ import { hashCommentsOf } from "./hash-comments.js";
8
+ import { matching, treeFiles } from "./tree.js";
9
+ const REMEDY = "add one after `--` on its line, or in a comment on the line above";
10
+ const SCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
11
+ const RUBY_EXTENSIONS = [".rb", ".rake", ".gemspec", ".ru"];
12
+ const RUBY_NAMES = ["Rakefile", "Gemfile"];
13
+ const PYTHON_EXTENSIONS = [".py", ".pyi"];
14
+ function languageOf(file) {
15
+ const extension = extname(file);
16
+ if (SCRIPT_EXTENSIONS.includes(extension)) {
17
+ return "script";
18
+ }
19
+ if (RUBY_EXTENSIONS.includes(extension) || RUBY_NAMES.includes(basename(file))) {
20
+ return "ruby";
21
+ }
22
+ return PYTHON_EXTENSIONS.includes(extension) ? "python" : undefined;
23
+ }
24
+ /**
25
+ * The pattern that finds a directive at the start of a comment's line, its name in group 1. A Python
26
+ * `noqa` may follow other words of its comment, after a `#` of its own: `# type: ignore # noqa`.
27
+ */
28
+ const DIRECTIVES = {
29
+ script: /^((?:oxlint|eslint)-disable(?:-next-line|-line)?|@ts-expect-error|@ts-ignore)(?![\w-])/,
30
+ ruby: /^(rubocop:disable)(?![\w-])/,
31
+ python: /(?:^|#\s*)(noqa)(?![\w-])/i
32
+ };
33
+ /** The reason `--` gives: text after it, set off from what comes before by space. */
34
+ const DASH_REASON = /(?:^|\s)--+\s*\S/;
35
+ /** A comment's lines, each as text starting after its markers and indent, with the line it sits on. */
36
+ function linesOf({ start, text }, language) {
37
+ return text.split("\n").map((raw, offset) => {
38
+ const body = language === "script" ? raw.replace(/^\s*(?:\/\/+|\/\*+|\*+(?!\/))?\s*/, "") : raw.replace(/^\s*#+\s*/, "");
39
+ return { line: start + offset, text: body.replace(/\s*\*\/\s*$/, "").trimEnd() };
40
+ });
41
+ }
42
+ /** Whether `rest`, the text of a directive's line after its name, gives a reason. */
43
+ function givesReason(directive, rest) {
44
+ if (directive.startsWith("@ts-")) {
45
+ return /[^\s-]/.test(rest.replace(/^\s*:/, ""));
46
+ }
47
+ return DASH_REASON.test(rest);
48
+ }
49
+ /** The directive that starts a line of a comment, and the text after it, or undefined. */
50
+ function directiveIn(text, language) {
51
+ const match = DIRECTIVES[language].exec(text);
52
+ return match === null ? undefined : { directive: match[1] ?? "", rest: text.slice(match.index + match[0].length) };
53
+ }
54
+ function run(root, { files, exempt }) {
55
+ const findings = [];
56
+ for (const file of matching(root, treeFiles(root), files, exempt)) {
57
+ const language = languageOf(file);
58
+ if (language === undefined) {
59
+ continue;
60
+ }
61
+ const text = readFileSync(join(root, file), "utf8");
62
+ const comments = language === "script" ? commentsOf(text) : hashCommentsOf(text, language);
63
+ const found = comments.flatMap((comment) => linesOf(comment, language).flatMap(({ line, text: body }) => {
64
+ const directive = directiveIn(body, language);
65
+ return directive === undefined ? [] : [{ line, ...directive }];
66
+ }));
67
+ const directiveLines = new Set(found.map(({ line }) => line));
68
+ // The line above a directive gives a reason when a comment alone on its line ends on it and no directive does.
69
+ const commentEnds = new Set(comments.filter(({ kind }) => kind !== "trailing").map(({ end }) => end));
70
+ for (const { line, directive, rest } of found) {
71
+ const above = commentEnds.has(line - 1) && !directiveLines.has(line - 1);
72
+ if (!givesReason(directive, rest) && !above) {
73
+ findings.push({ file, line, directive, message: `${directive} gives no reason`, remedy: REMEDY });
74
+ }
75
+ }
76
+ }
77
+ return findings;
78
+ }
79
+ export const disableDirectives = {
80
+ name: "disable-directives",
81
+ defaults: {
82
+ files: [
83
+ "**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
84
+ "**/*.{rb,rake,gemspec,ru}",
85
+ "**/{Rakefile,Gemfile}",
86
+ "**/*.{py,pyi}"
87
+ ],
88
+ exempt: ["references/**", "**/vendor/**", "**/node_modules/**"]
89
+ },
90
+ run
91
+ };
@@ -0,0 +1,4 @@
1
+ import type { Comment } from "./comments.ts";
2
+ export type HashLanguage = "ruby" | "python";
3
+ /** Every `#` comment in Ruby or Python `text`, skipping strings, Ruby heredocs, `=begin` blocks and Python triple quotes. */
4
+ export declare function hashCommentsOf(text: string, language: HashLanguage): Comment[];
@@ -0,0 +1,121 @@
1
+ // The `#` comments of a Ruby or Python file: every `#` that is not inside a string, as the comments of
2
+ // `comments.ts` are, so a directive in a string literal or a heredoc is none.
3
+ const HEREDOC = /^<<([~-]?)(["'`]?)([A-Za-z_]\w*)\2/;
4
+ /** The heredoc that opens at `index`, or undefined: a bare `<<NAME` counts only in capitals, as `list <<NAME` is an append. */
5
+ function heredocAt(text, index) {
6
+ const match = HEREDOC.exec(text.slice(index, index + 80));
7
+ if (match === null || (match[1] === "" && !/^[A-Z_]/.test(match[3] ?? ""))) {
8
+ return undefined;
9
+ }
10
+ return { terminator: match[3] ?? "", indented: match[1] !== "", length: match[0].length };
11
+ }
12
+ /** Where a heredoc body that starts at `start` ends: past the line that is its terminator. */
13
+ function heredocEnd(text, start, { terminator, indented }) {
14
+ for (let index = start; index < text.length;) {
15
+ const stop = text.indexOf("\n", index);
16
+ const end = stop === -1 ? text.length : stop;
17
+ const line = text.slice(index, end);
18
+ index = end + 1;
19
+ if ((indented ? line.trim() : line) === terminator) {
20
+ return Math.min(index, text.length);
21
+ }
22
+ }
23
+ return text.length;
24
+ }
25
+ /** Where the block that runs from `index` to the next line that starts with `until` ends; the end of `text` if none. */
26
+ function blockEnd(text, index, until) {
27
+ const stop = text.indexOf(`\n${until}`, index);
28
+ if (stop === -1) {
29
+ return text.length;
30
+ }
31
+ const end = text.indexOf("\n", stop + 1);
32
+ return end === -1 ? text.length : end;
33
+ }
34
+ /** Every `#` comment in Ruby or Python `text`, skipping strings, Ruby heredocs, `=begin` blocks and Python triple quotes. */
35
+ export function hashCommentsOf(text, language) {
36
+ const comments = [];
37
+ // The open quotes, a triple quote as its three characters, and a `{` for each brace opened in a Ruby `#{…}`.
38
+ const stack = [];
39
+ const heredocs = [];
40
+ let line = 1;
41
+ let index = 0;
42
+ const skipTo = (end) => {
43
+ line += text.slice(index, end).split("\n").length - 1;
44
+ index = end;
45
+ };
46
+ if (language === "ruby" && text.startsWith("=begin")) {
47
+ skipTo(blockEnd(text, 0, "=end"));
48
+ }
49
+ while (index < text.length) {
50
+ const char = text.charAt(index);
51
+ const next = text.charAt(index + 1);
52
+ const top = stack.at(-1);
53
+ if (top !== undefined && top !== "{") {
54
+ if (char === "\\") {
55
+ skipTo(Math.min(index + 2, text.length));
56
+ }
57
+ else if (language === "ruby" && char === "#" && next === "{" && top !== "'") {
58
+ stack.push("{");
59
+ index += 2;
60
+ }
61
+ else if (text.startsWith(top, index)) {
62
+ stack.pop();
63
+ index += top.length;
64
+ }
65
+ else {
66
+ line += char === "\n" ? 1 : 0;
67
+ index += 1;
68
+ }
69
+ }
70
+ else if (char === "'" || char === '"' || (language === "ruby" && char === "`")) {
71
+ const triple = language === "python" && text.startsWith(char.repeat(3), index);
72
+ stack.push(triple ? char.repeat(3) : char);
73
+ index += triple ? 3 : 1;
74
+ }
75
+ else if (char === "{" && top === "{") {
76
+ stack.push("{");
77
+ index += 1;
78
+ }
79
+ else if (char === "}" && top === "{") {
80
+ stack.pop();
81
+ index += 1;
82
+ }
83
+ else if (char === "#" && language === "ruby" && next === "{") {
84
+ index += 1;
85
+ }
86
+ else if (char === "#") {
87
+ const stop = text.indexOf("\n", index);
88
+ const end = stop === -1 ? text.length : stop;
89
+ const alone = text.slice(text.lastIndexOf("\n", index - 1) + 1, index).trim() === "";
90
+ comments.push({ kind: alone ? "line" : "trailing", start: line, end: line, text: text.slice(index, end) });
91
+ index = end;
92
+ }
93
+ else if (char === "\n") {
94
+ line += 1;
95
+ index += 1;
96
+ for (const heredoc of heredocs.splice(0)) {
97
+ skipTo(heredocEnd(text, index, heredoc));
98
+ }
99
+ if (language === "ruby" && text.startsWith("=begin", index)) {
100
+ skipTo(blockEnd(text, index, "=end"));
101
+ }
102
+ else if (language === "ruby" && text.startsWith("__END__", index)) {
103
+ skipTo(text.length);
104
+ }
105
+ }
106
+ else if (language === "ruby" && char === "<" && next === "<") {
107
+ const heredoc = heredocAt(text, index);
108
+ if (heredoc === undefined) {
109
+ index += 2;
110
+ }
111
+ else {
112
+ heredocs.push(heredoc);
113
+ index += heredoc.length;
114
+ }
115
+ }
116
+ else {
117
+ index += 1;
118
+ }
119
+ }
120
+ return comments;
121
+ }
@@ -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[];
@@ -4,11 +4,13 @@ import { agentsMap } from "./agents-map.js";
4
4
  import { optionsOf } from "./check.js";
5
5
  import { commentBudget } from "./comment-budget.js";
6
6
  import { decisionRecords } from "./decision-records.js";
7
+ import { disableDirectives } from "./disable-directives.js";
7
8
  import { fileSizeRatchet } from "./file-size-ratchet.js";
8
9
  import { markdownBudget } from "./markdown-budget.js";
9
10
  import { markdownRefs } from "./markdown-refs.js";
10
11
  import { primitives } from "./primitives.js";
11
12
  import { routedDocs } from "./routed-docs.js";
13
+ import { skillSections } from "./skill-sections.js";
12
14
  import { temporalLanguage } from "./temporal-language.js";
13
15
  function ship(check) {
14
16
  return {
@@ -23,6 +25,10 @@ function ship(check) {
23
25
  if (baseline !== undefined) {
24
26
  enabled.baseline = { file: baseline.file, refresh: (root) => baseline.refresh(root, options) };
25
27
  }
28
+ const { write } = check;
29
+ if (write !== undefined) {
30
+ enabled.write = (root) => write(root, options);
31
+ }
26
32
  return enabled;
27
33
  }
28
34
  };
@@ -36,7 +42,9 @@ const SHIPPED = [
36
42
  ship(temporalLanguage),
37
43
  ship(decisionRecords),
38
44
  ship(fileSizeRatchet),
39
- ship(commentBudget)
45
+ ship(commentBudget),
46
+ ship(disableDirectives),
47
+ ship(skillSections)
40
48
  ];
41
49
  /** The name of every Check the package ships, in the order the Gate runs them. */
42
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>;