tamperward 2.23.20 → 2.24.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/README.md CHANGED
@@ -626,11 +626,11 @@ option can never be reinterpreted as the agent command.
626
626
 
627
627
  ### The rules
628
628
 
629
- Eighteen rules are specified and seventeen ship (see the table in
629
+ Nineteen rules are specified and eighteen ship (see the table in
630
630
  [SPEC.md](./SPEC.md)). The families: test protection (`test-deletion`,
631
631
  `test-skip`, `test-content-removal`, plus the warning-only JS/TS
632
632
  `assertion-weakening` heuristic), verification-signal protection
633
- (`coverage-lowering`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
633
+ (`coverage-lowering`, `coverage-exclusion`, `snapshot-rewrite`, `snapshot-only-rewrite`), suppression
634
634
  (`ts-any-cast`, `ts-any-launder`, `ts-cast-growth`, `lint-suppression`), pipeline protection
635
635
  (`ci-tampering`, `hook-tampering`, `no-verify`), and the effect/outcome layers
636
636
  (`transient-protected-mutation`, `pristine-verification`, plus the `run` envelope).
@@ -653,6 +653,13 @@ change removed, reported as a warning that never blocks by default. It fires on
653
653
  of legitimate mainline pairs across four real TypeScript libraries
654
654
  (`harness/fp-study/CAST-GROWTH-CORPUS.md`), so block is closed by corpus; the same
655
655
  pass removed every assertion from TamperWard's own `src/` (`docs/CAST-INVENTORY.md`).
656
+ `coverage-exclusion` (2.24.0) is the per-function form of `coverage-lowering`: an
657
+ inline `istanbul ignore` / `c8 ignore` / `v8 ignore` / `node:coverage ignore` /
658
+ `# pragma: no cover` / `#[coverage(off)]` added to non-test source, or a `//go:build`
659
+ constraint added to an existing Go file. It fired on 0 of the same 460 mainline
660
+ pairs, but every real add in the deeper histories is an honest unreachable-branch
661
+ marker (`harness/fp-study/COVERAGE-EXCLUSION-CORPUS.md`), so it warns and never
662
+ blocks by default.
656
663
 
657
664
  ## What Tamperward does not do
658
665
 
package/dist/cli/index.js CHANGED
@@ -571,6 +571,12 @@ function defaultPolicy(version = 1) {
571
571
  "ts-cast-growth": { severity: "warn" },
572
572
  "lint-suppression": { severity: "block" },
573
573
  "coverage-lowering": { severity: "block" },
574
+ // the per-function form of coverage-lowering: an inline `istanbul ignore` /
575
+ // `c8 ignore` / `v8 ignore` / `node:coverage ignore` / `# pragma: no cover` /
576
+ // `#[coverage(off)]` added to non-test source (#438). Also the ordinary way to
577
+ // mark a genuinely unreachable branch, so it WARNs until the corpus in
578
+ // harness/fp-study/coverage-exclusion-corpus.json earns block (SPEC §7).
579
+ "coverage-exclusion": { severity: "warn" },
574
580
  "ci-tampering": { severity: "block" },
575
581
  "hook-tampering": { severity: "block" },
576
582
  "no-verify": { severity: "block" },
@@ -4590,7 +4596,7 @@ function literals(e) {
4590
4596
  return { items, opaque };
4591
4597
  }
4592
4598
  function parseLists(src) {
4593
- const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], opaqueCollect: false, opaqueIgnore: false };
4599
+ const res = { collectFrom: null, collectKey: "collectCoverageFrom", ignore: [], ignoreRe: [], opaqueCollect: false, opaqueIgnore: false };
4594
4600
  try {
4595
4601
  const sf = sourceOf(src);
4596
4602
  if (!sf) return res;
@@ -4606,7 +4612,7 @@ function parseLists(src) {
4606
4612
  if (opaque) res.opaqueCollect = true;
4607
4613
  } else if (k === "coveragePathIgnorePatterns" || k === "exclude" && vitestCoverage) {
4608
4614
  const { items, opaque } = literals(resolve16(node.initializer));
4609
- res.ignore.push(...items);
4615
+ (k === "exclude" ? res.ignore : res.ignoreRe).push(...items);
4610
4616
  if (opaque) res.opaqueIgnore = true;
4611
4617
  }
4612
4618
  }
@@ -4628,6 +4634,8 @@ function parseSwitches(src) {
4628
4634
  const k = keyName(node.name);
4629
4635
  const owner = ownerKey(node);
4630
4636
  if (k === "enabled" && owner === "coverage") res.enabled = bool2(node.initializer);
4637
+ else if (k === "all" && owner === "coverage") res.all = bool2(node.initializer);
4638
+ else if (k === "collectCoverage" && (owner === null || owner === "jest")) res.collectCoverage = bool2(node.initializer);
4631
4639
  else if (k === "perFile" && owner === "thresholds" && underKey(node, "coverage")) res.perFile = bool2(node.initializer);
4632
4640
  }
4633
4641
  ts.forEachChild(node, visit);
@@ -4736,13 +4744,19 @@ function narrowings(before, after, sources) {
4736
4744
  }
4737
4745
  const had = new Set(before.ignore);
4738
4746
  for (const g of after.ignore) {
4739
- if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out3.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns / coverage.exclude)`);
4747
+ if (!had.has(g) && !BENIGN_EXEMPTION.test(g)) out3.push(`coverage now exempts ${JSON.stringify(g)} (coverage.exclude)`);
4748
+ }
4749
+ const hadRe = new Set(before.ignoreRe);
4750
+ for (const g of after.ignoreRe) {
4751
+ if (!hadRe.has(g) && !BENIGN_EXEMPTION_RE.test(g)) out3.push(`coverage now exempts ${JSON.stringify(g)} (coveragePathIgnorePatterns)`);
4740
4752
  }
4741
4753
  return out3;
4742
4754
  }
4743
4755
  function switchWeakenings(before, after) {
4744
4756
  const out3 = [];
4745
4757
  if (before.enabled === true && after.enabled === false) out3.push("coverage.enabled switched off \u2014 the thresholds are never checked");
4758
+ if (before.collectCoverage === true && after.collectCoverage === false) out3.push("collectCoverage switched off \u2014 coverage is no longer collected, so the thresholds are never checked");
4759
+ if (before.all === true && after.all === false) out3.push("coverage.all switched off \u2014 a file no test imports is no longer measured");
4746
4760
  if (before.perFile === true && after.perFile !== true) out3.push("thresholds.perFile removed \u2014 a single uncovered file now hides behind the aggregate");
4747
4761
  return out3;
4748
4762
  }
@@ -4897,7 +4911,49 @@ function gateMovedElsewhere(c, changes, policy) {
4897
4911
  });
4898
4912
  });
4899
4913
  }
4900
- var RULE5, METRICS, isMetric, norm, BENIGN_EXEMPTION, globs, globList, CANONICAL_SOURCES, NON_SOURCE, NYC_KEYS, COVERAGE_PY, CODECOV, SIMPLE_FILES, lessThan, numOf, SWITCHES, isJsConfig, coverageLowering;
4914
+ function flagWeakenings(c, added, removed, policy) {
4915
+ const out3 = [];
4916
+ const removedCheck = removed.find((l) => CHECK_COVERAGE.test(l.content));
4917
+ if (removedCheck && !added.some((l) => CHECK_COVERAGE.test(l.content))) {
4918
+ out3.push(
4919
+ makeFinding(RULE5, policy, {
4920
+ file: c.path,
4921
+ message: "nyc --check-coverage was removed \u2014 the coverage thresholds are no longer enforced.",
4922
+ evidence: removedCheck.content.trim(),
4923
+ remediation: "Keep --check-coverage on; raise real coverage instead of switching the gate off."
4924
+ })
4925
+ );
4926
+ }
4927
+ const was = floorsOf(removed);
4928
+ const now = floorsOf(added);
4929
+ if (was.length === 0) return out3;
4930
+ const high = was.reduce((a, b) => b.v > a.v ? b : a);
4931
+ if (now.length === 0) {
4932
+ out3.push(
4933
+ makeFinding(RULE5, policy, {
4934
+ file: c.path,
4935
+ message: `--cov-fail-under removed (was ${high.v}) \u2014 the coverage floor is no longer enforced.`,
4936
+ evidence: high.l.content.trim(),
4937
+ remediation: "Keep the --cov-fail-under floor; raise real coverage instead of removing the gate."
4938
+ })
4939
+ );
4940
+ return out3;
4941
+ }
4942
+ const low = now.reduce((a, b) => b.v < a.v ? b : a);
4943
+ if (low.v < high.v) {
4944
+ out3.push(
4945
+ makeFinding(RULE5, policy, {
4946
+ file: c.path,
4947
+ line: low.l.newLine ?? void 0,
4948
+ message: `--cov-fail-under lowered ${high.v} \u2192 ${low.v}.`,
4949
+ evidence: low.l.content.trim(),
4950
+ remediation: "Restore the coverage floor and raise real coverage; do not lower the gate to pass."
4951
+ })
4952
+ );
4953
+ }
4954
+ return out3;
4955
+ }
4956
+ var RULE5, METRICS, isMetric, norm, BENIGN_DIRS, BENIGN_EXEMPTION, BENIGN_EXEMPTION_RE, globs, globList, CANONICAL_SOURCES, NON_SOURCE, NYC_KEYS, COVERAGE_PY, CODECOV, SIMPLE_FILES, lessThan, numOf, SWITCHES, isJsConfig, COV_FAIL_UNDER, CHECK_COVERAGE, floorsOf, coverageLowering;
4901
4957
  var init_coverage_lowering = __esm({
4902
4958
  "src/detectors/coverage-lowering.ts"() {
4903
4959
  "use strict";
@@ -4912,7 +4968,9 @@ var init_coverage_lowering = __esm({
4912
4968
  METRICS = ["branches", "functions", "lines", "statements"];
4913
4969
  isMetric = (k) => METRICS.some((m) => m === k);
4914
4970
  norm = (p) => p.replace(/^\.\//, "");
4915
- BENIGN_EXEMPTION = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings|generated|__generated__|codegen|migrations?)(?:\/|$|\*)|\.(?:test|spec|stories|d|gen|generated|pb|e2e-spec)\.|\.(?:config|setup)\.|(?:^|[/!*])(?:manage|wsgi|asgi|__init__)\.py$|(?:^|[/!*])main\.[jt]sx?$|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea|\.d\.ts|\.json$|\.md$|\.css$|\.s[ac]ss$/i;
4971
+ BENIGN_DIRS = /node_modules|(?:^|[/!])(?:dist|build|out|coverage|tmp|temp|vendor|test|tests|__tests__|__mocks__|__fixtures__|fixtures?|mocks?|spec|specs|e2e|cypress|playwright|storybook|scripts?|examples?|docs?|bin|types?|typings|generated|__generated__|codegen|migrations?)(?:\/|$|\*)|\.(?:config|setup)\.|(?:^|[/!*])(?:manage|wsgi|asgi|__init__)\.py$|(?:^|[/!*])main\.[jt]sx?$|\.git\b|\.cache|\.next|\.nuxt|\.output|\.idea/i;
4972
+ BENIGN_EXEMPTION = new RegExp(`${BENIGN_DIRS.source}|\\.(?:test|spec|stories|d|gen|generated|pb|e2e-spec)\\.|\\.d\\.ts|\\.json$|\\.md$|\\.css$|\\.s[ac]ss$`, "i");
4973
+ BENIGN_EXEMPTION_RE = new RegExp(`${BENIGN_DIRS.source}|\\\\\\.(?:test|spec|stories|gen|generated|pb|e2e-spec)\\\\\\.|\\\\\\.(?:d\\\\\\.ts|json|md|css|s[ac]ss)\\$?$`, "i");
4916
4974
  globs = (patterns) => {
4917
4975
  const ms = patterns.map((g) => {
4918
4976
  try {
@@ -4935,12 +4993,12 @@ var init_coverage_lowering = __esm({
4935
4993
  label: m,
4936
4994
  weakens: "lower"
4937
4995
  }));
4938
- COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml)$/;
4996
+ COVERAGE_PY = /(?:^|\/)(?:\.coveragerc|pyproject\.toml|setup\.cfg|tox\.ini)$/;
4939
4997
  CODECOV = /(?:^|\/)\.?codecov\.yml$/;
4940
4998
  SIMPLE_FILES = [
4941
4999
  {
4942
- // coverage.py: `fail_under = 90` under [report] (.coveragerc, setup.cfg-style) or
4943
- // [tool.coverage.report] (pyproject.toml)
5000
+ // coverage.py: `fail_under = 90` under [report] (.coveragerc), [coverage:report]
5001
+ // (setup.cfg, tox.ini) or [tool.coverage.report] (pyproject.toml)
4944
5002
  file: COVERAGE_PY,
4945
5003
  keys: [{ key: /^\s*fail[_-]under\s*=\s*"?(\d+(?:\.\d+)?)/, label: "fail_under", weakens: "lower" }],
4946
5004
  removable: true
@@ -4999,6 +5057,12 @@ var init_coverage_lowering = __esm({
4999
5057
  }
5000
5058
  ];
5001
5059
  isJsConfig = (path) => /\.(?:[cm]?[jt]sx?|json)$/.test(path);
5060
+ COV_FAIL_UNDER = /--cov-fail-under(?:=|\s+)(\d+(?:\.\d+)?)/;
5061
+ CHECK_COVERAGE = /--check-coverage\b/;
5062
+ floorsOf = (lines) => lines.flatMap((l) => {
5063
+ const m = l.content.match(COV_FAIL_UNDER);
5064
+ return m ? [{ v: Number(m[1]), l }] : [];
5065
+ });
5002
5066
  coverageLowering = {
5003
5067
  id: RULE5,
5004
5068
  surface: ["file"],
@@ -5007,7 +5071,10 @@ var init_coverage_lowering = __esm({
5007
5071
  const out3 = [];
5008
5072
  for (const c of changes) {
5009
5073
  if (c.kind !== "file") continue;
5010
- if (!isProtected(c.path, policy, "config")) continue;
5074
+ if (!isProtected(c.path, policy, "config")) {
5075
+ if (isProtected(c.path, policy, "ci")) out3.push(...flagWeakenings(c, addedLines(c), removedLines(c), policy));
5076
+ continue;
5077
+ }
5011
5078
  if (c.after != null && c.op !== "delete") {
5012
5079
  const reasons = [];
5013
5080
  if (isJsConfig(c.path)) {
@@ -5057,6 +5124,7 @@ var init_coverage_lowering = __esm({
5057
5124
  })
5058
5125
  );
5059
5126
  }
5127
+ out3.push(...flagWeakenings(c, added, removed, policy));
5060
5128
  for (const l of added) {
5061
5129
  for (const s of SWITCHES) {
5062
5130
  if (!s.re.test(l.content)) continue;
@@ -5110,6 +5178,93 @@ var init_coverage_lowering = __esm({
5110
5178
  }
5111
5179
  });
5112
5180
 
5181
+ // src/detectors/coverage-exclusion.ts
5182
+ function isText(line, idx, lang) {
5183
+ const opener = LINE_COMMENT[lang];
5184
+ let quote = null;
5185
+ for (let j = 0; j < idx; j++) {
5186
+ const ch = line[j];
5187
+ if (quote) {
5188
+ if (ch === "\\") j++;
5189
+ else if (ch === quote) quote = null;
5190
+ continue;
5191
+ }
5192
+ if (ch === '"' || ch === "'" || ch === "`") quote = ch;
5193
+ else if (opener && opener.test(line.slice(j, j + 2))) return true;
5194
+ }
5195
+ return quote !== null;
5196
+ }
5197
+ var RULE6, PATTERNS3, OUT_OF_SCOPE_PATH, LINE_COMMENT, coverageExclusion;
5198
+ var init_coverage_exclusion = __esm({
5199
+ "src/detectors/coverage-exclusion.ts"() {
5200
+ "use strict";
5201
+ init_select();
5202
+ init_files();
5203
+ init_policy();
5204
+ init_finding();
5205
+ RULE6 = "coverage-exclusion";
5206
+ PATTERNS3 = {
5207
+ js: [
5208
+ { re: /\/\*\s*istanbul\s+ignore\b/, why: "`istanbul ignore` takes the next statement, branch or the whole file out of the coverage measurement" },
5209
+ { re: /\/\*\s*c8\s+ignore\b(?!\s*stop\b)/, why: "`c8 ignore` takes the next statement or the marked range out of the coverage measurement" },
5210
+ { re: /\/\*\s*v8\s+ignore\b(?!\s*stop\b)/, why: "`v8 ignore` takes the next statement or the marked range out of the coverage measurement" },
5211
+ { re: /\/\*\s*node:coverage\s+(?:ignore|disable)\b/, why: "`node:coverage ignore/disable` takes the following code out of the coverage measurement" }
5212
+ ],
5213
+ py: [{ re: /#\s*pragma[:\s]\s*no\s*cover\b/i, why: "`# pragma: no cover` takes the line or block out of the coverage measurement" }],
5214
+ rs: [{ re: /#\s*!?\[[^\]]*\bcoverage\s*\(\s*off\s*\)/, why: "`#[coverage(off)]` takes the item out of the coverage measurement" }],
5215
+ go: [
5216
+ {
5217
+ re: /^\s*\/\/\s*(?:go:build\b|\+build\b)/,
5218
+ why: "a build constraint added to an existing file takes it out of the default build, and out of `go test -cover`",
5219
+ existingOnly: true
5220
+ }
5221
+ ]
5222
+ };
5223
+ OUT_OF_SCOPE_PATH = /(?:^|\/)(?:node_modules|vendor|third_party|dist|build|out|coverage|generated|__generated__|codegen|examples?|docs?|scripts?|fixtures?|__fixtures__|__mocks__)\/|\.d\.[cm]?ts$|\.(?:generated|gen|pb)\.[^/]*$/;
5224
+ LINE_COMMENT = { js: /^\/\//, go: /^\/\//, rs: /^\/\//, py: /^#/ };
5225
+ coverageExclusion = {
5226
+ id: RULE6,
5227
+ surface: ["file"],
5228
+ certainty: "mechanical",
5229
+ run(changes, policy) {
5230
+ const out3 = [];
5231
+ for (const c of changes) {
5232
+ if (c.kind !== "file" || c.op === "delete") continue;
5233
+ const lang = langOf(c.path);
5234
+ if (!lang) continue;
5235
+ const patterns = PATTERNS3[lang];
5236
+ if (!patterns) continue;
5237
+ const category = protectedCategory(c.path, policy);
5238
+ if (OUT_OF_SCOPE_PATH.test(c.path) || category === "tests" || category === "config") continue;
5239
+ const removed = new Set(removedLines(c).map((l) => l.content.trim()));
5240
+ const before = c.before ?? "";
5241
+ for (const l of addedLines(c)) {
5242
+ const trimmed = l.content.trim();
5243
+ if (removed.has(trimmed)) continue;
5244
+ for (const p of patterns) {
5245
+ const m = p.re.exec(l.content);
5246
+ if (!m || isText(l.content, m.index, lang)) continue;
5247
+ if (p.existingOnly && (c.op === "add" || c.before == null || before.split("\n").some((b) => p.re.test(b)))) continue;
5248
+ out3.push(
5249
+ makeFinding(RULE6, policy, {
5250
+ file: c.path,
5251
+ line: l.newLine ?? void 0,
5252
+ message: `Coverage exclusion added: ${p.why}.`,
5253
+ evidence: trimmed,
5254
+ remediation: "Cover the code with a test instead of excluding it from the measurement; if it is genuinely unreachable, say so in review.",
5255
+ defaultSeverity: "warn"
5256
+ })
5257
+ );
5258
+ break;
5259
+ }
5260
+ }
5261
+ }
5262
+ return out3;
5263
+ }
5264
+ };
5265
+ }
5266
+ });
5267
+
5113
5268
  // src/detectors/gh-expression.ts
5114
5269
  function foldExpressions(s) {
5115
5270
  return s.replace(/\$\{\{([^}]*)\}\}/g, (_m, e) => {
@@ -5878,7 +6033,7 @@ function ancestorOf(lines, i) {
5878
6033
  while (h >= 0 && (lines[h].trim() === "" || /^\s*#/.test(lines[h]) || indentOf(lines[h]) >= ind)) h--;
5879
6034
  return h >= 0 ? h : null;
5880
6035
  }
5881
- var RULE6, USES, CHECK, YAML_KEY, invokesCheck2, uncommented, SHELL_ERREXIT, indentOf, STEP_START, REPORTER_ACTION, usesCheck, stepHasCheck, jobHasCheck, NEUTRALISERS, LIST_KEYS, DEFAULT_BRANCH, ALL, CODE_EVENTS, PR_CODE_TYPES, pathsMatchSource, ignoresEverySource, CODE_PROBES, REUSED_WORKFLOW, MANIFEST, WORKDIR_LINE, REF_LINE, CHECKOUT, ciTampering;
6036
+ var RULE7, USES, CHECK, YAML_KEY, invokesCheck2, uncommented, SHELL_ERREXIT, indentOf, STEP_START, REPORTER_ACTION, usesCheck, stepHasCheck, jobHasCheck, NEUTRALISERS, LIST_KEYS, DEFAULT_BRANCH, ALL, CODE_EVENTS, PR_CODE_TYPES, pathsMatchSource, ignoresEverySource, CODE_PROBES, REUSED_WORKFLOW, MANIFEST, WORKDIR_LINE, REF_LINE, CHECKOUT, ciTampering;
5882
6037
  var init_ci_tampering = __esm({
5883
6038
  "src/detectors/ci-tampering.ts"() {
5884
6039
  "use strict";
@@ -5891,7 +6046,7 @@ var init_ci_tampering = __esm({
5891
6046
  init_gh_expression();
5892
6047
  init_invocation();
5893
6048
  init_invocation();
5894
- RULE6 = "ci-tampering";
6049
+ RULE7 = "ci-tampering";
5895
6050
  USES = /^\s*-\s*uses:/;
5896
6051
  CHECK = /\b(test|tests|lint|typecheck|type-check|tsc|eslint|jest|vitest|playwright|coverage|tamperward)\b/i;
5897
6052
  YAML_KEY = /^\s*-?\s*[A-Za-z_][\w-]*:\s*(?:$|\S)/;
@@ -5931,7 +6086,7 @@ var init_ci_tampering = __esm({
5931
6086
  REF_LINE = /^\s*ref:\s*(.+?)\s*$/;
5932
6087
  CHECKOUT = /^\s*-?\s*uses:\s*actions\/checkout(?:@|\s|$)/;
5933
6088
  ciTampering = {
5934
- id: RULE6,
6089
+ id: RULE7,
5935
6090
  surface: ["file"],
5936
6091
  certainty: "mechanical",
5937
6092
  run(changes, policy, _view, ctx) {
@@ -5943,7 +6098,7 @@ var init_ci_tampering = __esm({
5943
6098
  if (c.kind !== "file") continue;
5944
6099
  if (c.op === "rename" && c.oldPath && isActiveWorkflow(c.oldPath) && !isActiveWorkflow(c.path)) {
5945
6100
  out3.push(
5946
- makeFinding(RULE6, policy, {
6101
+ makeFinding(RULE7, policy, {
5947
6102
  file: c.path,
5948
6103
  message: `A CI workflow was renamed so it no longer runs (${c.oldPath} \u2192 ${c.path}).`,
5949
6104
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -5967,7 +6122,7 @@ var init_ci_tampering = __esm({
5967
6122
  }
5968
6123
  if (coe && isTruthy(coe[1])) {
5969
6124
  out3.push(
5970
- makeFinding(RULE6, policy, {
6125
+ makeFinding(RULE7, policy, {
5971
6126
  file: c.path,
5972
6127
  line: l.newLine ?? void 0,
5973
6128
  message: "continue-on-error: true added \u2014 failures will no longer fail the job.",
@@ -5977,7 +6132,7 @@ var init_ci_tampering = __esm({
5977
6132
  );
5978
6133
  } else if (cond && isAlwaysFalse(cond[1])) {
5979
6134
  out3.push(
5980
- makeFinding(RULE6, policy, {
6135
+ makeFinding(RULE7, policy, {
5981
6136
  file: c.path,
5982
6137
  line: l.newLine ?? void 0,
5983
6138
  message: `if: ${uncommented(cond[1])} added \u2014 this step can never run.`,
@@ -5997,7 +6152,7 @@ var init_ci_tampering = __esm({
5997
6152
  const n = NEUTRALISERS.find((x) => x.re.test(b.body[k]));
5998
6153
  if (!n || propagates && /set\s\+e/.test(b.body[k])) continue;
5999
6154
  out3.push(
6000
- makeFinding(RULE6, policy, {
6155
+ makeFinding(RULE7, policy, {
6001
6156
  file: c.path,
6002
6157
  line: idx + 1,
6003
6158
  message: `A check's run block was neutralised: ${n.what}.`,
@@ -6013,7 +6168,7 @@ var init_ci_tampering = __esm({
6013
6168
  const step = stepOf(afterLines, l.newLine - 1);
6014
6169
  if (step && !stepHasCheck(afterLines, step)) continue;
6015
6170
  out3.push(
6016
- makeFinding(RULE6, policy, {
6171
+ makeFinding(RULE7, policy, {
6017
6172
  file: c.path,
6018
6173
  line: l.newLine,
6019
6174
  message: "shell: \u2026 {0} added on a check step \u2014 the shell no longer stops on the first failure.",
@@ -6039,7 +6194,7 @@ var init_ci_tampering = __esm({
6039
6194
  if (job ? !jobHasCheck(afterLines, job) : !afterLines.some((x) => invokesCheck2(x) || usesCheck(x))) continue;
6040
6195
  }
6041
6196
  out3.push(
6042
- makeFinding(RULE6, policy, {
6197
+ makeFinding(RULE7, policy, {
6043
6198
  file: c.path,
6044
6199
  line: l.newLine,
6045
6200
  message: `A check was pointed at ${dir} by working-directory \u2014 a directory holding no code or package the check could run.`,
@@ -6056,7 +6211,7 @@ var init_ci_tampering = __esm({
6056
6211
  const target = literalValue(ref[1]);
6057
6212
  if (target === null) continue;
6058
6213
  out3.push(
6059
- makeFinding(RULE6, policy, {
6214
+ makeFinding(RULE7, policy, {
6060
6215
  file: c.path,
6061
6216
  line: l.newLine,
6062
6217
  message: `actions/checkout was pinned to ref: ${target} \u2014 the checks run against that ref, not against the change under review.`,
@@ -6069,7 +6224,7 @@ var init_ci_tampering = __esm({
6069
6224
  triggerOpts ??= { defaultBranch: defaultBranch(ctx), sources: sourceProbes(ctx), hasBranch: (b) => branchExists(b, ctx) };
6070
6225
  for (const reason of triggerNarrowings(parseTriggers(c.before), parseTriggers(c.after), triggerOpts)) {
6071
6226
  out3.push(
6072
- makeFinding(RULE6, policy, {
6227
+ makeFinding(RULE7, policy, {
6073
6228
  file: c.path,
6074
6229
  message: `The workflow's triggers were narrowed: ${reason}.`,
6075
6230
  evidence: reason,
@@ -6099,7 +6254,7 @@ var init_ci_tampering = __esm({
6099
6254
  if (s.state === "kept") continue;
6100
6255
  if (s.state === "neutralised") {
6101
6256
  out3.push(
6102
- makeFinding(RULE6, policy, {
6257
+ makeFinding(RULE7, policy, {
6103
6258
  file: c.path,
6104
6259
  message: "A CI check command was neutralised in place: it still runs, but its result no longer decides (or its suite was narrowed).",
6105
6260
  evidence: `${l.content.trim()} \u2192 ${s.by}`,
@@ -6109,7 +6264,7 @@ var init_ci_tampering = __esm({
6109
6264
  continue;
6110
6265
  }
6111
6266
  out3.push(
6112
- makeFinding(RULE6, policy, {
6267
+ makeFinding(RULE7, policy, {
6113
6268
  file: c.path,
6114
6269
  message: isUsesLine ? "A CI check step (test/lint/typecheck) was removed." : "A CI check command was removed from a run block.",
6115
6270
  evidence: l.content.trim(),
@@ -7139,7 +7294,7 @@ function claudeSettingsFindings(c, policy) {
7139
7294
  if (c.after == null) return null;
7140
7295
  const after = parseSettings(c.after);
7141
7296
  const before = c.before != null ? parseSettings(c.before) : null;
7142
- const finding = (reason) => makeFinding(RULE7, policy, {
7297
+ const finding = (reason) => makeFinding(RULE8, policy, {
7143
7298
  file: c.path,
7144
7299
  message: `The Claude Code hook wiring was weakened: ${reason}.`,
7145
7300
  evidence: reason,
@@ -7164,7 +7319,7 @@ function policyFindings(c, policy, ctx) {
7164
7319
  const reasons = c.before == null ? policyAddWeakening(c.after, probe) : policyWeakening(c.before, c.after, probe);
7165
7320
  if (reasons === null) return null;
7166
7321
  return reasons.map(
7167
- (reason) => makeFinding(RULE7, policy, {
7322
+ (reason) => makeFinding(RULE8, policy, {
7168
7323
  file: c.path,
7169
7324
  message: added ? `A policy file was added that weakens the baseline: ${reason}.` : `The policy was weakened: ${reason}.`,
7170
7325
  evidence: reason,
@@ -7210,6 +7365,50 @@ function syntheticBase(over) {
7210
7365
  }
7211
7366
  return base;
7212
7367
  }
7368
+ function freshLefthookBase(after) {
7369
+ const base = {};
7370
+ for (const [section, v] of Object.entries(after)) {
7371
+ if (!isObj(v)) continue;
7372
+ for (const group of ["commands", "scripts"]) {
7373
+ const g = v[group];
7374
+ if (!isObj(g)) continue;
7375
+ for (const [name, cfg] of Object.entries(g)) {
7376
+ if (!isObj(cfg)) continue;
7377
+ const run = cfg.run ?? cfg.runner;
7378
+ if (!namesGate(name) && !namesGate(run)) continue;
7379
+ const sec = base[section] ??= {};
7380
+ const grp = sec[group] ??= {};
7381
+ grp[name] = { run: typeof run === "string" ? liveOrCanonical(run) : CANONICAL_GATE };
7382
+ }
7383
+ }
7384
+ }
7385
+ return base;
7386
+ }
7387
+ function freshPreCommitBase(after) {
7388
+ const stagesOf = (v) => Array.isArray(v) && v.map(String).some((s) => COMMIT_STAGES.has(s)) ? v.map(String) : null;
7389
+ const defaults = stagesOf(after.default_stages);
7390
+ const repos = [];
7391
+ for (const repo of Array.isArray(after.repos) ? after.repos : []) {
7392
+ if (!isObj(repo)) continue;
7393
+ const hooks = [];
7394
+ for (const h of Array.isArray(repo.hooks) ? repo.hooks : []) {
7395
+ if (!isObj(h)) continue;
7396
+ if (![repo.repo, h.id, h.entry, h.name].some(namesGate)) continue;
7397
+ const fresh = { id: h.id, name: h.name };
7398
+ if (h.entry !== void 0) {
7399
+ const args = Array.isArray(h.args) ? h.args.map(String).join(" ") : "";
7400
+ fresh.entry = liveOrCanonical(`${typeof h.entry === "string" ? h.entry : ""} ${args}`.trim());
7401
+ }
7402
+ const stages = stagesOf(h.stages);
7403
+ if (stages) fresh.stages = stages;
7404
+ hooks.push(fresh);
7405
+ }
7406
+ repos.push({ repo: repo.repo, hooks });
7407
+ }
7408
+ const base = { repos };
7409
+ if (defaults) base.default_stages = defaults;
7410
+ return base;
7411
+ }
7213
7412
  function configFindings(c, changes, policy, ctx) {
7214
7413
  if (c.after == null) return null;
7215
7414
  let reasons;
@@ -7219,15 +7418,18 @@ function configFindings(c, changes, policy, ctx) {
7219
7418
  if (overBefore === null) return null;
7220
7419
  const base = lefthookBase(c, changes, ctx) ?? syntheticBase(overAfter ?? {});
7221
7420
  reasons = overAfter === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : lefthookWeakening(mergeDocs(base, overBefore), mergeDocs(base, overAfter));
7421
+ } else if (c.before == null) {
7422
+ const after = parseDoc(c.after);
7423
+ if (after === null) return [];
7424
+ reasons = isLefthook(c.path) ? lefthookWeakening(freshLefthookBase(after), after) : preCommitWeakening(freshPreCommitBase(after), after);
7222
7425
  } else {
7223
- if (c.before == null) return null;
7224
7426
  const before = parseDoc(c.before);
7225
7427
  const after = parseDoc(c.after);
7226
7428
  if (before === null) return null;
7227
7429
  reasons = after === null ? ["the file no longer parses as YAML \u2014 the tool that reads it runs nothing"] : isLefthook(c.path) ? lefthookWeakening(before, after) : preCommitWeakening(before, after);
7228
7430
  }
7229
7431
  return reasons.map(
7230
- (reason) => makeFinding(RULE7, policy, {
7432
+ (reason) => makeFinding(RULE8, policy, {
7231
7433
  file: c.path,
7232
7434
  message: `The hook configuration was weakened: ${reason}.`,
7233
7435
  evidence: reason,
@@ -7278,7 +7480,7 @@ function huskyRuntimeWrite(path, content, ctx) {
7278
7480
  (i) => i.identity === "tamperward check --staged" && i.state === "live" && i.pin === TW_VERSION
7279
7481
  );
7280
7482
  }
7281
- var RULE7, ownerExec2, EVENTS, isObj, declares, GATE_KEYS, HOOK_TYPES, SIBLING_KEYS, MENTIONS_GATE, FORGERY, ENV_MECHANISM, envMechanism, mentionsGate, carriesGate, HUSKY_SHIM, HUSKY_SHIM_NAMES, HUSKY_H_KNOWN, HUSKY_DEPRECATION, stripTrailingNl, isHuskyScript, isHookNote, hookTampering;
7483
+ var RULE8, ownerExec2, EVENTS, isObj, declares, GATE_KEYS, HOOK_TYPES, SIBLING_KEYS, MENTIONS_GATE, FORGERY, ENV_MECHANISM, envMechanism, mentionsGate, carriesGate, CANONICAL_GATE, COMMIT_STAGES, namesGate, liveOrCanonical, HUSKY_SHIM, HUSKY_SHIM_NAMES, HUSKY_H_KNOWN, HUSKY_DEPRECATION, stripTrailingNl, isHuskyScript, isHookNote, hookTampering;
7282
7484
  var init_hook_tampering = __esm({
7283
7485
  "src/detectors/hook-tampering.ts"() {
7284
7486
  "use strict";
@@ -7291,7 +7493,7 @@ var init_hook_tampering = __esm({
7291
7493
  init_command();
7292
7494
  init_repo();
7293
7495
  init_hook_wiring();
7294
- RULE7 = "hook-tampering";
7496
+ RULE8 = "hook-tampering";
7295
7497
  ownerExec2 = (mode) => (parseInt(mode.slice(-3), 8) & 64) !== 0;
7296
7498
  EVENTS = ["PreToolUse", "Stop"];
7297
7499
  isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
@@ -7314,6 +7516,10 @@ var init_hook_tampering = __esm({
7314
7516
  envMechanism = (k) => ENV_MECHANISM.find(([re]) => re.test(k))?.[1] ?? null;
7315
7517
  mentionsGate = (v) => /\btamperward\b/.test(JSON.stringify(v) ?? "");
7316
7518
  carriesGate = (s) => EVENTS.some((e) => gateCandidates(s, e).length > 0);
7519
+ CANONICAL_GATE = "npx tamperward check --staged";
7520
+ COMMIT_STAGES = /* @__PURE__ */ new Set(["pre-commit", "pre-push", "commit", "push"]);
7521
+ namesGate = (v) => typeof v === "string" && /\btamperward\b/.test(v);
7522
+ liveOrCanonical = (cmd) => gateOf(cmd) !== null ? cmd : CANONICAL_GATE;
7317
7523
  HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"';
7318
7524
  HUSKY_SHIM_NAMES = /* @__PURE__ */ new Set(["applypatch-msg", "commit-msg", "post-applypatch", "post-checkout", "post-commit", "post-merge", "post-rewrite", "pre-applypatch", "pre-auto-gc", "pre-commit", "pre-merge-commit", "pre-push", "pre-rebase", "prepare-commit-msg"]);
7319
7525
  HUSKY_H_KNOWN = [
@@ -7353,9 +7559,9 @@ They WILL FAIL in v10.0.0
7353
7559
  "`;
7354
7560
  stripTrailingNl = (s) => s.endsWith("\n") ? s.slice(0, -1) : s;
7355
7561
  isHuskyScript = (path) => /(?:^|\/)\.husky\/[^/]+$/.test(path);
7356
- isHookNote = (path) => /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
7562
+ isHookNote = (path) => !/(?:^|\/)\.husky\/_\//.test(path) && /\.(?:md|markdown|txt)$|(?:^|\/)\.git(?:ignore|attributes)$/i.test(path);
7357
7563
  hookTampering = {
7358
- id: RULE7,
7564
+ id: RULE8,
7359
7565
  surface: ["file", "command"],
7360
7566
  certainty: "mechanical",
7361
7567
  run(changes, policy, _view, ctx) {
@@ -7368,7 +7574,7 @@ They WILL FAIL in v10.0.0
7368
7574
  if (isPackageJson(c.path) && c.before != null && c.after != null) {
7369
7575
  for (const reason of packageJsonWeakening(c.before, c.after)) {
7370
7576
  out3.push(
7371
- makeFinding(RULE7, policy, {
7577
+ makeFinding(RULE8, policy, {
7372
7578
  file: c.path,
7373
7579
  message: `The hook installation was weakened: ${reason}.`,
7374
7580
  evidence: reason,
@@ -7381,7 +7587,7 @@ They WILL FAIL in v10.0.0
7381
7587
  }
7382
7588
  if (c.op !== "delete" && c.oldMode && c.newMode && ownerExec2(c.oldMode) && !ownerExec2(c.newMode)) {
7383
7589
  out3.push(
7384
- makeFinding(RULE7, policy, {
7590
+ makeFinding(RULE8, policy, {
7385
7591
  file: c.path,
7386
7592
  message: `Execute permission was removed from a protected hook (${c.oldMode} \u2192 ${c.newMode}) \u2014 git will skip it.`,
7387
7593
  evidence: `${c.path}: mode ${c.oldMode} \u2192 ${c.newMode}`,
@@ -7391,7 +7597,7 @@ They WILL FAIL in v10.0.0
7391
7597
  }
7392
7598
  if (c.op === "delete") {
7393
7599
  out3.push(
7394
- makeFinding(RULE7, policy, {
7600
+ makeFinding(RULE8, policy, {
7395
7601
  file: c.path,
7396
7602
  message: "A protected hook/policy file was deleted.",
7397
7603
  evidence: c.path,
@@ -7400,7 +7606,7 @@ They WILL FAIL in v10.0.0
7400
7606
  );
7401
7607
  } else if (c.op === "rename" && !renameKept(c)) {
7402
7608
  out3.push(
7403
- makeFinding(RULE7, policy, {
7609
+ makeFinding(RULE8, policy, {
7404
7610
  file: c.path,
7405
7611
  message: `A protected hook/policy file was renamed (${c.oldPath} \u2192 ${c.path}).`,
7406
7612
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -7414,7 +7620,7 @@ They WILL FAIL in v10.0.0
7414
7620
  if (c.before != null && c.after != null) {
7415
7621
  for (const reason of codeownersWeakening(c.before, c.after, [...repoGateFiles(ctx), c.path])) {
7416
7622
  out3.push(
7417
- makeFinding(RULE7, policy, {
7623
+ makeFinding(RULE8, policy, {
7418
7624
  file: c.path,
7419
7625
  message: `CODEOWNERS was weakened: ${reason}.`,
7420
7626
  evidence: reason,
@@ -7427,6 +7633,7 @@ They WILL FAIL in v10.0.0
7427
7633
  out3.push(...semantic);
7428
7634
  } else if (!c.path.endsWith(POLICY_FILE)) {
7429
7635
  if ((c.op === "add" || c.op === "modify") && huskyRuntimeWrite(c.path, c.after, ctx)) continue;
7636
+ if (isHookNote(c.path)) continue;
7430
7637
  const husky = isHuskyScript(c.path);
7431
7638
  const opts = husky ? { errexit: true } : {};
7432
7639
  const beforePin = c.before != null && c.after != null ? initScriptPin(c.before) : null;
@@ -7435,7 +7642,7 @@ They WILL FAIL in v10.0.0
7435
7642
  const kept = afterPin !== null && (beforePin === "" ? afterPin === "" || PLAIN_SEMVER.test(afterPin) : pinNotBelow(afterPin, beforePin));
7436
7643
  if (!kept) {
7437
7644
  out3.push(
7438
- makeFinding(RULE7, policy, {
7645
+ makeFinding(RULE8, policy, {
7439
7646
  file: c.path,
7440
7647
  message: "A protected hook script no longer matches the shape `tamperward init` writes.",
7441
7648
  evidence: "the gate script no longer matches the shape init wrote; re-run init or sign off",
@@ -7458,7 +7665,7 @@ They WILL FAIL in v10.0.0
7458
7665
  if (shebang || !live) {
7459
7666
  const why = shebang ?? (gate.length ? `its \`${gate[0].identity}\` is ${gate[0].state}${gate[0].why ? ` (${gate[0].why})` : ""}` : "it runs no `tamperward check`");
7460
7667
  out3.push(
7461
- makeFinding(RULE7, policy, {
7668
+ makeFinding(RULE8, policy, {
7462
7669
  file: c.path,
7463
7670
  message: `A protected hook script was added that does not run the gate live: ${why}. A new gate script needs a sign-off unless the gate is live in it.`,
7464
7671
  evidence: `a hook script was added; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or make the gate live in it`,
@@ -7467,7 +7674,7 @@ They WILL FAIL in v10.0.0
7467
7674
  );
7468
7675
  } else if (earlyExit) {
7469
7676
  out3.push(
7470
- makeFinding(RULE7, policy, {
7677
+ makeFinding(RULE8, policy, {
7471
7678
  file: c.path,
7472
7679
  line: earlyExit.newLine ?? void 0,
7473
7680
  message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
@@ -7489,7 +7696,7 @@ They WILL FAIL in v10.0.0
7489
7696
  deadGuard2 ? `a guard that can never be true was inserted (\`${deadGuard2.content.trim()}\`)` : null
7490
7697
  ].filter((d) => d !== null);
7491
7698
  out3.push(
7492
- makeFinding(RULE7, policy, {
7699
+ makeFinding(RULE8, policy, {
7493
7700
  file: c.path,
7494
7701
  message: `A hand-written protected hook script was changed: ${detail.length ? detail.join("; ") : "an edit other than a pin raise"}. Every edit to a gate script other than raising its pin needs a sign-off.`,
7495
7702
  evidence: `the gate script changed; sign off with \`tamperward allow hook-tampering --file ${c.path} --reason "..."\` locally or the \`tamperward:allow:hook-tampering@<head-sha>\` label in CI, or restore it`,
@@ -7501,7 +7708,7 @@ They WILL FAIL in v10.0.0
7501
7708
  }
7502
7709
  if (shebangChanged && c.after != null) {
7503
7710
  out3.push(
7504
- makeFinding(RULE7, policy, {
7711
+ makeFinding(RULE8, policy, {
7505
7712
  file: c.path,
7506
7713
  line: 1,
7507
7714
  message: `A protected hook's interpreter was changed: ${shebang}.`,
@@ -7512,7 +7719,7 @@ They WILL FAIL in v10.0.0
7512
7719
  }
7513
7720
  if (earlyExit) {
7514
7721
  out3.push(
7515
- makeFinding(RULE7, policy, {
7722
+ makeFinding(RULE8, policy, {
7516
7723
  file: c.path,
7517
7724
  line: earlyExit.newLine ?? void 0,
7518
7725
  message: "An early `exit 0` was inserted into a protected hook \u2014 it now passes everything.",
@@ -7524,7 +7731,7 @@ They WILL FAIL in v10.0.0
7524
7731
  const deadGuard = c.before == null || c.after == null ? added.find((l) => insertsDeadGuard(l.content)) : void 0;
7525
7732
  if (deadGuard) {
7526
7733
  out3.push(
7527
- makeFinding(RULE7, policy, {
7734
+ makeFinding(RULE8, policy, {
7528
7735
  file: c.path,
7529
7736
  line: deadGuard.newLine ?? void 0,
7530
7737
  message: "A guard that can never be true was inserted into a protected hook \u2014 what it wraps no longer runs.",
@@ -7536,7 +7743,7 @@ They WILL FAIL in v10.0.0
7536
7743
  const lost = scriptWeakening(beforeLines, afterLines, opts);
7537
7744
  if (lost.length) {
7538
7745
  out3.push(
7539
- makeFinding(RULE7, policy, {
7746
+ makeFinding(RULE8, policy, {
7540
7747
  file: c.path,
7541
7748
  message: `A check invocation was removed from a protected hook: ${lost[0].reason}.`,
7542
7749
  evidence: lost[0].evidence,
@@ -7555,7 +7762,7 @@ They WILL FAIL in v10.0.0
7555
7762
  const m = l.content.match(INLINE);
7556
7763
  if (m && m[2] === "warn" && removedSeverity.get(m[1]) === "block") {
7557
7764
  out3.push(
7558
- makeFinding(RULE7, policy, {
7765
+ makeFinding(RULE8, policy, {
7559
7766
  file: c.path,
7560
7767
  line: l.newLine ?? void 0,
7561
7768
  message: `Rule "${m[1]}" was lowered from block to warn in the policy.`,
@@ -7565,7 +7772,7 @@ They WILL FAIL in v10.0.0
7565
7772
  );
7566
7773
  } else if (/enabled:\s*false\b/.test(l.content)) {
7567
7774
  out3.push(
7568
- makeFinding(RULE7, policy, {
7775
+ makeFinding(RULE8, policy, {
7569
7776
  file: c.path,
7570
7777
  line: l.newLine ?? void 0,
7571
7778
  message: "A rule was disabled (enabled: false) in the policy.",
@@ -7583,7 +7790,7 @@ They WILL FAIL in v10.0.0
7583
7790
  const hiding = updateIndexHiding(toks, policy);
7584
7791
  if (hiding) {
7585
7792
  out3.push(
7586
- makeFinding(RULE7, policy, {
7793
+ makeFinding(RULE8, policy, {
7587
7794
  file: repoRelative(hiding.file, ctx),
7588
7795
  message: `Hook tampering via shell: ${hiding.reason}.`,
7589
7796
  evidence: seg,
@@ -7599,7 +7806,7 @@ They WILL FAIL in v10.0.0
7599
7806
  const file = target ? repoRelative(target, ctx) : void 0;
7600
7807
  const outside = file !== void 0 && (isAbsolute6(file) || /^[A-Za-z]:\//.test(file)) && isClaudeSettings(file) ? ` \u2014 ${file} is a Claude Code settings file the runtime reads hooks from` : "";
7601
7808
  out3.push(
7602
- makeFinding(RULE7, policy, {
7809
+ makeFinding(RULE8, policy, {
7603
7810
  ...file !== void 0 ? { file } : {},
7604
7811
  message: `Hook tampering via shell: ${why}${outside}.`,
7605
7812
  evidence: seg,
@@ -7835,7 +8042,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
7835
8042
  if (!was || !now || was.path === now.path) return [];
7836
8043
  const samples = runnerSamples(now.path, policy, ctx, "pytest");
7837
8044
  return suiteNarrowings(was.content, now.content, now.path, samples).map(
7838
- (n) => makeFinding(RULE8, policy, {
8045
+ (n) => makeFinding(RULE9, policy, {
7839
8046
  file: now.path,
7840
8047
  message: `The effective pytest config moved from ${was.path} to ${now.path}, and ${n.path} is no longer selected: ${n.reason}.`,
7841
8048
  evidence: `inifile precedence ${was.path} \u2192 ${now.path} drops ${n.path} from the suite`,
@@ -7843,7 +8050,7 @@ function pytestPrecedenceFindings(changes, policy, ctx) {
7843
8050
  })
7844
8051
  );
7845
8052
  }
7846
- var RULE8, JVM_TEST, TEST_DEFS, TABLE_METHOD, isEachOf, isDescribeEach, OTHER_RUNNER_DIR, WHOLE_SUITE_SCRIPT, SLICE_SCRIPT, SED_INPLACE, testDeletion;
8053
+ var RULE9, JVM_TEST, TEST_DEFS, TABLE_METHOD, isEachOf, isDescribeEach, OTHER_RUNNER_DIR, WHOLE_SUITE_SCRIPT, SLICE_SCRIPT, SED_INPLACE, testDeletion;
7847
8054
  var init_test_deletion = __esm({
7848
8055
  "src/detectors/test-deletion.ts"() {
7849
8056
  "use strict";
@@ -7856,7 +8063,7 @@ var init_test_deletion = __esm({
7856
8063
  init_suite_config();
7857
8064
  init_invocation();
7858
8065
  init_narrow();
7859
- RULE8 = "test-deletion";
8066
+ RULE9 = "test-deletion";
7860
8067
  JVM_TEST = /^\s*(?:@\w+(?:\([^)]*\))?\s+)*@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/;
7861
8068
  TEST_DEFS = {
7862
8069
  py: /^\s*(?:async\s+)?def\s+test\w*\s*\(/,
@@ -7876,7 +8083,7 @@ var init_test_deletion = __esm({
7876
8083
  SLICE_SCRIPT = /^test:(?:unit|integration)$/;
7877
8084
  SED_INPLACE = /(?:^|\s)(?:-i\b|--in-place\b|-[a-zA-Z]*i[a-zA-Z]*\b)/;
7878
8085
  testDeletion = {
7879
- id: RULE8,
8086
+ id: RULE9,
7880
8087
  surface: ["file", "command"],
7881
8088
  certainty: "mechanical",
7882
8089
  run(changes, policy, _view, ctx) {
@@ -7908,7 +8115,7 @@ var init_test_deletion = __esm({
7908
8115
  if (c.op === "delete" && isTest) {
7909
8116
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
7910
8117
  out3.push(
7911
- makeFinding(RULE8, policy, {
8118
+ makeFinding(RULE9, policy, {
7912
8119
  file: c.path,
7913
8120
  message: "A test file was deleted.",
7914
8121
  evidence: c.path,
@@ -7917,7 +8124,7 @@ var init_test_deletion = __esm({
7917
8124
  );
7918
8125
  } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
7919
8126
  out3.push(
7920
- makeFinding(RULE8, policy, {
8127
+ makeFinding(RULE9, policy, {
7921
8128
  file: c.path,
7922
8129
  message: `A test file was renamed out of the test glob (${c.oldPath} \u2192 ${c.path}).`,
7923
8130
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -7926,7 +8133,7 @@ var init_test_deletion = __esm({
7926
8133
  );
7927
8134
  } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && isTest && leavesWalk(c.oldPath, c.path)) {
7928
8135
  out3.push(
7929
- makeFinding(RULE8, policy, {
8136
+ makeFinding(RULE9, policy, {
7930
8137
  file: c.path,
7931
8138
  message: `A test file was renamed into a path the runner does not walk (${c.oldPath} \u2192 ${c.path}): ${leavesWalk(c.oldPath, c.path)}.`,
7932
8139
  evidence: `${c.oldPath} \u2192 ${c.path}`,
@@ -7941,7 +8148,7 @@ var init_test_deletion = __esm({
7941
8148
  const removed = new Set([...significantLines(c.before, c.path)].filter((l) => !afterLines.has(l)));
7942
8149
  if (isRelocation(removed, before.min - after.min)) continue;
7943
8150
  out3.push(
7944
- makeFinding(RULE8, policy, {
8151
+ makeFinding(RULE9, policy, {
7945
8152
  file: c.path,
7946
8153
  message: `Test blocks removed: ${before.min} \u2192 ${after.min} it()/test() in this spec (each-table rows count as tests).`,
7947
8154
  evidence: `${before.min - after.min} test block(s) removed from ${c.path}`,
@@ -7954,7 +8161,7 @@ var init_test_deletion = __esm({
7954
8161
  const readable = rn !== "pytest" || effectivePytestConfig(c.path, ctx);
7955
8162
  for (const n of readable ? suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx, rn)) : []) {
7956
8163
  out3.push(
7957
- makeFinding(RULE8, policy, {
8164
+ makeFinding(RULE9, policy, {
7958
8165
  file: c.path,
7959
8166
  message: `The runner no longer selects ${n.path}: ${n.reason}.`,
7960
8167
  evidence: `${n.path} dropped from the suite by ${c.path}`,
@@ -7966,12 +8173,12 @@ var init_test_deletion = __esm({
7966
8173
  const w = n.weakening;
7967
8174
  const evidence = `"${n.script}": ${JSON.stringify(n.before)} \u2192 ${n.after === "" ? "deleted" : JSON.stringify(n.after)}`;
7968
8175
  out3.push(
7969
- w.state === "removed" ? makeFinding(RULE8, policy, {
8176
+ w.state === "removed" ? makeFinding(RULE9, policy, {
7970
8177
  file: c.path,
7971
8178
  message: `The test script no longer runs the ${w.kind} check: scripts.${n.script} ${w.what}.`,
7972
8179
  evidence,
7973
8180
  remediation: "Keep the script running its check. Replacing the runner in package.json removes every test from every `npm test` while the workflow line stays untouched."
7974
- }) : makeFinding(RULE8, policy, {
8181
+ }) : makeFinding(RULE9, policy, {
7975
8182
  file: c.path,
7976
8183
  message: `The test script now narrows the suite: ${w.what} ${w.direction} ${w.direction === "added" ? "to" : "from"} scripts.${n.script}.`,
7977
8184
  evidence,
@@ -8062,7 +8269,7 @@ var init_test_deletion = __esm({
8062
8269
  }
8063
8270
  if (why) {
8064
8271
  out3.push(
8065
- makeFinding(RULE8, policy, {
8272
+ makeFinding(RULE9, policy, {
8066
8273
  message: `Test removed via shell: ${why}.`,
8067
8274
  evidence,
8068
8275
  remediation: "Fix the code under test. Mutating a spec from the shell is still deleting the test \u2014 and is blocked the same way."
@@ -8118,7 +8325,7 @@ function significantLinesOrdered(src, path) {
8118
8325
  }
8119
8326
  return out3;
8120
8327
  }
8121
- var RULE9, MIN_REMOVED_LINES, MIN_REMOVED_ROWS, ws, TEST_DIR_FILE, testContentRemoval;
8328
+ var RULE10, MIN_REMOVED_LINES, MIN_REMOVED_ROWS, ws, TEST_DIR_FILE, testContentRemoval;
8122
8329
  var init_test_content_removal = __esm({
8123
8330
  "src/detectors/test-content-removal.ts"() {
8124
8331
  "use strict";
@@ -8127,13 +8334,13 @@ var init_test_content_removal = __esm({
8127
8334
  init_finding();
8128
8335
  init_test_deletion();
8129
8336
  init_files();
8130
- RULE9 = "test-content-removal";
8337
+ RULE10 = "test-content-removal";
8131
8338
  MIN_REMOVED_LINES = 3;
8132
8339
  MIN_REMOVED_ROWS = 2;
8133
8340
  ws = (s) => s.replace(/\s+/g, "");
8134
8341
  TEST_DIR_FILE = /(?:^|\/)(?:test|tests|__tests__|spec|specs)\//;
8135
8342
  testContentRemoval = {
8136
- id: RULE9,
8343
+ id: RULE10,
8137
8344
  surface: ["file"],
8138
8345
  certainty: "mechanical",
8139
8346
  run(changes, policy) {
@@ -8174,7 +8381,7 @@ var init_test_content_removal = __esm({
8174
8381
  const goneRows = table.rows.size && tableAfter.open ? [...table.rows].filter(([k]) => !tableAfter.rows.has(k) && !addedPool.includes(k)).map(([, text]) => text) : [];
8175
8382
  if (goneRows.length >= MIN_REMOVED_ROWS) {
8176
8383
  out3.push(
8177
- makeFinding(RULE9, policy, {
8384
+ makeFinding(RULE10, policy, {
8178
8385
  file: c.path,
8179
8386
  message: `Rows removed from a test table: ${goneRows.length} case(s) gone from an each-table that spreads from elsewhere, and they reappear nowhere in the change.`,
8180
8387
  evidence: goneRows.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -8185,7 +8392,7 @@ var init_test_content_removal = __esm({
8185
8392
  }
8186
8393
  if (gone.length >= MIN_REMOVED_LINES && netDrop >= MIN_REMOVED_LINES) {
8187
8394
  out3.push(
8188
- makeFinding(RULE9, policy, {
8395
+ makeFinding(RULE10, policy, {
8189
8396
  file: c.path,
8190
8397
  message: `Test content removed from a surviving spec: ${gone.length} significant line(s) gone with no test block deleted.`,
8191
8398
  evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
@@ -8208,14 +8415,14 @@ function namesProtectedSnapshot(seg, policy) {
8208
8415
  }
8209
8416
  return null;
8210
8417
  }
8211
- var RULE10, RUNNER3, PM_TEST_SCRIPT, UPDATE_ANY, UPDATE_WITH_RUNNER, INSTA_ACCEPT, UPDATE_EXPECT, REGEN_SCRIPT, MUTATE, snapshotRewrite;
8418
+ var RULE11, RUNNER3, PM_TEST_SCRIPT, UPDATE_ANY, UPDATE_WITH_RUNNER, INSTA_ACCEPT, UPDATE_EXPECT, REGEN_SCRIPT, MUTATE, snapshotRewrite;
8212
8419
  var init_snapshot_rewrite = __esm({
8213
8420
  "src/detectors/snapshot-rewrite.ts"() {
8214
8421
  "use strict";
8215
8422
  init_policy();
8216
8423
  init_finding();
8217
8424
  init_command();
8218
- RULE10 = "snapshot-rewrite";
8425
+ RULE11 = "snapshot-rewrite";
8219
8426
  RUNNER3 = /\b(?:jest|vitest|playwright|ava)\b/;
8220
8427
  PM_TEST_SCRIPT = /(?:^|\s)(?:npm|pnpm|yarn|bun)\s+(?:t|tst|test|run(?:-script)?\s+\S*test\S*)(?:\s|$)/;
8221
8428
  UPDATE_ANY = /--(?:update-?[sS]napshots?|test-update-snapshots|snapshot-update)\b/;
@@ -8225,12 +8432,12 @@ var init_snapshot_rewrite = __esm({
8225
8432
  REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
8226
8433
  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/;
8227
8434
  snapshotRewrite = {
8228
- id: RULE10,
8435
+ id: RULE11,
8229
8436
  surface: ["command", "file"],
8230
8437
  certainty: "mechanical",
8231
8438
  run(changes, policy) {
8232
8439
  const out3 = [];
8233
- const warn = (input) => out3.push(makeFinding(RULE10, policy, { ...input, defaultSeverity: "warn" }));
8440
+ const warn = (input) => out3.push(makeFinding(RULE11, policy, { ...input, defaultSeverity: "warn" }));
8234
8441
  for (const c of changes) {
8235
8442
  if (c.kind === "command") {
8236
8443
  for (const seg of segments(c.raw)) {
@@ -8278,16 +8485,16 @@ function isSnapshotChange(c, policy) {
8278
8485
  if (isProtected(c.path, policy, "snapshots")) return true;
8279
8486
  return c.oldPath != null && isProtected(c.oldPath, policy, "snapshots");
8280
8487
  }
8281
- var RULE11, COMMIT_VIEWS, snapshotOnlyRewrite;
8488
+ var RULE12, COMMIT_VIEWS, snapshotOnlyRewrite;
8282
8489
  var init_snapshot_only = __esm({
8283
8490
  "src/detectors/snapshot-only.ts"() {
8284
8491
  "use strict";
8285
8492
  init_policy();
8286
8493
  init_finding();
8287
- RULE11 = "snapshot-only-rewrite";
8494
+ RULE12 = "snapshot-only-rewrite";
8288
8495
  COMMIT_VIEWS = ["staged", "range"];
8289
8496
  snapshotOnlyRewrite = {
8290
- id: RULE11,
8497
+ id: RULE12,
8291
8498
  surface: ["file"],
8292
8499
  certainty: "mechanical",
8293
8500
  run(changes, policy, view) {
@@ -8300,7 +8507,7 @@ var init_snapshot_only = __esm({
8300
8507
  if (!first) return [];
8301
8508
  const names = snaps.map((c) => c.path).slice(0, 3).join(", ");
8302
8509
  return [
8303
- makeFinding(RULE11, policy, {
8510
+ makeFinding(RULE12, policy, {
8304
8511
  file: first.path,
8305
8512
  message: `Only recorded expectations changed in this diff (${snaps.length} snapshot file${snaps.length === 1 ? "" : "s"}, no code change to explain them).`,
8306
8513
  evidence: names + (snaps.length > 3 ? ", \u2026" : ""),
@@ -8474,7 +8681,7 @@ function findingForBlock(path, before, after, policy) {
8474
8681
  const weakened2 = directionalWeakening(before.assertions, after.assertions);
8475
8682
  if (weakened2) {
8476
8683
  const message = weakened2.kind === "throw-specificity" ? `Assertion weakened in test "${before.identity}": exception-message specificity was removed from toThrow().` : `Assertion weakened in test "${before.identity}": ${weakened2.before.matcher} was replaced by weaker ${weakened2.after.matcher} on the same subject.`;
8477
- return makeFinding(RULE12, policy, {
8684
+ return makeFinding(RULE13, policy, {
8478
8685
  file: path,
8479
8686
  message,
8480
8687
  evidence: `${weakened2.before.text} -> ${weakened2.after.text}`,
@@ -8483,7 +8690,7 @@ function findingForBlock(path, before, after, policy) {
8483
8690
  }
8484
8691
  const removed = pureRemoval(before.assertions, after.assertions);
8485
8692
  if (removed) {
8486
- return makeFinding(RULE12, policy, {
8693
+ return makeFinding(RULE13, policy, {
8487
8694
  file: path,
8488
8695
  message: `Assertion removed from kept test "${before.identity}" without a replacement assertion.`,
8489
8696
  evidence: removed.text,
@@ -8492,7 +8699,7 @@ function findingForBlock(path, before, after, policy) {
8492
8699
  }
8493
8700
  return null;
8494
8701
  }
8495
- var RULE12, TEST_CALLS, SUITE_CALLS, EXACT_MATCHERS, WEAK_MATCHERS, compact, assertionWeakening;
8702
+ var RULE13, TEST_CALLS, SUITE_CALLS, EXACT_MATCHERS, WEAK_MATCHERS, compact, assertionWeakening;
8496
8703
  var init_assertion_weakening = __esm({
8497
8704
  "src/detectors/assertion-weakening.ts"() {
8498
8705
  "use strict";
@@ -8500,14 +8707,14 @@ var init_assertion_weakening = __esm({
8500
8707
  init_policy();
8501
8708
  init_finding();
8502
8709
  init_files();
8503
- RULE12 = "assertion-weakening";
8710
+ RULE13 = "assertion-weakening";
8504
8711
  TEST_CALLS = /* @__PURE__ */ new Set(["it", "test"]);
8505
8712
  SUITE_CALLS = /* @__PURE__ */ new Set(["describe", "suite", "context"]);
8506
8713
  EXACT_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual", "toStrictEqual", "toMatchObject"]);
8507
8714
  WEAK_MATCHERS = /* @__PURE__ */ new Set(["toBeTruthy", "toBeDefined"]);
8508
8715
  compact = (s) => s.replace(/\s+/g, "");
8509
8716
  assertionWeakening = {
8510
- id: RULE12,
8717
+ id: RULE13,
8511
8718
  surface: ["file"],
8512
8719
  certainty: "heuristic",
8513
8720
  run(changes, policy) {
@@ -8542,6 +8749,7 @@ var init_detectors = __esm({
8542
8749
  init_lint_suppression();
8543
8750
  init_test_skip();
8544
8751
  init_coverage_lowering();
8752
+ init_coverage_exclusion();
8545
8753
  init_ci_tampering();
8546
8754
  init_hook_tampering();
8547
8755
  init_test_deletion();
@@ -8556,6 +8764,7 @@ var init_detectors = __esm({
8556
8764
  lintSuppression,
8557
8765
  testSkip,
8558
8766
  coverageLowering,
8767
+ coverageExclusion,
8559
8768
  ciTampering,
8560
8769
  hookTampering,
8561
8770
  testDeletion,
@@ -10027,7 +10236,7 @@ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DE
10027
10236
  };
10028
10237
  }
10029
10238
  function transientFindings(events, persistentPaths, policy, finalHash) {
10030
- if (!isEnabled(RULE13, policy)) return [];
10239
+ if (!isEnabled(RULE14, policy)) return [];
10031
10240
  const byPath = /* @__PURE__ */ new Map();
10032
10241
  for (const e of events) {
10033
10242
  if (!isProtected(e.path, policy)) continue;
@@ -10047,7 +10256,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
10047
10256
  const modeChurn = modes.size > 1;
10048
10257
  if (!contentChurn && !modeChurn) continue;
10049
10258
  const what = contentChurn ? `content was modified and restored within the turn (${hashes.size} distinct states observed)` : `file mode changed during the turn (${[...modes].map((m) => (m & 4095).toString(8)).join(" \u2192 ")})`;
10050
- const f = makeFinding(RULE13, policy, {
10259
+ const f = makeFinding(RULE14, policy, {
10051
10260
  file: path,
10052
10261
  message: `Transient protected mutation: ${what}, leaving no net diff.`,
10053
10262
  evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
@@ -10062,14 +10271,14 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
10062
10271
  }
10063
10272
  return out3;
10064
10273
  }
10065
- var RULE13, MAX_EVENT_READ_BYTES, MAX_EVENT_SWEEP_BYTES, DEFAULT_EVENT_READ_IO;
10274
+ var RULE14, MAX_EVENT_READ_BYTES, MAX_EVENT_SWEEP_BYTES, DEFAULT_EVENT_READ_IO;
10066
10275
  var init_fs_events = __esm({
10067
10276
  "src/detectors/fs-events.ts"() {
10068
10277
  "use strict";
10069
10278
  init_policy();
10070
10279
  init_finding();
10071
10280
  init_narrow();
10072
- RULE13 = "transient-protected-mutation";
10281
+ RULE14 = "transient-protected-mutation";
10073
10282
  MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
10074
10283
  MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
10075
10284
  DEFAULT_EVENT_READ_IO = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.23.20",
3
+ "version": "2.24.1",
4
4
  "description": "The deterministic agent-integrity gate. One ruleset, evaluated on the actual diff/commands as a verdict, enforced everywhere a change can be made.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "hexrift",