neo-agent-skills 0.1.70 → 0.1.71
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.
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
# Design authority —
|
|
1
|
+
# Design authority — non-epic agent tickets
|
|
2
2
|
|
|
3
|
-
A receipt — geometry, timing, a rendered frame — shows what happens, never whether it is wanted.
|
|
3
|
+
A receipt — geometry, timing, a rendered frame — shows what happens, never whether it is wanted. Every non-epic agent ticket without a defect excuse (fenced error, spec path or failing test in Problem/Context, or quoted `@`-JSDoc) carries in The Architectural Reality before The Fix:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- `Design authority: <the record sentence, quoted>` — ADR, guide or owning JSDoc making the behavior intended.
|
|
6
|
+
- `Design authority: none found (searched: …)` — searched decisions, guides and `ask_knowledge_base`; fork on the owning record, not a Fix.
|
|
7
|
+
- `Design authority: n/a — <reason>` — calls no existing behavior wrong.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
Reviewers read the line as the premise: `pr-review` §9.0.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neo-agent-skills",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.71",
|
|
4
4
|
"description": "Install the workflow substrate a cross-model AI team actually runs on: ticket intake, PR review gates, lane coordination and guards, versioned as a package. The skills Neo.mjs maintainers load every session — materialized into your repo, not copied.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"bin": {
|
|
23
23
|
"neo-agent-skills-materialize": "./scripts/materialize-harness-skills.mjs",
|
|
24
24
|
"neo-agent-skills-pr-body": "./scripts/check-pr-body.mjs",
|
|
25
|
+
"neo-agent-skills-ticket-body": "./scripts/check-ticket-body.mjs",
|
|
25
26
|
"neo-agent-skills-substrate-size": "./scripts/check-substrate-size.mjs",
|
|
26
27
|
"neo-agent-skills-ticket-archaeology": "./scripts/check-ticket-archaeology.mjs",
|
|
27
28
|
"neo-agent-skills-workflow-concurrency": "./scripts/check-workflow-concurrency.mjs",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"scripts/check-secrets.mjs",
|
|
48
49
|
"scripts/check-substrate-size.mjs",
|
|
49
50
|
"scripts/check-ticket-archaeology.mjs",
|
|
51
|
+
"scripts/check-ticket-body.mjs",
|
|
50
52
|
"scripts/check-workflow-concurrency.mjs",
|
|
51
53
|
"scripts/generate-agents-md.mjs",
|
|
52
54
|
"scripts/materialize-harness-skills.mjs"
|
|
@@ -54,7 +56,7 @@
|
|
|
54
56
|
"scripts": {
|
|
55
57
|
"materialize": "node scripts/materialize-harness-skills.mjs",
|
|
56
58
|
"lint": "node scripts/lint-skill-corpus.mjs",
|
|
57
|
-
"test": "node scripts/test-lint-skill-corpus.mjs && node scripts/test-reusable-pr-baseline.mjs && node scripts/test-reusable-dependabot-automerge.mjs && node scripts/test-ticket-archaeology.mjs && node scripts/test-substrate-size.mjs && node scripts/test-check-pr-body.mjs && node scripts/test-workflow-concurrency.mjs && node scripts/test-generate-agents-md.mjs && node scripts/test-materialize-harness-skills.mjs && node scripts/test-npm-overrides.mjs && node scripts/test-secrets.mjs && node scripts/test-commit-authorship.mjs && node scripts/test-check-discussion-fidelity.mjs && node scripts/test-tag-release.mjs && node scripts/test-check-version-bump.mjs && node scripts/test-release-origin.mjs",
|
|
59
|
+
"test": "node scripts/test-lint-skill-corpus.mjs && node scripts/test-reusable-pr-baseline.mjs && node scripts/test-reusable-dependabot-automerge.mjs && node scripts/test-ticket-archaeology.mjs && node scripts/test-substrate-size.mjs && node scripts/test-check-pr-body.mjs && node scripts/test-check-ticket-body.mjs && node scripts/test-workflow-concurrency.mjs && node scripts/test-generate-agents-md.mjs && node scripts/test-materialize-harness-skills.mjs && node scripts/test-npm-overrides.mjs && node scripts/test-secrets.mjs && node scripts/test-commit-authorship.mjs && node scripts/test-check-discussion-fidelity.mjs && node scripts/test-tag-release.mjs && node scripts/test-check-version-bump.mjs && node scripts/test-release-origin.mjs",
|
|
58
60
|
"generate-agents-md": "node scripts/generate-agents-md.mjs",
|
|
59
61
|
"postpublish": "node scripts/tag-release.mjs"
|
|
60
62
|
},
|
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @summary Validates a GitHub issue body against the Design-authority gate and its three defect excuses.
|
|
4
|
+
*
|
|
5
|
+
* Pure and transport-free: receives a ticket body and returns findings without reading the network.
|
|
6
|
+
*
|
|
7
|
+
* Two arms (enforced on every non-epic agent ticket):
|
|
8
|
+
* 1. The `Design authority:` line is present in content using one of the three grammars from
|
|
9
|
+
* `.agents/skills/ticket-create/references/design-authority.md`:
|
|
10
|
+
* - `Design authority: "<the record sentence, quoted>"`
|
|
11
|
+
* - `Design authority: none found (searched: …)`
|
|
12
|
+
* - `Design authority: n/a — <reason>`
|
|
13
|
+
* 2. The line is absent, and the body carries at least one of the three defect excuses:
|
|
14
|
+
* - `crash`: a fenced code block containing a crash, error, or stack trace;
|
|
15
|
+
* - `red`: a spec/test path or failing test title in the Problem/Context sections;
|
|
16
|
+
* - `jsdoc`: a quoted `@`-JSDoc line showing the contradicted contract sentence.
|
|
17
|
+
*
|
|
18
|
+
* When neither arm holds, the guard exits non-zero and points to `design-authority.md`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {readFileSync, realpathSync} from 'node:fs';
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
import {parseArgs} from 'node:util';
|
|
24
|
+
import {fileURLToPath} from 'node:url';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonical skill reference path named in failure diagnostics.
|
|
28
|
+
* @member {String} DESIGN_AUTHORITY_REF
|
|
29
|
+
*/
|
|
30
|
+
export const DESIGN_AUTHORITY_REF = '.agents/skills/ticket-create/references/design-authority.md';
|
|
31
|
+
|
|
32
|
+
const
|
|
33
|
+
INDENT_CODE_RE = /^(?: {4,}|\t)/,
|
|
34
|
+
CRASH_TOKENS = Object.freeze([
|
|
35
|
+
'Error:',
|
|
36
|
+
'TypeError:',
|
|
37
|
+
'ReferenceError:',
|
|
38
|
+
'SyntaxError:',
|
|
39
|
+
'RangeError:',
|
|
40
|
+
'URIError:',
|
|
41
|
+
'EvalError:',
|
|
42
|
+
'AssertionError',
|
|
43
|
+
'ERR_',
|
|
44
|
+
'Uncaught ',
|
|
45
|
+
'Exception',
|
|
46
|
+
'Segmentation fault',
|
|
47
|
+
'FATAL',
|
|
48
|
+
'Command exited with code',
|
|
49
|
+
'npm ERR!',
|
|
50
|
+
'FAIL ',
|
|
51
|
+
'FAILED'
|
|
52
|
+
]),
|
|
53
|
+
JSDOC_TAGS = Object.freeze([
|
|
54
|
+
'@summary',
|
|
55
|
+
'@param',
|
|
56
|
+
'@returns',
|
|
57
|
+
'@return',
|
|
58
|
+
'@throws',
|
|
59
|
+
'@member',
|
|
60
|
+
'@class',
|
|
61
|
+
'@config',
|
|
62
|
+
'@function',
|
|
63
|
+
'@fires',
|
|
64
|
+
'@see',
|
|
65
|
+
'@default',
|
|
66
|
+
'@type',
|
|
67
|
+
'@example',
|
|
68
|
+
'@description'
|
|
69
|
+
]),
|
|
70
|
+
STOP_SECTIONS = Object.freeze([
|
|
71
|
+
'the architectural reality',
|
|
72
|
+
'architectural reality',
|
|
73
|
+
'the fix',
|
|
74
|
+
'proposed fix',
|
|
75
|
+
'fix',
|
|
76
|
+
'acceptance criteria',
|
|
77
|
+
'out of scope',
|
|
78
|
+
'avoided traps',
|
|
79
|
+
'related'
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @summary Parses a Markdown fence line (` ``` ` or ` ~~~ ` with at most 3 leading spaces) in linear time.
|
|
84
|
+
* @param {String} line
|
|
85
|
+
* @returns {{fence: String, info: String}|null}
|
|
86
|
+
*/
|
|
87
|
+
function parseFenceLine(line) {
|
|
88
|
+
let spaces = 0;
|
|
89
|
+
while (spaces < 4 && spaces < line.length && line[spaces] === ' ') {
|
|
90
|
+
spaces++
|
|
91
|
+
}
|
|
92
|
+
if (spaces > 3 || spaces >= line.length) return null;
|
|
93
|
+
|
|
94
|
+
const ch = line[spaces];
|
|
95
|
+
if (ch !== '`' && ch !== '~') return null;
|
|
96
|
+
|
|
97
|
+
let runLen = 0;
|
|
98
|
+
while (spaces + runLen < line.length && line[spaces + runLen] === ch) {
|
|
99
|
+
runLen++
|
|
100
|
+
}
|
|
101
|
+
if (runLen < 3) return null;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
fence: line.slice(spaces, spaces + runLen),
|
|
105
|
+
info : line.slice(spaces + runLen).trim()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @summary Extracts a Markdown heading's normalized lowercase title in linear time.
|
|
111
|
+
* @param {String} line
|
|
112
|
+
* @returns {String|null}
|
|
113
|
+
*/
|
|
114
|
+
function parseHeadingTitle(line) {
|
|
115
|
+
let spaces = 0;
|
|
116
|
+
while (spaces < 4 && spaces < line.length && line[spaces] === ' ') {
|
|
117
|
+
spaces++
|
|
118
|
+
}
|
|
119
|
+
if (spaces > 3 || spaces >= line.length) return null;
|
|
120
|
+
|
|
121
|
+
let hashes = 0;
|
|
122
|
+
while (hashes < 7 && spaces + hashes < line.length && line[spaces + hashes] === '#') {
|
|
123
|
+
hashes++
|
|
124
|
+
}
|
|
125
|
+
if (hashes < 1 || hashes > 6) return null;
|
|
126
|
+
|
|
127
|
+
const after = line.slice(spaces + hashes);
|
|
128
|
+
if (!after || (after[0] !== ' ' && after[0] !== '\t')) return null;
|
|
129
|
+
|
|
130
|
+
const title = after.replaceAll('`', '').trim().toLowerCase();
|
|
131
|
+
return title || null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @summary Extracts prose lines outside fenced and indented code blocks.
|
|
136
|
+
* @param {String} [body='']
|
|
137
|
+
* @returns {String[]}
|
|
138
|
+
*/
|
|
139
|
+
export function markdownContentLines(body = '') {
|
|
140
|
+
const
|
|
141
|
+
lines = String(body).split('\n'),
|
|
142
|
+
out = [];
|
|
143
|
+
|
|
144
|
+
let fence = null;
|
|
145
|
+
|
|
146
|
+
for (const rawLine of lines) {
|
|
147
|
+
const
|
|
148
|
+
line = rawLine.replace(/\r$/, ''),
|
|
149
|
+
opener = parseFenceLine(line);
|
|
150
|
+
|
|
151
|
+
if (fence) {
|
|
152
|
+
if (opener && opener.fence[0] === fence[0] && opener.fence.length >= fence.length && !opener.info) {
|
|
153
|
+
fence = null
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (opener) {
|
|
159
|
+
fence = opener.fence;
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (INDENT_CODE_RE.test(line)) continue;
|
|
164
|
+
|
|
165
|
+
out.push(line)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @summary Extracts fenced code blocks (`info` string and inner `text`) from a Markdown body.
|
|
173
|
+
* @param {String} [body='']
|
|
174
|
+
* @returns {Array<{info: String, text: String}>}
|
|
175
|
+
*/
|
|
176
|
+
export function fencedBlocks(body = '') {
|
|
177
|
+
const
|
|
178
|
+
lines = String(body).split('\n'),
|
|
179
|
+
blocks = [];
|
|
180
|
+
|
|
181
|
+
let active = null;
|
|
182
|
+
|
|
183
|
+
for (const rawLine of lines) {
|
|
184
|
+
const
|
|
185
|
+
line = rawLine.replace(/\r$/, ''),
|
|
186
|
+
opener = parseFenceLine(line);
|
|
187
|
+
|
|
188
|
+
if (active) {
|
|
189
|
+
if (opener && opener.fence[0] === active.fence[0] && opener.fence.length >= active.fence.length && !opener.info) {
|
|
190
|
+
blocks.push({info: active.info, text: active.lines.join('\n')});
|
|
191
|
+
active = null;
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
active.lines.push(line);
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (opener) {
|
|
199
|
+
active = {fence: opener.fence, info: opener.info.toLowerCase(), lines: []}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (active) {
|
|
204
|
+
blocks.push({info: active.info, text: active.lines.join('\n')})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return blocks
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @summary Collects all quoted or backticked substrings within a single line.
|
|
212
|
+
* @param {String} line
|
|
213
|
+
* @returns {String[]}
|
|
214
|
+
*/
|
|
215
|
+
function extractQuotedSpans(line) {
|
|
216
|
+
const
|
|
217
|
+
spans = [],
|
|
218
|
+
pairs = [
|
|
219
|
+
['"', '"'],
|
|
220
|
+
["'", "'"],
|
|
221
|
+
['\u201c', '\u201d'],
|
|
222
|
+
['`', '`']
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
for (const [open, close] of pairs) {
|
|
226
|
+
let pos = 0;
|
|
227
|
+
|
|
228
|
+
while (pos < line.length) {
|
|
229
|
+
const start = line.indexOf(open, pos);
|
|
230
|
+
if (start === -1) break;
|
|
231
|
+
|
|
232
|
+
const end = line.indexOf(close, start + open.length);
|
|
233
|
+
if (end === -1) break;
|
|
234
|
+
|
|
235
|
+
const inner = line.slice(start + open.length, end).trim();
|
|
236
|
+
if (inner) spans.push(inner);
|
|
237
|
+
|
|
238
|
+
pos = end + close.length
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return spans
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @summary Checks whether a ticket body carries a valid `Design authority:` line in content.
|
|
247
|
+
* @param {String} [body='']
|
|
248
|
+
* @returns {Boolean}
|
|
249
|
+
*/
|
|
250
|
+
export function hasDesignAuthorityLine(body = '') {
|
|
251
|
+
const lines = markdownContentLines(body);
|
|
252
|
+
|
|
253
|
+
for (const rawLine of lines) {
|
|
254
|
+
let text = rawLine.trim();
|
|
255
|
+
if (!text || text.startsWith('|')) continue;
|
|
256
|
+
|
|
257
|
+
if (text.startsWith('- ') || text.startsWith('* ') || text.startsWith('+ ')) {
|
|
258
|
+
text = text.slice(2).trimStart()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const normalized = text.replaceAll('**', '').trimStart();
|
|
262
|
+
if (!normalized.toLowerCase().startsWith('design authority:')) continue;
|
|
263
|
+
|
|
264
|
+
const
|
|
265
|
+
rest = normalized.slice('design authority:'.length).trim(),
|
|
266
|
+
lower = rest.toLowerCase();
|
|
267
|
+
|
|
268
|
+
if (!rest) continue;
|
|
269
|
+
|
|
270
|
+
if (lower.startsWith('none found')) {
|
|
271
|
+
const sIdx = lower.indexOf('(searched:');
|
|
272
|
+
if (sIdx !== -1) {
|
|
273
|
+
const closeIdx = rest.indexOf(')', sIdx + 10);
|
|
274
|
+
if (closeIdx !== -1) {
|
|
275
|
+
const searched = rest.slice(sIdx + 10, closeIdx).trim();
|
|
276
|
+
if (searched.length > 0 && searched !== '...' && searched !== '\u2026') {
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (lower.startsWith('n/a')) {
|
|
285
|
+
const tail = rest.slice(3).trimStart();
|
|
286
|
+
if (tail.startsWith('\u2014') || tail.startsWith('-') || tail.startsWith(':')) {
|
|
287
|
+
const reason = tail.slice(1).trim();
|
|
288
|
+
if (reason.length >= 3) return true
|
|
289
|
+
}
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (rest.startsWith('>') && rest.slice(1).trim().length >= 3) {
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const quoted = extractQuotedSpans(rest);
|
|
298
|
+
if (quoted.some(span => span.length >= 3)) {
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* @summary Checks whether a line inside a fenced block looks like a stack trace frame.
|
|
308
|
+
* @param {String} line
|
|
309
|
+
* @returns {Boolean}
|
|
310
|
+
*/
|
|
311
|
+
function isStackFrameLine(line) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
if (!trimmed.startsWith('at ')) return false;
|
|
314
|
+
|
|
315
|
+
const rest = trimmed.slice(3).trim();
|
|
316
|
+
return Boolean(rest) && (rest.includes('(') || rest.includes('/') || rest.includes('.mjs') || rest.includes('.js'))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @summary Extracts prose lines belonging to `## Context` and `## The Problem` (or pre-Fix prose).
|
|
321
|
+
* @param {String} [body='']
|
|
322
|
+
* @returns {String[]}
|
|
323
|
+
*/
|
|
324
|
+
export function problemContextLines(body = '') {
|
|
325
|
+
const
|
|
326
|
+
lines = markdownContentLines(body),
|
|
327
|
+
out = [];
|
|
328
|
+
|
|
329
|
+
let inProblemOrContext = true;
|
|
330
|
+
|
|
331
|
+
for (const line of lines) {
|
|
332
|
+
const title = parseHeadingTitle(line);
|
|
333
|
+
|
|
334
|
+
if (title) {
|
|
335
|
+
if (title === 'context' || title === 'the context' || title === 'problem' || title === 'the problem') {
|
|
336
|
+
inProblemOrContext = true;
|
|
337
|
+
continue
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (STOP_SECTIONS.some(stop => title === stop || title.startsWith(`${stop} `))) {
|
|
341
|
+
inProblemOrContext = false;
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (inProblemOrContext) {
|
|
347
|
+
out.push(line)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return out
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @summary Tests whether a token names a test or spec file path.
|
|
356
|
+
* @param {String} rawToken
|
|
357
|
+
* @returns {Boolean}
|
|
358
|
+
*/
|
|
359
|
+
function isSpecPathToken(rawToken) {
|
|
360
|
+
let token = rawToken.trim();
|
|
361
|
+
|
|
362
|
+
while (token.length > 0 && '`"([\'<'.includes(token[0])) {
|
|
363
|
+
token = token.slice(1)
|
|
364
|
+
}
|
|
365
|
+
while (token.length > 0 && '`")]\',.:;>'.includes(token[token.length - 1])) {
|
|
366
|
+
token = token.slice(0, -1)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const lineColIdx = token.indexOf(':');
|
|
370
|
+
if (lineColIdx > 0) {
|
|
371
|
+
token = token.slice(0, lineColIdx)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!token || token.includes(' ')) return false;
|
|
375
|
+
|
|
376
|
+
if (
|
|
377
|
+
token.endsWith('.spec.mjs') ||
|
|
378
|
+
token.endsWith('.spec.js') ||
|
|
379
|
+
token.endsWith('.test.mjs') ||
|
|
380
|
+
token.endsWith('.test.js')
|
|
381
|
+
) {
|
|
382
|
+
return token.length > 9
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (
|
|
386
|
+
(token.startsWith('test/') || token.startsWith('tests/') || token.startsWith('scripts/test-')) &&
|
|
387
|
+
(token.endsWith('.mjs') || token.endsWith('.js'))
|
|
388
|
+
) {
|
|
389
|
+
return true
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return token.startsWith('test-') && (token.endsWith('.mjs') || token.endsWith('.js'))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @summary Checks whether a text segment carries a JSDoc `@tag` with descriptive contract text.
|
|
397
|
+
* @param {String} segment
|
|
398
|
+
* @returns {Boolean}
|
|
399
|
+
*/
|
|
400
|
+
function containsJsdocClause(segment) {
|
|
401
|
+
const trimmed = segment.trim();
|
|
402
|
+
if (!trimmed) return false;
|
|
403
|
+
|
|
404
|
+
for (const tag of JSDOC_TAGS) {
|
|
405
|
+
let searchFrom = 0;
|
|
406
|
+
|
|
407
|
+
while (searchFrom < trimmed.length) {
|
|
408
|
+
const idx = trimmed.indexOf(tag, searchFrom);
|
|
409
|
+
if (idx === -1) break;
|
|
410
|
+
|
|
411
|
+
const
|
|
412
|
+
beforeOk = idx === 0 || ' \t*({["\'\u201c`'.includes(trimmed[idx - 1]),
|
|
413
|
+
afterIdx = idx + tag.length,
|
|
414
|
+
afterCh = afterIdx < trimmed.length ? trimmed[afterIdx] : '';
|
|
415
|
+
|
|
416
|
+
if (beforeOk && (afterCh === ' ' || afterCh === '\t')) {
|
|
417
|
+
const rest = trimmed.slice(afterIdx).trim();
|
|
418
|
+
if (rest.length >= 3) return true
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
searchFrom = idx + 1
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return false
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* @summary Evaluates the three defect excuses (`crash`, `red`, `jsdoc`) on a ticket body.
|
|
430
|
+
* @param {String} [body='']
|
|
431
|
+
* @returns {{crash: Boolean, red: Boolean, jsdoc: Boolean, excused: Boolean}}
|
|
432
|
+
*/
|
|
433
|
+
export function hasDefectExcuse(body = '') {
|
|
434
|
+
const
|
|
435
|
+
blocks = fencedBlocks(body),
|
|
436
|
+
content = markdownContentLines(body),
|
|
437
|
+
probCtx = problemContextLines(body);
|
|
438
|
+
|
|
439
|
+
const crash = blocks.some(block => {
|
|
440
|
+
if (['error', 'stderr', 'stack', 'trace'].includes(block.info) && block.text.trim().length > 0) {
|
|
441
|
+
return true
|
|
442
|
+
}
|
|
443
|
+
if (CRASH_TOKENS.some(token => block.text.includes(token))) {
|
|
444
|
+
return true
|
|
445
|
+
}
|
|
446
|
+
return block.text.split('\n').some(isStackFrameLine)
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const red = probCtx.some(line => {
|
|
450
|
+
const tokens = line.split(/[\s|]+/).filter(Boolean);
|
|
451
|
+
if (tokens.some(isSpecPathToken)) return true;
|
|
452
|
+
|
|
453
|
+
const lower = line.toLowerCase();
|
|
454
|
+
if (
|
|
455
|
+
(lower.includes('failing test:') || lower.includes('red test:') || lower.includes('failing spec:')) &&
|
|
456
|
+
extractQuotedSpans(line).some(span => span.length >= 3)
|
|
457
|
+
) {
|
|
458
|
+
return true
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (lower.includes('test-') && (lower.includes('exit 1') || lower.includes('failed'))) {
|
|
462
|
+
return true
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return false
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const jsdoc = content.some(line => {
|
|
469
|
+
const trimmed = line.trim();
|
|
470
|
+
if (!trimmed) return false;
|
|
471
|
+
|
|
472
|
+
if (trimmed.startsWith('>') && containsJsdocClause(trimmed.slice(1))) {
|
|
473
|
+
return true
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return extractQuotedSpans(trimmed).some(containsJsdocClause)
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
crash,
|
|
481
|
+
red,
|
|
482
|
+
jsdoc,
|
|
483
|
+
excused: crash || red || jsdoc
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @summary Returns ticket-body violations when neither `Design authority:` nor any defect excuse is present.
|
|
489
|
+
* @param {Object} [options={}]
|
|
490
|
+
* @param {String} [options.body='']
|
|
491
|
+
* @returns {{
|
|
492
|
+
* ok: Boolean,
|
|
493
|
+
* satisfiedBy: 'design-authority'|'defect-excuse'|null,
|
|
494
|
+
* excuses: {crash: Boolean, red: Boolean, jsdoc: Boolean, excused: Boolean},
|
|
495
|
+
* violations: String[]
|
|
496
|
+
* }}
|
|
497
|
+
*/
|
|
498
|
+
export function findTicketBodyViolations({body = ''} = {}) {
|
|
499
|
+
const
|
|
500
|
+
hasAuthority = hasDesignAuthorityLine(body),
|
|
501
|
+
excuses = hasDefectExcuse(body);
|
|
502
|
+
|
|
503
|
+
if (hasAuthority) {
|
|
504
|
+
return {
|
|
505
|
+
ok : true,
|
|
506
|
+
satisfiedBy: 'design-authority',
|
|
507
|
+
excuses,
|
|
508
|
+
violations : []
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (excuses.excused) {
|
|
513
|
+
return {
|
|
514
|
+
ok : true,
|
|
515
|
+
satisfiedBy: 'defect-excuse',
|
|
516
|
+
excuses,
|
|
517
|
+
violations : []
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
ok : false,
|
|
523
|
+
satisfiedBy: null,
|
|
524
|
+
excuses,
|
|
525
|
+
violations : [
|
|
526
|
+
`Ticket body carries neither a valid \`Design authority:\` line nor a defect excuse. See ${DESIGN_AUTHORITY_REF}.`
|
|
527
|
+
]
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* @summary CLI entry point. Reads a ticket body from `--body-file` or stdin and validates it.
|
|
533
|
+
* @param {String[]} [argv]
|
|
534
|
+
* @param {Object} [options={}]
|
|
535
|
+
* @param {Function} [options.out=console.log]
|
|
536
|
+
* @param {Function} [options.error=console.error]
|
|
537
|
+
* @param {String} [options.stdin='']
|
|
538
|
+
* @returns {Number} Exit code (`0` on pass, `1` on violation or read error).
|
|
539
|
+
*/
|
|
540
|
+
export function run(argv = process.argv.slice(2), {out = console.log, error = console.error, stdin = ''} = {}) {
|
|
541
|
+
const parsed = parseArgs({
|
|
542
|
+
args : argv,
|
|
543
|
+
allowPositionals: false,
|
|
544
|
+
strict : true,
|
|
545
|
+
options : {'body-file': {type: 'string'}}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
let body = stdin;
|
|
549
|
+
|
|
550
|
+
if (parsed.values['body-file']) {
|
|
551
|
+
try {
|
|
552
|
+
body = readFileSync(parsed.values['body-file'], 'utf8')
|
|
553
|
+
} catch (cause) {
|
|
554
|
+
error(`check-ticket-body: cannot read ${parsed.values['body-file']} — ${cause.message}`);
|
|
555
|
+
return 1
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const result = findTicketBodyViolations({body});
|
|
560
|
+
|
|
561
|
+
if (result.ok) {
|
|
562
|
+
out(`✅ Ticket body passes design-authority check (${result.satisfiedBy}).`);
|
|
563
|
+
return 0
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
error('❌ Ticket body is missing `Design authority:` and carries no defect excuse.');
|
|
567
|
+
error(` ${result.violations[0]}`);
|
|
568
|
+
return 1
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
572
|
+
const args = process.argv.slice(2);
|
|
573
|
+
|
|
574
|
+
if (args.some(arg => arg === '--body-file' || arg.startsWith('--body-file='))) {
|
|
575
|
+
process.exit(run(args))
|
|
576
|
+
} else {
|
|
577
|
+
const chunks = [];
|
|
578
|
+
|
|
579
|
+
process.stdin.on('data', chunk => chunks.push(chunk))
|
|
580
|
+
.on('end', () => process.exit(run(args, {stdin: chunks.join('')})))
|
|
581
|
+
}
|
|
582
|
+
}
|