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.
@@ -37,11 +37,14 @@
37
37
  * - Python truthiness (`""`/`None`/`{}`/`[]` falsy) via {@link pyTruthy}.
38
38
  */
39
39
  import { createHash } from 'node:crypto';
40
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
40
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
41
41
  import { homedir } from 'node:os';
42
42
  import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
43
43
  import { readJsonWithWarning } from './config-validation.js';
44
44
  import { uncertainDetectionMessage } from './detection.js';
45
+ import { _CONFIG_PROBES, inferPlaywrightShape, probe, } from './framework-probes.js';
46
+ import { comparePathParts, globDirs, globFiles, isDir, isFile, readTextOrNull, } from './fs-glob.js';
47
+ import { detectWorkspace, workspaceGlobs, } from './workspace-detect.js';
45
48
  import { FrameworkRegistry } from './framework-registry.js';
46
49
  import { EXIT_ABSTAINED, gateOutcome } from './gate-result.js';
47
50
  import { Scaffolder, scaffoldableFrameworks, TEMPLATES } from './scaffolder.js';
@@ -80,29 +83,6 @@ const EMDASH = '\u{2014}'; // em dash
80
83
  // Lives beside the deployed skills; never a deployable skill itself (leading dot
81
84
  // -> skipped by the skill scanner).
82
85
  export const DEPLOY_MANIFEST_NAME = '.deploy-manifest.json';
83
- /**
84
- * Order two POSIX-style relative paths the way Python orders `Path` objects:
85
- * component-wise (`PurePath.__lt__` compares the parts list), NOT as joined
86
- * strings. They differ when a directory name prefixes a sibling file name and
87
- * the next char sorts below '/' (0x2F) -- most commonly the '.' extension
88
- * separator, e.g. `scripts/run.sh` vs `scripts.md`. A joined-string sort places
89
- * `scripts.md` first ('.' 0x2E < '/' 0x2F); Python's component sort places
90
- * `scripts/run.sh` first ('scripts' < 'scripts.md'). This ordering feeds the
91
- * skill-dir hash, a byte-exact contract compared against Python-written
92
- * .deploy-manifest.json files on the upgrade path.
93
- */
94
- function comparePathParts(a, b) {
95
- const pa = a.split('/');
96
- const pb = b.split('/');
97
- const n = Math.min(pa.length, pb.length);
98
- for (let i = 0; i < n; i++) {
99
- if (pa[i] < pb[i])
100
- return -1;
101
- if (pa[i] > pb[i])
102
- return 1;
103
- }
104
- return pa.length - pb.length;
105
- }
106
86
  /**
107
87
  * A stable sha256 of every file under *skillDir* (component-sorted rel-path +
108
88
  * bytes). Exported so a test can pin it byte-for-byte to the Python oracle:
@@ -293,64 +273,6 @@ const _DOC_SKILL_LAYER_NAMES = new Set([
293
273
  'commands',
294
274
  'prompts',
295
275
  ]);
296
- // (config_file, framework, shape, confidence)
297
- const _CONFIG_PROBES = [
298
- ['playwright.config.ts', 'playwright', 'e2e_ui', 'config'],
299
- ['playwright.config.js', 'playwright', 'e2e_ui', 'config'],
300
- ['cypress.config.ts', 'playwright', 'e2e_ui', 'config'],
301
- ['cypress.config.js', 'playwright', 'e2e_ui', 'config'],
302
- ['vitest.config.ts', 'vitest', 'frontend_unit', 'config'],
303
- ['vitest.config.js', 'vitest', 'frontend_unit', 'config'],
304
- ['vitest.config.mts', 'vitest', 'frontend_unit', 'config'],
305
- ['jest.config.ts', 'vitest', 'frontend_unit', 'config'],
306
- ['jest.config.js', 'vitest', 'frontend_unit', 'config'],
307
- ['jest.config.mjs', 'vitest', 'frontend_unit', 'config'],
308
- ['k6.config.js', 'k6', 'performance', 'config'],
309
- ['pytest.ini', 'pytest', 'api', 'config'],
310
- ['setup.cfg', 'pytest', 'api', 'config'],
311
- ['axe.config.js', 'axe-core', 'accessibility', 'config'],
312
- ['backstop.json', 'backstopjs', 'visual', 'config'],
313
- ['pact.json', 'pact', 'contract', 'config'],
314
- ['.pact', 'pact', 'contract', 'config'],
315
- ['stryker.config.js', 'stryker', 'mutation', 'config'],
316
- ['stryker.config.mjs', 'stryker', 'mutation', 'config'],
317
- ['locust.conf', 'locust', 'load', 'config'],
318
- ['locustfile.py', 'locust', 'load', 'config'],
319
- ['wdio.conf.ts', 'wdio', 'mobile', 'config'],
320
- ['wdio.conf.js', 'wdio', 'mobile', 'config'],
321
- ['wdio.conf.mjs', 'wdio', 'mobile', 'config'],
322
- ];
323
- // pyproject.toml section markers
324
- const _PYPROJECT_MARKERS = [
325
- ['[tool.pytest.ini_options]', 'pytest', 'api'],
326
- ['[tool.coverage', 'pytest', 'api'],
327
- ];
328
- // package.json test script -> (framework, shape)
329
- const _PACKAGE_SCRIPT_PATTERNS = [
330
- [/\bplaywright\b/, 'playwright', 'e2e_ui'],
331
- [/\bcypress\b/, 'playwright', 'e2e_ui'],
332
- [/\bvitest\b/, 'vitest', 'frontend_unit'],
333
- [/\bjest\b/, 'vitest', 'frontend_unit'],
334
- [/\bk6\b/, 'k6', 'performance'],
335
- [/\blocust\b/, 'locust', 'load'],
336
- [/\bstryker\b/, 'stryker', 'mutation'],
337
- [/\bwdio\b/, 'wdio', 'mobile'],
338
- ];
339
- // Python dependency -> (framework, shape). MULTILINE `^` anchored on `\n` only.
340
- const _PYTHON_DEP_PATTERNS = [
341
- [/(?:^|(?<=\n))pytest\b/i, 'pytest', 'api'],
342
- [/(?:^|(?<=\n))locust\b/i, 'locust', 'load'],
343
- [/(?:^|(?<=\n))pact\b/i, 'pact', 'contract'],
344
- [/(?:^|(?<=\n))sdv\b/i, 'sdv', 'synthetic_data'],
345
- [/(?:^|(?<=\n))faker\b/i, 'faker', 'synthetic_data'],
346
- [/(?:^|(?<=\n))testcontainers\b/i, 'testcontainers', 'integration'],
347
- ];
348
- // Language -> (framework, shape) fallbacks from harness.config.json
349
- const _LANGUAGE_FALLBACKS = {
350
- python: ['pytest', 'api'],
351
- typescript: ['playwright', 'e2e_ui'],
352
- javascript: ['playwright', 'e2e_ui'],
353
- };
354
276
  const _TEST_GLOBS = [
355
277
  'tests/**/*.py',
356
278
  'test/**/*.py',
@@ -361,86 +283,62 @@ const _TEST_GLOBS = [
361
283
  'src/**/*.spec.ts',
362
284
  'src/**/*.test.ts',
363
285
  ];
364
- // Detects playwright UI fixture params. MULTILINE is a no-op (no `^`/`$`).
365
- const _PW_UI_FIXTURE_RE = /async\s*\(\s*\{[^}]*\b(?:page|browser)\b/;
366
- // ---------------------------------------------------------------------------
367
- // Small filesystem / glob helpers
368
- // ---------------------------------------------------------------------------
369
- function isDir(path) {
370
- try {
371
- return statSync(path).isDirectory();
372
- }
373
- catch {
374
- return false;
375
- }
376
- }
377
- function isFile(path) {
378
- try {
379
- return statSync(path).isFile();
380
- }
381
- catch {
382
- return false;
286
+ /**
287
+ * Suites for *framework* already present in this repo's workspace packages.
288
+ *
289
+ * The repo root is excluded on purpose: a root config is already reported via
290
+ * `skipped_configs`, and listing it here would double-count it and suppress a
291
+ * scaffold that the skip logic handles more precisely.
292
+ */
293
+ function findWorkspaceSuites(root, framework) {
294
+ const globs = workspaceGlobs(root);
295
+ if (globs.length === 0)
296
+ return [];
297
+ const configNames = _CONFIG_PROBES
298
+ .filter(([, fw]) => fw === framework)
299
+ .map(([filename]) => filename);
300
+ if (configNames.length === 0)
301
+ return [];
302
+ const dirs = new Set();
303
+ for (const glob of globs)
304
+ for (const d of globDirs(root, glob))
305
+ dirs.add(d);
306
+ const suites = [];
307
+ for (const dir of [...dirs].sort(comparePathParts)) {
308
+ const config = configNames.find((name) => isFile(join(dir, name)));
309
+ if (config === undefined)
310
+ continue;
311
+ const tests = new Set();
312
+ for (const pattern of _TEST_GLOBS) {
313
+ for (const f of globFiles(dir, pattern))
314
+ tests.add(f);
315
+ }
316
+ suites.push({
317
+ dir: relative(root, dir).split(sep).join('/'),
318
+ config,
319
+ test_count: tests.size,
320
+ });
383
321
  }
384
- }
385
- /** Compile a single glob segment (with `*` -> `[^/]*`) to an anchored regex. */
386
- function segGlobRegex(seg) {
387
- const body = seg
388
- .replace(/[.+^${}()|[\]\\?]/g, '\\$&')
389
- .replace(/\*/g, '[^/]*');
390
- return new RegExp(`^${body}$`);
322
+ return suites;
391
323
  }
392
324
  /**
393
- * Match files under *root* against a pathlib-style glob (`**` matches zero or
394
- * more directories; `*` matches within a single segment). Mirrors the subset of
395
- * `Path.glob` the migrator needs.
325
+ * The shape implied by an explicit `--framework` override (#504 (2)).
326
+ *
327
+ * Reuses the probe table so the override resolves exactly the shape a matching
328
+ * config file would have, including the playwright api-vs-UI refinement.
329
+ * Returns null for a framework no probe knows, so the caller keeps whatever
330
+ * shape detection produced rather than overwriting it with a guess.
396
331
  */
397
- function globFiles(root, pattern) {
398
- const segments = pattern.split('/');
399
- const out = [];
400
- const visit = (dir, si) => {
401
- const seg = segments[si];
402
- const last = si === segments.length - 1;
403
- if (seg === '**') {
404
- // `**` consumes zero directories -> continue at the same dir.
405
- visit(dir, si + 1);
406
- // `**` consumes one-or-more -> descend into each subdir, staying on `**`.
407
- for (const d of subDirs(dir))
408
- visit(d, si);
409
- return;
410
- }
411
- const re = segGlobRegex(seg);
412
- let entries;
413
- try {
414
- entries = readdirSync(dir, { withFileTypes: true });
415
- }
416
- catch {
417
- return;
332
+ function shapeForFrameworkOverride(framework, root) {
333
+ for (const [, probeFramework, shape] of _CONFIG_PROBES) {
334
+ if (probeFramework !== framework)
335
+ continue;
336
+ if (probeFramework === 'playwright' && shape === 'e2e_ui') {
337
+ return inferPlaywrightShape(root);
418
338
  }
419
- for (const e of entries) {
420
- if (!re.test(e.name))
421
- continue;
422
- const full = join(dir, e.name);
423
- if (last) {
424
- if (e.isFile() || isFile(full))
425
- out.push(full);
426
- }
427
- else if (e.isDirectory()) {
428
- visit(full, si + 1);
429
- }
430
- }
431
- };
432
- visit(root, 0);
433
- return out;
434
- }
435
- function subDirs(dir) {
436
- try {
437
- return readdirSync(dir, { withFileTypes: true })
438
- .filter((e) => e.isDirectory())
439
- .map((e) => join(dir, e.name));
440
- }
441
- catch {
442
- return [];
339
+ return shape;
443
340
  }
341
+ return null;
444
342
  }
445
343
  /**
446
344
  * Return a human reason when *config* describes a skills/docs overlay (not a
@@ -476,6 +374,23 @@ function skillsDocsOverlayReason(config) {
476
374
  `${sortedNames}), not a test suite. \`canary migrate\` ` +
477
375
  'scaffolds a test suite and has nothing to migrate here.');
478
376
  }
377
+ /**
378
+ * The deduplicated, sorted union of the root shape and every package's shape.
379
+ *
380
+ * `unknown` is excluded: it is the absence of a shape, and carrying it into a
381
+ * union that drives deployment would let "we could not tell" masquerade as a
382
+ * detected shape. An empty array therefore means nothing was detected (#504).
383
+ */
384
+ function unionShapes(rootShape, ws) {
385
+ const shapes = new Set();
386
+ if (rootShape !== 'unknown' && rootShape !== '')
387
+ shapes.add(rootShape);
388
+ for (const f of ws?.findings ?? []) {
389
+ if (f.shape !== 'unknown' && f.shape !== '')
390
+ shapes.add(f.shape);
391
+ }
392
+ return [...shapes].sort();
393
+ }
479
394
  export class MigrationContext {
480
395
  project_root;
481
396
  is_harness_project;
@@ -487,6 +402,21 @@ export class MigrationContext {
487
402
  detection_confidence;
488
403
  config_warnings;
489
404
  not_test_project_reason;
405
+ /**
406
+ * Declared workspace topology, or null for a single-package repo.
407
+ *
408
+ * Null is the back-compatibility guarantee -- a repo that declares no
409
+ * workspace never enters the plural path (#504 part 1).
410
+ */
411
+ workspace;
412
+ /**
413
+ * Every shape detected anywhere, deduplicated and sorted.
414
+ *
415
+ * Populated but not yet read: plural deployment is Milestone 2. Landing the
416
+ * data first keeps this change reviewable as "detection got richer, nothing
417
+ * else moved" (#504 part 1).
418
+ */
419
+ shapes;
490
420
  constructor(init) {
491
421
  this.project_root = init.project_root;
492
422
  this.is_harness_project = init.is_harness_project;
@@ -497,6 +427,8 @@ export class MigrationContext {
497
427
  this.detection_confidence = init.detection_confidence ?? 'none';
498
428
  this.config_warnings = init.config_warnings ?? [];
499
429
  this.not_test_project_reason = init.not_test_project_reason ?? null;
430
+ this.workspace = init.workspace ?? null;
431
+ this.shapes = init.shapes ?? [];
500
432
  }
501
433
  }
502
434
  export class SkillDeployResult {
@@ -735,10 +667,16 @@ export class MigrationReport {
735
667
  skipped_configs;
736
668
  preserved_files;
737
669
  would_create;
670
+ /** Suites already present in workspace packages (#504 (3)). */
671
+ existing_suites;
738
672
  manual_followups;
739
673
  deployed_skills;
740
674
  installed_workflows;
741
675
  config_warnings;
676
+ /** Declared workspace topology, or null for a single-package repo (#504). */
677
+ workspace;
678
+ /** Every detected shape, deduplicated and sorted; unread until Milestone 2. */
679
+ shapes;
742
680
  constructor(init) {
743
681
  this.framework = init.framework;
744
682
  this.shape = init.shape;
@@ -750,10 +688,39 @@ export class MigrationReport {
750
688
  this.skipped_configs = init.skipped_configs ?? [];
751
689
  this.preserved_files = init.preserved_files ?? [];
752
690
  this.would_create = init.would_create ?? [];
691
+ this.existing_suites = init.existing_suites ?? [];
753
692
  this.manual_followups = init.manual_followups ?? [];
754
693
  this.deployed_skills = init.deployed_skills ?? [];
755
694
  this.installed_workflows = init.installed_workflows ?? [];
756
695
  this.config_warnings = init.config_warnings ?? [];
696
+ this.workspace = init.workspace ?? null;
697
+ this.shapes = init.shapes ?? [];
698
+ }
699
+ /**
700
+ * What the workspace walk actually covered, stated with its denominator.
701
+ *
702
+ * Emitted only when a workspace was declared, so single-package output is
703
+ * untouched. Saying "0 packages carry a test config" is a different claim
704
+ * from saying nothing at all, which reads as "there was nothing to find"
705
+ * (#504 part 1, criteria 4 and 5).
706
+ */
707
+ workspaceNotes() {
708
+ const ws = this.workspace;
709
+ if (ws === null)
710
+ return [];
711
+ const notes = [];
712
+ if (ws.globs.length > 0 && ws.scanned === 0) {
713
+ const g = ws.globs.length === 1 ? '1 glob' : `${ws.globs.length} globs`;
714
+ notes.push(`Declared ${g}, matched 0 packages.`);
715
+ }
716
+ else if (ws.scanned > 0 && ws.findings.length === 0) {
717
+ const p = ws.scanned === 1 ? '1 package' : `${ws.scanned} packages`;
718
+ notes.push(`${p} scanned, none carries a recognizable test config.`);
719
+ }
720
+ for (const dir of ws.unreadable) {
721
+ notes.push(`\`${dir}/\` could not be read and was not scanned.`);
722
+ }
723
+ return notes;
757
724
  }
758
725
  /**
759
726
  * The dry run's denominator (#504): config files that would be created,
@@ -787,10 +754,28 @@ export class MigrationReport {
787
754
  config: `high ${EMDASH} dedicated config file`,
788
755
  content: `medium ${EMDASH} file content / dependency scan`,
789
756
  language: `low ${EMDASH} harness.config.json language fallback`,
757
+ override: `high ${EMDASH} explicit \`--framework\` flag`,
790
758
  }[this.detection_confidence] ?? this.detection_confidence;
791
759
  lines.push(`**Detected from:** \`${this.detection_source}\``, `**Confidence:** ${confidenceLabel}`);
792
760
  }
793
761
  lines.push('');
762
+ const wsNotes = this.workspaceNotes();
763
+ if (wsNotes.length > 0) {
764
+ lines.push('## Workspace', '');
765
+ for (const n of wsNotes)
766
+ lines.push(`- ${n}`);
767
+ lines.push('');
768
+ }
769
+ // #504 (3): name what was found before saying what would happen, so the
770
+ // empty "Would Create" below reads as a decision rather than a shrug.
771
+ if (this.existing_suites.length > 0) {
772
+ lines.push('## Existing Suites Found', '');
773
+ for (const s of this.existing_suites) {
774
+ const tests = s.test_count === 1 ? '1 test file' : `${s.test_count} test files`;
775
+ lines.push(`- \`${s.dir}/\` ${EMDASH} \`${s.config}\` (${tests})`);
776
+ }
777
+ lines.push('');
778
+ }
794
779
  if (this.dry_run) {
795
780
  if (this.preserved_files.length > 0) {
796
781
  lines.push('## Existing Tests (will be preserved)', '');
@@ -804,6 +789,12 @@ export class MigrationReport {
804
789
  lines.push(`- \`${f}\``);
805
790
  lines.push('');
806
791
  }
792
+ else if (this.existing_suites.length > 0) {
793
+ const names = this.existing_suites.map((s) => `\`${s.dir}/\``);
794
+ lines.push('## Would Create', '', `_Nothing ${EMDASH} ${names.join(', ')} already ` +
795
+ `carries a ${this.framework} suite. To scaffold a second one, ` +
796
+ 're-run `canary migrate` from inside that package._', '');
797
+ }
807
798
  else {
808
799
  lines.push('## Would Create', '', '_Nothing new ' +
809
800
  EMDASH +
@@ -880,11 +871,17 @@ export class MigrationReport {
880
871
  });
881
872
  lines.push('## Status', '');
882
873
  if (outcome.abstained) {
883
- lines.push(outcome.summaryLine, '', 'This dry run would migrate zero item(s) ' +
884
- EMDASH +
885
- ' the project already carries everything this migration would ' +
886
- 'produce. If you expected changes, check `--from <overlay>` and ' +
887
- 'the detected framework/shape above.', '');
874
+ // Zero is zero either way, but *why* differs: an already-complete
875
+ // project and a repo whose suite lives in a package are different
876
+ // situations, and the generic sentence misdescribes the second.
877
+ const why = this.existing_suites.length > 0
878
+ ? 'the suite this migration would scaffold already exists in ' +
879
+ `${this.existing_suites.map((s) => `\`${s.dir}/\``).join(', ')}. ` +
880
+ 'Re-run `canary migrate` from inside that package to migrate it.'
881
+ : 'the project already carries everything this migration would ' +
882
+ 'produce. If you expected changes, check `--from <overlay>` and ' +
883
+ 'the detected framework/shape above.';
884
+ lines.push(outcome.summaryLine, '', `This dry run would migrate zero item(s) ${EMDASH} ${why}`, '');
888
885
  }
889
886
  else {
890
887
  lines.push(`Dry run ${EMDASH} would migrate ${n} item(s). ` +
@@ -946,7 +943,10 @@ export class HarnessMigrator {
946
943
  not_test_project_reason: overlayReason,
947
944
  });
948
945
  }
949
- const [framework, shape, source, confidence] = this.detectFramework(projectRoot, harnessConfig);
946
+ // Detected once and threaded: `detectFramework` resolves the scalar from it
947
+ // and the context carries it, so the package walk happens a single time.
948
+ const workspace = detectWorkspace(projectRoot, harnessConfig, configWarnings);
949
+ const [framework, shape, source, confidence] = this.detectFramework(projectRoot, harnessConfig, workspace);
950
950
  return new MigrationContext({
951
951
  project_root: projectRoot,
952
952
  is_harness_project: true,
@@ -956,6 +956,8 @@ export class HarnessMigrator {
956
956
  detection_source: source,
957
957
  detection_confidence: confidence,
958
958
  config_warnings: configWarnings,
959
+ workspace,
960
+ shapes: unionShapes(shape, workspace),
959
961
  });
960
962
  }
961
963
  migrate(projectRoot, options = {}) {
@@ -973,11 +975,25 @@ export class HarnessMigrator {
973
975
  const effectiveFramework = pyTruthy(framework)
974
976
  ? framework
975
977
  : ctx.detected_framework;
976
- const shape = ctx.detected_shape;
977
978
  const source = pyTruthy(framework) ? 'CLI override' : ctx.detection_source;
979
+ // #504 (2): an override is high-confidence *user intent*, not a config-file
980
+ // find. Reporting it as 'config' rendered "high -- dedicated config file",
981
+ // asserting evidence no probe ever gathered.
978
982
  const confidence = pyTruthy(framework)
979
- ? 'config'
983
+ ? 'override'
980
984
  : ctx.detection_confidence;
985
+ // #504 (2): the override must resolve the shape too. Without this, shape
986
+ // stays 'unknown' and every shape-keyed behavior downstream (overlay-skill
987
+ // matching, `<shape>:`-prefixed workflow templates) silently no-ops, so the
988
+ // override only half-works. An explicit `canary_shape` is a stronger
989
+ // statement of intent than the framework name, so it still wins.
990
+ const explicitShape = String(ctx.harness_config['canary_shape'] ?? '')
991
+ .trim()
992
+ .toLowerCase();
993
+ const overrideShape = pyTruthy(framework) && explicitShape === ''
994
+ ? shapeForFrameworkOverride(framework, projectRoot)
995
+ : null;
996
+ const shape = overrideShape ?? ctx.detected_shape;
981
997
  const followups = [];
982
998
  if (effectiveFramework === null) {
983
999
  followups.push(uncertainDetectionMessage('test framework', {
@@ -997,6 +1013,8 @@ export class HarnessMigrator {
997
1013
  detection_confidence: confidence,
998
1014
  manual_followups: followups,
999
1015
  config_warnings: ctx.config_warnings,
1016
+ workspace: ctx.workspace,
1017
+ shapes: ctx.shapes,
1000
1018
  deployed_skills: deployed,
1001
1019
  installed_workflows: this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force),
1002
1020
  });
@@ -1013,6 +1031,9 @@ export class HarnessMigrator {
1013
1031
  `scaffolded; set it up manually.${run}`);
1014
1032
  }
1015
1033
  const preserved = this.findExistingTests(projectRoot);
1034
+ // #504 (3): a workspace repo that already has a suite for this framework
1035
+ // must not be offered a second one at the root.
1036
+ const existingSuites = findWorkspaceSuites(projectRoot, effectiveFramework);
1016
1037
  const scaffolder = new Scaffolder();
1017
1038
  const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
1018
1039
  // Post-copy install phase: the template bytes already landed under
@@ -1022,10 +1043,14 @@ export class HarnessMigrator {
1022
1043
  const tmpl = TEMPLATES[effectiveFramework];
1023
1044
  const files = tmpl?.files ?? {};
1024
1045
  const dirs = tmpl?.dirs ?? [];
1025
- const wouldCreate = [
1026
- ...Object.keys(files).filter((f) => !existsSync(join(projectRoot, f))),
1027
- ...dirs.filter((d) => !existsSync(join(projectRoot, d))),
1028
- ];
1046
+ // A suite already exists in a package: propose nothing rather than a
1047
+ // duplicate. The suites are named in the report so the zero is legible.
1048
+ const wouldCreate = existingSuites.length > 0
1049
+ ? []
1050
+ : [
1051
+ ...Object.keys(files).filter((f) => !existsSync(join(projectRoot, f))),
1052
+ ...dirs.filter((d) => !existsSync(join(projectRoot, d))),
1053
+ ];
1029
1054
  const alreadyPresent = Object.keys(files).filter((f) => existsSync(join(projectRoot, f)));
1030
1055
  return new MigrationReport({
1031
1056
  framework: effectiveFramework,
@@ -1036,19 +1061,28 @@ export class HarnessMigrator {
1036
1061
  would_create: wouldCreate,
1037
1062
  skipped_configs: alreadyPresent,
1038
1063
  preserved_files: preserved,
1064
+ existing_suites: existingSuites,
1039
1065
  manual_followups: followups,
1040
1066
  deployed_skills: deployed,
1041
1067
  installed_workflows: installedWorkflows,
1042
1068
  config_warnings: ctx.config_warnings,
1069
+ workspace: ctx.workspace,
1070
+ shapes: ctx.shapes,
1043
1071
  });
1044
1072
  }
1045
- const result = scaffolder.scaffold(effectiveFramework, String(projectRoot));
1073
+ // Same guard on the apply path: what the dry run refuses to propose, an
1074
+ // `--apply` run must refuse to write. Skills and workflows still deploy --
1075
+ // only the duplicate test-config scaffold is withheld.
1076
+ const result = existingSuites.length > 0
1077
+ ? { created_files: [], created_dirs: [], skipped_files: [] }
1078
+ : scaffolder.scaffold(effectiveFramework, String(projectRoot));
1046
1079
  return new MigrationReport({
1047
1080
  framework: effectiveFramework,
1048
1081
  shape,
1049
1082
  dry_run: false,
1050
1083
  detection_source: source,
1051
1084
  detection_confidence: confidence,
1085
+ existing_suites: existingSuites,
1052
1086
  created_files: result['created_files'],
1053
1087
  created_dirs: result['created_dirs'],
1054
1088
  skipped_configs: result['skipped_files'],
@@ -1057,6 +1091,8 @@ export class HarnessMigrator {
1057
1091
  deployed_skills: deployed,
1058
1092
  installed_workflows: installedWorkflows,
1059
1093
  config_warnings: ctx.config_warnings,
1094
+ workspace: ctx.workspace,
1095
+ shapes: ctx.shapes,
1060
1096
  });
1061
1097
  }
1062
1098
  // -- private helpers --------------------------------------------------------
@@ -1328,7 +1364,7 @@ export class HarnessMigrator {
1328
1364
  // writes. Informational only -- see FreshnessReport.workflows.
1329
1365
  this.installWorkflows(shape, overlayPath, projectRoot, true, false));
1330
1366
  }
1331
- detectFramework(root, config) {
1367
+ detectFramework(root, config, ws = null) {
1332
1368
  // Explicit override in .canary/company.json ("canary_shape" field) is
1333
1369
  // user intent: it wins over every probe tier's shape, including a total
1334
1370
  // probe miss (#502 — monorepos often have no root framework config).
@@ -1338,94 +1374,47 @@ export class HarnessMigrator {
1338
1374
  const explicitShape = (rawShape == null ? '' : String(rawShape))
1339
1375
  .trim()
1340
1376
  .toLowerCase();
1341
- const [framework, shape, source, confidence] = this.probeFramework(root, config);
1377
+ const rootProbe = this.probeFramework(root, config);
1378
+ // A root miss falls through to the workspace packages -- but only a miss.
1379
+ // A root config file still outranks them, unchanged from before (#504).
1380
+ const resolved = rootProbe[0] === null && ws !== null
1381
+ ? (this.resolveFromWorkspace(ws) ?? rootProbe)
1382
+ : rootProbe;
1383
+ const [framework, shape, source, confidence] = resolved;
1342
1384
  if (!explicitShape)
1343
1385
  return [framework, shape, source, confidence];
1344
1386
  return framework === null
1345
1387
  ? [null, explicitShape, 'canary_shape (.canary/company.json)', 'explicit']
1346
1388
  : [framework, explicitShape, source, confidence];
1347
1389
  }
1348
- probeFramework(root, config) {
1349
- // 1. Dedicated config file (highest confidence).
1350
- for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
1351
- if (existsSync(join(root, filename))) {
1352
- // For playwright config files, distinguish API vs UI suites.
1353
- if (framework === 'playwright' && shape === 'e2e_ui') {
1354
- const inferred = inferPlaywrightShape(root);
1355
- if (inferred !== shape)
1356
- return [framework, inferred, filename, 'content'];
1357
- }
1358
- return [framework, shape, filename, confidence];
1359
- }
1360
- }
1361
- // 2. pyproject.toml section markers then dependency scan.
1362
- const pyproject = join(root, 'pyproject.toml');
1363
- if (existsSync(pyproject)) {
1364
- const content = readTextOrNull(pyproject);
1365
- if (content !== null) {
1366
- for (const [marker, framework, shape] of _PYPROJECT_MARKERS) {
1367
- if (content.includes(marker)) {
1368
- return [framework, shape, 'pyproject.toml', 'content'];
1369
- }
1370
- }
1371
- for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
1372
- if (pattern.test(content)) {
1373
- return [
1374
- framework,
1375
- shape,
1376
- 'pyproject.toml (dependencies)',
1377
- 'content',
1378
- ];
1379
- }
1380
- }
1381
- }
1382
- }
1383
- // 3. requirements*.txt dependency scan.
1384
- for (const reqFile of [
1385
- 'requirements.txt',
1386
- 'requirements-test.txt',
1387
- 'requirements-dev.txt',
1388
- ]) {
1389
- const reqPath = join(root, reqFile);
1390
- if (existsSync(reqPath)) {
1391
- const content = readTextOrNull(reqPath);
1392
- if (content !== null) {
1393
- for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
1394
- if (pattern.test(content))
1395
- return [framework, shape, reqFile, 'content'];
1396
- }
1397
- }
1398
- }
1399
- }
1400
- // 4. package.json scripts.test scan.
1401
- const pkgJson = join(root, 'package.json');
1402
- if (existsSync(pkgJson)) {
1403
- try {
1404
- const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'));
1405
- const scripts = (pkg['scripts'] ?? {});
1406
- const testScript = String(scripts['test'] ?? '');
1407
- for (const [pattern, framework, shape] of _PACKAGE_SCRIPT_PATTERNS) {
1408
- if (pattern.test(testScript)) {
1409
- return [framework, shape, 'package.json (scripts.test)', 'content'];
1410
- }
1411
- }
1412
- }
1413
- catch {
1414
- // OSError / JSONDecodeError -> ignore.
1415
- }
1416
- }
1417
- // 5. Language fallback from harness config.
1418
- const language = String(config['language'] ?? '').toLowerCase();
1419
- if (Object.prototype.hasOwnProperty.call(_LANGUAGE_FALLBACKS, language)) {
1420
- const [fw, shape] = _LANGUAGE_FALLBACKS[language];
1421
- return [
1422
- fw,
1423
- shape,
1424
- `harness.config.json (language: ${language})`,
1425
- 'language',
1426
- ];
1390
+ /**
1391
+ * The scalar (framework, shape, source) implied by workspace findings.
1392
+ *
1393
+ * Unanimity is on the (framework, shape) PAIR, not the framework alone --
1394
+ * shape drives which overlay skills and workflow templates deploy, so two
1395
+ * playwright packages resolving to e2e_ui and api are not unanimous. Applies
1396
+ * at N >= 1: `detection_source` reports the package count, so the scalar
1397
+ * never pretends a root probe hit (#504 part 1).
1398
+ */
1399
+ resolveFromWorkspace(ws) {
1400
+ const findings = ws.findings;
1401
+ if (findings.length === 0)
1402
+ return null;
1403
+ const pairs = new Set(findings.map((f) => `${f.framework}${f.shape}`));
1404
+ if (pairs.size > 1) {
1405
+ return [null, 'unknown', 'workspace (mixed)', 'none'];
1427
1406
  }
1428
- return [null, 'unknown', 'none', 'none'];
1407
+ const first = findings[0];
1408
+ const n = new Set(findings.map((f) => f.dir)).size;
1409
+ return [
1410
+ first.framework,
1411
+ first.shape,
1412
+ `workspace (${n} package${n === 1 ? '' : 's'})`,
1413
+ first.confidence,
1414
+ ];
1415
+ }
1416
+ probeFramework(root, config) {
1417
+ return probe(root, config, ['config', 'content', 'language']);
1429
1418
  }
1430
1419
  findExistingTests(root) {
1431
1420
  const found = [];
@@ -1441,42 +1430,4 @@ export class HarnessMigrator {
1441
1430
  return found;
1442
1431
  }
1443
1432
  }
1444
- function readTextOrNull(path) {
1445
- try {
1446
- return readFileSync(path, 'utf-8');
1447
- }
1448
- catch {
1449
- return null;
1450
- }
1451
- }
1452
- /**
1453
- * Return 'api' when no playwright spec file uses page/browser fixtures, else
1454
- * 'e2e_ui' (the default when any UI signal is found or no spec files exist).
1455
- */
1456
- function inferPlaywrightShape(root) {
1457
- const specGlobs = [
1458
- 'tests/**/*.spec.ts',
1459
- 'tests/**/*.spec.js',
1460
- 'test/**/*.spec.ts',
1461
- 'test/**/*.spec.js',
1462
- ];
1463
- let total = 0;
1464
- for (const glob of specGlobs) {
1465
- for (const path of globFiles(root, glob)) {
1466
- // Python read_text(errors="ignore"); readFileSync substitutes U+FFFD for
1467
- // invalid bytes -- immaterial for the ASCII fixture pattern below.
1468
- let content;
1469
- try {
1470
- content = readFileSync(path, 'utf-8');
1471
- }
1472
- catch {
1473
- continue;
1474
- }
1475
- total += 1;
1476
- if (_PW_UI_FIXTURE_RE.test(content))
1477
- return 'e2e_ui';
1478
- }
1479
- }
1480
- return total > 0 ? 'api' : 'e2e_ui';
1481
- }
1482
1433
  //# sourceMappingURL=migrator.js.map