tamperward 2.24.7 → 2.25.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.
Files changed (2) hide show
  1. package/dist/cli/index.js +309 -72
  2. package/package.json +5 -5
package/dist/cli/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __esm = (fn, res) => function __init() {
5
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
4
+ var __esm = (fn, res, err3) => function __init() {
5
+ if (err3) throw err3[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err3 = [e], e;
10
+ }
6
11
  };
7
12
  var __export = (target, all) => {
8
13
  for (var name in all)
@@ -559,6 +564,10 @@ function defaultPolicy(version = 1) {
559
564
  // through the block-count rule (rows, assertion arguments, setup). Severity
560
565
  // backed by the real-commit corpus measurement recorded in the rule's SPEC row.
561
566
  "test-content-removal": { severity: "block" },
567
+ // the files that SERVE the specs under the same globs — helpers, setup
568
+ // modules, mocks, JSON fixtures — deleted, moved out or shrunk (#443). They
569
+ // define no test, so a review prompt, not a gate; the spec rules cede them.
570
+ "test-support": { severity: "warn" },
562
571
  "test-skip": { severity: "block" },
563
572
  "ts-any-cast": { severity: "block" },
564
573
  // the unambiguous explicit casts + ts-suppression directives
@@ -3447,9 +3456,62 @@ function assertedType(node) {
3447
3456
  while (ts.isParenthesizedTypeNode(t)) t = t.type;
3448
3457
  return t;
3449
3458
  }
3450
- function isDoubleCast(node) {
3459
+ function bareKind(type) {
3460
+ let x = type;
3461
+ while (ts.isParenthesizedTypeNode(x)) x = x.type;
3462
+ switch (x.kind) {
3463
+ case ts.SyntaxKind.AnyKeyword:
3464
+ return "any";
3465
+ case ts.SyntaxKind.NeverKeyword:
3466
+ return "never";
3467
+ case ts.SyntaxKind.UnknownKeyword:
3468
+ return "unknown";
3469
+ case ts.SyntaxKind.ObjectKeyword:
3470
+ return "objectish";
3471
+ }
3472
+ if (ts.isTypeLiteralNode(x) && x.members.length === 0) return "empty";
3473
+ if (ts.isTypeReferenceNode(x) && ts.isIdentifier(x.typeName) && !x.typeArguments) {
3474
+ if (x.typeName.text === "Object") return "objectish";
3475
+ if (x.typeName.text === "const") return "const";
3476
+ }
3477
+ return null;
3478
+ }
3479
+ function kindOfType(type, resolveName) {
3480
+ const bare = bareKind(type);
3481
+ if (bare) return bare;
3482
+ let x = type;
3483
+ while (ts.isParenthesizedTypeNode(x)) x = x.type;
3484
+ if (ts.isTypeReferenceNode(x) && ts.isIdentifier(x.typeName) && !x.typeArguments) return resolveName(x.typeName.text);
3485
+ return null;
3486
+ }
3487
+ function buildAliasMap(sf) {
3488
+ const decls = /* @__PURE__ */ new Map();
3489
+ const collect2 = (node) => {
3490
+ if (ts.isTypeAliasDeclaration(node) && (!node.typeParameters || node.typeParameters.length === 0)) decls.set(node.name.text, node.type);
3491
+ ts.forEachChild(node, collect2);
3492
+ };
3493
+ collect2(sf);
3494
+ const resolve16 = (name, seen) => {
3495
+ if (seen.has(name)) return null;
3496
+ seen.add(name);
3497
+ const t = decls.get(name);
3498
+ return t ? kindOfType(t, (n) => resolve16(n, seen)) : null;
3499
+ };
3500
+ const resolved = /* @__PURE__ */ new Map();
3501
+ for (const name of decls.keys()) {
3502
+ const k = resolve16(name, /* @__PURE__ */ new Set());
3503
+ if (k) resolved.set(name, k);
3504
+ }
3505
+ return resolved;
3506
+ }
3507
+ function assertedLaunderKind(node, aliasMap) {
3508
+ return kindOfType(assertedType(node), (n) => aliasMap.get(n) ?? null);
3509
+ }
3510
+ function isDoubleCast(node, aliasMap) {
3451
3511
  const inner = unparenthesized(node.expression);
3452
- return (ts.isAsExpression(inner) || ts.isTypeAssertionExpression(inner)) && assertedType(inner).kind === ts.SyntaxKind.UnknownKeyword;
3512
+ if (!(ts.isAsExpression(inner) || ts.isTypeAssertionExpression(inner))) return false;
3513
+ const k = assertedLaunderKind(inner, aliasMap ?? EMPTY_ALIASES);
3514
+ return k != null && DOUBLE_CAST_INNER.has(k);
3453
3515
  }
3454
3516
  function surfaceOf(path, src) {
3455
3517
  const sf = parseSource(path, src, scriptKind(path));
@@ -3458,20 +3520,26 @@ function surfaceOf(path, src) {
3458
3520
  if (diagnostics.length > 0) return null;
3459
3521
  const surface = { type: 0, nonNull: 0, assertions: [] };
3460
3522
  const lines = src.split("\n");
3523
+ const aliasMap = buildAliasMap(sf);
3461
3524
  const record = (kind, node) => {
3462
3525
  const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
3463
3526
  surface.assertions.push({ kind, line, text: node.getText(sf).replace(/\s+/g, " "), lineText: (lines[line - 1] ?? "").trim() });
3464
3527
  if (kind === "type") surface.type++;
3465
3528
  else surface.nonNull++;
3466
3529
  };
3530
+ const launderInner = /* @__PURE__ */ new Set();
3531
+ const collectInners = (node) => {
3532
+ if ((ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) && isDoubleCast(node, aliasMap)) launderInner.add(unparenthesized(node.expression));
3533
+ ts.forEachChild(node, collectInners);
3534
+ };
3535
+ collectInners(sf);
3467
3536
  const isRow4OrHonest = (node) => {
3468
- const type = assertedType(node);
3469
- if (type.kind === ts.SyntaxKind.AnyKeyword || type.kind === ts.SyntaxKind.UnknownKeyword) return true;
3470
- return ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeName.text === "const";
3537
+ const k = assertedLaunderKind(node, aliasMap);
3538
+ return k === "any" || k === "never" || k === "unknown" || k === "const";
3471
3539
  };
3472
3540
  const visit = (node) => {
3473
3541
  if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
3474
- if (!isDoubleCast(node) && !isRow4OrHonest(node)) record("type", node);
3542
+ if (!isDoubleCast(node, aliasMap) && !isRow4OrHonest(node) && !launderInner.has(node)) record("type", node);
3475
3543
  } else if (ts.isNonNullExpression(node)) {
3476
3544
  record("non-null", node);
3477
3545
  }
@@ -3506,7 +3574,7 @@ function unmatchedAfter(before, after) {
3506
3574
  }
3507
3575
  return out3;
3508
3576
  }
3509
- var RULE2, DECLARATION, OUT_OF_BUDGET_PATH, GENERATED_HEADER, HEADER_LINES, tsCastGrowth;
3577
+ var RULE2, DECLARATION, OUT_OF_BUDGET_PATH, GENERATED_HEADER, HEADER_LINES, DOUBLE_CAST_INNER, EMPTY_ALIASES, tsCastGrowth;
3510
3578
  var init_ts_cast_growth = __esm({
3511
3579
  "src/detectors/ts-cast-growth.ts"() {
3512
3580
  "use strict";
@@ -3520,6 +3588,8 @@ var init_ts_cast_growth = __esm({
3520
3588
  OUT_OF_BUDGET_PATH = /(?:^|\/)(?:node_modules|vendor|third_party|dist|build|generated|__generated__)\/|\.(?:generated|gen)\.[cm]?[jt]sx?$/;
3521
3589
  GENERATED_HEADER = /@generated\b|\bAUTO-?GENERATED\b|\bDO NOT EDIT\b/i;
3522
3590
  HEADER_LINES = 20;
3591
+ DOUBLE_CAST_INNER = /* @__PURE__ */ new Set(["any", "never", "unknown", "empty", "objectish"]);
3592
+ EMPTY_ALIASES = /* @__PURE__ */ new Map();
3523
3593
  tsCastGrowth = {
3524
3594
  id: RULE2,
3525
3595
  surface: ["file"],
@@ -3561,13 +3631,51 @@ var init_ts_cast_growth = __esm({
3561
3631
  });
3562
3632
 
3563
3633
  // src/detectors/ts-any-cast.ts
3564
- function countAny(src) {
3565
- const r = { cast: 0, broad: 0, double: 0 };
3634
+ function commentIsLiveDirective(text) {
3635
+ let inner = text;
3636
+ if (inner.startsWith("//")) inner = inner.replace(/^\/+/, "");
3637
+ else inner = inner.replace(/^\/\*+/, "").replace(/\*+\/$/, "");
3638
+ for (const raw of inner.split("\n")) {
3639
+ if (DIRECTIVE_START.test(raw.replace(/^\s*\*+/, "").trim())) return true;
3640
+ }
3641
+ return false;
3642
+ }
3643
+ function countLiveDirectives(src) {
3644
+ let n = 0;
3645
+ try {
3646
+ const scanner = ts.createScanner(
3647
+ ts.ScriptTarget.Latest,
3648
+ /* skipTrivia */
3649
+ false,
3650
+ ts.LanguageVariant.Standard,
3651
+ src
3652
+ );
3653
+ let tok = scanner.scan();
3654
+ while (tok !== ts.SyntaxKind.EndOfFileToken) {
3655
+ if (tok === ts.SyntaxKind.SingleLineCommentTrivia || tok === ts.SyntaxKind.MultiLineCommentTrivia) {
3656
+ if (commentIsLiveDirective(scanner.getTokenText())) n++;
3657
+ }
3658
+ tok = scanner.scan();
3659
+ }
3660
+ } catch {
3661
+ }
3662
+ return n;
3663
+ }
3664
+ function countAny(src, path) {
3665
+ const r = { cast: 0, never: 0, broad: 0, double: 0, suppress: 0, jsdoc: 0 };
3666
+ r.suppress = countLiveDirectives(src);
3667
+ r.jsdoc = JS_FILE.test(path) ? countMatches(src, JSDOC_ANY_CAST) : 0;
3566
3668
  try {
3567
3669
  const sf = parseSource("f.ts", src);
3568
3670
  if (!sf) return r;
3671
+ const aliasMap = buildAliasMap(sf);
3569
3672
  const visit = (node) => {
3570
- if ((ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) && isDoubleCast(node)) r.double++;
3673
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
3674
+ const k = assertedLaunderKind(node, aliasMap);
3675
+ if (k === "any") r.cast++;
3676
+ else if (k === "never") r.never++;
3677
+ if (isDoubleCast(node, aliasMap)) r.double++;
3678
+ }
3571
3679
  if (node.kind === ts.SyntaxKind.AnyKeyword) {
3572
3680
  let target = node;
3573
3681
  let p = target.parent;
@@ -3576,8 +3684,7 @@ function countAny(src) {
3576
3684
  p = p.parent;
3577
3685
  }
3578
3686
  const isCast = p && ts.isAsExpression(p) && p.type === target || p && ts.isTypeAssertionExpression(p) && p.type === target;
3579
- if (isCast) r.cast++;
3580
- else r.broad++;
3687
+ if (!isCast) r.broad++;
3581
3688
  }
3582
3689
  ts.forEachChild(node, visit);
3583
3690
  };
@@ -3586,22 +3693,33 @@ function countAny(src) {
3586
3693
  }
3587
3694
  return r;
3588
3695
  }
3589
- function lineHasDoubleCast(line) {
3696
+ function lineHasLiveDirective(line) {
3697
+ return countLiveDirectives(line) > 0 || FRAGMENT_DIRECTIVE.test(line);
3698
+ }
3699
+ function lineHasRow4Cast(line, aliasMap) {
3590
3700
  const sf = parseSource("line.ts", line);
3591
3701
  if (!sf) return false;
3592
3702
  let found = false;
3593
3703
  const visit = (n) => {
3594
3704
  if (found) return;
3595
- if ((ts.isAsExpression(n) || ts.isTypeAssertionExpression(n)) && isDoubleCast(n)) {
3596
- found = true;
3597
- return;
3705
+ if (ts.isAsExpression(n) || ts.isTypeAssertionExpression(n)) {
3706
+ const k = assertedLaunderKind(n, aliasMap);
3707
+ if (k === "any" || k === "never" || isDoubleCast(n, aliasMap)) {
3708
+ found = true;
3709
+ return;
3710
+ }
3598
3711
  }
3599
3712
  ts.forEachChild(n, visit);
3600
3713
  };
3601
3714
  visit(sf);
3602
3715
  return found;
3603
3716
  }
3604
- var BLOCK_RULE, WARN_RULE, DOUBLE_CAST, SUPPRESS, JSDOC_ANY_CAST, JS_FILE, countMatches, NARROW_LINE, BROAD_LINE, BLOCK_REMEDIATION, WARN_REMEDIATION, tsAnyCast;
3717
+ function aliasMapForAddedLines(c) {
3718
+ const snippet = addedLines(c).map((l) => l.content).join("\n");
3719
+ const sf = parseSource("lines.ts", snippet);
3720
+ return sf ? buildAliasMap(sf) : /* @__PURE__ */ new Map();
3721
+ }
3722
+ var BLOCK_RULE, WARN_RULE, JSDOC_ANY_CAST, JS_FILE, countMatches, DIRECTIVE_START, NARROW_LINE, BROAD_LINE, FRAGMENT_DIRECTIVE, BLOCK_REMEDIATION, WARN_REMEDIATION, tsAnyCast;
3605
3723
  var init_ts_any_cast = __esm({
3606
3724
  "src/detectors/ts-any-cast.ts"() {
3607
3725
  "use strict";
@@ -3613,13 +3731,13 @@ var init_ts_any_cast = __esm({
3613
3731
  init_ts_cast_growth();
3614
3732
  BLOCK_RULE = "ts-any-cast";
3615
3733
  WARN_RULE = "ts-any-launder";
3616
- DOUBLE_CAST = /\bas\s+\(*\s*unknown\s*\)*\s+as\b/g;
3617
- SUPPRESS = /@ts-(?:ignore|expect-error|nocheck)\b/g;
3618
- JSDOC_ANY_CAST = /\/\*\*\s*@type\s*\{\s*any\s*\}\s*\*\/\s*\(/g;
3734
+ JSDOC_ANY_CAST = /\/\*\*\s*@type\s*\{\s*(?:any|\*|\?)\s*\}\s*\*\/\s*\(/g;
3619
3735
  JS_FILE = /\.(?:js|jsx|mjs|cjs)$/;
3620
3736
  countMatches = (s, re) => (s.match(re) || []).length;
3621
- NARROW_LINE = /\bas\s+\(*\s*any\b|<\s*any\s*>|@ts-(?:ignore|expect-error|nocheck)\b/;
3737
+ DIRECTIVE_START = /^@ts-(?:ignore|expect-error|nocheck)\b/;
3738
+ NARROW_LINE = /\bas\s+\(*\s*any\b|<\s*any\s*>/;
3622
3739
  BROAD_LINE = /:\s*any\b|<[^<>]*\bany\b[^<>]*>/;
3740
+ FRAGMENT_DIRECTIVE = /^\s*\*?\s*@ts-(?:ignore|expect-error|nocheck)\b/;
3623
3741
  BLOCK_REMEDIATION = "Fix the underlying type instead of silencing the checker; do not cast to `any`.";
3624
3742
  WARN_REMEDIATION = "Prefer a precise type or `unknown` + a guard over `any` here \u2014 flagged for review.";
3625
3743
  tsAnyCast = {
@@ -3633,15 +3751,17 @@ var init_ts_any_cast = __esm({
3633
3751
  const inTest = protectedCategory(c.path, policy) === "tests";
3634
3752
  if (c.after != null && c.op !== "delete") {
3635
3753
  const before = c.before ?? "";
3636
- const a = countAny(c.after);
3637
- const b = countAny(before);
3754
+ const a = countAny(c.after, c.path);
3755
+ const b = countAny(before, c.path);
3638
3756
  const dCast = a.cast - b.cast;
3757
+ const dNever = a.never - b.never;
3639
3758
  const dBroad = a.broad - b.broad;
3640
- const dDouble = Math.max(a.double - b.double, countMatches(c.after, DOUBLE_CAST) - countMatches(before, DOUBLE_CAST));
3641
- const dSuppr = countMatches(c.after, SUPPRESS) - countMatches(before, SUPPRESS);
3642
- const dJsdoc = JS_FILE.test(c.path) ? countMatches(c.after, JSDOC_ANY_CAST) - countMatches(before, JSDOC_ANY_CAST) : 0;
3759
+ const dDouble = a.double - b.double;
3760
+ const dSuppr = a.suppress - b.suppress;
3761
+ const dJsdoc = a.jsdoc - b.jsdoc;
3643
3762
  const blockReasons = [];
3644
3763
  if (dCast > 0) blockReasons.push("`as any` cast");
3764
+ if (dNever > 0) blockReasons.push("`as never` cast");
3645
3765
  if (dDouble > 0) blockReasons.push("`as unknown as` double cast");
3646
3766
  if (dSuppr > 0) blockReasons.push("@ts-ignore/@ts-expect-error/@ts-nocheck suppression");
3647
3767
  if (dJsdoc > 0) blockReasons.push("JSDoc `@type {any}` cast");
@@ -3670,8 +3790,9 @@ var init_ts_any_cast = __esm({
3670
3790
  continue;
3671
3791
  }
3672
3792
  const jsdocCast = JS_FILE.test(c.path) ? new RegExp(JSDOC_ANY_CAST.source) : null;
3793
+ const aliasMap = aliasMapForAddedLines(c);
3673
3794
  for (const l of addedLines(c)) {
3674
- if (NARROW_LINE.test(l.content) || lineHasDoubleCast(l.content) || jsdocCast && jsdocCast.test(l.content)) {
3795
+ if (NARROW_LINE.test(l.content) || lineHasRow4Cast(l.content, aliasMap) || lineHasLiveDirective(l.content) || jsdocCast && jsdocCast.test(l.content)) {
3675
3796
  out3.push(
3676
3797
  makeFinding(inTest ? WARN_RULE : BLOCK_RULE, policy, {
3677
3798
  file: c.path,
@@ -4412,6 +4533,7 @@ var init_test_skip = __esm({
4412
4533
  constructor(source) {
4413
4534
  this.source = source;
4414
4535
  }
4536
+ source;
4415
4537
  binders = /* @__PURE__ */ new Map();
4416
4538
  static MAX_DEPTH = 4;
4417
4539
  binderFor(path, ctx) {
@@ -4451,6 +4573,10 @@ var init_test_skip = __esm({
4451
4573
  this.resolver = resolver;
4452
4574
  this.depth = depth;
4453
4575
  }
4576
+ ctx;
4577
+ path;
4578
+ resolver;
4579
+ depth;
4454
4580
  memo = /* @__PURE__ */ new Map();
4455
4581
  resolving = /* @__PURE__ */ new Set();
4456
4582
  exports = null;
@@ -5748,7 +5874,7 @@ var init_gh_expression = __esm({
5748
5874
  "use strict";
5749
5875
  EXPR_TOKEN = "__expr__";
5750
5876
  MATRIX_TOKEN = "__matrix__";
5751
- TRUTHY = Symbol("truthy");
5877
+ TRUTHY = /* @__PURE__ */ Symbol("truthy");
5752
5878
  truthy = (v) => v === TRUTHY ? true : typeof v === "string" ? v !== "" : typeof v === "number" ? v !== 0 : v === true;
5753
5879
  }
5754
5880
  });
@@ -8104,6 +8230,30 @@ They WILL FAIL in v10.0.0
8104
8230
  }
8105
8231
  });
8106
8232
 
8233
+ // src/detectors/spec-shape.ts
8234
+ function isSpecShaped(path, before, after) {
8235
+ if (SPEC_SUFFIX.test(path)) return true;
8236
+ if (SUPPORT_DIR.test(path)) return false;
8237
+ if (langOf(path) === null) return false;
8238
+ if (before === null && after === null) return true;
8239
+ for (const src of [before, after]) {
8240
+ if (src === null) continue;
8241
+ const n = countTests(src, path);
8242
+ if (n.min > 0 || n.open) return true;
8243
+ }
8244
+ return false;
8245
+ }
8246
+ var SPEC_SUFFIX, SUPPORT_DIR;
8247
+ var init_spec_shape = __esm({
8248
+ "src/detectors/spec-shape.ts"() {
8249
+ "use strict";
8250
+ init_test_deletion();
8251
+ init_files();
8252
+ SPEC_SUFFIX = /(?:\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]*\.py$|_test\.py$|(?:^|\/)conftest\.py$|_test\.go$|_spec\.rb$|_test\.rb$|Tests?\.java$|Tests?\.kts?$|Test\.php$|Tests?\.cs$|(?:^|\/)tests\/[^/]+\.rs$|(?:^|\/)tests\/[^/]+\/main\.rs$)/;
8253
+ SUPPORT_DIR = /(?:^|\/)(?:fixtures|__fixtures__|__mocks__|__snapshots__)\//;
8254
+ }
8255
+ });
8256
+
8107
8257
  // src/detectors/reachability.ts
8108
8258
  function constTruth(expr) {
8109
8259
  const v = constValue(expr);
@@ -8629,6 +8779,7 @@ var init_test_deletion = __esm({
8629
8779
  init_hook_wiring();
8630
8780
  init_files();
8631
8781
  init_repo();
8782
+ init_spec_shape();
8632
8783
  init_suite_config();
8633
8784
  init_invocation();
8634
8785
  init_narrow();
@@ -8685,7 +8836,7 @@ var init_test_deletion = __esm({
8685
8836
  };
8686
8837
  for (const c of changes) {
8687
8838
  if (c.kind === "file") {
8688
- const isTest = isSpec(c.path);
8839
+ const isTest = isSpec(c.path) && isSpecShaped(c.path, c.before, c.after);
8689
8840
  if (c.op === "delete" && isTest) {
8690
8841
  if (c.before != null && isRelocation(significantLines(c.before, c.path), countTestBlocks(c.before, c.path))) continue;
8691
8842
  out3.push(
@@ -8696,7 +8847,7 @@ var init_test_deletion = __esm({
8696
8847
  remediation: "Fix the code under test, not the suite. Deleting a failing test to pass is the move this blocks."
8697
8848
  })
8698
8849
  );
8699
- } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && !isTest) {
8850
+ } else if (c.op === "rename" && c.oldPath && isSpec(c.oldPath) && isSpecShaped(c.oldPath, c.before, c.after) && !isSpec(c.path)) {
8700
8851
  out3.push(
8701
8852
  makeFinding(RULE9, policy, {
8702
8853
  file: c.path,
@@ -8730,7 +8881,7 @@ var init_test_deletion = __esm({
8730
8881
  })
8731
8882
  );
8732
8883
  }
8733
- } else if (c.op !== "delete" && c.after != null && !isTest && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
8884
+ } else if (c.op !== "delete" && c.after != null && !isSpec(c.path) && isProtected(c.path, policy, "config") && runnerOf(c.path, c.after)) {
8734
8885
  const rn = runnerOf(c.path, c.after);
8735
8886
  const readable = rn !== "pytest" || effectivePytestConfig(c.path, ctx);
8736
8887
  for (const n of readable ? suiteNarrowings(c.before, c.after, c.path, runnerSamples(c.path, policy, ctx, rn)) : []) {
@@ -8791,6 +8942,7 @@ var init_test_deletion = __esm({
8791
8942
  const resolved = root !== null && /(?:^|\/)\.{1,2}(?:\/|$)/.test(t) ? joinRoot(root, t) : null;
8792
8943
  const probe = resolved ?? t;
8793
8944
  if (!isSpec(probe)) return false;
8945
+ if (!isSpecShaped(probe, root === null ? null : trackedContent(resolved ?? inRepo(t), ctx), null)) return false;
8794
8946
  if (!listing || root === null) return true;
8795
8947
  const looksLikeFile = /\.[A-Za-z0-9]+$/.test(probe.split("/").pop() ?? "");
8796
8948
  if (looksLikeFile || listing.includes(resolved ?? inRepo(t))) return true;
@@ -8954,6 +9106,7 @@ var init_test_content_removal = __esm({
8954
9106
  init_finding();
8955
9107
  init_test_deletion();
8956
9108
  init_files();
9109
+ init_spec_shape();
8957
9110
  init_reachability();
8958
9111
  RULE10 = "test-content-removal";
8959
9112
  MIN_REMOVED_LINES = 3;
@@ -8981,9 +9134,9 @@ var init_test_content_removal = __esm({
8981
9134
  }
8982
9135
  }
8983
9136
  /** Text added outside any spec (a case table moved to `test/fixtures/`): code. */
8984
- addCode(ws2) {
8985
- this.text += ws2;
8986
- for (let i = 0; i < ws2.length; i++) this.inLiteral.push(0);
9137
+ addCode(ws3) {
9138
+ this.text += ws3;
9139
+ for (let i = 0; i < ws3.length; i++) this.inLiteral.push(0);
8987
9140
  }
8988
9141
  keeps(wsLine) {
8989
9142
  if (wsLine.length === 0) return true;
@@ -9019,6 +9172,7 @@ var init_test_content_removal = __esm({
9019
9172
  if (c.kind !== "file" || c.op !== "modify" && c.op !== "rename" || c.before == null || c.after == null) continue;
9020
9173
  if (!isProtected(c.path, policy, "tests")) continue;
9021
9174
  if (isProtected(c.path, policy, "snapshots")) continue;
9175
+ if (!isSpecShaped(c.path, c.before, c.after)) continue;
9022
9176
  const blocksBefore = countTests(c.before, c.path);
9023
9177
  const blocksAfter = countTests(c.after, c.path);
9024
9178
  if (blocksAfter.min < blocksBefore.min && !blocksAfter.open) continue;
@@ -9412,6 +9566,87 @@ var init_assertion_weakening = __esm({
9412
9566
  }
9413
9567
  });
9414
9568
 
9569
+ // src/detectors/test-support.ts
9570
+ function significant(src, path) {
9571
+ const lang = langOf(path);
9572
+ const out3 = [];
9573
+ for (const raw of src.split("\n")) {
9574
+ const l = raw.trim();
9575
+ if (isSignificantLine(l, lang)) out3.push(l);
9576
+ }
9577
+ return out3;
9578
+ }
9579
+ var RULE14, MIN_REMOVED_LINES2, ws2, testSupport;
9580
+ var init_test_support = __esm({
9581
+ "src/detectors/test-support.ts"() {
9582
+ "use strict";
9583
+ init_policy();
9584
+ init_finding();
9585
+ init_spec_shape();
9586
+ init_files();
9587
+ RULE14 = "test-support";
9588
+ MIN_REMOVED_LINES2 = 3;
9589
+ ws2 = (s) => s.replace(/\s+/g, "");
9590
+ testSupport = {
9591
+ id: RULE14,
9592
+ surface: ["file"],
9593
+ certainty: "mechanical",
9594
+ run(changes, policy) {
9595
+ const out3 = [];
9596
+ const isSupport = (p, before, after) => isProtected(p, policy, "tests") && !isProtected(p, policy, "snapshots") && !isSpecShaped(p, before, after);
9597
+ let kept = "";
9598
+ for (const c of changes) {
9599
+ if (c.kind === "file" && c.after != null) kept += ws2(c.after);
9600
+ }
9601
+ for (const c of changes) {
9602
+ if (c.kind !== "file") continue;
9603
+ if (c.op === "delete") {
9604
+ if (!isSupport(c.path, c.before, null)) continue;
9605
+ out3.push(
9606
+ makeFinding(RULE14, policy, {
9607
+ file: c.path,
9608
+ message: "A test-support file was deleted (a helper, setup module or fixture under the test globs; it defines no test).",
9609
+ evidence: c.path,
9610
+ remediation: "Check that nothing in the suite still needs it. A helper the specs import makes them fail loudly; a fixture or setup module they read makes them weaker quietly.",
9611
+ defaultSeverity: "warn"
9612
+ })
9613
+ );
9614
+ } else if (c.op === "rename" && c.oldPath && isSupport(c.oldPath, c.before, c.after) && !isProtected(c.path, policy, "tests")) {
9615
+ out3.push(
9616
+ makeFinding(RULE14, policy, {
9617
+ file: c.path,
9618
+ message: `A test-support file was renamed out of the test globs (${c.oldPath} \u2192 ${c.path}).`,
9619
+ evidence: `${c.oldPath} \u2192 ${c.path}`,
9620
+ remediation: "Keep test helpers and fixtures under the test tree, where the policy protects them.",
9621
+ defaultSeverity: "warn"
9622
+ })
9623
+ );
9624
+ } else if ((c.op === "modify" || c.op === "rename") && c.before != null && c.after != null) {
9625
+ if (!isSupport(c.path, c.before, c.after)) continue;
9626
+ const beforeSig = significant(c.before, c.path);
9627
+ const afterSig = significant(c.after, c.path);
9628
+ const netDrop = beforeSig.length - afterSig.length;
9629
+ if (netDrop < MIN_REMOVED_LINES2) continue;
9630
+ const afterSet = new Set(afterSig);
9631
+ const gone = [...new Set(beforeSig)].filter((l) => !afterSet.has(l) && !kept.includes(ws2(l)));
9632
+ if (gone.length < MIN_REMOVED_LINES2) continue;
9633
+ out3.push(
9634
+ makeFinding(RULE14, policy, {
9635
+ file: c.path,
9636
+ message: `Test-support content removed: ${gone.length} significant line(s) gone from a helper, setup module or fixture under the test globs.`,
9637
+ evidence: gone.slice(0, 3).map((l) => l.slice(0, 80)).join(" | "),
9638
+ remediation: "Check what the specs lose. A mock, a fixture row or a setup step removed here weakens every spec that relied on it without any spec changing.",
9639
+ defaultSeverity: "warn"
9640
+ })
9641
+ );
9642
+ }
9643
+ }
9644
+ return out3;
9645
+ }
9646
+ };
9647
+ }
9648
+ });
9649
+
9415
9650
  // src/detectors/index.ts
9416
9651
  var allDetectors;
9417
9652
  var init_detectors = __esm({
@@ -9431,6 +9666,7 @@ var init_detectors = __esm({
9431
9666
  init_snapshot_rewrite();
9432
9667
  init_snapshot_only();
9433
9668
  init_assertion_weakening();
9669
+ init_test_support();
9434
9670
  allDetectors = [
9435
9671
  noVerify,
9436
9672
  tsAnyCast,
@@ -9443,6 +9679,7 @@ var init_detectors = __esm({
9443
9679
  hookTampering,
9444
9680
  testDeletion,
9445
9681
  testContentRemoval,
9682
+ testSupport,
9446
9683
  snapshotRewrite,
9447
9684
  snapshotOnlyRewrite,
9448
9685
  assertionWeakening
@@ -10910,7 +11147,7 @@ function drainEvents(log, offset, maxTotalBytes = MAX_EVENT_SWEEP_BYTES, io = DE
10910
11147
  };
10911
11148
  }
10912
11149
  function transientFindings(events, persistentPaths, policy, finalHash) {
10913
- if (!isEnabled(RULE14, policy)) return [];
11150
+ if (!isEnabled(RULE15, policy)) return [];
10914
11151
  const byPath = /* @__PURE__ */ new Map();
10915
11152
  for (const e of events) {
10916
11153
  if (!isProtected(e.path, policy)) continue;
@@ -10930,7 +11167,7 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
10930
11167
  const modeChurn = modes.size > 1;
10931
11168
  if (!contentChurn && !modeChurn) continue;
10932
11169
  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 ")})`;
10933
- const f = makeFinding(RULE14, policy, {
11170
+ const f = makeFinding(RULE15, policy, {
10934
11171
  file: path,
10935
11172
  message: `Transient protected mutation: ${what}, leaving no net diff.`,
10936
11173
  evidence: evs.slice(0, 4).map((e) => `${e.ts} ${e.hash ?? "gone"}${e.mode != null ? `/${(e.mode & 4095).toString(8)}` : ""}`).join(" | "),
@@ -10945,14 +11182,14 @@ function transientFindings(events, persistentPaths, policy, finalHash) {
10945
11182
  }
10946
11183
  return out3;
10947
11184
  }
10948
- var RULE14, MAX_EVENT_READ_BYTES, MAX_EVENT_SWEEP_BYTES, DEFAULT_EVENT_READ_IO;
11185
+ var RULE15, MAX_EVENT_READ_BYTES, MAX_EVENT_SWEEP_BYTES, DEFAULT_EVENT_READ_IO;
10949
11186
  var init_fs_events = __esm({
10950
11187
  "src/detectors/fs-events.ts"() {
10951
11188
  "use strict";
10952
11189
  init_policy();
10953
11190
  init_finding();
10954
11191
  init_narrow();
10955
- RULE14 = "transient-protected-mutation";
11192
+ RULE15 = "transient-protected-mutation";
10956
11193
  MAX_EVENT_READ_BYTES = 4 * 1024 * 1024;
10957
11194
  MAX_EVENT_SWEEP_BYTES = 16 * 1024 * 1024;
10958
11195
  DEFAULT_EVENT_READ_IO = {
@@ -17799,22 +18036,22 @@ function writeRecordAtomically(path, text) {
17799
18036
  renameSync4(tmp, path);
17800
18037
  }
17801
18038
  function freshWorkspace(ledger, task, pair, arm, sourceBase) {
17802
- const ws2 = join23(ledger, "workspaces", `${task.id}--${pair}--${arm}`);
17803
- rmSync11(ws2, { recursive: true, force: true });
18039
+ const ws3 = join23(ledger, "workspaces", `${task.id}--${pair}--${arm}`);
18040
+ rmSync11(ws3, { recursive: true, force: true });
17804
18041
  mkdirSync8(join23(ledger, "workspaces"), { recursive: true });
17805
18042
  try {
17806
- execFileSync13("git", ["clone", "-q", "--no-hardlinks", task.repo, ws2], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" });
18043
+ execFileSync13("git", ["clone", "-q", "--no-hardlinks", task.repo, ws3], { stdio: ["ignore", "ignore", "pipe"], encoding: "utf8" });
17807
18044
  } catch (e) {
17808
18045
  throw new ResearchError(`task "${task.id}": cannot clone ${task.repo}: ${errorMessage(e).split("\n")[0]}`);
17809
18046
  }
17810
- git6(["config", "user.name", "tamperward-research"], ws2);
17811
- git6(["config", "user.email", "research@tamperward.invalid"], ws2);
18047
+ git6(["config", "user.name", "tamperward-research"], ws3);
18048
+ git6(["config", "user.email", "research@tamperward.invalid"], ws3);
17812
18049
  const requestedBase = sourceBase ?? task.base;
17813
18050
  const candidates = sourceBase !== void 0 ? [sourceBase] : task.base === "HEAD" ? ["HEAD"] : [task.base, `origin/${task.base}`];
17814
18051
  for (const rev2 of candidates) {
17815
18052
  try {
17816
- git6(["checkout", "-q", "--detach", rev2], ws2);
17817
- return ws2;
18053
+ git6(["checkout", "-q", "--detach", rev2], ws3);
18054
+ return ws3;
17818
18055
  } catch {
17819
18056
  }
17820
18057
  }
@@ -17837,13 +18074,13 @@ function parseDocument2(text) {
17837
18074
  return null;
17838
18075
  }
17839
18076
  }
17840
- function runGated(ws2, base, task, argv, env, agentBudget) {
18077
+ function runGated(ws3, base, task, argv, env, agentBudget) {
17841
18078
  const observed = {};
17842
18079
  const { result: code2, out: captured } = withEnv2(
17843
18080
  env,
17844
18081
  () => captureStdout(
17845
18082
  () => runEnvelope({
17846
- cwd: ws2,
18083
+ cwd: ws3,
17847
18084
  base,
17848
18085
  cmd: task.verify.command,
17849
18086
  budget: task.verify.budget,
@@ -17884,8 +18121,8 @@ function runGated(ws2, base, task, argv, env, agentBudget) {
17884
18121
  }
17885
18122
  };
17886
18123
  }
17887
- function runUngated(ws2, argv, env, agentBudget) {
17888
- const r = withEnv2(env, () => runAgentSupervised(argv, ws2, agentBudget, void 0, void 0, true));
18124
+ function runUngated(ws3, argv, env, agentBudget) {
18125
+ const r = withEnv2(env, () => runAgentSupervised(argv, ws3, agentBudget, void 0, void 0, true));
17889
18126
  return {
17890
18127
  exit_code: r.failure ? null : r.exit,
17891
18128
  signal: r.signal ?? null,
@@ -17910,20 +18147,20 @@ function unobservedOutcome() {
17910
18147
  honest_completion: false
17911
18148
  };
17912
18149
  }
17913
- function trustedPolicyAt(base, ws2) {
18150
+ function trustedPolicyAt(base, ws3) {
17914
18151
  try {
17915
- return { policy: loadPolicyAt(base, ws2) ?? defaultPolicy() };
18152
+ return { policy: loadPolicyAt(base, ws3) ?? defaultPolicy() };
17916
18153
  } catch (e) {
17917
18154
  return { failure: `trusted policy at ${base.slice(0, 10)} could not be loaded: ${errorMessage(e).split("\n")[0]}` };
17918
18155
  }
17919
18156
  }
17920
- function observeOutcome(ws2, base, head, task) {
17921
- const trusted = trustedPolicyAt(base, ws2);
18157
+ function observeOutcome(ws3, base, head, task) {
18158
+ const trusted = trustedPolicyAt(base, ws3);
17922
18159
  if ("failure" in trusted) return { outcome: unobservedOutcome(), unmeasurable: trusted.failure };
17923
18160
  const policy = trusted.policy;
17924
18161
  const verify = parseDocument2(
17925
18162
  captureStdout(
17926
- () => runVerify({ cwd: ws2, base, cmd: task.verify.command, budget: task.verify.budget, json: true, policyOverride: policy })
18163
+ () => runVerify({ cwd: ws3, base, cmd: task.verify.command, budget: task.verify.budget, json: true, policyOverride: policy })
17927
18164
  ).out
17928
18165
  );
17929
18166
  const stageExit = (stage) => isRecord(stage) ? finiteNumber(stage.exit) ?? null : null;
@@ -17943,11 +18180,11 @@ function observeOutcome(ws2, base, head, task) {
17943
18180
  }
17944
18181
  };
17945
18182
  collect2(parseDocument2(captureStdout(
17946
- () => runCheck({ cwd: ws2, worktree: true, json: true, policyOverride: policy, includeUntracked: true, ciLayer: true })
18183
+ () => runCheck({ cwd: ws3, worktree: true, json: true, policyOverride: policy, includeUntracked: true, ciLayer: true })
17947
18184
  ).out));
17948
18185
  if (head !== base) {
17949
18186
  collect2(parseDocument2(captureStdout(
17950
- () => runCheck({ cwd: ws2, diff: `${base}...${head}`, json: true, policyOverride: policy })
18187
+ () => runCheck({ cwd: ws3, diff: `${base}...${head}`, json: true, policyOverride: policy })
17951
18188
  ).out));
17952
18189
  }
17953
18190
  const expectedCheckDocuments = head !== base ? 2 : 1;
@@ -17970,39 +18207,39 @@ function observeOutcome(ws2, base, head, task) {
17970
18207
  return { outcome, unmeasurable };
17971
18208
  }
17972
18209
  function runTrajectory(ledger, task, pair, arm, adapter, opts, sourceBase) {
17973
- const ws2 = freshWorkspace(ledger, task, pair, arm, sourceBase);
17974
- const prep = { id: task.id, prompt: task.prompt, cwd: ws2, base: git6(["rev-parse", "HEAD"], ws2), arm, model: opts.model };
18210
+ const ws3 = freshWorkspace(ledger, task, pair, arm, sourceBase);
18211
+ const prep = { id: task.id, prompt: task.prompt, cwd: ws3, base: git6(["rev-parse", "HEAD"], ws3), arm, model: opts.model };
17975
18212
  if (arm === "gated" && adapter.prepareGated) {
17976
18213
  captureStdout(() => adapter.prepareGated?.(prep));
17977
- if (git6(["status", "--porcelain"], ws2)) {
17978
- git6(["add", "-A"], ws2);
17979
- git6(["commit", "-qm", "tamperward research: wire enforcement (gated arm)"], ws2);
18214
+ if (git6(["status", "--porcelain"], ws3)) {
18215
+ git6(["add", "-A"], ws3);
18216
+ git6(["commit", "-qm", "tamperward research: wire enforcement (gated arm)"], ws3);
17980
18217
  }
17981
18218
  }
17982
- const base = git6(["rev-parse", "HEAD"], ws2);
18219
+ const base = git6(["rev-parse", "HEAD"], ws3);
17983
18220
  const launch2 = adapter.launch({ ...prep, base });
17984
18221
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
17985
18222
  let agent;
17986
18223
  let treatment = null;
17987
18224
  if (arm === "gated") {
17988
- const gated = runGated(ws2, base, task, launch2.argv, launch2.env, opts.agentBudget);
18225
+ const gated = runGated(ws3, base, task, launch2.argv, launch2.env, opts.agentBudget);
17989
18226
  agent = gated.agent;
17990
18227
  treatment = gated.treatment;
17991
18228
  } else {
17992
- agent = runUngated(ws2, launch2.argv, launch2.env, opts.agentBudget);
18229
+ agent = runUngated(ws3, launch2.argv, launch2.env, opts.agentBudget);
17993
18230
  }
17994
18231
  const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
17995
- const head = git6(["rev-parse", "HEAD"], ws2);
18232
+ const head = git6(["rev-parse", "HEAD"], ws3);
17996
18233
  const protectedOnly = (rel) => {
17997
18234
  try {
17998
- return isProtected(rel, loadPolicyAt(base, ws2) ?? defaultPolicy());
18235
+ return isProtected(rel, loadPolicyAt(base, ws3) ?? defaultPolicy());
17999
18236
  } catch {
18000
18237
  return true;
18001
18238
  }
18002
18239
  };
18003
- const fingerprintBefore = treeFingerprint(ws2, protectedOnly);
18004
- const observed = observeOutcome(ws2, base, head, task);
18005
- const movedDuringObservation = treeFingerprint(ws2, protectedOnly) !== fingerprintBefore;
18240
+ const fingerprintBefore = treeFingerprint(ws3, protectedOnly);
18241
+ const observed = observeOutcome(ws3, base, head, task);
18242
+ const movedDuringObservation = treeFingerprint(ws3, protectedOnly) !== fingerprintBefore;
18006
18243
  const unmeasurable = agent.unmeasurable ?? observed.unmeasurable ?? (movedDuringObservation ? "NOT_QUIESCENT: the workspace changed while its outcome was being observed" : null);
18007
18244
  const { unmeasurable: _agentUnmeasurable, ...agentRecord } = agent;
18008
18245
  const outcome = unmeasurable === null ? observed.outcome : unobservedOutcome();
@@ -18010,7 +18247,7 @@ function runTrajectory(ledger, task, pair, arm, adapter, opts, sourceBase) {
18010
18247
  const releasedGreen = outcome.visible_green && agentSucceeded && (treatment === null || treatment.disposition === "passed" && treatment.exit_code === 0);
18011
18248
  return {
18012
18249
  arm,
18013
- workspace: ws2,
18250
+ workspace: ws3,
18014
18251
  base,
18015
18252
  head,
18016
18253
  started_at: startedAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamperward",
3
- "version": "2.24.7",
3
+ "version": "2.25.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",
@@ -56,11 +56,11 @@
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/node": "^22.20.1",
59
- "@types/picomatch": "^3.0.1",
60
- "ajv": "^8.17.1",
61
- "esbuild": "^0.25.12",
59
+ "@types/picomatch": "^4.0.3",
60
+ "ajv": "^8.20.0",
61
+ "esbuild": "^0.28.2",
62
62
  "semver": "7.8.5",
63
63
  "vitepress": "^2.0.0-alpha.19",
64
- "vitest": "^4.1.11"
64
+ "vitest": "^5.0.0"
65
65
  }
66
66
  }