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,647 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canary MCP server - exposes Canary intelligence tools to Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Faithful TypeScript port of `agent/mcp_server.py`. The Python original builds
|
|
5
|
+
* a `FastMCP("canary")` server and registers six `canary__*` tools; this port
|
|
6
|
+
* uses the official MCP TypeScript SDK (`@modelcontextprotocol/sdk`) with
|
|
7
|
+
* `McpServer.registerTool` over a stdio transport.
|
|
8
|
+
*
|
|
9
|
+
* The internal implementation functions (`analyzeFileImpl`, ...) mirror the
|
|
10
|
+
* Python `_*_impl` functions one-for-one and are exported so unit tests can
|
|
11
|
+
* exercise them directly without a live MCP client - exactly as the Python
|
|
12
|
+
* tests call the `_impl` functions. The thin tool wrappers only JSON-wrap the
|
|
13
|
+
* dict the impl returns; the returned dict SHAPE (field names + values) is the
|
|
14
|
+
* MCP contract and is preserved byte-for-byte with the Python oracle.
|
|
15
|
+
*
|
|
16
|
+
* Python->TS nuances:
|
|
17
|
+
* - **AST-free function extraction.** Python `_extract_file_functions`
|
|
18
|
+
* `ast.walk`s a parsed module; there is no Python `ast` in TS, so
|
|
19
|
+
* {@link extractFileFunctions} reproduces the SAME name list via a careful
|
|
20
|
+
* source scan. For `.py` it blanks strings/comments (so a `def` inside a
|
|
21
|
+
* docstring never matches), collects `def`/`async def` names with their
|
|
22
|
+
* indentation depth and source order, then stable-sorts by
|
|
23
|
+
* `(indent, order)` - which reproduces `ast.walk`'s breadth-first ordering
|
|
24
|
+
* for `FunctionDef` nodes (top-level before nested, siblings in source
|
|
25
|
+
* order). For `.ts/.js` it applies the same two regexes as the Python
|
|
26
|
+
* original, functions first then const-arrows. A genuinely unparseable
|
|
27
|
+
* Python file yields best-effort names here rather than Python's `[]`
|
|
28
|
+
* (ast SyntaxError) - the documented cost of having no real parser.
|
|
29
|
+
* - **subprocess.** `_run_tests_impl` delegates to the already-ported
|
|
30
|
+
* {@link CanaryTestExecutor} (spawnSync, maxBuffer:Infinity).
|
|
31
|
+
* - **pathlib -> node:path/fs.** `Path(...).suffix` semantics are reproduced
|
|
32
|
+
* by {@link pySuffix} (empty for a trailing dot or dotfile).
|
|
33
|
+
* - **Python truthiness.** `target_dir or _WORKING_DIR` uses `||`; the env
|
|
34
|
+
* default for `_WORKING_DIR` uses `??` to mirror `os.environ.get(k, cwd)`
|
|
35
|
+
* (an explicitly-empty env var stays `""`).
|
|
36
|
+
* - **ensure_ascii.** Hand-built JSON returned to the MCP host is escaped via
|
|
37
|
+
* {@link ensureAscii} (the reporter.ts pattern) so non-ASCII code points
|
|
38
|
+
* emit `\uXXXX`, matching Python's default `json.dumps`.
|
|
39
|
+
* - **splitlines.** `context_snippets` uses {@link pySplitlines}, which drops
|
|
40
|
+
* a single trailing-newline empty tail exactly as `str.splitlines()` does.
|
|
41
|
+
* - File writes are LF + UTF-8 on every platform (matches the sibling ports).
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
44
|
+
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
45
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
46
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
47
|
+
import * as z from 'zod';
|
|
48
|
+
import { DomainScanner } from './core/domain-scanner.js';
|
|
49
|
+
import { detectEnvironment } from './core/environment-detect.js';
|
|
50
|
+
import { CanaryTestExecutor } from './core/executor.js';
|
|
51
|
+
import { FrameworkRegistry } from './core/framework-registry.js';
|
|
52
|
+
import { HarnessMigrator } from './core/migrator.js';
|
|
53
|
+
import { findTestFiles, PatternMatcher } from './core/pattern-matcher.js';
|
|
54
|
+
import { Scaffolder } from './core/scaffolder.js';
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Module constants (mirror agent/mcp_server.py)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
const SERVER_NAME = 'canary';
|
|
59
|
+
const SERVER_VERSION = '0.1.0';
|
|
60
|
+
/**
|
|
61
|
+
* Python: `_WORKING_DIR = os.environ.get("CLAUDE_PLUGIN_ROOT", os.getcwd())`.
|
|
62
|
+
* `??` (not `||`) mirrors `dict.get(key, default)`: an env var set to `""`
|
|
63
|
+
* keeps `""`; only an unset var falls back to the cwd.
|
|
64
|
+
*/
|
|
65
|
+
const WORKING_DIR = process.env['CLAUDE_PLUGIN_ROOT'] ?? process.cwd();
|
|
66
|
+
const CONFIG_FRAMEWORK_GLOBS = [
|
|
67
|
+
[
|
|
68
|
+
'playwright',
|
|
69
|
+
[
|
|
70
|
+
'playwright.config.ts',
|
|
71
|
+
'playwright.config.js',
|
|
72
|
+
'playwright.config.mts',
|
|
73
|
+
'playwright.config.mjs',
|
|
74
|
+
],
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
'vitest',
|
|
78
|
+
[
|
|
79
|
+
'vitest.config.ts',
|
|
80
|
+
'vitest.config.js',
|
|
81
|
+
'vitest.config.mts',
|
|
82
|
+
'vitest.config.mjs',
|
|
83
|
+
],
|
|
84
|
+
],
|
|
85
|
+
['pytest', ['pytest.ini', 'pyproject.toml']],
|
|
86
|
+
];
|
|
87
|
+
const SUFFIX_FRAMEWORK = {
|
|
88
|
+
'.ts': 'playwright',
|
|
89
|
+
'.tsx': 'playwright',
|
|
90
|
+
'.js': 'playwright',
|
|
91
|
+
'.jsx': 'playwright',
|
|
92
|
+
'.mjs': 'playwright',
|
|
93
|
+
'.py': 'pytest',
|
|
94
|
+
};
|
|
95
|
+
// Cap test-file path list size to keep response payload reasonable.
|
|
96
|
+
const MAX_EXISTING_TESTS = 10;
|
|
97
|
+
// Cap file-local function extraction so a giant file doesn't dominate.
|
|
98
|
+
const MAX_FILE_FUNCTIONS = 20;
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Python-compatibility helpers
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Reproduce Python's `json.dumps(..., ensure_ascii=True)` (the library default):
|
|
104
|
+
* escape every code point >= 0x80 as `\uXXXX`. Copied per-module, matching the
|
|
105
|
+
* reporter.ts / guardian pattern (the regex range is written with `\u....`
|
|
106
|
+
* escapes so this source stays ASCII).
|
|
107
|
+
*/
|
|
108
|
+
function ensureAscii(json) {
|
|
109
|
+
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Split like Python's `str.splitlines()` for the common line endings: splits on
|
|
113
|
+
* `\r\n` / `\r` / `\n` and drops the single empty tail a trailing separator
|
|
114
|
+
* would otherwise leave (so `"a\nb\n"` -> `["a", "b"]`, not `["a", "b", ""]`).
|
|
115
|
+
*/
|
|
116
|
+
function pySplitlines(text) {
|
|
117
|
+
if (text === '')
|
|
118
|
+
return [];
|
|
119
|
+
const parts = text.split(/\r\n|\r|\n/);
|
|
120
|
+
if (parts[parts.length - 1] === '')
|
|
121
|
+
parts.pop();
|
|
122
|
+
return parts;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Python `Path(name).suffix`: the last dotted extension, but empty when the dot
|
|
126
|
+
* is the first character (`.gitignore`) or the last character (`foo.`).
|
|
127
|
+
*/
|
|
128
|
+
function pySuffix(name) {
|
|
129
|
+
const i = name.lastIndexOf('.');
|
|
130
|
+
if (i > 0 && i < name.length - 1)
|
|
131
|
+
return name.slice(i);
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
/** Render a caught value the way Python's `str(e)` renders an exception. */
|
|
135
|
+
function errStr(err) {
|
|
136
|
+
if (err instanceof Error)
|
|
137
|
+
return err.message;
|
|
138
|
+
return String(err);
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Framework / project detection helpers (Python module-level functions)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
/**
|
|
144
|
+
* Return `[framework, source]` where source indicates trust level (Python:
|
|
145
|
+
* `_detect_framework_from_config`). Walks up from `projectRoot` checking for
|
|
146
|
+
* canonical config files. `source` is one of `"config"` (config file found) or
|
|
147
|
+
* `"unknown"` (no signal). `pyproject.toml` only counts when it actually
|
|
148
|
+
* configures pytest (`[tool.pytest` present).
|
|
149
|
+
*/
|
|
150
|
+
/**
|
|
151
|
+
* Match a single directory's canonical config files (Python: the inner loop of
|
|
152
|
+
* `_detect_framework_from_config`). Returns `[framework, "config"]` on the first
|
|
153
|
+
* hit, or `null`. `pyproject.toml` only counts when it configures pytest.
|
|
154
|
+
*/
|
|
155
|
+
function configFrameworkAt(dir) {
|
|
156
|
+
for (const [fw, globs] of CONFIG_FRAMEWORK_GLOBS) {
|
|
157
|
+
for (const name of globs) {
|
|
158
|
+
const candidate = join(dir, name);
|
|
159
|
+
if (!existsSync(candidate))
|
|
160
|
+
continue;
|
|
161
|
+
if (name !== 'pyproject.toml')
|
|
162
|
+
return [fw, 'config'];
|
|
163
|
+
// Confirm pyproject.toml actually configures pytest; otherwise keep
|
|
164
|
+
// looking - pyproject alone is not enough to claim pytest.
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = readFileSync(candidate, 'utf-8');
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (text.includes('[tool.pytest'))
|
|
173
|
+
return [fw, 'config'];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
export function detectFrameworkFromConfig(projectRoot) {
|
|
179
|
+
let cur = resolve(projectRoot);
|
|
180
|
+
// Walk up to filesystem root or .git boundary.
|
|
181
|
+
for (;;) {
|
|
182
|
+
const hit = configFrameworkAt(cur);
|
|
183
|
+
if (hit)
|
|
184
|
+
return hit;
|
|
185
|
+
if (existsSync(join(cur, '.git')))
|
|
186
|
+
break;
|
|
187
|
+
const parent = dirname(cur);
|
|
188
|
+
if (parent === cur)
|
|
189
|
+
break;
|
|
190
|
+
cur = parent;
|
|
191
|
+
}
|
|
192
|
+
return ['unknown', 'unknown'];
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Walk up from `filePath` to the nearest `.git` directory, else return the
|
|
196
|
+
* file's parent (Python: `_project_root_for`). The project boundary is the
|
|
197
|
+
* `.git` root, matching `canary skills list` and the overlay loader.
|
|
198
|
+
*/
|
|
199
|
+
export function projectRootFor(filePath) {
|
|
200
|
+
const start = dirname(resolve(filePath));
|
|
201
|
+
let cur = start;
|
|
202
|
+
for (;;) {
|
|
203
|
+
if (existsSync(join(cur, '.git')))
|
|
204
|
+
return cur;
|
|
205
|
+
const parent = dirname(cur);
|
|
206
|
+
if (parent === cur)
|
|
207
|
+
return start;
|
|
208
|
+
cur = parent;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// File-local function extraction (Python: _extract_file_functions)
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
const TS_LIKE_SUFFIXES = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']);
|
|
215
|
+
/**
|
|
216
|
+
* Blank the interior of every Python string literal and comment, preserving
|
|
217
|
+
* newlines and overall character positions. This neutralizes any `def ...` that
|
|
218
|
+
* appears inside a docstring/string or after a `#`, so the line-based `def`
|
|
219
|
+
* scan below only sees real statements - the guarantee Python's `ast` gives for
|
|
220
|
+
* free. Line structure and indentation are untouched (code is copied verbatim),
|
|
221
|
+
* so a matched `def` line keeps its true indentation depth.
|
|
222
|
+
*/
|
|
223
|
+
/** Blank a `#` comment through end-of-line. Returns the next scan index. */
|
|
224
|
+
function blankComment(src, start, out) {
|
|
225
|
+
let i = start;
|
|
226
|
+
while (i < src.length && src[i] !== '\n') {
|
|
227
|
+
out.push(' ');
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
return i;
|
|
231
|
+
}
|
|
232
|
+
/** Blank one char, preserving a newline so line structure survives. */
|
|
233
|
+
function blankChar(ch, out) {
|
|
234
|
+
out.push(ch === '\n' ? '\n' : ' ');
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Blank a Python string literal starting at `start` (a quote char). Handles
|
|
238
|
+
* single- and triple-quoted forms plus backslash escapes. Returns the index
|
|
239
|
+
* just past the string (or end-of-input / newline for an unterminated
|
|
240
|
+
* single-line string).
|
|
241
|
+
*/
|
|
242
|
+
function blankPyString(src, start, out) {
|
|
243
|
+
const q = src[start];
|
|
244
|
+
const triple = src.substr(start, 3) === q + q + q;
|
|
245
|
+
const delim = triple ? q + q + q : q;
|
|
246
|
+
const dl = delim.length;
|
|
247
|
+
const n = src.length;
|
|
248
|
+
for (let k = 0; k < dl; k++)
|
|
249
|
+
out.push(' ');
|
|
250
|
+
let i = start + dl;
|
|
251
|
+
while (i < n) {
|
|
252
|
+
const c = src[i];
|
|
253
|
+
if (c === '\\' && i + 1 < n) {
|
|
254
|
+
blankChar(c, out);
|
|
255
|
+
blankChar(src[i + 1], out);
|
|
256
|
+
i += 2;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!triple && c === '\n')
|
|
260
|
+
break; // unterminated single-line string
|
|
261
|
+
if (src.substr(i, dl) === delim) {
|
|
262
|
+
for (let k = 0; k < dl; k++)
|
|
263
|
+
out.push(' ');
|
|
264
|
+
return i + dl;
|
|
265
|
+
}
|
|
266
|
+
blankChar(c, out);
|
|
267
|
+
i++;
|
|
268
|
+
}
|
|
269
|
+
return i;
|
|
270
|
+
}
|
|
271
|
+
function blankPyStringsAndComments(src) {
|
|
272
|
+
const out = [];
|
|
273
|
+
let i = 0;
|
|
274
|
+
const n = src.length;
|
|
275
|
+
while (i < n) {
|
|
276
|
+
const c = src[i];
|
|
277
|
+
if (c === '#') {
|
|
278
|
+
i = blankComment(src, i, out);
|
|
279
|
+
}
|
|
280
|
+
else if (c === '"' || c === "'") {
|
|
281
|
+
i = blankPyString(src, i, out);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
out.push(c);
|
|
285
|
+
i++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return out.join('');
|
|
289
|
+
}
|
|
290
|
+
const PY_DEF_RE = /^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)/;
|
|
291
|
+
/**
|
|
292
|
+
* True iff `()`, `[]`, `{}` are balanced across `s` (with string/comment interiors
|
|
293
|
+
* already blanked). Valid Python always balances these outside strings, so an
|
|
294
|
+
* imbalance means the file would raise `SyntaxError` -- where Python's `ast.parse`
|
|
295
|
+
* returns no functions. A cheap proxy for "parseable": it never rejects valid
|
|
296
|
+
* Python (so it introduces no divergence), and it catches the common WIP case
|
|
297
|
+
* (a mid-edit unclosed paren) so we return `[]` like the oracle instead of
|
|
298
|
+
* best-effort names. It does NOT catch every SyntaxError; see the note below.
|
|
299
|
+
*/
|
|
300
|
+
function pyBracketsBalanced(s) {
|
|
301
|
+
const close = { ')': '(', ']': '[', '}': '{' };
|
|
302
|
+
const stack = [];
|
|
303
|
+
for (const ch of s) {
|
|
304
|
+
if (ch === '(' || ch === '[' || ch === '{')
|
|
305
|
+
stack.push(ch);
|
|
306
|
+
else if (ch === ')' || ch === ']' || ch === '}') {
|
|
307
|
+
if (stack.pop() !== close[ch])
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return stack.length === 0;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Collect Python `def`/`async def` names approximating `ast.walk` order via a
|
|
315
|
+
* stable sort of `(indentDepth, sourceOrder)`: a top-level def sorts before a
|
|
316
|
+
* method/nested def, and same-indent defs keep source order. This reproduces the
|
|
317
|
+
* breadth-first order `ast.walk` yields for consistently-indented modules (the
|
|
318
|
+
* formatter norm) -- the verified common case.
|
|
319
|
+
*
|
|
320
|
+
* Accepted limitations vs a real parser (advisory `file_functions` field only,
|
|
321
|
+
* not a machine contract): inconsistent indent WIDTHS across sibling blocks can
|
|
322
|
+
* reorder (indent columns approximate AST depth); a non-ASCII identifier name is
|
|
323
|
+
* missed (`\w` is ASCII); and a file that is unparseable in a way brackets still
|
|
324
|
+
* balance yields best-effort names rather than the oracle's `[]`.
|
|
325
|
+
*/
|
|
326
|
+
function extractPyFunctions(text) {
|
|
327
|
+
// Join backslash-continued physical lines so a `def \\<newline>foo():` header
|
|
328
|
+
// is seen as one logical line (Python's lexer does this).
|
|
329
|
+
const joined = text.replace(/\\\n/g, '');
|
|
330
|
+
const cleaned = blankPyStringsAndComments(joined);
|
|
331
|
+
// Unbalanced brackets -> the module cannot parse -> Python's ast returns [].
|
|
332
|
+
if (!pyBracketsBalanced(cleaned))
|
|
333
|
+
return [];
|
|
334
|
+
const found = [];
|
|
335
|
+
let order = 0;
|
|
336
|
+
for (const line of cleaned.split('\n')) {
|
|
337
|
+
const m = PY_DEF_RE.exec(line);
|
|
338
|
+
if (m)
|
|
339
|
+
found.push({ indent: m[1].length, order, name: m[2] });
|
|
340
|
+
order++;
|
|
341
|
+
}
|
|
342
|
+
found.sort((a, b) => a.indent - b.indent || a.order - b.order);
|
|
343
|
+
return found.map((d) => d.name);
|
|
344
|
+
}
|
|
345
|
+
const TS_FN_RE = /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm;
|
|
346
|
+
const TS_CONST_RE = /^\s*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/gm;
|
|
347
|
+
/**
|
|
348
|
+
* Collect TS/JS `function name(` then `const name = (` names, matching the two
|
|
349
|
+
* Python regexes and their order: all function declarations first (source
|
|
350
|
+
* order), then all const-arrow declarations (source order).
|
|
351
|
+
*/
|
|
352
|
+
function extractTsFunctions(text) {
|
|
353
|
+
const names = [];
|
|
354
|
+
for (const m of text.matchAll(TS_FN_RE))
|
|
355
|
+
names.push(m[1]);
|
|
356
|
+
for (const m of text.matchAll(TS_CONST_RE))
|
|
357
|
+
names.push(m[1]);
|
|
358
|
+
return names;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Best-effort file-local function extraction (Python:
|
|
362
|
+
* `_extract_file_functions`). Falls back to an empty list on read failure.
|
|
363
|
+
*/
|
|
364
|
+
export function extractFileFunctions(filePath) {
|
|
365
|
+
const suffix = extname(filePath).toLowerCase();
|
|
366
|
+
let text;
|
|
367
|
+
try {
|
|
368
|
+
text = readFileSync(filePath, 'utf-8');
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return [];
|
|
372
|
+
}
|
|
373
|
+
let names = [];
|
|
374
|
+
if (suffix === '.py') {
|
|
375
|
+
names = extractPyFunctions(text);
|
|
376
|
+
}
|
|
377
|
+
else if (TS_LIKE_SUFFIXES.has(suffix)) {
|
|
378
|
+
names = extractTsFunctions(text);
|
|
379
|
+
}
|
|
380
|
+
// Dedupe preserving order, then cap.
|
|
381
|
+
const seen = new Set();
|
|
382
|
+
const deduped = [];
|
|
383
|
+
for (const nm of names) {
|
|
384
|
+
if (!seen.has(nm)) {
|
|
385
|
+
seen.add(nm);
|
|
386
|
+
deduped.push(nm);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return deduped.slice(0, MAX_FILE_FUNCTIONS);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Return up to N existing test file paths relative to `projectRoot` (Python:
|
|
393
|
+
* `_find_existing_tests`). Uses the pattern-matcher's discovery; returns an
|
|
394
|
+
* empty list when nothing is found.
|
|
395
|
+
*/
|
|
396
|
+
export function findExistingTests(projectRoot, framework) {
|
|
397
|
+
const files = findTestFiles(projectRoot, framework, '');
|
|
398
|
+
if (files.length === 0)
|
|
399
|
+
return [];
|
|
400
|
+
const out = [];
|
|
401
|
+
for (const f of files.slice(0, MAX_EXISTING_TESTS)) {
|
|
402
|
+
// Python `f.relative_to(project_root)` raises for a non-descendant and
|
|
403
|
+
// falls back to the absolute path; a `..` prefix is the JS analog.
|
|
404
|
+
const rel = relative(projectRoot, f);
|
|
405
|
+
out.push(rel.startsWith('..') ? f : rel);
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Tool implementation functions (Python: _*_impl)
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
/** Python: `_analyze_file_impl`. */
|
|
413
|
+
export function analyzeFileImpl(filePath) {
|
|
414
|
+
if (!existsSync(filePath)) {
|
|
415
|
+
return { error: `file not found: ${filePath}` };
|
|
416
|
+
}
|
|
417
|
+
const projectRoot = projectRootFor(filePath);
|
|
418
|
+
const pattern = new PatternMatcher().scan(projectRoot);
|
|
419
|
+
const domain = new DomainScanner().scan(projectRoot);
|
|
420
|
+
// Framework detection: config files first, suffix as fallback.
|
|
421
|
+
let [framework, frameworkSource] = detectFrameworkFromConfig(projectRoot);
|
|
422
|
+
if (frameworkSource !== 'config') {
|
|
423
|
+
const suffixFw = SUFFIX_FRAMEWORK[extname(filePath).toLowerCase()];
|
|
424
|
+
if (suffixFw) {
|
|
425
|
+
framework = suffixFw;
|
|
426
|
+
frameworkSource = 'suffix';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
let contextSnippets;
|
|
430
|
+
try {
|
|
431
|
+
contextSnippets = pySplitlines(readFileSync(filePath, 'utf-8')).slice(0, 40);
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
contextSnippets = [];
|
|
435
|
+
}
|
|
436
|
+
// Context-aware persona & environment detection (#341): attach the detected
|
|
437
|
+
// BASE_URL, suite type, and SDET-vs-manual user level. The file under
|
|
438
|
+
// analysis is itself an "open file" signal for the user-level heuristic.
|
|
439
|
+
const environment = detectEnvironment(projectRoot, {
|
|
440
|
+
openFiles: [filePath],
|
|
441
|
+
}).toDict();
|
|
442
|
+
return {
|
|
443
|
+
framework,
|
|
444
|
+
framework_source: frameworkSource,
|
|
445
|
+
test_type: framework === 'playwright' ? 'e2e' : 'api',
|
|
446
|
+
imports: pattern.common_imports,
|
|
447
|
+
// `functions` historically returned project-wide public functions from the
|
|
448
|
+
// DomainScanner. Kept for backward compat; agents should prefer
|
|
449
|
+
// `file_functions` for the target file's own definitions.
|
|
450
|
+
functions: domain.functions.slice(0, 10),
|
|
451
|
+
file_functions: extractFileFunctions(filePath),
|
|
452
|
+
existing_tests: findExistingTests(projectRoot, framework),
|
|
453
|
+
context_snippets: contextSnippets,
|
|
454
|
+
environment,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
/** Python: `_write_test_file_impl`. */
|
|
458
|
+
export function writeTestFileImpl(filePath, content, framework) {
|
|
459
|
+
let outPath = filePath;
|
|
460
|
+
if (pySuffix(basename(filePath)) === '') {
|
|
461
|
+
const extMap = {
|
|
462
|
+
playwright: '.spec.ts',
|
|
463
|
+
vitest: '.test.ts',
|
|
464
|
+
pytest: '.py',
|
|
465
|
+
k6: '.js',
|
|
466
|
+
};
|
|
467
|
+
// Python `Path.with_suffix` on a suffix-less name appends the extension;
|
|
468
|
+
// with no existing suffix to strip, that is a plain concatenation.
|
|
469
|
+
outPath = filePath + (extMap[framework] ?? '.ts');
|
|
470
|
+
}
|
|
471
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
472
|
+
writeFileSync(outPath, content, 'utf-8');
|
|
473
|
+
return { written_path: outPath };
|
|
474
|
+
}
|
|
475
|
+
/** Python: `_run_tests_impl`. */
|
|
476
|
+
export function runTestsImpl(testFile) {
|
|
477
|
+
const suffix = extname(testFile).toLowerCase();
|
|
478
|
+
const framework = suffix === '.py' ? 'pytest' : 'playwright';
|
|
479
|
+
const executor = new CanaryTestExecutor();
|
|
480
|
+
let exitCode;
|
|
481
|
+
let stdout;
|
|
482
|
+
let stderr;
|
|
483
|
+
try {
|
|
484
|
+
[exitCode, stdout, stderr] = executor.execute(testFile, framework);
|
|
485
|
+
}
|
|
486
|
+
catch (exc) {
|
|
487
|
+
return { passed: 0, failed: 0, output: errStr(exc), exit_code: 1 };
|
|
488
|
+
}
|
|
489
|
+
const output = (stdout || '') + (stderr || '');
|
|
490
|
+
// `output.count(" passed")` - non-overlapping occurrences.
|
|
491
|
+
const passedCount = output.split(' passed').length - 1;
|
|
492
|
+
const passed = passedCount + (exitCode === 0 ? 1 : 0);
|
|
493
|
+
const failed = exitCode === 0 ? 0 : 1;
|
|
494
|
+
return { passed, failed, output, exit_code: exitCode };
|
|
495
|
+
}
|
|
496
|
+
/** Python: `_init_suite_impl`. */
|
|
497
|
+
export function initSuiteImpl(framework, targetDir) {
|
|
498
|
+
const target = targetDir || WORKING_DIR;
|
|
499
|
+
const scaffolder = new Scaffolder();
|
|
500
|
+
const result = scaffolder.scaffold(framework, target);
|
|
501
|
+
const createdFiles = result['created_files'] ?? [];
|
|
502
|
+
const createdDirs = result['created_dirs'] ?? [];
|
|
503
|
+
const out = {
|
|
504
|
+
files_created: [...createdFiles, ...createdDirs],
|
|
505
|
+
framework,
|
|
506
|
+
status: result['status'] ?? 'scaffolded',
|
|
507
|
+
};
|
|
508
|
+
if (result['status'] === 'unsupported') {
|
|
509
|
+
// Degraded: no template - surface the actionable guidance rather than
|
|
510
|
+
// implying an empty-but-successful scaffold.
|
|
511
|
+
out['guidance'] = result['guidance'];
|
|
512
|
+
out['execution_command'] = result['execution_command'];
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
/** Python: `_list_frameworks_impl`. */
|
|
517
|
+
export function listFrameworksImpl() {
|
|
518
|
+
const registry = new FrameworkRegistry();
|
|
519
|
+
const names = registry.getAllFrameworks().map((f) => f.name);
|
|
520
|
+
// `frameworks` stays a name list for backward compatibility; `details`
|
|
521
|
+
// additively exposes each framework's run-command (#357).
|
|
522
|
+
return { frameworks: names, details: registry.summaries() };
|
|
523
|
+
}
|
|
524
|
+
/** Python: `_migrate_impl`. */
|
|
525
|
+
export function migrateImpl(targetDir, apply) {
|
|
526
|
+
const root = targetDir || WORKING_DIR;
|
|
527
|
+
const migrator = new HarnessMigrator();
|
|
528
|
+
const ctx = migrator.detect(root);
|
|
529
|
+
if (!ctx.is_harness_project) {
|
|
530
|
+
// #319 C: distinguish "config present but not a test project" from a
|
|
531
|
+
// genuinely missing config.
|
|
532
|
+
if (ctx.not_test_project_reason) {
|
|
533
|
+
return { error: ctx.not_test_project_reason };
|
|
534
|
+
}
|
|
535
|
+
return { error: 'no harness.config.json found' };
|
|
536
|
+
}
|
|
537
|
+
const report = migrator.migrate(root, { dryRun: !apply });
|
|
538
|
+
if (report.dry_run) {
|
|
539
|
+
return {
|
|
540
|
+
framework: report.framework,
|
|
541
|
+
files_created: [],
|
|
542
|
+
files_skipped: [],
|
|
543
|
+
manual_followups: report.manual_followups,
|
|
544
|
+
dry_run: true,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
framework: report.framework,
|
|
549
|
+
files_created: [...report.created_files, ...report.created_dirs],
|
|
550
|
+
files_skipped: report.skipped_configs,
|
|
551
|
+
manual_followups: report.manual_followups,
|
|
552
|
+
dry_run: false,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** Wrap an impl's return dict as a JSON-text MCP tool result. */
|
|
556
|
+
function toToolResult(result) {
|
|
557
|
+
return {
|
|
558
|
+
content: [{ type: 'text', text: ensureAscii(JSON.stringify(result)) }],
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* The load-bearing namespace invariant: every canary tool carries the
|
|
563
|
+
* `canary__` prefix so it can never collide with a harness tool sharing the
|
|
564
|
+
* session's MCP namespace. Exported for the registration regression test.
|
|
565
|
+
*/
|
|
566
|
+
export const CANARY_TOOL_NAMES = [
|
|
567
|
+
'canary__analyze_file',
|
|
568
|
+
'canary__write_test_file',
|
|
569
|
+
'canary__run_tests',
|
|
570
|
+
'canary__init_suite',
|
|
571
|
+
'canary__list_frameworks',
|
|
572
|
+
'canary__migrate',
|
|
573
|
+
];
|
|
574
|
+
// Tool wrappers - each only JSON-wraps the dict its impl returns (Python: the
|
|
575
|
+
// thin `@mcp.tool()` functions). Exported for direct delegation tests.
|
|
576
|
+
export function analyzeFileTool(args) {
|
|
577
|
+
return toToolResult(analyzeFileImpl(args.file_path));
|
|
578
|
+
}
|
|
579
|
+
export function writeTestFileTool(args) {
|
|
580
|
+
return toToolResult(writeTestFileImpl(args.file_path, args.content, args.framework));
|
|
581
|
+
}
|
|
582
|
+
export function runTestsTool(args) {
|
|
583
|
+
return toToolResult(runTestsImpl(args.test_file));
|
|
584
|
+
}
|
|
585
|
+
export function initSuiteTool(args) {
|
|
586
|
+
return toToolResult(initSuiteImpl(args.framework, args.target_dir));
|
|
587
|
+
}
|
|
588
|
+
export function listFrameworksTool() {
|
|
589
|
+
return toToolResult(listFrameworksImpl());
|
|
590
|
+
}
|
|
591
|
+
export function migrateTool(args) {
|
|
592
|
+
return toToolResult(migrateImpl(args.target_dir, args.apply));
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Build the Canary MCP server and register all six `canary__*` tools (Python:
|
|
596
|
+
* the module-level `mcp = FastMCP("canary")` plus the `@mcp.tool()`
|
|
597
|
+
* registrations). Input schemas preserve the Python param names, types, and
|
|
598
|
+
* defaults exactly.
|
|
599
|
+
*/
|
|
600
|
+
export function createServer() {
|
|
601
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
602
|
+
server.registerTool('canary__analyze_file', {
|
|
603
|
+
description: 'Analyse a source file and return everything needed to write a test.',
|
|
604
|
+
inputSchema: { file_path: z.string() },
|
|
605
|
+
}, analyzeFileTool);
|
|
606
|
+
server.registerTool('canary__write_test_file', {
|
|
607
|
+
description: 'Write test content to file_path, creating parent directories as needed.',
|
|
608
|
+
inputSchema: {
|
|
609
|
+
file_path: z.string(),
|
|
610
|
+
content: z.string(),
|
|
611
|
+
framework: z.string(),
|
|
612
|
+
},
|
|
613
|
+
}, writeTestFileTool);
|
|
614
|
+
server.registerTool('canary__run_tests', {
|
|
615
|
+
description: 'Run a test file and return exit code and output without raising.',
|
|
616
|
+
inputSchema: { test_file: z.string() },
|
|
617
|
+
}, runTestsTool);
|
|
618
|
+
server.registerTool('canary__init_suite', {
|
|
619
|
+
description: 'Scaffold a test suite for framework in target_dir.',
|
|
620
|
+
inputSchema: {
|
|
621
|
+
framework: z.string(),
|
|
622
|
+
target_dir: z.string().default(''),
|
|
623
|
+
},
|
|
624
|
+
}, initSuiteTool);
|
|
625
|
+
server.registerTool('canary__list_frameworks', {
|
|
626
|
+
description: 'Return all frameworks registered in agent/frameworks/registry.json.',
|
|
627
|
+
}, listFrameworksTool);
|
|
628
|
+
server.registerTool('canary__migrate', {
|
|
629
|
+
description: 'Migrate a harness-scaffolded project to Canary layout. Dry-run by default.',
|
|
630
|
+
inputSchema: {
|
|
631
|
+
target_dir: z.string().default(''),
|
|
632
|
+
apply: z.boolean().default(false),
|
|
633
|
+
},
|
|
634
|
+
}, migrateTool);
|
|
635
|
+
return server;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Console-script entry point for `canary-mcp` (Python: `main()` calling
|
|
639
|
+
* `mcp.run()`). Starts the server over stdio so it works against any installed
|
|
640
|
+
* canary without depending on a checked-out source tree.
|
|
641
|
+
*/
|
|
642
|
+
export async function runStdio() {
|
|
643
|
+
const server = createServer();
|
|
644
|
+
const transport = new StdioServerTransport();
|
|
645
|
+
await server.connect(transport);
|
|
646
|
+
}
|
|
647
|
+
//# sourceMappingURL=mcp-server.js.map
|