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.
- package/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- 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
|