canary-test-cli 5.15.0 → 6.0.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 (79) hide show
  1. package/agent/frameworks/registry.json +655 -0
  2. package/bin/canary.js +20 -15
  3. package/dist/doctor-manifest.d.ts +94 -0
  4. package/dist/doctor.d.ts +67 -0
  5. package/dist/engine/analysis/cli.js +270 -0
  6. package/dist/engine/analysis/engine.js +146 -0
  7. package/dist/engine/analysis/reports.js +0 -0
  8. package/dist/engine/analysis/rows.js +9 -0
  9. package/dist/engine/cli-commands.js +618 -0
  10. package/dist/engine/cli-common.js +60 -0
  11. package/dist/engine/cli.core.js +208 -0
  12. package/dist/engine/cli.js +31 -0
  13. package/dist/engine/company-knowledge-cli.js +201 -0
  14. package/dist/engine/core/ci-env.js +33 -0
  15. package/dist/engine/core/classifier.js +192 -0
  16. package/dist/engine/core/company-knowledge.js +765 -0
  17. package/dist/engine/core/config-validation.js +74 -0
  18. package/dist/engine/core/detection.js +48 -0
  19. package/dist/engine/core/domain-scanner.js +212 -0
  20. package/dist/engine/core/environment-detect.js +410 -0
  21. package/dist/engine/core/executor.js +181 -0
  22. package/dist/engine/core/feedback.js +93 -0
  23. package/dist/engine/core/fixture-scanner.js +173 -0
  24. package/dist/engine/core/framework-registry.js +123 -0
  25. package/dist/engine/core/mcp-validator.js +218 -0
  26. package/dist/engine/core/metadata-scanner.js +147 -0
  27. package/dist/engine/core/migrator.js +1112 -0
  28. package/dist/engine/core/overlays.js +176 -0
  29. package/dist/engine/core/pattern-healer.js +147 -0
  30. package/dist/engine/core/pattern-matcher.js +255 -0
  31. package/dist/engine/core/quality-scorer.js +213 -0
  32. package/dist/engine/core/recommender.js +152 -0
  33. package/dist/engine/core/reporter.js +211 -0
  34. package/dist/engine/core/scaffolder.js +236 -0
  35. package/dist/engine/core/skill-registry.js +522 -0
  36. package/dist/engine/core/static-linter.js +237 -0
  37. package/dist/engine/core/ticket-updater.js +639 -0
  38. package/dist/engine/core/workflow-discovery.js +693 -0
  39. package/dist/engine/guardian/agent-tier.js +338 -0
  40. package/dist/engine/guardian/analysis-emit.js +201 -0
  41. package/dist/engine/guardian/cli.js +787 -0
  42. package/dist/engine/guardian/coverage.js +1055 -0
  43. package/dist/engine/guardian/delta-emitter.js +46 -0
  44. package/dist/engine/guardian/diff-extractor.js +257 -0
  45. package/dist/engine/guardian/hard-gate.js +373 -0
  46. package/dist/engine/guardian/impact-mapper.js +121 -0
  47. package/dist/engine/guardian/pr-check.js +975 -0
  48. package/dist/engine/guardian/pr-comment.js +200 -0
  49. package/dist/engine/guardian/summary-emitter.js +94 -0
  50. package/dist/engine/guardian/tier.js +58 -0
  51. package/dist/engine/history/cli.js +303 -0
  52. package/dist/engine/history/detector.js +68 -0
  53. package/dist/engine/history/ndjson-store.js +177 -0
  54. package/dist/engine/history/record.js +14 -0
  55. package/dist/engine/history/schema.js +59 -0
  56. package/dist/engine/history/store.js +47 -0
  57. package/dist/engine/history/supabase-store.js +113 -0
  58. package/dist/engine/main-deps.js +105 -0
  59. package/dist/engine/mcp-server.js +647 -0
  60. package/dist/engine/package.json +4 -0
  61. package/dist/engine/skills-cli.js +181 -0
  62. package/dist/engine/ui/banner.js +50 -0
  63. package/dist/engine/util/coalesce.js +12 -0
  64. package/dist/engine/util/round.js +43 -0
  65. package/dist/engine/workflow-cli.js +242 -0
  66. package/dist/engine-checks.d.ts +49 -0
  67. package/dist/overlay-commands.d.ts +81 -0
  68. package/dist/overlay-conflicts.d.ts +33 -0
  69. package/dist/overlay-lint.d.ts +19 -0
  70. package/dist/overlays-registry.d.ts +74 -0
  71. package/dist/reporters/testtracker.d.ts +89 -0
  72. package/dist/reporters/testtracker.js +195 -0
  73. package/dist/router.d.ts +12 -0
  74. package/dist/router.js +4 -4
  75. package/dist/skill-requirements.d.ts +57 -0
  76. package/dist/source-spec.d.ts +20 -0
  77. package/package.json +30 -6
  78. package/bin/canary +0 -0
  79. package/scripts/install.js +0 -104
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Test quality static analyser.
3
+ *
4
+ * Faithful TypeScript port of `agent/core/quality_scorer.py`. Scores test code
5
+ * on coverage breadth, assertion density, and flakiness risk, plus a
6
+ * magic-number maintainability nudge. Purely lexical — no execution.
7
+ */
8
+ import { num1, roundHalfEvenInt } from '../util/round.js';
9
+ const TEST_FN = {
10
+ pytest: /^[ \t]*def test_/gm,
11
+ playwright: /\btest\s*\(/gm,
12
+ vitest: /\b(?:it|test)\s*\(/gm,
13
+ k6: /\bcheck\s*\(/gm,
14
+ };
15
+ const ASSERTIONS = {
16
+ // `assert x`, pytest.raises, unittest self.assert*, AND a call to any
17
+ // assert*-named helper (e.g. `assert_valid(...)`) — the last so a test that
18
+ // delegates its check to a custom assert helper isn't misread as asserting
19
+ // nothing. (`\bassert\b` alone does NOT match `assert_valid`: `_` is a word
20
+ // char, so the `\b` after `assert` fails there.)
21
+ pytest: /\bassert\b|\bpytest\.raises\b|\bself\.assert\w+\b|\bassert\w*\s*\(/g,
22
+ playwright: /\bexpect\s*\(|\btoBeVisible\b|\btoHaveText\b|\btoHaveTitle\b|\btoHaveURL\b|\btoBeEnabled\b|\btoBeDisabled\b|\btoBeChecked\b|\btoHaveValue\b|\btoHaveCount\b/g,
23
+ // Plus non-`expect` assertion styles common in JS/TS: node:assert / vitest
24
+ // `assert(...)` / `assert.equal(...)`, and chai BDD `x.should.equal`.
25
+ vitest: /\bexpect\s*\(|\btoBe\s*\(|\btoEqual\s*\(|\btoThrow\b|\btoContain\s*\(|\btoBeNull\b|\btoBeUndefined\b|\btoMatchObject\b|\bassert\s*\(|\bassert\.\w+|\.should\b/g,
26
+ k6: /\bcheck\s*\(|'[^']+'\s*:\s*\([^)]*\)\s*=>/g,
27
+ };
28
+ const NEGATIVE_KW = /\b(error|invalid|empty|null|undefined|throws|raises|exception|fail|missing|negative|reject|4\d{2}|5\d{2}|boundary|edge)\b/i;
29
+ const PARAMETRIZE = /@pytest\.mark\.parametrize|test\.each\s*\(|describe\.each\s*\(|it\.each\s*\(/;
30
+ const SLEEP = /time\.sleep\s*\(|page\.waitForTimeout\s*\(|await\s+new\s+Promise[^)]*setTimeout|setTimeout\s*\(/g;
31
+ const RANDOM = /Math\.random\s*\(|random\.random\s*\(|random\.choice\s*\(|random\.randint\s*\(/;
32
+ const TIMESTAMP = /Date\.now\s*\(|datetime\.now\s*\(|datetime\.utcnow\s*\(/;
33
+ // Blank out string contents before magic-number scanning.
34
+ const STRING_LITERAL = /(['"])(?:\\.|(?!\1).)*?\1/g;
35
+ const NUMERIC_LITERAL = /(?<![\w.])-?\d+(?:\.\d+)?(?![\w.])/g;
36
+ const ALLOWED_NUMBERS = new Set(['0', '1', '2', '-1', '10', '100']);
37
+ const HTTP_STATUS = new Set([
38
+ '200',
39
+ '201',
40
+ '202',
41
+ '204',
42
+ '301',
43
+ '302',
44
+ '304',
45
+ '400',
46
+ '401',
47
+ '403',
48
+ '404',
49
+ '405',
50
+ '409',
51
+ '410',
52
+ '422',
53
+ '429',
54
+ '500',
55
+ '501',
56
+ '502',
57
+ '503',
58
+ '504',
59
+ ]);
60
+ const MAX_MAGIC_FINDINGS = 10;
61
+ function countMatches(re, code) {
62
+ return (code.match(re) ?? []).length;
63
+ }
64
+ function isAllowedNumber(token) {
65
+ if (ALLOWED_NUMBERS.has(token) || HTTP_STATUS.has(token))
66
+ return true;
67
+ const bare = token.replace(/^-/, '');
68
+ return /^\d$/.test(bare);
69
+ }
70
+ function detectMagicNumbers(code) {
71
+ const findings = [];
72
+ const lines = code.split('\n');
73
+ for (let i = 0; i < lines.length; i++) {
74
+ const stripped = lines[i].trim();
75
+ if (stripped.startsWith('#') || stripped.startsWith('//'))
76
+ continue;
77
+ const scrubbed = lines[i].replace(STRING_LITERAL, '""');
78
+ for (const m of scrubbed.matchAll(NUMERIC_LITERAL)) {
79
+ const token = m[0];
80
+ if (isAllowedNumber(token))
81
+ continue;
82
+ findings.push(`line ${i + 1}: magic number ${token} — name it or derive it`);
83
+ if (findings.length >= MAX_MAGIC_FINDINGS)
84
+ return findings;
85
+ }
86
+ }
87
+ return findings;
88
+ }
89
+ const COVERAGE_BASE = [0, 25, 45, 65, 80, 90];
90
+ function scoreCoverage(code, framework) {
91
+ const details = [];
92
+ const pattern = TEST_FN[framework] ?? TEST_FN['pytest'];
93
+ const count = countMatches(pattern, code);
94
+ const label = framework === 'k6' ? 'check' : 'test function';
95
+ details.push(`${count} ${label}${count !== 1 ? 's' : ''} found`);
96
+ const base = Math.min(90, COVERAGE_BASE[Math.min(count, 5)]);
97
+ let bonus = 0;
98
+ if (NEGATIVE_KW.test(code)) {
99
+ bonus += 10;
100
+ details.push('Covers error/invalid paths');
101
+ }
102
+ if (PARAMETRIZE.test(code)) {
103
+ bonus += 10;
104
+ details.push('Parametrized test cases detected');
105
+ }
106
+ return [Math.min(100, base + bonus), details];
107
+ }
108
+ function densityScore(density) {
109
+ if (density === 0)
110
+ return 0;
111
+ if (density < 1)
112
+ return 25;
113
+ if (density < 2)
114
+ return 55;
115
+ if (density < 3)
116
+ return 75;
117
+ if (density < 4)
118
+ return 88;
119
+ return 97;
120
+ }
121
+ function scoreAssertions(code, framework) {
122
+ const fnPat = TEST_FN[framework] ?? TEST_FN['pytest'];
123
+ const assertPat = ASSERTIONS[framework] ?? ASSERTIONS['pytest'];
124
+ const testCount = Math.max(1, countMatches(fnPat, code));
125
+ const assertCount = countMatches(assertPat, code);
126
+ const density = assertCount / testCount;
127
+ const details = [
128
+ `${assertCount} assertion${assertCount !== 1 ? 's' : ''}, ${num1(density)} per test`,
129
+ ];
130
+ return [densityScore(density), details];
131
+ }
132
+ function scoreFlakiness(code) {
133
+ const details = [];
134
+ let score = 100;
135
+ const sleepN = countMatches(SLEEP, code);
136
+ if (sleepN) {
137
+ score -= Math.min(40, sleepN * 20);
138
+ details.push(`${sleepN} hardcoded wait${sleepN !== 1 ? 's' : ''} detected`);
139
+ }
140
+ if (RANDOM.test(code)) {
141
+ score -= 15;
142
+ details.push('Non-deterministic random values detected');
143
+ }
144
+ if (TIMESTAMP.test(code)) {
145
+ score -= 10;
146
+ details.push('Timestamp-dependent assertions detected');
147
+ }
148
+ if (details.length === 0)
149
+ details.push('No flakiness signals detected');
150
+ return [Math.max(0, score), details];
151
+ }
152
+ function grade(score) {
153
+ if (score >= 85)
154
+ return 'A';
155
+ if (score >= 70)
156
+ return 'B';
157
+ if (score >= 55)
158
+ return 'C';
159
+ if (score >= 40)
160
+ return 'D';
161
+ return 'F';
162
+ }
163
+ function magicDetails(findings) {
164
+ if (findings.length === 0)
165
+ return ['No magic numbers detected'];
166
+ return [`${findings.length} magic number(s) detected`, ...findings];
167
+ }
168
+ /**
169
+ * True iff `code` defines at least one test function but contains no assertions
170
+ * — a test that asserts nothing.
171
+ *
172
+ * Faithful port of `is_assertion_free_test`. The high-precision "weak test"
173
+ * signal the PR guardian consumes: it requires BOTH a test function (so a
174
+ * non-test helper added to a test file isn't flagged) AND zero assertions. A
175
+ * snapshot or table-driven test still matches an assertion pattern (`expect(` /
176
+ * `assert`), so it is not flagged — keeping false positives, and the trust cost
177
+ * of them, low. Reuses the module's {@link TEST_FN}/{@link ASSERTIONS} maps.
178
+ */
179
+ export function isAssertionFreeTest(code, framework) {
180
+ const fw = framework.toLowerCase();
181
+ const fnPat = TEST_FN[fw] ?? TEST_FN['pytest'];
182
+ const assertPat = ASSERTIONS[fw] ?? ASSERTIONS['pytest'];
183
+ const hasTest = countMatches(fnPat, code) > 0;
184
+ const hasAssertion = countMatches(assertPat, code) > 0;
185
+ return hasTest && !hasAssertion;
186
+ }
187
+ export class QualityScorer {
188
+ /** Score `code` (a source string) for the given framework. */
189
+ score(code, framework) {
190
+ const fw = framework.toLowerCase();
191
+ const [coverage, covDetails] = scoreCoverage(code, fw);
192
+ const [assertion, asrDetails] = scoreAssertions(code, fw);
193
+ const [flakiness, flkDetails] = scoreFlakiness(code);
194
+ const magic = detectMagicNumbers(code);
195
+ const raw = roundHalfEvenInt(0.4 * coverage + 0.4 * assertion + 0.2 * flakiness);
196
+ const composite = Math.max(0, raw - Math.min(15, magic.length * 3));
197
+ return {
198
+ score: composite,
199
+ grade: grade(composite),
200
+ coverage_breadth: coverage,
201
+ assertion_density: assertion,
202
+ flakiness_risk: flakiness,
203
+ magic_numbers: magic.length,
204
+ details: [
205
+ ...covDetails,
206
+ ...asrDetails,
207
+ ...flkDetails,
208
+ ...magicDetails(magic),
209
+ ],
210
+ };
211
+ }
212
+ }
213
+ //# sourceMappingURL=quality-scorer.js.map
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Framework Recommender — picks testing tools for a classification, ranked.
3
+ *
4
+ * Faithful TypeScript port of `agent/core/recommender.py`. Wires the classifier
5
+ * result + the framework registry into a ranked candidate list.
6
+ */
7
+ import { def } from '../util/coalesce.js';
8
+ import { FrameworkRegistry } from './framework-registry.js';
9
+ const MAX_CANDIDATES = 3;
10
+ const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
11
+ /** Whether a framework may surface given the current env license signals. */
12
+ export function licenseAllowed(framework) {
13
+ const gate = framework.license_gate;
14
+ if (!gate)
15
+ return true;
16
+ const value = def(process.env[gate], '').trim();
17
+ if (!value || FALSE_VALUES.has(value.toLowerCase()))
18
+ return false;
19
+ const scopes = framework.license_scopes;
20
+ if (scopes)
21
+ return scopes.includes(value);
22
+ return true;
23
+ }
24
+ const LANG_EXT = {
25
+ python: 'py',
26
+ javascript: 'js',
27
+ typescript: 'ts',
28
+ };
29
+ function fileExtensionFor(f) {
30
+ const exts = def(f.file_extensions, []);
31
+ if (exts.length > 0)
32
+ return exts[0];
33
+ const langs = def(f.languages, []);
34
+ if (langs.length > 0)
35
+ return def(LANG_EXT[langs[0].toLowerCase()], 'ts');
36
+ return 'ts';
37
+ }
38
+ function buildReason(f) {
39
+ const reasons = [];
40
+ reasons.push(...def(f.recommended_for, []));
41
+ reasons.push(...def(f.strengths, []).slice(0, 2));
42
+ if (f.maturity)
43
+ reasons.push(`Maturity level: ${f.maturity}`);
44
+ return reasons;
45
+ }
46
+ function formatCandidate(f, confidence) {
47
+ const candidate = {
48
+ framework: f.name,
49
+ category: def(f.category, ''),
50
+ file_extension: fileExtensionFor(f),
51
+ reason: buildReason(f),
52
+ confidence,
53
+ };
54
+ if (f.license_note) {
55
+ candidate.license = def(f.license, null);
56
+ candidate.warning = f.license_note;
57
+ const label = def(f.license, 'non-OSI license');
58
+ candidate.reason.unshift(`⚠ ${label}: review against your license policy`);
59
+ }
60
+ return candidate;
61
+ }
62
+ export class FrameworkRecommender {
63
+ registry;
64
+ constructor(registry = new FrameworkRegistry()) {
65
+ this.registry = registry;
66
+ }
67
+ recommend(classification, metadata = null, frameworkHint = null) {
68
+ if (classification.test_type === 'observability') {
69
+ return this.recommendObservability(classification);
70
+ }
71
+ let frameworks = this.registry.getByCategory(classification.test_type);
72
+ frameworks = frameworks.filter(licenseAllowed);
73
+ if (frameworks.length === 0)
74
+ return [];
75
+ const candidates = this.applyLanguageFilter(frameworks, metadata);
76
+ const ranked = this.rankPool(candidates, frameworkHint);
77
+ return this.dedupeAndFormat(ranked, frameworkHint, classification.confidence);
78
+ }
79
+ applyLanguageFilter(frameworks, metadata) {
80
+ if (metadata === null)
81
+ return frameworks;
82
+ const detected = new Set(def(metadata.detected_languages, []));
83
+ if (detected.size === 0)
84
+ return frameworks;
85
+ const filtered = frameworks.filter((f) => def(f.languages, []).some((l) => detected.has(l)));
86
+ return filtered.length > 0 ? filtered : frameworks;
87
+ }
88
+ rankPool(candidates, frameworkHint) {
89
+ const hinted = frameworkHint
90
+ ? candidates.find((f) => f.name === frameworkHint.toLowerCase())
91
+ : undefined;
92
+ const preferred = candidates.filter((f) => f.status === 'preferred');
93
+ const rest = candidates.filter((f) => f.status !== 'preferred');
94
+ return [...(hinted ? [hinted] : []), ...preferred, ...rest];
95
+ }
96
+ dedupeAndFormat(pool, frameworkHint, confidence) {
97
+ const hintName = frameworkHint ? frameworkHint.toLowerCase() : null;
98
+ const ranked = [];
99
+ const seen = new Set();
100
+ for (const fw of pool) {
101
+ if (seen.has(fw.name))
102
+ continue;
103
+ seen.add(fw.name);
104
+ const entry = formatCandidate(fw, confidence);
105
+ if (hintName !== null && fw.name === hintName) {
106
+ entry.reason.unshift(`prompt-named framework (${fw.name})`);
107
+ }
108
+ ranked.push(entry);
109
+ if (ranked.length >= MAX_CANDIDATES)
110
+ break;
111
+ }
112
+ return ranked;
113
+ }
114
+ recommendObservability(classification) {
115
+ const confidence = classification.confidence;
116
+ const candidates = [];
117
+ const scope = def(process.env['CANARY_SCOPE'], '').trim();
118
+ if (scope) {
119
+ candidates.push({
120
+ framework: `${scope}-dashboard`,
121
+ category: 'observability',
122
+ file_extension: '',
123
+ reason: [
124
+ `configured aggregation dashboard (CANARY_SCOPE=${scope})`,
125
+ 'overlay reporting sink — receives results in addition to ReportPortal',
126
+ ],
127
+ confidence,
128
+ kind: 'reporting-sink',
129
+ });
130
+ }
131
+ candidates.push({
132
+ framework: 'reportportal',
133
+ category: 'observability',
134
+ file_extension: '',
135
+ reason: [
136
+ 'self-hosted OSS reporting sink — default for observability output',
137
+ ],
138
+ confidence,
139
+ kind: 'reporting-sink',
140
+ });
141
+ const otel = this.registry
142
+ .getByCategory('observability')
143
+ .find((f) => f.name === 'opentelemetry');
144
+ if (otel) {
145
+ const c = formatCandidate(otel, confidence);
146
+ c.kind = 'instrumentation';
147
+ candidates.push(c);
148
+ }
149
+ return candidates.slice(0, MAX_CANDIDATES);
150
+ }
151
+ }
152
+ //# sourceMappingURL=recommender.js.map
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Standardized reporting for Canary execution results.
3
+ *
4
+ * Faithful TypeScript port of `agent/core/reporter.py`. Exports generation and
5
+ * execution results to JSON or SARIF format for consumption by Datadog,
6
+ * SonarQube, GitHub Code Scanning, and similar dashboards.
7
+ *
8
+ * SARIF 2.1.0 spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/
9
+ *
10
+ * Python→TS nuances:
11
+ * - **JSON shape is a contract.** Both serializers mirror Python's
12
+ * `json.dumps(..., indent=2, default=str)`: `JSON.stringify(x, replacer, 2)`
13
+ * with the library-default `ensure_ascii=True` reproduced via
14
+ * {@link ensureAscii} (so an em-dash in a message emits `—`, exactly
15
+ * as the oracle does). Object key insertion order preserves the field order
16
+ * Python emits.
17
+ * - **`default=str`.** Python coerces any value its encoder cannot natively
18
+ * serialize (a `Path`, a `datetime`, ...) via `str()`. JS inputs are already
19
+ * plain JSON objects; the only common value `JSON.stringify` would *throw*
20
+ * on is `BigInt`, which the replacer stringifies. There is no JS analog of a
21
+ * `Path` object, so that specific coercion is not reproducible — see the
22
+ * ported test, which exercises the BigInt path instead.
23
+ * - Python truthiness (`""`/`{}`/`None` falsy) via {@link pyTruthy}; missing
24
+ * dict keys via {@link pyGet}.
25
+ */
26
+ import { mkdirSync, writeFileSync } from 'node:fs';
27
+ import { dirname } from 'node:path';
28
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
29
+ const TOOL_NAME = 'Canary';
30
+ const TOOL_VERSION = '0.1.0';
31
+ const TOOL_URI = 'https://github.com/bop-clocktower/canary';
32
+ const RULES = [
33
+ {
34
+ id: 'canary/test-generation',
35
+ name: 'TestGeneration',
36
+ shortDescription: { text: 'AI-generated test file' },
37
+ helpUri: TOOL_URI,
38
+ },
39
+ {
40
+ id: 'canary/test-execution',
41
+ name: 'TestExecution',
42
+ shortDescription: { text: 'Automated test execution result' },
43
+ helpUri: TOOL_URI,
44
+ },
45
+ ];
46
+ export const SUPPORTED_FORMATS = ['json', 'sarif'];
47
+ // ---------------------------------------------------------------------------
48
+ // Python-compatibility helpers (mirrors `guardian/diff-extractor.ts`)
49
+ // ---------------------------------------------------------------------------
50
+ /**
51
+ * Python-truthiness for JSON-shaped values: `None`/`undefined`, `false`, `0`,
52
+ * `""`, empty array, and empty object are all falsy (mirrors `if x:`).
53
+ */
54
+ function pyTruthy(value) {
55
+ if (value === null || value === undefined || value === false)
56
+ return false;
57
+ if (value === 0 || value === '')
58
+ return false;
59
+ if (Array.isArray(value))
60
+ return value.length > 0;
61
+ if (typeof value === 'object')
62
+ return Object.keys(value).length > 0;
63
+ return Boolean(value);
64
+ }
65
+ /** Python `a or b`: the fallback wins only when `a` is falsy. */
66
+ function pyOr(value, fallback) {
67
+ return pyTruthy(value) ? value : fallback;
68
+ }
69
+ /** Python `dict.get(key, default)`: default only on a missing key. */
70
+ function pyGet(obj, key, fallback) {
71
+ return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
72
+ }
73
+ /**
74
+ * Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default)
75
+ * on `JSON.stringify` output: escape every code point >= 0x80 as `\uXXXX`. Only
76
+ * touches the >= 0x80 range, so the ASCII escapes `JSON.stringify` already
77
+ * produced are left intact. (Same helper as `guardian/pr-check.ts`.)
78
+ */
79
+ function ensureAscii(json) {
80
+ return json.replace(/[€-￿]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
81
+ }
82
+ /**
83
+ * `json.dumps(default=str)` replacer. Values the encoder can't natively handle
84
+ * are coerced via `str()`; in JS the only common such value that would
85
+ * otherwise *throw* is `BigInt`, which we stringify.
86
+ */
87
+ function pyDefaultStr(_key, value) {
88
+ return typeof value === 'bigint' ? value.toString() : value;
89
+ }
90
+ /** Converts Canary pipeline results into standardized report formats. */
91
+ export class Reporter {
92
+ /**
93
+ * Serialize `result` to `fmt` ('json' or 'sarif') and write to disk; throws
94
+ * `Error` (Python `ValueError`) for unsupported formats. Returns the written
95
+ * path (Python returns a `Path`; here a string).
96
+ */
97
+ write(result, fmt, outputPath) {
98
+ if (!SUPPORTED_FORMATS.includes(fmt)) {
99
+ throw new Error(`Unsupported report format '${fmt}'. ` +
100
+ `Choose from: ${SUPPORTED_FORMATS.join(', ')}`);
101
+ }
102
+ const path = pyTruthy(outputPath)
103
+ ? outputPath
104
+ : `canary-report.${fmt}`;
105
+ const content = fmt === 'json' ? this.toJson(result) : this.toSarif(result);
106
+ mkdirSync(dirname(path), { recursive: true });
107
+ writeFileSync(path, content, 'utf-8');
108
+ return path;
109
+ }
110
+ /** Serialize `result` as pretty-printed JSON. */
111
+ toJson(result) {
112
+ return ensureAscii(JSON.stringify(result, pyDefaultStr, 2));
113
+ }
114
+ /** Serialize `result` as SARIF 2.1.0 JSON. */
115
+ toSarif(result) {
116
+ const sarif = {
117
+ version: '2.1.0',
118
+ $schema: SARIF_SCHEMA,
119
+ runs: [this.buildRun(result)],
120
+ };
121
+ return ensureAscii(JSON.stringify(sarif, pyDefaultStr, 2));
122
+ }
123
+ // --------------------------------------------------------------------
124
+ // SARIF construction
125
+ // --------------------------------------------------------------------
126
+ buildRun(result) {
127
+ return {
128
+ tool: {
129
+ driver: {
130
+ name: TOOL_NAME,
131
+ version: TOOL_VERSION,
132
+ informationUri: TOOL_URI,
133
+ rules: RULES,
134
+ },
135
+ },
136
+ results: this.buildResults(result),
137
+ };
138
+ }
139
+ buildResults(result) {
140
+ const sarifResults = [];
141
+ const outputFile = pyGet(result, 'output_file', '');
142
+ const testType = pyGet(result, 'test_type', 'unknown');
143
+ const framework = pyGet(result, 'framework', 'unknown');
144
+ // Generation result — always present.
145
+ const genProps = {
146
+ framework,
147
+ test_type: testType,
148
+ reasoning: pyGet(result, 'reasoning', []),
149
+ };
150
+ if (pyTruthy(pyGet(result, 'quality', null))) {
151
+ genProps['quality'] = result['quality'];
152
+ }
153
+ sarifResults.push({
154
+ ruleId: 'canary/test-generation',
155
+ message: {
156
+ text: `Generated ${String(framework)} test (${String(testType)})` +
157
+ (pyTruthy(outputFile) ? ` — ${String(outputFile)}` : ''),
158
+ },
159
+ level: 'none',
160
+ locations: pyTruthy(outputFile) ? [location(String(outputFile))] : [],
161
+ properties: genProps,
162
+ });
163
+ // Execution result — only if the test was run.
164
+ const execution = pyGet(result, 'execution', null);
165
+ if (pyTruthy(execution)) {
166
+ const exec = execution;
167
+ const exitCode = pyGet(exec, 'exit_code', -1);
168
+ const passed = exitCode === 0;
169
+ const fixed = pyGet(exec, 'fixed', false);
170
+ const messageParts = [
171
+ `Test execution ${passed ? 'passed' : 'failed'} ` +
172
+ `(exit code ${String(exitCode)})`,
173
+ ];
174
+ if (pyTruthy(fixed)) {
175
+ messageParts.push('Self-healed after initial failure.');
176
+ }
177
+ if (!passed) {
178
+ const stderr = pyOr(pyGet(exec, 'stderr', null), pyGet(exec, 'stdout', ''));
179
+ if (pyTruthy(stderr)) {
180
+ const s = String(stderr);
181
+ // Truncate long error output for readability in dashboards. Python
182
+ // `stderr[:300]` and `len(stderr) > 300` count code points; JS slice
183
+ // and .length count UTF-16 units, so astral chars would truncate
184
+ // early and flip the >300 guard. Count/slice by code point instead.
185
+ const chars = Array.from(s);
186
+ const preview = chars.slice(0, 300).join('') + (chars.length > 300 ? '…' : '');
187
+ messageParts.push(`Error: ${preview}`);
188
+ }
189
+ }
190
+ sarifResults.push({
191
+ ruleId: 'canary/test-execution',
192
+ message: { text: messageParts.join(' ') },
193
+ level: passed ? 'none' : 'error',
194
+ locations: pyTruthy(outputFile) ? [location(String(outputFile))] : [],
195
+ properties: {
196
+ exit_code: exitCode,
197
+ fixed,
198
+ },
199
+ });
200
+ }
201
+ return sarifResults;
202
+ }
203
+ }
204
+ function location(uri) {
205
+ return {
206
+ physicalLocation: {
207
+ artifactLocation: { uri, uriBaseId: '%SRCROOT%' },
208
+ },
209
+ };
210
+ }
211
+ //# sourceMappingURL=reporter.js.map