canary-test-cli 6.8.1 → 7.1.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/dist/engine/analysis/cli.js +261 -89
- package/dist/engine/analysis/engine.js +39 -21
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/cli-commands.js +251 -43
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +151 -48
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-examples.js +292 -0
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +312 -40
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +556 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +6 -6
- package/dist/engine/guardian/agent-tier.js +3 -3
- package/dist/engine/guardian/analysis-emit.js +13 -27
- package/dist/engine/guardian/cli.js +30 -43
- package/dist/engine/guardian/coverage.js +15 -1420
- package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
- package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
- package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
- package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
- package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
- package/dist/engine/guardian/diff-coverage/paths.js +164 -0
- package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
- package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
- package/dist/engine/guardian/diff-coverage/types.js +115 -0
- package/dist/engine/guardian/pr-check.js +10 -20
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +133 -11
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/engine-checks.d.ts +15 -0
- package/dist/engine-checks.js +92 -1
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/overlay-commands.d.ts +12 -1
- package/dist/overlay-commands.js +28 -2
- package/dist/router.js +17 -5
- package/dist/uninstall-render.d.ts +11 -0
- package/dist/uninstall-render.js +60 -0
- package/dist/uninstall-scan.d.ts +14 -0
- package/dist/uninstall-scan.js +273 -0
- package/dist/uninstall-types.d.ts +46 -0
- package/dist/uninstall-types.js +91 -0
- package/dist/uninstall.d.ts +13 -0
- package/dist/uninstall.js +181 -0
- package/package.json +1 -1
|
@@ -42,13 +42,14 @@ 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 {
|
|
45
|
+
import { CONFIG_PROBES, inferPlaywrightTestType, probeFramework, } from './framework-probes.js';
|
|
46
46
|
import { comparePathParts, globDirs, globFiles, isDir, isFile, readTextOrNull, } from './fs-glob.js';
|
|
47
47
|
import { detectWorkspace, workspaceGlobs, } from './workspace-detect.js';
|
|
48
48
|
import { FrameworkRegistry } from './framework-registry.js';
|
|
49
49
|
import { EXIT_ABSTAINED, gateOutcome } from './gate-result.js';
|
|
50
50
|
import { Scaffolder, scaffoldableFrameworks, TEMPLATES } from './scaffolder.js';
|
|
51
51
|
import { SkillRegistry } from './skill-registry.js';
|
|
52
|
+
import { ensureAscii } from '../util/ensure-ascii.js';
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
53
54
|
// Python-compatibility helpers (copied locally per-module, matching reporter.ts)
|
|
54
55
|
// ---------------------------------------------------------------------------
|
|
@@ -64,13 +65,6 @@ function pyTruthy(value) {
|
|
|
64
65
|
return Object.keys(value).length > 0;
|
|
65
66
|
return Boolean(value);
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Reproduce `json.dumps(..., ensure_ascii=True)` on `JSON.stringify` output:
|
|
69
|
-
* escape every code point >= 0x80 as `\uXXXX`. (Same helper as reporter.ts.)
|
|
70
|
-
*/
|
|
71
|
-
function ensureAscii(json) {
|
|
72
|
-
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
73
|
-
}
|
|
74
68
|
// Non-ASCII glyphs kept out of the source text as escapes, emitted verbatim.
|
|
75
69
|
const CHECK = '\u{2705}'; // white heavy check mark
|
|
76
70
|
const WARN = '\u{26A0}'; // warning sign
|
|
@@ -248,6 +242,23 @@ function resolveTemplatePath(skillDir, rel) {
|
|
|
248
242
|
return null;
|
|
249
243
|
return full;
|
|
250
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Is the deployed copy of a skill the consumer's rather than the overlay's?
|
|
247
|
+
*
|
|
248
|
+
* True when it differs from the overlay AND from the hash recorded at deploy
|
|
249
|
+
* time -- or carries no recorded provenance at all, since unknown provenance is
|
|
250
|
+
* not permission to overwrite.
|
|
251
|
+
*
|
|
252
|
+
* This is the single definition of `local_edit`. The skill-deploy phase refuses
|
|
253
|
+
* to overwrite on it (#334) and the workflow-install phase withholds on it
|
|
254
|
+
* (#667); sharing one predicate is what keeps the two phases from reporting
|
|
255
|
+
* contradictory things about the same skill in the same run.
|
|
256
|
+
*/
|
|
257
|
+
function isLocallyEdited(overlayHash, targetHash, recordedHash) {
|
|
258
|
+
if (targetHash === overlayHash)
|
|
259
|
+
return false;
|
|
260
|
+
return recordedHash === undefined || targetHash !== recordedHash;
|
|
261
|
+
}
|
|
251
262
|
// ---------------------------------------------------------------------------
|
|
252
263
|
// Constants / probes
|
|
253
264
|
// ---------------------------------------------------------------------------
|
|
@@ -255,7 +266,7 @@ function resolveTemplatePath(skillDir, rel) {
|
|
|
255
266
|
* Frameworks a user can pass to `canary migrate --framework <name>`. Surfaced in
|
|
256
267
|
* the fail-loud message when auto-detection is uncertain (issue #295).
|
|
257
268
|
*/
|
|
258
|
-
|
|
269
|
+
const KNOWN_FRAMEWORKS = [
|
|
259
270
|
'playwright',
|
|
260
271
|
'vitest',
|
|
261
272
|
'pytest',
|
|
@@ -294,9 +305,7 @@ function findWorkspaceSuites(root, framework) {
|
|
|
294
305
|
const globs = workspaceGlobs(root);
|
|
295
306
|
if (globs.length === 0)
|
|
296
307
|
return [];
|
|
297
|
-
const configNames =
|
|
298
|
-
.filter(([, fw]) => fw === framework)
|
|
299
|
-
.map(([filename]) => filename);
|
|
308
|
+
const configNames = CONFIG_PROBES.filter(([, fw]) => fw === framework).map(([filename]) => filename);
|
|
300
309
|
if (configNames.length === 0)
|
|
301
310
|
return [];
|
|
302
311
|
const dirs = new Set();
|
|
@@ -330,11 +339,11 @@ function findWorkspaceSuites(root, framework) {
|
|
|
330
339
|
* shape detection produced rather than overwriting it with a guess.
|
|
331
340
|
*/
|
|
332
341
|
function shapeForFrameworkOverride(framework, root) {
|
|
333
|
-
for (const [,
|
|
334
|
-
if (
|
|
342
|
+
for (const [, candidateFramework, shape] of CONFIG_PROBES) {
|
|
343
|
+
if (candidateFramework !== framework)
|
|
335
344
|
continue;
|
|
336
|
-
if (
|
|
337
|
-
return
|
|
345
|
+
if (candidateFramework === 'playwright' && shape === 'e2e_ui') {
|
|
346
|
+
return inferPlaywrightTestType(root);
|
|
338
347
|
}
|
|
339
348
|
return shape;
|
|
340
349
|
}
|
|
@@ -455,8 +464,10 @@ export class SkillDeployResult {
|
|
|
455
464
|
* - `dry_run` -- what an `--apply` run would have done
|
|
456
465
|
* - `missing` -- the overlay declares a template it does not ship
|
|
457
466
|
* - `invalid` -- the declared path escapes the skill directory (refused)
|
|
467
|
+
* - `withheld` -- the declaring skill was skipped as locally edited, so its
|
|
468
|
+
* templates were not installed either (#667)
|
|
458
469
|
*
|
|
459
|
-
* `outdated` and `
|
|
470
|
+
* `outdated`, `conflict`, and `withheld` are REPORTS. None ever writes.
|
|
460
471
|
*/
|
|
461
472
|
export class WorkflowInstallResult {
|
|
462
473
|
/** File name under `.github/workflows/`. */
|
|
@@ -506,11 +517,21 @@ export class FreshnessReport {
|
|
|
506
517
|
* nagging about something canary has no claim over.
|
|
507
518
|
*/
|
|
508
519
|
workflows;
|
|
509
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Every shape the gate actually resolved -- the set `migrate` deploys for
|
|
522
|
+
* (#504 part 1). The scalar `shape` above stays, because the `--check --json`
|
|
523
|
+
* payload is documented and consumers read it; `shapes` is additive.
|
|
524
|
+
*
|
|
525
|
+
* A mixed monorepo collapses `shape` to `unknown` while `shapes` still names
|
|
526
|
+
* both, so the two disagree by design rather than by accident.
|
|
527
|
+
*/
|
|
528
|
+
shapes;
|
|
529
|
+
constructor(shape, overlay_path = null, results = [], workflows = [], shapes = []) {
|
|
510
530
|
this.shape = shape;
|
|
511
531
|
this.overlay_path = overlay_path;
|
|
512
532
|
this.results = results;
|
|
513
533
|
this.workflows = workflows;
|
|
534
|
+
this.shapes = shapes;
|
|
514
535
|
}
|
|
515
536
|
get stale() {
|
|
516
537
|
return this.results.filter((r) => r.status === 'stale' || r.status === 'missing');
|
|
@@ -564,6 +585,7 @@ export class FreshnessReport {
|
|
|
564
585
|
to_dict() {
|
|
565
586
|
return {
|
|
566
587
|
shape: this.shape,
|
|
588
|
+
shapes: this.shapes,
|
|
567
589
|
overlay_path: this.overlay_path,
|
|
568
590
|
in_sync: this.in_sync,
|
|
569
591
|
has_drift: this.has_drift,
|
|
@@ -587,14 +609,20 @@ export class FreshnessReport {
|
|
|
587
609
|
`**Shape:** ${this.shape}`,
|
|
588
610
|
'',
|
|
589
611
|
];
|
|
612
|
+
// A workspace repo resolves several shapes at once; naming only the scalar
|
|
613
|
+
// would tell a reader `unknown` while the gate checked two shapes (#504).
|
|
614
|
+
if (this.shapes.length > 1) {
|
|
615
|
+
lines.push(`**Shapes checked:** ${this.shapes.map((s) => `\`${s}\``).join(', ')}`, '');
|
|
616
|
+
}
|
|
590
617
|
if (this.results.length === 0) {
|
|
591
618
|
lines.push("_No overlay skills match this project's shape._", '');
|
|
592
619
|
lines.push(`${WARN} **Abstained** ${EMDASH} the gate verified zero skills, so this is not a pass.`, '');
|
|
593
|
-
if (this.
|
|
620
|
+
if (this.shapes.length === 0) {
|
|
594
621
|
lines.push('The shape could not be detected. Set `canary_shape` in', '`.canary/company.json` or pass `--framework <name>`.', '');
|
|
595
622
|
}
|
|
596
623
|
else {
|
|
597
|
-
|
|
624
|
+
const covered = this.shapes.map((s) => `\`${s}\``).join(', ');
|
|
625
|
+
lines.push(`The overlay ships no skills with \`deploy_to\` covering ${covered}.`, "Check the overlay's `deploy_to` lists or the resolved `canary_shape`.", '');
|
|
598
626
|
}
|
|
599
627
|
lines.push(...workflowMarkdown(this.workflows, false));
|
|
600
628
|
return lines.join('\n');
|
|
@@ -654,6 +682,14 @@ function workflowMarkdown(results, dryRun) {
|
|
|
654
682
|
'differs from the template. Re-run with `--force` to take the ' +
|
|
655
683
|
"overlay's version.", '');
|
|
656
684
|
}
|
|
685
|
+
// #667: a withheld install is the one line that says why a template the
|
|
686
|
+
// overlay declares is absent from the target -- without it, the section
|
|
687
|
+
// simply omits the file and reads as though it were never declared.
|
|
688
|
+
if (results.some((r) => r.status === 'withheld')) {
|
|
689
|
+
lines.push(`${WARN} Templates declared by a skill with local edits were withheld: ` +
|
|
690
|
+
'the skill itself was skipped, so its workflows were not installed ' +
|
|
691
|
+
'either. Revert the local edits, or re-run with `--force`.', '');
|
|
692
|
+
}
|
|
657
693
|
return lines;
|
|
658
694
|
}
|
|
659
695
|
export class MigrationReport {
|
|
@@ -994,6 +1030,10 @@ export class HarnessMigrator {
|
|
|
994
1030
|
? shapeForFrameworkOverride(framework, projectRoot)
|
|
995
1031
|
: null;
|
|
996
1032
|
const shape = overrideShape ?? ctx.detected_shape;
|
|
1033
|
+
// The set that actually drives deployment. Re-derived from the *effective*
|
|
1034
|
+
// scalar rather than reusing `ctx.shapes`, so a `--framework` override
|
|
1035
|
+
// contributes its resolved shape to the union too (#504 parts 1-2).
|
|
1036
|
+
const deployShapes = unionShapes(shape, ctx.workspace);
|
|
997
1037
|
const followups = [];
|
|
998
1038
|
if (effectiveFramework === null) {
|
|
999
1039
|
followups.push(uncertainDetectionMessage('test framework', {
|
|
@@ -1004,7 +1044,7 @@ export class HarnessMigrator {
|
|
|
1004
1044
|
// Issue #295 point 3: a detection miss must not block skill deployment --
|
|
1005
1045
|
// nor, for the same reason, the workflow install (#459). The guardian
|
|
1006
1046
|
// workflow is exactly what an unrecognised repo most needs.
|
|
1007
|
-
const deployed = this.deploySkills(
|
|
1047
|
+
const deployed = this.deploySkills(deployShapes, overlayPath, projectRoot, dryRun);
|
|
1008
1048
|
return new MigrationReport({
|
|
1009
1049
|
framework: 'unknown',
|
|
1010
1050
|
shape,
|
|
@@ -1014,9 +1054,9 @@ export class HarnessMigrator {
|
|
|
1014
1054
|
manual_followups: followups,
|
|
1015
1055
|
config_warnings: ctx.config_warnings,
|
|
1016
1056
|
workspace: ctx.workspace,
|
|
1017
|
-
shapes:
|
|
1057
|
+
shapes: deployShapes,
|
|
1018
1058
|
deployed_skills: deployed,
|
|
1019
|
-
installed_workflows: this.installWorkflows(
|
|
1059
|
+
installed_workflows: this.installWorkflows(deployShapes, overlayPath, projectRoot, dryRun, force),
|
|
1020
1060
|
});
|
|
1021
1061
|
}
|
|
1022
1062
|
// A framework canary knows but cannot scaffold gets no config boilerplate.
|
|
@@ -1035,10 +1075,10 @@ export class HarnessMigrator {
|
|
|
1035
1075
|
// must not be offered a second one at the root.
|
|
1036
1076
|
const existingSuites = findWorkspaceSuites(projectRoot, effectiveFramework);
|
|
1037
1077
|
const scaffolder = new Scaffolder();
|
|
1038
|
-
const deployed = this.deploySkills(
|
|
1078
|
+
const deployed = this.deploySkills(deployShapes, overlayPath, projectRoot, dryRun);
|
|
1039
1079
|
// Post-copy install phase: the template bytes already landed under
|
|
1040
1080
|
// .canary/skills/ with the skill; this puts them where Actions looks.
|
|
1041
|
-
const installedWorkflows = this.installWorkflows(
|
|
1081
|
+
const installedWorkflows = this.installWorkflows(deployShapes, overlayPath, projectRoot, dryRun, force);
|
|
1042
1082
|
if (dryRun) {
|
|
1043
1083
|
const tmpl = TEMPLATES[effectiveFramework];
|
|
1044
1084
|
const files = tmpl?.files ?? {};
|
|
@@ -1067,7 +1107,7 @@ export class HarnessMigrator {
|
|
|
1067
1107
|
installed_workflows: installedWorkflows,
|
|
1068
1108
|
config_warnings: ctx.config_warnings,
|
|
1069
1109
|
workspace: ctx.workspace,
|
|
1070
|
-
shapes:
|
|
1110
|
+
shapes: deployShapes,
|
|
1071
1111
|
});
|
|
1072
1112
|
}
|
|
1073
1113
|
// Same guard on the apply path: what the dry run refuses to propose, an
|
|
@@ -1092,16 +1132,25 @@ export class HarnessMigrator {
|
|
|
1092
1132
|
installed_workflows: installedWorkflows,
|
|
1093
1133
|
config_warnings: ctx.config_warnings,
|
|
1094
1134
|
workspace: ctx.workspace,
|
|
1095
|
-
shapes:
|
|
1135
|
+
shapes: deployShapes,
|
|
1096
1136
|
});
|
|
1097
1137
|
}
|
|
1098
1138
|
// -- private helpers --------------------------------------------------------
|
|
1099
1139
|
/**
|
|
1100
1140
|
* Return `[[SkillInfo, skillDir], ...]` for overlay skills whose `deploy_to`
|
|
1101
|
-
* matches
|
|
1102
|
-
* `~/.canary/skills/`. The first definition of a
|
|
1141
|
+
* matches **any** shape in *shapes* (or the `all` sentinel). Sources:
|
|
1142
|
+
* *overlayPath* first, then `~/.canary/skills/`. The first definition of a
|
|
1143
|
+
* name wins.
|
|
1144
|
+
*
|
|
1145
|
+
* *shapes* is a set, not a scalar, because a monorepo genuinely has several
|
|
1146
|
+
* shapes at once (#504 part 1). One pass over the overlay collects the union
|
|
1147
|
+
* deduplicated by name, so a skill matching two shapes is collected once and
|
|
1148
|
+
* the deploy manifest is read and written exactly once -- which a per-shape
|
|
1149
|
+
* loop would not give.
|
|
1150
|
+
*
|
|
1151
|
+
* An empty *shapes* is the old `unknown`: only `deploy_to: [all]` matches.
|
|
1103
1152
|
*/
|
|
1104
|
-
collectOverlaySkills(
|
|
1153
|
+
collectOverlaySkills(shapes, overlayPath) {
|
|
1105
1154
|
const candidateRoots = [];
|
|
1106
1155
|
if (overlayPath !== null)
|
|
1107
1156
|
candidateRoots.push(overlayPath);
|
|
@@ -1143,7 +1192,7 @@ export class HarnessMigrator {
|
|
|
1143
1192
|
continue;
|
|
1144
1193
|
if (!pyTruthy(info.deploy_to))
|
|
1145
1194
|
continue;
|
|
1146
|
-
if (!info.deploy_to.includes(
|
|
1195
|
+
if (!shapes.some((s) => info.deploy_to.includes(s)) &&
|
|
1147
1196
|
!info.deploy_to.includes('all')) {
|
|
1148
1197
|
continue;
|
|
1149
1198
|
}
|
|
@@ -1154,12 +1203,12 @@ export class HarnessMigrator {
|
|
|
1154
1203
|
return collected;
|
|
1155
1204
|
}
|
|
1156
1205
|
/**
|
|
1157
|
-
* Copy skills from the overlay's `.canary/skills/`
|
|
1206
|
+
* Copy skills from the overlay's `.canary/skills/` matching any of *shapes*.
|
|
1158
1207
|
* Deployment is strictly one-way -- the overlay owns deployed files (#334).
|
|
1159
1208
|
*/
|
|
1160
|
-
deploySkills(
|
|
1209
|
+
deploySkills(shapes, overlayPath, targetRoot, dryRun) {
|
|
1161
1210
|
const results = [];
|
|
1162
|
-
const skillsToDeploy = this.collectOverlaySkills(
|
|
1211
|
+
const skillsToDeploy = this.collectOverlaySkills(shapes, overlayPath);
|
|
1163
1212
|
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
1164
1213
|
const doc = readManifestDoc(targetSkillsDir);
|
|
1165
1214
|
const manifest = doc.skills;
|
|
@@ -1178,8 +1227,7 @@ export class HarnessMigrator {
|
|
|
1178
1227
|
}
|
|
1179
1228
|
continue;
|
|
1180
1229
|
}
|
|
1181
|
-
|
|
1182
|
-
if (recorded === undefined || targetHash !== recorded) {
|
|
1230
|
+
if (isLocallyEdited(overlayHash, targetHash, manifest[dirName]?.hash)) {
|
|
1183
1231
|
// Hand-edited (or unprovenanced) -- one-way ownership refuses to clobber.
|
|
1184
1232
|
results.push(new SkillDeployResult(info.name, 'skipped', `local edits ${EMDASH} not overwritten`));
|
|
1185
1233
|
continue;
|
|
@@ -1210,6 +1258,21 @@ export class HarnessMigrator {
|
|
|
1210
1258
|
writeManifestDoc(targetSkillsDir, doc);
|
|
1211
1259
|
return results;
|
|
1212
1260
|
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Would the deploy phase skip this skill as `local_edit` (#667)?
|
|
1263
|
+
*
|
|
1264
|
+
* Answers for the CURRENT on-disk state, so it holds whether the deploy phase
|
|
1265
|
+
* has already run this invocation (apply) or never will (dry run / `--check`)
|
|
1266
|
+
* -- the workflow report then says the same thing about a skill as the skill
|
|
1267
|
+
* report does.
|
|
1268
|
+
*/
|
|
1269
|
+
skillWasSkipped(skillDir, targetSkillsDir, manifest) {
|
|
1270
|
+
const dirName = basename(skillDir);
|
|
1271
|
+
const dest = join(targetSkillsDir, dirName);
|
|
1272
|
+
if (!existsSync(dest))
|
|
1273
|
+
return false;
|
|
1274
|
+
return isLocallyEdited(hashSkillDir(skillDir), hashSkillDir(dest), manifest[dirName]?.hash);
|
|
1275
|
+
}
|
|
1213
1276
|
/**
|
|
1214
1277
|
* Install the workflow templates the shape-matching overlay skills declare
|
|
1215
1278
|
* into the target's `.github/workflows/` (#459).
|
|
@@ -1225,22 +1288,43 @@ export class HarnessMigrator {
|
|
|
1225
1288
|
* a hand-tuned workflow -- or nagging that it is "stale" via an exit code --
|
|
1226
1289
|
* would be a worse failure than the partial adoption this fixes.
|
|
1227
1290
|
*
|
|
1228
|
-
*
|
|
1229
|
-
* `
|
|
1230
|
-
*
|
|
1291
|
+
* **The skill's own protection extends here (#667).** A skill the deploy
|
|
1292
|
+
* phase skips as `local_edit` installs nothing: it is reported `withheld`
|
|
1293
|
+
* with the reason and no bytes are written. Otherwise a run could announce
|
|
1294
|
+
* "skipped -- local edits, not overwritten" while writing that same skill's
|
|
1295
|
+
* files to `.github/workflows/` anyway, which is how a consumer who
|
|
1296
|
+
* deliberately de-listed a template got it reinstated on every sync. The
|
|
1297
|
+
* de-listing lives in the consumer's SKILL.md, and the install list is read
|
|
1298
|
+
* from the OVERLAY copy -- so withholding is also the only way that edit can
|
|
1299
|
+
* mean anything.
|
|
1300
|
+
*
|
|
1301
|
+
* Shape selection reuses the same resolved shape set that drives `deploy_to`
|
|
1302
|
+
* matching: skills are gated by {@link collectOverlaySkills}, and an entry may
|
|
1303
|
+
* additionally carry a `<shape>:` prefix to pick a variant. A prefixed entry
|
|
1304
|
+
* installs when its shape is anywhere in *shapes* (#504 part 1) -- a monorepo
|
|
1305
|
+
* carrying both an e2e and a unit package wants both variants, and matching
|
|
1306
|
+
* only the scalar would silently install neither once the scalar collapses to
|
|
1307
|
+
* `unknown`.
|
|
1231
1308
|
*/
|
|
1232
|
-
installWorkflows(
|
|
1309
|
+
installWorkflows(shapes, overlayPath, targetRoot, dryRun, force = false) {
|
|
1233
1310
|
const results = [];
|
|
1234
|
-
const skills = this.collectOverlaySkills(
|
|
1311
|
+
const skills = this.collectOverlaySkills(shapes, overlayPath);
|
|
1235
1312
|
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
1236
1313
|
const doc = readManifestDoc(targetSkillsDir);
|
|
1237
1314
|
const workflowsDir = join(targetRoot, '.github', 'workflows');
|
|
1238
1315
|
let manifestDirty = false;
|
|
1239
1316
|
for (const [info, skillDir] of skills) {
|
|
1240
1317
|
const { entries, version } = readWorkflowDeclaration(info.path);
|
|
1318
|
+
// Hashing a skill directory is not free, so only skills that actually
|
|
1319
|
+
// declare templates pay for the local-edit lookup.
|
|
1320
|
+
const skillWithheld = !force &&
|
|
1321
|
+
entries.length > 0 &&
|
|
1322
|
+
this.skillWasSkipped(skillDir, targetSkillsDir, doc.skills);
|
|
1241
1323
|
for (const entry of entries) {
|
|
1242
1324
|
const [wantShape, rel] = parseWorkflowEntry(entry);
|
|
1243
|
-
if (wantShape !== null &&
|
|
1325
|
+
if (wantShape !== null &&
|
|
1326
|
+
!shapes.includes(wantShape) &&
|
|
1327
|
+
wantShape !== 'all') {
|
|
1244
1328
|
continue;
|
|
1245
1329
|
}
|
|
1246
1330
|
const src = resolveTemplatePath(skillDir, rel);
|
|
@@ -1274,6 +1358,21 @@ export class HarnessMigrator {
|
|
|
1274
1358
|
const push = (status, detail) => {
|
|
1275
1359
|
results.push(new WorkflowInstallResult(name, info.name, status, detail));
|
|
1276
1360
|
};
|
|
1361
|
+
const installedBytes = existsSync(dest) ? readBytesOrNull(dest) : null;
|
|
1362
|
+
const alreadyCurrent = installedBytes !== null && installedBytes.equals(templateBytes);
|
|
1363
|
+
// #667: the declaring skill was skipped as locally edited, so this
|
|
1364
|
+
// template is not ours to place. An already-identical file is exempt:
|
|
1365
|
+
// it is a no-op, and calling that "withheld" would raise a finding on
|
|
1366
|
+
// every scheduled freshness check. Everything else is gated here,
|
|
1367
|
+
// ahead of `outdated`, whose remedy ("re-run with --force") would
|
|
1368
|
+
// otherwise talk a consumer into the very overwrite the skip refused.
|
|
1369
|
+
if (skillWithheld && !alreadyCurrent) {
|
|
1370
|
+
push('withheld', `.github/workflows/${name} was NOT installed ${EMDASH} its skill ` +
|
|
1371
|
+
`'${info.name}' has local edits and was skipped, so its ` +
|
|
1372
|
+
'templates were withheld too; re-run with --force to install it ' +
|
|
1373
|
+
'anyway');
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1277
1376
|
if (!existsSync(dest)) {
|
|
1278
1377
|
if (dryRun) {
|
|
1279
1378
|
push('dry_run', `would install .github/workflows/${name} (v${version})`);
|
|
@@ -1283,8 +1382,7 @@ export class HarnessMigrator {
|
|
|
1283
1382
|
push('installed', `installed .github/workflows/${name} (v${version})`);
|
|
1284
1383
|
continue;
|
|
1285
1384
|
}
|
|
1286
|
-
|
|
1287
|
-
if (installedBytes !== null && installedBytes.equals(templateBytes)) {
|
|
1385
|
+
if (alreadyCurrent) {
|
|
1288
1386
|
// Back-fill provenance for a hand-placed but identical file so a later
|
|
1289
1387
|
// template fix can be reported as `outdated` rather than `conflict`.
|
|
1290
1388
|
if (doc.workflows[name]?.hash !== templateHash)
|
|
@@ -1334,7 +1432,12 @@ export class HarnessMigrator {
|
|
|
1334
1432
|
'Expected harness.config.json and .harness/ directory.');
|
|
1335
1433
|
}
|
|
1336
1434
|
const shape = ctx.detected_shape;
|
|
1337
|
-
|
|
1435
|
+
// The gate must examine exactly what `migrate` deploys. Resolving only the
|
|
1436
|
+
// scalar here would let `--check` report "in sync" about skills it never
|
|
1437
|
+
// looked at in a monorepo -- a deliberate false green in a drift gate
|
|
1438
|
+
// (#504 part 1).
|
|
1439
|
+
const shapes = ctx.shapes;
|
|
1440
|
+
const skills = this.collectOverlaySkills(shapes, overlayPath);
|
|
1338
1441
|
const targetSkillsDir = join(projectRoot, '.canary', 'skills');
|
|
1339
1442
|
const manifest = readManifestDoc(targetSkillsDir).skills;
|
|
1340
1443
|
const results = [];
|
|
@@ -1362,7 +1465,7 @@ export class HarnessMigrator {
|
|
|
1362
1465
|
return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results,
|
|
1363
1466
|
// dryRun = true: `--check` reports what an install WOULD do and never
|
|
1364
1467
|
// writes. Informational only -- see FreshnessReport.workflows.
|
|
1365
|
-
this.installWorkflows(
|
|
1468
|
+
this.installWorkflows(shapes, overlayPath, projectRoot, true, false), shapes);
|
|
1366
1469
|
}
|
|
1367
1470
|
detectFramework(root, config, ws = null) {
|
|
1368
1471
|
// Explicit override in .canary/company.json ("canary_shape" field) is
|
|
@@ -1400,7 +1503,7 @@ export class HarnessMigrator {
|
|
|
1400
1503
|
const findings = ws.findings;
|
|
1401
1504
|
if (findings.length === 0)
|
|
1402
1505
|
return null;
|
|
1403
|
-
const pairs = new Set(findings.map((f) => `${f.framework}
|
|
1506
|
+
const pairs = new Set(findings.map((f) => `${f.framework}\0${f.shape}`));
|
|
1404
1507
|
if (pairs.size > 1) {
|
|
1405
1508
|
return [null, 'unknown', 'workspace (mixed)', 'none'];
|
|
1406
1509
|
}
|
|
@@ -1414,7 +1517,7 @@ export class HarnessMigrator {
|
|
|
1414
1517
|
];
|
|
1415
1518
|
}
|
|
1416
1519
|
probeFramework(root, config) {
|
|
1417
|
-
return
|
|
1520
|
+
return probeFramework(root, config, ['config', 'content', 'language']);
|
|
1418
1521
|
}
|
|
1419
1522
|
findExistingTests(root) {
|
|
1420
1523
|
const found = [];
|
|
@@ -8,18 +8,36 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
10
10
|
import { extname, join, resolve } from 'node:path';
|
|
11
|
+
import { JS_TEST_EXTENSIONS } from './static-linter.js';
|
|
12
|
+
/**
|
|
13
|
+
* `**\/*.<infix>.<ext>` for every extension the scanners can actually read.
|
|
14
|
+
*
|
|
15
|
+
* Derived from {@link JS_TEST_EXTENSIONS} rather than hand-listed, because the
|
|
16
|
+
* hand-listed version is what made #566 possible and this module carried the
|
|
17
|
+
* same gap one layer deeper: the globs covered `.ts`/`.js` only, so a project
|
|
18
|
+
* whose suite is ESM (`.mjs`) or CJS (`.cjs`) scanned to `test_count: 0` — a
|
|
19
|
+
* value indistinguishable from "this project has no tests", which every
|
|
20
|
+
* consumer branching on {@link isEmpty} then treats as a clean absence.
|
|
21
|
+
*
|
|
22
|
+
* No suite in this repo is `.mjs`/`.cjs`, so CI can never trip over this by
|
|
23
|
+
* accident; `pattern-matcher.test.ts` asserts the coverage against the shared
|
|
24
|
+
* extension list instead of restating it.
|
|
25
|
+
*/
|
|
26
|
+
function jsGlobs(...infixes) {
|
|
27
|
+
return infixes.flatMap((infix) => JS_TEST_EXTENSIONS.map((ext) => `**/*.${infix}${ext}`));
|
|
28
|
+
}
|
|
11
29
|
const FILE_PATTERNS = {
|
|
12
|
-
playwright:
|
|
13
|
-
vitest:
|
|
30
|
+
playwright: jsGlobs('spec', 'test'),
|
|
31
|
+
vitest: jsGlobs('test', 'spec'),
|
|
14
32
|
pytest: ['**/test_*.py', '**/*_test.py'],
|
|
15
33
|
k6: ['**/*.load.js', '**/load.js'],
|
|
16
|
-
e2e_ui:
|
|
17
|
-
frontend_unit:
|
|
34
|
+
e2e_ui: jsGlobs('spec'),
|
|
35
|
+
frontend_unit: jsGlobs('test'),
|
|
18
36
|
api: ['**/test_*.py', '**/*_test.py'],
|
|
19
37
|
performance: ['**/*.load.js'],
|
|
20
38
|
python_unit: ['**/test_*.py', '**/*_test.py'],
|
|
21
39
|
};
|
|
22
|
-
const DEFAULT_PATTERNS = ['**/test_*.py', '
|
|
40
|
+
const DEFAULT_PATTERNS = ['**/test_*.py', ...jsGlobs('spec', 'test')];
|
|
23
41
|
const IGNORED_DIRS = new Set([
|
|
24
42
|
'node_modules',
|
|
25
43
|
'.git',
|