instar 1.3.1153 → 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.1154.md +65 -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,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,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.
|