tamperward 2.1.1 → 2.2.1

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/cli/index.js CHANGED
@@ -356,31 +356,59 @@ function tokens(seg) {
356
356
  function unquote(t) {
357
357
  return t.replace(/^['"]+|['"]+$/g, "");
358
358
  }
359
+ function words(seg) {
360
+ return tokens(seg).map(unquote);
361
+ }
362
+ var GIT_OPT_WITH_ARG = /* @__PURE__ */ new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"]);
363
+ function gitSubcommand(toks) {
364
+ const i = toks.findIndex((t) => t === "git" || t.endsWith("/git"));
365
+ if (i < 0) return null;
366
+ for (let j = i + 1; j < toks.length; j++) {
367
+ const t = toks[j];
368
+ if (GIT_OPT_WITH_ARG.has(t)) {
369
+ j++;
370
+ continue;
371
+ }
372
+ if (t.startsWith("-")) continue;
373
+ return t;
374
+ }
375
+ return null;
376
+ }
359
377
 
360
378
  // src/detectors/no-verify.ts
361
379
  var RULE = "no-verify";
362
- var LITERAL = [
363
- { re: /(?:^|\s)--no-verify(?:\s|$)/, why: "--no-verify skips the pre-commit/pre-push hooks" },
364
- { re: /(?:^|\s)--no-hooks(?:\s|$)/, why: "--no-hooks disables hook execution" },
365
- { re: /(?:^|[\s;&|=])HUSKY=0(?:\s|$)/, why: "HUSKY=0 disables all Husky hooks" },
366
- { re: /\bHUSKY_SKIP_HOOKS=1\b/, why: "HUSKY_SKIP_HOOKS=1 skips Husky hooks" },
367
- // git-native hook disabling. core.hooksPath is strictly MORE general than the
368
- // flags above: it turns off husky, lefthook, plain .git/hooks and pre-commit
369
- // alike, and it was reachable in three spellings the literal list missed
370
- // (-c, `git config`, and GIT_CONFIG_* env injection). (P1-9, external review.)
380
+ var RAW = [
371
381
  { re: /\bcore\.hooksPath\b/i, why: "core.hooksPath redirects git away from the installed hooks" },
372
- { re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" },
373
- { re: /(?:^|[\s;&|=])LEFTHOOK=0(?:\s|$)/, why: "LEFTHOOK=0 disables Lefthook hooks" },
374
- { re: /\bSKIP_SIMPLE_GIT_HOOKS=1\b/, why: "SKIP_SIMPLE_GIT_HOOKS=1 skips simple-git-hooks" },
375
- { re: /\bPRE_COMMIT_ALLOW_NO_CONFIG=1\b/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" },
376
- // The pre-commit framework's own escape hatch: SKIP=<hook-id>[,…] skips those
377
- // hooks for that one invocation. `.pre-commit-config.yaml` is a protected hook,
378
- // so its documented bypass has to be one too. `SKIP=` is a plausible variable
379
- // name elsewhere, so it counts only in a segment that runs git or pre-commit.
380
- { re: /(?:^|[\s;&|])SKIP=\S+/, why: "SKIP=<hook-id> tells the pre-commit framework to skip that hook", when: /\b(?:git|pre-commit)\b/ },
381
- { re: /(?:^|[\s;&|])LEFTHOOK_EXCLUDE=\S+/, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
382
+ { re: /\bGIT_CONFIG_KEY_\d+\s*=\s*["']?core\.hooksPath/i, why: "GIT_CONFIG_* injection sets core.hooksPath" }
383
+ ];
384
+ var ENV = [
385
+ { re: /^HUSKY=0$/, why: "HUSKY=0 disables all Husky hooks" },
386
+ { re: /^HUSKY_SKIP_HOOKS=1$/, why: "HUSKY_SKIP_HOOKS=1 skips Husky hooks" },
387
+ { re: /^LEFTHOOK=0$/, why: "LEFTHOOK=0 disables Lefthook hooks" },
388
+ { re: /^SKIP_SIMPLE_GIT_HOOKS=1$/, why: "SKIP_SIMPLE_GIT_HOOKS=1 skips simple-git-hooks" },
389
+ { re: /^PRE_COMMIT_ALLOW_NO_CONFIG=1$/, why: "PRE_COMMIT_ALLOW_NO_CONFIG=1 lets pre-commit no-op" },
390
+ { re: /^LEFTHOOK_EXCLUDE=./, why: "LEFTHOOK_EXCLUDE=<name> skips the named Lefthook commands" }
382
391
  ];
392
+ var SKIP = /^SKIP=./;
393
+ var SKIP_WHY = "SKIP=<hook-id> tells the pre-commit framework to skip that hook";
394
+ var ENV_PREFIX = /^(?:export|env|sudo)$/;
395
+ var ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
396
+ var HOOK_SUBCOMMANDS = /* @__PURE__ */ new Set(["commit", "push", "merge"]);
397
+ var NO_VERIFY = /^--no-veri(?:f|fy)?$/;
398
+ var NO_HOOKS = "--no-hooks";
383
399
  var SHORT_N = /^-[a-z]*n[a-z]*$/i;
400
+ var TAKES_VALUE = /* @__PURE__ */ new Set(["-m", "--message", "-F", "--file", "--author", "--date", "--trailer", "-c", "-C", "--reuse-message", "--reedit-message"]);
401
+ function gitArgs(toks, sub) {
402
+ const out2 = [];
403
+ for (let j = toks.indexOf(sub) + 1; j < toks.length; j++) {
404
+ if (TAKES_VALUE.has(toks[j])) {
405
+ j++;
406
+ continue;
407
+ }
408
+ out2.push(toks[j]);
409
+ }
410
+ return out2;
411
+ }
384
412
  var noVerify = {
385
413
  id: RULE,
386
414
  surface: ["command"],
@@ -389,24 +417,53 @@ var noVerify = {
389
417
  const out2 = [];
390
418
  for (const c of changes) {
391
419
  if (c.kind !== "command") continue;
420
+ let carriedSkip = null;
392
421
  for (const seg of segments(c.raw)) {
393
422
  let why = null;
394
- for (const p of LITERAL) {
395
- if (p.re.test(seg) && (!p.when || p.when.test(seg))) {
423
+ let evidence = seg;
424
+ for (const p of RAW) {
425
+ if (p.re.test(seg)) {
396
426
  why = p.why;
397
427
  break;
398
428
  }
399
429
  }
400
- if (!why && /\bgit\b/.test(seg) && /\bcommit\b/.test(seg)) {
401
- if (tokens(seg).some((t) => SHORT_N.test(t))) {
402
- why = "git commit -n skips the pre-commit hook";
430
+ const toks = words(seg);
431
+ let k = 0;
432
+ while (k < toks.length && ENV_PREFIX.test(toks[k])) k++;
433
+ const exported = k > 0 && toks[0] === "export";
434
+ const assigns = [];
435
+ while (k < toks.length && ASSIGNMENT.test(toks[k])) assigns.push(toks[k++]);
436
+ const bareAssignment = k === toks.length && assigns.length > 0;
437
+ const sub = gitSubcommand(toks);
438
+ const runsHookTool = sub !== null || toks.includes("pre-commit");
439
+ if (!why) {
440
+ for (const a of assigns) {
441
+ const hit = ENV.find((p) => p.re.test(a));
442
+ if (hit) {
443
+ why = hit.why;
444
+ break;
445
+ }
446
+ if (SKIP.test(a)) {
447
+ if (runsHookTool) why = SKIP_WHY;
448
+ else if (exported || bareAssignment) carriedSkip = seg;
449
+ }
403
450
  }
404
451
  }
452
+ if (!why && carriedSkip && runsHookTool) {
453
+ why = `${SKIP_WHY} (set earlier in the same command)`;
454
+ evidence = `${carriedSkip}; ${seg}`;
455
+ }
456
+ if (!why && sub && HOOK_SUBCOMMANDS.has(sub)) {
457
+ const args = gitArgs(toks, sub);
458
+ if (args.some((t) => NO_VERIFY.test(t))) why = "--no-verify skips the pre-commit/pre-push hooks";
459
+ else if (sub === "commit" && args.some((t) => SHORT_N.test(t))) why = "git commit -n skips the pre-commit hook";
460
+ }
461
+ if (!why && toks.includes(NO_HOOKS)) why = "--no-hooks disables hook execution";
405
462
  if (why) {
406
463
  out2.push(
407
464
  makeFinding(RULE, policy, {
408
465
  message: `Command bypasses git hooks: ${why}.`,
409
- evidence: seg,
466
+ evidence,
410
467
  remediation: "Let the hooks run and fix what they catch. Skipping verification is exactly the move this gate blocks \u2014 editing the hook and --no-hooks are blocked too."
411
468
  })
412
469
  );
@@ -443,6 +500,16 @@ function applyVersionGates(rules, version, gates = BLOCK_SINCE) {
443
500
  }
444
501
  return out2;
445
502
  }
503
+ function normalizeGlob(glob) {
504
+ return glob.startsWith("/") ? glob.replace(/^\/+/, "") : glob;
505
+ }
506
+ function mergeRules(base, user) {
507
+ const out2 = { ...base };
508
+ for (const [name, cfg] of Object.entries(user ?? {})) {
509
+ out2[name] = { ...out2[name] ?? {}, ...cfg ?? {} };
510
+ }
511
+ return out2;
512
+ }
446
513
  var cache = /* @__PURE__ */ new Map();
447
514
  function matcher(glob) {
448
515
  let m = cache.get(glob);
@@ -452,12 +519,12 @@ function matcher(glob) {
452
519
  }
453
520
  return m;
454
521
  }
455
- function matchesAny(path, globs) {
456
- return !!globs && globs.some((g) => matcher(g)(path));
522
+ function matchesAny(path, globs2) {
523
+ return !!globs2 && globs2.some((g) => matcher(g)(path));
457
524
  }
458
525
  function protectedCategory(path, policy) {
459
- for (const [cat, globs] of Object.entries(policy.protected ?? {})) {
460
- if (matchesAny(path, globs)) return cat;
526
+ for (const [cat, globs2] of Object.entries(policy.protected ?? {})) {
527
+ if (matchesAny(path, globs2)) return cat;
461
528
  }
462
529
  return null;
463
530
  }
@@ -473,9 +540,9 @@ function isIgnored(path, policy) {
473
540
  }
474
541
  function mergeProtected(base, user) {
475
542
  const out2 = { ...base };
476
- for (const [cat, globs] of Object.entries(user ?? {})) {
543
+ for (const [cat, globs2] of Object.entries(user ?? {})) {
477
544
  const have = new Set(out2[cat] ?? []);
478
- out2[cat] = [...out2[cat] ?? [], ...(globs ?? []).filter((g) => !have.has(g))];
545
+ out2[cat] = [...out2[cat] ?? [], ...(globs2 ?? []).filter((g) => !have.has(g))];
479
546
  }
480
547
  return out2;
481
548
  }
@@ -635,12 +702,38 @@ function isSignificantLine(trimmed, lang) {
635
702
  if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
636
703
  return true;
637
704
  }
705
+ var SLASH_COMMENT = /^(?:\/\/|\/\*|\*(?:\s|\/|$))/;
706
+ var HASH_COMMENT_LANGS = /* @__PURE__ */ new Set(["py", "rb", "php"]);
707
+ function isCommentLine(trimmed, lang) {
708
+ const l = lang ?? "js";
709
+ if (HASH_COMMENT_LANGS.has(l) && trimmed.startsWith("#") && !trimmed.startsWith("#[")) return true;
710
+ if (l === "py" || l === "rb") return false;
711
+ return SLASH_COMMENT.test(trimmed);
712
+ }
713
+ function insideStringLiteral(line, idx, lang) {
714
+ const hash = HASH_COMMENT_LANGS.has(lang ?? "js");
715
+ let quote = null;
716
+ for (let j = 0; j < idx; j++) {
717
+ const ch = line[j];
718
+ if (quote) {
719
+ if (ch === "\\") j++;
720
+ else if (ch === quote) quote = null;
721
+ continue;
722
+ }
723
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
724
+ else if (ch === "/" && (line[j + 1] === "/" || line[j + 1] === "*")) return false;
725
+ else if (hash && ch === "#") return false;
726
+ }
727
+ return quote !== null;
728
+ }
638
729
 
639
730
  // src/detectors/ts-any-cast.ts
640
731
  var BLOCK_RULE = "ts-any-cast";
641
732
  var WARN_RULE = "ts-any-launder";
642
733
  var DOUBLE_CAST = /\bas\s+unknown\s+as\b/g;
643
734
  var SUPPRESS = /@ts-(?:ignore|expect-error|nocheck)\b/g;
735
+ var JSDOC_ANY_CAST = /\/\*\*\s*@type\s*\{\s*any\s*\}\s*\*\/\s*\(/g;
736
+ var JS_FILE = /\.(?:js|jsx|mjs|cjs)$/;
644
737
  var countMatches = (s, re) => (s.match(re) || []).length;
645
738
  function countAny(src) {
646
739
  const r = { cast: 0, broad: 0 };
@@ -648,8 +741,13 @@ function countAny(src) {
648
741
  const sf = ts.createSourceFile("f.ts", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
649
742
  const visit = (node) => {
650
743
  if (node.kind === ts.SyntaxKind.AnyKeyword) {
651
- const p = node.parent;
652
- const isCast = p && ts.isAsExpression(p) && p.type === node || p && ts.isTypeAssertionExpression(p) && p.type === node;
744
+ let target = node;
745
+ let p = target.parent;
746
+ while (p && ts.isParenthesizedTypeNode(p)) {
747
+ target = p;
748
+ p = p.parent;
749
+ }
750
+ const isCast = p && ts.isAsExpression(p) && p.type === target || p && ts.isTypeAssertionExpression(p) && p.type === target;
653
751
  if (isCast) r.cast++;
654
752
  else r.broad++;
655
753
  }
@@ -660,7 +758,7 @@ function countAny(src) {
660
758
  }
661
759
  return r;
662
760
  }
663
- var NARROW_LINE = /\bas\s+any\b|<\s*any\s*>|\bas\s+unknown\s+as\b|@ts-(?:ignore|expect-error|nocheck)\b/;
761
+ var NARROW_LINE = /\bas\s+\(*\s*any\b|<\s*any\s*>|\bas\s+unknown\s+as\b|@ts-(?:ignore|expect-error|nocheck)\b/;
664
762
  var BROAD_LINE = /:\s*any\b|<[^<>]*\bany\b[^<>]*>/;
665
763
  var BLOCK_REMEDIATION = "Fix the underlying type instead of silencing the checker; do not cast to `any`.";
666
764
  var WARN_REMEDIATION = "Prefer a precise type or `unknown` + a guard over `any` here \u2014 flagged for review.";
@@ -681,10 +779,12 @@ var tsAnyCast = {
681
779
  const dBroad = a.broad - b.broad;
682
780
  const dDouble = countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST);
683
781
  const dSuppr = countMatches(c.after, SUPPRESS) - countMatches(before, SUPPRESS);
782
+ const dJsdoc = JS_FILE.test(c.path) ? countMatches(c.after, JSDOC_ANY_CAST) - countMatches(before, JSDOC_ANY_CAST) : 0;
684
783
  const blockReasons = [];
685
784
  if (dCast > 0) blockReasons.push("`as any` cast");
686
785
  if (dDouble > 0) blockReasons.push("`as unknown as` double cast");
687
786
  if (dSuppr > 0) blockReasons.push("@ts-ignore/@ts-expect-error/@ts-nocheck suppression");
787
+ if (dJsdoc > 0) blockReasons.push("JSDoc `@type {any}` cast");
688
788
  if (blockReasons.length) {
689
789
  out2.push(
690
790
  makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
@@ -709,8 +809,9 @@ var tsAnyCast = {
709
809
  }
710
810
  continue;
711
811
  }
812
+ const jsdocCast = JS_FILE.test(c.path) ? new RegExp(JSDOC_ANY_CAST.source) : null;
712
813
  for (const l of addedLines(c)) {
713
- if (NARROW_LINE.test(l.content)) {
814
+ if (NARROW_LINE.test(l.content) || jsdocCast && jsdocCast.test(l.content)) {
714
815
  out2.push(
715
816
  makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
716
817
  file: c.path,
@@ -744,30 +845,47 @@ var RULE2 = "lint-suppression";
744
845
  var PATTERNS = {
745
846
  js: [
746
847
  { re: /eslint-disable(?:-next-line|-line)?\b/, why: "eslint-disable suppresses lint rules" },
848
+ // Inline config is the other spelling of the same directive: `/* eslint rule: off */`,
849
+ // `: "off"`, `: 0`, `: [0, …]`. `/* eslint-env */` and `/* eslint rule: "error" */` are not.
850
+ { re: /\/\*\s*eslint\s+(?!-)(?:[^*]*?[\s,])?[\w@/.-]+\s*:\s*\[?\s*(?:["']off["']|off\b|0\b)/, why: "an inline eslint config turns a rule off" },
747
851
  { re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
748
852
  { re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
749
853
  ],
750
854
  py: [
751
855
  { re: /#\s*noqa\b/, why: "# noqa suppresses flake8/ruff findings" },
856
+ { re: /#\s*(?:ruff|flake8):\s*noqa\b/, why: "# ruff: noqa / # flake8: noqa suppresses every finding in the file" },
752
857
  { re: /#\s*type:\s*ignore\b/, why: "# type: ignore suppresses the type checker" },
858
+ { re: /#\s*mypy:\s*ignore-errors\b/, why: "# mypy: ignore-errors suppresses the type checker for the file" },
753
859
  { re: /#\s*pyright:\s*ignore\b/, why: "# pyright: ignore suppresses the type checker" },
754
860
  { re: /#\s*pylint:\s*disable\b/, why: "# pylint: disable suppresses lint rules" },
861
+ { re: /#\s*pylint:\s*skip-file\b/, why: "# pylint: skip-file suppresses lint for the file" },
755
862
  { re: /#\s*nosec\b/, why: "# nosec suppresses bandit security findings" },
756
863
  { re: /#\s*fmt:\s*(?:off|skip)\b/, why: "# fmt: off/skip suppresses formatting checks" }
757
864
  ],
758
- go: [{ re: /\/\/\s*nolint\b/, why: "//nolint suppresses golangci-lint findings" }],
865
+ go: [
866
+ { re: /\/\/\s*nolint\b/, why: "//nolint suppresses golangci-lint findings" },
867
+ { re: /\/\/\s*lint:ignore\b/, why: "//lint:ignore suppresses staticcheck findings" }
868
+ ],
759
869
  rs: [],
760
870
  rb: [{ re: /#\s*rubocop:\s*(?:disable|todo)\b/, why: "# rubocop:disable suppresses lint rules" }],
761
- java: [{ re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" }],
762
- kt: [{ re: /@Suppress(?:Warnings)?\s*\(/, why: "@Suppress suppresses compiler and lint warnings" }],
871
+ java: [
872
+ { re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" },
873
+ { re: /\/\/\s*NOSONAR\b/, why: "// NOSONAR suppresses SonarQube findings" }
874
+ ],
875
+ kt: [
876
+ { re: /@(?:file:)?Suppress(?:Warnings)?\s*\(/, why: "@Suppress suppresses compiler and lint warnings" },
877
+ { re: /\/\/\s*NOSONAR\b/, why: "// NOSONAR suppresses SonarQube findings" }
878
+ ],
763
879
  php: [
764
880
  { re: /\bphpcs:(?:ignore|disable)\b/, why: "phpcs:ignore/disable suppresses coding-standard checks" },
881
+ { re: /@codingStandardsIgnore(?:Line|Start|File)\b/, why: "@codingStandardsIgnore* suppresses coding-standard checks" },
765
882
  { re: /@phpstan-ignore\b/, why: "@phpstan-ignore suppresses static-analysis findings" },
766
883
  { re: /@psalm-suppress\b/, why: "@psalm-suppress suppresses static-analysis findings" }
767
884
  ],
768
885
  cs: [
769
886
  { re: /#pragma\s+warning\s+disable\b/, why: "#pragma warning disable suppresses compiler warnings" },
770
- { re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" }
887
+ { re: /\[SuppressMessage\s*\(/, why: "[SuppressMessage] suppresses analyzer findings" },
888
+ { re: /\/\/\s*ReSharper\s+disable\b/, why: "// ReSharper disable suppresses inspections" }
771
889
  ]
772
890
  };
773
891
  var lintSuppression = {
@@ -784,18 +902,18 @@ var lintSuppression = {
784
902
  if (patterns.length === 0) continue;
785
903
  for (const l of addedLines(c)) {
786
904
  for (const p of patterns) {
787
- if (p.re.test(l.content)) {
788
- out2.push(
789
- makeFinding(RULE2, policy, {
790
- file: c.path,
791
- line: l.newLine ?? void 0,
792
- message: `Lint suppression added: ${p.why}.`,
793
- evidence: l.content.trim(),
794
- remediation: "Resolve the finding rather than suppressing the rule."
795
- })
796
- );
797
- break;
798
- }
905
+ const m = p.re.exec(l.content);
906
+ if (!m || insideStringLiteral(l.content, m.index, lang)) continue;
907
+ out2.push(
908
+ makeFinding(RULE2, policy, {
909
+ file: c.path,
910
+ line: l.newLine ?? void 0,
911
+ message: `Lint suppression added: ${p.why}.`,
912
+ evidence: l.content.trim(),
913
+ remediation: "Resolve the finding rather than suppressing the rule."
914
+ })
915
+ );
916
+ break;
799
917
  }
800
918
  }
801
919
  }
@@ -807,40 +925,66 @@ var lintSuppression = {
807
925
  var RULE3 = "test-skip";
808
926
  var PATTERNS2 = {
809
927
  js: [
810
- { re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
811
- { re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
812
- { re: /\b(?:fit|ftest|fdescribe)\b/, why: "an f-prefixed focused test (narrows the suite)" },
928
+ // vitest chains a concurrency modifier before the marker: it.concurrent.skip.
929
+ { re: /\b(?:it|test|describe|suite)(?:\.(?:concurrent|sequential|shuffle))?\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
930
+ { re: /\b(?:it|test|describe|suite)\.(?:skipIf|runIf)\s*\(/, why: "a .skipIf()/.runIf() condition (the test runs only when the condition allows)" },
931
+ { re: /\b(?:it|test)\.(?:fails|failing)\s*\(/, why: "a .fails/.failing marker (the test now passes by failing)" },
932
+ { re: /\b(?:it|test|describe)\.each\s*\(\s*\[\s*\]\s*\)/, why: "an empty .each table (no case ever runs)" },
933
+ { re: /\b(?:xit|xtest|xdescribe)\s*\(/, why: "an x-prefixed disabled test" },
934
+ { re: /\b(?:fit|ftest|fdescribe)\s*\(/, why: "an f-prefixed focused test (narrows the suite)" },
813
935
  { re: /\bpending\(\s*\)/, why: "a pending() marker" },
814
936
  // node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
815
937
  // a runtime call. A live evasion used the options form the day this gap was measured
816
938
  // (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
817
- { re: /\bskip:\s*(?:true|1|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
939
+ // In options position (right after the title) ANY value but false/0 skips — a
940
+ // non-literal `{ skip: process.env.CI }` included. Elsewhere on a line only the
941
+ // literal forms count: `list({ skip: 1, take: 5 })` is pagination.
942
+ { re: /\b(?:it|test|describe|suite)\s*\(\s*(['"`])(?:(?!\1).)*\1\s*,\s*\{[^}]*?\bskip:(?!\s*(?:false|0)\b)/, why: "a { skip: ... } option disabling the test (node:test options form)" },
943
+ { re: /\bskip:\s*(?:true|['"`])/, why: "a { skip: ... } option disabling the test (node:test options form)" },
818
944
  { re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
819
- { re: /\b(?:t|ctx|context)\.skip\(/, why: "a runtime t.skip() call" }
945
+ { re: /\b(?:t|ctx|context|this)\.skip\(/, why: "a runtime t.skip()/this.skip() call" }
820
946
  ],
821
947
  py: [
822
- { re: /@pytest\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
948
+ // `import pytest as pt` makes the decorator `@pt.mark.skip`; the module alias is free.
949
+ { re: /@\w+\.mark\.(?:skip|skipif|xfail)\b/, why: "a pytest skip/skipif/xfail marker" },
950
+ { re: /\bpytestmark\s*=.*\.mark\.(?:skip|skipif|xfail)\b/, why: "a module-level pytestmark skip (every test in the file is skipped)" },
823
951
  { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
824
- { re: /@unittest\.(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
952
+ // `from unittest import skip` makes the decorator bare `@skip`.
953
+ { re: /@(?:unittest\.)?(?:skip|skipIf|skipUnless|expectedFailure)\b/, why: "a unittest skip/expectedFailure decorator" },
825
954
  { re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
826
- { re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" }
955
+ { re: /\braise\s+(?:unittest\.)?SkipTest\b/, why: "a raised SkipTest" },
956
+ { re: /\b__test__\s*=\s*False\b/, why: "__test__ = False hides the test from collection" }
827
957
  ],
828
958
  go: [
829
- { re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" }
959
+ { re: /\b[tb]\.Skip(?:f|Now)?\(/, why: "a runtime t.Skip()/Skipf()/SkipNow() call" },
960
+ { re: /\bif\s+testing\.Short\(\)/, why: "a testing.Short() guard (the body is skipped under -short)" },
961
+ { re: /^\s*\/\/\s*(?:go:build|\+build)\s+ignore\b/, why: "a build-ignore constraint (the file is excluded from the test run)", comment: true }
962
+ ],
963
+ rs: [
964
+ { re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" },
965
+ { re: /#\[cfg_attr\(.*\bignore\b/, why: "a cfg_attr(\u2026, ignore) attribute (the test is ignored under that configuration)" }
830
966
  ],
831
- rs: [{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" }],
832
967
  rb: [
833
- { re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*$)/, why: "a skip/pending call" },
834
- { re: /\bx(?:it|describe|context|specify|example)\b/, why: "an x-prefixed disabled example" },
835
- { re: /\b(?:fit|fdescribe|fcontext)\b/, why: "an f-prefixed focused example (narrows the suite)" },
836
- { re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip:/pending: metadata flag" }
968
+ { re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*\}|\s*$)/, why: "a skip/pending call" },
969
+ { re: /(?:^|[\s;{(])x(?:it|describe|context|specify|example)\s*[('"]/, why: "an x-prefixed disabled example" },
970
+ { re: /(?:^|[\s;{(])f(?:it|describe|context)\s*[('"]/, why: "an f-prefixed focused example (narrows the suite)" },
971
+ { re: /\b(?:skip|pending):\s*(?:true|['"])/, why: "a skip/pending metadata flag" },
972
+ { re: /^\s*(?:it|specify|example)\s+(['"])(?:(?!\1).)*\1\s*$/, why: "an example with no block (RSpec reports it as pending)" }
973
+ ],
974
+ java: [
975
+ { re: /@(?:[\w.]+\.)?(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" },
976
+ { re: /\bassume(?:True\(\s*false|False\(\s*true)\s*[,)]/, why: "an assumption that never holds (the test aborts as skipped)" }
977
+ ],
978
+ kt: [
979
+ { re: /@(?:[\w.]+\.)?(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" },
980
+ { re: /\bassume(?:True\(\s*false|False\(\s*true)\s*[,)]/, why: "an assumption that never holds (the test aborts as skipped)" }
837
981
  ],
838
- java: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
839
- kt: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
840
982
  php: [{ re: /\bmarkTest(?:Skipped|Incomplete)\(/, why: "a markTestSkipped()/markTestIncomplete() call" }],
841
983
  cs: [
842
984
  { re: /\[Ignore\b/, why: "an [Ignore] attribute" },
843
- { re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' }
985
+ { re: /\bSkip\s*=\s*"/, why: 'a Skip = "..." attribute argument' },
986
+ { re: /(?:\[|,)\s*Explicit\b/, why: "an [Explicit] attribute (the test runs only when selected by name)" },
987
+ { re: /\bAssert\.Inconclusive\(/, why: "an Assert.Inconclusive() call (the test ends without a verdict)" }
844
988
  ]
845
989
  };
846
990
  var testSkip = {
@@ -852,9 +996,12 @@ var testSkip = {
852
996
  for (const c of changes) {
853
997
  if (c.kind !== "file") continue;
854
998
  if (!isProtected(c.path, policy, "tests")) continue;
855
- const patterns = PATTERNS2[langOf(c.path) ?? "js"];
999
+ const lang = langOf(c.path);
1000
+ const patterns = PATTERNS2[lang ?? "js"];
856
1001
  for (const l of addedLines(c)) {
1002
+ const comment = isCommentLine(l.content.trim(), lang);
857
1003
  for (const p of patterns) {
1004
+ if (comment && !p.comment) continue;
858
1005
  if (p.re.test(l.content)) {
859
1006
  out2.push(
860
1007
  makeFinding(RULE3, policy, {
@@ -1164,6 +1311,7 @@ var ciTampering = {
1164
1311
 
1165
1312
  // src/detectors/policy-diff.ts
1166
1313
  import { parse } from "yaml";
1314
+ var globs = (list) => Array.isArray(list) ? list.filter((g) => typeof g === "string").map(normalizeGlob) : [];
1167
1315
  function safeParse(src) {
1168
1316
  try {
1169
1317
  const v = parse(src);
@@ -1177,12 +1325,19 @@ function effective(raw) {
1177
1325
  const v = raw.version;
1178
1326
  const version = typeof v === "number" && Number.isInteger(v) && v >= 1 ? v : 1;
1179
1327
  const base = defaultPolicy(version);
1328
+ const rules = { ...base.rules };
1329
+ for (const [name, cfg] of Object.entries(raw.rules ?? {})) {
1330
+ const over = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg : {};
1331
+ rules[name] = { ...rules[name] ?? {}, ...over, ...over.exclude ? { exclude: globs(over.exclude) } : {} };
1332
+ }
1333
+ const userProtected = raw.protected && typeof raw.protected === "object" ? Object.fromEntries(Object.entries(raw.protected).map(([cat, list]) => [cat, globs(list)])) : void 0;
1180
1334
  return {
1181
1335
  version,
1182
- rules: { ...base.rules, ...raw.rules ?? {} },
1183
- ignore: raw.ignore ?? base.ignore ?? [],
1184
- protected: mergeProtected(base.protected, raw.protected),
1185
- requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor
1336
+ rules,
1337
+ ignore: raw.ignore ? globs(raw.ignore) : base.ignore ?? [],
1338
+ protected: mergeProtected(base.protected, userProtected),
1339
+ requiredFor: raw.signoff?.required_for ?? raw.signoff?.requiredFor ?? base.signoff.requiredFor,
1340
+ ledger: typeof raw.signoff?.ledger === "string" ? raw.signoff.ledger : base.signoff.ledger
1186
1341
  };
1187
1342
  }
1188
1343
  function policyWeakening(before, after) {
@@ -1234,6 +1389,9 @@ function policyWeakening(before, after) {
1234
1389
  if (be.requiredFor.includes("block") && !new Set(ae.requiredFor).has("block")) {
1235
1390
  reasons.push("sign-off no longer required for blocking findings");
1236
1391
  }
1392
+ if (ae.ledger !== be.ledger) {
1393
+ reasons.push(`sign-off ledger moved (${be.ledger} \u2192 ${ae.ledger}) \u2014 the local layer would trust a different file`);
1394
+ }
1237
1395
  const bv = b.verify;
1238
1396
  const av = a.verify;
1239
1397
  if (bv?.command) {
@@ -1633,10 +1791,13 @@ var testContentRemoval = {
1633
1791
  // src/detectors/snapshot-rewrite.ts
1634
1792
  var RULE9 = "snapshot-rewrite";
1635
1793
  var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
1636
- var UPDATE_ANY = /--update-?[sS]napshots?\b/;
1794
+ var PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
1795
+ var UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
1637
1796
  var UPDATE_WITH_RUNNER = /(?:^|\s)(?:-u|--update)(?:\s|$)/;
1797
+ var INSTA_ACCEPT = /\bcargo\s+insta\s+(?:accept\b|.*\s--accept\b)/;
1798
+ var UPDATE_EXPECT = /(?:^|\s)UPDATE_EXPECT=\S+/;
1638
1799
  var REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
1639
- var MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}/;
1800
+ var MUTATE = /(?:^|\s)(?:rm|cp|mv|tee|truncate|dd)(?:\s|$)|sed\s+-i|>{1,2}|\bfind\b.*\s-(?:delete\b|exec\s+rm\b)|\bgit\s+(?:checkout|restore)\b/;
1640
1801
  function namesProtectedSnapshot(seg, policy) {
1641
1802
  for (const t of tokens(seg)) {
1642
1803
  const path = unquote(t).replace(/^>{1,2}/, "");
@@ -1656,7 +1817,9 @@ var snapshotRewrite = {
1656
1817
  for (const seg of segments(c.raw)) {
1657
1818
  let why = null;
1658
1819
  if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
1659
- else if (RUNNER.test(seg) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
1820
+ else if ((RUNNER.test(seg) || PM_TEST_SCRIPT.test(seg)) && UPDATE_WITH_RUNNER.test(seg)) why = "running the test runner in update mode rewrites failing snapshots to match current output";
1821
+ else if (INSTA_ACCEPT.test(seg)) why = "cargo insta accept rewrites every pending snapshot to match current output";
1822
+ else if (UPDATE_EXPECT.test(seg)) why = "UPDATE_EXPECT=1 rewrites every expect-test expectation to match current output";
1660
1823
  else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
1661
1824
  else {
1662
1825
  const path = MUTATE.test(seg) ? namesProtectedSnapshot(seg, policy) : null;
@@ -1789,7 +1952,7 @@ function hasBlocking(findings) {
1789
1952
 
1790
1953
  // src/policy-load.ts
1791
1954
  import { readFileSync as readFileSync2, existsSync } from "node:fs";
1792
- import { join as join2 } from "node:path";
1955
+ import { isAbsolute, join as join2, posix } from "node:path";
1793
1956
  import { parse as parse2 } from "yaml";
1794
1957
  var PolicyError = class extends Error {
1795
1958
  };
@@ -1804,11 +1967,21 @@ var SEVERITIES = ["block", "warn"];
1804
1967
  var isSeverity = (v) => SEVERITIES.includes(v);
1805
1968
  var isStringList = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
1806
1969
  var isMapping = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1970
+ var TOP_LEVEL_KEYS = /* @__PURE__ */ new Set(["version", "protected", "rules", "ignore", "signoff", "verify"]);
1971
+ function ledgerInsideRepo(ledger) {
1972
+ if (!ledger.trim() || isAbsolute(ledger) || /^[A-Za-z]:[\\/]/.test(ledger)) return false;
1973
+ const norm2 = posix.normalize(ledger.replace(/\\/g, "/"));
1974
+ return norm2 !== ".." && !norm2.startsWith("../") && !norm2.startsWith("/");
1975
+ }
1807
1976
  function validate(r, where) {
1808
1977
  function bad(msg) {
1809
1978
  throw new PolicyError(`${where}: ${msg}`);
1810
1979
  }
1811
1980
  const show = (v) => JSON.stringify(v) ?? String(v);
1981
+ const unknown = Object.keys(r).filter((k) => !TOP_LEVEL_KEYS.has(k));
1982
+ if (unknown.length) {
1983
+ bad(`unknown top-level key${unknown.length === 1 ? "" : "s"} ${unknown.map((k) => JSON.stringify(k)).join(", ")} (expected one of ${[...TOP_LEVEL_KEYS].join(", ")})`);
1984
+ }
1812
1985
  if (r.rules !== void 0) {
1813
1986
  if (!isMapping(r.rules)) bad(`rules must be a mapping of rule name to { severity, enabled, exclude }, got ${show(r.rules)}`);
1814
1987
  for (const [name, cfg] of Object.entries(r.rules)) {
@@ -1827,8 +2000,8 @@ function validate(r, where) {
1827
2000
  if (r.ignore !== void 0 && !isStringList(r.ignore)) bad(`ignore must be a list of globs, got ${show(r.ignore)}`);
1828
2001
  if (r.protected !== void 0) {
1829
2002
  if (!isMapping(r.protected)) bad(`protected must be a mapping of category to a list of globs, got ${show(r.protected)}`);
1830
- for (const [cat, globs] of Object.entries(r.protected)) {
1831
- if (!isStringList(globs)) bad(`protected.${cat} must be a list of globs, got ${show(globs)}`);
2003
+ for (const [cat, globs2] of Object.entries(r.protected)) {
2004
+ if (!isStringList(globs2)) bad(`protected.${cat} must be a list of globs, got ${show(globs2)}`);
1832
2005
  }
1833
2006
  }
1834
2007
  if (r.signoff !== void 0) {
@@ -1841,6 +2014,9 @@ function validate(r, where) {
1841
2014
  }
1842
2015
  const ledger = r.signoff.ledger;
1843
2016
  if (ledger !== void 0 && typeof ledger !== "string") bad(`signoff.ledger must be a path, got ${show(ledger)}`);
2017
+ if (typeof ledger === "string" && !ledgerInsideRepo(ledger)) {
2018
+ bad(`signoff.ledger must be a relative path inside the repository, got ${show(ledger)}`);
2019
+ }
1844
2020
  }
1845
2021
  if (r.verify !== void 0) {
1846
2022
  if (!isMapping(r.verify)) bad(`verify must be a mapping like { command: "npm test" }, got ${show(r.verify)}`);
@@ -1857,14 +2033,20 @@ function parsePolicy(raw, where = POLICY_FILE) {
1857
2033
  const version = normalizeVersion(r.version, where);
1858
2034
  validate(r, where);
1859
2035
  const base = defaultPolicy(version);
2036
+ const globs2 = (list) => list?.map(normalizeGlob);
2037
+ const userProtected = r.protected ? Object.fromEntries(Object.entries(r.protected).map(([cat, list]) => [cat, globs2(list) ?? []])) : void 0;
2038
+ const userRules = r.rules ? Object.fromEntries(
2039
+ Object.entries(r.rules).map(([name, cfg]) => [name, cfg?.exclude ? { ...cfg, exclude: globs2(cfg.exclude) } : cfg])
2040
+ ) : void 0;
1860
2041
  return {
1861
2042
  version,
1862
2043
  // Merge with the baseline, never replace. For an integrity tool, a config that sets
1863
2044
  // one rule's severity must NOT silently drop the other nine — nor may naming one
1864
- // protected glob wipe out the rest of its category (see mergeProtected).
1865
- protected: mergeProtected(base.protected, r.protected),
1866
- rules: { ...base.rules, ...r.rules ?? {} },
1867
- ignore: r.ignore ?? base.ignore,
2045
+ // protected glob wipe out the rest of its category (see mergeProtected), nor may
2046
+ // an `exclude`-only override drop the rule's baseline severity (see mergeRules).
2047
+ protected: mergeProtected(base.protected, userProtected),
2048
+ rules: mergeRules(base.rules, userRules),
2049
+ ignore: globs2(r.ignore) ?? base.ignore,
1868
2050
  signoff: {
1869
2051
  requiredFor: r.signoff?.required_for ?? r.signoff?.requiredFor ?? base.signoff.requiredFor,
1870
2052
  ledger: r.signoff?.ledger ?? base.signoff.ledger
@@ -1914,7 +2096,11 @@ function fingerprint(rule, file, evidence) {
1914
2096
  }
1915
2097
  var fingerprintOf = (f) => fingerprint(f.rule, f.file, f.evidence);
1916
2098
  function ledgerPath(cwd, policy) {
1917
- return join3(cwd, policy.signoff?.ledger ?? ".tamperward/ledger.jsonl");
2099
+ const rel = policy.signoff?.ledger ?? ".tamperward/ledger.jsonl";
2100
+ if (!ledgerInsideRepo(rel)) {
2101
+ throw new PolicyError(`signoff.ledger must be a relative path inside the repository, got ${JSON.stringify(rel)}`);
2102
+ }
2103
+ return join3(cwd, rel);
1918
2104
  }
1919
2105
  function readLedger(cwd, policy) {
1920
2106
  const p = ledgerPath(cwd, policy);
@@ -2008,7 +2194,7 @@ function paint(s, code, on) {
2008
2194
  function wrap(text, width) {
2009
2195
  const out2 = [];
2010
2196
  let cur = "";
2011
- for (const w of text.split(/\s+/).filter(Boolean)) {
2197
+ for (const w of stripControl(text).split(/\s+/).filter(Boolean)) {
2012
2198
  if (cur === "") cur = w;
2013
2199
  else if (cur.length + 1 + w.length <= width) cur += " " + w;
2014
2200
  else {
@@ -2073,15 +2259,15 @@ function renderText(input, opts) {
2073
2259
  for (const f of findings) {
2074
2260
  const isBlock = f.severity === "block";
2075
2261
  const mark = paint(isBlock ? "BLOCK" : "warn ", isBlock ? BOLD + RED : YELLOW, c);
2076
- const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "(command)";
2077
- out2.push(` ${mark} ${paint(f.rule, BOLD, c)} ${paint(loc, DIM, c)}`);
2262
+ const loc = f.file ? `${stripControl(f.file)}${f.line ? `:${f.line}` : ""}` : "(command)";
2263
+ out2.push(` ${mark} ${paint(stripControl(f.rule), BOLD, c)} ${paint(loc, DIM, c)}`);
2078
2264
  for (const l of wrap(f.message, opts.width - 4)) out2.push(` ${l}`);
2079
2265
  const row = (key2, lines) => {
2080
2266
  lines.forEach((l, i) => out2.push(` ${paint(pad(i === 0 ? key2 : ""), DIM, c)} ${l}`));
2081
2267
  };
2082
2268
  row("evidence", [paint(clip(f.evidence, body), DIM, c)]);
2083
2269
  row("instead", wrap(f.remediation, body));
2084
- if (f.signoff.required) row("sign-off", [f.signoff.command]);
2270
+ if (f.signoff.required) row("sign-off", [stripControl(f.signoff.command)]);
2085
2271
  out2.push("");
2086
2272
  }
2087
2273
  if (blocks.length > 0) {
@@ -2095,7 +2281,7 @@ function renderText(input, opts) {
2095
2281
  // src/cli/render/github.ts
2096
2282
  import { appendFileSync as appendFileSync2 } from "node:fs";
2097
2283
  function escData(s) {
2098
- return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
2284
+ return stripControl(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
2099
2285
  }
2100
2286
  function escProp(s) {
2101
2287
  return escData(s).replace(/:/g, "%3A").replace(/,/g, "%2C");
@@ -2114,7 +2300,7 @@ function annotations(findings) {
2114
2300
  });
2115
2301
  }
2116
2302
  function cell(s) {
2117
- return s.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
2303
+ return stripControl(s).replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
2118
2304
  }
2119
2305
  function mdCode(s) {
2120
2306
  return s.replace(/`/g, "'").replace(/[\r\n]/g, " ");
@@ -2222,8 +2408,13 @@ function report(input) {
2222
2408
 
2223
2409
  // src/cli/check.ts
2224
2410
  function check(opts) {
2225
- let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
2226
2411
  const cwd = opts.cwd ?? process.cwd();
2412
+ if (!isGitRepo(cwd)) {
2413
+ process.stderr.write(`tamperward: ${cwd} is not inside a git repository \u2014 nothing to check
2414
+ `);
2415
+ return 2;
2416
+ }
2417
+ let policy = opts.policyOverride ?? loadPolicy(opts.cwd);
2227
2418
  let changes;
2228
2419
  let layer;
2229
2420
  let view;
@@ -2236,13 +2427,20 @@ function check(opts) {
2236
2427
  layer = opts.ciLayer ? "ci" : "local";
2237
2428
  view = "worktree";
2238
2429
  } else if (opts.diff) {
2239
- const [base, head] = opts.diff.split(/\.{2,3}/);
2240
- if (!base || !head) {
2430
+ const m = opts.diff.match(/^(.+?)\.{2,3}(.+)$/);
2431
+ const [base, head] = m ? [m[1], m[2]] : ["", ""];
2432
+ if (!base || !head || base.endsWith(".") || head.startsWith(".")) {
2241
2433
  process.stderr.write(`tamperward: invalid --diff range "${opts.diff}" (expected <base>...<head>)
2242
2434
  `);
2243
2435
  return 2;
2244
2436
  }
2245
2437
  changes = diffRange(base, head, { cwd: opts.cwd });
2438
+ if (changes.length === 0) {
2439
+ process.stderr.write(
2440
+ `tamperward: note: the range ${base}...${head} contains no changes \u2014 if this is not an empty pull request, check that the range resolves to the intended commits
2441
+ `
2442
+ );
2443
+ }
2246
2444
  layer = "ci";
2247
2445
  view = "range";
2248
2446
  policy = opts.policyOverride ?? loadPolicyAt(mergeBaseOf(base, head, { cwd: opts.cwd }), opts.cwd) ?? defaultPolicy();
@@ -2283,7 +2481,7 @@ import { createHash as createHash4 } from "node:crypto";
2283
2481
  import { execFileSync as execFileSync2 } from "node:child_process";
2284
2482
  import { readFileSync as readFileSync4, existsSync as existsSync3, writeFileSync, mkdtempSync, rmSync } from "node:fs";
2285
2483
  import { tmpdir } from "node:os";
2286
- import { isAbsolute, join as join4, relative, resolve } from "node:path";
2484
+ import { isAbsolute as isAbsolute2, join as join4, relative, resolve } from "node:path";
2287
2485
  function asStr(v) {
2288
2486
  return typeof v === "string" ? v : "";
2289
2487
  }
@@ -2299,7 +2497,7 @@ function abs(path, cwd) {
2299
2497
  }
2300
2498
  function relForDisplay(path, cwd) {
2301
2499
  const rel = relative(cwd, path);
2302
- return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
2500
+ return rel && !rel.startsWith("..") && !isAbsolute2(rel) ? rel : path;
2303
2501
  }
2304
2502
  function applyEdit(content, oldStr, newStr) {
2305
2503
  if (content === null) return newStr;
@@ -2443,7 +2641,7 @@ function advanceTurnBaseline(cwd, sessionId) {
2443
2641
  // src/effect.ts
2444
2642
  import { createHash as createHash2 } from "node:crypto";
2445
2643
  import { existsSync as existsSync5, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync6, statSync, writeFileSync as writeFileSync3 } from "node:fs";
2446
- import { isAbsolute as isAbsolute2, join as join6 } from "node:path";
2644
+ import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
2447
2645
  import { execFileSync as execFileSync3 } from "node:child_process";
2448
2646
  var UNSAFE2 = /[^A-Za-z0-9._-]/g;
2449
2647
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
@@ -2455,7 +2653,7 @@ function gitDir2(cwd) {
2455
2653
  stdio: ["ignore", "pipe", "pipe"]
2456
2654
  }).trim();
2457
2655
  if (!d) return null;
2458
- return isAbsolute2(d) ? d : join6(cwd, d);
2656
+ return isAbsolute3(d) ? d : join6(cwd, d);
2459
2657
  } catch {
2460
2658
  return null;
2461
2659
  }
@@ -2535,7 +2733,7 @@ function savePtree(cwd, sessionId, tree) {
2535
2733
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2536
2734
  import { join as join7 } from "node:path";
2537
2735
  import { createHash as createHash3 } from "node:crypto";
2538
- var SKIP = /(^|\/)(\.git|node_modules)(\/|$)/;
2736
+ var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
2539
2737
  function snap(abs2) {
2540
2738
  try {
2541
2739
  const st = statSync2(abs2);
@@ -2562,7 +2760,7 @@ function watchTree(dir, cb) {
2562
2760
  }
2563
2761
  const watchers = /* @__PURE__ */ new Map();
2564
2762
  const addDir = (rel) => {
2565
- if (watchers.has(rel) || SKIP.test(rel + "/")) return;
2763
+ if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
2566
2764
  let w;
2567
2765
  try {
2568
2766
  w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
@@ -2589,7 +2787,7 @@ function watchTree(dir, cb) {
2589
2787
  }
2590
2788
  for (const name of names) {
2591
2789
  const child = rel ? `${rel}/${name}` : name;
2592
- if (SKIP.test(child + "/")) continue;
2790
+ if (SKIP2.test(child + "/")) continue;
2593
2791
  try {
2594
2792
  if (statSync2(join7(dir, child)).isDirectory()) walk(child);
2595
2793
  } catch {
@@ -2608,7 +2806,7 @@ function startWatcher(dir, log, policy) {
2608
2806
  }
2609
2807
  const last = /* @__PURE__ */ new Map();
2610
2808
  return watchTree(dir, (kind, rel) => {
2611
- if (SKIP.test(rel) || !isProtected(rel, policy)) return;
2809
+ if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
2612
2810
  const s = snap(join7(dir, rel));
2613
2811
  const key2 = `${s.hash}:${s.mode}`;
2614
2812
  if (last.get(rel) === key2) return;
@@ -2972,9 +3170,18 @@ Honored at LOCAL pre-commit only. The agent-layer hook ignores this file; CI req
2972
3170
  // src/cli/init.ts
2973
3171
  import { execFileSync as execFileSync4 } from "node:child_process";
2974
3172
  import { createHash as createHash5 } from "node:crypto";
2975
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync } from "node:fs";
2976
- import { dirname as dirname3, join as join8 } from "node:path";
3173
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync5, chmodSync, statSync as statSync5 } from "node:fs";
3174
+ import { basename, dirname as dirname3, isAbsolute as isAbsolute4, join as join8, relative as relative2, resolve as resolve2 } from "node:path";
2977
3175
  import { fileURLToPath } from "node:url";
3176
+ var errText2 = (e) => (e instanceof Error ? e.message : String(e)).replace(/\s+/g, " ").trim();
3177
+ var isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3178
+ function gitConfig(cwd, key2) {
3179
+ try {
3180
+ return execFileSync4("git", ["config", "--get", key2], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
3181
+ } catch {
3182
+ return null;
3183
+ }
3184
+ }
2978
3185
  var PRE_MATCHER = "Bash|Edit|Write|MultiEdit|NotebookEdit";
2979
3186
  var MARKER = "# tamperward: block agent shortcuts before they land";
2980
3187
  var HOOK_RE = /\btamperward(?:@\S+)?\s+hook\s+claude\b/;
@@ -2987,25 +3194,28 @@ function coveredBy(existing, critical) {
2987
3194
  for (const line of existing.split("\n")) {
2988
3195
  const t = line.trim();
2989
3196
  if (!t || t.startsWith("#")) continue;
2990
- const pattern = t.split(/\s+/)[0];
2991
- if (!t.slice(pattern.length).trim()) continue;
2992
- if (pattern === "*" || pattern === critical) return true;
2993
- if (pattern.endsWith("/") && critical.startsWith(pattern)) return true;
3197
+ const raw = t.split(/\s+/)[0];
3198
+ if (!t.slice(raw.length).trim()) continue;
3199
+ if (raw === "*" || raw === "**") return true;
3200
+ const pattern = raw.replace(/\/\*{1,2}$/, "/");
3201
+ if (pattern.includes("*")) continue;
3202
+ const anchored = pattern.startsWith("/") ? pattern : "/" + pattern;
3203
+ if (anchored === critical) return true;
3204
+ if (anchored.endsWith("/") && critical.startsWith(anchored)) return true;
3205
+ if (!pattern.startsWith("/")) {
3206
+ if (critical.endsWith("/" + pattern)) return true;
3207
+ if (pattern.endsWith("/") && critical.includes("/" + pattern)) return true;
3208
+ }
2994
3209
  }
2995
3210
  return false;
2996
3211
  }
2997
3212
  function inferOwner(cwd) {
2998
- try {
2999
- const url = execFileSync4("git", ["remote", "get-url", "origin"], {
3000
- cwd,
3001
- encoding: "utf8",
3002
- stdio: ["ignore", "pipe", "ignore"]
3003
- }).trim();
3004
- const m = url.match(/github\.com[:/]+([^/]+)\//);
3005
- return m ? `@${m[1]}` : null;
3006
- } catch {
3007
- return null;
3008
- }
3213
+ const url = gitConfig(cwd, "remote.origin.url");
3214
+ const m = url?.match(/github\.com[:/]+([^/]+)\//);
3215
+ if (!m) return null;
3216
+ const segment = m[1];
3217
+ const self = [gitConfig(cwd, "github.user"), gitConfig(cwd, "user.name")].filter((v) => v !== null);
3218
+ return { owner: `@${segment}`, confirmed: self.some((v) => v.toLowerCase() === segment.toLowerCase()) };
3009
3219
  }
3010
3220
  var POLICY_CONTENT = `# Tamperward policy. The BASELINE (all rules, standard protected globs) applies even
3011
3221
  # without this file \u2014 everything here is an override, so an empty file changes nothing.
@@ -3143,9 +3353,31 @@ function missingTools(matcher2) {
3143
3353
  if (have === null) return [];
3144
3354
  return PRE_MATCHER.split("|").filter((t) => !have.has(t));
3145
3355
  }
3356
+ function hooksShapeError(hooks) {
3357
+ if (hooks === void 0 || hooks === null) return null;
3358
+ if (!isPlainObject(hooks)) return "hooks is not an object";
3359
+ for (const [event, arr] of Object.entries(hooks)) {
3360
+ if (arr === void 0 || arr === null) continue;
3361
+ if (!Array.isArray(arr)) return `hooks.${event} is not a list`;
3362
+ for (const m of arr) {
3363
+ if (!isPlainObject(m)) return `hooks.${event} contains an entry that is not an object`;
3364
+ if (m.hooks !== void 0 && m.hooks !== null && !(Array.isArray(m.hooks) && m.hooks.every(isPlainObject))) {
3365
+ return `hooks.${event} has a "hooks" value that is not a list of { type, command }`;
3366
+ }
3367
+ }
3368
+ }
3369
+ return null;
3370
+ }
3146
3371
  function planPolicy(cwd) {
3147
3372
  const path = join8(cwd, POLICY_FILE);
3148
- if (existsSync7(path)) return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present \u2014 left untouched" };
3373
+ if (existsSync7(path)) {
3374
+ try {
3375
+ loadPolicy(cwd);
3376
+ } catch (e) {
3377
+ return { item: "policy", path: POLICY_FILE, status: "error", detail: `present but does not load \u2014 ${errText2(e)}; fix it, then re-run init (left untouched)` };
3378
+ }
3379
+ return { item: "policy", path: POLICY_FILE, status: "ok", detail: "already present and loads \u2014 left untouched" };
3380
+ }
3149
3381
  return {
3150
3382
  item: "policy",
3151
3383
  path: POLICY_FILE,
@@ -3175,6 +3407,10 @@ function planClaudeHooks(cwd) {
3175
3407
  }
3176
3408
  settings = parsed;
3177
3409
  }
3410
+ const shapeError = hooksShapeError(settings.hooks);
3411
+ if (shapeError) {
3412
+ return { item: "agent", path: rel, status: "error", detail: `${shapeError} \u2014 fix it, then re-run init (refusing to overwrite)` };
3413
+ }
3178
3414
  const hooks = settings.hooks ??= {};
3179
3415
  const entriesWith = (arr, needle) => (arr ?? []).filter((m) => (m.hooks ?? []).some((h) => needle.test(String(h.command ?? ""))));
3180
3416
  const preEntries = entriesWith(hooks.PreToolUse, HOOK_RE);
@@ -3220,47 +3456,170 @@ function planClaudeHooks(cwd) {
3220
3456
  }
3221
3457
  };
3222
3458
  }
3459
+ var MANAGED_HOOKS = [
3460
+ {
3461
+ name: "lefthook",
3462
+ configs: ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml", "lefthook-local.yml", "lefthook-local.yaml"],
3463
+ hookRe: /\blefthook\b/i,
3464
+ hint: `add this under \`pre-commit:\` \u2192 \`commands:\` in lefthook.yml:
3465
+ tamperward:
3466
+ run: ${PRECOMMIT_CMD}`
3467
+ },
3468
+ {
3469
+ name: "pre-commit",
3470
+ configs: [".pre-commit-config.yaml", ".pre-commit-config.yml"],
3471
+ hookRe: /pre-commit\.com|File generated by pre-commit/i,
3472
+ hint: `add this under \`repos:\` in .pre-commit-config.yaml:
3473
+ - repo: local
3474
+ hooks:
3475
+ - id: tamperward
3476
+ name: tamperward
3477
+ entry: ${PRECOMMIT_CMD}
3478
+ language: system
3479
+ pass_filenames: false
3480
+ always_run: true`
3481
+ }
3482
+ ];
3483
+ function hooksDir(cwd) {
3484
+ try {
3485
+ const out2 = execFileSync4("git", ["rev-parse", "--git-path", "hooks"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
3486
+ if (out2) return resolve2(cwd, out2);
3487
+ } catch {
3488
+ }
3489
+ return existsSync7(join8(cwd, ".git")) ? join8(cwd, ".git", "hooks") : null;
3490
+ }
3491
+ function display(cwd, path) {
3492
+ const rel = relative2(cwd, path);
3493
+ return rel && !rel.startsWith("..") && !isAbsolute4(rel) ? rel : path;
3494
+ }
3495
+ function unconditionalExitAt(lines) {
3496
+ let depth = 0;
3497
+ for (let i = 0; i < lines.length; i++) {
3498
+ const t = lines[i].trim();
3499
+ if (!t || t.startsWith("#")) continue;
3500
+ if (depth === 0) {
3501
+ if (/^exit(\s|$|;)/.test(t)) return i;
3502
+ const exec = t.match(/^exec(?:\s+(.*))?$/);
3503
+ if (exec && exec[1] && !/^(\d*[<>]|&>)/.test(exec[1])) return i;
3504
+ }
3505
+ const opens = (t.match(/(?:^|[;(]\s*|&&\s*|\|\|\s*)(?:if|case|for|while|until)\s/g) ?? []).length + (/(?:^|\s)\{\s*$/.test(t) ? 1 : 0);
3506
+ const closes = (t.match(/(?:^|[;]\s*)(?:fi|esac|done)(?:\s|;|$)/g) ?? []).length + (/^\}/.test(t) ? 1 : 0);
3507
+ depth = Math.max(0, depth + opens - closes);
3508
+ }
3509
+ return -1;
3510
+ }
3223
3511
  function planPreCommit(cwd) {
3224
- const line = `${MARKER}
3225
- ${PRECOMMIT_CMD}
3226
- `;
3227
- const husky = join8(cwd, ".husky");
3228
- const gitDir3 = join8(cwd, ".git");
3229
- const target = existsSync7(husky) ? { rel: ".husky/pre-commit", note: "husky" } : existsSync7(gitDir3) ? { rel: ".git/hooks/pre-commit", note: "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" } : null;
3230
- if (!target) return { item: "pre-commit", path: "(none)", status: "skip", detail: "not a git repo and no .husky/ \u2014 nothing to wire" };
3231
- const path = join8(cwd, target.rel);
3512
+ const block = [MARKER, PRECOMMIT_CMD];
3513
+ const skip = (detail) => ({ item: "pre-commit", path: "(none)", status: "skip", detail });
3514
+ let path;
3515
+ let note;
3516
+ if (existsSync7(join8(cwd, ".husky"))) {
3517
+ path = join8(cwd, ".husky", "pre-commit");
3518
+ note = "husky";
3519
+ } else {
3520
+ const managed = MANAGED_HOOKS.find((m) => m.configs.some((c) => existsSync7(join8(cwd, c))));
3521
+ if (managed) {
3522
+ return skip(`${managed.name} manages this repository's hooks and regenerates them on install, so a line in the hooks directory would not last \u2014 ${managed.hint}`);
3523
+ }
3524
+ const dir = hooksDir(cwd);
3525
+ if (!dir) return skip("not a git repo and no .husky/ \u2014 nothing to wire");
3526
+ if (basename(dir) === "_" && basename(dirname3(dir)) === ".husky") {
3527
+ path = join8(dirname3(dir), "pre-commit");
3528
+ note = "husky";
3529
+ } else {
3530
+ path = join8(dir, "pre-commit");
3531
+ const rel2 = display(cwd, dir);
3532
+ note = rel2 === join8(".git", "hooks") ? "plain git hook (local-only: .git/hooks is not committed \u2014 consider husky to share it)" : `git hooks directory ${rel2} (core.hooksPath)`;
3533
+ }
3534
+ }
3535
+ const rel = display(cwd, path);
3536
+ if (existsSync7(path) && !statSync5(path).isFile()) {
3537
+ return { item: "pre-commit", path: rel, status: "error", detail: "exists but is not a regular file \u2014 refusing to write" };
3538
+ }
3232
3539
  const existing = existsSync7(path) ? readFileSync10(path, "utf8") : null;
3233
- if (existing !== null && PRECOMMIT_RE.test(existing)) {
3234
- const lines = existing.split("\n");
3235
- const staleAt = lines.findIndex((l) => PRECOMMIT_RE.test(l) && stalePin(l));
3236
- if (staleAt === -1) {
3237
- return { item: "pre-commit", path: target.rel, status: "ok", detail: "already runs the staged check" };
3540
+ if (existing === null) {
3541
+ return {
3542
+ item: "pre-commit",
3543
+ path: rel,
3544
+ status: "create",
3545
+ detail: `create via ${note}`,
3546
+ apply: () => {
3547
+ mkdirSync5(dirname3(path), { recursive: true });
3548
+ writeFileSync5(path, `#!/bin/sh
3549
+ ${block.join("\n")}
3550
+ `);
3551
+ chmodSync(path, 493);
3552
+ }
3553
+ };
3554
+ }
3555
+ const owner = MANAGED_HOOKS.find((m) => m.hookRe.test(existing));
3556
+ if (owner) {
3557
+ return { item: "pre-commit", path: rel, status: "skip", detail: `generated by ${owner.name}, which overwrites it on install \u2014 ${owner.hint}` };
3558
+ }
3559
+ const lines = existing.split("\n");
3560
+ const dead = unconditionalExitAt(lines);
3561
+ const at = lines.findIndex((l) => PRECOMMIT_RE.test(l));
3562
+ const write = (out2) => {
3563
+ writeFileSync5(path, out2.join("\n"));
3564
+ chmodSync(path, 493);
3565
+ };
3566
+ if (at !== -1 && (dead === -1 || at < dead)) {
3567
+ if (!stalePin(lines[at])) {
3568
+ return { item: "pre-commit", path: rel, status: "ok", detail: "already runs the staged check" };
3238
3569
  }
3239
- const was = pinOf(lines[staleAt]) || "unpinned";
3570
+ const was = pinOf(lines[at]) || "unpinned";
3240
3571
  return {
3241
3572
  item: "pre-commit",
3242
- path: target.rel,
3573
+ path: rel,
3243
3574
  status: "update",
3244
3575
  detail: `re-pin the staged check to tamperward@${TW_VERSION} (was ${was})`,
3245
3576
  apply: () => {
3246
- lines[staleAt] = PRECOMMIT_CMD;
3247
- writeFileSync5(path, lines.join("\n"));
3248
- chmodSync(path, 493);
3577
+ lines[at] = PRECOMMIT_CMD;
3578
+ write(lines);
3579
+ }
3580
+ };
3581
+ }
3582
+ if (at !== -1) {
3583
+ const ours = pinOf(lines[at]) !== null && lines[at - 1]?.trim() === MARKER;
3584
+ if (!ours) {
3585
+ return {
3586
+ item: "pre-commit",
3587
+ path: rel,
3588
+ status: "error",
3589
+ detail: `the staged check on line ${at + 1} never runs: line ${dead + 1} (\`${lines[dead].trim()}\`) ends the script first \u2014 move the tamperward line above it`
3590
+ };
3591
+ }
3592
+ return {
3593
+ item: "pre-commit",
3594
+ path: rel,
3595
+ status: "update",
3596
+ detail: `move the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ended the script before it ran`,
3597
+ apply: () => {
3598
+ const out2 = lines.filter((_, i) => i !== at && i !== at - 1);
3599
+ out2.splice(dead, 0, ...block);
3600
+ write(out2);
3601
+ }
3602
+ };
3603
+ }
3604
+ if (dead !== -1) {
3605
+ return {
3606
+ item: "pre-commit",
3607
+ path: rel,
3608
+ status: "update",
3609
+ detail: `insert the staged check above line ${dead + 1} (\`${lines[dead].trim()}\`), which ends the script \u2014 ${note}`,
3610
+ apply: () => {
3611
+ const out2 = [...lines];
3612
+ out2.splice(dead, 0, ...block);
3613
+ write(out2);
3249
3614
  }
3250
3615
  };
3251
3616
  }
3252
3617
  return {
3253
3618
  item: "pre-commit",
3254
- path: target.rel,
3255
- status: existing === null ? "create" : "update",
3256
- detail: existing === null ? `create via ${target.note}` : `append the staged check (${target.note})`,
3257
- apply: () => {
3258
- mkdirSync5(dirname3(path), { recursive: true });
3259
- const content = existing === null ? `#!/bin/sh
3260
- ${line}` : existing.replace(/\n?$/, "\n") + line;
3261
- writeFileSync5(path, content);
3262
- chmodSync(path, 493);
3263
- }
3619
+ path: rel,
3620
+ status: "update",
3621
+ detail: `append the staged check (${note})`,
3622
+ apply: () => write([...existing.replace(/\n?$/, "\n").split("\n").slice(0, -1), ...block, ""])
3264
3623
  };
3265
3624
  }
3266
3625
  function planCodeowners(cwd) {
@@ -3274,18 +3633,26 @@ function planCodeowners(cwd) {
3274
3633
  if (missing.length === 0) {
3275
3634
  return { item: "codeowners", path: existingRel, status: "ok", detail: "gate paths already have code owners" };
3276
3635
  }
3277
- const owner = inferOwner(cwd);
3636
+ const inferred = inferOwner(cwd);
3637
+ const owner = inferred?.owner ?? null;
3638
+ const unconfirmed = inferred !== null && !inferred.confirmed;
3278
3639
  const block = `
3279
3640
  ${CODEOWNERS_MARK}
3280
3641
  # A pull request that rewrites the workflow can keep the job name, replace the
3281
3642
  # gate with \`true\`, and still report a green required check. Only a human
3282
3643
  # requirement on these paths prevents that.
3283
- ` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
3644
+ ` + (owner === null ? "# REPLACE @OWNER BELOW with a real user or team \u2014 an unresolvable owner protects nothing.\n" : unconfirmed ? `# TODO: replace ${owner} with ${owner}/<team> if "${owner.slice(1)}" is an organisation. GitHub
3645
+ # rejects a bare organisation as an owner, and a rule with an invalid owner protects
3646
+ # nothing. A user login is fine as written.
3647
+ ` : "") + missing.map((p) => `${p.padEnd(24)} ${owner ?? "@OWNER"}`).join("\n") + "\n";
3284
3648
  return {
3285
3649
  item: "codeowners",
3286
3650
  path: existingRel ?? rel,
3287
3651
  status: existing === null ? "create" : "update",
3288
- detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner})`),
3652
+ detail: (existing === null ? "require a code owner on " : "add missing code-owner rules for ") + missing.join(", ") + (owner === null ? " \u2014 OWNER UNKNOWN, edit @OWNER before this does anything" : ` (${owner}${unconfirmed ? ", unconfirmed \u2014 see warning below" : ""})`),
3653
+ ...inferred && unconfirmed ? {
3654
+ warning: `${inferred.owner} was inferred from the origin remote and is not confirmed to be a user. If "${inferred.owner.slice(1)}" is an organisation, replace it with ${inferred.owner}/<team> in CODEOWNERS: GitHub rejects a bare organisation as an owner, and a rule with an invalid owner protects nothing.`
3655
+ } : {},
3289
3656
  apply: () => {
3290
3657
  mkdirSync5(dirname3(path), { recursive: true });
3291
3658
  writeFileSync5(path, existing === null ? block.replace(/^\n/, "") : existing.replace(/\n?$/, "\n") + block);
@@ -3344,21 +3711,49 @@ function planWorkflow(cwd, force) {
3344
3711
  detail: (mark ? `edited since tamperward v${mark.version} generated it` : "present but not generated by tamperward") + ` \u2014 left untouched. Compare it against the v${TW_VERSION} template (an older one may lack the pristine-verify step) and re-run with --force-workflow to replace it.`
3345
3712
  };
3346
3713
  }
3714
+ function planned(item, path, plan) {
3715
+ try {
3716
+ return plan();
3717
+ } catch (e) {
3718
+ return { item, path, status: "error", detail: `cannot plan this item \u2014 ${errText2(e)}` };
3719
+ }
3720
+ }
3347
3721
  function planInit(cwd, opts = {}) {
3348
- return [planPolicy(cwd), planClaudeHooks(cwd), planPreCommit(cwd), planWorkflow(cwd, opts.forceWorkflow ?? false), planCodeowners(cwd)];
3722
+ return [
3723
+ planned("policy", POLICY_FILE, () => planPolicy(cwd)),
3724
+ planned("agent", ".claude/settings.json", () => planClaudeHooks(cwd)),
3725
+ planned("pre-commit", "(hooks)", () => planPreCommit(cwd)),
3726
+ planned("ci", ".github/workflows/tamperward.yml", () => planWorkflow(cwd, opts.forceWorkflow ?? false)),
3727
+ planned("codeowners", ".github/CODEOWNERS", () => planCodeowners(cwd))
3728
+ ];
3349
3729
  }
3350
3730
  function runInit(opts) {
3351
3731
  const cwd = opts.cwd ?? process.cwd();
3352
3732
  const plan = planInit(cwd, { forceWorkflow: opts.forceWorkflow });
3353
3733
  const w = process.stdout;
3734
+ let applied = 0;
3354
3735
  for (const a of plan) {
3736
+ if (!opts.dryRun && a.apply) {
3737
+ try {
3738
+ a.apply();
3739
+ applied++;
3740
+ } catch (e) {
3741
+ a.status = "error";
3742
+ a.detail = `could not be written \u2014 ${errText2(e)}`;
3743
+ a.apply = void 0;
3744
+ }
3745
+ }
3355
3746
  const verb = opts.dryRun && (a.status === "create" || a.status === "update") ? `would ${a.status}` : a.status;
3356
3747
  w.write(` ${a.item.padEnd(10)} ${verb.padEnd(12)} ${a.path} \u2014 ${a.detail}
3357
3748
  `);
3358
- if (!opts.dryRun && a.apply) a.apply();
3749
+ }
3750
+ for (const a of plan) {
3751
+ if (a.warning) w.write(`
3752
+ WARNING (${a.item}): ${a.warning}
3753
+ `);
3359
3754
  }
3360
3755
  const errors = plan.filter((a) => a.status === "error");
3361
- const changed = plan.filter((a) => a.apply).length;
3756
+ const changed = opts.dryRun ? plan.filter((a) => a.apply).length : applied;
3362
3757
  if (errors.length) {
3363
3758
  w.write(`
3364
3759
  tamperward init: ${errors.length} item(s) need your attention above; the rest ${opts.dryRun ? "are planned" : "were applied"}.
@@ -3381,15 +3776,15 @@ tamperward init: ${changed} change(s) applied. Commit them so the gate travels w
3381
3776
  // src/cli/verify.ts
3382
3777
  import { execFileSync as execFileSync7, spawnSync } from "node:child_process";
3383
3778
  import { createHash as createHash8 } from "node:crypto";
3384
- import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync6, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3779
+ import { chmodSync as chmodSync2, cpSync, lstatSync, mkdirSync as mkdirSync6, mkdtempSync as mkdtempSync2, readFileSync as readFileSync13, readlinkSync as readlinkSync2, rmSync as rmSync2, statSync as statSync7, symlinkSync, writeFileSync as writeFileSync6, existsSync as existsSync9 } from "node:fs";
3385
3780
  import { tmpdir as tmpdir2 } from "node:os";
3386
- import { dirname as dirname4, join as join11, resolve as resolve2 } from "node:path";
3781
+ import { dirname as dirname4, join as join11, resolve as resolve3 } from "node:path";
3387
3782
 
3388
3783
  // src/git/trusted.ts
3389
3784
  import { execFileSync as execFileSync5 } from "node:child_process";
3390
3785
  import { createHash as createHash6 } from "node:crypto";
3391
3786
  import { existsSync as existsSync8, readFileSync as readFileSync11 } from "node:fs";
3392
- import { isAbsolute as isAbsolute3, join as join9 } from "node:path";
3787
+ import { isAbsolute as isAbsolute5, join as join9 } from "node:path";
3393
3788
  function trustedGitEnv() {
3394
3789
  return { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" };
3395
3790
  }
@@ -3402,7 +3797,7 @@ function gitDirOf(cwd) {
3402
3797
  env: trustedGitEnv()
3403
3798
  }).trim();
3404
3799
  if (!d) return null;
3405
- return isAbsolute3(d) ? d : join9(cwd, d);
3800
+ return isAbsolute5(d) ? d : join9(cwd, d);
3406
3801
  } catch {
3407
3802
  return null;
3408
3803
  }
@@ -3440,7 +3835,7 @@ function objectRewriteState(cwd) {
3440
3835
  // src/fingerprint.ts
3441
3836
  import { execFileSync as execFileSync6 } from "node:child_process";
3442
3837
  import { createHash as createHash7 } from "node:crypto";
3443
- import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync5 } from "node:fs";
3838
+ import { readdirSync as readdirSync3, readFileSync as readFileSync12, readlinkSync, statSync as statSync6 } from "node:fs";
3444
3839
  import { join as join10 } from "node:path";
3445
3840
  function treeFingerprint(cwd) {
3446
3841
  const files = execFileSync6("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
@@ -3479,7 +3874,7 @@ function depsFingerprint(cwd) {
3479
3874
  h.update(p);
3480
3875
  try {
3481
3876
  h.update(readFileSync12(p));
3482
- h.update(String(statSync5(p).mode));
3877
+ h.update(String(statSync6(p).mode));
3483
3878
  } catch {
3484
3879
  h.update("<unreadable>");
3485
3880
  }
@@ -3494,7 +3889,7 @@ function depsFingerprint(cwd) {
3494
3889
  }
3495
3890
  };
3496
3891
  try {
3497
- if (!statSync5(root).isDirectory()) return "none";
3892
+ if (!statSync6(root).isDirectory()) return "none";
3498
3893
  } catch {
3499
3894
  return "none";
3500
3895
  }
@@ -3552,7 +3947,7 @@ function materialize(cwd, dest) {
3552
3947
  const src = join11(cwd, rel);
3553
3948
  let st;
3554
3949
  try {
3555
- st = statSync6(src);
3950
+ st = statSync7(src);
3556
3951
  } catch {
3557
3952
  continue;
3558
3953
  }
@@ -3567,7 +3962,7 @@ function materialize(cwd, dest) {
3567
3962
  }
3568
3963
  chmodSync2(out2, st.mode);
3569
3964
  }
3570
- const nm = resolve2(cwd, "node_modules");
3965
+ const nm = resolve3(cwd, "node_modules");
3571
3966
  if (existsSync9(nm)) symlinkSync(nm, join11(dest, "node_modules"), "dir");
3572
3967
  }
3573
3968
  function dropSymlink(p) {
@@ -3583,9 +3978,9 @@ function verifierInputs(cmd, atBase, policy) {
3583
3978
  const tok = raw.replace(/^['"]|['"]$/g, "").replace(/^\.\//, "");
3584
3979
  if (tok && baseFiles.has(tok)) picked.add(tok);
3585
3980
  }
3586
- const globs = policy.verify?.inputs;
3587
- if (globs && globs.length) {
3588
- for (const rel of atBase) if (matchesAny(rel, globs)) picked.add(rel);
3981
+ const globs2 = policy.verify?.inputs;
3982
+ if (globs2 && globs2.length) {
3983
+ for (const rel of atBase) if (matchesAny(rel, globs2)) picked.add(rel);
3589
3984
  }
3590
3985
  return picked;
3591
3986
  }
@@ -3857,7 +4252,7 @@ function parseVerify(args) {
3857
4252
  // src/cli/run.ts
3858
4253
  import { execFileSync as execFileSync8, spawnSync as spawnSync2 } from "node:child_process";
3859
4254
  import { readdirSync as readdirSync4, readFileSync as readFileSync14, realpathSync } from "node:fs";
3860
- import { resolve as resolve3 } from "node:path";
4255
+ import { resolve as resolve4 } from "node:path";
3861
4256
  var out = (s) => process.stdout.write(s + "\n");
3862
4257
  var err = (s) => process.stderr.write(s + "\n");
3863
4258
  function git3(args, cwd) {
@@ -3904,7 +4299,7 @@ function survivorsHoldingTree(cwd, spawnedAfterTicks) {
3904
4299
  return out2;
3905
4300
  }
3906
4301
  function runEnvelope(opts) {
3907
- const cwd = resolve3(opts.cwd ?? process.cwd());
4302
+ const cwd = resolve4(opts.cwd ?? process.cwd());
3908
4303
  if (!opts.argv.length) {
3909
4304
  err("tamperward run: no agent command given (usage: tamperward run [options] -- <command...>)");
3910
4305
  return 2;
@@ -4041,7 +4436,7 @@ function parseRun(args) {
4041
4436
  return o;
4042
4437
  }
4043
4438
 
4044
- // src/cli/index.ts
4439
+ // src/cli/main.ts
4045
4440
  function parseAllow(args) {
4046
4441
  const o = {};
4047
4442
  for (let i = 0; i < args.length; i++) {
@@ -4149,9 +4544,11 @@ Formats:
4149
4544
  Exit codes: 0 clean \xB7 1 a blocking finding (check), MASKED_FAILURE or SUITE_RED
4150
4545
  (verify), any blocking finding or masked failure (run) \xB7 2 cannot
4151
4546
  evaluate, failing closed: policy error, bad --diff range, no view,
4152
- unresolvable base, --require-ancestor refused, budget exceeded.
4153
- hook/sweep \u2192 0 with a deny emitted as JSON on stdout (exit 2 would make
4154
- Claude Code ignore the JSON); 2 only for an unsupported agent name.
4547
+ not a git repository, unresolvable base or revision, --require-ancestor
4548
+ refused, budget exceeded \u2014 always one line on stderr, never a stack
4549
+ trace. hook/sweep \u2192 0 with a deny emitted as JSON on stdout (exit 2
4550
+ would make Claude Code ignore the JSON); 2 only for an unsupported
4551
+ agent name.
4155
4552
  `);
4156
4553
  }
4157
4554
  function main(argv2) {
@@ -4183,9 +4580,21 @@ function main(argv2) {
4183
4580
  return 2;
4184
4581
  }
4185
4582
  }
4583
+ function guardedMain(argv2, stderr = process.stderr) {
4584
+ try {
4585
+ return main(argv2);
4586
+ } catch (e) {
4587
+ const first = (e instanceof Error ? e.message : String(e)).split("\n").map((l) => l.trim()).find(Boolean) ?? "";
4588
+ stderr.write(`tamperward: ${first || "unexpected failure"}
4589
+ `);
4590
+ return 2;
4591
+ }
4592
+ }
4593
+
4594
+ // src/cli/index.ts
4186
4595
  var argv = process.argv.slice(2);
4187
4596
  if (argv[0] === "watch") {
4188
4597
  runWatch(argv.slice(1));
4189
4598
  } else {
4190
- process.exit(main(argv));
4599
+ process.exit(guardedMain(argv));
4191
4600
  }