instar 1.3.1153 → 1.3.1155

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.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1153",
5
+ "packageVersion": "1.3.1155",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "c9fe9271930f52c79239f9b43e4d3c873717dc1dd98744525290c8ccba4f07bf",
2
+ "sha256": "4768f67585d114ee7064f8a97895cdf66f96074719ad149a661bcee76e79625b",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1153"
4
+ "packageVersion": "1.3.1155"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1153"
5
+ "packageVersion": "1.3.1155"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1153",
3
+ "version": "1.3.1155",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,6 +23,9 @@
23
23
  * Exit codes:
24
24
  * 0 — no violations.
25
25
  * 1 — at least one violation.
26
+ * 2 — could not inspect: files were scanned and NONE parsed (an environment
27
+ * problem, e.g. a missing `typescript`). Distinct from 1 on purpose —
28
+ * "I could not look" is not "I looked and found nothing".
26
29
  *
27
30
  * Usage:
28
31
  * node scripts/lint-no-direct-destructive.js # full repo
@@ -39,6 +42,14 @@ import { fileURLToPath } from 'node:url';
39
42
  // only when a TS file actually needs parsing.
40
43
  let _ts = null;
41
44
  function ts() {
45
+ // Test hook for the parsed-nothing refusal below. It can ONLY force the
46
+ // fail-closed path (every file fails to parse → the run refuses to report
47
+ // clean); there is no flag here that can make this lint pass something it
48
+ // would otherwise flag. Without it the refusal would be untested, which is
49
+ // the failure mode this whole guard exists to prevent.
50
+ if (process.env.INSTAR_LINT_FORCE_PARSE_FAILURE === '1') {
51
+ throw new Error('forced parse failure (INSTAR_LINT_FORCE_PARSE_FAILURE=1)');
52
+ }
42
53
  if (_ts) return _ts;
43
54
  _ts = require('typescript');
44
55
  return _ts;
@@ -304,7 +315,15 @@ function lintTsFile(file, text) {
304
315
  const T = ts();
305
316
  const sf = T.createSourceFile(file, text, T.ScriptTarget.Latest, true);
306
317
  const ctx = { text };
307
- const r = (f, l, c, m) => report(f, l, c, m, ctx);
318
+ // Declaration collection runs BEFORE reporting (see the collect/report loop at
319
+ // the foot of this function), so a binding declared after the function that
320
+ // uses it is still resolved. While collecting, findings are suppressed —
321
+ // otherwise every violation would be reported once per collection pass.
322
+ let collecting = true;
323
+ const r = (f, l, c, m) => {
324
+ if (collecting) return;
325
+ report(f, l, c, m, ctx);
326
+ };
308
327
 
309
328
  /** module name → local identifier (default + namespace + named) */
310
329
  const childProcessIdentifiers = new Set();
@@ -319,6 +338,34 @@ function lintTsFile(file, text) {
319
338
  return { line: lc.line + 1, col: lc.character + 1 };
320
339
  }
321
340
 
341
+ /**
342
+ * Strip syntax that changes nothing at runtime but hides an identifier from a
343
+ * structural check — parentheses, `as` assertions, `<T>` assertions and `!`.
344
+ * `(fs as any)['rmSync'](p)` performs exactly the same delete as `fs.rmSync(p)`;
345
+ * without this it reached the computed-access branch as a ParenthesizedExpression
346
+ * and the `isIdentifier` test failed, so the call was invisible.
347
+ */
348
+ /**
349
+ * Idempotent: collection now runs more than once (see the collect/report loop
350
+ * below), and an array push is the one collector here that is not naturally
351
+ * idempotent — repeated passes would duplicate entries and keep the binding
352
+ * count growing, so the fixpoint would never be reached.
353
+ */
354
+ function addSimpleGitImport(localName) {
355
+ if (!simpleGitImports.some((s2) => s2.localName === localName)) {
356
+ simpleGitImports.push({ localName });
357
+ }
358
+ }
359
+
360
+ function unwrap(node) {
361
+ let n = node;
362
+ while (n && (T.isParenthesizedExpression(n) || T.isAsExpression(n)
363
+ || T.isTypeAssertionExpression?.(n) || T.isNonNullExpression(n))) {
364
+ n = n.expression;
365
+ }
366
+ return n;
367
+ }
368
+
322
369
  function visit(node) {
323
370
  // ── Imports ────────────────────────────────────────────────
324
371
  if (T.isImportDeclaration(node) && node.moduleSpecifier && T.isStringLiteral(node.moduleSpecifier)) {
@@ -342,7 +389,7 @@ function lintTsFile(file, text) {
342
389
  } else if (FS_MODULE_NAMES.has(mod) && DESTRUCTIVE_FS_NAMES.has(importedName)) {
343
390
  fsNamedDestructiveImports.set(localName, importedName);
344
391
  } else if (mod === 'simple-git' && importedName === 'simpleGit') {
345
- simpleGitImports.push({ localName });
392
+ addSimpleGitImport(localName);
346
393
  }
347
394
  }
348
395
  }
@@ -355,7 +402,7 @@ function lintTsFile(file, text) {
355
402
  } else if (FS_MODULE_NAMES.has(mod)) {
356
403
  fsNamespaceIdentifiers.add(localName);
357
404
  } else if (mod === 'simple-git') {
358
- simpleGitImports.push({ localName });
405
+ addSimpleGitImport(localName);
359
406
  }
360
407
  }
361
408
  }
@@ -376,7 +423,7 @@ function lintTsFile(file, text) {
376
423
  fsNamespaceIdentifiers.add(localName);
377
424
  requireBindings.set(localName, mod);
378
425
  } else if (mod === 'simple-git') {
379
- simpleGitImports.push({ localName });
426
+ addSimpleGitImport(localName);
380
427
  }
381
428
  }
382
429
  // Destructured: const { execFileSync } = require('child_process')
@@ -392,7 +439,7 @@ function lintTsFile(file, text) {
392
439
  } else if (FS_MODULE_NAMES.has(mod) && DESTRUCTIVE_FS_NAMES.has(importedName)) {
393
440
  fsNamedDestructiveImports.set(localName, importedName);
394
441
  } else if (mod === 'simple-git' && importedName === 'simpleGit') {
395
- simpleGitImports.push({ localName });
442
+ addSimpleGitImport(localName);
396
443
  }
397
444
  }
398
445
  }
@@ -441,6 +488,83 @@ function lintTsFile(file, text) {
441
488
  }
442
489
  }
443
490
 
491
+ // ── Local re-bindings of a banned namespace or function ────
492
+ // The sets above were populated ONLY from imports/requires, so one line of
493
+ // ordinary tidying disabled the funnel for a whole file:
494
+ //
495
+ // const fsp = fs.promises; await fsp.rm(p, { recursive: true });
496
+ //
497
+ // Aliasing `fs.promises` to a short name is idiomatic JavaScript, not an
498
+ // evasion — and `fs.promises.rm` written out IS caught, so the difference
499
+ // between flagged and invisible was a variable. Resolving the binding feeds
500
+ // the EXISTING checks below rather than adding a parallel rule.
501
+ if (T.isVariableDeclaration(node) && node.initializer) {
502
+ const init = unwrap(node.initializer);
503
+
504
+ // `const X = <expr>` where X is a plain identifier.
505
+ if (T.isIdentifier(node.name)) {
506
+ const local = node.name.text;
507
+
508
+ // Whole-namespace alias: `const f = fs` / `const fsp = fs.promises`.
509
+ if (T.isIdentifier(init) && fsNamespaceIdentifiers.has(init.text)) {
510
+ fsNamespaceIdentifiers.add(local);
511
+ } else if (T.isIdentifier(init) && childProcessIdentifiers.has(init.text)) {
512
+ childProcessIdentifiers.add(local);
513
+ } else if (T.isPropertyAccessExpression(init)) {
514
+ const base = unwrap(init.expression);
515
+ const member = init.name.text;
516
+
517
+ // `const fsp = fs.promises` — still the fs namespace, so the member
518
+ // checks below apply to it unchanged.
519
+ if (T.isIdentifier(base) && fsNamespaceIdentifiers.has(base.text) && member === 'promises') {
520
+ fsNamespaceIdentifiers.add(local);
521
+ } else if (T.isIdentifier(base) && fsNamespaceIdentifiers.has(base.text)
522
+ && DESTRUCTIVE_FS_NAMES.has(member)) {
523
+ // `const del = fs.rmSync`
524
+ fsNamedDestructiveImports.set(local, member);
525
+ } else if (T.isIdentifier(base) && childProcessIdentifiers.has(base.text)
526
+ && CHILD_PROCESS_FNS.has(member)) {
527
+ // `const ex = cp.execFileSync`
528
+ childProcessNamedImports.set(local, member);
529
+ } else if (T.isPropertyAccessExpression(base) && T.isIdentifier(unwrap(base.expression))
530
+ && fsNamespaceIdentifiers.has(unwrap(base.expression).text)
531
+ && base.name.text === 'promises'
532
+ && DESTRUCTIVE_FS_NAMES.has(member)) {
533
+ // `const del = fs.promises.rm`
534
+ fsNamedDestructiveImports.set(local, member);
535
+ }
536
+ }
537
+ }
538
+
539
+ // `const { rmSync, unlink: del } = fs` / `... = fs.promises`
540
+ if (T.isObjectBindingPattern(node.name)) {
541
+ const init2 = unwrap(node.initializer);
542
+ let isFsSource = false;
543
+ let isCpSource = false;
544
+ if (T.isIdentifier(init2)) {
545
+ isFsSource = fsNamespaceIdentifiers.has(init2.text);
546
+ isCpSource = childProcessIdentifiers.has(init2.text);
547
+ } else if (T.isPropertyAccessExpression(init2) && init2.name.text === 'promises') {
548
+ const base = unwrap(init2.expression);
549
+ isFsSource = T.isIdentifier(base) && fsNamespaceIdentifiers.has(base.text);
550
+ }
551
+ if (isFsSource || isCpSource) {
552
+ for (const el of node.name.elements) {
553
+ if (!T.isBindingElement(el) || !T.isIdentifier(el.name)) continue;
554
+ const original = el.propertyName && T.isIdentifier(el.propertyName)
555
+ ? el.propertyName.text
556
+ : el.name.text;
557
+ const local = el.name.text;
558
+ if (isFsSource && DESTRUCTIVE_FS_NAMES.has(original)) {
559
+ fsNamedDestructiveImports.set(local, original);
560
+ } else if (isCpSource && CHILD_PROCESS_FNS.has(original)) {
561
+ childProcessNamedImports.set(local, original);
562
+ }
563
+ }
564
+ }
565
+ }
566
+ }
567
+
444
568
  // ── Member call on namespace identifier ────────────────────
445
569
  if (T.isCallExpression(node) && T.isPropertyAccessExpression(node.expression)) {
446
570
  const obj = node.expression.expression;
@@ -479,7 +603,7 @@ function lintTsFile(file, text) {
479
603
 
480
604
  // ── Dynamic / computed access: fs['rm' + 'Sync'](...) ──────
481
605
  if (T.isCallExpression(node) && T.isElementAccessExpression(node.expression)) {
482
- const obj = node.expression.expression;
606
+ const obj = unwrap(node.expression.expression);
483
607
  const arg = node.expression.argumentExpression;
484
608
  if (T.isIdentifier(obj) && fsNamespaceIdentifiers.has(obj.text)) {
485
609
  // Any computed member access on the fs namespace is suspicious;
@@ -513,6 +637,19 @@ function lintTsFile(file, text) {
513
637
  return false;
514
638
  }
515
639
 
640
+ // Collect, then report. Collection repeats until the binding sets stop
641
+ // growing, so a chain (`const a = fs; const b = a.promises`) resolves however
642
+ // it is ordered in the file, and a binding declared BELOW the function that
643
+ // uses it is still known. Bounded so a pathological file cannot spin.
644
+ const bindingCount = () =>
645
+ fsNamespaceIdentifiers.size + fsNamedDestructiveImports.size
646
+ + childProcessIdentifiers.size + childProcessNamedImports.size + simpleGitImports.length;
647
+ for (let pass = 0; pass < 5; pass += 1) {
648
+ const before = bindingCount();
649
+ visit(sf);
650
+ if (bindingCount() === before) break;
651
+ }
652
+ collecting = false;
516
653
  visit(sf);
517
654
  }
518
655
 
@@ -661,6 +798,9 @@ function main() {
661
798
  files = gatherAll();
662
799
  }
663
800
 
801
+ // Scan-coverage counters: a run that parsed nothing inspected nothing.
802
+ let astAttempted = 0;
803
+ let astParsed = 0;
664
804
  for (const file of files) {
665
805
  let text;
666
806
  try {
@@ -685,14 +825,43 @@ function main() {
685
825
  if (hasAllowComment(text)) continue;
686
826
 
687
827
  // AST lint for ts/js/mjs/cjs
828
+ astAttempted += 1;
688
829
  try {
689
830
  lintTsFile(file, text);
831
+ astParsed += 1;
690
832
  } catch (err) {
691
- // Parse failure → emit a soft warning, not a violation.
833
+ // Parse failure → emit a soft warning, not a violation. Deliberate: one
834
+ // unparseable file should not fail a build over syntax this parser does
835
+ // not accept. The total-failure case below is a different situation.
692
836
  process.stderr.write(`[lint-no-direct-destructive] failed to parse ${rel}: ${err.message}\n`);
693
837
  }
694
838
  }
695
839
 
840
+ // A run in which NOTHING parsed did not inspect anything, and "no violations
841
+ // found" would be a statement about a scan that never happened. Measured live
842
+ // on 2026-08-15: in a checkout without node_modules the `typescript` require
843
+ // fails for every file, so this guard against unaudited deletes reported
844
+ // clean and exited 0, with only stderr lines a CI log buries.
845
+ //
846
+ // This is deliberately the TOTAL-failure case only, so it cannot fail a build
847
+ // over one file the parser dislikes: in any working checkout, files parse.
848
+ if (astAttempted > 0 && astParsed === 0) {
849
+ process.stderr.write('\n');
850
+ process.stderr.write(
851
+ `[lint-no-direct-destructive] COULD NOT INSPECT — ${astAttempted} file(s) were scanned `
852
+ + 'and NONE could be parsed, so nothing was actually inspected. This is an environment '
853
+ + "problem (most often a missing `typescript` dependency — run `npm install`), not a clean "
854
+ + 'tree. Reporting success here would silently disable the destructive-operation funnel.\n'
855
+ );
856
+ // Exit 2, NOT 1. "I could not inspect" and "I found violations" are
857
+ // different facts and callers act on them differently: pre-push-gate.js
858
+ // already treats a lint that FAILED TO RUN as a warning and a lint that
859
+ // found VIOLATIONS as a push-blocking error. Returning 1 here reported an
860
+ // uninstalled scratch tree as violations — which is how this was caught,
861
+ // by three pre-push-gate tests going red in CI.
862
+ return 2;
863
+ }
864
+
696
865
  if (violations.length === 0) {
697
866
  return 0;
698
867
  }
@@ -78,6 +78,61 @@ const ALLOW = /lint-allow-sync-spawn:/;
78
78
  const FUNNELED = /\bwithSyncOp\s*\(/;
79
79
 
80
80
  const inScanDir = (p) => SCAN_DIRS.some((d) => p === d || p.startsWith(d + '/'));
81
+
82
+ /**
83
+ * Names this file has bound to a raw sync spawn, so `ex(...)` is seen the same
84
+ * as `execFileSync(...)`. Two forms, both ordinary code rather than evasions:
85
+ *
86
+ * import { execFileSync as run } from 'node:child_process'; // renamed import
87
+ * const ex = execFileSync; // local alias
88
+ *
89
+ * Measured before this was added: BOTH walked past the check while the plain
90
+ * form was caught, and NEITHER appears anywhere in the scanned directories
91
+ * today — so this is a pure forward ratchet with no baseline to grow.
92
+ *
93
+ * DELIBERATELY NOT COLLECTED: `const ex = <something>.execFileSync`. The
94
+ * VIOLATION regex excludes a dot-prefixed name on purpose, and that exclusion
95
+ * was measured to be RIGHT: all 14 namespace-form occurrences in the scanned
96
+ * dirs are either calls through `SafeGitExecutor` (the audited git funnel, 13
97
+ * of them) or sit inside a generated hook script's template literal, which runs
98
+ * in its own process and cannot block this event loop. Widening to dot-prefixed
99
+ * names would flag the funnel itself. Recorded here so it is not re-litigated.
100
+ */
101
+ function collectSyncSpawnAliases(content) {
102
+ const names = new Set();
103
+ const SPAWNS = '(?:spawnSync|execSync|execFileSync)';
104
+
105
+ // import { execFileSync as run } from 'node:child_process'
106
+ const importRe = new RegExp(
107
+ String.raw`import\s*\{([^}]*)\}\s*from\s*['"\`](?:node:)?child_process['"\`]`,
108
+ 'g'
109
+ );
110
+ let m;
111
+ while ((m = importRe.exec(content)) !== null) {
112
+ for (const part of m[1].split(',')) {
113
+ const bit = part.trim().match(new RegExp(String.raw`^${SPAWNS}\s+as\s+([A-Za-z_$][\w$]*)$`));
114
+ if (bit) names.add(bit[1]);
115
+ }
116
+ }
117
+
118
+ // const ex = execFileSync; (bare RHS only — see the dot note above)
119
+ const aliasRe = new RegExp(
120
+ String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${SPAWNS}\s*(?=[;,\n)])`,
121
+ 'g'
122
+ );
123
+ while ((m = aliasRe.exec(content)) !== null) names.add(m[1]);
124
+
125
+ return names;
126
+ }
127
+
128
+ /** A call-shape matcher for the collected names, or null when there are none. */
129
+ function aliasCallRegex(names) {
130
+ if (!names.size) return null;
131
+ const alt = [...names].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
132
+ // Same dot-exclusion as VIOLATION: `obj.ex(...)` is a method on something
133
+ // else, not the bound spawn.
134
+ return new RegExp(String.raw`(?<![.\w])(?:${alt})\s*\(`);
135
+ }
81
136
  const normalize = (p) => p.split(path.sep).join('/');
82
137
 
83
138
  function listFiles() {
@@ -133,12 +188,15 @@ function collectHits() {
133
188
  continue;
134
189
  }
135
190
  const lines = content.split('\n');
191
+ // Names this file has bound to a raw sync spawn. Collected up-front so a
192
+ // binding that appears BELOW the function using it is still resolved.
193
+ const aliasRe = aliasCallRegex(collectSyncSpawnAliases(content));
136
194
  const seenLineText = new Map(); // trimmed-line-text → occurrence count so far
137
195
  for (let i = 0; i < lines.length; i++) {
138
196
  const raw = lines[i];
139
197
  const trimmed = raw.trimStart();
140
198
  if (/^(\/\/|\*|\/\*)/.test(trimmed)) continue; // comment-only mention
141
- if (!VIOLATION.test(raw)) continue;
199
+ if (!VIOLATION.test(raw) && !(aliasRe && aliasRe.test(raw))) continue;
142
200
  // FUNNELED: a sync spawn wrapped by withSyncOp(...) on the same line is the required
143
201
  // pattern (the marker sees it) — allowed unconditionally, never grandfathered/baselined.
144
202
  if (FUNNELED.test(raw)) continue;
@@ -420,7 +420,13 @@ try {
420
420
  [path.join(ROOT, 'scripts/lint-no-direct-destructive.js')],
421
421
  { cwd: ROOT, stdio: ['ignore', 'inherit', 'inherit'] },
422
422
  );
423
- if (result.status !== 0) {
423
+ if (result.status === 2) {
424
+ // Exit 2 = the lint could not inspect anything (an environment problem such
425
+ // as a missing `typescript`), which is NOT a violation. The gate already
426
+ // treats a lint that failed to run as a warning; this is the same fact
427
+ // arriving through an exit code instead of a thrown error.
428
+ warnings.push('lint-no-direct-destructive could not inspect this tree (see output above)');
429
+ } else if (result.status !== 0) {
424
430
  errors.push('lint-no-direct-destructive: violations detected (see output above)');
425
431
  }
426
432
  } catch (err) {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-08-15T01:31:14.741Z",
5
- "instarVersion": "1.3.1153",
4
+ "generatedAt": "2026-08-15T02:10:19.346Z",
5
+ "instarVersion": "1.3.1155",
6
6
  "entryCount": 202,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
5
- "packageVersion": "1.3.1153",
5
+ "packageVersion": "1.3.1155",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "c9fe9271930f52c79239f9b43e4d3c873717dc1dd98744525290c8ccba4f07bf",
2
+ "sha256": "4768f67585d114ee7064f8a97895cdf66f96074719ad149a661bcee76e79625b",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1153"
4
+ "packageVersion": "1.3.1155"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
3
3
  "articleCount": 88,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1153"
5
+ "packageVersion": "1.3.1155"
6
6
  }
@@ -0,0 +1,132 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ `scripts/lint-no-direct-destructive.js` — the funnel guard that keeps every
9
+ destructive git/fs operation inside `SafeGitExecutor` / `SafeFsExecutor`, so each
10
+ delete carries an audit entry — now resolves local bindings before applying its
11
+ rules.
12
+
13
+ Its identifier sets were populated ONLY from imports and requires. Measured
14
+ against the shipped lint with a positive control (`fs.rmSync`) firing in the same
15
+ pass:
16
+
17
+ ```ts
18
+ const fsp = fs.promises;
19
+ await fsp.rm(p, { recursive: true, force: true }); // exit 0 — EVADED
20
+ const del = fs.rmSync; del(p); // exit 0 — EVADED
21
+ const { rmSync: del } = fs; del(p); // exit 0 — EVADED
22
+ (fs as any)['rmSync'](p); // exit 0 — EVADED
23
+ ```
24
+
25
+ `fs.promises.rm` written out IS caught, so the difference between flagged and
26
+ invisible was a variable. Aliasing a namespace to a short name is idiomatic
27
+ JavaScript — the gap needed a tidy afternoon, not an attacker.
28
+
29
+ The lint AST-walks, so it already caught renamed imports (`import { rmSync as
30
+ nuke }`) where a regex check could not. The gap was narrower than "it doesn't
31
+ understand names": it understood names arriving from imports and nothing about
32
+ names a file creates itself.
33
+
34
+ Added: `unwrap()` (strips parens / `as` / `!` before identity tests),
35
+ local-binding collection for namespace aliases, function aliases and object
36
+ binding patterns, and a collect-then-report loop that repeats until the binding
37
+ sets stop growing — so resolution is order-independent and alias chains resolve.
38
+
39
+ **Second finding, hit live rather than reasoned about.** Running the lint in a
40
+ worktree with no `node_modules`, the `typescript` require failed for EVERY file
41
+ and the lint reported **clean, exit 0** — a guard against unaudited deletes
42
+ silently became a no-op, with only stderr lines a CI log buries. The per-file
43
+ soft-warning is deliberate and right; ONE file failing and EVERY file failing are
44
+ different situations. A run that scanned files and parsed none of them now
45
+ refuses to report clean and names the likely cause, exiting **2** rather than 1:
46
+ "I could not look" and "I looked and found nothing" are different facts, and
47
+ `pre-push-gate.js` already treats a lint that failed to run as a warning and one
48
+ that found violations as a push-blocking error. (The first version returned 1 and
49
+ turned three pre-push-gate tests red — the gate copies itself into a scratch
50
+ fixture with no dependencies, so my "no working checkout can reach it" claim was
51
+ wrong; a test fixture reaches it.)
52
+
53
+ **Declared open in the source:** cross-module names, runtime-assembled access,
54
+ and indirection through a container object (`const io = { del: fs.unlinkSync }`)
55
+ — measured, and left open because that is not an ordinary way to write a delete
56
+ and chasing it widens over-block risk on a build-failing lint.
57
+
58
+ `scripts/lint-sync-subprocess-chokepoint.js` — the forward ratchet that keeps raw
59
+ synchronous subprocess spawns out of the runtime hot path, so a blocked-but-alive
60
+ server is never mistaken for a dead one — now resolves bound names.
61
+
62
+ It matched the spawn NAME on the call line, so two ordinary forms walked past
63
+ while the plain call was caught. Measured with a positive control firing in the
64
+ same run:
65
+
66
+ ```ts
67
+ import { execFileSync as run } from 'node:child_process'; run(...); // exit 0 — EVADED
68
+ const ex = execFileSync; ex(...); // exit 0 — EVADED
69
+ ```
70
+
71
+ A renamed import is not an evasion; it is how a name collision gets resolved.
72
+
73
+ **Neither form appears anywhere in the scanned directories today** (0 local
74
+ aliases, 0 renamed imports, against a control of 53 files carrying plain named
75
+ imports), so this is a pure forward ratchet: the frozen baseline does not grow
76
+ and nothing existing can break.
77
+
78
+ **Scope reversed by measurement.** `VIOLATION` also excludes a DOT-prefixed name,
79
+ and there are 14 namespace-form occurrences in the scanned dirs — "14 invisible
80
+ blocking spawns" would have been the headline. Counting what they *are*:
81
+ **13 are `SafeGitExecutor.execSync(`**, i.e. calls THROUGH the audited git funnel
82
+ (flagging them would report correct use of the funnel as a bypass of it), and the
83
+ **1 remaining sits inside a generated hook script's template literal**, which runs
84
+ in its own process and cannot block this event loop. All 14 exclusions are
85
+ correct; the dot-exclusion is left alone and pinned by two tests so it is not
86
+ "fixed" later.
87
+
88
+ Added `collectSyncSpawnAliases()` (renamed imports from `(node:)child_process`,
89
+ and bare local aliases) and `aliasCallRegex()` (carrying the same dot-exclusion as
90
+ the original rule). `VIOLATION`, `FUNNELED`, `ALLOW`, the baseline format and the
91
+ exit codes are unchanged.
92
+
93
+ ## What to Tell Your User
94
+
95
+ None — internal change (no user-facing surface).
96
+
97
+ None — internal change (no user-facing surface).
98
+
99
+ ## Summary of New Capabilities
100
+
101
+ None — internal change (no user-facing surface).
102
+
103
+ None — internal change (no user-facing surface).
104
+
105
+ ## Evidence
106
+
107
+ - `tests/unit/destructive-lint-local-bindings.test.ts` — 16/16 green.
108
+ - **Negative control: 7 of 16 fail** against the shipped lint (all six defect
109
+ cases plus the parsed-nothing refusal). The other 9 pass both ways and are the
110
+ controls. Script restored byte-exact after the control.
111
+ - Five anti-over-block controls, because this lint fails builds: a
112
+ non-destructive call; a non-destructive method on an aliased namespace;
113
+ `mkdir` through that alias (creating is not deleting); an unrelated object
114
+ exposing the same names; an alias that is never called.
115
+ - Real tree: `exit 0` before AND after. Full `npm run lint` chain exit 0.
116
+ `tsc --noEmit` exit 0.
117
+ - The new test routes its own teardown through `SafeFsExecutor` rather than
118
+ taking an allowlist entry — the test that argues for the rule follows it.
119
+
120
+ - `tests/unit/sync-spawn-alias-resolution.test.ts` — 12/12 green.
121
+ - **Negative control: 4 of 12 fail** against the shipped lint (exactly the four
122
+ defect cases). The other 8 pass both ways and are the controls. Script restored
123
+ byte-exact after the control.
124
+ - Six anti-over-block controls, because this lint fails builds — the two that
125
+ matter most: an aliased spawn wrapped by `withSyncOp` is still NOT flagged (the
126
+ funnel is the required pattern; overriding it would punish the code the rule
127
+ exists to produce), and an aliased spawn carrying an allow-comment is still NOT
128
+ flagged.
129
+ - Real tree: `exit 0` before AND after. `tsc --noEmit` exit 0. Full `npm run lint`
130
+ chain exit 0.
131
+ - Declared open in the source: dot-prefixed names (measured correct), cross-module
132
+ aliases, and `const ex = <ns>.execFileSync`.
@@ -0,0 +1,193 @@
1
+ # Side-Effects Review — destructive-op guard resolves local bindings
2
+
3
+ **Version / slug:** `destructive-lint-local-bindings`
4
+ **Date:** `2026-08-15`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not required — Tier 1 (CI-only lint script, no runtime path). The rule and the funnel are unchanged; the check now resolves one more kind of name before applying the rules it already had, and refuses to report clean on a run that inspected nothing.`
7
+
8
+ ## Summary of the change
9
+
10
+ `scripts/lint-no-direct-destructive.js` is the funnel guard for
11
+ `COMPREHENSIVE-DESTRUCTIVE-TOOL-CONTAINMENT-SPEC` — only `SafeGitExecutor` /
12
+ `SafeFsExecutor` may call destructive git/fs primitives directly, so that every
13
+ delete carries an audit entry.
14
+
15
+ It AST-walks, which already buys it real import resolution. But its identifier
16
+ sets were populated ONLY from imports and requires, never from local bindings.
17
+ Measured against the shipped lint with a positive control (a plain `fs.rmSync`)
18
+ firing in the same pass:
19
+
20
+ | form | shipped |
21
+ |---|---|
22
+ | `fs.rmSync(p)` — POSITIVE CONTROL | exit 1 (caught) |
23
+ | `import { rmSync }` / `import { rmSync as nuke }` | exit 1 (caught — better than any regex lint here) |
24
+ | `fs.promises.rm(p)` written out | exit 1 (caught) |
25
+ | **`const fsp = fs.promises; await fsp.rm(p, …)`** | **exit 0 — EVADES** |
26
+ | `const del = fs.rmSync; del(p)` | **exit 0 — EVADES** |
27
+ | `const { rmSync: del } = fs; del(p)` | **exit 0 — EVADES** |
28
+ | `(fs as any)['rmSync'](p)` | **exit 0 — EVADES** |
29
+
30
+ The first evasion is the one that matters. `fs.promises.rm` IS caught, so the
31
+ difference between flagged and invisible was a variable — and aliasing a
32
+ namespace to a short name is idiomatic JavaScript, not an evasion. The gap needed
33
+ a tidy afternoon, not an attacker.
34
+
35
+ Resolution now feeds the EXISTING checks rather than adding rules beside them.
36
+
37
+ ## The second finding, hit live rather than reasoned about
38
+
39
+ Running the lint in a fresh worktree with no `node_modules`, the `typescript`
40
+ require failed for **every** file — and the lint reported **clean, exit 0**, with
41
+ only stderr lines a CI log buries. A guard against unaudited deletes silently
42
+ became a no-op.
43
+
44
+ The per-file soft-warning is deliberate and stated in the source ("Parse failure
45
+ → emit a soft warning, not a violation"), and it is right: one file the parser
46
+ dislikes should not fail a build. But ONE file failing and EVERY file failing are
47
+ different situations — the second means nothing was inspected, and "no violations
48
+ found" is then a statement about a scan that never happened.
49
+
50
+ Added: a run that scanned files and parsed NONE of them refuses to report clean
51
+ and names the likely cause. Deliberately the total-failure case only, so it
52
+ cannot fail a build over one awkward file.
53
+
54
+ **It exits 2, not 1, and CI taught me that distinction.** My first version
55
+ returned 1 — the violation code — and three `pre-push-gate` tests went red,
56
+ because the gate copies itself into a scratch fixture with no `node_modules`,
57
+ runs this lint there, and (correctly) reads a non-zero exit as "violations
58
+ detected". My claim that "no working checkout can reach it" was wrong: a test
59
+ fixture reaches it. The gate ALREADY distinguishes a lint that failed to RUN
60
+ (warning) from one that found VIOLATIONS (push-blocking error) — I was sending an
61
+ environment problem down the violation channel. Now the lint exits **2** for
62
+ could-not-inspect and `pre-push-gate.js` maps 2 onto its existing warning path.
63
+ "I could not look" and "I looked and found nothing" are different facts.
64
+
65
+ ## Decision-point inventory
66
+
67
+ - `unwrap(node)` — ADD. Strips parens / `as` / `<T>` / `!` before identity tests.
68
+ - Local-binding collection in `visit` — ADD. Namespace aliases, destructive-function
69
+ aliases, object-binding patterns, for both fs and child_process.
70
+ - `addSimpleGitImport(localName)` — ADD. Idempotent; collection now runs more than
71
+ once and an array push is the one collector that is not naturally idempotent.
72
+ - Collect-then-report loop — CHANGED. Collection repeats until the binding sets
73
+ stop growing (bounded at 5), then one reporting pass. Makes resolution
74
+ order-independent and resolves alias chains.
75
+ - Parsed-nothing refusal — ADD.
76
+ - `INSTAR_LINT_FORCE_PARSE_FAILURE` — ADD, test hook. It can ONLY force the
77
+ fail-closed path; there is no flag here that can make this lint pass something
78
+ it would otherwise flag.
79
+ - Exit code **2** — ADD, for could-not-inspect. 0 and 1 keep their meanings.
80
+ - `scripts/pre-push-gate.js` — CHANGED: maps exit 2 onto its EXISTING
81
+ failed-to-run warning path instead of the violations error path.
82
+ - `ALLOWLIST`, `DESTRUCTIVE_FS_NAMES`, `CHILD_PROCESS_FNS`, the violation
83
+ messages and the shell/package.json grep — UNCHANGED.
84
+
85
+ ## 1. Over-block
86
+
87
+ **The dominant risk — this lint fails builds, and a noisy check gets switched
88
+ off, after which it protects nothing.** Five controls, each with a test, all
89
+ passing under BOTH the old and new behaviour:
90
+
91
+ - a non-destructive fs call is not flagged;
92
+ - a NON-destructive method on an aliased namespace is not flagged (the alias is
93
+ not the violation, the destructive method is);
94
+ - `mkdir` through that same alias is not flagged — creating is not deleting;
95
+ - an unrelated object exposing the same names is not flagged (resolution is
96
+ anchored to the fs/child_process namespace, never to a method name);
97
+ - an alias that is never called is not flagged.
98
+
99
+ **Real tree: exit 0 before AND after, zero violations.** Full `npm run lint`
100
+ chain exit 0.
101
+
102
+ The parsed-nothing refusal is scoped to `attempted > 0 && parsed === 0`.
103
+ **I first wrote here that "no working checkout can reach it" — that was wrong and
104
+ CI proved it.** The pre-push gate copies itself into a scratch fixture with no
105
+ `node_modules` and runs this lint there, so three of its tests went red. The
106
+ condition is reachable; what was wrong was reporting it through the VIOLATION
107
+ exit code. Exit 2 fixes the signal rather than narrowing the condition, and the
108
+ three tests pass with the refusal still firing.
109
+
110
+ ## 2. Under-block
111
+
112
+ Stated in the source rather than implied:
113
+
114
+ - **Cross-module names** — a destructive function re-exported from another file
115
+ is not followed. Needs a whole-program symbol graph, not one file at a time.
116
+ - **Runtime-assembled access** — a name built from a variable, a call, or a
117
+ template.
118
+ - **Indirection through a container** — `const io = { del: fs.unlinkSync }; io.del(p)`
119
+ remains invisible. Measured and left open: unlike the namespace alias, that is
120
+ not an ordinary way to write a delete, and chasing it widens over-block risk on
121
+ a build-failing lint for a contrived shape.
122
+
123
+ ## 3. Level-of-abstraction fit
124
+
125
+ Same layer as the existing check — TypeScript AST over one file, no type checker,
126
+ no new dependency. Local-binding resolution is the smallest addition that answers
127
+ the question the existing rules already ask ("is this call a destructive fs/git
128
+ primitive?") for names the file creates itself.
129
+
130
+ ## 4. Signal vs authority compliance
131
+
132
+ A CI guard, not a runtime authority. It gained reach (more forms of the same
133
+ violation) and one fail-closed condition, but no new decision-making power over
134
+ agent behaviour. The funnel itself is untouched.
135
+
136
+ ## 5. Interactions
137
+
138
+ - Already in the `lint` chain CI runs; membership verified explicitly rather than
139
+ inferred from a `package.json` reference. Chain exit 0 with this change.
140
+ - Collection now runs up to 5 times per file plus one reporting pass. Measured on
141
+ the real tree: no perceptible change in chain duration.
142
+ - **The new test routes its own teardown through `SafeFsExecutor` rather than
143
+ taking an allowlist entry** — the test that argues for the rule follows it. This
144
+ is the one behavioural cost: one audit entry per test run.
145
+ - No source module, route, config key, or state file touched.
146
+
147
+ ## 6. External surfaces
148
+
149
+ None. Developer tooling. The Agent Awareness Standard does not apply.
150
+
151
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
152
+
153
+ **Machine-local by design, and correct.** A CI-time source scan: reads files in
154
+ one checkout, returns an exit code. No durable state, no user-facing notice, no
155
+ generated URL, no runtime decision — nothing to replicate, merge on read, or
156
+ strand on a topic transfer. Every machine runs it over its own checkout of the
157
+ same tracked source and reaches the same verdict; determinism comes from the
158
+ source tree, not from coordination.
159
+
160
+ One honest note: the parsed-nothing refusal makes the verdict depend on the
161
+ checkout being *installed*, not just present. That is the intended behaviour —
162
+ an uninstalled checkout cannot inspect anything, and saying so is the point.
163
+
164
+ ## 8. Rollback cost
165
+
166
+ `git revert` of one script plus the added test file. No migration, no state, no
167
+ deployed artifact, no runtime impact.
168
+
169
+ ## Conclusion
170
+
171
+ Ship. Four evasions closed on a safety funnel — one of them an ordinary tidying
172
+ pattern rather than an evasion — a run that inspects nothing can no longer report
173
+ clean, five anti-over-block controls added, and the real tree verified clean in
174
+ both directions.
175
+
176
+ ## Evidence pointers
177
+
178
+ - `tests/unit/destructive-lint-local-bindings.test.ts` — **16/16 green**.
179
+ - **Negative control: 7 of 16 fail** against the shipped lint (all six defect
180
+ cases plus the parsed-nothing refusal). The other 9 pass **both ways** — which
181
+ is what makes them controls. Script restored **byte-exact** after the control
182
+ (sha match).
183
+ - Reproduced by hand FIRST with a positive control firing in the same run; the
184
+ control is what makes the EVADES verdicts mean anything. A first probe batch in
185
+ an uninstalled worktree returned exit 0 for *everything including the controls*
186
+ — uniform results across independent subjects indicted the instrument, which is
187
+ how the parsed-nothing finding was discovered at all.
188
+ - Real-tree verdict: exit 0 before and after. Full `npm run lint` chain exit 0.
189
+ - `tsc --noEmit` exit 0.
190
+ - Tier **1** declared: `classifyTier` reports riskFloor 1 with no safety-invariant
191
+ match (directional controls: `SessionReaper.ts` and `SecretStore.ts` both floor
192
+ 2). The size heuristic suggests 2 on added LOC alone — stated openly, since the
193
+ tier I choose is the one that lets my own change through.
@@ -0,0 +1,166 @@
1
+ # Side-Effects Review — sync-spawn ratchet resolves bound names
2
+
3
+ **Version / slug:** `sync-spawn-alias-resolution`
4
+ **Date:** `2026-08-15`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `not required — Tier 1 (CI-only lint script, no runtime path). The rule, the funnel, the allow-comment escape and the frozen baseline are all unchanged; the check now resolves two more ways of naming the same banned call.`
7
+
8
+ ## Summary of the change
9
+
10
+ `scripts/lint-sync-subprocess-chokepoint.js` is the forward ratchet for tmux
11
+ event-loop resilience: a synchronous subprocess spawn blocks the single-threaded
12
+ event loop for the child's whole lifetime, so outside the `withSyncOp` marker
13
+ funnel a raw sync spawn is banned. The incident behind it — a blocked-but-alive
14
+ server that looked dead to its supervisor and was restarted for being busy.
15
+
16
+ It matched the spawn NAME on the call line. Measured against the shipped lint
17
+ with a positive control (plain `execFileSync(...)`) firing in the same run:
18
+
19
+ | form | shipped |
20
+ |---|---|
21
+ | `execFileSync('tmux', …)` — POSITIVE CONTROL | exit 1 (caught) |
22
+ | **`import { execFileSync as run } …; run(…)`** | **exit 0 — EVADES** |
23
+ | **`const ex = execFileSync; ex(…)`** | **exit 0 — EVADES** |
24
+
25
+ A renamed import is not an evasion; it is how a name collision gets resolved.
26
+
27
+ **Neither form appears anywhere in the scanned directories today** (measured: 0
28
+ local aliases, 0 renamed imports, against a control of 53 files carrying plain
29
+ named imports). So this is a pure forward ratchet — nothing is added to the
30
+ frozen baseline and nothing existing can break.
31
+
32
+ ## The scope decision, which measurement reversed
33
+
34
+ `VIOLATION` also excludes a DOT-prefixed name, and my first read was that this
35
+ was the same class of hole. There are **14** namespace-form occurrences in the
36
+ scanned directories, and "14 invisible blocking spawns" would have been the
37
+ headline.
38
+
39
+ Counting what they *are* rather than how many:
40
+
41
+ - **13 are `SafeGitExecutor.execSync(`** — calls THROUGH the audited git funnel.
42
+ Flagging them would invert the rule, reporting correct use of the funnel as a
43
+ bypass of it.
44
+ - **1 is `childProcess.execFileSync(` inside `getStopGateRouterHook()`** — which
45
+ returns a template literal for a generated hook script. That text runs in its
46
+ own short-lived process and cannot block this event loop.
47
+
48
+ **All 14 exclusions are correct. The dot-exclusion is left alone**, and two tests
49
+ pin it so a future reader does not "fix" it and break the funnel. The header
50
+ records the measurement for the same reason.
51
+
52
+ **A probe of mine returned the flattering answer and was wrong.** To test whether
53
+ the 14th sat inside a template literal I counted unescaped backticks before its
54
+ line — in a 16,000-line file, where backticks inside strings and comments corrupt
55
+ the count. It reported "not inside a template", which supported the bigger
56
+ finding. Reading the enclosing function signature settled it in one line.
57
+
58
+ ## Decision-point inventory
59
+
60
+ - `collectSyncSpawnAliases(content)` — ADD. Per-file: renamed imports from
61
+ `(node:)child_process`, and `const|let|var X = <bare spawn name>`.
62
+ - `aliasCallRegex(names)` — ADD. Call-shape matcher carrying the SAME
63
+ dot-exclusion as `VIOLATION`; returns null when there are no names.
64
+ - The per-file loop — CHANGED: `if (!VIOLATION.test(raw) && !(aliasRe && aliasRe.test(raw))) continue;`
65
+ - `VIOLATION`, `FUNNELED`, `ALLOW`, `SCAN_DIRS`, `EXTENSIONS`, the baseline
66
+ format, the baseline file and the exit codes — UNCHANGED.
67
+ - No runtime block/allow decision added or modified. CI-time only.
68
+
69
+ ## 1. Over-block
70
+
71
+ The dominant risk — this lint fails builds. Six controls, each with a test, all
72
+ passing under BOTH old and new behaviour:
73
+
74
+ - **an aliased spawn wrapped by `withSyncOp` is not flagged.** The most important
75
+ one: the funnel is the REQUIRED pattern, and if resolution overrode it the fix
76
+ would punish exactly the code the rule exists to produce.
77
+ - **an aliased spawn carrying `lint-allow-sync-spawn:` is not flagged** — the
78
+ existing escape for genuinely pre-runtime calls still works.
79
+ - an unrelated identifier that merely shares the name is not flagged — only a
80
+ name actually bound to a spawn is collected.
81
+ - a method call on another object (`helper.ex(...)`) is not flagged — the alias
82
+ matcher carries the same dot-exclusion as the original rule.
83
+ - a file with no sync spawn is not flagged.
84
+ - the two dot-exclusion pins above (`SafeGitExecutor.execSync`, `cp.execFileSync`).
85
+
86
+ **Real tree: exit 0 before AND after.** Full `npm run lint` chain exit 0.
87
+
88
+ Residual over-block risk, stated: a very short alias (`run`, `ex`) shadowed later
89
+ in the same file by an unrelated binding of the same name would be flagged. Not
90
+ observed anywhere today, and the failure is loud and one line to fix, unlike the
91
+ silent miss it replaces.
92
+
93
+ ## 2. Under-block
94
+
95
+ Stated in the source:
96
+
97
+ - **Dot-prefixed names** — deliberately excluded, measured correct (above).
98
+ - **Cross-module aliases** — a wrapper exported from another file.
99
+ - **`const ex = <ns>.execFileSync`** — not collected, because collecting it would
100
+ require resolving the namespace, which is the dot case.
101
+ - The header's pre-existing honesty stands: this is a static line regex and
102
+ cannot prove a flagged line is actually wrapped at runtime — that is the
103
+ marker unit tests' job.
104
+
105
+ ## 3. Level-of-abstraction fit
106
+
107
+ Same layer as the existing check — line regex over file text, no AST, no new
108
+ dependency. Alias collection is the smallest addition that answers the question
109
+ the rule already asks ("is this line a raw sync spawn?") for names the file
110
+ creates itself.
111
+
112
+ ## 4. Signal vs authority compliance
113
+
114
+ A CI ratchet, not a runtime authority. It gained reach over two more spellings of
115
+ a violation it already forbade, and no new decision-making power. The funnel, the
116
+ escape and the baseline are untouched.
117
+
118
+ ## 5. Interactions
119
+
120
+ - Already in the `lint` chain CI runs; chain exit 0 with this change.
121
+ - The frozen baseline is untouched and does not grow — the newly-reachable forms
122
+ have zero existing instances.
123
+ - Alias collection is one extra regex pass per file; no perceptible change in
124
+ chain duration.
125
+ - No source module, route, config key, or state file touched.
126
+
127
+ ## 6. External surfaces
128
+
129
+ None. Developer tooling. The Agent Awareness Standard does not apply.
130
+
131
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
132
+
133
+ **Machine-local by design, and correct.** A CI-time source scan: reads files in
134
+ one checkout, returns an exit code. No durable state, no user-facing notice, no
135
+ generated URL, no runtime decision — nothing to replicate, merge on read, or
136
+ strand on a topic transfer. Every machine runs it over its own checkout of the
137
+ same tracked source and reaches the same verdict; alias collection is explicitly
138
+ per-file, so it cannot depend on the rest of the checkout, let alone another
139
+ machine.
140
+
141
+ ## 8. Rollback cost
142
+
143
+ `git revert` of one script plus the added test file. No migration, no state, no
144
+ deployed artifact, no runtime impact, no baseline change to undo.
145
+
146
+ ## Conclusion
147
+
148
+ Ship. Two ordinary ways of naming a banned blocking call are now seen, the
149
+ existing funnel and escape still win over the new reach, the deliberate
150
+ dot-exclusion is measured-correct and pinned rather than widened, and the real
151
+ tree is verified clean in both directions.
152
+
153
+ ## Evidence pointers
154
+
155
+ - `tests/unit/sync-spawn-alias-resolution.test.ts` — **12/12 green**.
156
+ - **Negative control: 4 of 12 fail** against the shipped lint (exactly the four
157
+ defect cases). The other 8 pass **both ways** — one positive control, two
158
+ escape-still-wins, three over-block, two dot-exclusion pins. Script restored
159
+ **byte-exact** after the control (sha match).
160
+ - Reproduced by hand FIRST with a positive control in the same run.
161
+ - Zero existing instances of either newly-reached form (control: 53 files with
162
+ plain named imports), so the frozen baseline does not grow.
163
+ - Real-tree verdict: exit 0 before and after. `tsc --noEmit` exit 0. Full chain
164
+ exit 0.
165
+ - Tier **1** declared: CI-only script, no runtime path, no authority, no
166
+ capability.