canary-test-cli 6.7.1 → 6.8.1
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/dist/doctor-manifest.d.ts +2 -3
- package/dist/doctor-manifest.js +5 -6
- package/dist/engine/core/feedback.js +1 -1
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/static-linter.js +34 -10
- package/dist/engine/core/string-literals.js +202 -0
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +1 -1
- package/dist/engine/guardian/agent-tier.js +2 -2
- package/dist/engine/guardian/analysis-emit.js +6 -1
- package/dist/engine/guardian/cli.js +31 -12
- package/dist/engine/guardian/coverage.js +39 -16
- package/dist/engine/guardian/impact-mapper.js +4 -3
- package/dist/engine/guardian/pr-check.js +23 -3
- package/dist/engine/history/cli.js +1 -1
- package/dist/overlay-commands.d.ts +0 -2
- package/dist/overlay-commands.js +0 -1
- package/dist/overlay-lint.d.ts +0 -7
- package/dist/overlay-lint.js +3 -4
- package/dist/overlays-registry.d.ts +0 -1
- package/dist/overlays-registry.js +0 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CheckResult } from './doctor.js';
|
|
2
|
-
|
|
2
|
+
declare const CHECK_TYPES: readonly ["file-exists", "url-reachable", "command-succeeds"];
|
|
3
3
|
export type CheckType = (typeof CHECK_TYPES)[number];
|
|
4
4
|
/** A single validated check from an overlay's `doctor.json`. */
|
|
5
5
|
export interface ManifestCheck {
|
|
@@ -60,8 +60,6 @@ export declare function collectAudiences(checks: ManifestCheck[]): string[];
|
|
|
60
60
|
* contains the tag (case-insensitive).
|
|
61
61
|
*/
|
|
62
62
|
export declare function filterByAudience(checks: ManifestCheck[], audience: string | null): ManifestCheck[];
|
|
63
|
-
/** Default per-check timeout for url and command checks. */
|
|
64
|
-
export declare const DEFAULT_CHECK_TIMEOUT_MS = 10000;
|
|
65
63
|
/** Probe a URL for reachability (injectable). Resolves true on a 2xx/3xx. */
|
|
66
64
|
export type UrlProbe = (url: string, timeoutMs: number) => Promise<boolean>;
|
|
67
65
|
/** Run a command (injectable). `ok` = exit 0; `timedOut` = killed at the timeout. */
|
|
@@ -92,3 +90,4 @@ export interface RunContext {
|
|
|
92
90
|
* granted. Never throws.
|
|
93
91
|
*/
|
|
94
92
|
export declare function runCheck(check: ManifestCheck, ctx: RunContext): Promise<CheckResult>;
|
|
93
|
+
export {};
|
package/dist/doctor-manifest.js
CHANGED
|
@@ -33,7 +33,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.DEFAULT_CHECK_TIMEOUT_MS = exports.CHECK_TYPES = void 0;
|
|
37
36
|
exports.manifestPath = manifestPath;
|
|
38
37
|
exports.commandSucceedsHash = commandSucceedsHash;
|
|
39
38
|
exports.loadManifest = loadManifest;
|
|
@@ -54,7 +53,7 @@ const http = __importStar(require("node:http"));
|
|
|
54
53
|
const https = __importStar(require("node:https"));
|
|
55
54
|
const path = __importStar(require("node:path"));
|
|
56
55
|
const node_child_process_1 = require("node:child_process");
|
|
57
|
-
|
|
56
|
+
const CHECK_TYPES = [
|
|
58
57
|
'file-exists',
|
|
59
58
|
'url-reachable',
|
|
60
59
|
'command-succeeds',
|
|
@@ -109,8 +108,8 @@ function validateCommonFields(c, index) {
|
|
|
109
108
|
return `check[${index}] is missing a string "id"`;
|
|
110
109
|
}
|
|
111
110
|
if (typeof c.type !== 'string' ||
|
|
112
|
-
!
|
|
113
|
-
return `check "${c.id}" has an unknown type (expected one of: ${
|
|
111
|
+
!CHECK_TYPES.includes(c.type)) {
|
|
112
|
+
return `check "${c.id}" has an unknown type (expected one of: ${CHECK_TYPES.join(', ')})`;
|
|
114
113
|
}
|
|
115
114
|
if (typeof c.remedy !== 'string' || c.remedy === '') {
|
|
116
115
|
return `check "${c.id}" is missing a string "remedy"`;
|
|
@@ -233,7 +232,7 @@ function filterByAudience(checks, audience) {
|
|
|
233
232
|
c.audience.some((p) => p.toLowerCase() === want));
|
|
234
233
|
}
|
|
235
234
|
/** Default per-check timeout for url and command checks. */
|
|
236
|
-
|
|
235
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 10000;
|
|
237
236
|
function defaultProbeUrl(url, timeoutMs) {
|
|
238
237
|
return new Promise((resolve) => {
|
|
239
238
|
let mod;
|
|
@@ -336,7 +335,7 @@ function runCommandSucceeds(check, ctx, timeoutMs) {
|
|
|
336
335
|
* granted. Never throws.
|
|
337
336
|
*/
|
|
338
337
|
async function runCheck(check, ctx) {
|
|
339
|
-
const timeoutMs = ctx.timeoutMs ??
|
|
338
|
+
const timeoutMs = ctx.timeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS;
|
|
340
339
|
if (check.type === 'file-exists') {
|
|
341
340
|
return runFileExists(check, ctx.cloneDir);
|
|
342
341
|
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { release, type } from 'node:os';
|
|
26
26
|
/** The public issue tracker (from npm/package.json `repository`). */
|
|
27
|
-
|
|
27
|
+
const TRACKER_URL = 'https://github.com/bop-clocktower/canary';
|
|
28
28
|
export const VALID_CATEGORIES = ['bug', 'ux', 'docs', 'idea'];
|
|
29
29
|
/** Best-effort install-method label — never fails, never inspects secrets. */
|
|
30
30
|
function installMethod() {
|
|
@@ -26,7 +26,7 @@ export function isFile(path) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
/** Compile a single glob segment (with `*` -> `[^/]*`) to an anchored regex. */
|
|
29
|
-
|
|
29
|
+
function segGlobRegex(seg) {
|
|
30
30
|
const body = seg
|
|
31
31
|
.replace(/[.+^${}()|[\]\\?]/g, '\\$&')
|
|
32
32
|
.replace(/\*/g, '[^/]*');
|
|
@@ -75,7 +75,7 @@ export function globFiles(root, pattern) {
|
|
|
75
75
|
visit(root, 0);
|
|
76
76
|
return out;
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
function subDirs(dir) {
|
|
79
79
|
try {
|
|
80
80
|
return readdirSync(dir, { withFileTypes: true })
|
|
81
81
|
.filter((e) => e.isDirectory())
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { readFileSync } from 'node:fs';
|
|
9
9
|
import { basename, extname } from 'node:path';
|
|
10
|
+
import { blankStringContent } from './string-literals.js';
|
|
10
11
|
export function formatFinding(f) {
|
|
11
12
|
return `[${f.severity.toUpperCase()}] ${f.file}:${f.line} (${f.rule})\n ${f.message}\n → ${f.suggestion}`;
|
|
12
13
|
}
|
|
@@ -23,8 +24,19 @@ const LOCATOR_METHODS = /\.(locator|querySelector)\s*\(/;
|
|
|
23
24
|
// Missing await
|
|
24
25
|
const BARE_PLAYWRIGHT_CALL = /(?<!await\s)(?<!return\s)(?<!\w)(?:page|frame|locator)\.(?:click|fill|type|check|uncheck|selectOption|hover|focus|press|tap|dblclick)\s*\(/;
|
|
25
26
|
// Assertion detection
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
// Indentation is `[ \t]*`, NOT `\s*`: `\s` matches a newline, so a `^`-anchored
|
|
28
|
+
// `\s*` starts the match at the FIRST of any run of blank lines above the `def`
|
|
29
|
+
// and counts those newlines as indentation. PEP 8 mandates blank lines between
|
|
30
|
+
// defs, so that was the normal case, and it broke two things at once -- the
|
|
31
|
+
// reported line landed above the test, and the inflated indent made the
|
|
32
|
+
// "next def at the same indent" body boundary un-matchable, so the body ran to
|
|
33
|
+
// end-of-file and could borrow a later test's assert (#633).
|
|
34
|
+
const TEST_FN_PY = /^([ \t]*)def (test_\w+)\s*\(/gm;
|
|
35
|
+
// `d` (hasIndices) so the NAME can be read back out of the ORIGINAL source.
|
|
36
|
+
// The scanner matches against string-blanked source, where the name itself has
|
|
37
|
+
// been blanked away; blanking is length-preserving precisely so these offsets
|
|
38
|
+
// still address the untouched text (#590).
|
|
39
|
+
const TEST_FN_JS = /(?:^|\s)(?:it|test)\s*\(\s*['"]([^'"]*)['"]/dgm;
|
|
28
40
|
const ASSERT_PY = /\bassert\b|\bpytest\.raises\b/;
|
|
29
41
|
// Assertion styles a JS/TS test may use. `expect()` (jest/vitest/playwright)
|
|
30
42
|
// was the only one recognized until canary was pointed at its own suites and
|
|
@@ -276,7 +288,7 @@ function scanAssertionFreePy(code, file) {
|
|
|
276
288
|
}
|
|
277
289
|
return out;
|
|
278
290
|
}
|
|
279
|
-
function scanAssertionFreeJs(code, file) {
|
|
291
|
+
function scanAssertionFreeJs(code, file, source) {
|
|
280
292
|
const out = [];
|
|
281
293
|
// The test declarations, in source order, so each body can be bounded by the
|
|
282
294
|
// NEXT one -- the JS analogue of what the pytest scanner already does with
|
|
@@ -296,7 +308,16 @@ function scanAssertionFreeJs(code, file) {
|
|
|
296
308
|
const bodyEnd = decls[i + 1]?.index ?? code.length;
|
|
297
309
|
const rest = code.slice(bodyStart, bodyEnd);
|
|
298
310
|
if (!ASSERT_JS.test(rest)) {
|
|
299
|
-
|
|
311
|
+
const name = m.indices?.[1]
|
|
312
|
+
? source.slice(m.indices[1][0], m.indices[1][1])
|
|
313
|
+
: m[1];
|
|
314
|
+
// The reported coordinate comes from the NAME's offset, not the match's.
|
|
315
|
+
// `TEST_FN_JS` opens with `(?:^|\s)`, which CONSUMES the character before
|
|
316
|
+
// `it`/`test` -- for any test not on line 1 that is the newline ending the
|
|
317
|
+
// previous line, so `m.index` sits one line early (#633). `start` is still
|
|
318
|
+
// the right anchor for the body bounds; only the line moves.
|
|
319
|
+
const nameStart = m.indices?.[1]?.[0] ?? start;
|
|
320
|
+
out.push(mk(file, lineOf(code, nameStart), 'LINT-006', 'warning', `Test "${name}" contains no assertions.`, 'Add an expect() call; a test that never asserts always passes.'));
|
|
300
321
|
}
|
|
301
322
|
}
|
|
302
323
|
return out;
|
|
@@ -356,13 +377,16 @@ export class StaticLinter {
|
|
|
356
377
|
/** Full quality audit — all rules. */
|
|
357
378
|
lint(path, framework) {
|
|
358
379
|
const code = readFileSync(path, 'utf-8');
|
|
359
|
-
// No rule may read the interior of a
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
380
|
+
// No rule may read the interior of a string as code. The per-line rules
|
|
381
|
+
// keep the line-oriented blanking they were written against; the assertion
|
|
382
|
+
// scanners take a whole-source pass instead, because they are the only
|
|
383
|
+
// rules that bound one match by the offset of the NEXT one -- so a phantom
|
|
384
|
+
// declaration inside a fixture does not just add a finding, it truncates a
|
|
385
|
+
// real test's body and attributes its assertion past the end (#590).
|
|
386
|
+
// Both blankers preserve line numbering, so findings agree on line numbers.
|
|
363
387
|
const lines = blankMultilineStrings(code.split('\n'));
|
|
364
|
-
const scanned = lines.join('\n');
|
|
365
388
|
const fw = requireFramework(path, framework);
|
|
389
|
+
const scanned = blankStringContent(code, { python: fw === 'pytest' });
|
|
366
390
|
const findings = [
|
|
367
391
|
...scanFlakiness(lines, path),
|
|
368
392
|
...scanSelectors(lines, path),
|
|
@@ -370,7 +394,7 @@ export class StaticLinter {
|
|
|
370
394
|
...scanMagicNumbers(lines, path),
|
|
371
395
|
...(fw === 'pytest'
|
|
372
396
|
? scanAssertionFreePy(scanned, path)
|
|
373
|
-
: scanAssertionFreeJs(scanned, path)),
|
|
397
|
+
: scanAssertionFreeJs(scanned, path, code)),
|
|
374
398
|
];
|
|
375
399
|
findings.sort((a, b) => a.line - b.line || cmp(a.rule, b.rule));
|
|
376
400
|
return findings;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offset-preserving string-literal blanking for the static linter. #590.
|
|
3
|
+
*
|
|
4
|
+
* The linter regexes over raw source, so a `test(...)` written inside a STRING
|
|
5
|
+
* is indistinguishable from one written in code. That is not a hypothetical:
|
|
6
|
+
* the files most likely to embed test source in a fixture are the linters,
|
|
7
|
+
* scanners and codemods -- exactly the tooling whose own tests you most want
|
|
8
|
+
* read. A downstream overlay on 6.7.0 got four LINT-006 findings on one suite
|
|
9
|
+
* and all four were data, not tests.
|
|
10
|
+
*
|
|
11
|
+
* Two narrower mechanisms already existed and #590 fell through the seam
|
|
12
|
+
* between them:
|
|
13
|
+
*
|
|
14
|
+
* - `blankMultilineStrings` toggles state on an ODD count of a delimiter per
|
|
15
|
+
* line, so a template literal that opens AND closes on the same physical
|
|
16
|
+
* line is skipped entirely. A `\n`-escaped fixture is one physical line.
|
|
17
|
+
* - The single-line `STRING_LITERAL` stripper has no backtick in its character
|
|
18
|
+
* class, and was never applied to the assertion scanners at all.
|
|
19
|
+
*
|
|
20
|
+
* This scanner reads the WHOLE source with a small state machine instead, so a
|
|
21
|
+
* literal is handled the same way whether it spans one line or twenty.
|
|
22
|
+
*
|
|
23
|
+
* ## Why blanking rather than rejecting matches
|
|
24
|
+
*
|
|
25
|
+
* The sibling helper in canary-blackhawk / canary-savant
|
|
26
|
+
* (`scripts/string-literals.mjs`) rejects a match whose start index falls
|
|
27
|
+
* inside a literal, because those scanners need the anchor token to stay
|
|
28
|
+
* visible -- blackhawk's BH003 matches `strftime('..%Z')` with the `%Z` inside
|
|
29
|
+
* the quotes on purpose. LINT-006 has the opposite need: it bounds each test
|
|
30
|
+
* body by the NEXT declaration's offset, so a phantom declaration inside a
|
|
31
|
+
* string truncates a real test's body and its assertion is attributed past the
|
|
32
|
+
* end. Suppressing the match is not enough; the text has to stop looking like
|
|
33
|
+
* a declaration. Hence blanking, and hence a separate implementation rather
|
|
34
|
+
* than a third copy of the `.mjs` helper.
|
|
35
|
+
*
|
|
36
|
+
* ## Invariants
|
|
37
|
+
*
|
|
38
|
+
* - Output has the SAME length and the SAME newline positions as the input, so
|
|
39
|
+
* every byte offset and computed line number stays valid.
|
|
40
|
+
* - Only literal CONTENT is replaced (with spaces); the quote characters stay,
|
|
41
|
+
* so a rule keying off the delimiter still sees that a string was there.
|
|
42
|
+
* - An UNTERMINATED literal is discarded, never applied. Blanking an unclosed
|
|
43
|
+
* run to end-of-file would silently disable every downstream rule from that
|
|
44
|
+
* point on -- the abstention shape, one layer inside the linter. This
|
|
45
|
+
* matches the deliberate choice already made in `blankMultilineStrings`.
|
|
46
|
+
*
|
|
47
|
+
* Fidelity limits (a state machine, not a parser):
|
|
48
|
+
* - A regex literal containing a quote (`/['"]/`) can open a phantom string.
|
|
49
|
+
* Because an unterminated run is discarded, the usual outcome is a no-op;
|
|
50
|
+
* the residual risk is a suppressed finding, never a fabricated one.
|
|
51
|
+
* - JSX text and Python f-string nesting beyond `${...}` are not modelled.
|
|
52
|
+
*/
|
|
53
|
+
const SPACE = ' ';
|
|
54
|
+
/**
|
|
55
|
+
* Replace the content of every CLOSED string literal in `code` with spaces,
|
|
56
|
+
* preserving length, newline positions, and the quote characters themselves.
|
|
57
|
+
*/
|
|
58
|
+
export function blankStringContent(code, options = {}) {
|
|
59
|
+
const spans = literalContentSpans(code, options.python === true);
|
|
60
|
+
if (spans.length === 0)
|
|
61
|
+
return code;
|
|
62
|
+
const out = [...code];
|
|
63
|
+
for (const [start, end] of spans) {
|
|
64
|
+
for (let i = start; i < end; i += 1) {
|
|
65
|
+
// Newlines survive so line numbering is unchanged; everything else goes.
|
|
66
|
+
if (out[i] !== '\n')
|
|
67
|
+
out[i] = SPACE;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out.join('');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Walk `code` once and collect the content spans of every literal that closes.
|
|
74
|
+
*
|
|
75
|
+
* The stack holds two frame kinds: a string frame (`quote` set) whose content
|
|
76
|
+
* accumulates, and an interpolation frame (`interp`) for a template's
|
|
77
|
+
* `${...}`, which is CODE and may itself contain strings.
|
|
78
|
+
*/
|
|
79
|
+
function literalContentSpans(code, python) {
|
|
80
|
+
const spans = [];
|
|
81
|
+
const stack = [];
|
|
82
|
+
const top = () => stack[stack.length - 1];
|
|
83
|
+
for (let i = 0; i < code.length; i += 1) {
|
|
84
|
+
const ch = code[i];
|
|
85
|
+
const frame = top();
|
|
86
|
+
if (frame?.kind === 'string') {
|
|
87
|
+
i = advanceInsideString(code, i, frame, spans, stack);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Code context: top level, or inside a `${ ... }` interpolation.
|
|
91
|
+
const commentEnd = skipComment(code, i, python);
|
|
92
|
+
if (commentEnd !== null) {
|
|
93
|
+
i = commentEnd;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const opener = readOpener(code, i, python);
|
|
97
|
+
if (opener !== null) {
|
|
98
|
+
stack.push({ kind: 'string', quote: opener, start: i + opener.length });
|
|
99
|
+
i += opener.length - 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (frame?.kind === 'interp')
|
|
103
|
+
advanceInsideInterp(ch, i, frame, stack);
|
|
104
|
+
}
|
|
105
|
+
// Anything still open never closed. Discard it -- see the header.
|
|
106
|
+
return spans;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Handle one character of a template's `${ ... }`. Only braces matter here:
|
|
110
|
+
* everything else in an interpolation is code the main loop already handles.
|
|
111
|
+
* Lives apart from the walk so brace bookkeeping cannot leak into it.
|
|
112
|
+
*/
|
|
113
|
+
function advanceInsideInterp(ch, i, frame, stack) {
|
|
114
|
+
if (ch === '{') {
|
|
115
|
+
frame.depth += 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (ch !== '}')
|
|
119
|
+
return;
|
|
120
|
+
if (frame.depth > 0) {
|
|
121
|
+
frame.depth -= 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
stack.pop();
|
|
125
|
+
// The enclosing template resumes its content after the `}`.
|
|
126
|
+
const enclosing = stack[stack.length - 1];
|
|
127
|
+
if (enclosing?.kind === 'string')
|
|
128
|
+
enclosing.start = i + 1;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Handle one character while inside a string frame. Returns the index the main
|
|
132
|
+
* loop should continue FROM (it will be incremented), so escape pairs and
|
|
133
|
+
* multi-character delimiters are consumed atomically.
|
|
134
|
+
*/
|
|
135
|
+
function advanceInsideString(code, i, frame, spans, stack) {
|
|
136
|
+
const ch = code[i];
|
|
137
|
+
// An escaped character is content and can never close the literal. Python
|
|
138
|
+
// raw strings are not modelled; treating `\` as an escape there costs at
|
|
139
|
+
// most a suppressed match, never a fabricated one.
|
|
140
|
+
if (ch === '\\')
|
|
141
|
+
return i + 1;
|
|
142
|
+
if (code.startsWith(frame.quote, i)) {
|
|
143
|
+
pushSpan(spans, frame.start, i);
|
|
144
|
+
stack.pop();
|
|
145
|
+
return i + frame.quote.length - 1;
|
|
146
|
+
}
|
|
147
|
+
// Template interpolation closes the current content run and opens code.
|
|
148
|
+
if (frame.quote === '`' && ch === '$' && code[i + 1] === '{') {
|
|
149
|
+
pushSpan(spans, frame.start, i);
|
|
150
|
+
stack.push({ kind: 'interp', depth: 0 });
|
|
151
|
+
return i + 1;
|
|
152
|
+
}
|
|
153
|
+
return i;
|
|
154
|
+
}
|
|
155
|
+
/** Record a content span, skipping empty ones. */
|
|
156
|
+
function pushSpan(spans, start, end) {
|
|
157
|
+
if (end > start)
|
|
158
|
+
spans.push([start, end]);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* If a comment starts at `i`, return the index of its last character so the
|
|
162
|
+
* caller can skip it. Quotes inside a comment are prose, not delimiters: an
|
|
163
|
+
* apostrophe in "don't" would otherwise open a phantom literal.
|
|
164
|
+
*/
|
|
165
|
+
function skipComment(code, i, python) {
|
|
166
|
+
if (python && code[i] === '#')
|
|
167
|
+
return endOfLine(code, i);
|
|
168
|
+
if (code[i] !== '/')
|
|
169
|
+
return null;
|
|
170
|
+
if (code[i + 1] === '/')
|
|
171
|
+
return endOfLine(code, i);
|
|
172
|
+
if (code[i + 1] === '*') {
|
|
173
|
+
const close = code.indexOf('*/', i + 2);
|
|
174
|
+
// An unterminated block comment runs to EOF; that IS how the language
|
|
175
|
+
// reads it, so unlike an unclosed string there is nothing to discard.
|
|
176
|
+
return close === -1 ? code.length - 1 : close + 1;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
function endOfLine(code, i) {
|
|
181
|
+
const nl = code.indexOf('\n', i);
|
|
182
|
+
return nl === -1 ? code.length - 1 : nl - 1;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Return the delimiter opening at `i`, longest first so a Python triple quote
|
|
186
|
+
* is never mistaken for an empty string followed by an opener.
|
|
187
|
+
*/
|
|
188
|
+
function readOpener(code, i, python) {
|
|
189
|
+
if (python) {
|
|
190
|
+
if (code.startsWith('"""', i))
|
|
191
|
+
return '"""';
|
|
192
|
+
if (code.startsWith("'''", i))
|
|
193
|
+
return "'''";
|
|
194
|
+
}
|
|
195
|
+
const ch = code[i];
|
|
196
|
+
if (ch === "'" || ch === '"')
|
|
197
|
+
return ch;
|
|
198
|
+
if (!python && ch === '`')
|
|
199
|
+
return '`';
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=string-literals.js.map
|
|
Binary file
|
|
@@ -35,7 +35,7 @@ import { dirname, join } from 'node:path';
|
|
|
35
35
|
import { STICKY_MARKER, findSticky } from './pr-comment.js';
|
|
36
36
|
import { readAllPages, restPageReader } from './github-paging.js';
|
|
37
37
|
/** Schema tag for adjudication records (independent of the findings schema). */
|
|
38
|
-
|
|
38
|
+
const ADJUDICATION_SCHEMA_VERSION = '1.0';
|
|
39
39
|
/**
|
|
40
40
|
* Record `source` + filename prefix. Deliberately namespaced UNDER the
|
|
41
41
|
* `canary-pr-guardian-` prefix, because harness's `AnalysisArchive` reads every
|
|
@@ -28,7 +28,7 @@ import { existsSync } from 'node:fs';
|
|
|
28
28
|
import { Severity } from './impact-mapper.js';
|
|
29
29
|
import { Finding } from './pr-check.js';
|
|
30
30
|
/** Construct a {@link ReviewRequest} (a frozen dataclass in Python). */
|
|
31
|
-
|
|
31
|
+
function reviewRequest(test_paths) {
|
|
32
32
|
return { test_paths };
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
@@ -86,7 +86,7 @@ const SEVERITY_BY_NAME = new Map(Object.values(Severity).map((s) => [s, s]));
|
|
|
86
86
|
* RecordingInvoker} default) yields `[]` -- the SKILL reports its review
|
|
87
87
|
* directly in-session.
|
|
88
88
|
*/
|
|
89
|
-
|
|
89
|
+
function parseReviewFindings(transcript) {
|
|
90
90
|
const findings = [];
|
|
91
91
|
for (const raw of transcript.split(/\r\n|\r|\n/)) {
|
|
92
92
|
const match = REVIEW_LINE_RE.exec(raw);
|
|
@@ -37,7 +37,11 @@ import { dirname, join } from 'node:path';
|
|
|
37
37
|
import { coverageDegradedNotice, coverageStatus, } from './coverage.js';
|
|
38
38
|
import { combineNotices, render } from './pr-check.js';
|
|
39
39
|
// 1.1 adds the additive `coverage` block (#554); readers of 1.0 are unaffected.
|
|
40
|
-
|
|
40
|
+
// 1.2 adds the additive `skipped` list (#582). Additive again, and bumped again
|
|
41
|
+
// for the reason recorded in #572: a reader that pins a version must be able to
|
|
42
|
+
// tell which fields it can rely on being present, and silence about a new field
|
|
43
|
+
// is indistinguishable from the field being absent for a real reason.
|
|
44
|
+
export const SCHEMA_VERSION = '1.2';
|
|
41
45
|
export const SOURCE = 'canary-pr-guardian';
|
|
42
46
|
const REF_SAFE = /[^A-Za-z0-9._-]/g;
|
|
43
47
|
const REF_MAX = 100; // cap the sanitized ref so a long branch never hits ENAMETOOLONG
|
|
@@ -120,6 +124,7 @@ export function buildAnalysisRecord(findings, args) {
|
|
|
120
124
|
coverage: coverage === null
|
|
121
125
|
? null
|
|
122
126
|
: { status: coverageStatus(coverage), ...coverage },
|
|
127
|
+
skipped: args.skipped ?? [],
|
|
123
128
|
summary: {
|
|
124
129
|
total: findings.length,
|
|
125
130
|
unaddressed: active.length,
|
|
@@ -853,7 +853,7 @@ async function postStickyComment(findings, resolution, deps, gateMeta = null) {
|
|
|
853
853
|
// D7: every filtered path stays visible as a SkipEntry, never folded
|
|
854
854
|
// into "passed". One entry per path so the rendered count still equals
|
|
855
855
|
// the path count the old `N path(s) skipped` line reported.
|
|
856
|
-
function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [],
|
|
856
|
+
function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [], typeOnlyUnits = []) {
|
|
857
857
|
return [
|
|
858
858
|
...skipped.map((u) => ({ name: u.path, reason: 'skipGlobs' })),
|
|
859
859
|
...testUnits.map((u) => ({ name: u.path, reason: 'test path' })),
|
|
@@ -868,12 +868,17 @@ function prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits = [],
|
|
|
868
868
|
name: u.path,
|
|
869
869
|
reason: 're-export barrel',
|
|
870
870
|
})),
|
|
871
|
-
...noisePaths.map((p) => ({
|
|
872
|
-
name: p,
|
|
873
|
-
reason: 'heuristic-ineligible',
|
|
874
|
-
})),
|
|
875
871
|
];
|
|
876
872
|
}
|
|
873
|
+
/**
|
|
874
|
+
* The heuristic-noise skip class (#413), which is only knowable AFTER the
|
|
875
|
+
* coverage ladder has scored each unit — so it cannot join
|
|
876
|
+
* {@link prCheckSkipEntries}, which must be built before the abstain exit.
|
|
877
|
+
* Kept a named function so the reason token has exactly one definition.
|
|
878
|
+
*/
|
|
879
|
+
function heuristicSkipEntries(noisePaths) {
|
|
880
|
+
return noisePaths.map((p) => ({ name: p, reason: 'heuristic-ineligible' }));
|
|
881
|
+
}
|
|
877
882
|
// Remediation is required copy (#508): say WHY the denominator
|
|
878
883
|
// collapsed and the first fix step. The #456 class, now loud.
|
|
879
884
|
const PR_CHECK_ABSTAIN_REMEDIATION = [
|
|
@@ -998,13 +1003,17 @@ async function prCheckCmd(opts, deps) {
|
|
|
998
1003
|
const weakFindings = config.weak_tests
|
|
999
1004
|
? buildWeakTestFindings(testUnits, diffText)
|
|
1000
1005
|
: [];
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
+
// #582: build the skip list ONCE, above the abstain exit, so the surviving
|
|
1007
|
+
// (non-abstain) path carries the same denominator the abstain payload has
|
|
1008
|
+
// carried since #579. The heuristic-noise class is not known until the
|
|
1009
|
+
// coverage ladder has run, so it is appended below rather than passed here.
|
|
1010
|
+
//
|
|
1011
|
+
// This supersedes a `preFilterSkipped` count that was computed at this point
|
|
1012
|
+
// and read by nothing — the fossil of an earlier attempt to surface the same
|
|
1013
|
+
// number on this path.
|
|
1014
|
+
const preCoverageSkips = prCheckSkipEntries(skipped, testUnits, barrelUnits, supportUnits, typeOnlyUnits);
|
|
1006
1015
|
if (kept.length === 0 && weakFindings.length === 0) {
|
|
1007
|
-
abstainPrCheck(
|
|
1016
|
+
abstainPrCheck(preCoverageSkips, opts.format, deps);
|
|
1008
1017
|
}
|
|
1009
1018
|
const { results, coverage } = resolveCoverageWithInput(kept, {
|
|
1010
1019
|
coveragePath: opts.coverage ?? null,
|
|
@@ -1020,11 +1029,17 @@ async function prCheckCmd(opts, deps) {
|
|
|
1020
1029
|
...applySuppressions(buildFindings(scoredResults)),
|
|
1021
1030
|
...weakFindings,
|
|
1022
1031
|
];
|
|
1032
|
+
// The complete skip list for this run: the pre-coverage filters plus the
|
|
1033
|
+
// heuristic-noise class the ladder just revealed.
|
|
1034
|
+
const allSkips = [
|
|
1035
|
+
...preCoverageSkips,
|
|
1036
|
+
...heuristicSkipEntries(noiseResults.map((r) => r.unit.path)),
|
|
1037
|
+
];
|
|
1023
1038
|
// #413: if the heuristic filter consumed every scorable unit, report it as a
|
|
1024
1039
|
// SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
|
|
1025
1040
|
// must be able to tell "nothing was judgeable" from "everything passed".
|
|
1026
1041
|
if (scoredResults.length === 0 && findings.length === 0) {
|
|
1027
|
-
abstainPrCheck(
|
|
1042
|
+
abstainPrCheck(allSkips, opts.format, deps);
|
|
1028
1043
|
}
|
|
1029
1044
|
// SC-5 (PR half): resolve the requested tier against actual capability. No
|
|
1030
1045
|
// agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
|
|
@@ -1043,6 +1058,9 @@ async function prCheckCmd(opts, deps) {
|
|
|
1043
1058
|
checked: scoredResults.length,
|
|
1044
1059
|
abstained: false,
|
|
1045
1060
|
coverage,
|
|
1061
|
+
// #582: `checked` is the numerator of a fraction whose denominator was
|
|
1062
|
+
// never printed. This is the rest of it.
|
|
1063
|
+
skipped: allSkips,
|
|
1046
1064
|
};
|
|
1047
1065
|
const coverageNotice = coverageDegradedNotice(coverage);
|
|
1048
1066
|
if (coverageNotice) {
|
|
@@ -1069,6 +1087,7 @@ async function prCheckCmd(opts, deps) {
|
|
|
1069
1087
|
checked: scoredResults.length,
|
|
1070
1088
|
abstained: false, // an abstained run exits before emit (see plan)
|
|
1071
1089
|
coverage,
|
|
1090
|
+
skipped: allSkips,
|
|
1072
1091
|
});
|
|
1073
1092
|
if (res.action === 'emitted') {
|
|
1074
1093
|
deps.out(`guardian: wrote analysis record ${RIGHT_ARROW} ${res.path}`);
|
|
@@ -171,7 +171,7 @@ function parseLcov(text) {
|
|
|
171
171
|
// The coverage-json contract version this build understands. Bumped only on a
|
|
172
172
|
// breaking change; the shape evolves additively (see
|
|
173
173
|
// docs/specs/coverage-json-contract.md).
|
|
174
|
-
|
|
174
|
+
const COVERAGE_JSON_SCHEMA_VERSION = 1;
|
|
175
175
|
/**
|
|
176
176
|
* Parse the canary coverage-json shape into `{path: {line: hits}}`.
|
|
177
177
|
*
|
|
@@ -612,48 +612,56 @@ function readReportText(reportPath) {
|
|
|
612
612
|
* blocks).
|
|
613
613
|
*/
|
|
614
614
|
export function resolveFromReport(units, reportPath) {
|
|
615
|
-
const { index } = readReportIndex(reportPath);
|
|
615
|
+
const { index, absence } = readReportIndex(reportPath);
|
|
616
616
|
if (index === null)
|
|
617
617
|
return null;
|
|
618
|
-
return matchUnitsToIndex(units, index);
|
|
618
|
+
return matchUnitsToIndex(units, index, absence);
|
|
619
619
|
}
|
|
620
620
|
/** Read + parse a coverage report, reporting each step's outcome separately. */
|
|
621
621
|
function readReportIndex(reportPath) {
|
|
622
|
+
const unusable = (found) => ({
|
|
623
|
+
found,
|
|
624
|
+
index: null,
|
|
625
|
+
absence: 'not-coverable',
|
|
626
|
+
});
|
|
622
627
|
if (!existsSync(reportPath))
|
|
623
|
-
return
|
|
628
|
+
return unusable(false);
|
|
624
629
|
const text = readReportText(reportPath);
|
|
625
630
|
// Present but unreadable/non-UTF-8 counts as found-and-unusable, not absent.
|
|
626
631
|
if (text === null)
|
|
627
|
-
return
|
|
632
|
+
return unusable(true);
|
|
628
633
|
const name = basename(reportPath).toLowerCase();
|
|
629
634
|
let index;
|
|
635
|
+
let absence;
|
|
630
636
|
if (name.endsWith('.json')) {
|
|
631
637
|
let parsed;
|
|
632
638
|
try {
|
|
633
639
|
parsed = JSON.parse(text);
|
|
634
640
|
}
|
|
635
641
|
catch {
|
|
636
|
-
return
|
|
642
|
+
return unusable(true);
|
|
637
643
|
}
|
|
638
644
|
index = parseCoverageJson(parsed);
|
|
645
|
+
absence = 'uncovered';
|
|
639
646
|
}
|
|
640
647
|
else if (name.endsWith('.info') || name.includes('lcov')) {
|
|
641
648
|
index = parseLcov(text);
|
|
649
|
+
absence = 'not-coverable';
|
|
642
650
|
}
|
|
643
651
|
else if (name.endsWith('.xml')) {
|
|
644
652
|
index = parseCobertura(text);
|
|
653
|
+
absence = 'not-coverable';
|
|
645
654
|
}
|
|
646
655
|
else {
|
|
647
656
|
// Unrecognized format → fall through to a lower fidelity tier.
|
|
648
|
-
return
|
|
657
|
+
return unusable(true);
|
|
649
658
|
}
|
|
650
|
-
if (index === null || Object.keys(index).length === 0)
|
|
651
|
-
return
|
|
652
|
-
}
|
|
653
|
-
return { found: true, index };
|
|
659
|
+
if (index === null || Object.keys(index).length === 0)
|
|
660
|
+
return unusable(true);
|
|
661
|
+
return { found: true, index, absence };
|
|
654
662
|
}
|
|
655
663
|
/** Resolve every unit the report index can speak to (COVERAGE_VERIFIED). */
|
|
656
|
-
function matchUnitsToIndex(units, index) {
|
|
664
|
+
function matchUnitsToIndex(units, index, absence) {
|
|
657
665
|
const results = [];
|
|
658
666
|
for (const unit of units) {
|
|
659
667
|
const hits = matchHits(unit.path, index);
|
|
@@ -665,17 +673,30 @@ function matchUnitsToIndex(units, index) {
|
|
|
665
673
|
continue;
|
|
666
674
|
}
|
|
667
675
|
const added = expandRanges(unit.added_ranges);
|
|
668
|
-
|
|
676
|
+
// The per-line form of the check above (#655): under a format that
|
|
677
|
+
// enumerates instrumented lines, a changed line with no record could not
|
|
678
|
+
// have been executed and is scored by neither side.
|
|
679
|
+
const coverable = absence === 'not-coverable' ? added.filter((ln) => ln in hits) : added;
|
|
680
|
+
if (coverable.length === 0) {
|
|
681
|
+
// Every changed line is non-coverable, so this report has nothing to say
|
|
682
|
+
// about the unit. An abstention — never a clean pass, never a finding.
|
|
683
|
+
// Falls through to the graph/heuristic tier exactly as an absent path does.
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const uncovered = coverable.filter((ln) => (hits[ln] ?? 0) <= 0);
|
|
669
687
|
const covered = uncovered.length === 0;
|
|
688
|
+
// State the denominator: "all covered" over 20 changed lines and over the 3
|
|
689
|
+
// of them that were coverable are very different claims (#508).
|
|
670
690
|
const evidence = covered
|
|
671
|
-
? `lines ${rangesStr(unit.added_ranges)}: all covered`
|
|
672
|
-
: `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} uncovered`;
|
|
691
|
+
? `lines ${rangesStr(unit.added_ranges)}: all ${coverable.length} coverable line(s) covered`
|
|
692
|
+
: `lines ${rangesStr(unit.added_ranges)}: ${uncovered.length} of ${coverable.length} coverable line(s) uncovered`;
|
|
673
693
|
results.push(makeResult({
|
|
674
694
|
unit,
|
|
675
695
|
covered,
|
|
676
696
|
fidelity: Fidelity.CoverageVerified,
|
|
677
697
|
evidence,
|
|
678
698
|
uncovered_lines: uncovered,
|
|
699
|
+
coverable_lines: coverable.length,
|
|
679
700
|
}));
|
|
680
701
|
}
|
|
681
702
|
return results;
|
|
@@ -1399,7 +1420,9 @@ export function resolveCoverageWithInput(units, options = {}) {
|
|
|
1399
1420
|
coverage.parsed = read.index !== null;
|
|
1400
1421
|
coverage.filesInReport =
|
|
1401
1422
|
read.index === null ? 0 : Object.keys(read.index).length;
|
|
1402
|
-
const report = read.index === null
|
|
1423
|
+
const report = read.index === null
|
|
1424
|
+
? null
|
|
1425
|
+
: matchUnitsToIndex(remaining, read.index, read.absence);
|
|
1403
1426
|
// An empty array (no unit matched the report) is falsy-equivalent in the
|
|
1404
1427
|
// Python `if report:` guard — fall through rather than lock in nothing.
|
|
1405
1428
|
if (report !== null && report.length > 0) {
|
|
@@ -21,10 +21,11 @@ export var Severity;
|
|
|
21
21
|
Severity["LOW"] = "low";
|
|
22
22
|
})(Severity || (Severity = {}));
|
|
23
23
|
/**
|
|
24
|
-
* Python: `Severity.sort_key` (ascending — CRITICAL sorts first).
|
|
25
|
-
*
|
|
24
|
+
* Python: `Severity.sort_key` (ascending — CRITICAL sorts first). The canonical
|
|
25
|
+
* severity ordering; reached from outside this module through
|
|
26
|
+
* {@link severitySortKey} rather than directly (#544).
|
|
26
27
|
*/
|
|
27
|
-
|
|
28
|
+
const SEVERITY_SORT_KEY = {
|
|
28
29
|
[Severity.CRITICAL]: 0,
|
|
29
30
|
[Severity.HIGH]: 1,
|
|
30
31
|
[Severity.MEDIUM]: 2,
|
|
@@ -565,6 +565,22 @@ const HIGH_UNCOVERED_SHARE = 0.5;
|
|
|
565
565
|
function addedLineCount(ranges) {
|
|
566
566
|
return ranges.reduce((total, [start, end]) => total + (end - start + 1), 0);
|
|
567
567
|
}
|
|
568
|
+
/**
|
|
569
|
+
* The fraction of what the report could speak to that came back unhit (#655).
|
|
570
|
+
*
|
|
571
|
+
* The denominator is the unit's **coverable** lines — added lines overstate it,
|
|
572
|
+
* because on a new file that is largely imports, types and blanks most changed
|
|
573
|
+
* lines were never instrumented at all, and dividing by them drives every share
|
|
574
|
+
* toward zero. Where the count is unknown (the graph and heuristic tiers, which
|
|
575
|
+
* are not graded by share anyway) it falls back to the added-line count, so
|
|
576
|
+
* grades predating that field are unchanged. An unknown denominator yields a
|
|
577
|
+
* full share rather than a low one — an absent measurement must never read as a
|
|
578
|
+
* good score (ADR 0010).
|
|
579
|
+
*/
|
|
580
|
+
function uncoveredShare(result, uncovered) {
|
|
581
|
+
const denominator = result.coverable_lines ?? addedLineCount(result.unit.added_ranges);
|
|
582
|
+
return denominator > 0 ? uncovered / denominator : 1;
|
|
583
|
+
}
|
|
568
584
|
/**
|
|
569
585
|
* Severity for an uncovered **coverage-verified** result (#553).
|
|
570
586
|
*
|
|
@@ -589,8 +605,7 @@ function coverageVerifiedSeverity(result) {
|
|
|
589
605
|
const uncovered = result.uncovered_lines?.length ?? 0;
|
|
590
606
|
if (uncovered === 0)
|
|
591
607
|
return Severity.HIGH;
|
|
592
|
-
const
|
|
593
|
-
const share = added > 0 ? uncovered / added : 1;
|
|
608
|
+
const share = uncoveredShare(result, uncovered);
|
|
594
609
|
if (uncovered >= CRITICAL_UNCOVERED_LINES &&
|
|
595
610
|
share >= CRITICAL_UNCOVERED_SHARE)
|
|
596
611
|
return Severity.CRITICAL;
|
|
@@ -914,7 +929,7 @@ export function combineNotices(...notices) {
|
|
|
914
929
|
return kept.length > 0 ? kept.join('; ') : null;
|
|
915
930
|
}
|
|
916
931
|
/** The `coverage` block the json/analysis surfaces carry (#554). */
|
|
917
|
-
|
|
932
|
+
function coverageBlock(state) {
|
|
918
933
|
return { status: coverageStatus(state), ...state };
|
|
919
934
|
}
|
|
920
935
|
/**
|
|
@@ -958,6 +973,11 @@ export function render(findings, fmt, tier = 0, degradedNotice = null, gateMeta
|
|
|
958
973
|
if (gateMeta !== null) {
|
|
959
974
|
payload['checked'] = gateMeta.checked;
|
|
960
975
|
payload['abstained'] = gateMeta.abstained;
|
|
976
|
+
// #582: unconditional, and `[]` when nothing was dropped. An omitted key
|
|
977
|
+
// would make "this run skipped nothing" indistinguishable from "this
|
|
978
|
+
// producer predates #582", leaving a consumer to guess exactly the thing
|
|
979
|
+
// the field exists to state.
|
|
980
|
+
payload['skipped'] = gateMeta.skipped ?? [];
|
|
961
981
|
if (coverageState)
|
|
962
982
|
payload['coverage'] = coverageBlock(coverageState);
|
|
963
983
|
}
|
|
@@ -104,7 +104,7 @@ function pick(src, fields) {
|
|
|
104
104
|
return out;
|
|
105
105
|
}
|
|
106
106
|
/** Process-backed defaults for production. */
|
|
107
|
-
|
|
107
|
+
function defaultHistoryDeps() {
|
|
108
108
|
return {
|
|
109
109
|
out: (s) => process.stdout.write(`${s}\n`),
|
|
110
110
|
err: (s) => process.stderr.write(`${s}\n`),
|
|
@@ -33,8 +33,6 @@ export declare function classifyCloneFailure(res: GitResult): string;
|
|
|
33
33
|
export declare function add(source: string, options?: {
|
|
34
34
|
ref?: string | null;
|
|
35
35
|
}, deps?: CommandDeps): number;
|
|
36
|
-
/** Count `.canary/skills/<name>/SKILL.md` entries in a clone. */
|
|
37
|
-
export declare function skillCount(dest: string): number;
|
|
38
36
|
/** Working-tree cleanliness of a clone. */
|
|
39
37
|
export type CleanStatus = 'clean' | 'dirty' | 'unreadable';
|
|
40
38
|
/**
|
package/dist/overlay-commands.js
CHANGED
|
@@ -35,7 +35,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.classifyCloneFailure = classifyCloneFailure;
|
|
37
37
|
exports.add = add;
|
|
38
|
-
exports.skillCount = skillCount;
|
|
39
38
|
exports.workingTreeStatus = workingTreeStatus;
|
|
40
39
|
exports.freshness = freshness;
|
|
41
40
|
exports.list = list;
|
package/dist/overlay-lint.d.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The BUNDLED migration target shapes, plus the `all` sentinel. Not a closed
|
|
3
|
-
* set: `migrate` matches `deploy_to` against the consuming repo's resolved
|
|
4
|
-
* `canary_shape` by plain string comparison, so downstream overlays may use
|
|
5
|
-
* custom shapes. Lint warns (never errors) on a value outside this set (#501).
|
|
6
|
-
*/
|
|
7
|
-
export declare const VALID_DEPLOY_TARGETS: ReadonlySet<string>;
|
|
8
1
|
export interface LintFinding {
|
|
9
2
|
/** Skill name, or `(overlay)` for an overlay-level finding. */
|
|
10
3
|
skill: string;
|
package/dist/overlay-lint.js
CHANGED
|
@@ -33,7 +33,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.VALID_DEPLOY_TARGETS = void 0;
|
|
37
36
|
exports.lintOverlay = lintOverlay;
|
|
38
37
|
/**
|
|
39
38
|
* `canary overlay lint` — validate an overlay against the authoring contract
|
|
@@ -65,7 +64,7 @@ const skill_frontmatter_js_1 = require("./skill-frontmatter.js");
|
|
|
65
64
|
* `canary_shape` by plain string comparison, so downstream overlays may use
|
|
66
65
|
* custom shapes. Lint warns (never errors) on a value outside this set (#501).
|
|
67
66
|
*/
|
|
68
|
-
|
|
67
|
+
const VALID_DEPLOY_TARGETS = new Set([
|
|
69
68
|
'api',
|
|
70
69
|
'e2e_ui',
|
|
71
70
|
'frontend_unit',
|
|
@@ -116,11 +115,11 @@ function frontmatterFindings(skill, fm, parseErrors) {
|
|
|
116
115
|
}
|
|
117
116
|
}
|
|
118
117
|
for (const target of (0, skill_frontmatter_js_1.listField)(fm, 'deploy_to')) {
|
|
119
|
-
if (!
|
|
118
|
+
if (!VALID_DEPLOY_TARGETS.has(target)) {
|
|
120
119
|
findings.push({
|
|
121
120
|
skill,
|
|
122
121
|
level: 'warning',
|
|
123
|
-
message: `deploy_to value "${target}" is not a bundled target (${[...
|
|
122
|
+
message: `deploy_to value "${target}" is not a bundled target (${[...VALID_DEPLOY_TARGETS].join(', ')}); fine if it matches a consuming repo's custom canary_shape, otherwise a typo`,
|
|
124
123
|
});
|
|
125
124
|
}
|
|
126
125
|
}
|
|
@@ -48,7 +48,6 @@ export interface OverlayRegistry {
|
|
|
48
48
|
export declare class RegistryError extends Error {
|
|
49
49
|
constructor(message: string);
|
|
50
50
|
}
|
|
51
|
-
export declare function canaryHome(homeDir?: string): string;
|
|
52
51
|
export declare function registryPath(homeDir?: string): string;
|
|
53
52
|
export declare function overlaysDir(homeDir?: string): string;
|
|
54
53
|
export declare function clonePath(name: string, homeDir?: string): string;
|
|
@@ -34,7 +34,6 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.RegistryError = exports.SCHEMA_VERSION = void 0;
|
|
37
|
-
exports.canaryHome = canaryHome;
|
|
38
37
|
exports.registryPath = registryPath;
|
|
39
38
|
exports.overlaysDir = overlaysDir;
|
|
40
39
|
exports.clonePath = clonePath;
|