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,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve tracked overlays for `canary migrate --from`.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/overlays.py`. Tracked overlays are git
|
|
5
|
+
* clones under `~/.canary/overlays/<name>/` created by `canary overlay add`.
|
|
6
|
+
* This module maps a `--from` value to a clone path.
|
|
7
|
+
*
|
|
8
|
+
* Cross-runtime contract (matches the Phase 1 skill loader): the clone
|
|
9
|
+
* **directories** are the source of truth. `overlays.json` is written solely by
|
|
10
|
+
* the TS side and is read here **only** to order overlay names when it parses —
|
|
11
|
+
* its absence or corruption never blocks resolution (a directory scan is the
|
|
12
|
+
* fallback).
|
|
13
|
+
*
|
|
14
|
+
* Disambiguation: a value containing a path separator (`./o`, `../o`, `/abs/o`)
|
|
15
|
+
* is a **path**; a bare token (`example-org-example-overlay`) is an overlay
|
|
16
|
+
* **name**. A bare token is never treated as a path, so a same-named directory
|
|
17
|
+
* in the current working directory cannot shadow a tracked overlay, and a
|
|
18
|
+
* mistyped name fails loudly instead of silently resolving to a stray path.
|
|
19
|
+
*
|
|
20
|
+
* Python→TS nuances:
|
|
21
|
+
* - `Path.resolve()` (absolute + symlink resolution + normalize) maps to
|
|
22
|
+
* `fs.realpathSync`, so a macOS `/var` → `/private/var` symlink resolves the
|
|
23
|
+
* same on both sides.
|
|
24
|
+
* - Python's `OverlayNotFound.name` attribute (the overlay name) is exposed as
|
|
25
|
+
* {@link OverlayNotFound.overlayName} — `Error.name` is reserved for the
|
|
26
|
+
* class name in JS.
|
|
27
|
+
*/
|
|
28
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, } from 'node:fs';
|
|
29
|
+
import { homedir } from 'node:os';
|
|
30
|
+
import { join, sep } from 'node:path';
|
|
31
|
+
/** A `--from` value did not resolve to an overlay (bad name or missing path). */
|
|
32
|
+
export class OverlayNotFound extends Error {
|
|
33
|
+
overlayName;
|
|
34
|
+
available;
|
|
35
|
+
constructor(name, available) {
|
|
36
|
+
const hint = available.length
|
|
37
|
+
? 'tracked overlays: ' + available.join(', ')
|
|
38
|
+
: 'no overlays are tracked — add one with `canary overlay add <source>`';
|
|
39
|
+
super(`could not resolve overlay '${name}' (${hint})`);
|
|
40
|
+
this.name = 'OverlayNotFound';
|
|
41
|
+
this.overlayName = name;
|
|
42
|
+
this.available = available;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function overlaysRoot(home) {
|
|
46
|
+
return join(home, '.canary', 'overlays');
|
|
47
|
+
}
|
|
48
|
+
/** Overlay names in `overlays.json` order, or `[]` when it is unreadable. */
|
|
49
|
+
function registryOrder(home) {
|
|
50
|
+
const registry = join(home, '.canary', 'overlays.json');
|
|
51
|
+
let data;
|
|
52
|
+
try {
|
|
53
|
+
data = JSON.parse(readFileSync(registry, 'utf-8'));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
if (!isRecord(data))
|
|
59
|
+
return [];
|
|
60
|
+
const overlays = data['overlays'];
|
|
61
|
+
if (!Array.isArray(overlays))
|
|
62
|
+
return [];
|
|
63
|
+
const names = [];
|
|
64
|
+
for (const o of overlays) {
|
|
65
|
+
if (isRecord(o) && typeof o['name'] === 'string') {
|
|
66
|
+
names.push(o['name']);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return names;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Map overlay name -> declared precedence from `overlays.json` (#333).
|
|
73
|
+
*
|
|
74
|
+
* A null/absent/non-numeric precedence is 0. An unreadable or malformed registry
|
|
75
|
+
* yields an empty map, so callers fall back to directory-name order. Higher
|
|
76
|
+
* precedence wins a skill-name collision — the winner rule mirrors the TS side
|
|
77
|
+
* (`npm/src/overlay-conflicts.ts`) so both runtimes agree.
|
|
78
|
+
*/
|
|
79
|
+
export function registryPrecedence(home) {
|
|
80
|
+
const h = home ?? homedir();
|
|
81
|
+
const registry = join(h, '.canary', 'overlays.json');
|
|
82
|
+
let data;
|
|
83
|
+
try {
|
|
84
|
+
data = JSON.parse(readFileSync(registry, 'utf-8'));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
if (!isRecord(data))
|
|
90
|
+
return {};
|
|
91
|
+
const overlays = data['overlays'];
|
|
92
|
+
if (!Array.isArray(overlays))
|
|
93
|
+
return {};
|
|
94
|
+
const result = {};
|
|
95
|
+
for (const o of overlays) {
|
|
96
|
+
if (!isRecord(o) || typeof o['name'] !== 'string')
|
|
97
|
+
continue;
|
|
98
|
+
const p = o['precedence'];
|
|
99
|
+
// typeof excludes booleans (JSON `true`), null, undefined, and strings —
|
|
100
|
+
// the JS analog of Python's `isinstance(p, (int, float)) and not
|
|
101
|
+
// isinstance(p, bool)`.
|
|
102
|
+
result[o['name']] = typeof p === 'number' ? p : 0;
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Names of tracked overlays (clone dirs under `~/.canary/overlays/`).
|
|
108
|
+
*
|
|
109
|
+
* Ordered by `overlays.json` when it parses (on-disk overlays not listed there
|
|
110
|
+
* are appended in sorted order); otherwise a plain sorted directory scan.
|
|
111
|
+
* Returns `[]` when no overlays root exists.
|
|
112
|
+
*/
|
|
113
|
+
export function listOverlays(home) {
|
|
114
|
+
const h = home ?? homedir();
|
|
115
|
+
const root = overlaysRoot(h);
|
|
116
|
+
if (!isDir(root))
|
|
117
|
+
return [];
|
|
118
|
+
const onDisk = new Set(listDirs(root));
|
|
119
|
+
const ordered = registryOrder(h).filter((name) => onDisk.has(name));
|
|
120
|
+
const orderedSet = new Set(ordered);
|
|
121
|
+
const extras = [...onDisk].filter((name) => !orderedSet.has(name)).sort();
|
|
122
|
+
return [...ordered, ...extras];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a `--from` value to an overlay clone path.
|
|
126
|
+
*
|
|
127
|
+
* A value with a path separator is resolved as a path (which must exist). A bare
|
|
128
|
+
* token is looked up as a tracked-overlay name under `~/.canary/overlays/`.
|
|
129
|
+
* Either miss throws {@link OverlayNotFound} carrying the available names.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveOverlay(nameOrPath, home) {
|
|
132
|
+
const h = home ?? homedir();
|
|
133
|
+
if (looksLikePath(nameOrPath)) {
|
|
134
|
+
if (existsSync(nameOrPath))
|
|
135
|
+
return realpathSync(nameOrPath);
|
|
136
|
+
throw new OverlayNotFound(nameOrPath, listOverlays(h));
|
|
137
|
+
}
|
|
138
|
+
const candidate = join(overlaysRoot(h), nameOrPath);
|
|
139
|
+
if (isDir(candidate))
|
|
140
|
+
return realpathSync(candidate);
|
|
141
|
+
throw new OverlayNotFound(nameOrPath, listOverlays(h));
|
|
142
|
+
}
|
|
143
|
+
function looksLikePath(value) {
|
|
144
|
+
const separators = new Set([sep]);
|
|
145
|
+
// Python's `os.altsep` is "/" on Windows, unset on POSIX.
|
|
146
|
+
if (process.platform === 'win32')
|
|
147
|
+
separators.add('/');
|
|
148
|
+
for (const s of separators) {
|
|
149
|
+
if (value.includes(s))
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
// ── small helpers ─────────────────────────────────────────────────────────
|
|
155
|
+
function isRecord(value) {
|
|
156
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
157
|
+
}
|
|
158
|
+
function isDir(path) {
|
|
159
|
+
try {
|
|
160
|
+
return statSync(path).isDirectory();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function listDirs(path) {
|
|
167
|
+
try {
|
|
168
|
+
return readdirSync(path, { withFileTypes: true })
|
|
169
|
+
.filter((e) => e.isDirectory())
|
|
170
|
+
.map((e) => e.name);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=overlays.js.map
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern-based test healer.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/pattern_healer.py`. Applies
|
|
5
|
+
* regex-safe, deterministic fixes to test files without an LLM. Powers
|
|
6
|
+
* `canary heal-test --pattern`.
|
|
7
|
+
*
|
|
8
|
+
* Only fixes that are unambiguously correct are applied automatically:
|
|
9
|
+
* - Hardcoded sleeps → replaced with a TODO comment pointing at event-based
|
|
10
|
+
* waits.
|
|
11
|
+
* - Missing `await` before Playwright action calls.
|
|
12
|
+
*
|
|
13
|
+
* Selector fixes are NOT auto-applied — swapping a selector without the actual
|
|
14
|
+
* DOM snapshot produces wrong fixes. Selector issues are flagged but left for
|
|
15
|
+
* the developer (or the /canary-heal-test slash command) to fix.
|
|
16
|
+
*
|
|
17
|
+
* Python→TS nuances: `re.sub` (replace-all) maps to `String.replace` with a `g`
|
|
18
|
+
* flag; `re.MULTILINE` maps to `m`. The greedy `\s*$` backtracking that
|
|
19
|
+
* governs whether a trailing newline is consumed is identical across both
|
|
20
|
+
* engines, so `before`/line-number bookkeeping matches byte-for-byte.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Result types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export class HealChange {
|
|
27
|
+
line;
|
|
28
|
+
rule;
|
|
29
|
+
before;
|
|
30
|
+
after;
|
|
31
|
+
description;
|
|
32
|
+
constructor(line, rule, before, after, description) {
|
|
33
|
+
this.line = line;
|
|
34
|
+
this.rule = rule;
|
|
35
|
+
this.before = before;
|
|
36
|
+
this.after = after;
|
|
37
|
+
this.description = description;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class HealResult {
|
|
41
|
+
file;
|
|
42
|
+
changes;
|
|
43
|
+
skipped;
|
|
44
|
+
patched_content;
|
|
45
|
+
constructor(file) {
|
|
46
|
+
this.file = file;
|
|
47
|
+
this.changes = [];
|
|
48
|
+
this.skipped = [];
|
|
49
|
+
this.patched_content = '';
|
|
50
|
+
}
|
|
51
|
+
get changed() {
|
|
52
|
+
return this.changes.length > 0;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Fix rules
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// NOTE on anchors: Python's `re.MULTILINE` treats ONLY `\n` as a line
|
|
59
|
+
// boundary, but JS caret/dollar under /m also break on lone CR and
|
|
60
|
+
// U+2028/U+2029. Under /g that would let these rules fire on lone-CR /
|
|
61
|
+
// where the oracle matches nothing — and since `apply()` rewrites the file,
|
|
62
|
+
// that silently changes content. So we anchor on `\n` explicitly —
|
|
63
|
+
// `(?:^|(?<=\n))` for line start, `(?=\n|$)` for line end — and drop `/m`.
|
|
64
|
+
// time.sleep(N) → # TODO: replace with event-based wait
|
|
65
|
+
const PY_SLEEP = /(?:^|(?<=\n))(\s*)time\.sleep\s*\([^)]*\)\s*(?=\n|$)/g;
|
|
66
|
+
// page.waitForTimeout(N) → # TODO: replace with event-based wait
|
|
67
|
+
const PW_WAIT_TIMEOUT = /(?:^|(?<=\n))(\s*)(await\s+)?page\.waitForTimeout\s*\([^)]*\)\s*;?\s*(?=\n|$)/g;
|
|
68
|
+
// Missing await before Playwright action — line starts with indentation then
|
|
69
|
+
// page/frame/locator.<action>( without a leading await
|
|
70
|
+
const BARE_PW_ACTION = /(?:^|(?<=\n))(\s*)((?:page|frame|locator)\.(?:click|fill|type|check|uncheck|selectOption|hover|focus|press|tap|dblclick)\s*\([^)]*\))/g;
|
|
71
|
+
// Brittle-selector detection (flag-only; never auto-fixed).
|
|
72
|
+
const SELECTOR_SKIP = new RegExp(`['"]\\.[a-zA-Z][\\w\\-]*['"]|['"]#[a-zA-Z][\\w\\-]*['"]|['"]/+[a-zA-Z\\[\\]/@*]`);
|
|
73
|
+
/** Python `code[:m.start()].count("\n") + 1`. */
|
|
74
|
+
function lineOf(code, index) {
|
|
75
|
+
let n = 1;
|
|
76
|
+
for (let i = 0; i < index; i++) {
|
|
77
|
+
if (code[i] === '\n')
|
|
78
|
+
n++;
|
|
79
|
+
}
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
/** Python `str.rstrip("\n")`. */
|
|
83
|
+
function rstripNewlines(s) {
|
|
84
|
+
return s.replace(/\n+$/, '');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Python `str[:n]` slices by code point; JS `slice` slices by UTF-16 unit, so
|
|
88
|
+
* an astral char (e.g. an emoji) counts as 2 and truncates early. Slice by code
|
|
89
|
+
* point to match the oracle.
|
|
90
|
+
*/
|
|
91
|
+
function pySlice(s, n) {
|
|
92
|
+
return Array.from(s).slice(0, n).join('');
|
|
93
|
+
}
|
|
94
|
+
function fixPySleep(code, changes) {
|
|
95
|
+
return code.replace(PY_SLEEP, (full, indent, offset) => {
|
|
96
|
+
const before = rstripNewlines(full);
|
|
97
|
+
const after = `${indent}# TODO(canary): replace with an event-based wait (e.g. waitFor, wait_for_selector)`;
|
|
98
|
+
changes.push(new HealChange(lineOf(code, offset), 'HEAL-001', before, after, 'Replaced time.sleep() with a TODO comment.'));
|
|
99
|
+
return after + '\n';
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function fixPwWaitTimeout(code, changes) {
|
|
103
|
+
return code.replace(PW_WAIT_TIMEOUT, (full, indent, _await, offset) => {
|
|
104
|
+
const before = rstripNewlines(full);
|
|
105
|
+
const after = `${indent}// TODO(canary): replace with an event-based wait (e.g. await expect(locator).toBeVisible())`;
|
|
106
|
+
changes.push(new HealChange(lineOf(code, offset), 'HEAL-002', before, after, 'Replaced page.waitForTimeout() with a TODO comment.'));
|
|
107
|
+
return after + '\n';
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function fixMissingAwait(code, changes) {
|
|
111
|
+
return code.replace(BARE_PW_ACTION, (full, indent, call, offset) => {
|
|
112
|
+
const before = rstripNewlines(full);
|
|
113
|
+
const after = `${indent}await ${call}`;
|
|
114
|
+
changes.push(new HealChange(lineOf(code, offset), 'HEAL-003', before, after, `Added missing \`await\` before \`${pySlice(call, 40)}\`.`));
|
|
115
|
+
return after;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Public API
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
/** Applies deterministic, regex-safe fixes to test files. */
|
|
122
|
+
export class PatternHealer {
|
|
123
|
+
heal(path) {
|
|
124
|
+
const original = readFileSync(path, 'utf-8');
|
|
125
|
+
const result = new HealResult(path);
|
|
126
|
+
let code = original;
|
|
127
|
+
code = fixPySleep(code, result.changes);
|
|
128
|
+
code = fixPwWaitTimeout(code, result.changes);
|
|
129
|
+
code = fixMissingAwait(code, result.changes);
|
|
130
|
+
result.patched_content = code;
|
|
131
|
+
// Note what we deliberately skip.
|
|
132
|
+
if (SELECTOR_SKIP.test(original)) {
|
|
133
|
+
result.skipped.push('Brittle selectors detected but not auto-fixed — selector swaps require DOM context. ' +
|
|
134
|
+
'Use /canary-heal-test in Claude Code for selector repair.');
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
/** Heal and write the result back to disk. */
|
|
139
|
+
apply(path) {
|
|
140
|
+
const result = this.heal(path);
|
|
141
|
+
if (result.changed) {
|
|
142
|
+
writeFileSync(path, result.patched_content, 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=pattern-healer.js.map
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern matcher — extracts a project's existing test conventions.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/core/pattern_matcher.py`. Scans a project
|
|
5
|
+
* for existing test files and summarises naming/import/assertion conventions so
|
|
6
|
+
* generated tests match. Uses a dependency-free recursive walk with the same
|
|
7
|
+
* glob tails and ignore-dir set as the Python original.
|
|
8
|
+
*/
|
|
9
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
+
import { extname, join, resolve } from 'node:path';
|
|
11
|
+
const FILE_PATTERNS = {
|
|
12
|
+
playwright: ['**/*.spec.ts', '**/*.spec.js', '**/*.test.ts', '**/*.test.js'],
|
|
13
|
+
vitest: ['**/*.test.ts', '**/*.test.js', '**/*.spec.ts', '**/*.spec.js'],
|
|
14
|
+
pytest: ['**/test_*.py', '**/*_test.py'],
|
|
15
|
+
k6: ['**/*.load.js', '**/load.js'],
|
|
16
|
+
e2e_ui: ['**/*.spec.ts', '**/*.spec.js'],
|
|
17
|
+
frontend_unit: ['**/*.test.ts', '**/*.test.js'],
|
|
18
|
+
api: ['**/test_*.py', '**/*_test.py'],
|
|
19
|
+
performance: ['**/*.load.js'],
|
|
20
|
+
python_unit: ['**/test_*.py', '**/*_test.py'],
|
|
21
|
+
};
|
|
22
|
+
const DEFAULT_PATTERNS = ['**/test_*.py', '**/*.spec.ts', '**/*.test.ts'];
|
|
23
|
+
const IGNORED_DIRS = new Set([
|
|
24
|
+
'node_modules',
|
|
25
|
+
'.git',
|
|
26
|
+
'__pycache__',
|
|
27
|
+
'.venv',
|
|
28
|
+
'venv',
|
|
29
|
+
'dist',
|
|
30
|
+
'build',
|
|
31
|
+
'.next',
|
|
32
|
+
'.nuxt',
|
|
33
|
+
]);
|
|
34
|
+
const MAX_FILES = 10;
|
|
35
|
+
const MAX_FILE_BYTES = 32_768;
|
|
36
|
+
function emptyProfile() {
|
|
37
|
+
return {
|
|
38
|
+
test_count: 0,
|
|
39
|
+
language: '',
|
|
40
|
+
naming_style: '',
|
|
41
|
+
assertion_style: '',
|
|
42
|
+
uses_classes: false,
|
|
43
|
+
uses_fixtures: false,
|
|
44
|
+
uses_describe: false,
|
|
45
|
+
common_imports: [],
|
|
46
|
+
sample_names: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Convert a recursive-glob pattern to a basename regex (`*` becomes `[^/]*`). */
|
|
50
|
+
function tailRegex(pattern) {
|
|
51
|
+
const tail = pattern.replace(/^\*\*\//, '');
|
|
52
|
+
const body = tail
|
|
53
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
54
|
+
.replace(/\*/g, '[^/]*');
|
|
55
|
+
return new RegExp(`^${body}$`);
|
|
56
|
+
}
|
|
57
|
+
/** `Counter.most_common(n)`: count desc, ties in first-seen order. */
|
|
58
|
+
function mostCommon(items, n) {
|
|
59
|
+
const counts = new Map();
|
|
60
|
+
for (const it of items)
|
|
61
|
+
counts.set(it, (counts.get(it) ?? 0) + 1);
|
|
62
|
+
return [...counts.entries()]
|
|
63
|
+
.map(([key], i) => ({ key, count: counts.get(key), i }))
|
|
64
|
+
.sort((a, b) => b.count - a.count || a.i - b.i)
|
|
65
|
+
.slice(0, n)
|
|
66
|
+
.map((e) => e.key);
|
|
67
|
+
}
|
|
68
|
+
function walk(dir, out) {
|
|
69
|
+
let entries;
|
|
70
|
+
try {
|
|
71
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (e.isDirectory()) {
|
|
78
|
+
if (IGNORED_DIRS.has(e.name))
|
|
79
|
+
continue;
|
|
80
|
+
walk(join(dir, e.name), out);
|
|
81
|
+
}
|
|
82
|
+
else if (e.isFile()) {
|
|
83
|
+
out.push(join(dir, e.name));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function findTestFiles(root, framework, testType) {
|
|
88
|
+
const patterns = FILE_PATTERNS[framework] ?? FILE_PATTERNS[testType] ?? DEFAULT_PATTERNS;
|
|
89
|
+
const regexes = patterns.map(tailRegex);
|
|
90
|
+
const all = [];
|
|
91
|
+
walk(root, all);
|
|
92
|
+
const found = [];
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
for (const path of all) {
|
|
95
|
+
const base = path.slice(path.lastIndexOf('/') + 1);
|
|
96
|
+
if (!regexes.some((re) => re.test(base)))
|
|
97
|
+
continue;
|
|
98
|
+
if (seen.has(path))
|
|
99
|
+
continue;
|
|
100
|
+
try {
|
|
101
|
+
if (statSync(path).size > MAX_FILE_BYTES)
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
seen.add(path);
|
|
108
|
+
found.push(path);
|
|
109
|
+
}
|
|
110
|
+
return found.sort();
|
|
111
|
+
}
|
|
112
|
+
function readText(path) {
|
|
113
|
+
try {
|
|
114
|
+
return readFileSync(path, 'utf-8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// --- Python analysis -------------------------------------------------------
|
|
121
|
+
const PY_IMPORT = /^(?:from\s+([\w.]+)\s+import|import\s+([\w.,\s]+))/gm;
|
|
122
|
+
const PY_TEST_FN = /^\s*def\s+(test_\w+)/gm;
|
|
123
|
+
const PY_CLASS = /^\s*class\s+Test\w+/m;
|
|
124
|
+
const PY_FIXTURE = /@pytest\.fixture|conftest/;
|
|
125
|
+
function pythonNamingStyle(names, usesClasses) {
|
|
126
|
+
if (names.length === 0) {
|
|
127
|
+
return `${usesClasses ? 'class-based' : 'function-based'} pytest tests`;
|
|
128
|
+
}
|
|
129
|
+
const bodies = names
|
|
130
|
+
.filter((n) => n.startsWith('test_'))
|
|
131
|
+
.map((n) => n.slice('test_'.length));
|
|
132
|
+
const should = bodies.filter((b) => b.startsWith('should_')).length;
|
|
133
|
+
const when = bodies.filter((b) => b.startsWith('when_')).length;
|
|
134
|
+
const raises = bodies.filter((b) => b.includes('raises') || b.includes('error')).length;
|
|
135
|
+
let pattern = 'test_<action>_<result> convention';
|
|
136
|
+
if (should > Math.floor(bodies.length / 2))
|
|
137
|
+
pattern = 'test_should_* convention';
|
|
138
|
+
else if (when > Math.floor(bodies.length / 2))
|
|
139
|
+
pattern = 'test_when_* convention';
|
|
140
|
+
else if (raises > Math.floor(bodies.length / 3))
|
|
141
|
+
pattern = 'test_<action>_raises_* convention';
|
|
142
|
+
return `${usesClasses ? 'class-based' : 'function-based'} pytest, ${pattern}`;
|
|
143
|
+
}
|
|
144
|
+
function parsePyImports(text) {
|
|
145
|
+
const mods = [];
|
|
146
|
+
for (const m of text.matchAll(PY_IMPORT)) {
|
|
147
|
+
const mod = (m[1] || m[2] || '').split(/\s+/)[0].replace(/,$/, '');
|
|
148
|
+
if (mod)
|
|
149
|
+
mods.push(mod);
|
|
150
|
+
}
|
|
151
|
+
return mods;
|
|
152
|
+
}
|
|
153
|
+
function analyzePython(files, profile) {
|
|
154
|
+
const imports = [];
|
|
155
|
+
const funcNames = [];
|
|
156
|
+
let hasClasses = false;
|
|
157
|
+
let hasFixtures = false;
|
|
158
|
+
for (const path of files) {
|
|
159
|
+
const text = readText(path);
|
|
160
|
+
if (text === null)
|
|
161
|
+
continue;
|
|
162
|
+
imports.push(...parsePyImports(text));
|
|
163
|
+
for (const m of text.matchAll(PY_TEST_FN))
|
|
164
|
+
funcNames.push(m[1]);
|
|
165
|
+
if (PY_CLASS.test(text))
|
|
166
|
+
hasClasses = true;
|
|
167
|
+
if (PY_FIXTURE.test(text))
|
|
168
|
+
hasFixtures = true;
|
|
169
|
+
}
|
|
170
|
+
profile.uses_classes = hasClasses;
|
|
171
|
+
profile.uses_fixtures = hasFixtures;
|
|
172
|
+
profile.common_imports = mostCommon(imports, 8);
|
|
173
|
+
profile.sample_names = funcNames.slice(0, 5);
|
|
174
|
+
profile.naming_style = pythonNamingStyle(funcNames, hasClasses);
|
|
175
|
+
profile.assertion_style = hasClasses
|
|
176
|
+
? 'unittest self.assert* methods'
|
|
177
|
+
: 'pytest assert statements';
|
|
178
|
+
}
|
|
179
|
+
// --- JS / TS analysis ------------------------------------------------------
|
|
180
|
+
const JS_IMPORT = /^import\s+.*?\s+from\s+['"]([^'"]+)['"]/gm;
|
|
181
|
+
const JS_REQUIRE = /require\(['"]([^'"]+)['"]\)/g;
|
|
182
|
+
const JS_DESCRIBE = /\bdescribe\s*\(/;
|
|
183
|
+
const JS_TEST_NAME = /\b(?:it|test)\s*\(\s*['"]([^'"]{3,60})['"]/g;
|
|
184
|
+
function jsNamingStyle(names, usesDescribe) {
|
|
185
|
+
const structure = usesDescribe
|
|
186
|
+
? 'describe/it blocks'
|
|
187
|
+
: 'top-level test() calls';
|
|
188
|
+
if (names.length === 0)
|
|
189
|
+
return structure;
|
|
190
|
+
const should = names.filter((n) => n.toLowerCase().startsWith('should')).length;
|
|
191
|
+
const sentence = names.filter((n) => /^[A-Z]/.test(n)).length;
|
|
192
|
+
let style = 'imperative names';
|
|
193
|
+
if (should > Math.floor(names.length / 2))
|
|
194
|
+
style = 'should-style names';
|
|
195
|
+
else if (sentence > Math.floor(names.length / 2))
|
|
196
|
+
style = 'sentence-style names';
|
|
197
|
+
return `${structure}, ${style}`;
|
|
198
|
+
}
|
|
199
|
+
function jsAssertionStyle(imports) {
|
|
200
|
+
const relevant = imports.filter((i) => i.includes('expect') || i.includes('chai') || i.includes('assert'));
|
|
201
|
+
if (relevant.some((i) => i.includes('chai')))
|
|
202
|
+
return 'chai expect assertions';
|
|
203
|
+
if (relevant.some((i) => i.includes('assert')))
|
|
204
|
+
return 'assert-style assertions';
|
|
205
|
+
return 'expect().toBe() / toEqual() assertions';
|
|
206
|
+
}
|
|
207
|
+
function analyzeJs(files, profile) {
|
|
208
|
+
const imports = [];
|
|
209
|
+
const testNames = [];
|
|
210
|
+
let hasDescribe = false;
|
|
211
|
+
for (const path of files) {
|
|
212
|
+
const text = readText(path);
|
|
213
|
+
if (text === null)
|
|
214
|
+
continue;
|
|
215
|
+
for (const m of text.matchAll(JS_IMPORT))
|
|
216
|
+
imports.push(m[1]);
|
|
217
|
+
for (const m of text.matchAll(JS_REQUIRE))
|
|
218
|
+
imports.push(m[1]);
|
|
219
|
+
if (JS_DESCRIBE.test(text))
|
|
220
|
+
hasDescribe = true;
|
|
221
|
+
for (const m of text.matchAll(JS_TEST_NAME))
|
|
222
|
+
testNames.push(m[1]);
|
|
223
|
+
}
|
|
224
|
+
profile.uses_describe = hasDescribe;
|
|
225
|
+
profile.common_imports = mostCommon(imports, 8);
|
|
226
|
+
profile.sample_names = testNames.slice(0, 5);
|
|
227
|
+
profile.naming_style = jsNamingStyle(testNames, hasDescribe);
|
|
228
|
+
profile.assertion_style = jsAssertionStyle([...new Set(imports)]);
|
|
229
|
+
}
|
|
230
|
+
export function isEmpty(profile) {
|
|
231
|
+
return profile.test_count === 0;
|
|
232
|
+
}
|
|
233
|
+
export class PatternMatcher {
|
|
234
|
+
scan(projectRoot = '.', framework = '', testType = '') {
|
|
235
|
+
const root = resolve(projectRoot);
|
|
236
|
+
const files = findTestFiles(root, framework, testType);
|
|
237
|
+
if (files.length === 0)
|
|
238
|
+
return emptyProfile();
|
|
239
|
+
const sample = files.slice(0, MAX_FILES);
|
|
240
|
+
let isPython = framework === 'pytest' ||
|
|
241
|
+
testType === 'api' ||
|
|
242
|
+
testType === 'python_unit';
|
|
243
|
+
if (!isPython && sample.length > 0)
|
|
244
|
+
isPython = extname(sample[0]) === '.py';
|
|
245
|
+
const profile = emptyProfile();
|
|
246
|
+
profile.test_count = files.length;
|
|
247
|
+
profile.language = isPython ? 'python' : 'javascript';
|
|
248
|
+
if (isPython)
|
|
249
|
+
analyzePython(sample, profile);
|
|
250
|
+
else
|
|
251
|
+
analyzeJs(sample, profile);
|
|
252
|
+
return profile;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=pattern-matcher.js.map
|