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,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
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "module",
3
+ "private": true
4
+ }