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,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canary-katana -- quarantine deleted and newly-skipped tests, with provenance.
|
|
3
|
+
//
|
|
4
|
+
// Captures every removed or skipped test into an append-only ledger (who
|
|
5
|
+
// deleted it, when, in which commit, and why), and alarms in exactly one case:
|
|
6
|
+
// the removed test was the last coverage of a symbol `critical-areas.json`
|
|
7
|
+
// marks high-risk.
|
|
8
|
+
//
|
|
9
|
+
// Advisory by default (always exit 0). `--strict` exits 1 only on a real alarm;
|
|
10
|
+
// a degraded run (no critical-area data) stays exit 0 even under `--strict` --
|
|
11
|
+
// a gate that fails on missing data gets muted, and a muted gate is worse than
|
|
12
|
+
// none.
|
|
13
|
+
//
|
|
14
|
+
// Invoked via `canary skills run canary-katana -- [options]`.
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createParser,
|
|
21
|
+
formatUsageError,
|
|
22
|
+
EXIT_USAGE,
|
|
23
|
+
} from '../../../lib/parse-args.mjs';
|
|
24
|
+
import * as diffscan from './diffscan.mjs';
|
|
25
|
+
import * as alarm from './alarm.mjs';
|
|
26
|
+
import * as ledger from './ledger.mjs';
|
|
27
|
+
|
|
28
|
+
// --- no-silent-abstention (#508 D2, skill-CLI convention half) ---------------
|
|
29
|
+
//
|
|
30
|
+
// Skill CLIs are deliberately self-contained -- no engine import -- so they
|
|
31
|
+
// cannot call `gateOutcome`. They honour the doctrine by CONVENTION, emitting
|
|
32
|
+
// the same greppable line the engine helper does; the skill-layer conformance
|
|
33
|
+
// registry (agents/skills/test/gate-conformance.test.ts) holds them to it.
|
|
34
|
+
//
|
|
35
|
+
// U+26A0 / U+2014 as escapes so this source stays ASCII, matching
|
|
36
|
+
// ts/src/core/gate-result.ts.
|
|
37
|
+
const ABSTAINED_LINE =
|
|
38
|
+
'\u{26A0} Abstained \u{2014} verified zero items; this is not a pass.';
|
|
39
|
+
|
|
40
|
+
const PREFIX = 'canary-katana:';
|
|
41
|
+
|
|
42
|
+
const USAGE =
|
|
43
|
+
'usage: canary-katana [-h] [--repo PATH] [--diff-file PATH] [--ledger PATH]\n' +
|
|
44
|
+
' [--critical-areas PATH] [--json] [--strict] [--no-write]\n' +
|
|
45
|
+
'\n' +
|
|
46
|
+
'Quarantine deleted and newly-skipped tests into an append-only ledger with\n' +
|
|
47
|
+
'provenance, and alarm when a removal drops the last coverage of a critical\n' +
|
|
48
|
+
'area.\n' +
|
|
49
|
+
'\n' +
|
|
50
|
+
'options:\n' +
|
|
51
|
+
' -h, --help show this help message and exit\n' +
|
|
52
|
+
' --repo PATH repository to inspect (default: .)\n' +
|
|
53
|
+
' --diff-file PATH read the diff from a file instead of git\n' +
|
|
54
|
+
' --ledger PATH ledger location (default: <repo>/.canary/quarantine.json)\n' +
|
|
55
|
+
' --critical-areas PATH critical-areas.json used to raise alarms\n' +
|
|
56
|
+
' --json emit machine-readable output instead of human text\n' +
|
|
57
|
+
' --strict exit 1 on a real alarm (degraded runs stay 0)\n' +
|
|
58
|
+
' --no-write do not append to the ledger (read-only run)';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* katana takes no positionals, so any leftover token -- dashed or not -- is a
|
|
62
|
+
* usage error rather than something silently ignored, and `--` has nothing to
|
|
63
|
+
* protect. The four shared invariants (null-prototype lookup, empty-value
|
|
64
|
+
* rejection, arity, `--flag=value`) live in the shared parser; see #479 for why
|
|
65
|
+
* they stopped living here.
|
|
66
|
+
*/
|
|
67
|
+
export const CLI_SPEC = {
|
|
68
|
+
prog: 'canary-katana',
|
|
69
|
+
booleans: {
|
|
70
|
+
'--json': 'json',
|
|
71
|
+
'--strict': 'strict',
|
|
72
|
+
'--no-write': 'noWrite',
|
|
73
|
+
},
|
|
74
|
+
values: {
|
|
75
|
+
'--repo': { key: 'repo' },
|
|
76
|
+
'--diff-file': { key: 'diffFile' },
|
|
77
|
+
'--ledger': { key: 'ledger' },
|
|
78
|
+
'--critical-areas': { key: 'criticalAreas' },
|
|
79
|
+
},
|
|
80
|
+
defaults: { repo: '.' },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const parseArgs = createParser(CLI_SPEC);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Return { text, base }. `base` is a git ref when one is resolvable. With
|
|
87
|
+
* diffFile the diff is read verbatim and git is still consulted (best-effort)
|
|
88
|
+
* for provenance; without it the diff is computed from the repo's own history.
|
|
89
|
+
*/
|
|
90
|
+
function loadDiff(repo, diffFile) {
|
|
91
|
+
if (diffFile) {
|
|
92
|
+
if (!fs.existsSync(diffFile)) {
|
|
93
|
+
const err = new Error(`diff file not found: ${diffFile}`);
|
|
94
|
+
err.notFound = true;
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
const text = fs.readFileSync(diffFile, 'utf8');
|
|
98
|
+
let base = null;
|
|
99
|
+
try {
|
|
100
|
+
base = diffscan.resolveBase(repo, null);
|
|
101
|
+
} catch {
|
|
102
|
+
base = null; // non-git repo: provenance stays unknown
|
|
103
|
+
}
|
|
104
|
+
return { text, base };
|
|
105
|
+
}
|
|
106
|
+
const base = diffscan.resolveBase(repo, null);
|
|
107
|
+
return { text: diffscan.diffText(repo, base), base };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function provenance(repo, base, file) {
|
|
111
|
+
if (base === null) return null;
|
|
112
|
+
try {
|
|
113
|
+
return diffscan.commitForFile(repo, base, file);
|
|
114
|
+
} catch {
|
|
115
|
+
return null; // missing history is unknown, not fatal
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toEntries(repo, base, deletions) {
|
|
120
|
+
return deletions.map((d) => {
|
|
121
|
+
const commit = provenance(repo, base, d.file);
|
|
122
|
+
return ledger.LedgerEntry({
|
|
123
|
+
test: d.name,
|
|
124
|
+
file: d.file,
|
|
125
|
+
kind: d.kind,
|
|
126
|
+
marker: d.marker,
|
|
127
|
+
commit: commit ? commit.sha : '',
|
|
128
|
+
author: commit ? commit.author : 'unknown',
|
|
129
|
+
date: commit ? commit.date : '',
|
|
130
|
+
reason: commit ? commit.subject : '',
|
|
131
|
+
// The `Ticket:` trailer is one way an issue link arrives; a quarantine
|
|
132
|
+
// producer writing a caused row is the other. Both land in `issue`.
|
|
133
|
+
issue: commit ? commit.ticket : '',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderText(deletions, findings, degraded, scanned) {
|
|
139
|
+
// #508: katana's denominator is the DIFF it read, not the deletions it found.
|
|
140
|
+
// Zero deletions in a 500-line diff is a real result; zero deletions in an
|
|
141
|
+
// EMPTY diff means nothing was examined at all. `0 deletion(s) captured` reads
|
|
142
|
+
// identically in both cases, which is precisely the shape the doctrine bans.
|
|
143
|
+
if (!scanned) {
|
|
144
|
+
return (
|
|
145
|
+
`${ABSTAINED_LINE} The diff was empty, so no deleted test could be ` +
|
|
146
|
+
'captured. Check --repo/--diff-file, or that the range actually ' +
|
|
147
|
+
'contains changes.'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const lines = [`${deletions.length} deletion(s) captured.`];
|
|
151
|
+
if (degraded) lines.push(alarm.DEGRADED_NOTICE);
|
|
152
|
+
for (const f of findings) {
|
|
153
|
+
lines.push(
|
|
154
|
+
` [${f.severity.value}] ${f.file}::${f.test} removed the last coverage of ${f.area}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function main(argv = []) {
|
|
161
|
+
const { opts: args, help, error } = parseArgs(argv);
|
|
162
|
+
|
|
163
|
+
// FIRST, before loadDiff and before any ledger write: a usage request or a
|
|
164
|
+
// typo must never mutate the working tree.
|
|
165
|
+
if (help) {
|
|
166
|
+
console.log(USAGE);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (error) {
|
|
170
|
+
console.error(formatUsageError(CLI_SPEC.prog, error));
|
|
171
|
+
return EXIT_USAGE;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const repo = args.repo;
|
|
175
|
+
// `!= null`, not a truthiness test: "--ledger was not given" and "--ledger
|
|
176
|
+
// was given an empty path" are different situations, and only the first one
|
|
177
|
+
// may fall back to the default. (An empty value is rejected at parse time,
|
|
178
|
+
// so this branch is now unreachable with '' -- the explicit null check keeps
|
|
179
|
+
// it that way if the parser ever loosens.)
|
|
180
|
+
const ledgerPath =
|
|
181
|
+
args.ledger != null
|
|
182
|
+
? args.ledger
|
|
183
|
+
: path.join(repo, '.canary', 'quarantine.json');
|
|
184
|
+
|
|
185
|
+
let diff;
|
|
186
|
+
let base;
|
|
187
|
+
try {
|
|
188
|
+
({ text: diff, base } = loadDiff(repo, args.diffFile));
|
|
189
|
+
} catch (exc) {
|
|
190
|
+
if (exc.notFound) {
|
|
191
|
+
console.error(`${PREFIX} ${exc.message}`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
console.error(`${PREFIX} could not read diff: ${exc.message}`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Non-blank diff text is the denominator probe: `loadDiff` succeeding does
|
|
199
|
+
// not mean it returned anything to scan.
|
|
200
|
+
const scanned = diff.trim().length > 0;
|
|
201
|
+
const deletions = diffscan.findDeletions(diff);
|
|
202
|
+
const entries = toEntries(repo, base, deletions);
|
|
203
|
+
|
|
204
|
+
if (!args.noWrite) {
|
|
205
|
+
try {
|
|
206
|
+
ledger.appendEntries(ledgerPath, entries);
|
|
207
|
+
} catch (exc) {
|
|
208
|
+
console.error(`${PREFIX} ${exc.message}`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const areas = alarm.loadCriticalAreas(args.criticalAreas);
|
|
214
|
+
const degraded = !areas.available;
|
|
215
|
+
const findings = alarm.buildFindings(deletions, areas, repo);
|
|
216
|
+
|
|
217
|
+
if (args.json) {
|
|
218
|
+
const payload = {
|
|
219
|
+
schema_version: ledger.SCHEMA_VERSION,
|
|
220
|
+
captured: deletions.map(diffscan.deletionToDict),
|
|
221
|
+
findings: findings.map(alarm.findingToDict),
|
|
222
|
+
ledger: String(ledgerPath),
|
|
223
|
+
};
|
|
224
|
+
if (degraded) payload.degraded_notice = alarm.DEGRADED_NOTICE;
|
|
225
|
+
// Additive (#508): a consumer can distinguish "no deletions" from "nothing
|
|
226
|
+
// examined" without parsing prose.
|
|
227
|
+
payload.checked = scanned ? 1 : 0;
|
|
228
|
+
payload.abstained = !scanned;
|
|
229
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
230
|
+
} else {
|
|
231
|
+
console.log(renderText(deletions, findings, degraded, scanned));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Advisory by default (D3); --strict inherits EXIT_ABSTAINED (3) on an empty
|
|
235
|
+
// diff, distinct from 1 ("captured a real deletion").
|
|
236
|
+
if (args.strict && !scanned) return 3;
|
|
237
|
+
return args.strict && findings.length ? 1 : 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Direct execution (the skill runner execs this file via its shebang).
|
|
241
|
+
//
|
|
242
|
+
// `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
|
|
243
|
+
// the pipe buffer, and `process.exit` tears the process down mid-write, leaving
|
|
244
|
+
// truncated JSON that still exits 0 (#791).
|
|
245
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
246
|
+
process.exitCode = main(process.argv.slice(2));
|
|
247
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ledger -- the quarantine record for tests that are out of the suite.
|
|
2
|
+
//
|
|
3
|
+
// Every captured deletion is written with its provenance (who, when, what
|
|
4
|
+
// commit, why) so a test that vanishes leaves a trail instead of a silent gap.
|
|
5
|
+
// The ledger is de-duplicated: re-running the capture on the same change adds
|
|
6
|
+
// nothing, and a batch of new entries is sorted for a stable on-disk order.
|
|
7
|
+
//
|
|
8
|
+
// SCHEMA v2 (#771) adds the WHY a row is out, alongside the provenance of how it
|
|
9
|
+
// left: `cause`, `issue`, `expiry`. Two fields deserve their separation --
|
|
10
|
+
// `reason` is DERIVED (the commit subject, "what change did this"), `cause` is
|
|
11
|
+
// ASSERTED (a judgement someone made, "why is it out"). Collapsing them would
|
|
12
|
+
// dress an auto-derived string up as a claim a person stands behind.
|
|
13
|
+
//
|
|
14
|
+
// v2 also makes the ledger no longer purely append-only, for one narrow case
|
|
15
|
+
// documented at `appendEntries`: a row that states a cause SUPERSEDES a causeless
|
|
16
|
+
// row for the same (test, file). See the comment there for why leaving both is
|
|
17
|
+
// worse than replacing one.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
export const SCHEMA_VERSION = 2;
|
|
23
|
+
|
|
24
|
+
// The fields, in the order they define a row's identity for de-duplication.
|
|
25
|
+
const FIELDS = [
|
|
26
|
+
'test',
|
|
27
|
+
'file',
|
|
28
|
+
'kind',
|
|
29
|
+
'marker',
|
|
30
|
+
'commit',
|
|
31
|
+
'author',
|
|
32
|
+
'date',
|
|
33
|
+
'reason',
|
|
34
|
+
'cause',
|
|
35
|
+
'issue',
|
|
36
|
+
'expiry',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Why a test is out of the suite. The two middle values are the ones where the
|
|
41
|
+
* TEST IS CORRECT and someone else owns the fix, so a producer must require an
|
|
42
|
+
* `issue` for them -- an untracked "the product is broken" note decays into an
|
|
43
|
+
* unexplained skip within a release.
|
|
44
|
+
*/
|
|
45
|
+
export const CAUSES = ['flaky', 'product-defect', 'blocked-data', 'obsolete'];
|
|
46
|
+
|
|
47
|
+
/** Causes for which a row without an `issue` is not a real record. */
|
|
48
|
+
export const CAUSES_REQUIRING_ISSUE = ['product-defect', 'blocked-data'];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {{test: string, file: string, kind: string, marker: string,
|
|
52
|
+
* commit: string, author: string, date: string, reason: string,
|
|
53
|
+
* cause: string, issue: string, expiry: string}} LedgerRow
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** Normalize an entry-like object into a row with the canonical field order. */
|
|
57
|
+
export function LedgerEntry(fields) {
|
|
58
|
+
const row = {};
|
|
59
|
+
for (const f of FIELDS) row[f] = fields[f] ?? '';
|
|
60
|
+
// v1 wrote the tracker link as `ticket` (#781). It is the same fact under a
|
|
61
|
+
// different name, so it migrates onto `issue` rather than being dropped or
|
|
62
|
+
// kept alongside -- two fields answering "what is this waiting on" is how a
|
|
63
|
+
// consumer ends up reading the empty one. `Ticket:` survives as the name of
|
|
64
|
+
// the COMMIT TRAILER that populates it, which is a mechanism, not a schema.
|
|
65
|
+
if (!row.issue && typeof fields.ticket === 'string')
|
|
66
|
+
row.issue = fields.ticket;
|
|
67
|
+
return row;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The subset that defines a row's IDENTITY for de-duplication.
|
|
71
|
+
//
|
|
72
|
+
// `issue` and `expiry` are excluded, for the reason #781 excluded `ticket`:
|
|
73
|
+
// identity is what happened -- which test, in which file, muted how, by which
|
|
74
|
+
// commit and why -- and a tracker link is an attribute of that event rather
|
|
75
|
+
// than part of it. Including them would also break the append-only guarantee
|
|
76
|
+
// across this schema change, since rows written before the fields existed key
|
|
77
|
+
// as `''` and the same capture re-run with an issue present would hash
|
|
78
|
+
// differently and append a duplicate of a row already on disk.
|
|
79
|
+
//
|
|
80
|
+
// `cause` IS identity, and deliberately so: `appendEntries` decides supersede
|
|
81
|
+
// on whether a row states one, so a caused and a causeless row for the same
|
|
82
|
+
// test must remain distinguishable here for that decision to have anything to
|
|
83
|
+
// act on.
|
|
84
|
+
const IDENTITY_FIELDS = FIELDS.filter((f) => f !== 'issue' && f !== 'expiry');
|
|
85
|
+
|
|
86
|
+
const key = (row) => IDENTITY_FIELDS.map((f) => row[f] ?? '').join('\u0000');
|
|
87
|
+
|
|
88
|
+
/** The (test, file) pair a supersede decision is made on. */
|
|
89
|
+
const pair = (row) => `${row.test ?? ''}\u0000${row.file ?? ''}`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Load the ledger document, or an empty one when the file is absent.
|
|
93
|
+
* Throws on unparseable JSON or a non-object top level -- a corrupt ledger is a
|
|
94
|
+
* hard error the caller must surface, not silently overwrite.
|
|
95
|
+
*
|
|
96
|
+
* Every row is normalized through `LedgerEntry`, so a v1 file read here comes
|
|
97
|
+
* back with the v2 fields present and empty. That is what makes writing
|
|
98
|
+
* `schema_version: 2` honest: the version claims "these rows have these fields",
|
|
99
|
+
* and after normalization they do. Stamping the version over un-migrated rows
|
|
100
|
+
* would make it a promise the file does not keep.
|
|
101
|
+
* @returns {{schema_version: number, entries: LedgerRow[]}}
|
|
102
|
+
*/
|
|
103
|
+
export function load(filePath) {
|
|
104
|
+
if (!fs.existsSync(filePath)) {
|
|
105
|
+
return { schema_version: SCHEMA_VERSION, entries: [] };
|
|
106
|
+
}
|
|
107
|
+
let data;
|
|
108
|
+
try {
|
|
109
|
+
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
110
|
+
} catch (exc) {
|
|
111
|
+
throw new Error(`ledger is not valid JSON: ${filePath}: ${exc.message}`);
|
|
112
|
+
}
|
|
113
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
114
|
+
throw new Error(`ledger top level must be an object: ${filePath}`);
|
|
115
|
+
}
|
|
116
|
+
if (!('schema_version' in data)) data.schema_version = SCHEMA_VERSION;
|
|
117
|
+
if (!('entries' in data)) data.entries = [];
|
|
118
|
+
if (!Array.isArray(data.entries)) {
|
|
119
|
+
throw new Error(`ledger entries must be an array: ${filePath}`);
|
|
120
|
+
}
|
|
121
|
+
data.entries = data.entries.map((e) => LedgerEntry(e));
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Append `entries` to the ledger at `filePath` and persist it. New entries are
|
|
127
|
+
* sorted by (file, test) and de-duplicated against the batch and disk.
|
|
128
|
+
*
|
|
129
|
+
* ONE ROW PER (test, file) MAY STATE A CAUSE, and a caused row wins:
|
|
130
|
+
*
|
|
131
|
+
* - a new row WITH a cause replaces a causeless row for the same pair
|
|
132
|
+
* - a new row WITHOUT a cause is dropped when a caused row already exists
|
|
133
|
+
*
|
|
134
|
+
* Without this, katana recording `{kind: 'skipped', cause: ''}` and a quarantine
|
|
135
|
+
* writer recording `{kind: 'skipped', cause: 'product-defect', issue: '#123'}`
|
|
136
|
+
* differ in `key()` and BOTH persist. A consumer that fails on an unlinked
|
|
137
|
+
* quarantine (canary-ci-ready does) then fails on the causeless row while the
|
|
138
|
+
* linked row sits beside it -- the ledger contradicting itself about one test.
|
|
139
|
+
*
|
|
140
|
+
* History is not lost to this: rows that differ in cause-bearing state are the
|
|
141
|
+
* only ones that collapse. Two caused rows, or two causeless rows, keep the
|
|
142
|
+
* full-field identity and both remain.
|
|
143
|
+
*/
|
|
144
|
+
export function appendEntries(filePath, entries) {
|
|
145
|
+
const doc = load(filePath);
|
|
146
|
+
const existing = doc.entries;
|
|
147
|
+
const seen = new Set(existing.map(key));
|
|
148
|
+
const causedPairs = new Set(existing.filter((r) => r.cause).map(pair));
|
|
149
|
+
|
|
150
|
+
const newRows = entries
|
|
151
|
+
.map((e) => LedgerEntry(e))
|
|
152
|
+
.sort(
|
|
153
|
+
(a, b) => a.file.localeCompare(b.file) || a.test.localeCompare(b.test),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
for (const row of newRows) {
|
|
157
|
+
const k = key(row);
|
|
158
|
+
if (seen.has(k)) continue;
|
|
159
|
+
const p = pair(row);
|
|
160
|
+
|
|
161
|
+
if (row.cause) {
|
|
162
|
+
// Supersede: drop any causeless row for this pair, then take its place.
|
|
163
|
+
for (let i = existing.length - 1; i >= 0; i--) {
|
|
164
|
+
if (!existing[i].cause && pair(existing[i]) === p) {
|
|
165
|
+
seen.delete(key(existing[i]));
|
|
166
|
+
existing.splice(i, 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
causedPairs.add(p);
|
|
170
|
+
} else if (causedPairs.has(p)) {
|
|
171
|
+
// A causeless row must never sit next to a caused one for the same test.
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
seen.add(k);
|
|
176
|
+
existing.push(row);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
doc.schema_version = SCHEMA_VERSION;
|
|
180
|
+
fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true });
|
|
181
|
+
fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
|
182
|
+
return doc;
|
|
183
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-pr-guardian
|
|
3
|
+
description: >
|
|
4
|
+
PR/pre-commit test-guardian orchestrator: runs the deterministic Tier-0
|
|
5
|
+
diff-coverage pass, audits affected tests via canary-test-reviewer (Tier 1),
|
|
6
|
+
and — at the desk with authorTests opt-in — authors missing tests via
|
|
7
|
+
canary-test-author (Tier 2), staging them and blocking the commit once for
|
|
8
|
+
human review. Use to guard a change's test quality before it lands.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Canary: PR Guardian
|
|
12
|
+
|
|
13
|
+
Guards a change's test quality before it lands. Composes the deterministic
|
|
14
|
+
Tier-0 diff-coverage engine with two native agents — `canary-test-reviewer`
|
|
15
|
+
(read-only audit) and `canary-test-author` (authoring) — under a strict
|
|
16
|
+
write-safety model. This is the **Option A** driver: the engine never calls an
|
|
17
|
+
LLM; **this skill** invokes the agents in-session and enforces
|
|
18
|
+
stage-and-block-once.
|
|
19
|
+
|
|
20
|
+
**On the tier numbers.** Here they are the values of
|
|
21
|
+
`canary guardian pr-check --tier 0|1|2`, not a repo-wide capability scale — Tier
|
|
22
|
+
1 means "the `--tier 1` pass," which is the agent audit. `Tier-0` is the one
|
|
23
|
+
number with a repo-wide meaning (deterministic, no network, no agent), and
|
|
24
|
+
`Tier-1`/`Tier-2` are guardian-local by
|
|
25
|
+
[ADR 0015](../../../../docs/knowledge/decisions/0015-skill-capability-vocabulary.md).
|
|
26
|
+
Do not carry them into other skills.
|
|
27
|
+
|
|
28
|
+
## When to Use
|
|
29
|
+
|
|
30
|
+
- Before opening or updating a PR, to check that new/changed code is tested.
|
|
31
|
+
- As a pre-commit companion when `preCommit.authorTests: true` is set and you
|
|
32
|
+
want the guardian to author the missing tests for you (at the desk only).
|
|
33
|
+
- NOT in CI for Tier-2 write-back — that is a NON-GOAL. CI runs Tier-0 only (the
|
|
34
|
+
`CANARY_GUARDIAN_AGENT` env is unset there).
|
|
35
|
+
|
|
36
|
+
## Safety model (non-negotiable)
|
|
37
|
+
|
|
38
|
+
- **NEVER commit or push.** This skill only authors and `git add`s. The human
|
|
39
|
+
reviews the staged tests and re-commits.
|
|
40
|
+
- **Honor every `skipped` reason** from `author-plan` verbatim (opt-in-off /
|
|
41
|
+
tier / fork / collision / loop-guard). Never override a skip.
|
|
42
|
+
- **Block once.** When `block.block == true`, print the block message and stop —
|
|
43
|
+
leave the staged tests for the human. The loop-guard sentinel you write in
|
|
44
|
+
Phase 3 is what stops the guardian re-authoring over its own output on the
|
|
45
|
+
next run. It is stamped with the current `HEAD` and expires by itself once the
|
|
46
|
+
human's review commit moves `HEAD` — never delete it yourself.
|
|
47
|
+
- **Authoring is opt-in.** No `preCommit.authorTests: true` ⇒ no writes, ever.
|
|
48
|
+
|
|
49
|
+
## Phases
|
|
50
|
+
|
|
51
|
+
### Phase 0 — Deterministic scope
|
|
52
|
+
|
|
53
|
+
Run the Tier-0 pass and read its findings:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
canary guardian pr-check --format json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Findings are `untested-new-code` gaps. If there are none, report clean and stop.
|
|
60
|
+
|
|
61
|
+
### Phase 1 — Quality audit (Tier ≥ 1, read-only)
|
|
62
|
+
|
|
63
|
+
Export the availability signal so the probe reports the ceiling, then audit the
|
|
64
|
+
affected tests with `canary-test-reviewer`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
export CANARY_GUARDIAN_AGENT=1 # or 2 when authoring is enabled
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
If this checkout is a **fork** (an untrusted/read-only context — detect it, e.g.
|
|
71
|
+
`git config --get remote.origin.url` pointing at a fork, or a CI fork PR), arm
|
|
72
|
+
the fork guard so the engine's safety layer never authors on it:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export CANARY_GUARDIAN_IS_FORK=1 # any value other than "0"/unset means fork
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Leave `CANARY_GUARDIAN_IS_FORK` unset (or `0`) at your own desk on a trusted
|
|
79
|
+
checkout. The guard fails CLOSED: any ambiguous value is treated as a fork and
|
|
80
|
+
authoring is skipped.
|
|
81
|
+
|
|
82
|
+
Use the `canary-test-reviewer` agent to review the affected tests. This is
|
|
83
|
+
**read-only** — surface weak-test findings; write nothing.
|
|
84
|
+
|
|
85
|
+
### Phase 2 — Authoring plan (Tier 2 + opt-in)
|
|
86
|
+
|
|
87
|
+
Ask the engine's safety layer for the plan (intents + block decision):
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
canary guardian author-plan --json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The JSON is `{"intents": [...], "block": {...}}`. Each intent carries `status`
|
|
94
|
+
(`planned` | `authored` | `skipped`), `target_path`, `requirement`, and a
|
|
95
|
+
`skip_reason` when skipped. **Do not author anything the plan skipped** — the
|
|
96
|
+
Engine guards (opt-in, fork, collision, loop-guard) are authoritative.
|
|
97
|
+
|
|
98
|
+
### Phase 3 — Author, stage, block once
|
|
99
|
+
|
|
100
|
+
For each intent with `status: "planned"`, use the `canary-test-author` agent
|
|
101
|
+
with the intent's `requirement` as the task and `target_path` as the
|
|
102
|
+
destination. **Never overwrite an existing file at `target_path`.** The engine's
|
|
103
|
+
collision guard runs at plan time, so between planning and writing another
|
|
104
|
+
PR/session may have created the file (a TOCTOU window). If the target already
|
|
105
|
+
exists at write time, skip that intent and report it — do not clobber it. Then
|
|
106
|
+
stage the authored files:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
git add <target_path>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
When `block.block == true`, record the guardian-authored paths in the loop-guard
|
|
113
|
+
sentinel BEFORE blocking, by running the deterministic producer command once per
|
|
114
|
+
authored path:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
canary guardian mark-authored --path <target_path> [--path <target_path> ...]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This is a real CLI step (not something you `touch` yourself): it writes the
|
|
121
|
+
sentinel inside the real git dir and records exactly the paths you authored,
|
|
122
|
+
stamped with the current `HEAD`. `author-plan` reads it on the next run and
|
|
123
|
+
returns a `loop-guard` skip **while `HEAD` is unchanged**, so the guardian never
|
|
124
|
+
authors on top of its own output — and once the human commits the reviewed
|
|
125
|
+
tests, `HEAD` moves and authoring re-enables itself.
|
|
126
|
+
|
|
127
|
+
Then print `block.message` (the "N test(s) authored & staged — review and
|
|
128
|
+
re-commit" notice) and **stop**. Do not commit. The human reviews the staged
|
|
129
|
+
tests and re-commits.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name: canary-pr-guardian
|
|
2
|
+
version: '1.0.0'
|
|
3
|
+
description:
|
|
4
|
+
PR/pre-commit test-guardian orchestrator — Tier-0 diff-coverage, Tier-1
|
|
5
|
+
quality audit via canary-test-reviewer, and at-desk Tier-2 authoring via
|
|
6
|
+
canary-test-author with stage-and-block-once review.
|
|
7
|
+
stability: static
|
|
8
|
+
triggers:
|
|
9
|
+
- manual
|
|
10
|
+
platforms:
|
|
11
|
+
- claude-code
|
|
12
|
+
type: rigid
|
|
13
|
+
tools: []
|
|
14
|
+
tier: 1
|
|
15
|
+
depends_on:
|
|
16
|
+
- canary-test-reviewer
|
|
17
|
+
- canary-test-author
|