canary-test-cli 6.8.0 → 7.0.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 (36) hide show
  1. package/dist/engine/analysis/cli.js +155 -45
  2. package/dist/engine/analysis/engine.js +9 -9
  3. package/dist/engine/analysis/reports.js +0 -0
  4. package/dist/engine/cli-commands.js +2 -2
  5. package/dist/engine/core/migrator.js +142 -31
  6. package/dist/engine/core/static-linter.js +2 -2
  7. package/dist/engine/core/workspace-detect.js +0 -0
  8. package/dist/engine/guardian/adjudication.js +1 -1
  9. package/dist/engine/guardian/agent-tier.js +3 -3
  10. package/dist/engine/guardian/coverage.js +15 -1397
  11. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +130 -0
  12. package/dist/engine/guardian/diff-coverage/formats/coverage-json-lint.js +197 -0
  13. package/dist/engine/guardian/diff-coverage/formats/coverage-json.js +107 -0
  14. package/dist/engine/guardian/diff-coverage/formats/xml.js +151 -0
  15. package/dist/engine/guardian/diff-coverage/graph-tier.js +223 -0
  16. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +150 -0
  17. package/dist/engine/guardian/diff-coverage/orchestrator.js +125 -0
  18. package/dist/engine/guardian/diff-coverage/paths.js +164 -0
  19. package/dist/engine/guardian/diff-coverage/report-tier.js +153 -0
  20. package/dist/engine/guardian/diff-coverage/type-only.js +150 -0
  21. package/dist/engine/guardian/diff-coverage/types.js +115 -0
  22. package/dist/engine/guardian/pr-check.js +22 -7
  23. package/dist/engine-checks.d.ts +15 -0
  24. package/dist/engine-checks.js +92 -1
  25. package/dist/overlay-commands.d.ts +12 -1
  26. package/dist/overlay-commands.js +28 -2
  27. package/dist/router.js +17 -5
  28. package/dist/uninstall-render.d.ts +11 -0
  29. package/dist/uninstall-render.js +60 -0
  30. package/dist/uninstall-scan.d.ts +14 -0
  31. package/dist/uninstall-scan.js +273 -0
  32. package/dist/uninstall-types.d.ts +46 -0
  33. package/dist/uninstall-types.js +91 -0
  34. package/dist/uninstall.d.ts +13 -0
  35. package/dist/uninstall.js +174 -0
  36. package/package.json +1 -1
@@ -248,6 +248,23 @@ function resolveTemplatePath(skillDir, rel) {
248
248
  return null;
249
249
  return full;
250
250
  }
251
+ /**
252
+ * Is the deployed copy of a skill the consumer's rather than the overlay's?
253
+ *
254
+ * True when it differs from the overlay AND from the hash recorded at deploy
255
+ * time -- or carries no recorded provenance at all, since unknown provenance is
256
+ * not permission to overwrite.
257
+ *
258
+ * This is the single definition of `local_edit`. The skill-deploy phase refuses
259
+ * to overwrite on it (#334) and the workflow-install phase withholds on it
260
+ * (#667); sharing one predicate is what keeps the two phases from reporting
261
+ * contradictory things about the same skill in the same run.
262
+ */
263
+ function isLocallyEdited(overlayHash, targetHash, recordedHash) {
264
+ if (targetHash === overlayHash)
265
+ return false;
266
+ return recordedHash === undefined || targetHash !== recordedHash;
267
+ }
251
268
  // ---------------------------------------------------------------------------
252
269
  // Constants / probes
253
270
  // ---------------------------------------------------------------------------
@@ -455,8 +472,10 @@ export class SkillDeployResult {
455
472
  * - `dry_run` -- what an `--apply` run would have done
456
473
  * - `missing` -- the overlay declares a template it does not ship
457
474
  * - `invalid` -- the declared path escapes the skill directory (refused)
475
+ * - `withheld` -- the declaring skill was skipped as locally edited, so its
476
+ * templates were not installed either (#667)
458
477
  *
459
- * `outdated` and `conflict` are REPORTS. Neither ever writes.
478
+ * `outdated`, `conflict`, and `withheld` are REPORTS. None ever writes.
460
479
  */
461
480
  export class WorkflowInstallResult {
462
481
  /** File name under `.github/workflows/`. */
@@ -506,11 +525,21 @@ export class FreshnessReport {
506
525
  * nagging about something canary has no claim over.
507
526
  */
508
527
  workflows;
509
- constructor(shape, overlay_path = null, results = [], workflows = []) {
528
+ /**
529
+ * Every shape the gate actually resolved -- the set `migrate` deploys for
530
+ * (#504 part 1). The scalar `shape` above stays, because the `--check --json`
531
+ * payload is documented and consumers read it; `shapes` is additive.
532
+ *
533
+ * A mixed monorepo collapses `shape` to `unknown` while `shapes` still names
534
+ * both, so the two disagree by design rather than by accident.
535
+ */
536
+ shapes;
537
+ constructor(shape, overlay_path = null, results = [], workflows = [], shapes = []) {
510
538
  this.shape = shape;
511
539
  this.overlay_path = overlay_path;
512
540
  this.results = results;
513
541
  this.workflows = workflows;
542
+ this.shapes = shapes;
514
543
  }
515
544
  get stale() {
516
545
  return this.results.filter((r) => r.status === 'stale' || r.status === 'missing');
@@ -564,6 +593,7 @@ export class FreshnessReport {
564
593
  to_dict() {
565
594
  return {
566
595
  shape: this.shape,
596
+ shapes: this.shapes,
567
597
  overlay_path: this.overlay_path,
568
598
  in_sync: this.in_sync,
569
599
  has_drift: this.has_drift,
@@ -587,14 +617,20 @@ export class FreshnessReport {
587
617
  `**Shape:** ${this.shape}`,
588
618
  '',
589
619
  ];
620
+ // A workspace repo resolves several shapes at once; naming only the scalar
621
+ // would tell a reader `unknown` while the gate checked two shapes (#504).
622
+ if (this.shapes.length > 1) {
623
+ lines.push(`**Shapes checked:** ${this.shapes.map((s) => `\`${s}\``).join(', ')}`, '');
624
+ }
590
625
  if (this.results.length === 0) {
591
626
  lines.push("_No overlay skills match this project's shape._", '');
592
627
  lines.push(`${WARN} **Abstained** ${EMDASH} the gate verified zero skills, so this is not a pass.`, '');
593
- if (this.shape === 'unknown') {
628
+ if (this.shapes.length === 0) {
594
629
  lines.push('The shape could not be detected. Set `canary_shape` in', '`.canary/company.json` or pass `--framework <name>`.', '');
595
630
  }
596
631
  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`.", '');
632
+ const covered = this.shapes.map((s) => `\`${s}\``).join(', ');
633
+ 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
634
  }
599
635
  lines.push(...workflowMarkdown(this.workflows, false));
600
636
  return lines.join('\n');
@@ -654,6 +690,14 @@ function workflowMarkdown(results, dryRun) {
654
690
  'differs from the template. Re-run with `--force` to take the ' +
655
691
  "overlay's version.", '');
656
692
  }
693
+ // #667: a withheld install is the one line that says why a template the
694
+ // overlay declares is absent from the target -- without it, the section
695
+ // simply omits the file and reads as though it were never declared.
696
+ if (results.some((r) => r.status === 'withheld')) {
697
+ lines.push(`${WARN} Templates declared by a skill with local edits were withheld: ` +
698
+ 'the skill itself was skipped, so its workflows were not installed ' +
699
+ 'either. Revert the local edits, or re-run with `--force`.', '');
700
+ }
657
701
  return lines;
658
702
  }
659
703
  export class MigrationReport {
@@ -994,6 +1038,10 @@ export class HarnessMigrator {
994
1038
  ? shapeForFrameworkOverride(framework, projectRoot)
995
1039
  : null;
996
1040
  const shape = overrideShape ?? ctx.detected_shape;
1041
+ // The set that actually drives deployment. Re-derived from the *effective*
1042
+ // scalar rather than reusing `ctx.shapes`, so a `--framework` override
1043
+ // contributes its resolved shape to the union too (#504 parts 1-2).
1044
+ const deployShapes = unionShapes(shape, ctx.workspace);
997
1045
  const followups = [];
998
1046
  if (effectiveFramework === null) {
999
1047
  followups.push(uncertainDetectionMessage('test framework', {
@@ -1004,7 +1052,7 @@ export class HarnessMigrator {
1004
1052
  // Issue #295 point 3: a detection miss must not block skill deployment --
1005
1053
  // nor, for the same reason, the workflow install (#459). The guardian
1006
1054
  // workflow is exactly what an unrecognised repo most needs.
1007
- const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
1055
+ const deployed = this.deploySkills(deployShapes, overlayPath, projectRoot, dryRun);
1008
1056
  return new MigrationReport({
1009
1057
  framework: 'unknown',
1010
1058
  shape,
@@ -1014,9 +1062,9 @@ export class HarnessMigrator {
1014
1062
  manual_followups: followups,
1015
1063
  config_warnings: ctx.config_warnings,
1016
1064
  workspace: ctx.workspace,
1017
- shapes: ctx.shapes,
1065
+ shapes: deployShapes,
1018
1066
  deployed_skills: deployed,
1019
- installed_workflows: this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force),
1067
+ installed_workflows: this.installWorkflows(deployShapes, overlayPath, projectRoot, dryRun, force),
1020
1068
  });
1021
1069
  }
1022
1070
  // A framework canary knows but cannot scaffold gets no config boilerplate.
@@ -1035,10 +1083,10 @@ export class HarnessMigrator {
1035
1083
  // must not be offered a second one at the root.
1036
1084
  const existingSuites = findWorkspaceSuites(projectRoot, effectiveFramework);
1037
1085
  const scaffolder = new Scaffolder();
1038
- const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
1086
+ const deployed = this.deploySkills(deployShapes, overlayPath, projectRoot, dryRun);
1039
1087
  // Post-copy install phase: the template bytes already landed under
1040
1088
  // .canary/skills/ with the skill; this puts them where Actions looks.
1041
- const installedWorkflows = this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force);
1089
+ const installedWorkflows = this.installWorkflows(deployShapes, overlayPath, projectRoot, dryRun, force);
1042
1090
  if (dryRun) {
1043
1091
  const tmpl = TEMPLATES[effectiveFramework];
1044
1092
  const files = tmpl?.files ?? {};
@@ -1067,7 +1115,7 @@ export class HarnessMigrator {
1067
1115
  installed_workflows: installedWorkflows,
1068
1116
  config_warnings: ctx.config_warnings,
1069
1117
  workspace: ctx.workspace,
1070
- shapes: ctx.shapes,
1118
+ shapes: deployShapes,
1071
1119
  });
1072
1120
  }
1073
1121
  // Same guard on the apply path: what the dry run refuses to propose, an
@@ -1092,16 +1140,25 @@ export class HarnessMigrator {
1092
1140
  installed_workflows: installedWorkflows,
1093
1141
  config_warnings: ctx.config_warnings,
1094
1142
  workspace: ctx.workspace,
1095
- shapes: ctx.shapes,
1143
+ shapes: deployShapes,
1096
1144
  });
1097
1145
  }
1098
1146
  // -- private helpers --------------------------------------------------------
1099
1147
  /**
1100
1148
  * 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.
1149
+ * matches **any** shape in *shapes* (or the `all` sentinel). Sources:
1150
+ * *overlayPath* first, then `~/.canary/skills/`. The first definition of a
1151
+ * name wins.
1152
+ *
1153
+ * *shapes* is a set, not a scalar, because a monorepo genuinely has several
1154
+ * shapes at once (#504 part 1). One pass over the overlay collects the union
1155
+ * deduplicated by name, so a skill matching two shapes is collected once and
1156
+ * the deploy manifest is read and written exactly once -- which a per-shape
1157
+ * loop would not give.
1158
+ *
1159
+ * An empty *shapes* is the old `unknown`: only `deploy_to: [all]` matches.
1103
1160
  */
1104
- collectOverlaySkills(shape, overlayPath) {
1161
+ collectOverlaySkills(shapes, overlayPath) {
1105
1162
  const candidateRoots = [];
1106
1163
  if (overlayPath !== null)
1107
1164
  candidateRoots.push(overlayPath);
@@ -1143,7 +1200,7 @@ export class HarnessMigrator {
1143
1200
  continue;
1144
1201
  if (!pyTruthy(info.deploy_to))
1145
1202
  continue;
1146
- if (!info.deploy_to.includes(shape) &&
1203
+ if (!shapes.some((s) => info.deploy_to.includes(s)) &&
1147
1204
  !info.deploy_to.includes('all')) {
1148
1205
  continue;
1149
1206
  }
@@ -1154,12 +1211,12 @@ export class HarnessMigrator {
1154
1211
  return collected;
1155
1212
  }
1156
1213
  /**
1157
- * Copy skills from the overlay's `.canary/skills/` that match *shape*.
1214
+ * Copy skills from the overlay's `.canary/skills/` matching any of *shapes*.
1158
1215
  * Deployment is strictly one-way -- the overlay owns deployed files (#334).
1159
1216
  */
1160
- deploySkills(shape, overlayPath, targetRoot, dryRun) {
1217
+ deploySkills(shapes, overlayPath, targetRoot, dryRun) {
1161
1218
  const results = [];
1162
- const skillsToDeploy = this.collectOverlaySkills(shape, overlayPath);
1219
+ const skillsToDeploy = this.collectOverlaySkills(shapes, overlayPath);
1163
1220
  const targetSkillsDir = join(targetRoot, '.canary', 'skills');
1164
1221
  const doc = readManifestDoc(targetSkillsDir);
1165
1222
  const manifest = doc.skills;
@@ -1178,8 +1235,7 @@ export class HarnessMigrator {
1178
1235
  }
1179
1236
  continue;
1180
1237
  }
1181
- const recorded = manifest[dirName]?.hash;
1182
- if (recorded === undefined || targetHash !== recorded) {
1238
+ if (isLocallyEdited(overlayHash, targetHash, manifest[dirName]?.hash)) {
1183
1239
  // Hand-edited (or unprovenanced) -- one-way ownership refuses to clobber.
1184
1240
  results.push(new SkillDeployResult(info.name, 'skipped', `local edits ${EMDASH} not overwritten`));
1185
1241
  continue;
@@ -1210,6 +1266,21 @@ export class HarnessMigrator {
1210
1266
  writeManifestDoc(targetSkillsDir, doc);
1211
1267
  return results;
1212
1268
  }
1269
+ /**
1270
+ * Would the deploy phase skip this skill as `local_edit` (#667)?
1271
+ *
1272
+ * Answers for the CURRENT on-disk state, so it holds whether the deploy phase
1273
+ * has already run this invocation (apply) or never will (dry run / `--check`)
1274
+ * -- the workflow report then says the same thing about a skill as the skill
1275
+ * report does.
1276
+ */
1277
+ skillWasSkipped(skillDir, targetSkillsDir, manifest) {
1278
+ const dirName = basename(skillDir);
1279
+ const dest = join(targetSkillsDir, dirName);
1280
+ if (!existsSync(dest))
1281
+ return false;
1282
+ return isLocallyEdited(hashSkillDir(skillDir), hashSkillDir(dest), manifest[dirName]?.hash);
1283
+ }
1213
1284
  /**
1214
1285
  * Install the workflow templates the shape-matching overlay skills declare
1215
1286
  * into the target's `.github/workflows/` (#459).
@@ -1225,22 +1296,43 @@ export class HarnessMigrator {
1225
1296
  * a hand-tuned workflow -- or nagging that it is "stale" via an exit code --
1226
1297
  * would be a worse failure than the partial adoption this fixes.
1227
1298
  *
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.
1299
+ * **The skill's own protection extends here (#667).** A skill the deploy
1300
+ * phase skips as `local_edit` installs nothing: it is reported `withheld`
1301
+ * with the reason and no bytes are written. Otherwise a run could announce
1302
+ * "skipped -- local edits, not overwritten" while writing that same skill's
1303
+ * files to `.github/workflows/` anyway, which is how a consumer who
1304
+ * deliberately de-listed a template got it reinstated on every sync. The
1305
+ * de-listing lives in the consumer's SKILL.md, and the install list is read
1306
+ * from the OVERLAY copy -- so withholding is also the only way that edit can
1307
+ * mean anything.
1308
+ *
1309
+ * Shape selection reuses the same resolved shape set that drives `deploy_to`
1310
+ * matching: skills are gated by {@link collectOverlaySkills}, and an entry may
1311
+ * additionally carry a `<shape>:` prefix to pick a variant. A prefixed entry
1312
+ * installs when its shape is anywhere in *shapes* (#504 part 1) -- a monorepo
1313
+ * carrying both an e2e and a unit package wants both variants, and matching
1314
+ * only the scalar would silently install neither once the scalar collapses to
1315
+ * `unknown`.
1231
1316
  */
1232
- installWorkflows(shape, overlayPath, targetRoot, dryRun, force = false) {
1317
+ installWorkflows(shapes, overlayPath, targetRoot, dryRun, force = false) {
1233
1318
  const results = [];
1234
- const skills = this.collectOverlaySkills(shape, overlayPath);
1319
+ const skills = this.collectOverlaySkills(shapes, overlayPath);
1235
1320
  const targetSkillsDir = join(targetRoot, '.canary', 'skills');
1236
1321
  const doc = readManifestDoc(targetSkillsDir);
1237
1322
  const workflowsDir = join(targetRoot, '.github', 'workflows');
1238
1323
  let manifestDirty = false;
1239
1324
  for (const [info, skillDir] of skills) {
1240
1325
  const { entries, version } = readWorkflowDeclaration(info.path);
1326
+ // Hashing a skill directory is not free, so only skills that actually
1327
+ // declare templates pay for the local-edit lookup.
1328
+ const skillWithheld = !force &&
1329
+ entries.length > 0 &&
1330
+ this.skillWasSkipped(skillDir, targetSkillsDir, doc.skills);
1241
1331
  for (const entry of entries) {
1242
1332
  const [wantShape, rel] = parseWorkflowEntry(entry);
1243
- if (wantShape !== null && wantShape !== shape && wantShape !== 'all') {
1333
+ if (wantShape !== null &&
1334
+ !shapes.includes(wantShape) &&
1335
+ wantShape !== 'all') {
1244
1336
  continue;
1245
1337
  }
1246
1338
  const src = resolveTemplatePath(skillDir, rel);
@@ -1274,6 +1366,21 @@ export class HarnessMigrator {
1274
1366
  const push = (status, detail) => {
1275
1367
  results.push(new WorkflowInstallResult(name, info.name, status, detail));
1276
1368
  };
1369
+ const installedBytes = existsSync(dest) ? readBytesOrNull(dest) : null;
1370
+ const alreadyCurrent = installedBytes !== null && installedBytes.equals(templateBytes);
1371
+ // #667: the declaring skill was skipped as locally edited, so this
1372
+ // template is not ours to place. An already-identical file is exempt:
1373
+ // it is a no-op, and calling that "withheld" would raise a finding on
1374
+ // every scheduled freshness check. Everything else is gated here,
1375
+ // ahead of `outdated`, whose remedy ("re-run with --force") would
1376
+ // otherwise talk a consumer into the very overwrite the skip refused.
1377
+ if (skillWithheld && !alreadyCurrent) {
1378
+ push('withheld', `.github/workflows/${name} was NOT installed ${EMDASH} its skill ` +
1379
+ `'${info.name}' has local edits and was skipped, so its ` +
1380
+ 'templates were withheld too; re-run with --force to install it ' +
1381
+ 'anyway');
1382
+ continue;
1383
+ }
1277
1384
  if (!existsSync(dest)) {
1278
1385
  if (dryRun) {
1279
1386
  push('dry_run', `would install .github/workflows/${name} (v${version})`);
@@ -1283,8 +1390,7 @@ export class HarnessMigrator {
1283
1390
  push('installed', `installed .github/workflows/${name} (v${version})`);
1284
1391
  continue;
1285
1392
  }
1286
- const installedBytes = readBytesOrNull(dest);
1287
- if (installedBytes !== null && installedBytes.equals(templateBytes)) {
1393
+ if (alreadyCurrent) {
1288
1394
  // Back-fill provenance for a hand-placed but identical file so a later
1289
1395
  // template fix can be reported as `outdated` rather than `conflict`.
1290
1396
  if (doc.workflows[name]?.hash !== templateHash)
@@ -1334,7 +1440,12 @@ export class HarnessMigrator {
1334
1440
  'Expected harness.config.json and .harness/ directory.');
1335
1441
  }
1336
1442
  const shape = ctx.detected_shape;
1337
- const skills = this.collectOverlaySkills(shape, overlayPath);
1443
+ // The gate must examine exactly what `migrate` deploys. Resolving only the
1444
+ // scalar here would let `--check` report "in sync" about skills it never
1445
+ // looked at in a monorepo -- a deliberate false green in a drift gate
1446
+ // (#504 part 1).
1447
+ const shapes = ctx.shapes;
1448
+ const skills = this.collectOverlaySkills(shapes, overlayPath);
1338
1449
  const targetSkillsDir = join(projectRoot, '.canary', 'skills');
1339
1450
  const manifest = readManifestDoc(targetSkillsDir).skills;
1340
1451
  const results = [];
@@ -1362,7 +1473,7 @@ export class HarnessMigrator {
1362
1473
  return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results,
1363
1474
  // dryRun = true: `--check` reports what an install WOULD do and never
1364
1475
  // writes. Informational only -- see FreshnessReport.workflows.
1365
- this.installWorkflows(shape, overlayPath, projectRoot, true, false));
1476
+ this.installWorkflows(shapes, overlayPath, projectRoot, true, false), shapes);
1366
1477
  }
1367
1478
  detectFramework(root, config, ws = null) {
1368
1479
  // Explicit override in .canary/company.json ("canary_shape" field) is
@@ -1400,7 +1511,7 @@ export class HarnessMigrator {
1400
1511
  const findings = ws.findings;
1401
1512
  if (findings.length === 0)
1402
1513
  return null;
1403
- const pairs = new Set(findings.map((f) => `${f.framework}${f.shape}`));
1514
+ const pairs = new Set(findings.map((f) => `${f.framework}\0${f.shape}`));
1404
1515
  if (pairs.size > 1) {
1405
1516
  return [null, 'unknown', 'workspace (mixed)', 'none'];
1406
1517
  }
@@ -347,7 +347,7 @@ const JS_EXT_SET = new Set(JS_TEST_EXTENSIONS);
347
347
  * guess that cannot be distinguished from a clean result is a false green;
348
348
  * `null` forces the caller to abstain instead.
349
349
  */
350
- export function lintableFramework(path) {
350
+ export function frameworkForPath(path) {
351
351
  const suffix = extname(path).toLowerCase();
352
352
  const name = basename(path).toLowerCase();
353
353
  if (suffix === '.py')
@@ -368,7 +368,7 @@ export class UnsupportedTestFileError extends Error {
368
368
  }
369
369
  }
370
370
  function requireFramework(path, framework) {
371
- const fw = framework || lintableFramework(path);
371
+ const fw = framework || frameworkForPath(path);
372
372
  if (fw === null)
373
373
  throw new UnsupportedTestFileError(path);
374
374
  return fw;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Finding adjudication collection — the precision the hard gate depends on
2
+ * Guardian finding adjudication collection — the precision the hard gate depends on
3
3
  * (#490).
4
4
  *
5
5
  * `pr-check.ts` documents the soft→hard promotion contract as
@@ -8,7 +8,7 @@
8
8
  * `tier.ts`), which imports it never (SC-11). Under the chosen **Option A**
9
9
  * invocation mechanism it **never calls an LLM directly**: it (i) turns
10
10
  * deterministic Tier-0 gaps into structured authoring intents, (ii) parses and
11
- * validates agent results into `Finding`/`GeneratedTest` records, and (iii)
11
+ * validates agent results into `GuardianFinding`/`GeneratedTest` records, and (iii)
12
12
  * enforces the write-safety model (opt-in, fork, collision, loop-guard) and the
13
13
  * block-once decision. It reaches an agent runtime only through an injected
14
14
  * `AgentInvoker` **port**; the production default (`RecordingInvoker`) records
@@ -26,7 +26,7 @@
26
26
  */
27
27
  import { existsSync } from 'node:fs';
28
28
  import { Severity } from './impact-mapper.js';
29
- import { Finding } from './pr-check.js';
29
+ import { GuardianFinding } from './pr-check.js';
30
30
  /** Construct a {@link ReviewRequest} (a frozen dataclass in Python). */
31
31
  function reviewRequest(test_paths) {
32
32
  return { test_paths };
@@ -98,7 +98,7 @@ function parseReviewFindings(transcript) {
98
98
  if (severity === undefined)
99
99
  continue;
100
100
  const path = match.groups['path'];
101
- findings.push(new Finding({
101
+ findings.push(new GuardianFinding({
102
102
  path,
103
103
  unit: path,
104
104
  kind: 'weak-test',