dw-sdlc 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -34,6 +34,10 @@ alone, with the same lines, exit codes and document.
34
34
  { "scripts": { "check": "dw-sdlc gate" } }
35
35
  ```
36
36
 
37
+ Under `gate.checks`, a Check the package ships is `true` for its defaults or an object of its options.
38
+ `comment-budget` holds each comment block in the JavaScript, TypeScript and shell (`.sh`) files under
39
+ its `roots` to `maxProseLines` lines of prose, 3 by default.
40
+
37
41
  ## Commands
38
42
 
39
43
  | command | what it does |
@@ -331,7 +331,7 @@
331
331
  "additionalProperties": false
332
332
  },
333
333
  "comment-budget": {
334
- "description": "The comment budget: each comment block in the JavaScript and TypeScript files under `roots` carries at most `maxProseLines` lines of prose, a line with a letter or a digit once the comment markers are off. A block is one `/* … */` comment or a run of `//` comments each alone on consecutive lines; a comment after code on its line is none.",
334
+ "description": "The comment budget: each comment block in the JavaScript, TypeScript and shell (`.sh`) files under `roots` carries at most `maxProseLines` lines of prose, a line with a letter or a digit once the comment markers are off. A block is one `/* … */` comment or a run of `//` or `#` comments each alone on consecutive lines; a comment after code on its line is none, and so is a shell `#` that does not start a word of code.",
335
335
  "type": ["boolean", "object"],
336
336
  "properties": {
337
337
  "maxProseLines": {
@@ -1,9 +1,10 @@
1
1
  // `comment-budget`: a comment says what the code cannot, in a few lines. A block, one `/* … */` or a run
2
- // of `//` lines alone on theirs, carries at most `maxProseLines` lines with a letter or a digit.
2
+ // of `//` or shell `#` lines alone on theirs, carries at most `maxProseLines` lines with a letter or a digit.
3
3
  import { readFileSync } from "node:fs";
4
- import { join } from "node:path";
4
+ import { join, matchesGlob } from "node:path";
5
5
  import { matching, treeFiles } from "./tree.js";
6
6
  const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"];
7
+ const SHELL = "**/*.sh";
7
8
  const REMEDY = "cut it to what the code cannot say, or move the reasoning into a doc and point at it";
8
9
  /** The characters after which a `/` opens a regular expression, not a division. */
9
10
  const BEFORE_REGEX = new Set("(,=:[!&|?{};+-*%<>~^");
@@ -94,7 +95,170 @@ function commentsOf(text) {
94
95
  }
95
96
  return comments;
96
97
  }
97
- /** The comments as blocks: a run of `//` lines alone on consecutive lines is one; a comment after code is none. */
98
+ /** The characters that end a shell word, so a `#` after one opens a comment. */
99
+ const WORD_BREAK = new Set(" \t\n;&|()<>");
100
+ /** Where the line that holds `index` ends: at its newline, or at the end of `text`. */
101
+ function lineEnd(text, index) {
102
+ const newline = text.indexOf("\n", index);
103
+ return newline === -1 ? text.length : newline;
104
+ }
105
+ /** Where a span that `closer` ends, read from `start`, stops, past the closer; `\` escapes a character when `escapes`. */
106
+ function spanEnd(text, start, closer, escapes) {
107
+ for (let index = start; index < text.length; index += 1) {
108
+ if (escapes && text.charAt(index) === "\\") {
109
+ index += 1;
110
+ }
111
+ else if (text.charAt(index) === closer) {
112
+ return index + 1;
113
+ }
114
+ }
115
+ return text.length;
116
+ }
117
+ /** The heredoc whose `<<` is at `start`, its delimiter's quotes off, and where the delimiter's word ends. */
118
+ function heredocAt(text, start) {
119
+ const strip = text.charAt(start + 2) === "-";
120
+ let index = start + (strip ? 3 : 2);
121
+ while (text.charAt(index) === " " || text.charAt(index) === "\t") {
122
+ index += 1;
123
+ }
124
+ let end = index;
125
+ while (end < text.length && !WORD_BREAK.has(text.charAt(end))) {
126
+ const char = text.charAt(end);
127
+ end = char === "'" || char === '"' ? spanEnd(text, end + 1, char, false) : end + (char === "\\" ? 2 : 1);
128
+ }
129
+ return { delimiter: text.slice(index, end).replace(/["'\\]/g, ""), strip, end };
130
+ }
131
+ /** Where the heredoc body that starts at `start` ends: past the line that is its delimiter. */
132
+ function heredocEnd(text, start, { delimiter, strip }) {
133
+ for (let index = start; index < text.length;) {
134
+ const end = lineEnd(text, index);
135
+ const line = text.slice(index, end);
136
+ index = end + 1;
137
+ if ((strip ? line.replace(/^\t+/, "") : line) === delimiter) {
138
+ return index;
139
+ }
140
+ }
141
+ return text.length;
142
+ }
143
+ /** The character that opens a nesting inside each frame and the one that closes it; a double quote has none. */
144
+ const NESTING = {
145
+ code: ["(", ")"],
146
+ arithmetic: ["(", ")"],
147
+ expansion: ["{", "}"],
148
+ double: []
149
+ };
150
+ /**
151
+ * Every `#` comment in shell `text`: a `#` that starts a word of code, never one in a quote, an
152
+ * expansion, an arithmetic `((…))` or a heredoc body, nor the shebang on line 1.
153
+ */
154
+ function shellCommentsOf(text) {
155
+ const comments = [];
156
+ const newlines = [...text.matchAll(/\n/g)].map((match) => match.index);
157
+ const outer = [];
158
+ let frame = { kind: "code", depth: 0 };
159
+ const heredocs = [];
160
+ let wordStart = true;
161
+ let index = text.startsWith("#!") ? lineEnd(text, 0) : 0;
162
+ const open = (kind, length) => {
163
+ outer.push(frame);
164
+ frame = { kind, depth: 0 };
165
+ index += length;
166
+ wordStart = true;
167
+ };
168
+ const close = (length) => {
169
+ frame = outer.pop() ?? frame;
170
+ index += length;
171
+ wordStart = false;
172
+ };
173
+ const skipTo = (end) => {
174
+ index = end;
175
+ wordStart = false;
176
+ };
177
+ while (index < text.length) {
178
+ const char = text.charAt(index);
179
+ const next = text.charAt(index + 1);
180
+ const { kind } = frame;
181
+ const [opener, closer] = NESTING[kind];
182
+ if (char === "\\" && next === "\n") {
183
+ index += 2;
184
+ }
185
+ else if (char === "\\") {
186
+ skipTo(index + 2);
187
+ }
188
+ else if (kind === "double" && char === '"') {
189
+ close(1);
190
+ }
191
+ else if (char === "$" && text.startsWith("((", index + 1)) {
192
+ open("arithmetic", 3);
193
+ }
194
+ else if (char === "$" && next === "(") {
195
+ open("code", 2);
196
+ }
197
+ else if (char === "$" && next === "{") {
198
+ open("expansion", 2);
199
+ }
200
+ else if (char === "`") {
201
+ skipTo(spanEnd(text, index + 1, "`", true));
202
+ }
203
+ else if (kind === "double") {
204
+ index += 1;
205
+ }
206
+ else if (char === "$" && next === "'") {
207
+ skipTo(spanEnd(text, index + 2, "'", true));
208
+ }
209
+ else if (char === "'") {
210
+ skipTo(spanEnd(text, index + 1, "'", false));
211
+ }
212
+ else if (char === '"') {
213
+ open("double", 1);
214
+ }
215
+ else if (kind === "code" && char === "#" && wordStart) {
216
+ const end = lineEnd(text, index);
217
+ const line = newlines.filter((newline) => newline < index).length + 1;
218
+ const alone = text.slice(text.lastIndexOf("\n", index - 1) + 1, index).trim() === "";
219
+ comments.push({ kind: alone ? "line" : "trailing", start: line, end: line, text: text.slice(index, end) });
220
+ index = end;
221
+ }
222
+ else if (kind === "code" && char === "\n") {
223
+ index += 1;
224
+ for (const heredoc of heredocs.splice(0)) {
225
+ index = heredocEnd(text, index, heredoc);
226
+ }
227
+ wordStart = true;
228
+ }
229
+ else if (kind === "code" && text.startsWith("<<<", index)) {
230
+ index += 3;
231
+ wordStart = true;
232
+ }
233
+ else if (kind === "code" && text.startsWith("<<", index)) {
234
+ const { end, ...heredoc } = heredocAt(text, index);
235
+ heredocs.push(heredoc);
236
+ skipTo(end);
237
+ }
238
+ else if (kind === "code" && text.startsWith("((", index)) {
239
+ open("arithmetic", 2);
240
+ }
241
+ else if (char === opener) {
242
+ frame.depth += 1;
243
+ index += 1;
244
+ wordStart = true;
245
+ }
246
+ else if (char === closer && frame.depth > 0) {
247
+ frame.depth -= 1;
248
+ index += 1;
249
+ wordStart = true;
250
+ }
251
+ else if (char === closer && outer.length > 0) {
252
+ close(kind === "arithmetic" && next === ")" ? 2 : 1);
253
+ }
254
+ else {
255
+ wordStart = WORD_BREAK.has(char);
256
+ index += 1;
257
+ }
258
+ }
259
+ return comments;
260
+ }
261
+ /** The comments as blocks: a run of `//` or `#` lines alone on consecutive lines is one; a comment after code is none. */
98
262
  function blocksOf(comments) {
99
263
  const blocks = [];
100
264
  for (const comment of comments) {
@@ -123,8 +287,9 @@ function proseLines(block) {
123
287
  }
124
288
  function run(root, { maxProseLines, roots }) {
125
289
  const findings = [];
126
- for (const file of matching(root, treeFiles(root, roots), SOURCE)) {
127
- for (const block of blocksOf(commentsOf(readFileSync(join(root, file), "utf8")))) {
290
+ for (const file of matching(root, treeFiles(root, roots), [...SOURCE, SHELL])) {
291
+ const text = readFileSync(join(root, file), "utf8");
292
+ for (const block of blocksOf(matchesGlob(file, SHELL) ? shellCommentsOf(text) : commentsOf(text))) {
128
293
  const prose = proseLines(block.text);
129
294
  if (prose > maxProseLines) {
130
295
  const message = `a comment block of ${prose} prose lines, over the ${maxProseLines}-line budget`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, and the Sync and setup of References; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
5
5
  "license": "MIT",
6
6
  "repository": {