canary-test-cli 6.1.0 → 6.3.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.
@@ -55,7 +55,7 @@ import { buildApiDelta, writeApiDelta } from './delta-emitter.js';
55
55
  import { extractApiDiff } from './diff-extractor.js';
56
56
  import { HardGateBlocked, RestBranchProtectionClient, applyHardGate, renderPlaybook, } from './hard-gate.js';
57
57
  import { mapImpact } from './impact-mapper.js';
58
- import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
58
+ import { applySuppressions, buildFindings, buildWeakTestFindings, computeExitCode, effectiveGraphDepth, filterHeuristicNoise, filterSkipped, filterTestUnits, findReexportOnly, loadGuardianConfig, render, scopeDiff, } from './pr-check.js';
59
59
  import { RestGitHubClient, degradationAnnotation, upsertStickyComment, } from './pr-comment.js';
60
60
  import { buildSummary } from './summary-emitter.js';
61
61
  import { resolveTier } from './tier.js';
@@ -258,22 +258,206 @@ const AUTHORED_SENTINEL_NAME = 'canary-guardian-authored';
258
258
  function authoredSentinelPath(deps, root) {
259
259
  return join(gitDir(deps, root), AUTHORED_SENTINEL_NAME);
260
260
  }
261
+ // The sentinel's FIRST line stamps the HEAD the guardian authored at:
262
+ // `HEAD <sha>`. Every line after it is one authored path. Anchored at the start
263
+ // of the body and hex-only, with a trailing `[ \t\r]*` so a CRLF-written file
264
+ // still parses -- anything else reads as malformed, which fails OPEN.
265
+ const SENTINEL_HEAD_RE = /^HEAD ([0-9a-fA-F]{7,64})[ \t\r]*(?:\n|$)/;
266
+ /**
267
+ * Parse the `HEAD <sha>` stamp off a sentinel body; `null` when malformed.
268
+ *
269
+ * Malformed covers empty, headerless (the pre-#456 paths-only format), and any
270
+ * unparseable first line. Callers MUST treat `null` as "cannot verify" and fail
271
+ * OPEN -- an unreadable sentinel must never wedge authoring off (#456).
272
+ */
273
+ function sentinelHeadStamp(text) {
274
+ const match = SENTINEL_HEAD_RE.exec(text);
275
+ return match === null ? null : match[1].toLowerCase();
276
+ }
277
+ /** Current `HEAD` sha for `root`, or `null` when git/HEAD is unavailable. */
278
+ function headSha(deps, root) {
279
+ const res = deps.runGit(['rev-parse', 'HEAD'], root);
280
+ if (res === null || res.code !== 0)
281
+ return null; // no git / no commits
282
+ return res.stdout.trim().toLowerCase() || null;
283
+ }
284
+ /**
285
+ * Is the loop guard live -- i.e. does a sentinel stamped at the CURRENT `HEAD`
286
+ * exist?
287
+ *
288
+ * This is the surviving half of the stage-and-block-once contract (#456). The
289
+ * component that CLEARED the sentinel on the next commit
290
+ * (`hooks/guardian_precommit.py`) was deleted as dead code in #449, which left
291
+ * `author-plan` fail-closed forever: author once in a clone and Tier-2 authoring
292
+ * never ran again. Stamping HEAD makes the guard self-expiring -- once the human
293
+ * reviews and commits the staged tests, `HEAD` moves, the stamp stops matching,
294
+ * and authoring re-enables itself with no manual step and no hook.
295
+ *
296
+ * Every unverifiable state FAILS OPEN (returns `false`, authoring allowed):
297
+ * missing or unreadable sentinel, a malformed/absent `HEAD` header, or a `HEAD`
298
+ * we cannot resolve. Fail-closed here is exactly the bug being fixed.
299
+ */
300
+ function authoredSentinelActive(deps, root) {
301
+ let body;
302
+ try {
303
+ body = readFileSync(authoredSentinelPath(deps, root), 'utf-8');
304
+ }
305
+ catch {
306
+ return false; // absent or unreadable -> fail open
307
+ }
308
+ const stamp = sentinelHeadStamp(body);
309
+ if (stamp === null)
310
+ return false; // malformed -> fail open
311
+ const head = headSha(deps, root);
312
+ if (head === null)
313
+ return false; // unverifiable -> fail open
314
+ return head === stamp;
315
+ }
261
316
  /**
262
317
  * Return raw unified-diff text from a source.
263
318
  *
264
319
  * `source === '-'` reads stdin; a path reads that file; `null` runs `git diff`
265
320
  * and falls back to `git diff --staged` when the worktree is clean.
321
+ *
322
+ * This is the AT-DESK resolution: the working tree is the subject. `pr-check`
323
+ * uses {@link readPrDiff} instead, which prefers the PR diff in CI (#369).
266
324
  */
267
325
  function readDiff(source, deps) {
268
326
  if (source === '-')
269
327
  return deps.readStdin();
270
328
  if (source !== null)
271
329
  return readFileSync(source, 'utf-8');
330
+ return readWorktreeDiff(deps);
331
+ }
332
+ /** `git diff`, falling back to `git diff --staged` on a clean worktree. */
333
+ function readWorktreeDiff(deps) {
272
334
  const unstaged = deps.runGit(['diff'])?.stdout ?? '';
273
335
  if (unstaged.trim())
274
336
  return unstaged;
275
337
  return deps.runGit(['diff', '--staged'])?.stdout ?? '';
276
338
  }
339
+ /** True when the process looks like a CI runner rather than a dev worktree. */
340
+ function isCiContext(env) {
341
+ return Boolean(env['GITHUB_ACTIONS'] || env['CI']);
342
+ }
343
+ /**
344
+ * Read `pull_request.base.sha` from the Actions event payload, if present.
345
+ *
346
+ * Optional chaining over a narrow interface (rather than an `unknown` +
347
+ * `typeof` ladder) keeps this at one branch per real failure mode: unreadable
348
+ * file, unparseable JSON, or a payload without a usable sha.
349
+ */
350
+ function eventBaseSha(env) {
351
+ const eventPath = env['GITHUB_EVENT_PATH'];
352
+ if (!eventPath)
353
+ return null;
354
+ let sha;
355
+ try {
356
+ const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
357
+ sha = event?.pull_request?.base?.sha;
358
+ }
359
+ catch {
360
+ return null;
361
+ }
362
+ return typeof sha === 'string' && sha.trim() ? sha.trim() : null;
363
+ }
364
+ /**
365
+ * Base-rev candidates for the PR diff, most-preferred first.
366
+ *
367
+ * `origin/<ref>` leads because `actions/checkout` fetches the base branch under
368
+ * the remote namespace and usually does NOT create a local branch for it; the
369
+ * bare `<ref>` covers checkouts that do. The event payload's `base.sha` is the
370
+ * last resort — exact, but only present on `pull_request` events.
371
+ */
372
+ function baseRefCandidates(env) {
373
+ const candidates = [];
374
+ const baseRef = env['GITHUB_BASE_REF']?.trim();
375
+ if (baseRef)
376
+ candidates.push(`origin/${baseRef}`, baseRef);
377
+ const sha = eventBaseSha(env);
378
+ if (sha)
379
+ candidates.push(sha);
380
+ return candidates;
381
+ }
382
+ /**
383
+ * Return the first base candidate that actually resolves to a commit locally.
384
+ *
385
+ * A shallow clone (`fetch-depth: 1`, the `actions/checkout` default) will NOT
386
+ * have the base commit, so every candidate fails `rev-parse` and we return
387
+ * `null` — the caller then falls back to the worktree diff and warns.
388
+ */
389
+ function resolveBaseRev(deps) {
390
+ for (const candidate of baseRefCandidates(deps.env)) {
391
+ const res = deps.runGit([
392
+ 'rev-parse',
393
+ '--verify',
394
+ '--quiet',
395
+ `${candidate}^{commit}`,
396
+ ]);
397
+ if (res !== null && res.code === 0 && res.stdout.trim())
398
+ return candidate;
399
+ }
400
+ return null;
401
+ }
402
+ /**
403
+ * Resolve the diff `pr-check` should scope, preferring the PR diff in CI (#369).
404
+ *
405
+ * An explicit `--diff` (stdin or file) always wins and never shells out. With
406
+ * `--diff` omitted:
407
+ *
408
+ * - **In CI** with a resolvable base rev → `git diff <base>...HEAD`. The
409
+ * TRIPLE-dot form diffs against the merge base, so commits that land on the
410
+ * base branch mid-PR never appear as part of this PR's changed surface.
411
+ * - **Otherwise** → the at-desk working-tree diff ({@link readWorktreeDiff}).
412
+ *
413
+ * The legacy behavior was the working-tree diff unconditionally, which is empty
414
+ * on a clean CI checkout — the gate then scoped zero paths and exited 0, so an
415
+ * adopting repo could not tell a working gate from a broken one.
416
+ */
417
+ export function readPrDiff(source, deps) {
418
+ if (source === '-') {
419
+ return { text: deps.readStdin(), origin: 'stdin', base: null };
420
+ }
421
+ if (source !== null) {
422
+ return { text: readFileSync(source, 'utf-8'), origin: 'file', base: null };
423
+ }
424
+ if (isCiContext(deps.env)) {
425
+ const base = resolveBaseRev(deps);
426
+ if (base !== null) {
427
+ const res = deps.runGit(['diff', `${base}...HEAD`]);
428
+ if (res !== null && res.code === 0) {
429
+ return { text: res.stdout, origin: 'ci-base', base };
430
+ }
431
+ }
432
+ }
433
+ return { text: readWorktreeDiff(deps), origin: 'worktree', base: null };
434
+ }
435
+ const EMPTY_CI_DIFF_NOTICE = 'guardian: 0 changed paths — fell back to a working-tree `git diff`, which ' +
436
+ 'is empty on a clean CI checkout, so NOTHING was verified. Pass ' +
437
+ '`--diff <base>...<head>`, or checkout with `fetch-depth: 0` so the PR base ' +
438
+ 'ref resolves automatically.';
439
+ /**
440
+ * Warn LOUDLY when a CI run scoped zero paths off the worktree fallback (#369).
441
+ *
442
+ * Fires only for the exact broken shape — `--diff` omitted, CI detected, base
443
+ * rev unresolvable, and zero changed paths. A diff that DID carry paths which
444
+ * were then all skipped is a legitimate no-op and stays quiet.
445
+ *
446
+ * Deliberately non-blocking: it annotates (`::warning::` + step summary +
447
+ * stderr) rather than exiting non-zero, so adopting an engine upgrade never
448
+ * flips a green build red — but a silent green no-op becomes impossible.
449
+ */
450
+ function warnIfEmptyCiDiff(resolved, unitCount, deps) {
451
+ if (resolved.origin !== 'worktree')
452
+ return;
453
+ if (unitCount > 0)
454
+ return;
455
+ if (!isCiContext(deps.env))
456
+ return;
457
+ deps.out(degradationAnnotation(EMPTY_CI_DIFF_NOTICE));
458
+ appendStepSummary(deps.env, EMPTY_CI_DIFF_NOTICE);
459
+ deps.err(EMPTY_CI_DIFF_NOTICE);
460
+ }
277
461
  // --- analyze ------------------------------------------------------------------
278
462
  function loadSpec(path, deps) {
279
463
  if (!existsSync(path)) {
@@ -494,6 +678,10 @@ async function postStickyComment(findings, resolution, deps) {
494
678
  appendStepSummary(deps.env, res.notice);
495
679
  }
496
680
  }
681
+ /** The gate's no-op line, shared by the pre- and post-filter exits. */
682
+ function nothingToVerify(skippedCount) {
683
+ return `guardian: nothing to verify (${skippedCount} path(s) skipped).`;
684
+ }
497
685
  async function prCheckCmd(opts, deps) {
498
686
  const [config, warning] = loadGuardianConfig(opts.config);
499
687
  if (warning !== null) {
@@ -507,8 +695,12 @@ async function prCheckCmd(opts, deps) {
507
695
  throw new CliExit(0);
508
696
  }
509
697
  const effectiveGate = opts.gate ?? config.pr_gate;
510
- const diffText = readDiff(opts.diff ?? null, deps);
698
+ // #369: in CI an omitted `--diff` resolves the PR diff from the base ref;
699
+ // the working-tree fallback is empty on a clean checkout.
700
+ const resolvedDiff = readPrDiff(opts.diff ?? null, deps);
701
+ const diffText = resolvedDiff.text;
511
702
  const units = scopeDiff(diffText);
703
+ warnIfEmptyCiDiff(resolvedDiff, units.length, deps);
512
704
  // SC-2: drop docs/config-only units matching skipGlobs.
513
705
  const [keptSkip, skipped] = filterSkipped(units, config.skip_globs);
514
706
  // FIX A: drop test-path units -- a test does not itself need a test.
@@ -521,9 +713,9 @@ async function prCheckCmd(opts, deps) {
521
713
  const weakFindings = config.weak_tests
522
714
  ? buildWeakTestFindings(testUnits, diffText)
523
715
  : [];
716
+ const preFilterSkipped = skipped.length + testUnits.length + barrelUnits.length;
524
717
  if (kept.length === 0 && weakFindings.length === 0) {
525
- deps.out(`guardian: nothing to verify ` +
526
- `(${skipped.length + testUnits.length + barrelUnits.length} path(s) skipped).`);
718
+ deps.out(nothingToVerify(preFilterSkipped));
527
719
  throw new CliExit(0);
528
720
  }
529
721
  const results = resolveCoverage(kept, {
@@ -532,10 +724,21 @@ async function prCheckCmd(opts, deps) {
532
724
  // (depth 1); soft stays unbounded. An explicit config value wins.
533
725
  graphMaxDepth: effectiveGraphDepth(config, effectiveGate),
534
726
  });
727
+ // #413: drop uncovered HEURISTIC verdicts on paths a naming heuristic can
728
+ // never judge (non-source, or an excluded glob). Coverage/graph-verified
729
+ // verdicts on the same paths are real evidence and survive.
730
+ const [scoredResults, noiseResults] = filterHeuristicNoise(results, opts.heuristicExclude ?? config.heuristic_exclude);
535
731
  const findings = [
536
- ...applySuppressions(buildFindings(results)),
732
+ ...applySuppressions(buildFindings(scoredResults)),
537
733
  ...weakFindings,
538
734
  ];
735
+ // #413: if the heuristic filter consumed every scorable unit, report it as a
736
+ // SKIP rather than rendering an empty "0 unaddressed" report -- an adopter
737
+ // must be able to tell "nothing was judgeable" from "everything passed".
738
+ if (scoredResults.length === 0 && findings.length === 0) {
739
+ deps.out(nothingToVerify(preFilterSkipped + noiseResults.length));
740
+ throw new CliExit(0);
741
+ }
539
742
  // SC-5 (PR half): resolve the requested tier against actual capability. No
540
743
  // agent runtime exists (default NoAgentProbe), so any `pr.tier > 0` drops to
541
744
  // tier 0 with a LOUD degradation notice.
@@ -602,7 +805,10 @@ function buildGaps(diffText, config, coveragePath, graphMaxDepth) {
602
805
  if (kept.length === 0)
603
806
  return [];
604
807
  const results = resolveCoverage(kept, { coveragePath, graphMaxDepth });
605
- return applySuppressions(buildFindings(results));
808
+ // #413: never hand the authoring tier a heuristic FP -- a generated "test" for
809
+ // a config dotfile is worse noise than the finding was.
810
+ const [scored] = filterHeuristicNoise(results, config.heuristic_exclude);
811
+ return applySuppressions(buildFindings(scored));
606
812
  }
607
813
  /** Serialize a {@link GeneratedTest} intent for the SKILL (JSON-safe). */
608
814
  function intentDict(intent) {
@@ -634,7 +840,8 @@ function authorPlanCmd(opts, deps) {
634
840
  const ctx = new AuthoringContext(config.precommit_author_tests, effective, {
635
841
  is_fork: isForkContext(deps.env),
636
842
  repo_root: repoRoot,
637
- authored_sentinel_present: existsSync(authoredSentinelPath(deps, repoRoot)),
843
+ // #456: HEAD-stamped, so the guard expires on the next commit by itself.
844
+ authored_sentinel_present: authoredSentinelActive(deps, repoRoot),
638
845
  });
639
846
  const results = deps.makeAgentTier().author_tests(gaps, ctx);
640
847
  const decision = decideBlock(results);
@@ -648,11 +855,23 @@ function authorPlanCmd(opts, deps) {
648
855
  };
649
856
  deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
650
857
  }
858
+ /**
859
+ * Record the authored paths in the loop-guard sentinel, stamped with the HEAD
860
+ * they were authored at (#456).
861
+ *
862
+ * The `HEAD <sha>` header is what makes the guard self-expiring: `author-plan`
863
+ * honors it only while `HEAD` still matches, so the human's review commit clears
864
+ * it implicitly. When `HEAD` cannot be resolved (a repo with no commits, or no
865
+ * git at all) the header is omitted -- an unstamped sentinel reads as malformed
866
+ * and FAILS OPEN, which is the safe direction.
867
+ */
651
868
  function markAuthoredCmd(opts, deps) {
652
869
  const root = gitToplevel(deps);
653
870
  const sentinel = authoredSentinelPath(deps, root);
654
871
  mkdirSync(dirname(sentinel), { recursive: true });
655
- const body = opts.path.map((p) => `${p}\n`).join('');
872
+ const head = headSha(deps, root);
873
+ const header = head === null ? '' : `HEAD ${head}\n`;
874
+ const body = header + opts.path.map((p) => `${p}\n`).join('');
656
875
  writeFileSync(sentinel, body, 'utf-8');
657
876
  deps.out(`guardian: recorded ${opts.path.length} authored path(s) ${RIGHT_ARROW} ${sentinel}`);
658
877
  }
@@ -729,11 +948,15 @@ export function createGuardianCommand(depsInit = {}) {
729
948
  program
730
949
  .command('pr-check')
731
950
  .description('Tier 0 deterministic PR guardian: scope, resolve, gate.')
732
- .option('--diff <diff>', "Diff file, '-' for stdin, or omit to use `git diff`.")
951
+ .option('--diff <diff>', "Diff file, '-' for stdin, or omit to auto-resolve: the PR diff " +
952
+ '(`<base>...HEAD`) in CI, else the local working-tree `git diff`.')
733
953
  .option('--coverage <path>', 'Coverage report path (lcov/json).')
734
954
  .addOption(new Option('--format <fmt>', 'comment|json|text').default('comment'))
735
955
  .addOption(new Option('--config <path>').default('harness.config.json'))
736
956
  .option('--gate <gate>', 'Override config gate: soft|hard')
957
+ .option('--heuristic-exclude <glob>', 'Glob whose paths never produce a heuristic-tier finding (repeatable). ' +
958
+ 'Replaces canary.guardian.pr.heuristicExclude for this run. ' +
959
+ 'Coverage/graph-verified findings are unaffected.', (value, previous) => [...(previous ?? []), value])
737
960
  .option('--post-comment', 'Post/update the sticky PR comment via the GitHub API (CI).')
738
961
  .option('--emit-analysis', 'Write the finding record to the .harness/analyses/ channel ' +
739
962
  '(harness handoff, #899); falls back LOUDLY to the sticky comment ' +
@@ -682,6 +682,96 @@ const TEST_PATH_RE = /(^|\/)tests?\/|(^|\/)test_[^/]*\.py$|\.test\.[^/]+$|\.spec
682
682
  export function isTestPath(path) {
683
683
  return TEST_PATH_RE.test(path);
684
684
  }
685
+ /**
686
+ * Extensions that denote hand-authored, executable program source (#413).
687
+ *
688
+ * The membership rule is deliberately simple and defensible: **a programming
689
+ * language belongs; data, config, markup, and style do not.** `.sh` is in (it is
690
+ * executable logic — bats/shunit2 exist); `.json`, `.yaml`, `.sql`, `.css`, and
691
+ * `.html` are out (nothing a naming heuristic could meaningfully judge).
692
+ *
693
+ * A repo that disagrees at the margins tunes the glob layer
694
+ * (`canary.guardian.pr.heuristicExclude`) rather than this list.
695
+ */
696
+ const SOURCE_EXTENSIONS = new Set([
697
+ // TS/JS + component dialects.
698
+ '.ts',
699
+ '.tsx',
700
+ '.mts',
701
+ '.cts',
702
+ '.js',
703
+ '.jsx',
704
+ '.mjs',
705
+ '.cjs',
706
+ '.vue',
707
+ '.svelte',
708
+ '.astro',
709
+ // Python / Ruby / PHP / Perl / Lua.
710
+ '.py',
711
+ '.pyi',
712
+ '.rb',
713
+ '.php',
714
+ '.pl',
715
+ '.pm',
716
+ '.lua',
717
+ // JVM + .NET.
718
+ '.java',
719
+ '.kt',
720
+ '.kts',
721
+ '.scala',
722
+ '.groovy',
723
+ '.clj',
724
+ '.cljs',
725
+ '.cs',
726
+ '.fs',
727
+ '.vb',
728
+ // Systems.
729
+ '.go',
730
+ '.rs',
731
+ '.c',
732
+ '.h',
733
+ '.cc',
734
+ '.cpp',
735
+ '.cxx',
736
+ '.hpp',
737
+ '.hh',
738
+ '.m',
739
+ '.mm',
740
+ '.swift',
741
+ // Functional / scientific / other.
742
+ '.ex',
743
+ '.exs',
744
+ '.erl',
745
+ '.dart',
746
+ '.r',
747
+ '.jl',
748
+ // Shell.
749
+ '.sh',
750
+ '.bash',
751
+ '.zsh',
752
+ '.ps1',
753
+ '.psm1',
754
+ ]);
755
+ /**
756
+ * True if `path` looks like hand-authored program source (#413).
757
+ *
758
+ * Used to gate the Tier-3 naming heuristic. That heuristic asks "does any test
759
+ * file reference this file's stem or a top-level symbol?" — for a config
760
+ * dotfile, a lockfile, or a data blob there are no symbols and no test will
761
+ * ever name it, so the verdict is structurally always "uncovered": a guaranteed
762
+ * false positive rather than a signal. An extension-less file (`Makefile`,
763
+ * `Dockerfile`) and a bare dotfile (`.eslintrc`) are both non-source.
764
+ */
765
+ export function isSourcePath(path) {
766
+ const base = basename(path);
767
+ // `.eslintrc` — `extname` calls this '' already, but a dotfile WITH a real
768
+ // extension (`.eslintrc.json`) must be judged on that extension, which the
769
+ // normal path handles.
770
+ const ext = extname(base).toLowerCase();
771
+ if (!ext)
772
+ return false;
773
+ return SOURCE_EXTENSIONS.has(ext);
774
+ }
685
775
  /**
686
776
  * Tier 2: derive coverage from the harness knowledge graph (`GRAPH_VERIFIED`).
687
777
  *