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,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)