dw-sdlc 0.15.2 → 0.17.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
@@ -35,6 +35,10 @@ lines, exit codes and document.
35
35
  }
36
36
  ```
37
37
 
38
+ A Step is a command string, or `{ "command": "…", "hint": "…" }`; a `gate.checks.run` entry takes a
39
+ `hint` beside `name` and `command`. When the Step or script fails, the Gate prints `hint: <text>` under
40
+ its failure line, and `--json` carries `hint` on every Step and Check, `null` where there is none.
41
+
38
42
  ```json
39
43
  { "scripts": { "check": "dw-sdlc gate" } }
40
44
  ```
@@ -42,13 +46,16 @@ lines, exit codes and document.
42
46
  Under `gate.checks`, a Check the package ships is `true` for its defaults or an object of its options.
43
47
  `comment-budget` holds each comment block in the JavaScript, TypeScript and shell (`.sh`) files under
44
48
  its `roots` to `maxProseLines` lines of prose, 3 by default.
49
+ `disable-directives` fails every Disable directive in a comment that gives no reason: `oxlint-disable*`,
50
+ `eslint-disable*`, `@ts-expect-error`, `@ts-ignore`, `rubocop:disable` and `noqa`. A reason follows `--`
51
+ on the directive's line, follows an `@ts-` directive, or is a comment on the line above.
45
52
 
46
53
  ## The Ship script
47
54
 
48
55
  `dw-sdlc pr ship [<n>] [-R owner/name]` reads one pull request through an authenticated `gh` and
49
56
  answers the one Action it needs next, as a line or, with `--json`, one document. The exit code says
50
57
  whose turn it is: 0 done, 10 the agent's, 20 wait, 30 the dev's, 2 it cannot run. The script
51
- performs its own Actions: it updates a branch behind its base, marks the head reviewed when the
58
+ performs its own Actions: it updates a branch GitHub reports `BEHIND`, marks the head reviewed when the
52
59
  Review depth is `off`, marks a draft ready once nothing else holds it back, and squash merges a pull
53
60
  request that is green, reviewed and opened by the `gh` account, when the merge is its own. `--dry-run` performs nothing: it answers the first Action the
54
61
  script would perform as the dev's. It never deletes a branch; a merged pull request answers with
@@ -42,6 +42,11 @@
42
42
  "description": "`deny` refuses a `git commit`, `merge`, `cherry-pick` or `revert` on a branch listed in `protectedBranches`, so every change starts on a branch of its own. `allow` lets it through, for a repository that commits small fixes on its protected branch and pushes them from a person's terminal. Default `allow`. Strictest first: deny, allow.",
43
43
  "enum": ["deny", "allow"]
44
44
  },
45
+ "filteredOutput": {
46
+ "description": "`deny` refuses a check command whose output a pipe feeds to `head`, `tail`, `grep`, `egrep`, `fgrep`, `rg`, `sed`, `awk`, `less` or `more`: the pipe hides the failure and, with `pipefail` off, the exit code. A check command is the `gate.command`, a Step, a `gate.checks.run` command, `dw-sdlc gate`, `dw-sdlc check`, or a common runner such as `vitest`, `tsc` or `rspec`. `tee` and a redirect pass. `allow` turns the guard off, for a repository whose workflow pipes check output. Default `deny`.",
47
+ "enum": ["deny", "allow"],
48
+ "default": "deny"
49
+ },
45
50
  "envFiles": {
46
51
  "description": "Which env files (`.env`, `.env.*`, `.envrc`) an agent may read, edit or write. A candidate is judged in this order: listed in `deny` → refused; listed in `allow` → readable; `policy` `deny` → refused; `policy` `git` → readable when git tracks it, refused when git ignores it or does not know it. Entries are globs against the path relative to the repository root: `*` and `?` as in a shell pattern (`*` crosses `/`), a leading `**/` means at any depth, so `.env` is the one in the root and `**/.env.example` every file of that name. No brace groups. A path outside the repository root, or outside any repository, is judged by its basename against the `**/` entries alone. Defaults: policy `git`, allow the `.env.example`, `.env.sample` and `.env.template` names anywhere, deny nothing. The local override may drop `allow` entries and add `deny` entries, never the reverse.",
47
52
  "type": "object",
@@ -114,11 +119,25 @@
114
119
  "minLength": 1
115
120
  },
116
121
  "steps": {
117
- "description": "The Steps: toolchain commands run in order from the repository root through `sh -c`; the first that does not pass ends the Gate and no Check runs. Each one's output is captured, and its last 50 lines are printed when it does not pass; `--verbose` passes the output through instead. A command the shell cannot find (exit 127) or execute (126) means the Gate cannot run.",
122
+ "description": "The Steps: toolchain commands run in order from the repository root through `sh -c`; the first that does not pass ends the Gate and no Check runs. A Step is a command string, or an object with its `command` and a `hint`. Each one's output is captured, and its last 50 lines are printed when it does not pass, with the `hint` under them; `--verbose` passes the output through instead. A command the shell cannot find (exit 127) or execute (126) means the Gate cannot run.",
118
123
  "type": "array",
119
124
  "items": {
120
- "type": "string",
121
- "minLength": 1
125
+ "type": ["string", "object"],
126
+ "minLength": 1,
127
+ "properties": {
128
+ "command": {
129
+ "description": "The command the Step runs.",
130
+ "type": "string",
131
+ "minLength": 1
132
+ },
133
+ "hint": {
134
+ "description": "What to try when the Step fails: one line the Gate prints as `hint: <text>` under the failure. A passing Step prints nothing.",
135
+ "type": "string",
136
+ "minLength": 1
137
+ }
138
+ },
139
+ "required": ["command"],
140
+ "additionalProperties": false
122
141
  }
123
142
  },
124
143
  "drift": {
@@ -144,6 +163,11 @@
144
163
  "command": {
145
164
  "type": "string",
146
165
  "minLength": 1
166
+ },
167
+ "hint": {
168
+ "description": "What to try when the script fails: one line the Gate prints as `hint: <text>` under the failure. A passing script prints nothing.",
169
+ "type": "string",
170
+ "minLength": 1
147
171
  }
148
172
  },
149
173
  "required": ["name", "command"],
@@ -379,6 +403,31 @@
379
403
  }
380
404
  },
381
405
  "additionalProperties": false
406
+ },
407
+ "disable-directives": {
408
+ "description": "Every Disable directive in a comment gives a reason: `oxlint-disable*` and `eslint-disable*` in every form, `@ts-expect-error` and `@ts-ignore` in JavaScript and TypeScript, `rubocop:disable` in Ruby and `noqa` in Python. A reason is text after `--` on the directive's line; for `@ts-expect-error` and `@ts-ignore`, text after the directive, with or without `:`; or a comment on the line directly above. A directive inside a string or a template is none, and a rule set to `off` in a lint config is not judged. A hard fail, with no Ratchet; the Gate runs it, never a session hook.",
409
+ "type": ["boolean", "object"],
410
+ "properties": {
411
+ "files": {
412
+ "description": "Globs of the files to read, against the files git tracks or would track; `*` and `**` skip a name that starts with a dot. Default every JavaScript, TypeScript, Ruby (`.rb`, `.rake`, `.gemspec`, `.ru`, `Rakefile`, `Gemfile`) and Python file.",
413
+ "type": "array",
414
+ "items": {
415
+ "type": "string",
416
+ "minLength": 1
417
+ },
418
+ "uniqueItems": true
419
+ },
420
+ "exempt": {
421
+ "description": "Globs of the files left out. Default `references/**`, `**/vendor/**` and `**/node_modules/**`; a list replaces the default.",
422
+ "type": "array",
423
+ "items": {
424
+ "type": "string",
425
+ "minLength": 1
426
+ },
427
+ "uniqueItems": true
428
+ }
429
+ },
430
+ "additionalProperties": false
382
431
  }
383
432
  },
384
433
  "additionalProperties": false
@@ -54,11 +54,21 @@ export type Guardrails = {
54
54
  export type GuardrailsLayer = Partial<Omit<Guardrails, "envFiles">> & {
55
55
  envFiles?: Partial<EnvFiles>;
56
56
  };
57
- /** A repository's own script under `gate.checks.run`, reported as a Check under `name`. */
57
+ /** A repository's own script under `gate.checks.run`, reported as a Check under `name`; `hint` is what to try when it fails. */
58
58
  export type RunCheck = {
59
59
  name: string;
60
60
  command: string;
61
+ hint?: string;
61
62
  };
63
+ /** A Step of `gate.steps`: its command, or the command with a `hint` on what to try when it fails. */
64
+ export type Step = string | {
65
+ command: string;
66
+ hint?: string;
67
+ };
68
+ /** The command a Step runs. */
69
+ export declare function stepCommand(step: Step): string;
70
+ /** The Hint a Step carries, null where it has none. */
71
+ export declare function stepHint(step: Step): string | null;
62
72
  /**
63
73
  * `run`, and under each other key a Check the package ships, as the schema takes it: `true`, `false` or
64
74
  * its options. `src/checks/` reads the setting of each.
@@ -72,14 +82,14 @@ export type DriftSetting = "fail" | "allow";
72
82
  /** The Gate: its command, the steps run in order, whether they may write the tree, the Checks run after them. */
73
83
  export type Gate = {
74
84
  command?: string;
75
- steps: string[];
85
+ steps: Step[];
76
86
  drift: DriftSetting;
77
87
  checks: GateChecks;
78
88
  };
79
89
  /** `gate` as one layer writes it: every key optional, `checks` Check by Check. */
80
90
  export type GateLayer = {
81
91
  command?: string;
82
- steps?: string[];
92
+ steps?: Step[];
83
93
  drift?: DriftSetting;
84
94
  checks?: Partial<GateChecks>;
85
95
  };
@@ -15,6 +15,14 @@ export const COMMIT_CONVENTIONS = ["ticket-key", "conventional"];
15
15
  export const REVIEW_DEPTHS = ["full", "light", "off"];
16
16
  /** Claude Code's effort levels, lowest first: what `worker.effort` takes. */
17
17
  export const WORKER_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
18
+ /** The command a Step runs. */
19
+ export function stepCommand(step) {
20
+ return typeof step === "string" ? step : step.command;
21
+ }
22
+ /** The Hint a Step carries, null where it has none. */
23
+ export function stepHint(step) {
24
+ return typeof step === "string" ? null : (step.hint ?? null);
25
+ }
18
26
  /** Strictest value first; the local override may only move towards the front. */
19
27
  export const STRICTNESS = {
20
28
  forcePush: ["deny", "ask", "lease"],
@@ -2,262 +2,11 @@
2
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
4
  import { join, matchesGlob } from "node:path";
5
+ import { commentsOf, shellCommentsOf } from "./comments.js";
5
6
  import { matching, treeFiles } from "./tree.js";
6
7
  const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"];
7
8
  const SHELL = "**/*.sh";
8
9
  const REMEDY = "cut it to what the code cannot say, or move the reasoning into a doc and point at it";
9
- /** The characters after which a `/` opens a regular expression, not a division. */
10
- const BEFORE_REGEX = new Set("(,=:[!&|?{};+-*%<>~^");
11
- const KEYWORD_BEFORE_REGEX = /(^|[^\w$])(return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/;
12
- /** Where the regular expression whose `/` is at `start` ends, or undefined when the line ends first. */
13
- function regexEnd(text, start) {
14
- let inClass = false;
15
- for (let index = start + 1; index < text.length; index += 1) {
16
- const char = text[index];
17
- if (char === "\\") {
18
- index += 1;
19
- }
20
- else if (char === "\n") {
21
- return undefined;
22
- }
23
- else if (char === "[" || char === "]") {
24
- inClass = char === "[";
25
- }
26
- else if (char === "/" && !inClass) {
27
- return index + 1;
28
- }
29
- }
30
- return undefined;
31
- }
32
- /** Whether the `/` at `index` opens a regular expression: the code before it ends where a value starts. */
33
- function opensRegex(text, index) {
34
- const before = text.slice(0, index).trimEnd();
35
- const last = before.at(-1);
36
- return last === undefined || BEFORE_REGEX.has(last) || KEYWORD_BEFORE_REGEX.test(before);
37
- }
38
- /** Every comment in JavaScript or TypeScript `text`, skipping strings, template literals and regular expressions. */
39
- function commentsOf(text) {
40
- const comments = [];
41
- // The open quotes, and a `{` for each brace opened inside a template literal's `${…}`.
42
- const stack = [];
43
- let line = 1;
44
- let index = 0;
45
- while (index < text.length) {
46
- const char = text[index];
47
- const next = text[index + 1];
48
- const top = stack.at(-1);
49
- if (top === "'" || top === '"' || top === "`") {
50
- if (char === "\\") {
51
- line += next === "\n" ? 1 : 0;
52
- index += 2;
53
- continue;
54
- }
55
- if (top === "`" && char === "$" && next === "{") {
56
- stack.push("{");
57
- index += 2;
58
- continue;
59
- }
60
- if (char === top) {
61
- stack.pop();
62
- }
63
- line += char === "\n" ? 1 : 0;
64
- index += 1;
65
- continue;
66
- }
67
- if (char === "'" || char === '"' || char === "`" || (char === "{" && top === "{")) {
68
- stack.push(char);
69
- }
70
- else if (char === "}" && top === "{") {
71
- stack.pop();
72
- }
73
- else if (char === "/" && next === "/") {
74
- const stop = text.indexOf("\n", index) === -1 ? text.length : text.indexOf("\n", index);
75
- const alone = text.slice(text.lastIndexOf("\n", index - 1) + 1, index).trim() === "";
76
- comments.push({ kind: alone ? "line" : "trailing", start: line, end: line, text: text.slice(index, stop) });
77
- index = stop;
78
- continue;
79
- }
80
- else if (char === "/" && next === "*") {
81
- const close = text.indexOf("*/", index + 2);
82
- const body = text.slice(index, close === -1 ? text.length : close + 2);
83
- const start = line;
84
- line += body.split("\n").length - 1;
85
- comments.push({ kind: "block", start, end: line, text: body });
86
- index += body.length;
87
- continue;
88
- }
89
- else if (char === "/" && opensRegex(text, index)) {
90
- index = regexEnd(text, index) ?? index + 1;
91
- continue;
92
- }
93
- line += char === "\n" ? 1 : 0;
94
- index += 1;
95
- }
96
- return comments;
97
- }
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
10
  /** The comments as blocks: a run of `//` or `#` lines alone on consecutive lines is one; a comment after code is none. */
262
11
  function blocksOf(comments) {
263
12
  const blocks = [];
@@ -0,0 +1,29 @@
1
+ /** A `//` or `#` comment alone on its line, one after code, or a block comment, by its lines and text. */
2
+ export type Comment = {
3
+ kind: "line" | "trailing" | "block";
4
+ start: number;
5
+ end: number;
6
+ text: string;
7
+ };
8
+ /** Every comment in JavaScript or TypeScript `text`, skipping strings, template literals and regular expressions. */
9
+ export declare function commentsOf(text: string): Comment[];
10
+ /** Where the line that holds `index` ends: at its newline, or at the end of `text`. */
11
+ export declare function lineEnd(text: string, index: number): number;
12
+ /** Where a span that `closer` ends, read from `start`, stops, past the closer; `\` escapes a character when `escapes`. */
13
+ export declare function spanEnd(text: string, start: number, closer: string, escapes: boolean): number;
14
+ /** A heredoc whose body starts on the next line: its delimiter, and whether `<<-` strips the body's leading tabs. */
15
+ export type Heredoc = {
16
+ delimiter: string;
17
+ strip: boolean;
18
+ };
19
+ /** The heredoc whose `<<` is at `start`, its delimiter's quotes off, and where the delimiter's word ends. */
20
+ export declare function heredocAt(text: string, start: number): Heredoc & {
21
+ end: number;
22
+ };
23
+ /** Where the heredoc body that starts at `start` ends: past the line that is its delimiter. */
24
+ export declare function heredocEnd(text: string, start: number, { delimiter, strip }: Heredoc): number;
25
+ /**
26
+ * Every `#` comment in shell `text`: a `#` that starts a word of code, never one in a quote, an
27
+ * expansion, an arithmetic `((…))` or a heredoc body, nor the shebang on line 1.
28
+ */
29
+ export declare function shellCommentsOf(text: string): Comment[];