dw-sdlc 0.16.1 → 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 +7 -0
- package/config/agentic.config.schema.json +52 -3
- package/dist/agentic-config.d.ts +13 -3
- package/dist/agentic-config.js +8 -0
- package/dist/checks/comment-budget.js +1 -252
- package/dist/checks/comments.d.ts +29 -0
- package/dist/checks/comments.js +255 -0
- package/dist/checks/disable-directives.d.ts +6 -0
- package/dist/checks/disable-directives.js +91 -0
- package/dist/checks/hash-comments.d.ts +4 -0
- package/dist/checks/hash-comments.js +121 -0
- package/dist/checks/index.js +3 -1
- package/dist/commands/gate.d.ts +4 -1
- package/dist/commands/gate.js +22 -10
- package/dist/report.d.ts +8 -2
- package/dist/report.js +8 -3
- package/package.json +1 -1
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,6 +46,9 @@ 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
|
|
|
@@ -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
|
package/dist/agentic-config.d.ts
CHANGED
|
@@ -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:
|
|
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?:
|
|
92
|
+
steps?: Step[];
|
|
83
93
|
drift?: DriftSetting;
|
|
84
94
|
checks?: Partial<GateChecks>;
|
|
85
95
|
};
|
package/dist/agentic-config.js
CHANGED
|
@@ -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[];
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/checks/index.js
CHANGED
|
@@ -4,6 +4,7 @@ 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";
|
|
@@ -36,7 +37,8 @@ const SHIPPED = [
|
|
|
36
37
|
ship(temporalLanguage),
|
|
37
38
|
ship(decisionRecords),
|
|
38
39
|
ship(fileSizeRatchet),
|
|
39
|
-
ship(commentBudget)
|
|
40
|
+
ship(commentBudget),
|
|
41
|
+
ship(disableDirectives)
|
|
40
42
|
];
|
|
41
43
|
/** The name of every Check the package ships, in the order the Gate runs them. */
|
|
42
44
|
export const CHECK_NAMES = SHIPPED.map(({ name }) => name);
|
package/dist/commands/gate.d.ts
CHANGED
|
@@ -6,13 +6,16 @@ type Outcome = {
|
|
|
6
6
|
exitCode: number | null;
|
|
7
7
|
tail: string[] | null;
|
|
8
8
|
};
|
|
9
|
+
/** `hint` is what the Step carries to try when it fails, null where it has none. */
|
|
9
10
|
type StepReport = Outcome & {
|
|
10
11
|
command: string;
|
|
12
|
+
hint: string | null;
|
|
11
13
|
};
|
|
12
|
-
/** A Check the package ships runs in this process: it has no command. */
|
|
14
|
+
/** A Check the package ships runs in this process: it has no command and no hint. */
|
|
13
15
|
type CheckReport = Outcome & {
|
|
14
16
|
name: string;
|
|
15
17
|
command: string | null;
|
|
18
|
+
hint: string | null;
|
|
16
19
|
};
|
|
17
20
|
type GateReport = {
|
|
18
21
|
status: Status;
|
package/dist/commands/gate.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
|
|
2
2
|
// run and the report serve `dw-sdlc check` too, which judges one Check of it.
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
4
|
+
import { repositoryRoot, resolveConfig, stepCommand, stepHint } from "../agentic-config.js";
|
|
5
5
|
import { CannotRun } from "../checks/check.js";
|
|
6
6
|
import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
|
|
7
7
|
import { watchDrift } from "../drift.js";
|
|
8
8
|
import { fingerprint, isGreen, stampGreen } from "../gate-cache.js";
|
|
9
|
-
import { annotation, failureJson, failureLine } from "../report.js";
|
|
9
|
+
import { annotation, failureJson, failureLine, hintLines } from "../report.js";
|
|
10
10
|
import { holdingSignals, runCommand, stoppedBy, TAIL_LINES } from "../run-command.js";
|
|
11
11
|
export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
12
12
|
const USAGE = `Usage: dw-sdlc gate [--json] [--verbose] [--root <dir>] [--update-baseline | --cache]
|
|
@@ -21,6 +21,9 @@ Actions it is also a workflow annotation. A Step or run script prints nothing of
|
|
|
21
21
|
does not pass: then its last ${TAIL_LINES} lines, both streams in the order written, under its own
|
|
22
22
|
line. In a terminal a Step's line shows when it starts and takes its result when it ends.
|
|
23
23
|
|
|
24
|
+
A Step written as { "command", "hint" }, and a run script with a hint, prints "hint: <text>" under its
|
|
25
|
+
failure line when it does not pass; a passing one prints nothing more.
|
|
26
|
+
|
|
24
27
|
Each Step runs between two records of every file git tracks or would track: a file the Step created,
|
|
25
28
|
changed or deleted is Drift, one failure per file, and the Steps after it still run. Ignored files
|
|
26
29
|
and edits made before the run never drift, and a Step a signal stopped is not compared. gate.drift
|
|
@@ -33,7 +36,7 @@ second signal, or one sent while the Checks the package ships run, ends the Gate
|
|
|
33
36
|
|
|
34
37
|
--verbose passes each command's output through as it runs instead, under a line naming the command,
|
|
35
38
|
and keeps no tail. --json prints the document alone; each Step and Check in it carries tail, the last
|
|
36
|
-
${TAIL_LINES} lines of a command that did not pass, null otherwise. With --verbose, under --json, the
|
|
39
|
+
${TAIL_LINES} lines of a command that did not pass, null otherwise, and hint, null where there is none. With --verbose, under --json, the
|
|
37
40
|
commands' output goes to stderr and every tail is null.
|
|
38
41
|
|
|
39
42
|
--update-baseline then rewrites the baseline of each Ratchet, only when every Step and every other
|
|
@@ -268,7 +271,7 @@ export async function runGate(root, options) {
|
|
|
268
271
|
const failures = [];
|
|
269
272
|
const watch = watchDrift(root, config.gate);
|
|
270
273
|
/** Runs one Step or Check, prints its progress and records its failure under `rule`, if it has one. */
|
|
271
|
-
const runOne = async (kind, subject, command) => {
|
|
274
|
+
const runOne = async (kind, subject, command, hint) => {
|
|
272
275
|
progress.start(kind, subject);
|
|
273
276
|
const ran = await runCommand(command, root, output);
|
|
274
277
|
progress.end(kind, subject, ran);
|
|
@@ -277,7 +280,8 @@ export async function runGate(root, options) {
|
|
|
277
280
|
failures.push({
|
|
278
281
|
rule: kind === "step" ? "step" : subject,
|
|
279
282
|
message: `${JSON.stringify(command)} ${ran.detail}`,
|
|
280
|
-
remedy
|
|
283
|
+
remedy,
|
|
284
|
+
...(hint === null ? {} : { hint })
|
|
281
285
|
});
|
|
282
286
|
}
|
|
283
287
|
return { status: ran.status, exitCode: ran.exitCode, tail: ran.tail };
|
|
@@ -289,15 +293,19 @@ export async function runGate(root, options) {
|
|
|
289
293
|
const halted = () => failedStep || stoppedBy() !== undefined;
|
|
290
294
|
const steps = [];
|
|
291
295
|
for (const step of selected.steps) {
|
|
292
|
-
const
|
|
296
|
+
const command = stepCommand(step);
|
|
297
|
+
const hint = stepHint(step);
|
|
298
|
+
const watched = halted() ? undefined : await watch.step(command, () => runOne("step", command, command, hint));
|
|
293
299
|
failures.push(...(watched?.failures ?? []));
|
|
294
300
|
const outcome = watched?.result ?? SKIPPED;
|
|
295
|
-
steps.push({ command
|
|
301
|
+
steps.push({ command, hint, ...outcome });
|
|
296
302
|
failedStep ||= outcome.status !== "pass";
|
|
297
303
|
}
|
|
298
304
|
const checks = [];
|
|
299
305
|
for (const check of selected.run) {
|
|
300
|
-
|
|
306
|
+
const hint = check.hint ?? null;
|
|
307
|
+
const outcome = halted() ? SKIPPED : await runOne("check", check.name, check.command, hint);
|
|
308
|
+
checks.push({ name: check.name, command: check.command, hint, ...outcome });
|
|
301
309
|
}
|
|
302
310
|
return { steps, checks, halted: halted() };
|
|
303
311
|
});
|
|
@@ -323,7 +331,7 @@ export async function runGate(root, options) {
|
|
|
323
331
|
progress.end("check", check.name, outcome);
|
|
324
332
|
}
|
|
325
333
|
failures.push(...found);
|
|
326
|
-
checks.push({ name: check.name, command: null, ...outcome });
|
|
334
|
+
checks.push({ name: check.name, command: null, hint: null, ...outcome });
|
|
327
335
|
}
|
|
328
336
|
for (const note of notes) {
|
|
329
337
|
log.write(`${note}\n`);
|
|
@@ -361,7 +369,11 @@ export function printReport(report, json, label) {
|
|
|
361
369
|
}
|
|
362
370
|
}
|
|
363
371
|
else {
|
|
364
|
-
const lines = report.failures.flatMap((failure) =>
|
|
372
|
+
const lines = report.failures.flatMap((failure) => [
|
|
373
|
+
failureLine(failure),
|
|
374
|
+
...(actions ? [annotation(failure)] : []),
|
|
375
|
+
...hintLines(failure)
|
|
376
|
+
]);
|
|
365
377
|
process.stdout.write(`${[...lines, verdict(label, report)].join("\n")}\n`);
|
|
366
378
|
}
|
|
367
379
|
return EXIT_CODES[report.status];
|
package/dist/report.d.ts
CHANGED
|
@@ -4,10 +4,16 @@ export type Failure = {
|
|
|
4
4
|
rule: string;
|
|
5
5
|
message: string;
|
|
6
6
|
remedy: string;
|
|
7
|
+
/** What the repository owner wrote to try, printed as `hint: <text>` under the failure line. */
|
|
8
|
+
hint?: string;
|
|
9
|
+
/** The Disable directive a `disable-directives` failure names, as written. */
|
|
10
|
+
directive?: string;
|
|
7
11
|
};
|
|
12
|
+
/** `hint: <text>` where the failure carries a Hint, nothing otherwise. */
|
|
13
|
+
export declare function hintLines({ hint }: Failure): string[];
|
|
8
14
|
/** `file:line: rule: message — remedy`, without the location where the failure has none. */
|
|
9
15
|
export declare function failureLine({ file, line, rule, message, remedy }: Failure): string;
|
|
10
16
|
/** `::error file=…,line=…,title=<rule>::<message> — <remedy>`, an annotation on the file where there is one. */
|
|
11
17
|
export declare function annotation({ file, line, rule, message, remedy }: Failure): string;
|
|
12
|
-
/** The failure as JSON: every key present, null where it has no file or line. */
|
|
13
|
-
export declare function failureJson({ file, line, rule, message, remedy }: Failure): Record<string, unknown>;
|
|
18
|
+
/** The failure as JSON: every key present, null where it has no file or line, and `directive` where it names one. */
|
|
19
|
+
export declare function failureJson({ file, line, rule, message, remedy, directive }: Failure): Record<string, unknown>;
|
package/dist/report.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// A failure of the Gate as every consumer reads it: one line naming the file and line where there is
|
|
2
2
|
// one, the rule, what is wrong and the remedy; the same as a GitHub Actions workflow annotation; the
|
|
3
3
|
// same as JSON.
|
|
4
|
+
/** `hint: <text>` where the failure carries a Hint, nothing otherwise. */
|
|
5
|
+
export function hintLines({ hint }) {
|
|
6
|
+
return hint === undefined ? [] : [`hint: ${hint}`];
|
|
7
|
+
}
|
|
4
8
|
/** `file:line: rule: message — remedy`, without the location where the failure has none. */
|
|
5
9
|
export function failureLine({ file, line, rule, message, remedy }) {
|
|
6
10
|
const where = file === undefined ? "" : `${file}${line === undefined ? "" : `:${line}`}: `;
|
|
@@ -23,7 +27,8 @@ export function annotation({ file, line, rule, message, remedy }) {
|
|
|
23
27
|
];
|
|
24
28
|
return `::error ${properties.join(",")}::${escapeData(`${message} — ${remedy}`)}`;
|
|
25
29
|
}
|
|
26
|
-
/** The failure as JSON: every key present, null where it has no file or line. */
|
|
27
|
-
export function failureJson({ file, line, rule, message, remedy }) {
|
|
28
|
-
|
|
30
|
+
/** The failure as JSON: every key present, null where it has no file or line, and `directive` where it names one. */
|
|
31
|
+
export function failureJson({ file, line, rule, message, remedy, directive }) {
|
|
32
|
+
const json = { file: file ?? null, line: line ?? null, rule, message, remedy };
|
|
33
|
+
return directive === undefined ? json : { ...json, directive };
|
|
29
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|