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