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 +69 -1
- package/dist/doctor-manifest.js +6 -1
- package/dist/doctor.js +7 -4
- package/dist/engine/cli-commands.js +84 -25
- package/dist/engine/cli.core.js +1 -1
- package/dist/engine/core/framework-probes.js +218 -0
- package/dist/engine/core/fs-glob.js +185 -0
- package/dist/engine/core/gate-result.js +27 -4
- package/dist/engine/core/migrator.js +240 -289
- package/dist/engine/core/static-linter.js +44 -4
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +34 -30
- package/dist/engine/guardian/analysis-emit.js +13 -4
- package/dist/engine/guardian/cli.js +113 -16
- package/dist/engine/guardian/coverage.js +291 -9
- package/dist/engine/guardian/github-paging.js +97 -0
- package/dist/engine/guardian/pr-check.js +285 -26
- package/dist/engine/guardian/pr-comment.js +29 -15
- package/dist/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/package.json +2 -2
|
@@ -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,
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
return
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
'the
|
|
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
|
-
|
|
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
|
-
? '
|
|
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
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
|
|
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
|