canary-test-cli 7.0.0 → 7.2.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/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- package/package.json +3 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structured verdict `canary-promote-test` gates on (#477).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists now, when the issue says it is blocked
|
|
5
|
+
*
|
|
6
|
+
* #477 was parked on the emit side: `harness:test-craft` runs an 8-axis per-test
|
|
7
|
+
* LLM critique with no machine-readable output, so there was nothing to consume
|
|
8
|
+
* and building a consumer against an unspecified shape meant building it twice.
|
|
9
|
+
* That is still true of the LLM critique. It is no longer true of the gate:
|
|
10
|
+
* #605 (soundness) and #612 (vacuity) emit structured, per-test, DETERMINISTIC
|
|
11
|
+
* verdicts, and those are the ones that were ever going to be allowed to block.
|
|
12
|
+
*
|
|
13
|
+
* ## The three decisions #477 asked for
|
|
14
|
+
*
|
|
15
|
+
* **Which axes gate.** Deterministic defects gate; style reports.
|
|
16
|
+
*
|
|
17
|
+
* | Axis | Rules | Gates | Why |
|
|
18
|
+
* | ----------------- | ---------------------------- | ----- | ---------------------------------------------------------------- |
|
|
19
|
+
* | `soundness` | `SOUND-001/002/003` | yes | Pins a value no correct implementation must produce |
|
|
20
|
+
* | `assertions` | `LINT-006` | yes | A test that asserts nothing always passes |
|
|
21
|
+
* | `flakiness` | `FLAKE-001/002` | yes | The issue's own named blocker; both are `critical` |
|
|
22
|
+
* | `vacuity` | `VAC-001/003`, annotated 002 | yes | Deterministic, or the author declared the target themselves |
|
|
23
|
+
* | `selectors` | `LINT-001/002/003` | no | Brittle, not wrong; a reviewer's call |
|
|
24
|
+
* | `maintainability` | `LINT-005`, `FLAKE-003/004` | no | Style and softer signals |
|
|
25
|
+
*
|
|
26
|
+
* Gating on all of them would block nearly every promotion, which is exactly the
|
|
27
|
+
* outcome #477 predicted for a naive 8-axis gate.
|
|
28
|
+
*
|
|
29
|
+
* **What happens with no verdict.** `abstain`, exit 3, and say so. Promotion
|
|
30
|
+
* falls back to today's manual review. It must never become silently stricter
|
|
31
|
+
* (a `block` nobody can act on) or silently looser (a `promote` over a file the
|
|
32
|
+
* scanner could not read) -- and two distinct zeros are guarded: a file no
|
|
33
|
+
* ruleset parses, and a parseable file holding no tests.
|
|
34
|
+
*
|
|
35
|
+
* **Whether an LLM judgement may block.** No. Everything that gates in this repo
|
|
36
|
+
* is deterministic, and this change does not spend that. The decision is
|
|
37
|
+
* structural rather than documentary: {@link VerdictSource} admits one value, so
|
|
38
|
+
* there is no field an LLM verdict can arrive in and quietly acquire authority.
|
|
39
|
+
* `harness:test-craft` stays what `canary-promote-test` already calls it -- an
|
|
40
|
+
* optional deeper audit for a human.
|
|
41
|
+
*
|
|
42
|
+
* ## The fidelity ladder
|
|
43
|
+
*
|
|
44
|
+
* `VAC-002` is inference, and #477's real anxiety was making a heuristic
|
|
45
|
+
* load-bearing on a promotion gate. So the rung decides the authority, mirroring
|
|
46
|
+
* the guardian's `coverage-verified > graph-verified > heuristic`:
|
|
47
|
+
* `annotated` (the author named the target) blocks; `import-inferred` reports.
|
|
48
|
+
*/
|
|
49
|
+
import { EXIT_ABSTAINED, errnoCode, gateOutcome, } from './gate-result.js';
|
|
50
|
+
import { StaticLinter, UnsupportedTestFileError, frameworkForPath, } from './static-linter.js';
|
|
51
|
+
import { scanVacuity, } from './vacuity-scanner.js';
|
|
52
|
+
/**
|
|
53
|
+
* Which rules land on which axis, and whether that axis gates.
|
|
54
|
+
*
|
|
55
|
+
* A rule matching NO row here is invisible to the verdict: not gating, not
|
|
56
|
+
* advisory, not printed. `LINT-004` -- an unawaited Playwright action, the
|
|
57
|
+
* linter's other `critical` and the canonical false-green defect -- was omitted
|
|
58
|
+
* from the first cut and walked straight through the gate built to stop it.
|
|
59
|
+
* `test-signal-review-findings.test.ts` now asserts that every rule a real lint
|
|
60
|
+
* produces lands somewhere, because the omission is otherwise silent.
|
|
61
|
+
*/
|
|
62
|
+
const AXES = [
|
|
63
|
+
{ axis: 'soundness', gating: true, rules: /^SOUND-/ },
|
|
64
|
+
{ axis: 'assertions', gating: true, rules: /^LINT-006$/ },
|
|
65
|
+
// LINT-004 sits here rather than in its own axis because an unawaited action
|
|
66
|
+
// IS the classic race: the assertion runs before the action lands.
|
|
67
|
+
{ axis: 'flakiness', gating: true, rules: /^FLAKE-00[12]$|^LINT-004$/ },
|
|
68
|
+
{ axis: 'vacuity', gating: true, rules: /^VAC-/ },
|
|
69
|
+
{ axis: 'selectors', gating: false, rules: /^LINT-00[123]$/ },
|
|
70
|
+
{
|
|
71
|
+
axis: 'maintainability',
|
|
72
|
+
gating: false,
|
|
73
|
+
rules: /^LINT-005$|^FLAKE-00[34]$/,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* Whether a finding on a gating axis may actually block.
|
|
78
|
+
*
|
|
79
|
+
* The one place the fidelity ladder is spent. `VAC-002` at `import-inferred` is
|
|
80
|
+
* an inference about which symbol a test was meant to exercise; blocking a
|
|
81
|
+
* promotion on that would make a heuristic load-bearing, which is the specific
|
|
82
|
+
* thing #477 asked not to do by accident. At `annotated` fidelity the author
|
|
83
|
+
* wrote the target down, so the finding is a contradiction of a stated contract
|
|
84
|
+
* and blocks.
|
|
85
|
+
*/
|
|
86
|
+
function mayBlock(f) {
|
|
87
|
+
if (f.rule !== 'VAC-002')
|
|
88
|
+
return true;
|
|
89
|
+
return f.fidelity === 'annotated';
|
|
90
|
+
}
|
|
91
|
+
function normalizeLint(f) {
|
|
92
|
+
return {
|
|
93
|
+
rule: f.rule,
|
|
94
|
+
line: f.line,
|
|
95
|
+
severity: f.severity,
|
|
96
|
+
message: f.message,
|
|
97
|
+
suggestion: f.suggestion,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function normalizeVacuity(f) {
|
|
101
|
+
const out = {
|
|
102
|
+
rule: f.rule,
|
|
103
|
+
line: f.line,
|
|
104
|
+
severity: f.severity,
|
|
105
|
+
message: `${f.test}: ${f.message}`,
|
|
106
|
+
suggestion: f.suggestion,
|
|
107
|
+
};
|
|
108
|
+
if (f.fidelity)
|
|
109
|
+
out.fidelity = f.fidelity;
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
const ABSTAIN_REMEDY = 'No verdict could be produced, so promotion falls back to manual review ' +
|
|
113
|
+
'(canary-promote-test Phase 1) -- it has NOT been approved. ' +
|
|
114
|
+
'Point at a single generated test file that contains at least one test.';
|
|
115
|
+
function abstained(file, skipped, axes) {
|
|
116
|
+
const outcome = gateOutcome({ checked: 0, findings: [], skipped }, 'gate');
|
|
117
|
+
return {
|
|
118
|
+
file,
|
|
119
|
+
decision: 'abstain',
|
|
120
|
+
source: 'deterministic',
|
|
121
|
+
checked: 0,
|
|
122
|
+
axes,
|
|
123
|
+
blocked: [],
|
|
124
|
+
skipped,
|
|
125
|
+
summaryLine: outcome.summaryLine,
|
|
126
|
+
remedy: ABSTAIN_REMEDY,
|
|
127
|
+
exitCode: EXIT_ABSTAINED,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function emptyAxes() {
|
|
131
|
+
return AXES.map((a) => ({ axis: a.axis, gating: a.gating, findings: [] }));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Produce the promotion verdict for one generated test file.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately single-file: promotion is a per-file decision, and a directory
|
|
137
|
+
* roll-up would let one clean file's verdict read as cover for a sibling's.
|
|
138
|
+
*/
|
|
139
|
+
export function promotionVerdict(path) {
|
|
140
|
+
if (frameworkForPath(path) === null) {
|
|
141
|
+
return abstained(path, [
|
|
142
|
+
{
|
|
143
|
+
name: path,
|
|
144
|
+
reason: 'no ruleset parses this extension, so a clean result would be meaningless',
|
|
145
|
+
},
|
|
146
|
+
], emptyAxes());
|
|
147
|
+
}
|
|
148
|
+
const lint = lintOrAbstain(path);
|
|
149
|
+
if (!Array.isArray(lint))
|
|
150
|
+
return lint;
|
|
151
|
+
const vacuity = scanVacuity(path);
|
|
152
|
+
const axes = groupIntoAxes([
|
|
153
|
+
...lint.map(normalizeLint),
|
|
154
|
+
...vacuity.findings.map(normalizeVacuity),
|
|
155
|
+
]);
|
|
156
|
+
const skipped = vacuity.skipped ?? [];
|
|
157
|
+
// A parseable file with no tests is another zero, and it must not read as a
|
|
158
|
+
// pass: promotion would let an empty file into the committed suite. Note that
|
|
159
|
+
// `lint` can still be non-empty here (a stray `Date.now()` outside any test),
|
|
160
|
+
// so the check is on the TEST count, not on the finding count.
|
|
161
|
+
if (vacuity.checked === 0) {
|
|
162
|
+
return abstained(path, [
|
|
163
|
+
...skipped,
|
|
164
|
+
{
|
|
165
|
+
name: path,
|
|
166
|
+
reason: 'file holds no test declarations, so there is nothing to promote',
|
|
167
|
+
},
|
|
168
|
+
], axes);
|
|
169
|
+
}
|
|
170
|
+
return decide(path, axes, skipped, vacuity.checked);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The lint findings, or a ready-made abstention when the linter refused.
|
|
174
|
+
*
|
|
175
|
+
* Two distinct refusals, and BOTH have to become an abstention rather than an
|
|
176
|
+
* exception. Letting a read error propagate meant the CLI printed a raw ENOENT
|
|
177
|
+
* stack and exited 0 -- a promotion gate that could not open the draft,
|
|
178
|
+
* reporting success. Anything without an `errno`-style `code` still throws,
|
|
179
|
+
* because swallowing an unknown fault is how a scanner learns to go quiet.
|
|
180
|
+
*/
|
|
181
|
+
function lintOrAbstain(path) {
|
|
182
|
+
try {
|
|
183
|
+
return new StaticLinter().lint(path);
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
if (e instanceof UnsupportedTestFileError) {
|
|
187
|
+
return abstained(path, [{ name: path, reason: e.message }], emptyAxes());
|
|
188
|
+
}
|
|
189
|
+
// ERRNO-shaped only. Keying off "has a string `code`" swept in every Node
|
|
190
|
+
// PROGRAMMER error too -- `ERR_INVALID_ARG_TYPE`, `ERR_STRING_TOO_LONG` --
|
|
191
|
+
// so a genuine defect inside the linter was reported as a clean ABSTAIN with
|
|
192
|
+
// a misleading reason instead of surfacing. An unknown fault must still
|
|
193
|
+
// throw; that is the difference between degrading honestly and going quiet.
|
|
194
|
+
const code = errnoCode(e);
|
|
195
|
+
if (code === null)
|
|
196
|
+
throw e;
|
|
197
|
+
return abstained(path, [{ name: path, reason: `could not be read (${code})` }], emptyAxes());
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/** Every axis, in registry order, each carrying its findings sorted by line. */
|
|
201
|
+
function groupIntoAxes(all) {
|
|
202
|
+
return AXES.map((spec) => ({
|
|
203
|
+
axis: spec.axis,
|
|
204
|
+
gating: spec.gating,
|
|
205
|
+
findings: all
|
|
206
|
+
.filter((f) => spec.rules.test(f.rule))
|
|
207
|
+
.sort((a, b) => a.line - b.line),
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
/** Turn evaluated axes into the promote/block decision and its copy. */
|
|
211
|
+
function decide(path, axes, skipped, checked) {
|
|
212
|
+
const blocked = [
|
|
213
|
+
...new Set(axes
|
|
214
|
+
.filter((a) => a.gating)
|
|
215
|
+
.flatMap((a) => a.findings)
|
|
216
|
+
.filter(mayBlock)
|
|
217
|
+
.map((f) => f.rule)),
|
|
218
|
+
];
|
|
219
|
+
// `gateOutcome` owns the summary line so promotion reports its denominator in
|
|
220
|
+
// the same shape as every other gate, rather than inventing a private format.
|
|
221
|
+
const outcome = gateOutcome({ checked, findings: blocked, skipped }, 'gate', {
|
|
222
|
+
noun: 'test(s)',
|
|
223
|
+
});
|
|
224
|
+
const decision = blocked.length > 0 ? 'block' : 'promote';
|
|
225
|
+
const advisoryCount = axes
|
|
226
|
+
.filter((a) => !a.gating)
|
|
227
|
+
.reduce((n, a) => n + a.findings.length, 0);
|
|
228
|
+
return {
|
|
229
|
+
file: path,
|
|
230
|
+
decision,
|
|
231
|
+
source: 'deterministic',
|
|
232
|
+
checked,
|
|
233
|
+
axes,
|
|
234
|
+
blocked,
|
|
235
|
+
skipped,
|
|
236
|
+
summaryLine: outcome.summaryLine,
|
|
237
|
+
remedy: remedyFor(decision, blocked, advisoryCount, skipped.length),
|
|
238
|
+
exitCode: decision === 'block' ? 1 : 0,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function remedyFor(decision, blocked, advisoryCount, skipCount) {
|
|
242
|
+
if (decision === 'block') {
|
|
243
|
+
return (`Blocked on ${blocked.join(', ')}. Fix the test or regenerate it ` +
|
|
244
|
+
'with a sharper prompt -- do not hand-patch a generated draft ' +
|
|
245
|
+
'(canary-promote-test Phase 1).');
|
|
246
|
+
}
|
|
247
|
+
const parts = ['Promotable.'];
|
|
248
|
+
if (advisoryCount > 0) {
|
|
249
|
+
parts.push(`${advisoryCount} advisory finding(s) are a reviewer's call, not a blocker.`);
|
|
250
|
+
}
|
|
251
|
+
// A `promote` whose rules went dark must not read as an unqualified pass.
|
|
252
|
+
// `checked` legitimately counts the tests VAC-001 did run on, so the
|
|
253
|
+
// denominator is right -- but a reader seeing only "promotable" would never
|
|
254
|
+
// learn that two of the three vacuity rules could not be evaluated at all.
|
|
255
|
+
if (skipCount > 0) {
|
|
256
|
+
parts.push(`${skipCount} check(s) could not run on this file -- see the skip list; ` +
|
|
257
|
+
'those rules did NOT pass, they abstained.');
|
|
258
|
+
}
|
|
259
|
+
return advisoryCount > 0 || skipCount > 0 ? parts.join(' ') : '';
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=promotion-verdict.js.map
|
|
@@ -19,10 +19,23 @@ const ASSERTIONS = {
|
|
|
19
19
|
// nothing. (`\bassert\b` alone does NOT match `assert_valid`: `_` is a word
|
|
20
20
|
// char, so the `\b` after `assert` fails there.)
|
|
21
21
|
pytest: /\bassert\b|\bpytest\.raises\b|\bself\.assert\w+\b|\bassert\w*\s*\(/g,
|
|
22
|
-
|
|
22
|
+
// `\bexpect\w*\s*\(` is the JS/TS half of that same rationale (#738). The
|
|
23
|
+
// naming convention for a custom assertion helper is `expect*` here, not
|
|
24
|
+
// `assert*`, and it is the pattern Playwright's own docs recommend: a suite
|
|
25
|
+
// that routes its checks through `expectRouteTestId(page, id)` so the
|
|
26
|
+
// assertion semantics live in one documented place had EVERY added test
|
|
27
|
+
// flagged `added test asserts nothing`. `\w*` is zero-width-matchable, so
|
|
28
|
+
// plain `expect(` still matches and the change is purely additive.
|
|
29
|
+
//
|
|
30
|
+
// The precision cost is the mirror image of the accepted pytest one: a
|
|
31
|
+
// non-asserting function whose name happens to start with `expect` now
|
|
32
|
+
// counts. That is the right side to err on for an advisory finding that
|
|
33
|
+
// never gates — a false "asserts nothing" on a correct test is what teaches
|
|
34
|
+
// a reader to ignore the finding.
|
|
35
|
+
playwright: /\bexpect\w*\s*\(|\btoBeVisible\b|\btoHaveText\b|\btoHaveTitle\b|\btoHaveURL\b|\btoBeEnabled\b|\btoBeDisabled\b|\btoBeChecked\b|\btoHaveValue\b|\btoHaveCount\b/g,
|
|
23
36
|
// Plus non-`expect` assertion styles common in JS/TS: node:assert / vitest
|
|
24
37
|
// `assert(...)` / `assert.equal(...)`, and chai BDD `x.should.equal`.
|
|
25
|
-
vitest: /\bexpect\s*\(|\btoBe\s*\(|\btoEqual\s*\(|\btoThrow\b|\btoContain\s*\(|\btoBeNull\b|\btoBeUndefined\b|\btoMatchObject\b|\bassert\s*\(|\bassert\.\w+|\.should\b/g,
|
|
38
|
+
vitest: /\bexpect\w*\s*\(|\btoBe\s*\(|\btoEqual\s*\(|\btoThrow\b|\btoContain\s*\(|\btoBeNull\b|\btoBeUndefined\b|\btoMatchObject\b|\bassert\s*\(|\bassert\.\w+|\.should\b/g,
|
|
26
39
|
k6: /\bcheck\s*\(|'[^']+'\s*:\s*\([^)]*\)\s*=>/g,
|
|
27
40
|
};
|
|
28
41
|
const NEGATIVE_KW = /\b(error|invalid|empty|null|undefined|throws|raises|exception|fail|missing|negative|reject|4\d{2}|5\d{2}|boundary|edge)\b/i;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { dirname } from 'node:path';
|
|
28
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
28
29
|
const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
|
|
29
30
|
const TOOL_NAME = 'Canary';
|
|
30
31
|
const TOOL_VERSION = '0.1.0';
|
|
@@ -70,15 +71,6 @@ function pyOr(value, fallback) {
|
|
|
70
71
|
function pyGet(obj, key, fallback) {
|
|
71
72
|
return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
|
|
72
73
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
|
|
75
|
-
* on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`. Only
|
|
76
|
-
* touches the >= 0x80 range, so the ASCII escapes `JSON.stringify` already
|
|
77
|
-
* produced are left intact. (Same helper as `guardian/pr-check.ts`.)
|
|
78
|
-
*/
|
|
79
|
-
function ensureAscii(json) {
|
|
80
|
-
return json.replace(/[-]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
81
|
-
}
|
|
82
74
|
/**
|
|
83
75
|
* `json.dumps(default=str)` replacer. Values the encoder can't natively handle
|
|
84
76
|
* are coerced via `str()`; in JS the only common such value that would
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier 2 of `canary skills run`: the dispatcher (#756).
|
|
3
|
+
*
|
|
4
|
+
* Canary shipped tier 1 (a skill declaring `cli:`/`entry:` is spawned) and
|
|
5
|
+
* tier 3 (a prose skill is unreachable), and nothing between. 14 of canary's 21
|
|
6
|
+
* skills carry no `cli:`, so no orchestrator, CI step, or sibling skill could
|
|
7
|
+
* invoke them at all -- a have/have-not split that costs far more here than the
|
|
8
|
+
* same split costs harness, where the dispatcher runs the CLI-less majority.
|
|
9
|
+
*
|
|
10
|
+
* ## What "running a prose skill" means here, honestly
|
|
11
|
+
*
|
|
12
|
+
* Canary is a CLI. It has no agent runtime, and it is not going to grow one to
|
|
13
|
+
* close this gap. So the dispatcher does the one thing a CLI can do faithfully:
|
|
14
|
+
* it RESOLVES the skill and hands back its executable contract -- identity,
|
|
15
|
+
* declared runtime requirements, and the workflow text an agent is to apply --
|
|
16
|
+
* with the tier and the determinism stated on the payload. The caller gets a
|
|
17
|
+
* resolved, machine-readable handle to a real skill instead of exit 2.
|
|
18
|
+
*
|
|
19
|
+
* What it deliberately does NOT do is apply the workflow and present the result
|
|
20
|
+
* as canary's. That would be canary claiming an answer it did not compute.
|
|
21
|
+
*
|
|
22
|
+
* ## Determinism labelling (issue design question 2)
|
|
23
|
+
*
|
|
24
|
+
* Every dispatch is stamped `determinism: 'agent-applied'`, against
|
|
25
|
+
* `'deterministic'` for a `cli:` skill. A consumer merging findings across
|
|
26
|
+
* skills must be able to tell a scanner's output from an agent's reading of a
|
|
27
|
+
* ruleset; without the label the two look interchangeable, which is exactly the
|
|
28
|
+
* confusion #755 documents about cassandra.
|
|
29
|
+
*
|
|
30
|
+
* ## Why no `--allow-executable-skills` equivalent (design question 3)
|
|
31
|
+
*
|
|
32
|
+
* That flag exists because a freshly cloned overlay can carry a `cli:` script,
|
|
33
|
+
* and invoking it runs someone else's code on the next CI run. Dispatch runs
|
|
34
|
+
* nothing: it reads a markdown file the registry already read at discovery and
|
|
35
|
+
* prints it. There is no new execution to gate, so gating it would be
|
|
36
|
+
* ceremony -- and ceremony that would keep the 14 skills unreachable in exactly
|
|
37
|
+
* the non-interactive contexts the issue is about. The trust boundary moves to
|
|
38
|
+
* whatever the caller does with the returned text, which is the caller's gate
|
|
39
|
+
* to own, and the payload labels itself so the caller can see what it holds.
|
|
40
|
+
*
|
|
41
|
+
* ## Failure mode (design question 4)
|
|
42
|
+
*
|
|
43
|
+
* A skill that could not be dispatched raises {@link SkillDispatchError}. An
|
|
44
|
+
* unreadable or bodyless SKILL.md is a failure, never an empty success -- a
|
|
45
|
+
* dispatcher that returned "nothing to do" for a skill it could not read would
|
|
46
|
+
* be indistinguishable from one that ran and found nothing.
|
|
47
|
+
*/
|
|
48
|
+
import { readFileSync } from 'node:fs';
|
|
49
|
+
import { errnoCode } from './gate-result.js';
|
|
50
|
+
// Written as an escape so this source stays ASCII, matching gate-result.ts.
|
|
51
|
+
const EMDASH = '\u{2014}';
|
|
52
|
+
/** A dispatch that could not be completed. Never degrades to an empty result. */
|
|
53
|
+
export class SkillDispatchError extends Error {
|
|
54
|
+
skill;
|
|
55
|
+
constructor(skill, message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'SkillDispatchError';
|
|
58
|
+
this.skill = skill;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Strip a leading `---` frontmatter block, leaving the workflow prose.
|
|
63
|
+
*
|
|
64
|
+
* Mirrors the delimiter handling in `SkillRegistry.parseFrontmatter`: an
|
|
65
|
+
* unterminated block means the whole file was frontmatter, and there is no
|
|
66
|
+
* body to hand back.
|
|
67
|
+
*/
|
|
68
|
+
export function skillBody(text) {
|
|
69
|
+
if (!text.startsWith('---'))
|
|
70
|
+
return text.trim();
|
|
71
|
+
const rest = text.split('\n').slice(1);
|
|
72
|
+
const end = rest.findIndex((l) => l.trim() === '---');
|
|
73
|
+
return end === -1
|
|
74
|
+
? ''
|
|
75
|
+
: rest
|
|
76
|
+
.slice(end + 1)
|
|
77
|
+
.join('\n')
|
|
78
|
+
.trim();
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a prose skill into its dispatch payload.
|
|
82
|
+
*
|
|
83
|
+
* @throws {SkillDispatchError} when SKILL.md cannot be read, or holds no body.
|
|
84
|
+
*/
|
|
85
|
+
export function dispatchProseSkill(skill, args) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readFileSync(skill.path, 'utf-8');
|
|
89
|
+
}
|
|
90
|
+
catch (exc) {
|
|
91
|
+
const code = errnoCode(exc);
|
|
92
|
+
if (code === null)
|
|
93
|
+
throw exc;
|
|
94
|
+
throw new SkillDispatchError(skill.name, `cannot read ${skill.path} (${code}) ${EMDASH} the skill was ` +
|
|
95
|
+
'discovered but its workflow could not be loaded.');
|
|
96
|
+
}
|
|
97
|
+
const instructions = skillBody(text);
|
|
98
|
+
if (!instructions) {
|
|
99
|
+
throw new SkillDispatchError(skill.name, `${skill.path} carries frontmatter but no workflow body ${EMDASH} ` +
|
|
100
|
+
'there is nothing to dispatch. Reporting this as an empty run would ' +
|
|
101
|
+
'be indistinguishable from a skill that ran and found nothing.');
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
skill: skill.name,
|
|
105
|
+
path: skill.path,
|
|
106
|
+
tier: 'dispatcher',
|
|
107
|
+
determinism: 'agent-applied',
|
|
108
|
+
requires_agent_runtime: true,
|
|
109
|
+
requires: skill.requires,
|
|
110
|
+
description: skill.description,
|
|
111
|
+
instructions,
|
|
112
|
+
args,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=skill-dispatch.js.map
|