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,252 @@
|
|
|
1
|
+
// canary-strix -- the scanner. Deterministic: no LLM, no network, no execution.
|
|
2
|
+
//
|
|
3
|
+
// Two surfaces, because a company identifier reaches a public repo two ways and
|
|
4
|
+
// only one of them is in a file:
|
|
5
|
+
//
|
|
6
|
+
// FILES tracked file contents (the obvious half)
|
|
7
|
+
// AUTHORSHIP the author, committer and `Co-authored-by:` trailers of the
|
|
8
|
+
// commits a change adds -- metadata, so a tree can scan perfectly
|
|
9
|
+
// clean while every commit in it is stamped with a company email
|
|
10
|
+
//
|
|
11
|
+
// Ported from canary's own repo-internal gate, which had no way to reach a
|
|
12
|
+
// consumer: `scripts/` is not in the published package's `files`, so the only
|
|
13
|
+
// callers were canary's CI and its pre-commit hook.
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Findings carry a stable id so a consumer can filter or waive by rule. */
|
|
20
|
+
export const RULES = {
|
|
21
|
+
FILE: 'STRIX-001',
|
|
22
|
+
AUTHOR: 'STRIX-002',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Identities that appear in a `Name <email>` trailer. */
|
|
26
|
+
const TRAILER =
|
|
27
|
+
/^(?:co-authored|signed-off|reviewed|acked|tested|reported|suggested|helped|mentored)-by:\s*(.+)$/gim;
|
|
28
|
+
|
|
29
|
+
/** A range `git log` will read as a range rather than as a flag. */
|
|
30
|
+
const RANGE_SHAPE = /^[\w./^~-]+\.{2,3}[\w./^~-]+$/;
|
|
31
|
+
|
|
32
|
+
/** Suffixes worth reading. Prose and config leak names as readily as code. */
|
|
33
|
+
export const SCANNED_SUFFIXES = new Set([
|
|
34
|
+
'.md',
|
|
35
|
+
'.txt',
|
|
36
|
+
'.py',
|
|
37
|
+
'.ts',
|
|
38
|
+
'.tsx',
|
|
39
|
+
'.js',
|
|
40
|
+
'.mjs',
|
|
41
|
+
'.cjs',
|
|
42
|
+
'.json',
|
|
43
|
+
'.yml',
|
|
44
|
+
'.yaml',
|
|
45
|
+
'.html',
|
|
46
|
+
'.svg',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function reEscape(s) {
|
|
50
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compile a term into a matcher.
|
|
55
|
+
*
|
|
56
|
+
* Terms are authored as prose ("Acme Health", "Acme Inc."), and an identity or
|
|
57
|
+
* a domain has a different grammar. A naive `\b<term>\b` silently never matches
|
|
58
|
+
* three common shapes, and it fails OPEN, which is the worst direction:
|
|
59
|
+
*
|
|
60
|
+
* term subject \b-anchored
|
|
61
|
+
* Acme Health dev@acmehealth.example MISS (separator dropped)
|
|
62
|
+
* Acme Health dev@acme-health.example MISS (separator changed)
|
|
63
|
+
* Acme Inc. Acme Inc. <a@b> MISS (\b after `.` wants \w)
|
|
64
|
+
* Café Café <a@b> MISS (\b is ASCII-only)
|
|
65
|
+
*
|
|
66
|
+
* So: split into alphanumeric tokens, allow any run of separators between them,
|
|
67
|
+
* and bound with unicode-aware lookarounds. Boundaries still hold, so `Acme`
|
|
68
|
+
* does not match `acmecorp`.
|
|
69
|
+
*/
|
|
70
|
+
export function compileTerms(terms) {
|
|
71
|
+
return terms
|
|
72
|
+
.map((t) => String(t).trim())
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((term) => ({
|
|
75
|
+
term,
|
|
76
|
+
tokens: term.split(/[^\p{L}\p{N}]+/u).filter(Boolean),
|
|
77
|
+
}))
|
|
78
|
+
.filter(({ tokens }) => tokens.length)
|
|
79
|
+
.map(({ term, tokens }) => {
|
|
80
|
+
const body = tokens.map(reEscape).join('[^\\p{L}\\p{N}]*');
|
|
81
|
+
return {
|
|
82
|
+
term,
|
|
83
|
+
re: new RegExp(`(?<![\\p{L}\\p{N}])${body}(?![\\p{L}\\p{N}])`, 'iu'),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function git(args, cwd) {
|
|
89
|
+
return execFileSync('git', args, {
|
|
90
|
+
cwd,
|
|
91
|
+
encoding: 'utf-8',
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Tracked files, so an untracked scratch file is never a finding. */
|
|
97
|
+
export function trackedFiles(root) {
|
|
98
|
+
try {
|
|
99
|
+
return git(['ls-files'], root).split('\n').filter(Boolean);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function suffixOf(rel) {
|
|
106
|
+
const dot = rel.lastIndexOf('.');
|
|
107
|
+
return dot === -1 ? '' : rel.slice(dot);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Scan tracked file CONTENTS.
|
|
112
|
+
*
|
|
113
|
+
* Returns the denominator alongside the findings. A caller that reports
|
|
114
|
+
* findings without the count cannot tell "clean" from "read nothing".
|
|
115
|
+
*/
|
|
116
|
+
export function scanFiles(root, matchers, { exclude = [] } = {}) {
|
|
117
|
+
const files = trackedFiles(root);
|
|
118
|
+
if (files === null)
|
|
119
|
+
return { unavailable: 'not a git repository', findings: [] };
|
|
120
|
+
|
|
121
|
+
const findings = [];
|
|
122
|
+
let scanned = 0;
|
|
123
|
+
for (const rel of files.sort()) {
|
|
124
|
+
if (!SCANNED_SUFFIXES.has(suffixOf(rel))) continue;
|
|
125
|
+
if (exclude.some((rx) => rx.test(rel))) continue;
|
|
126
|
+
const abs = resolve(root, rel);
|
|
127
|
+
if (!existsSync(abs) || !statSync(abs).isFile()) continue;
|
|
128
|
+
scanned += 1;
|
|
129
|
+
const lines = readFileSync(abs, 'utf-8').split('\n');
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
for (const { term, re } of matchers) {
|
|
132
|
+
if (re.test(lines[i])) {
|
|
133
|
+
findings.push({
|
|
134
|
+
rule: RULES.FILE,
|
|
135
|
+
file: rel,
|
|
136
|
+
line: i + 1,
|
|
137
|
+
term,
|
|
138
|
+
// Deliberately NOT the matched line. On a public repo the CI log is
|
|
139
|
+
// world-readable, so echoing it publishes the identifier this scan
|
|
140
|
+
// exists to keep off the record -- on exactly the lines that trip.
|
|
141
|
+
detail: 'company identifier in a tracked file',
|
|
142
|
+
});
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { scanned, findings };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* pull_request. The range ends at the PR HEAD, not `HEAD`: actions/checkout
|
|
153
|
+
* has `refs/pull/N/merge` checked out, an ephemeral commit GitHub synthesises
|
|
154
|
+
* per event and authors with the PR author's ACCOUNT email. It is discarded at
|
|
155
|
+
* merge, so scanning it reports a leak that cannot reach the branch.
|
|
156
|
+
*/
|
|
157
|
+
function prRange(env) {
|
|
158
|
+
const base = (env.GITHUB_BASE_REF ?? '').trim();
|
|
159
|
+
if (!base) return null;
|
|
160
|
+
const head = (env.GITHUB_PR_HEAD_SHA ?? '').trim();
|
|
161
|
+
return `origin/${base}..${head || 'HEAD'}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** push. `before` is all-zeroes on a branch's first push: no usable range. */
|
|
165
|
+
function pushRange(env) {
|
|
166
|
+
const before = (env.GITHUB_EVENT_BEFORE ?? '').trim();
|
|
167
|
+
return before && !/^0+$/.test(before) ? `${before}..HEAD` : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function rangeFromEvent(env) {
|
|
171
|
+
return prRange(env) ?? pushRange(env);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function resolveRange(env, explicit) {
|
|
175
|
+
const given = String(explicit ?? '').trim();
|
|
176
|
+
return given || rangeFromEvent(env);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Scan the AUTHORSHIP of the commits a range adds.
|
|
181
|
+
*
|
|
182
|
+
* Not `--no-merges`: "Merge branch 'main' into <feature>" made in a clone that
|
|
183
|
+
* inherited a global `user.email` is one of the likeliest ways an identity
|
|
184
|
+
* reaches a shared branch, and skipping merges would leave exactly that commit
|
|
185
|
+
* unread while still reporting a confident count.
|
|
186
|
+
*/
|
|
187
|
+
/** Read the commits in `range`, or say why not. */
|
|
188
|
+
function readCommits(root, range) {
|
|
189
|
+
if (!RANGE_SHAPE.test(range) || range.startsWith('-')) {
|
|
190
|
+
return { unavailable: `${range} (not a well-formed commit range)` };
|
|
191
|
+
}
|
|
192
|
+
let records;
|
|
193
|
+
try {
|
|
194
|
+
records = git(
|
|
195
|
+
['log', '--format=%H%x00%an <%ae>%x00%cn <%ce>%x00%B%x1e', range],
|
|
196
|
+
root,
|
|
197
|
+
)
|
|
198
|
+
.split('\x1e')
|
|
199
|
+
.map((r) => r.replace(/^\n/, ''))
|
|
200
|
+
.filter((r) => r.trim());
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const detail = String(err?.stderr ?? '')
|
|
203
|
+
.trim()
|
|
204
|
+
.split('\n')[0];
|
|
205
|
+
return { unavailable: detail || `cannot read ${range}` };
|
|
206
|
+
}
|
|
207
|
+
// Zero commits is not a clean scan, it is no scan.
|
|
208
|
+
if (!records.length) {
|
|
209
|
+
return { unavailable: `${range} (resolved, but held no commits)` };
|
|
210
|
+
}
|
|
211
|
+
return { records };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The identities one commit puts on the record, and which ones matched. */
|
|
215
|
+
function commitFinding(record, matchers) {
|
|
216
|
+
const [sha, author, committer, body = ''] = record.split('\0');
|
|
217
|
+
const roles = [
|
|
218
|
+
['author', author],
|
|
219
|
+
['committer', committer],
|
|
220
|
+
...[...body.matchAll(TRAILER)].map((m) => ['trailer', m[1].trim()]),
|
|
221
|
+
];
|
|
222
|
+
const hit = roles.filter(([, id]) => matchers.some(({ re }) => re.test(id)));
|
|
223
|
+
if (!hit.length) return null;
|
|
224
|
+
// One row per commit, not per field: `user.email` sets author and committer
|
|
225
|
+
// together, so the common case matches twice.
|
|
226
|
+
const short = sha.slice(0, 9);
|
|
227
|
+
return {
|
|
228
|
+
rule: RULES.AUTHOR,
|
|
229
|
+
commit: short,
|
|
230
|
+
fields: hit.map(([who]) => who),
|
|
231
|
+
// Not the identity itself -- see scanFiles.
|
|
232
|
+
detail: `company identity on a commit (inspect: git log -1 --format='%an <%ae> %cn <%ce>' ${short})`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Scan the AUTHORSHIP of the commits a range adds.
|
|
238
|
+
*
|
|
239
|
+
* Not `--no-merges`: "Merge branch 'main' into <feature>" made in a clone that
|
|
240
|
+
* inherited a global `user.email` is one of the likeliest ways an identity
|
|
241
|
+
* reaches a shared branch, and skipping merges would leave exactly that commit
|
|
242
|
+
* unread while still reporting a confident count.
|
|
243
|
+
*/
|
|
244
|
+
export function scanAuthorship(root, matchers, range) {
|
|
245
|
+
if (!range) return { skipped: 'no commit range' };
|
|
246
|
+
const { records, unavailable } = readCommits(root, range);
|
|
247
|
+
if (unavailable) return { unavailable, findings: [] };
|
|
248
|
+
const findings = records
|
|
249
|
+
.map((r) => commitFinding(r, matchers))
|
|
250
|
+
.filter(Boolean);
|
|
251
|
+
return { scanned: records.length, findings };
|
|
252
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Where a consumer's denylist terms come from.
|
|
2
|
+
//
|
|
3
|
+
// Three sources, unioned, because a consumer's repo may be public or private
|
|
4
|
+
// and the right answer differs:
|
|
5
|
+
//
|
|
6
|
+
// 1. CANARY_PROPRIETARY_DENYLIST env -- a CI secret. The only source that
|
|
7
|
+
// never lands in the repo, so it is the one a PUBLIC repo must use.
|
|
8
|
+
// 2. .proprietary-denylist (gitignored) -- the same list at the desk, so the
|
|
9
|
+
// pre-commit path catches a term before a push rather than after one.
|
|
10
|
+
// 3. .canary/company.json -- committed, and therefore only
|
|
11
|
+
// appropriate for a PRIVATE repo.
|
|
12
|
+
//
|
|
13
|
+
// (3) is the convenient one and the one to warn about: a committed denylist on
|
|
14
|
+
// a public repo publishes precisely the list of things being hidden. The loader
|
|
15
|
+
// reports which sources it read so a caller can say so out loud.
|
|
16
|
+
//
|
|
17
|
+
// Separators: comma OR newline. Newline matters — GitHub masks a multi-line
|
|
18
|
+
// secret line by line, so one term per line means each term is masked
|
|
19
|
+
// individually in a log. A comma-joined value is masked only as the whole
|
|
20
|
+
// string, which is not the form that ever appears in output.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
export const DENYLIST_ENV = 'CANARY_PROPRIETARY_DENYLIST';
|
|
26
|
+
export const DENYLIST_FILE = '.proprietary-denylist';
|
|
27
|
+
export const COMPANY_FILE = '.canary/company.json';
|
|
28
|
+
/** The company.json key. Array of strings. */
|
|
29
|
+
export const COMPANY_KEY = 'proprietary_denylist';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Terms from a comma- or newline-separated source.
|
|
33
|
+
*
|
|
34
|
+
* Comments are stripped PER LINE, before the comma split. The order matters
|
|
35
|
+
* and getting it wrong is #818: splitting on `[,\n]` first meant a comment
|
|
36
|
+
* line containing a comma survived in part — the fragment before the comma
|
|
37
|
+
* starts with `#` and is dropped, but every fragment after one does not, and
|
|
38
|
+
* was kept as a denylist term. This repo's own `.proprietary-denylist` has a
|
|
39
|
+
* commented header, so 8 of the 15 terms it appeared to declare were
|
|
40
|
+
* fragments of its own prose. One of them was `and on a`, which matched three
|
|
41
|
+
* innocent files and reported them as company identifiers.
|
|
42
|
+
*
|
|
43
|
+
* Two harms, and the second is worse: a leak gate that cries wolf on the word
|
|
44
|
+
* "and" is an alarm nobody reads, and the run summary said `15 term(s)` when
|
|
45
|
+
* seven were real — an inflated denominator on the last line of defence
|
|
46
|
+
* before a company name reaches a public repo.
|
|
47
|
+
*/
|
|
48
|
+
function split(raw) {
|
|
49
|
+
return String(raw)
|
|
50
|
+
.split('\n')
|
|
51
|
+
.map((line) => line.trim())
|
|
52
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
53
|
+
.flatMap((line) => line.split(','))
|
|
54
|
+
.map((s) => s.trim())
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
/**
|
|
60
|
+
* Terms that do not look like an identifier anyone would need to hide.
|
|
61
|
+
*
|
|
62
|
+
* Defence in depth for #818, independent of the parser that produced it: the
|
|
63
|
+
* eight phantom terms leaked from comment prose were all three-or-more
|
|
64
|
+
* all-lowercase words, and this would have caught every one of them without
|
|
65
|
+
* knowing anything about comments. A company, client or consumer name is one
|
|
66
|
+
* or two tokens, or carries capitals; `and from this file at the` is neither.
|
|
67
|
+
*
|
|
68
|
+
* Reported, never dropped. A consumer could legitimately declare an odd
|
|
69
|
+
* phrase, and silently discarding a term from a leak gate is a worse failure
|
|
70
|
+
* than flagging a suspicious one. The caller surfaces the COUNT and shape, not
|
|
71
|
+
* the values -- these are the names the scan exists to keep out of a public
|
|
72
|
+
* repo, and the warning goes to a CI log.
|
|
73
|
+
*/
|
|
74
|
+
function implausibleTerms(terms) {
|
|
75
|
+
return terms.filter((t) => {
|
|
76
|
+
const words = t.split(/\s+/).filter(Boolean);
|
|
77
|
+
return words.length >= 3 && t === t.toLowerCase();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @returns {{terms: string[], sources: string[], committedSource: boolean, implausible: string[]}}
|
|
83
|
+
* `committedSource` is true when a term came from a file that is tracked,
|
|
84
|
+
* which is the shape a caller should warn about on a public repo.
|
|
85
|
+
*/
|
|
86
|
+
export function loadTerms(root, env = process.env) {
|
|
87
|
+
const terms = new Set();
|
|
88
|
+
const sources = [];
|
|
89
|
+
|
|
90
|
+
const fromEnv = split(env[DENYLIST_ENV] ?? '');
|
|
91
|
+
if (fromEnv.length) {
|
|
92
|
+
fromEnv.forEach((t) => terms.add(t));
|
|
93
|
+
sources.push(DENYLIST_ENV);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const file = resolve(root, DENYLIST_FILE);
|
|
97
|
+
if (existsSync(file)) {
|
|
98
|
+
const fromFile = split(readFileSync(file, 'utf-8'));
|
|
99
|
+
if (fromFile.length) {
|
|
100
|
+
fromFile.forEach((t) => terms.add(t));
|
|
101
|
+
sources.push(DENYLIST_FILE);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let committedSource = false;
|
|
106
|
+
const company = resolve(root, COMPANY_FILE);
|
|
107
|
+
if (existsSync(company)) {
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(readFileSync(company, 'utf-8'));
|
|
110
|
+
const raw = parsed?.[COMPANY_KEY];
|
|
111
|
+
const fromCompany = Array.isArray(raw) ? split(raw.join('\n')) : [];
|
|
112
|
+
if (fromCompany.length) {
|
|
113
|
+
fromCompany.forEach((t) => terms.add(t));
|
|
114
|
+
sources.push(COMPANY_FILE);
|
|
115
|
+
committedSource = true;
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// A malformed company.json is the consuming repo's problem to surface,
|
|
119
|
+
// not this scan's to guess at. Recorded as a source that yielded nothing
|
|
120
|
+
// rather than silently treated as absent.
|
|
121
|
+
sources.push(`${COMPANY_FILE} (unreadable)`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sorted = [...terms].sort();
|
|
126
|
+
return {
|
|
127
|
+
terms: sorted,
|
|
128
|
+
sources,
|
|
129
|
+
committedSource,
|
|
130
|
+
implausible: implausibleTerms(sorted),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-test-pipeline
|
|
3
|
+
description: >
|
|
4
|
+
Multi-phase orchestrator that composes canary-ci-ready, canary-critical-areas,
|
|
5
|
+
canary-edge-case-discovery, canary-failure-impact, and canary-write-test into
|
|
6
|
+
a sequential pipeline with a convergence loop and health report.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Canary: Test Pipeline
|
|
10
|
+
|
|
11
|
+
Runs the full test intelligence pipeline from risk assessment to verified CI
|
|
12
|
+
readiness. Follows the same pattern as `harness:docs-pipeline` and
|
|
13
|
+
`harness:knowledge-pipeline` — sequential phases, convergence loop, qualitative
|
|
14
|
+
health report on exit.
|
|
15
|
+
|
|
16
|
+
## When to Use
|
|
17
|
+
|
|
18
|
+
- Starting a new test suite from scratch
|
|
19
|
+
|
|
20
|
+
- Improving a suite that is not yet CI-ready
|
|
21
|
+
|
|
22
|
+
- After a major feature lands and test coverage needs updating
|
|
23
|
+
|
|
24
|
+
- When asked to "bring this suite to CI-ready"
|
|
25
|
+
|
|
26
|
+
## Phases
|
|
27
|
+
|
|
28
|
+
### Phase 0 — Probe, then gate (`/canary-ci-ready`)
|
|
29
|
+
|
|
30
|
+
**Probe for the harness MCP exactly once, here, and thread the result through
|
|
31
|
+
every later phase.** Several sub-skills (`canary-critical-areas`,
|
|
32
|
+
`canary-failure-impact`, `canary-ci-ready`) each adapt to whether harness
|
|
33
|
+
primitives are available. Left alone they rediscover that independently, which
|
|
34
|
+
is wasted work and — worse — can yield an inconsistent run where one phase
|
|
35
|
+
scores against the graph and another silently falls back.
|
|
36
|
+
|
|
37
|
+
Record the probe result once:
|
|
38
|
+
|
|
39
|
+
```text
|
|
40
|
+
harness: available → phases may call get_impact, get_critical_paths,
|
|
41
|
+
get_perf_baselines, ask_graph
|
|
42
|
+
harness: unavailable → every phase uses its documented fallback
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pass that verdict to each phase rather than letting it re-probe. If the probe is
|
|
46
|
+
ambiguous, treat harness as **unavailable** — a consistent weaker run beats a
|
|
47
|
+
run whose fidelity varies by phase, because a mixed run's health report cannot
|
|
48
|
+
be compared against any previous one.
|
|
49
|
+
|
|
50
|
+
Then run `canary-ci-ready` as a baseline.
|
|
51
|
+
|
|
52
|
+
- If **CI-READY**: inform the user and offer to exit or run an improvement sweep
|
|
53
|
+
anyway.
|
|
54
|
+
|
|
55
|
+
- If **NOT CI-READY**: show the score, continue to Phase 1.
|
|
56
|
+
|
|
57
|
+
### Phase 1 — Assess (`/canary-critical-areas`)
|
|
58
|
+
|
|
59
|
+
Run `canary-critical-areas` on the repo (or `--diff` if provided). Save
|
|
60
|
+
`critical-areas.json` automatically (no `--save` required when run inside the
|
|
61
|
+
pipeline).
|
|
62
|
+
|
|
63
|
+
Present the top 5 areas and ask the user to confirm or trim the list before
|
|
64
|
+
continuing.
|
|
65
|
+
|
|
66
|
+
### Phase 2 — Discover (`/canary-edge-cases`)
|
|
67
|
+
|
|
68
|
+
For each confirmed critical area from Phase 1, run `canary-edge-case-discovery`
|
|
69
|
+
with the area as context (passed via `critical-areas.json`).
|
|
70
|
+
|
|
71
|
+
Group edge cases by critical area. Present the full list before Phase 3 so the
|
|
72
|
+
user can review.
|
|
73
|
+
|
|
74
|
+
### Phase 3 — Impact (`/canary-failure-impact`)
|
|
75
|
+
|
|
76
|
+
For each high-risk path (risk_score ≥ 0.7 from Phase 1), run
|
|
77
|
+
`canary-failure-impact`. Use `critical-areas.json` as context.
|
|
78
|
+
|
|
79
|
+
Annotate each gap with its severity (Critical / High / Medium / Low).
|
|
80
|
+
|
|
81
|
+
### Phase 4 — Generate (`/canary-write-test`)
|
|
82
|
+
|
|
83
|
+
Present the consolidated gap list sorted by: severity (Critical first) then
|
|
84
|
+
depth (0 before 1 before 2). Include edge cases from Phase 2 as test variant
|
|
85
|
+
suggestions for each gap.
|
|
86
|
+
|
|
87
|
+
For each gap, ask the user to confirm before invoking `canary-write-test`. Do
|
|
88
|
+
not generate tests silently.
|
|
89
|
+
|
|
90
|
+
After generation, show a summary of files written before proceeding to Phase 5.
|
|
91
|
+
|
|
92
|
+
### Phase 5 — Verify (`/canary-ci-ready`)
|
|
93
|
+
|
|
94
|
+
Re-run `canary-ci-ready`. Show the delta — which checks improved, which remain
|
|
95
|
+
failing.
|
|
96
|
+
|
|
97
|
+
If any check still fails, investigate using the same user-catalog logic defined
|
|
98
|
+
in `canary-ci-ready`: auth/config failures get a catalog lookup before being
|
|
99
|
+
declared blockers.
|
|
100
|
+
|
|
101
|
+
## Convergence
|
|
102
|
+
|
|
103
|
+
After Phase 5:
|
|
104
|
+
|
|
105
|
+
- **ci-ready passes** → emit health report and exit
|
|
106
|
+
|
|
107
|
+
- **no new gaps found** (Phase 1 produces empty list) → emit health report, note
|
|
108
|
+
remaining gaps are outside current signal, suggest manual review
|
|
109
|
+
|
|
110
|
+
- **gaps remain and user confirms** → loop back to Phase 1
|
|
111
|
+
|
|
112
|
+
- **user stops** → emit health report with current state
|
|
113
|
+
|
|
114
|
+
## Health Report
|
|
115
|
+
|
|
116
|
+
Emit on every exit (convergence or user stop):
|
|
117
|
+
|
|
118
|
+
```text
|
|
119
|
+
Test Pipeline — run complete
|
|
120
|
+
|
|
121
|
+
Areas assessed: N → N now at depth 3+ (+N this run)
|
|
122
|
+
Tests written: N → N critical paths now covered
|
|
123
|
+
Gaps remaining: N → <top gap names>
|
|
124
|
+
CI-Ready: CI-READY or NOT CI-READY (N/5 checks)
|
|
125
|
+
|
|
126
|
+
<if not ready> Next: /canary-test-pipeline --continue, or address manually.
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Also **write the report to `.harness/analyses/`** on every exit, so harness's
|
|
130
|
+
pre-merge tooling can cite the pipeline's verdict instead of it living only in
|
|
131
|
+
session scrollback. Record the harness-availability verdict from Phase 0 in the
|
|
132
|
+
record: a run that used fallbacks throughout is not comparable to one scored
|
|
133
|
+
against the graph, and a reader needs to know which they are looking at.
|
|
134
|
+
|
|
135
|
+
If the analyses channel is unavailable, say so loudly and still emit the report
|
|
136
|
+
to the session — degrade, never drop. (Same producer contract the guardian's
|
|
137
|
+
`--emit-analysis` follows.)
|
|
138
|
+
|
|
139
|
+
## Flags
|
|
140
|
+
|
|
141
|
+
- `--continue` — skip the Phase 0 **baseline** (resume a prior run). The Phase 0
|
|
142
|
+
**probe still runs** — it is what every later phase depends on, so skipping it
|
|
143
|
+
would leave the resumed run with no availability verdict at all.
|
|
144
|
+
|
|
145
|
+
- `--diff <ref>` — pass to `canary-critical-areas` to scope assessment to a diff
|
|
146
|
+
|
|
147
|
+
- `--threshold <n>` — pass to `canary-ci-ready` (default: 2)
|
|
148
|
+
|
|
149
|
+
## Related skills
|
|
150
|
+
|
|
151
|
+
- `/canary-ci-ready` — Phase 0 and convergence gate
|
|
152
|
+
|
|
153
|
+
- `/canary-critical-areas` — Phase 1
|
|
154
|
+
|
|
155
|
+
- `/canary-edge-cases` — Phase 2
|
|
156
|
+
|
|
157
|
+
- `/canary-failure-impact` — Phase 3
|
|
158
|
+
|
|
159
|
+
- `/canary-write-test` — Phase 4
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: canary-test-pipeline
|
|
2
|
+
version: '1.0.0'
|
|
3
|
+
description:
|
|
4
|
+
Multi-phase orchestrator chaining canary-critical-areas, edge-case-discovery,
|
|
5
|
+
failure-impact, ci-ready, and write-test into a convergence loop with a health
|
|
6
|
+
report.
|
|
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-ci-ready
|
|
17
|
+
- canary-critical-areas
|
|
18
|
+
- canary-edge-case-discovery
|
|
19
|
+
- canary-failure-impact
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canary-test-reporter
|
|
3
|
+
description:
|
|
4
|
+
Playwright JSON results → Markdown + JSON test report. Summarises passed,
|
|
5
|
+
failed, flaky, and skipped counts with a per-failure error block and a summary
|
|
6
|
+
table. Exits non-zero when any test failed so the CI step fails on real
|
|
7
|
+
failures. Complements canary-fail-fast (which aborts early); this skill
|
|
8
|
+
summarises the full run at the end.
|
|
9
|
+
cli: scripts/cli.mjs
|
|
10
|
+
requires: [node>=20]
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Canary Test Reporter
|
|
14
|
+
|
|
15
|
+
Turn a Playwright JSON results file into a human-readable **Markdown** report
|
|
16
|
+
and/or a machine-readable **JSON** artifact. Designed to run after the
|
|
17
|
+
Playwright step in CI (`if: always()`) and upload both files as job artifacts.
|
|
18
|
+
|
|
19
|
+
## Invocation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Markdown to stdout:
|
|
23
|
+
canary skills run canary-test-reporter -- --results test-results/results.json
|
|
24
|
+
|
|
25
|
+
# Markdown to file:
|
|
26
|
+
canary skills run canary-test-reporter -- \
|
|
27
|
+
--results test-results/results.json \
|
|
28
|
+
--markdown-out test-results/report.md
|
|
29
|
+
|
|
30
|
+
# JSON to file:
|
|
31
|
+
canary skills run canary-test-reporter -- \
|
|
32
|
+
--results test-results/results.json \
|
|
33
|
+
--json-out test-results/report.json
|
|
34
|
+
|
|
35
|
+
# Both at once (recommended for CI):
|
|
36
|
+
canary skills run canary-test-reporter -- \
|
|
37
|
+
--results test-results/results.json \
|
|
38
|
+
--markdown-out test-results/report.md \
|
|
39
|
+
--json-out test-results/report.json
|
|
40
|
+
|
|
41
|
+
# Usage and the full flag list (exits 0):
|
|
42
|
+
canary skills run canary-test-reporter -- --help
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Exit code:** `1` when any test failed; `0` otherwise. Flaky tests and skipped
|
|
46
|
+
tests never affect the exit code.
|
|
47
|
+
|
|
48
|
+
## Output formats
|
|
49
|
+
|
|
50
|
+
### Markdown
|
|
51
|
+
|
|
52
|
+
````text
|
|
53
|
+
# Test Report
|
|
54
|
+
|
|
55
|
+
**2 failed** · **1 flaky** · **14 passed** · **1 skipped** · 18 tests · 12.4s
|
|
56
|
+
|
|
57
|
+
## Failed (2)
|
|
58
|
+
|
|
59
|
+
### suite > spec > test title
|
|
60
|
+
`tests/auth.spec.ts:42`
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
Expected: 401
|
|
64
|
+
Received: 200
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Flaky (1)
|
|
68
|
+
|
|
69
|
+
- `tests/search.spec.ts:17` — search > autocomplete > debounce
|
|
70
|
+
|
|
71
|
+
## Summary
|
|
72
|
+
|
|
73
|
+
| Status | Count |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| Passed | 14 |
|
|
76
|
+
| Failed | 2 |
|
|
77
|
+
| Flaky | 1 |
|
|
78
|
+
| Skipped | 1 |
|
|
79
|
+
| **Total** | **18** |
|
|
80
|
+
````
|
|
81
|
+
|
|
82
|
+
### JSON
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"version": 1,
|
|
87
|
+
"generated_at": "2026-07-13T20:07:00Z",
|
|
88
|
+
"summary": {
|
|
89
|
+
"total": 18,
|
|
90
|
+
"passed": 14,
|
|
91
|
+
"failed": 2,
|
|
92
|
+
"flaky": 1,
|
|
93
|
+
"skipped": 1,
|
|
94
|
+
"duration_ms": 12400
|
|
95
|
+
},
|
|
96
|
+
"results": [
|
|
97
|
+
{
|
|
98
|
+
"title": "suite > spec > test",
|
|
99
|
+
"status": "failed",
|
|
100
|
+
"file": "tests/auth.spec.ts",
|
|
101
|
+
"line": 42,
|
|
102
|
+
"duration_ms": 1823,
|
|
103
|
+
"error": "Expected: 401\nReceived: 200"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The `version` field pins the contract for downstream tooling. `results` includes
|
|
110
|
+
**all** tests so external tools can compute their own views.
|
|
111
|
+
|
|
112
|
+
## CI wiring (GitHub Actions)
|
|
113
|
+
|
|
114
|
+
```yaml
|
|
115
|
+
- name: Run Playwright
|
|
116
|
+
run:
|
|
117
|
+
npx playwright test --reporter=json --output-file=test-results/results.json
|
|
118
|
+
|
|
119
|
+
- name: Test report
|
|
120
|
+
if: always()
|
|
121
|
+
run: |
|
|
122
|
+
canary skills run canary-test-reporter --allow-executable-skills -- \
|
|
123
|
+
--results test-results/results.json \
|
|
124
|
+
--markdown-out test-results/report.md \
|
|
125
|
+
--json-out test-results/report.json
|
|
126
|
+
|
|
127
|
+
- name: Upload test report
|
|
128
|
+
if: always()
|
|
129
|
+
uses: actions/upload-artifact@v4
|
|
130
|
+
with:
|
|
131
|
+
name: test-report
|
|
132
|
+
path: test-results/report.*
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Related skills
|
|
136
|
+
|
|
137
|
+
- `canary-fail-fast` — aborts the run early and emits `::error` annotations; use
|
|
138
|
+
with this skill for complete CI coverage (abort fast + summarise at end)
|