canary-test-cli 6.6.0 → 6.7.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/bin/canary.js CHANGED
@@ -1,6 +1,68 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ // ---------------------------------------------------------------------------
5
+ // Node-floor guard (#559). Everything in this block runs BEFORE the bundled
6
+ // engine is required, and deliberately so.
7
+ //
8
+ // `engines` is advisory: npm prints `warn EBADENGINE` and installs anyway
9
+ // (verified — it errors only under `engine-strict=true`, which almost nobody
10
+ // sets). So on an unsupported Node the user reaches this file, and the require
11
+ // of `../dist/router.js` below can throw a bare SyntaxError from engine code
12
+ // compiled for a newer runtime. A guard placed after that require would only
13
+ // ever run on versions that did not need it.
14
+ //
15
+ // The floor is read from `engines.node` rather than hardcoded, so the manifest
16
+ // stays the single source of truth — see ts/test/node-engines-floor.test.ts,
17
+ // which holds the manifest, the README badge, and the README prose together.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /** Floor major from this package's own `engines.node` (e.g. `>=22` -> 22). */
21
+ function readMinNodeMajor() {
22
+ var declared = (require('../package.json').engines || {}).node || '';
23
+ var match = /^>=\s*(\d+)/.exec(String(declared).trim());
24
+ return match ? Number(match[1]) : 0;
25
+ }
26
+
27
+ var MIN_NODE_MAJOR = readMinNodeMajor();
28
+
29
+ /**
30
+ * Returns an error message when `version` is below `minMajor`, else null.
31
+ *
32
+ * Abstains (returns null) on a version string it cannot parse: a false block
33
+ * would be worse than letting an odd runtime through to whatever the real
34
+ * error turns out to be.
35
+ */
36
+ function checkNodeFloor(version, minMajor) {
37
+ var match = /^v?(\d+)\./.exec(String(version));
38
+ if (!match || !minMajor) return null;
39
+ var major = Number(match[1]);
40
+ if (major >= minMajor) return null;
41
+ return (
42
+ 'canary requires Node ' +
43
+ minMajor +
44
+ ' or newer, but this is Node ' +
45
+ version +
46
+ '.\n' +
47
+ 'The bundled engine is compiled for Node ' +
48
+ minMajor +
49
+ ', so older runtimes fail in ways that look\n' +
50
+ 'like canary bugs. npm only warns about this (EBADENGINE), it does not ' +
51
+ 'stop the install.\n' +
52
+ 'Upgrade with nvm, volta, or mise — e.g. `nvm install ' +
53
+ minMajor +
54
+ '` — then re-run canary.\n'
55
+ );
56
+ }
57
+
58
+ if (require.main === module) {
59
+ var floorError = checkNodeFloor(process.versions.node, MIN_NODE_MAJOR);
60
+ if (floorError) {
61
+ process.stderr.write(floorError);
62
+ process.exit(1);
63
+ }
64
+ }
65
+
4
66
  const { execFileSync } = require('node:child_process');
5
67
  const path = require('node:path');
6
68
  const fs = require('node:fs');
@@ -65,5 +127,11 @@ function main() {
65
127
  );
66
128
  }
67
129
 
68
- module.exports = { getEnginePath, forwardToEngine, run };
130
+ module.exports = {
131
+ getEnginePath,
132
+ forwardToEngine,
133
+ run,
134
+ checkNodeFloor,
135
+ MIN_NODE_MAJOR,
136
+ };
69
137
  if (require.main === module) main();
@@ -308,7 +308,12 @@ function skipped(check, reason) {
308
308
  }
309
309
  function runCommandSucceeds(check, ctx, timeoutMs) {
310
310
  if (!ctx.consentGranted) {
311
- return skipped(check, "command checks need consent — re-run 'canary overlay add'");
311
+ return skipped(check,
312
+ // #505: name the overlay's source, and say the re-add is safe. The bare
313
+ // "re-run 'canary overlay add'" read as "reinstall it", which is the
314
+ // one thing a user with a working overlay will not risk.
315
+ "command checks need consent — re-run 'canary overlay add <source>' " +
316
+ '(safe on an installed overlay: it re-asks consent, never re-clones)');
312
317
  }
313
318
  const command = check.command ?? [];
314
319
  const cmd = `\`${command.join(' ')}\``;
package/dist/doctor.js CHANGED
@@ -186,10 +186,13 @@ function summarizeChecks(groups) {
186
186
  */
187
187
  function abstentionRemedy(summary) {
188
188
  return summary.skipped.length > 0
189
- ? 'Every registered check was skipped or informational, so doctor verified ' +
190
- 'nothing. Grant command-check consent (re-run `canary overlay add ' +
191
- '<name> --yes`) or install an overlay whose checks apply here, then ' +
192
- 're-run.'
189
+ ? // #505: `<source>`, not `<name>` -- `add` takes the source spec, so the
190
+ // copied-and-pasted form failed for anyone who followed it literally.
191
+ 'Every registered check was skipped or informational, so doctor verified ' +
192
+ 'nothing. Grant command-check consent (re-run `canary overlay add ' +
193
+ '<source> --yes`, which re-asks consent on an already-installed overlay ' +
194
+ 'without re-cloning) or install an overlay whose checks apply here, ' +
195
+ 'then re-run.'
193
196
  : 'No check was registered, so doctor verified nothing. Install an ' +
194
197
  'overlay that ships a `.canary/doctor.json` (`canary overlay add ' +
195
198
  '<source>`), then re-run.';
@@ -11,7 +11,7 @@
11
11
  * `\u{...}` escapes emitted verbatim.
12
12
  */
13
13
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
14
- import { basename, join, resolve } from 'node:path';
14
+ import { basename, extname, join, resolve } from 'node:path';
15
15
  import pc from 'picocolors';
16
16
  import { CliExit, jsonIndent2 } from './cli-common.js';
17
17
  import { gateOutcome } from './core/gate-result.js';
@@ -19,6 +19,7 @@ import { ckInitCmd } from './company-knowledge-cli.js';
19
19
  import { extractFrameworkHint } from './core/classifier.js';
20
20
  import { VALID_CATEGORIES, buildFeedback } from './core/feedback.js';
21
21
  import { OverlayNotFound, listOverlays, resolveOverlay, } from './core/overlays.js';
22
+ import { JS_TEST_EXTENSIONS, lintableFramework } from './core/static-linter.js';
22
23
  import { RunSummary } from './core/ticket-updater.js';
23
24
  import { renderBanner } from './ui/banner.js';
24
25
  import { ARROW, CHECK, CHECK_MARK, CROSS, EM_DASH, HAMMER, NEXT, REDX, ROCKET, WARN, WRENCH, } from './main-deps.js';
@@ -46,6 +47,24 @@ function isDir(p) {
46
47
  return false;
47
48
  }
48
49
  }
50
+ /**
51
+ * Directories never worth walking. A dependency's own test suite is not the
52
+ * consumer's to fix: before #566, `node_modules` accounted for 254 of 256
53
+ * findings in one downstream run, and the only `critical` sat inside vendored
54
+ * code. `pattern-matcher.ts` has carried this set since the Python port; this
55
+ * walk was the copy that never got it.
56
+ */
57
+ const IGNORED_DIRS = new Set([
58
+ 'node_modules',
59
+ '.git',
60
+ '__pycache__',
61
+ '.venv',
62
+ 'venv',
63
+ 'dist',
64
+ 'build',
65
+ '.next',
66
+ '.nuxt',
67
+ ]);
49
68
  function walkFiles(dir) {
50
69
  const out = [];
51
70
  let entries;
@@ -57,23 +76,27 @@ function walkFiles(dir) {
57
76
  }
58
77
  for (const e of entries) {
59
78
  const full = join(dir, e.name);
60
- if (e.isDirectory())
61
- out.push(...walkFiles(full));
79
+ if (e.isDirectory()) {
80
+ if (!IGNORED_DIRS.has(e.name))
81
+ out.push(...walkFiles(full));
82
+ }
62
83
  else if (e.isFile())
63
84
  out.push(full);
64
85
  }
65
86
  return out;
66
87
  }
88
+ /**
89
+ * `test_*.py` plus `*.test.*` / `*.spec.*` over every extension the scanners
90
+ * can actually read -- `.mjs` and `.cjs` included, which is the half of #566
91
+ * that made a directory of ESM tests collect zero files.
92
+ */
93
+ const JS_TEST_FILE_RE = new RegExp(`\\.(test|spec)\\.(${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join('|')})$`);
67
94
  /** Recursive test-file glob matching Python's `rglob` union, sorted by path. */
68
95
  function collectTestFiles(dir) {
69
96
  return walkFiles(dir)
70
97
  .filter((p) => {
71
98
  const b = basename(p);
72
- return ((b.startsWith('test_') && b.endsWith('.py')) ||
73
- b.endsWith('.spec.ts') ||
74
- b.endsWith('.spec.js') ||
75
- b.endsWith('.test.ts') ||
76
- b.endsWith('.test.js'));
99
+ return ((b.startsWith('test_') && b.endsWith('.py')) || JS_TEST_FILE_RE.test(b));
77
100
  })
78
101
  .sort();
79
102
  }
@@ -394,6 +417,21 @@ function findingPayload(f) {
394
417
  suggestion: f.suggestion,
395
418
  };
396
419
  }
420
+ /** Human-readable list of what the collectors look for, for remedy text. */
421
+ const SCANNABLE_DESC = `test_*.py, *.test|spec.{${JS_TEST_EXTENSIONS.map((e) => e.slice(1)).join(',')}}`;
422
+ /** Emit the abstention notice in the caller's output mode, then exit 3. */
423
+ function abstain(remedy, deps, json) {
424
+ const outcome = gateOutcome({ checked: 0, findings: [] }, 'gate');
425
+ if (json) {
426
+ deps.out(jsonIndent2([]));
427
+ deps.err(`${outcome.summaryLine} ${remedy}`);
428
+ }
429
+ else {
430
+ deps.out(pc.bold(pc.yellow(outcome.summaryLine)));
431
+ deps.out(` ${remedy}`);
432
+ }
433
+ throw new CliExit(outcome.exitCode);
434
+ }
397
435
  /**
398
436
  * The denominator guard shared by the file-scanning gates (#508 Wave 4a).
399
437
  *
@@ -410,29 +448,44 @@ function findingPayload(f) {
410
448
  function abstainOnZeroFiles(files, path, deps, json) {
411
449
  if (files.length > 0)
412
450
  return;
413
- const outcome = gateOutcome({ checked: 0, findings: [] }, 'gate');
414
- const remedy = `No test file matched under ${path} (looked for test_*.py, ` +
415
- `*.spec.ts/js, *.test.ts/js). Point at a directory that holds tests, ` +
416
- `or pass a single file directly.`;
417
- if (json) {
418
- deps.out(jsonIndent2([]));
419
- deps.err(`${outcome.summaryLine} ${remedy}`);
420
- }
421
- else {
422
- deps.out(pc.bold(pc.yellow(outcome.summaryLine)));
423
- deps.out(` ${remedy}`);
424
- }
425
- throw new CliExit(outcome.exitCode);
451
+ abstain(`No test file matched under ${path} (looked for ${SCANNABLE_DESC}). ` +
452
+ `Point at a directory that holds tests, or pass a single file directly.`, deps, json);
453
+ }
454
+ /**
455
+ * The single-file half of the same guard (#566).
456
+ *
457
+ * `abstainOnZeroFiles` only ever fires on a directory: a single file is passed
458
+ * straight through as a one-element list, so its denominator is never zero. It
459
+ * can still be unmeasurable -- an extension no ruleset parses used to fall back
460
+ * to the Python scanners, which find nothing in ESM JavaScript and so rendered
461
+ * "No issues found" over a file that was never actually read. Zero findings
462
+ * from a scanner that could not parse the input is an abstention, not a pass.
463
+ */
464
+ function abstainOnUnlintableFile(path, deps, json) {
465
+ if (lintableFramework(path) !== null)
466
+ return;
467
+ const ext = extname(path) || basename(path);
468
+ abstain(`Cannot lint ${ext} — no ruleset parses it, so a clean result would be ` +
469
+ `meaningless (looked for ${SCANNABLE_DESC}).`, deps, json);
426
470
  }
427
471
  export function reviewTestCmd(path, opts, deps) {
472
+ const json = opts.json === true;
428
473
  const files = isDir(path) ? collectTestFiles(path) : [path];
429
- abstainOnZeroFiles(files, path, deps, opts.json === true);
474
+ abstainOnZeroFiles(files, path, deps, json);
475
+ if (!isDir(path) && !opts.framework)
476
+ abstainOnUnlintableFile(path, deps, json);
430
477
  const linter = deps.makeLinter();
431
478
  const allFindings = [];
432
479
  for (const f of files)
433
480
  allFindings.push(...linter.lint(f, opts.framework));
434
- if (opts.json) {
481
+ // `--json` renders the machine payload and then falls through to the same
482
+ // exit-code decision as human mode. It used to `return` here, so a consumer
483
+ // gating on `$?` saw every finding-bearing run as clean (#566).
484
+ if (json) {
435
485
  deps.out(jsonIndent2(allFindings.map(findingPayload)));
486
+ if (allFindings.some((f) => f.severity === 'critical')) {
487
+ throw new CliExit(1);
488
+ }
436
489
  return;
437
490
  }
438
491
  if (allFindings.length === 0) {
@@ -462,14 +515,20 @@ export function reviewTestCmd(path, opts, deps) {
462
515
  throw new CliExit(1);
463
516
  }
464
517
  export function flakeCheckCmd(path, opts, deps) {
518
+ const json = opts.json === true;
465
519
  const files = isDir(path) ? collectTestFiles(path) : [path];
466
- abstainOnZeroFiles(files, path, deps, opts.json === true);
520
+ abstainOnZeroFiles(files, path, deps, json);
521
+ if (!isDir(path))
522
+ abstainOnUnlintableFile(path, deps, json);
467
523
  const linter = deps.makeLinter();
468
524
  const allFindings = [];
469
525
  for (const f of files)
470
526
  allFindings.push(...linter.flakeCheck(f));
471
- if (opts.json) {
527
+ // Exit-code parity with human mode, same reason as `review-test` above.
528
+ if (json) {
472
529
  deps.out(jsonIndent2(allFindings.map(findingPayload)));
530
+ if (allFindings.length > 0)
531
+ throw new CliExit(1);
473
532
  return;
474
533
  }
475
534
  if (allFindings.length === 0) {
@@ -102,7 +102,7 @@ export function createCanaryCommand(depsInit = {}) {
102
102
  .command('migrate')
103
103
  .description("Migrate a harness-scaffolded test-suite project to Canary's layout.")
104
104
  .addOption(new Option('-p, --path <path>', 'Project root to migrate (default: current directory).').default('.'))
105
- .option('-f, --framework <framework>', 'Override auto-detected framework.')
105
+ .option('-f, --framework <framework>', 'Override the auto-detected framework (also resolves its test shape).')
106
106
  .option('--from <overlay>', 'Tracked overlay (name or path) whose .canary/skills/ are deployed into the target.')
107
107
  .option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
108
108
  .option('--apply', 'Write files. Without this flag the command is a dry run.')
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Test-framework detection probes, tiered by the kind of evidence they read.
3
+ *
4
+ * Split out of `migrator.ts` (#504 part 1) so workspace detection can probe an
5
+ * individual package without importing the migrator -- see `fs-glob.ts` for why
6
+ * the direction has to stay leafward.
7
+ */
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { globFiles, readTextOrNull } from './fs-glob.js';
11
+ // (config_file, framework, shape, confidence)
12
+ export const _CONFIG_PROBES = [
13
+ ['playwright.config.ts', 'playwright', 'e2e_ui', 'config'],
14
+ ['playwright.config.js', 'playwright', 'e2e_ui', 'config'],
15
+ ['cypress.config.ts', 'playwright', 'e2e_ui', 'config'],
16
+ ['cypress.config.js', 'playwright', 'e2e_ui', 'config'],
17
+ ['vitest.config.ts', 'vitest', 'frontend_unit', 'config'],
18
+ ['vitest.config.js', 'vitest', 'frontend_unit', 'config'],
19
+ ['vitest.config.mts', 'vitest', 'frontend_unit', 'config'],
20
+ ['jest.config.ts', 'vitest', 'frontend_unit', 'config'],
21
+ ['jest.config.js', 'vitest', 'frontend_unit', 'config'],
22
+ ['jest.config.mjs', 'vitest', 'frontend_unit', 'config'],
23
+ ['k6.config.js', 'k6', 'performance', 'config'],
24
+ ['pytest.ini', 'pytest', 'api', 'config'],
25
+ ['setup.cfg', 'pytest', 'api', 'config'],
26
+ ['axe.config.js', 'axe-core', 'accessibility', 'config'],
27
+ ['backstop.json', 'backstopjs', 'visual', 'config'],
28
+ ['pact.json', 'pact', 'contract', 'config'],
29
+ ['.pact', 'pact', 'contract', 'config'],
30
+ ['stryker.config.js', 'stryker', 'mutation', 'config'],
31
+ ['stryker.config.mjs', 'stryker', 'mutation', 'config'],
32
+ ['locust.conf', 'locust', 'load', 'config'],
33
+ ['locustfile.py', 'locust', 'load', 'config'],
34
+ ['wdio.conf.ts', 'wdio', 'mobile', 'config'],
35
+ ['wdio.conf.js', 'wdio', 'mobile', 'config'],
36
+ ['wdio.conf.mjs', 'wdio', 'mobile', 'config'],
37
+ ];
38
+ // pyproject.toml section markers
39
+ const _PYPROJECT_MARKERS = [
40
+ ['[tool.pytest.ini_options]', 'pytest', 'api'],
41
+ ['[tool.coverage', 'pytest', 'api'],
42
+ ];
43
+ // package.json test script -> (framework, shape)
44
+ const _PACKAGE_SCRIPT_PATTERNS = [
45
+ [/\bplaywright\b/, 'playwright', 'e2e_ui'],
46
+ [/\bcypress\b/, 'playwright', 'e2e_ui'],
47
+ [/\bvitest\b/, 'vitest', 'frontend_unit'],
48
+ [/\bjest\b/, 'vitest', 'frontend_unit'],
49
+ [/\bk6\b/, 'k6', 'performance'],
50
+ [/\blocust\b/, 'locust', 'load'],
51
+ [/\bstryker\b/, 'stryker', 'mutation'],
52
+ [/\bwdio\b/, 'wdio', 'mobile'],
53
+ ];
54
+ // Python dependency -> (framework, shape). MULTILINE `^` anchored on `\n` only.
55
+ const _PYTHON_DEP_PATTERNS = [
56
+ [/(?:^|(?<=\n))pytest\b/i, 'pytest', 'api'],
57
+ [/(?:^|(?<=\n))locust\b/i, 'locust', 'load'],
58
+ [/(?:^|(?<=\n))pact\b/i, 'pact', 'contract'],
59
+ [/(?:^|(?<=\n))sdv\b/i, 'sdv', 'synthetic_data'],
60
+ [/(?:^|(?<=\n))faker\b/i, 'faker', 'synthetic_data'],
61
+ [/(?:^|(?<=\n))testcontainers\b/i, 'testcontainers', 'integration'],
62
+ ];
63
+ // Language -> (framework, shape) fallbacks from harness.config.json
64
+ const _LANGUAGE_FALLBACKS = {
65
+ python: ['pytest', 'api'],
66
+ typescript: ['playwright', 'e2e_ui'],
67
+ javascript: ['playwright', 'e2e_ui'],
68
+ };
69
+ // Detects playwright UI fixture params. MULTILINE is a no-op (no `^`/`$`).
70
+ const _PW_UI_FIXTURE_RE = /async\s*\(\s*\{[^}]*\b(?:page|browser)\b/;
71
+ /**
72
+ * Return 'api' when no playwright spec file uses page/browser fixtures, else
73
+ * 'e2e_ui' (the default when any UI signal is found or no spec files exist).
74
+ */
75
+ export function inferPlaywrightShape(root) {
76
+ const specGlobs = [
77
+ 'tests/**/*.spec.ts',
78
+ 'tests/**/*.spec.js',
79
+ 'test/**/*.spec.ts',
80
+ 'test/**/*.spec.js',
81
+ ];
82
+ let total = 0;
83
+ for (const glob of specGlobs) {
84
+ for (const path of globFiles(root, glob)) {
85
+ // Python read_text(errors="ignore"); readFileSync substitutes U+FFFD for
86
+ // invalid bytes -- immaterial for the ASCII fixture pattern below.
87
+ let content;
88
+ try {
89
+ content = readFileSync(path, 'utf-8');
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ total += 1;
95
+ if (_PW_UI_FIXTURE_RE.test(content))
96
+ return 'e2e_ui';
97
+ }
98
+ }
99
+ return total > 0 ? 'api' : 'e2e_ui';
100
+ }
101
+ /** Tier 1 -- a dedicated config file (highest confidence). */
102
+ function probeConfig(root) {
103
+ for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
104
+ if (existsSync(join(root, filename))) {
105
+ // For playwright config files, distinguish API vs UI suites.
106
+ if (framework === 'playwright' && shape === 'e2e_ui') {
107
+ const inferred = inferPlaywrightShape(root);
108
+ if (inferred !== shape)
109
+ return [framework, inferred, filename, 'content'];
110
+ }
111
+ return [framework, shape, filename, confidence];
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ /** Tier 2a -- pyproject.toml section markers, then its dependency scan. */
117
+ function probePyproject(root) {
118
+ const pyproject = join(root, 'pyproject.toml');
119
+ if (!existsSync(pyproject))
120
+ return null;
121
+ const content = readTextOrNull(pyproject);
122
+ if (content === null)
123
+ return null;
124
+ for (const [marker, framework, shape] of _PYPROJECT_MARKERS) {
125
+ if (content.includes(marker)) {
126
+ return [framework, shape, 'pyproject.toml', 'content'];
127
+ }
128
+ }
129
+ for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
130
+ if (pattern.test(content)) {
131
+ return [framework, shape, 'pyproject.toml (dependencies)', 'content'];
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ /** Tier 2b -- requirements*.txt dependency scan. */
137
+ function probeRequirements(root) {
138
+ for (const reqFile of [
139
+ 'requirements.txt',
140
+ 'requirements-test.txt',
141
+ 'requirements-dev.txt',
142
+ ]) {
143
+ const reqPath = join(root, reqFile);
144
+ if (!existsSync(reqPath))
145
+ continue;
146
+ const content = readTextOrNull(reqPath);
147
+ if (content === null)
148
+ continue;
149
+ for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
150
+ if (pattern.test(content))
151
+ return [framework, shape, reqFile, 'content'];
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ /** Tier 2c -- package.json scripts.test scan. */
157
+ function probePackageScripts(root) {
158
+ const pkgJson = join(root, 'package.json');
159
+ if (!existsSync(pkgJson))
160
+ return null;
161
+ try {
162
+ const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'));
163
+ const scripts = (pkg['scripts'] ?? {});
164
+ const testScript = String(scripts['test'] ?? '');
165
+ for (const [pattern, framework, shape] of _PACKAGE_SCRIPT_PATTERNS) {
166
+ if (pattern.test(testScript)) {
167
+ return [framework, shape, 'package.json (scripts.test)', 'content'];
168
+ }
169
+ }
170
+ }
171
+ catch {
172
+ // OSError / JSONDecodeError -> ignore.
173
+ }
174
+ return null;
175
+ }
176
+ /** Tier 3 -- language fallback from harness config. */
177
+ function probeLanguage(config) {
178
+ const language = String(config['language'] ?? '').toLowerCase();
179
+ if (!Object.prototype.hasOwnProperty.call(_LANGUAGE_FALLBACKS, language)) {
180
+ return null;
181
+ }
182
+ const [fw, shape] = _LANGUAGE_FALLBACKS[language];
183
+ return [fw, shape, `harness.config.json (language: ${language})`, 'language'];
184
+ }
185
+ /**
186
+ * Detect a test framework under *dir*, running only the requested *tiers*.
187
+ *
188
+ * The tier list is the whole point of this function. Root detection passes all
189
+ * three; per-package detection passes `['config', 'content']` ONLY. The
190
+ * language tier maps `language: typescript` to playwright/e2e_ui, so running it
191
+ * per package would make every package in a TypeScript monorepo "detect"
192
+ * playwright by inheritance -- canary inventing findings it never observed
193
+ * (#504 part 1, spec test #8).
194
+ *
195
+ * Note the tier list and the returned `confidence` are not the same axis: the
196
+ * config tier returns confidence `content` when `inferPlaywrightShape` refines
197
+ * e2e_ui to api, because the refinement read file contents to decide.
198
+ */
199
+ export function probe(dir, config, tiers) {
200
+ const on = (t) => tiers.includes(t);
201
+ if (on('config')) {
202
+ const hit = probeConfig(dir);
203
+ if (hit !== null)
204
+ return hit;
205
+ }
206
+ if (on('content')) {
207
+ const hit = probePyproject(dir) ?? probeRequirements(dir) ?? probePackageScripts(dir);
208
+ if (hit !== null)
209
+ return hit;
210
+ }
211
+ if (on('language')) {
212
+ const hit = probeLanguage(config);
213
+ if (hit !== null)
214
+ return hit;
215
+ }
216
+ return [null, 'unknown', 'none', 'none'];
217
+ }
218
+ //# sourceMappingURL=framework-probes.js.map