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.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. 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
- playwright: /\bexpect\s*\(|\btoBeVisible\b|\btoHaveText\b|\btoHaveTitle\b|\btoHaveURL\b|\btoBeEnabled\b|\btoBeDisabled\b|\btoBeChecked\b|\btoHaveValue\b|\btoHaveCount\b/g,
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