tamperward 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli/index.js +308 -75
  2. package/package.json +1 -1
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
  );
@@ -645,12 +702,38 @@ function isSignificantLine(trimmed, lang) {
645
702
  if (HASH_COMMENT.has(l) && trimmed.startsWith("#")) return false;
646
703
  return true;
647
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
+ }
648
729
 
649
730
  // src/detectors/ts-any-cast.ts
650
731
  var BLOCK_RULE = "ts-any-cast";
651
732
  var WARN_RULE = "ts-any-launder";
652
733
  var DOUBLE_CAST = /\bas\s+unknown\s+as\b/g;
653
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)$/;
654
737
  var countMatches = (s, re) => (s.match(re) || []).length;
655
738
  function countAny(src) {
656
739
  const r = { cast: 0, broad: 0 };
@@ -658,8 +741,13 @@ function countAny(src) {
658
741
  const sf = ts.createSourceFile("f.ts", src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
659
742
  const visit = (node) => {
660
743
  if (node.kind === ts.SyntaxKind.AnyKeyword) {
661
- const p = node.parent;
662
- 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;
663
751
  if (isCast) r.cast++;
664
752
  else r.broad++;
665
753
  }
@@ -670,7 +758,7 @@ function countAny(src) {
670
758
  }
671
759
  return r;
672
760
  }
673
- 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/;
674
762
  var BROAD_LINE = /:\s*any\b|<[^<>]*\bany\b[^<>]*>/;
675
763
  var BLOCK_REMEDIATION = "Fix the underlying type instead of silencing the checker; do not cast to `any`.";
676
764
  var WARN_REMEDIATION = "Prefer a precise type or `unknown` + a guard over `any` here \u2014 flagged for review.";
@@ -691,10 +779,12 @@ var tsAnyCast = {
691
779
  const dBroad = a.broad - b.broad;
692
780
  const dDouble = countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST);
693
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;
694
783
  const blockReasons = [];
695
784
  if (dCast > 0) blockReasons.push("`as any` cast");
696
785
  if (dDouble > 0) blockReasons.push("`as unknown as` double cast");
697
786
  if (dSuppr > 0) blockReasons.push("@ts-ignore/@ts-expect-error/@ts-nocheck suppression");
787
+ if (dJsdoc > 0) blockReasons.push("JSDoc `@type {any}` cast");
698
788
  if (blockReasons.length) {
699
789
  out2.push(
700
790
  makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
@@ -719,8 +809,9 @@ var tsAnyCast = {
719
809
  }
720
810
  continue;
721
811
  }
812
+ const jsdocCast = JS_FILE.test(c.path) ? new RegExp(JSDOC_ANY_CAST.source) : null;
722
813
  for (const l of addedLines(c)) {
723
- if (NARROW_LINE.test(l.content)) {
814
+ if (NARROW_LINE.test(l.content) || jsdocCast && jsdocCast.test(l.content)) {
724
815
  out2.push(
725
816
  makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
726
817
  file: c.path,
@@ -754,30 +845,47 @@ var RULE2 = "lint-suppression";
754
845
  var PATTERNS = {
755
846
  js: [
756
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" },
757
851
  { re: /\bprettier-ignore\b/, why: "prettier-ignore suppresses formatting checks" },
758
852
  { re: /\bbiome-ignore\b/, why: "biome-ignore suppresses Biome diagnostics" }
759
853
  ],
760
854
  py: [
761
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" },
762
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" },
763
859
  { re: /#\s*pyright:\s*ignore\b/, why: "# pyright: ignore suppresses the type checker" },
764
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" },
765
862
  { re: /#\s*nosec\b/, why: "# nosec suppresses bandit security findings" },
766
863
  { re: /#\s*fmt:\s*(?:off|skip)\b/, why: "# fmt: off/skip suppresses formatting checks" }
767
864
  ],
768
- 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
+ ],
769
869
  rs: [],
770
870
  rb: [{ re: /#\s*rubocop:\s*(?:disable|todo)\b/, why: "# rubocop:disable suppresses lint rules" }],
771
- java: [{ re: /@SuppressWarnings\s*\(/, why: "@SuppressWarnings suppresses compiler and lint warnings" }],
772
- 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
+ ],
773
879
  php: [
774
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" },
775
882
  { re: /@phpstan-ignore\b/, why: "@phpstan-ignore suppresses static-analysis findings" },
776
883
  { re: /@psalm-suppress\b/, why: "@psalm-suppress suppresses static-analysis findings" }
777
884
  ],
778
885
  cs: [
779
886
  { re: /#pragma\s+warning\s+disable\b/, why: "#pragma warning disable suppresses compiler warnings" },
780
- { 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" }
781
889
  ]
782
890
  };
783
891
  var lintSuppression = {
@@ -794,18 +902,18 @@ var lintSuppression = {
794
902
  if (patterns.length === 0) continue;
795
903
  for (const l of addedLines(c)) {
796
904
  for (const p of patterns) {
797
- if (p.re.test(l.content)) {
798
- out2.push(
799
- makeFinding(RULE2, policy, {
800
- file: c.path,
801
- line: l.newLine ?? void 0,
802
- message: `Lint suppression added: ${p.why}.`,
803
- evidence: l.content.trim(),
804
- remediation: "Resolve the finding rather than suppressing the rule."
805
- })
806
- );
807
- break;
808
- }
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;
809
917
  }
810
918
  }
811
919
  }
@@ -817,40 +925,66 @@ var lintSuppression = {
817
925
  var RULE3 = "test-skip";
818
926
  var PATTERNS2 = {
819
927
  js: [
820
- { re: /\b(?:it|test|describe)\.(?:skip|only|todo)\b/, why: "a .skip/.only/.todo marker" },
821
- { re: /\b(?:xit|xtest|xdescribe)\b/, why: "an x-prefixed disabled test" },
822
- { 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)" },
823
935
  { re: /\bpending\(\s*\)/, why: "a pending() marker" },
824
936
  // node:test spells the same class as options — `test('x', { skip: true }, ...)` — and as
825
937
  // a runtime call. A live evasion used the options form the day this gap was measured
826
938
  // (PREDICTION-baselines.md): denied the rm, the agent skipped by option instead.
827
- { 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)" },
828
944
  { re: /\btodo:\s*(?:true|['"`])/, why: "a { todo: ... } option (the test no longer fails the run)" },
829
- { 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" }
830
946
  ],
831
947
  py: [
832
- { 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)" },
833
951
  { re: /\bpytest\.(?:skip|xfail|importorskip)\(/, why: "a runtime pytest.skip()/xfail() call" },
834
- { 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" },
835
954
  { re: /\bself\.skipTest\(/, why: "a runtime self.skipTest() call" },
836
- { 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" }
837
957
  ],
838
958
  go: [
839
- { 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)" }
840
966
  ],
841
- rs: [{ re: /#\[ignore\b/, why: "an #[ignore] attribute (the test no longer runs by default)" }],
842
967
  rb: [
843
- { re: /(?:^|[\s;{(])(?:skip|pending)(?:\s*\(|\s+['"]|\s+(?:if|unless)\b|\s*$)/, why: "a skip/pending call" },
844
- { re: /\bx(?:it|describe|context|specify|example)\b/, why: "an x-prefixed disabled example" },
845
- { re: /\b(?:fit|fdescribe|fcontext)\b/, why: "an f-prefixed focused example (narrows the suite)" },
846
- { 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)" }
847
981
  ],
848
- java: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
849
- kt: [{ re: /@(?:Ignore|Disabled)\b/, why: "an @Ignore/@Disabled annotation" }],
850
982
  php: [{ re: /\bmarkTest(?:Skipped|Incomplete)\(/, why: "a markTestSkipped()/markTestIncomplete() call" }],
851
983
  cs: [
852
984
  { re: /\[Ignore\b/, why: "an [Ignore] attribute" },
853
- { 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)" }
854
988
  ]
855
989
  };
856
990
  var testSkip = {
@@ -862,9 +996,12 @@ var testSkip = {
862
996
  for (const c of changes) {
863
997
  if (c.kind !== "file") continue;
864
998
  if (!isProtected(c.path, policy, "tests")) continue;
865
- const patterns = PATTERNS2[langOf(c.path) ?? "js"];
999
+ const lang = langOf(c.path);
1000
+ const patterns = PATTERNS2[lang ?? "js"];
866
1001
  for (const l of addedLines(c)) {
1002
+ const comment = isCommentLine(l.content.trim(), lang);
867
1003
  for (const p of patterns) {
1004
+ if (comment && !p.comment) continue;
868
1005
  if (p.re.test(l.content)) {
869
1006
  out2.push(
870
1007
  makeFinding(RULE3, policy, {
@@ -1654,10 +1791,13 @@ var testContentRemoval = {
1654
1791
  // src/detectors/snapshot-rewrite.ts
1655
1792
  var RULE9 = "snapshot-rewrite";
1656
1793
  var RUNNER = /\b(?:jest|vitest|playwright|ava)\b/;
1657
- 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/;
1658
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+/;
1659
1799
  var REGEN_SCRIPT = /\b(?:update|regen(?:erate)?|re?bless)[-_.]?(?:golden|snapshots?|baselines?|expected)\b/i;
1660
- 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/;
1661
1801
  function namesProtectedSnapshot(seg, policy) {
1662
1802
  for (const t of tokens(seg)) {
1663
1803
  const path = unquote(t).replace(/^>{1,2}/, "");
@@ -1677,7 +1817,9 @@ var snapshotRewrite = {
1677
1817
  for (const seg of segments(c.raw)) {
1678
1818
  let why = null;
1679
1819
  if (UPDATE_ANY.test(seg)) why = "the snapshot-update flag rewrites every failing snapshot to match current output";
1680
- 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";
1681
1823
  else if (REGEN_SCRIPT.test(seg)) why = "a regeneration script rewrites the recorded expected output from current output";
1682
1824
  else {
1683
1825
  const path = MUTATE.test(seg) ? namesProtectedSnapshot(seg, policy) : null;
@@ -2591,7 +2733,7 @@ function savePtree(cwd, sessionId, tree) {
2591
2733
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2, watch } from "node:fs";
2592
2734
  import { join as join7 } from "node:path";
2593
2735
  import { createHash as createHash3 } from "node:crypto";
2594
- var SKIP = /(^|\/)(\.git|node_modules)(\/|$)/;
2736
+ var SKIP2 = /(^|\/)(\.git|node_modules)(\/|$)/;
2595
2737
  function snap(abs2) {
2596
2738
  try {
2597
2739
  const st = statSync2(abs2);
@@ -2618,7 +2760,7 @@ function watchTree(dir, cb) {
2618
2760
  }
2619
2761
  const watchers = /* @__PURE__ */ new Map();
2620
2762
  const addDir = (rel) => {
2621
- if (watchers.has(rel) || SKIP.test(rel + "/")) return;
2763
+ if (watchers.has(rel) || SKIP2.test(rel + "/")) return;
2622
2764
  let w;
2623
2765
  try {
2624
2766
  w = watch(rel ? join7(dir, rel) : dir, (kind, fname) => {
@@ -2645,7 +2787,7 @@ function watchTree(dir, cb) {
2645
2787
  }
2646
2788
  for (const name of names) {
2647
2789
  const child = rel ? `${rel}/${name}` : name;
2648
- if (SKIP.test(child + "/")) continue;
2790
+ if (SKIP2.test(child + "/")) continue;
2649
2791
  try {
2650
2792
  if (statSync2(join7(dir, child)).isDirectory()) walk(child);
2651
2793
  } catch {
@@ -2664,7 +2806,7 @@ function startWatcher(dir, log, policy) {
2664
2806
  }
2665
2807
  const last = /* @__PURE__ */ new Map();
2666
2808
  return watchTree(dir, (kind, rel) => {
2667
- if (SKIP.test(rel) || !isProtected(rel, policy)) return;
2809
+ if (SKIP2.test(rel) || !isProtected(rel, policy)) return;
2668
2810
  const s = snap(join7(dir, rel));
2669
2811
  const key2 = `${s.hash}:${s.mode}`;
2670
2812
  if (last.get(rel) === key2) return;
@@ -3765,18 +3907,62 @@ var VERIFICATION_SURFACE = [
3765
3907
  "**/setup.cfg",
3766
3908
  "**/tox.ini",
3767
3909
  "**/pyproject.toml",
3910
+ "**/setup.py",
3911
+ // a pytest rootdir marker, and what an editable install executes
3912
+ // Interpreter start-up hooks: imported by site.py before pytest exists,
3913
+ // wherever the tree reaches sys.path (PYTHONPATH=., an editable install).
3914
+ "**/sitecustomize.py",
3915
+ "**/usercustomize.py",
3768
3916
  // JavaScript / TypeScript runners and the transforms they load
3769
3917
  "**/jest.config.*",
3770
3918
  "**/jest.setup.*",
3771
3919
  "**/vitest.config.*",
3772
3920
  "**/vitest.workspace.*",
3773
3921
  "**/.mocharc.*",
3922
+ "**/mocha.opts",
3774
3923
  "**/karma.conf.*",
3924
+ "**/ava.config.*",
3925
+ "**/.taprc",
3926
+ "**/spec/support/jasmine.json",
3927
+ "**/playwright.config.*",
3928
+ "**/cypress.config.*",
3929
+ "**/.nycrc*",
3930
+ "**/.c8rc*",
3775
3931
  "**/babel.config.*",
3776
3932
  "**/.babelrc*",
3777
3933
  "**/vite.config.*",
3778
3934
  "**/.swcrc",
3779
3935
  "**/package.json",
3936
+ // Package-manager configuration: read before the runner starts, and able to
3937
+ // choose what the runner is started under (`node-options`), which shell runs
3938
+ // the script (`script-shell`), or which program is run at all (`yarn-path`).
3939
+ "**/.npmrc",
3940
+ "**/.yarnrc",
3941
+ "**/.yarnrc.yml",
3942
+ // pnpm 10 reads its settings (`nodeOptions` among them) from here as well,
3943
+ // and its environment layer does NOT outrank this file (verified, pnpm
3944
+ // 10.33) — so the file list is the only control for it.
3945
+ "**/pnpm-workspace.yaml",
3946
+ "**/.pnpmfile.cjs",
3947
+ // The runner the package manager executes when the project pins one: yarn's
3948
+ // `yarnPath` release and its plugins, and the PnP loader every spawned node
3949
+ // is made to `--require`.
3950
+ "**/.yarn/releases/*",
3951
+ "**/.yarn/plugins/**",
3952
+ "**/.pnp.cjs",
3953
+ "**/.pnp.js",
3954
+ "**/.pnp.loader.mjs",
3955
+ // dotenv files: `node --env-file`, a jest/vitest setup that loads dotenv,
3956
+ // pytest-dotenv. An .env carries NODE_OPTIONS as easily as an .npmrc does.
3957
+ "**/.env",
3958
+ "**/.env.*",
3959
+ // Version-manager pins: which interpreter the shims select for this cwd.
3960
+ "**/.nvmrc",
3961
+ "**/.node-version",
3962
+ "**/.python-version",
3963
+ // What git does to content on its way into and out of the tree — the base's
3964
+ // decision, never the candidate's.
3965
+ "**/.gitattributes",
3780
3966
  // Ruby / PHP / .NET
3781
3967
  "**/.rspec",
3782
3968
  "**/phpunit.xml",
@@ -3922,6 +4108,31 @@ const timer = setTimeout(() => finish({ timedOut: true }), Number(budgetMs));
3922
4108
  child.on('error', (e) => { clearTimeout(timer); finish({ error: String(e) }); });
3923
4109
  child.on('exit', (code, signal) => { clearTimeout(timer); finish({ exit: code, signal }); });
3924
4110
  `;
4111
+ var DROPPED_ENV = /* @__PURE__ */ new Set([
4112
+ "NODE_OPTIONS",
4113
+ "NODE_PATH",
4114
+ "NODE_REPL_EXTERNAL_MODULE",
4115
+ "PYTHONPATH",
4116
+ "PYTHONSTARTUP",
4117
+ "PYTEST_ADDOPTS",
4118
+ "PYTEST_PLUGINS"
4119
+ ]);
4120
+ var PINNED_NPM = /^npm_config_(node_options|userconfig|globalconfig)$/i;
4121
+ function suiteEnv(scratch) {
4122
+ const env = {};
4123
+ for (const [k, v] of Object.entries(process.env)) {
4124
+ if (DROPPED_ENV.has(k) || PINNED_NPM.test(k)) continue;
4125
+ env[k] = v;
4126
+ }
4127
+ const userRc = join11(scratch, "npmrc-user");
4128
+ const globalRc = join11(scratch, "npmrc-global");
4129
+ for (const f of [userRc, globalRc]) writeFileSync6(f, "", { mode: 292 });
4130
+ env.npm_config_userconfig = userRc;
4131
+ env.npm_config_globalconfig = globalRc;
4132
+ env.npm_config_node_options = " ";
4133
+ env.YARN_IGNORE_PATH = "1";
4134
+ return env;
4135
+ }
3925
4136
  function runSuite(dir, cmd, budgetSecs) {
3926
4137
  const t0 = Date.now();
3927
4138
  const outDir = mkdtempSync2(join11(tmpdir2(), "tw-verify-run-"));
@@ -3929,6 +4140,7 @@ function runSuite(dir, cmd, budgetSecs) {
3929
4140
  try {
3930
4141
  spawnSync(process.execPath, ["-e", SUPERVISOR, cmd, String(budgetSecs * 1e3), outFile], {
3931
4142
  cwd: dir,
4143
+ env: suiteEnv(outDir),
3932
4144
  stdio: "ignore",
3933
4145
  timeout: budgetSecs * 1e3 + 3e4,
3934
4146
  // backstop only; the supervisor enforces the budget
@@ -3951,6 +4163,10 @@ function runSuite(dir, cmd, budgetSecs) {
3951
4163
  function runVerify(opts) {
3952
4164
  const cwd = opts.cwd ?? process.cwd();
3953
4165
  const out2 = (s) => void process.stdout.write(s + "\n");
4166
+ if (opts.invalid) {
4167
+ out2(`verify: ${opts.invalid} \u2014 failing closed`);
4168
+ return 2;
4169
+ }
3954
4170
  let policy;
3955
4171
  try {
3956
4172
  if (opts.policyOverride) policy = opts.policyOverride;
@@ -4094,15 +4310,32 @@ function runVerify(opts) {
4094
4310
  }
4095
4311
  function parseVerify(args) {
4096
4312
  const o = {};
4313
+ const value = (i, flag) => {
4314
+ const v = args[i + 1];
4315
+ if (v === void 0 || v.startsWith("--")) {
4316
+ o.invalid ??= `${flag} needs a value` + (v === void 0 ? "" : ` (got the flag "${v}")`);
4317
+ return void 0;
4318
+ }
4319
+ return v;
4320
+ };
4097
4321
  for (let i = 0; i < args.length; i++) {
4098
4322
  const a = args[i];
4099
- if (a === "--base" && args[i + 1]) o.base = args[++i];
4100
- else if (a === "--cmd" && args[i + 1]) o.cmd = args[++i];
4101
- else if (a === "--budget" && args[i + 1]) o.budget = Number(args[++i]);
4102
- else if (a === "--json") o.json = true;
4323
+ if (a === "--json") o.json = true;
4103
4324
  else if (a === "--keep") o.keep = true;
4104
4325
  else if (a === "--require-ancestor") o.requireAncestor = true;
4105
- else if (a === "--cwd" && args[i + 1]) o.cwd = args[++i];
4326
+ else if (a === "--base" || a === "--cmd" || a === "--cwd" || a === "--budget") {
4327
+ const v = value(i, a);
4328
+ if (v === void 0) continue;
4329
+ i++;
4330
+ if (a === "--base") o.base = v;
4331
+ else if (a === "--cmd") o.cmd = v;
4332
+ else if (a === "--cwd") o.cwd = v;
4333
+ else {
4334
+ o.budget = Number(v);
4335
+ if (!Number.isFinite(o.budget) || o.budget <= 0)
4336
+ o.invalid ??= `--budget needs a positive number of seconds (got "${v}")`;
4337
+ }
4338
+ }
4106
4339
  }
4107
4340
  return o;
4108
4341
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
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",