instar 1.3.1152 → 1.3.1154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/package.json +1 -1
- package/scripts/lint-sync-subprocess-chokepoint.js +59 -1
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/upgrades/1.3.1153.md +62 -0
- package/upgrades/1.3.1154.md +65 -0
- package/upgrades/side-effects/atomic-writes-method-scope.md +152 -0
- package/upgrades/side-effects/sync-spawn-alias-resolution.md +166 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"generatedFrom": "source-tree",
|
|
4
4
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
5
|
-
"packageVersion": "1.3.
|
|
5
|
+
"packageVersion": "1.3.1154",
|
|
6
6
|
"guards": [
|
|
7
7
|
{
|
|
8
8
|
"ref": "docs/audits/phase-b/f10-triage.md",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sha256": "
|
|
2
|
+
"sha256": "49844097b7495749a584409f6b28788b61127591a3f707a03ef9622e69bf1dd1",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1154"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -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;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-08-
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-08-15T02:06:17.465Z",
|
|
5
|
+
"instarVersion": "1.3.1154",
|
|
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.
|
|
5
|
+
"packageVersion": "1.3.1154",
|
|
6
6
|
"guards": [
|
|
7
7
|
{
|
|
8
8
|
"ref": "docs/audits/phase-b/f10-triage.md",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sha256": "
|
|
2
|
+
"sha256": "49844097b7495749a584409f6b28788b61127591a3f707a03ef9622e69bf1dd1",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1154"
|
|
5
5
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The atomic-writes consistency test could not detect the defect it exists to detect.
|
|
9
|
+
|
|
10
|
+
Measured rather than argued: a bare `fs.writeFileSync` of durable session state, inserted into
|
|
11
|
+
`saveSession` — a DECLARED method of a DECLARED module — passed all 21 of its assertions.
|
|
12
|
+
|
|
13
|
+
Three causes, all fixed:
|
|
14
|
+
|
|
15
|
+
1. **Scoping.** `inSaveMethod` was set when a method NAME appeared on a line and never reset, while
|
|
16
|
+
`hasWriteFile`/`hasRename` were re-zeroed at each occurrence. Only the window from the LAST name
|
|
17
|
+
mention to EOF reached the assertion — 125 of 617 lines (20%) in `StateManager.ts`, leaving three
|
|
18
|
+
of its four declared methods structurally unreachable. Bodies are now brace-matched per method.
|
|
19
|
+
2. **File-scope substring checks.** `source.includes('renameSync')` and `source.includes('.tmp')` are
|
|
20
|
+
satisfied by one occurrence anywhere in the file, comments included. Pairing is now per body.
|
|
21
|
+
3. **Silent declaration rot.** A missing file was `it.skip`ped and a missing method never set the
|
|
22
|
+
flag, so a rename dropped a module out of coverage without a sound. Both are failures now — and
|
|
23
|
+
enabling that found two immediately: `saveState` has ZERO occurrences in `StateManager.ts` and in
|
|
24
|
+
`QuotaTracker.ts`. QuotaTracker's real writer, `updateState()`, is atomic and had never been
|
|
25
|
+
verified by this test. The declared list is corrected here.
|
|
26
|
+
|
|
27
|
+
**Delegation.** `StateManager` funnels every write through a private `atomicWrite()`. A naive
|
|
28
|
+
per-method rule would have failed the best-written module in the set for being well written, so one
|
|
29
|
+
level of `this.helper()` delegation is resolved and the funnel is what gets verified — which means
|
|
30
|
+
`StateManager`'s writes are now genuinely checked, where before nothing checked them.
|
|
31
|
+
|
|
32
|
+
Added `tests/helpers/atomicWriteScope.ts` (`stripComments`, `methodBodies`, `delegateTargets`,
|
|
33
|
+
`classifyMethod`) and `tests/unit/atomic-write-scope.test.ts`.
|
|
34
|
+
|
|
35
|
+
**Declared open in the source:** the module list is curated at 7 entries and says nothing about the
|
|
36
|
+
hundreds of other files under `src/` that call `writeFileSync`; delegation resolves one level within
|
|
37
|
+
one file; only `writeFileSync`/`renameSync` are recognised.
|
|
38
|
+
|
|
39
|
+
**The production code is atomic** everywhere the check now looks. This fixes a weak instrument, not a
|
|
40
|
+
live corruption bug.
|
|
41
|
+
|
|
42
|
+
## What to Tell Your User
|
|
43
|
+
|
|
44
|
+
None — internal change (no user-facing surface).
|
|
45
|
+
|
|
46
|
+
## Summary of New Capabilities
|
|
47
|
+
|
|
48
|
+
None — internal change (no user-facing surface).
|
|
49
|
+
|
|
50
|
+
## Evidence
|
|
51
|
+
|
|
52
|
+
- `tests/unit/atomic-write-scope.test.ts` — 18/18 green (6 defect cases, 6 over-block controls, 6 primitives).
|
|
53
|
+
- `tests/unit/atomic-writes-consistency.test.ts` — 32/32 green against the real tree.
|
|
54
|
+
- **Both-directions proof in ONE worktree**, shipped check restored from git alongside the new one so
|
|
55
|
+
subject and control share a boundary: shipped → **21/21 PASSED** against the mutation; new →
|
|
56
|
+
**1 failed / 31 passed**, naming file, method, deciding body and line. `src/core/StateManager.ts`
|
|
57
|
+
restored byte-exact (sha match, zero markers, zero stray files).
|
|
58
|
+
- Six over-block controls pass under BOTH behaviours — genuine funnel delegation, in-body
|
|
59
|
+
tmp-then-rename, a method that writes nothing, a commented-out write, a call site vs a declaration,
|
|
60
|
+
and worst-verdict-wins across duplicate declarations.
|
|
61
|
+
- `tsc --noEmit` exit 0 — with the boundary stated: `tsconfig.json` excludes `tests`, so that run says
|
|
62
|
+
nothing about these files.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
`scripts/lint-sync-subprocess-chokepoint.js` — the forward ratchet that keeps raw
|
|
9
|
+
synchronous subprocess spawns out of the runtime hot path, so a blocked-but-alive
|
|
10
|
+
server is never mistaken for a dead one — now resolves bound names.
|
|
11
|
+
|
|
12
|
+
It matched the spawn NAME on the call line, so two ordinary forms walked past
|
|
13
|
+
while the plain call was caught. Measured with a positive control firing in the
|
|
14
|
+
same run:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { execFileSync as run } from 'node:child_process'; run(...); // exit 0 — EVADED
|
|
18
|
+
const ex = execFileSync; ex(...); // exit 0 — EVADED
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A renamed import is not an evasion; it is how a name collision gets resolved.
|
|
22
|
+
|
|
23
|
+
**Neither form appears anywhere in the scanned directories today** (0 local
|
|
24
|
+
aliases, 0 renamed imports, against a control of 53 files carrying plain named
|
|
25
|
+
imports), so this is a pure forward ratchet: the frozen baseline does not grow
|
|
26
|
+
and nothing existing can break.
|
|
27
|
+
|
|
28
|
+
**Scope reversed by measurement.** `VIOLATION` also excludes a DOT-prefixed name,
|
|
29
|
+
and there are 14 namespace-form occurrences in the scanned dirs — "14 invisible
|
|
30
|
+
blocking spawns" would have been the headline. Counting what they *are*:
|
|
31
|
+
**13 are `SafeGitExecutor.execSync(`**, i.e. calls THROUGH the audited git funnel
|
|
32
|
+
(flagging them would report correct use of the funnel as a bypass of it), and the
|
|
33
|
+
**1 remaining sits inside a generated hook script's template literal**, which runs
|
|
34
|
+
in its own process and cannot block this event loop. All 14 exclusions are
|
|
35
|
+
correct; the dot-exclusion is left alone and pinned by two tests so it is not
|
|
36
|
+
"fixed" later.
|
|
37
|
+
|
|
38
|
+
Added `collectSyncSpawnAliases()` (renamed imports from `(node:)child_process`,
|
|
39
|
+
and bare local aliases) and `aliasCallRegex()` (carrying the same dot-exclusion as
|
|
40
|
+
the original rule). `VIOLATION`, `FUNNELED`, `ALLOW`, the baseline format and the
|
|
41
|
+
exit codes are unchanged.
|
|
42
|
+
|
|
43
|
+
## What to Tell Your User
|
|
44
|
+
|
|
45
|
+
None — internal change (no user-facing surface).
|
|
46
|
+
|
|
47
|
+
## Summary of New Capabilities
|
|
48
|
+
|
|
49
|
+
None — internal change (no user-facing surface).
|
|
50
|
+
|
|
51
|
+
## Evidence
|
|
52
|
+
|
|
53
|
+
- `tests/unit/sync-spawn-alias-resolution.test.ts` — 12/12 green.
|
|
54
|
+
- **Negative control: 4 of 12 fail** against the shipped lint (exactly the four
|
|
55
|
+
defect cases). The other 8 pass both ways and are the controls. Script restored
|
|
56
|
+
byte-exact after the control.
|
|
57
|
+
- Six anti-over-block controls, because this lint fails builds — the two that
|
|
58
|
+
matter most: an aliased spawn wrapped by `withSyncOp` is still NOT flagged (the
|
|
59
|
+
funnel is the required pattern; overriding it would punish the code the rule
|
|
60
|
+
exists to produce), and an aliased spawn carrying an allow-comment is still NOT
|
|
61
|
+
flagged.
|
|
62
|
+
- Real tree: `exit 0` before AND after. `tsc --noEmit` exit 0. Full `npm run lint`
|
|
63
|
+
chain exit 0.
|
|
64
|
+
- Declared open in the source: dot-prefixed names (measured correct), cross-module
|
|
65
|
+
aliases, and `const ex = <ns>.execFileSync`.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Side-Effects Review — atomic-writes check scopes to method bodies
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `atomic-writes-method-scope`
|
|
4
|
+
**Date:** `2026-08-15`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `not required — Tier 1. Test-only change; no file under src/, scripts/, .husky/ or skills/ is touched, so there is no runtime path and no shipped behaviour. The change makes an existing check stricter and adds no authority.`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`tests/unit/atomic-writes-consistency.test.ts` verifies that state-writing modules use
|
|
11
|
+
write-to-tmp-then-rename, so a crash mid-write cannot leave a truncated state file. **It could not
|
|
12
|
+
detect the defect it exists to detect.**
|
|
13
|
+
|
|
14
|
+
Measured, not argued — a bare `fs.writeFileSync` of durable session state inserted into
|
|
15
|
+
`saveSession`, a DECLARED method of a DECLARED module:
|
|
16
|
+
|
|
17
|
+
| check | verdict against that mutation |
|
|
18
|
+
|---|---|
|
|
19
|
+
| shipped | **21/21 PASSED** |
|
|
20
|
+
| this change | **1 failed / 31 passed**, naming file, method, deciding body, line |
|
|
21
|
+
|
|
22
|
+
Both runs were made in the SAME worktree against the SAME mutated source, with the shipped check
|
|
23
|
+
restored from git alongside the new one — subject and control share a boundary rather than being
|
|
24
|
+
compared across two checkouts.
|
|
25
|
+
|
|
26
|
+
Three causes, all fixed:
|
|
27
|
+
|
|
28
|
+
1. **Scoping.** `inSaveMethod` was set when a method NAME appeared on a line and never reset, while
|
|
29
|
+
`hasWriteFile`/`hasRename` were re-zeroed at each occurrence. Only the window from the LAST name
|
|
30
|
+
mention to EOF survived to the assertion — measured at **125 of 617 lines (20%)** in
|
|
31
|
+
`StateManager.ts`, leaving three of its four declared methods structurally unreachable. Within
|
|
32
|
+
that window the booleans were file-scope, so a rename in one method vouched for a write in another.
|
|
33
|
+
2. **File-scope substring checks.** `source.includes('renameSync')` and `source.includes('.tmp')` are
|
|
34
|
+
satisfied by one occurrence anywhere, comments included.
|
|
35
|
+
3. **Silent declaration rot.** A missing file was `it.skip`ped; a missing method simply never set the
|
|
36
|
+
flag. Both are failures now.
|
|
37
|
+
|
|
38
|
+
## What enabling (3) immediately found
|
|
39
|
+
|
|
40
|
+
**Two of the ten declared (module, method) pairs name methods that do not exist.** `saveState` has
|
|
41
|
+
ZERO occurrences in `src/core/StateManager.ts` and ZERO in `src/monitoring/QuotaTracker.ts` — verified
|
|
42
|
+
by grep with a control (`saveSession(`, `appendEvent(`, `persistUsers(` all found). QuotaTracker's
|
|
43
|
+
real writer is `updateState()`, which is correctly atomic and had never been verified by this test.
|
|
44
|
+
The declared list is corrected in the same change.
|
|
45
|
+
|
|
46
|
+
## Decision-point inventory
|
|
47
|
+
|
|
48
|
+
- `tests/helpers/atomicWriteScope.ts` — ADD. `stripComments` (quote-aware, line-count preserving),
|
|
49
|
+
`methodBodies` (brace-matched, string-aware, declaration-vs-call aware), `delegateTargets`,
|
|
50
|
+
`classifyMethod`.
|
|
51
|
+
- `tests/unit/atomic-writes-consistency.test.ts` — REWRITTEN to per-method assertions; declared list
|
|
52
|
+
corrected; missing file/method now fail; anti-vacuity assertion added.
|
|
53
|
+
- `tests/unit/atomic-write-scope.test.ts` — ADD. 18 tests pinning the primitives and both directions.
|
|
54
|
+
- No file under `src/`, `scripts/`, `.husky/` or `skills/` is touched. No runtime decision added.
|
|
55
|
+
|
|
56
|
+
## 1. Over-block
|
|
57
|
+
|
|
58
|
+
**The dominant risk, and it nearly bit me.** The obvious fix — require a rename in each declared
|
|
59
|
+
method's own body — would have FAILED on `StateManager`, the best-written module in the set, because
|
|
60
|
+
it routes every write through a private `atomicWrite()` funnel and its save methods contain no write
|
|
61
|
+
call at all. Failing the single-funnel pattern this codebase argues for everywhere else would be a
|
|
62
|
+
false red on exemplary code.
|
|
63
|
+
|
|
64
|
+
So one level of `this.helper()` delegation is resolved and the funnel is what gets verified — which
|
|
65
|
+
also means `StateManager`'s writes are now genuinely checked, where previously nothing checked them.
|
|
66
|
+
|
|
67
|
+
Six controls, each with a test, all passing under BOTH the old and new behaviour:
|
|
68
|
+
|
|
69
|
+
- delegation to a genuine atomic funnel → `atomic-via-funnel`, not a violation;
|
|
70
|
+
- in-body tmp-then-rename → `atomic-inline`;
|
|
71
|
+
- a method that legitimately writes nothing → `no-write`, no invented violation;
|
|
72
|
+
- a commented-out write is not a write;
|
|
73
|
+
- a CALL site (`this.saveSession({...})` inside another method) is not a declaration — treating it as
|
|
74
|
+
one is precisely how the old flag conflated two methods;
|
|
75
|
+
- an unbalanced brace yields no body rather than a wrong region, so a syntax error elsewhere cannot
|
|
76
|
+
become a false verdict here.
|
|
77
|
+
|
|
78
|
+
**Verified against the real tree: 32/32 green.** The production code is atomic everywhere the check
|
|
79
|
+
now looks, including through the funnel.
|
|
80
|
+
|
|
81
|
+
**A defect in my own helper, caught by these controls before it shipped:** the first `methodBodies`
|
|
82
|
+
required a declaration at line start. That works on real source (which indents declarations) and
|
|
83
|
+
returned `found: false` for every hand-written fixture — so five tests failed loudly rather than
|
|
84
|
+
passing vacuously. The matcher now decides by the PRECEDING token (start / `{` / `}` / `;` after
|
|
85
|
+
skipping modifiers), which rejects `this.save(` and `helper(save(1))` as calls while accepting a
|
|
86
|
+
declaration that does not begin its own line.
|
|
87
|
+
|
|
88
|
+
## 2. Under-block
|
|
89
|
+
|
|
90
|
+
Stated in the source rather than implied:
|
|
91
|
+
|
|
92
|
+
- **Population.** The module list is CURATED and holds 7 entries. Hundreds of files under `src/` call
|
|
93
|
+
`writeFileSync`; this test says nothing about any of them. A heuristic sweep suggested a state-writing
|
|
94
|
+
population in the low hundreds, but that heuristic missed 2 of the 7 KNOWN-good modules, so it is not
|
|
95
|
+
a defect count and is not quoted as one. Widening the population is separate work with real
|
|
96
|
+
over-block risk and is deliberately not attempted here.
|
|
97
|
+
- **Delegation depth.** One level, one file. A helper calling another helper, or an imported writer,
|
|
98
|
+
is not resolved — that needs a symbol graph, not text.
|
|
99
|
+
- **Write vocabulary.** Only `writeFileSync`/`renameSync`. A module writing via a stream, `fs.promises`,
|
|
100
|
+
or a third-party helper is invisible.
|
|
101
|
+
|
|
102
|
+
## 3. Level-of-abstraction fit
|
|
103
|
+
|
|
104
|
+
Same layer as the existing check — source-text analysis in a unit test, no AST, no type information,
|
|
105
|
+
no new dependency. The brace matcher is the minimum needed to answer "which method is this line in?",
|
|
106
|
+
which is the question the original flag was trying and failing to answer.
|
|
107
|
+
|
|
108
|
+
## 4. Signal vs authority compliance
|
|
109
|
+
|
|
110
|
+
A test, not a runtime authority. It gates CI only. It gained teeth (it can now fail) but no new
|
|
111
|
+
decision-making power over agent behaviour.
|
|
112
|
+
|
|
113
|
+
## 5. Interactions
|
|
114
|
+
|
|
115
|
+
- Runs in the existing unit shards; no new script, no lint-chain entry, no CI wiring change.
|
|
116
|
+
- `tsc --noEmit` exit 0 — **but stated honestly: `tsconfig.json` excludes `tests`, so that run says
|
|
117
|
+
nothing about these files.** Their correctness is evidenced by the suite passing, not by the compiler.
|
|
118
|
+
- No source module, route, config key, or state file touched.
|
|
119
|
+
|
|
120
|
+
## 6. External surfaces
|
|
121
|
+
|
|
122
|
+
None. Developer tooling. The Agent Awareness Standard does not apply — no agent capability is added.
|
|
123
|
+
|
|
124
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
125
|
+
|
|
126
|
+
**Machine-local by design, and correct.** A unit test reads files in one checkout and returns an exit
|
|
127
|
+
code. No durable state, no user-facing notice, no generated URL, no runtime decision — nothing to
|
|
128
|
+
replicate, merge on read, or strand on a topic transfer. Every machine runs it over its own checkout
|
|
129
|
+
of the same tracked source and reaches the same verdict; determinism comes from the source tree, not
|
|
130
|
+
from coordination.
|
|
131
|
+
|
|
132
|
+
## 8. Rollback cost
|
|
133
|
+
|
|
134
|
+
`git revert` of three test files. No migration, no state, no deployed artifact, no runtime impact.
|
|
135
|
+
|
|
136
|
+
## Conclusion
|
|
137
|
+
|
|
138
|
+
Ship. A check that could not fail on its own subject now fails on it, two stale declarations are
|
|
139
|
+
repaired, the best-written module in the set is verified for the first time, and the limits are named
|
|
140
|
+
in the source rather than implied.
|
|
141
|
+
|
|
142
|
+
## Evidence pointers
|
|
143
|
+
|
|
144
|
+
- `tests/unit/atomic-write-scope.test.ts` — 18/18 green (6 defect cases, 6 over-block controls, 6 primitives).
|
|
145
|
+
- `tests/unit/atomic-writes-consistency.test.ts` — 32/32 green against the real tree.
|
|
146
|
+
- **Both-directions proof in ONE worktree:** shipped check vs the mutation → 21/21 PASSED; new check
|
|
147
|
+
vs the SAME mutation → 1 failed / 31 passed. `src/core/StateManager.ts` restored byte-exact
|
|
148
|
+
(sha match, zero probe markers, zero stray files).
|
|
149
|
+
- Stale declarations verified by grep with a control that fired.
|
|
150
|
+
- `tsc --noEmit` exit 0 (boundary stated above: tests are excluded from that config).
|
|
151
|
+
- Tier **1** declared: `classifyTier` reports riskFloor 1 with no safety-invariant match, and `tests/`
|
|
152
|
+
is outside `inScope()`, so the size heuristic contributes nothing either.
|
|
@@ -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.
|