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.
Files changed (69) hide show
  1. package/dist/engine/analysis/cli.js +261 -89
  2. package/dist/engine/analysis/engine.js +39 -21
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +251 -43
  5. package/dist/engine/cli-common.js +15 -24
  6. package/dist/engine/cli.core.js +37 -11
  7. package/dist/engine/cli.js +2 -2
  8. package/dist/engine/company-knowledge-cli.js +2 -2
  9. package/dist/engine/core/adoption.js +408 -0
  10. package/dist/engine/core/framework-probes.js +7 -7
  11. package/dist/engine/core/fs-glob.js +2 -2
  12. package/dist/engine/core/gate-result.js +17 -0
  13. package/dist/engine/core/migrator.js +151 -48
  14. package/dist/engine/core/pattern-matcher.js +23 -5
  15. package/dist/engine/core/persona.js +421 -0
  16. package/dist/engine/core/promotion-verdict.js +261 -0
  17. package/dist/engine/core/reporter.js +1 -9
  18. package/dist/engine/core/skill-examples.js +292 -0
  19. package/dist/engine/core/skill-surfaces.js +307 -0
  20. package/dist/engine/core/static-linter.js +312 -40
  21. package/dist/engine/core/ticket-updater.js +1 -7
  22. package/dist/engine/core/vacuity-scanner.js +556 -0
  23. package/dist/engine/core/workflow-discovery.js +2 -8
  24. package/dist/engine/core/workspace-detect.js +0 -0
  25. package/dist/engine/data/personas/registry.json +36 -0
  26. package/dist/engine/guardian/adjudication.js +6 -6
  27. package/dist/engine/guardian/agent-tier.js +3 -3
  28. package/dist/engine/guardian/analysis-emit.js +13 -27
  29. package/dist/engine/guardian/cli.js +30 -43
  30. package/dist/engine/guardian/coverage.js +15 -1420
  31. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  32. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  33. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  34. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  35. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  36. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  37. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  38. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  39. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  40. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  41. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  42. package/dist/engine/guardian/pr-check.js +10 -20
  43. package/dist/engine/guardian/pr-comment.js +4 -3
  44. package/dist/engine/history/cli.js +210 -6
  45. package/dist/engine/history/ndjson-store.js +9 -5
  46. package/dist/engine/history/record.js +34 -5
  47. package/dist/engine/history/run-recorder.js +165 -0
  48. package/dist/engine/history/schema.js +25 -7
  49. package/dist/engine/history/store.js +9 -0
  50. package/dist/engine/mcp-server.js +35 -13
  51. package/dist/engine/skills-cli.js +133 -11
  52. package/dist/engine/util/ensure-ascii.js +37 -0
  53. package/dist/engine/workflow-cli.js +6 -6
  54. package/dist/engine-checks.d.ts +15 -0
  55. package/dist/engine-checks.js +92 -1
  56. package/dist/gate-result.d.ts +11 -0
  57. package/dist/gate-result.js +18 -0
  58. package/dist/overlay-commands.d.ts +12 -1
  59. package/dist/overlay-commands.js +28 -2
  60. package/dist/router.js +17 -5
  61. package/dist/uninstall-render.d.ts +11 -0
  62. package/dist/uninstall-render.js +60 -0
  63. package/dist/uninstall-scan.d.ts +14 -0
  64. package/dist/uninstall-scan.js +273 -0
  65. package/dist/uninstall-types.d.ts +46 -0
  66. package/dist/uninstall-types.js +91 -0
  67. package/dist/uninstall.d.ts +13 -0
  68. package/dist/uninstall.js +181 -0
  69. 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 { _CONFIG_PROBES, inferPlaywrightShape, probe, } from './framework-probes.js';
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
- export const KNOWN_FRAMEWORKS = [
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 = _CONFIG_PROBES
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 [, probeFramework, shape] of _CONFIG_PROBES) {
334
- if (probeFramework !== framework)
342
+ for (const [, candidateFramework, shape] of CONFIG_PROBES) {
343
+ if (candidateFramework !== framework)
335
344
  continue;
336
- if (probeFramework === 'playwright' && shape === 'e2e_ui') {
337
- return inferPlaywrightShape(root);
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 `conflict` are REPORTS. Neither ever writes.
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
- constructor(shape, overlay_path = null, results = [], workflows = []) {
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.shape === 'unknown') {
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
- lines.push(`The overlay ships no skills with \`deploy_to\` covering \`${this.shape}\`.`, "Check the overlay's `deploy_to` lists or the resolved `canary_shape`.", '');
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(shape, overlayPath, projectRoot, dryRun);
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: ctx.shapes,
1057
+ shapes: deployShapes,
1018
1058
  deployed_skills: deployed,
1019
- installed_workflows: this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force),
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(shape, overlayPath, projectRoot, dryRun);
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(shape, overlayPath, projectRoot, dryRun, force);
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: ctx.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: ctx.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 *shape* (or the `all` sentinel). Sources: *overlayPath* first, then
1102
- * `~/.canary/skills/`. The first definition of a name wins.
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(shape, overlayPath) {
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(shape) &&
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/` that match *shape*.
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(shape, overlayPath, targetRoot, dryRun) {
1209
+ deploySkills(shapes, overlayPath, targetRoot, dryRun) {
1161
1210
  const results = [];
1162
- const skillsToDeploy = this.collectOverlaySkills(shape, overlayPath);
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
- const recorded = manifest[dirName]?.hash;
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
- * Shape selection reuses the same resolved `canary_shape` that drives
1229
- * `deploy_to` matching: skills are gated by {@link collectOverlaySkills}, and
1230
- * an entry may additionally carry a `<shape>:` prefix to pick a variant.
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(shape, overlayPath, targetRoot, dryRun, force = false) {
1309
+ installWorkflows(shapes, overlayPath, targetRoot, dryRun, force = false) {
1233
1310
  const results = [];
1234
- const skills = this.collectOverlaySkills(shape, overlayPath);
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 && wantShape !== shape && wantShape !== 'all') {
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
- const installedBytes = readBytesOrNull(dest);
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
- const skills = this.collectOverlaySkills(shape, overlayPath);
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(shape, overlayPath, projectRoot, true, false));
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}${f.shape}`));
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 probe(root, config, ['config', 'content', 'language']);
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: ['**/*.spec.ts', '**/*.spec.js', '**/*.test.ts', '**/*.test.js'],
13
- vitest: ['**/*.test.ts', '**/*.test.js', '**/*.spec.ts', '**/*.spec.js'],
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: ['**/*.spec.ts', '**/*.spec.js'],
17
- frontend_unit: ['**/*.test.ts', '**/*.test.js'],
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', '**/*.spec.ts', '**/*.test.ts'];
40
+ const DEFAULT_PATTERNS = ['**/test_*.py', ...jsGlobs('spec', 'test')];
23
41
  const IGNORED_DIRS = new Set([
24
42
  'node_modules',
25
43
  '.git',