instar 1.3.1149 → 1.3.1151

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.1149",
5
+ "packageVersion": "1.3.1151",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "1ed8f5bd6959a002ef362c9be5f88a382b0956d4992b16879350851ce3711ac9",
2
+ "sha256": "2781a4e62937b5faedcefb2635981134a4f27ba058786b5d16b5017f41b017e9",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1149"
4
+ "packageVersion": "1.3.1151"
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.1149"
5
+ "packageVersion": "1.3.1151"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1149",
3
+ "version": "1.3.1151",
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",
@@ -64,6 +64,86 @@ const PATTERNS = [
64
64
  /method\s*:\s*['"`]createForumTopic['"`]/,
65
65
  ];
66
66
 
67
+ // ── Name resolution (added 2026-08-15) ────────────────────────────────────
68
+ // The three patterns above each require `createForumTopic` as a string
69
+ // LITERAL adjacent to the seam. Measured against the shipped lint, all three
70
+ // of these reach the Bot API and NONE were caught:
71
+ //
72
+ // const M = 'createForumTopic'; apiCall(M, {...});
73
+ // apiCall('createForum' + 'Topic', {...});
74
+ // const M = 'createForumTopic'; ({ method: M, ... });
75
+ //
76
+ // None of that is evasive — lifting a repeated string into a named constant
77
+ // is ordinary tidying. Someone could step around the notification-flood
78
+ // ceiling while making code NICER, and this lint would say `clean`. So the
79
+ // method name is RESOLVED before the existing rules are applied; the rules
80
+ // themselves are unchanged, they simply see through one level of naming.
81
+ //
82
+ // Deliberately NOT a parser. Everything below is bounded, per-file, and
83
+ // fails toward NOT flagging, because this lint blocks commits and a check
84
+ // that flags correct code gets switched off — which would cost more than the
85
+ // hole it closes.
86
+
87
+ const METHOD = 'createForumTopic';
88
+
89
+ /**
90
+ * Fold ADJACENT string literals only: `'a' + 'b'` -> `'ab'`.
91
+ * Never invents text and never folds across an identifier, so
92
+ * `'createForum' + suffix` stays unresolved rather than guessed at.
93
+ */
94
+ export function foldAdjacentLiterals(text) {
95
+ let out = text;
96
+ for (let i = 0; i < 5; i++) {
97
+ const next = out.replace(
98
+ /(['"`])([^'"`\n]*)\1\s*\+\s*(['"`])([^'"`\n]*)\3/g,
99
+ (_m, q, a, _q2, b) => `${q}${a}${b}${q}`,
100
+ );
101
+ if (next === out) break;
102
+ out = next;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Identifiers bound to a string literal (after folding) in THIS file.
109
+ * An identifier bound more than once to DIFFERENT values is UNRESOLVABLE and
110
+ * is dropped, so an ambiguous name can never be substituted into a match.
111
+ */
112
+ export function collectStringConsts(lines) {
113
+ const seen = new Map();
114
+ const conflicting = new Set();
115
+ const DECL = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^;]+)/;
116
+ for (const line of lines) {
117
+ const m = DECL.exec(line);
118
+ if (!m) continue;
119
+ const name = m[1];
120
+ const folded = foldAdjacentLiterals(m[2].trim());
121
+ const lit = /^(['"`])([^'"`\n]*)\1\s*$/.exec(folded);
122
+ if (!lit) continue;
123
+ const value = lit[2];
124
+ if (seen.has(name) && seen.get(name) !== value) conflicting.add(name);
125
+ else seen.set(name, value);
126
+ }
127
+ for (const name of conflicting) seen.delete(name);
128
+ return seen;
129
+ }
130
+
131
+ /**
132
+ * Produce a view of one line with the method name resolved, for matching only.
133
+ * Substitution happens ONLY in the two seam positions the rules look at —
134
+ * never globally — so an unrelated identifier sharing a value is untouched.
135
+ */
136
+ export function resolveLine(line, consts) {
137
+ let out = foldAdjacentLiterals(line);
138
+ out = out.replace(/(apiCall\(\s*)([A-Za-z_$][\w$]*)/g, (m, head, name) =>
139
+ consts.get(name) === METHOD ? `${head}'${METHOD}'` : m,
140
+ );
141
+ out = out.replace(/(method\s*:\s*)([A-Za-z_$][\w$]*)/g, (m, head, name) =>
142
+ consts.get(name) === METHOD ? `${head}'${METHOD}'` : m,
143
+ );
144
+ return out;
145
+ }
146
+
67
147
  function listFiles() {
68
148
  const staged = process.argv.includes('--staged');
69
149
  if (staged) {
@@ -94,36 +174,54 @@ function listFiles() {
94
174
  return files;
95
175
  }
96
176
 
97
- let violations = 0;
98
- for (const rel of listFiles()) {
99
- const normalized = rel.split(path.sep).join('/');
100
- if (ALLOWLIST.has(normalized)) continue;
101
- if (!EXTENSIONS.has(path.extname(normalized))) continue;
102
- const full = path.join(ROOT, normalized);
103
- let content;
104
- try {
105
- content = fs.readFileSync(full, 'utf-8');
106
- } catch {
107
- continue;
108
- }
177
+ export function scanFile(normalized, content) {
109
178
  const lines = content.split('\n');
179
+ const consts = collectStringConsts(lines);
180
+ const hits = [];
110
181
  for (let i = 0; i < lines.length; i++) {
182
+ const resolved = resolveLine(lines[i], consts);
111
183
  for (const pattern of PATTERNS) {
112
- if (pattern.test(lines[i])) {
113
- console.error(
114
- `${normalized}:${i + 1} — raw createForumTopic invocation outside the budgeted funnel. ` +
115
- `Route through TelegramAdapter.createForumTopic / findOrCreateForumTopic (declare an origin/label), ` +
116
- `or add an allowlist entry here with a bounded-volume justification.`,
117
- );
118
- violations++;
119
- }
184
+ if (pattern.test(resolved)) hits.push({ file: normalized, line: i + 1 });
120
185
  }
121
186
  }
187
+ return hits;
122
188
  }
123
189
 
124
- if (violations > 0) {
125
- console.error(`\nlint-no-unfunneled-topic-creation: ${violations} violation(s). ` +
126
- `See docs/STANDARDS-REGISTRY.md "Bounded Notification Surface".`);
127
- process.exit(1);
190
+ function runLint() {
191
+ let violations = 0;
192
+ for (const rel of listFiles()) {
193
+ const normalized = rel.split(path.sep).join('/');
194
+ if (ALLOWLIST.has(normalized)) continue;
195
+ if (!EXTENSIONS.has(path.extname(normalized))) continue;
196
+ const full = path.join(ROOT, normalized);
197
+ let content;
198
+ try {
199
+ content = fs.readFileSync(full, 'utf-8');
200
+ } catch {
201
+ continue;
202
+ }
203
+ for (const hit of scanFile(normalized, content)) {
204
+ console.error(
205
+ `${hit.file}:${hit.line} — raw createForumTopic invocation outside the budgeted funnel. ` +
206
+ `Route through TelegramAdapter.createForumTopic / findOrCreateForumTopic (declare an origin/label), ` +
207
+ `or add an allowlist entry here with a bounded-volume justification.`,
208
+ );
209
+ violations++;
210
+ }
211
+ }
212
+
213
+ if (violations > 0) {
214
+ console.error(`\nlint-no-unfunneled-topic-creation: ${violations} violation(s). ` +
215
+ `See docs/STANDARDS-REGISTRY.md "Bounded Notification Surface".`);
216
+ process.exit(1);
217
+ }
218
+ console.log('lint-no-unfunneled-topic-creation: clean');
128
219
  }
129
- console.log('lint-no-unfunneled-topic-creation: clean');
220
+
221
+ // DIRECT-INVOCATION GUARD. Without this, importing this module to unit-test the
222
+ // resolution helpers runs the whole repo scan — and calls process.exit(1) the
223
+ // moment the repo has a real violation, killing the test runner. Three other
224
+ // lints hit exactly that this window; the guard is the same fix.
225
+ const invokedDirectly =
226
+ process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename);
227
+ if (invokedDirectly) runLint();
@@ -42,14 +42,160 @@ export const UNREACHABLE_OFF_GATE =
42
42
 
43
43
  const SUPPRESS = /lint-allow-messaging-gate\s*:/;
44
44
 
45
+ function stripCommentsPreserveLines(text) {
46
+ let out = '';
47
+ let inBlock = false;
48
+ let quote = null;
49
+
50
+ for (let i = 0; i < text.length; i += 1) {
51
+ const ch = text[i];
52
+ const next = text[i + 1];
53
+
54
+ if (inBlock) {
55
+ if (ch === '*' && next === '/') {
56
+ out += ' ';
57
+ i += 1;
58
+ inBlock = false;
59
+ } else {
60
+ out += ch === '\n' ? '\n' : ' ';
61
+ }
62
+ continue;
63
+ }
64
+
65
+ if (quote) {
66
+ out += ch;
67
+ if (ch === '\\' && i + 1 < text.length) {
68
+ out += text[i + 1];
69
+ i += 1;
70
+ continue;
71
+ }
72
+ if (ch === quote) quote = null;
73
+ continue;
74
+ }
75
+
76
+ if (ch === '/' && next === '/') {
77
+ out += ' ';
78
+ i += 1;
79
+ while (i + 1 < text.length && text[i + 1] !== '\n') {
80
+ out += ' ';
81
+ i += 1;
82
+ }
83
+ continue;
84
+ }
85
+
86
+ if (ch === '/' && next === '*') {
87
+ out += ' ';
88
+ i += 1;
89
+ inBlock = true;
90
+ continue;
91
+ }
92
+
93
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch;
94
+ out += ch;
95
+ }
96
+
97
+ return out;
98
+ }
99
+
100
+ function skipWs(s, i) {
101
+ while (i < s.length && /\s/.test(s[i])) i += 1;
102
+ return i;
103
+ }
104
+
105
+ function parseStringLiteral(s, start) {
106
+ const quote = s[start];
107
+ if (quote !== "'" && quote !== '"' && quote !== '`') return null;
108
+
109
+ let value = '';
110
+ for (let i = start + 1; i < s.length; i += 1) {
111
+ const ch = s[i];
112
+ if (ch === '\\' && i + 1 < s.length) {
113
+ value += s[i + 1];
114
+ i += 1;
115
+ continue;
116
+ }
117
+ if (quote === '`' && ch === '$' && s[i + 1] === '{') return null;
118
+ if (ch === quote) return { value, end: i + 1 };
119
+ value += ch;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ function parseLiteralConcat(s, start) {
125
+ let i = skipWs(s, start);
126
+ let parsed = parseStringLiteral(s, i);
127
+ if (!parsed) return null;
128
+
129
+ let value = parsed.value;
130
+ i = skipWs(s, parsed.end);
131
+ while (s[i] === '+') {
132
+ i = skipWs(s, i + 1);
133
+ parsed = parseStringLiteral(s, i);
134
+ if (!parsed) return null;
135
+ value += parsed.value;
136
+ i = skipWs(s, parsed.end);
137
+ }
138
+
139
+ return { value, end: i };
140
+ }
141
+
142
+ function getCallArgStartAt(s, start) {
143
+ if (!s.startsWith('.get', start)) return null;
144
+ if (/[$_\p{ID_Continue}]/u.test(s[start + 4] ?? '')) return null;
145
+
146
+ let i = skipWs(s, start + 4);
147
+ if (s[i] === '<') {
148
+ const close = s.indexOf('>', i + 1);
149
+ if (close === -1) return null;
150
+ i = skipWs(s, close + 1);
151
+ }
152
+ if (s[i] !== '(') return null;
153
+ return i + 1;
154
+ }
155
+
156
+ function lineHasUnreachableOffGate(line) {
157
+ let quote = null;
158
+ for (let i = 0; i < line.length; i += 1) {
159
+ const ch = line[i];
160
+ if (quote) {
161
+ if (ch === '\\' && i + 1 < line.length) {
162
+ i += 1;
163
+ continue;
164
+ }
165
+ if (ch === quote) quote = null;
166
+ continue;
167
+ }
168
+
169
+ if (ch === "'" || ch === '"' || ch === '`') {
170
+ quote = ch;
171
+ continue;
172
+ }
173
+
174
+ const firstArgStart = getCallArgStartAt(line, i);
175
+ if (firstArgStart === null) continue;
176
+
177
+ const firstArg = parseLiteralConcat(line, firstArgStart);
178
+ if (!firstArg || !firstArg.value.startsWith('messaging.')) continue;
179
+
180
+ let j = skipWs(line, firstArg.end);
181
+ if (line[j] !== ',') continue;
182
+ j = skipWs(line, j + 1);
183
+ if (line.startsWith('false', j) && !/[$_\p{ID_Continue}]/u.test(line[j + 5] ?? '')) {
184
+ return true;
185
+ }
186
+ }
187
+ return false;
188
+ }
189
+
45
190
  /** Scan raw source text; return 1-indexed line numbers of un-suppressed offenders. */
46
191
  export function scanText(text) {
47
- const lines = text.split('\n');
192
+ const lines = stripCommentsPreserveLines(text).split('\n');
193
+ const originalLines = text.split('\n');
48
194
  const hits = [];
49
195
  lines.forEach((line, i) => {
50
- if (!UNREACHABLE_OFF_GATE.test(line)) return;
51
- if (SUPPRESS.test(line)) return;
52
- if (i > 0 && SUPPRESS.test(lines[i - 1])) return;
196
+ if (!lineHasUnreachableOffGate(line)) return;
197
+ if (SUPPRESS.test(originalLines[i])) return;
198
+ if (i > 0 && SUPPRESS.test(originalLines[i - 1])) return;
53
199
  hits.push(i + 1);
54
200
  });
55
201
  return hits;
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-08-15T00:08:31.593Z",
5
- "instarVersion": "1.3.1149",
4
+ "generatedAt": "2026-08-15T00:48:31.405Z",
5
+ "instarVersion": "1.3.1151",
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.1149",
5
+ "packageVersion": "1.3.1151",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "1ed8f5bd6959a002ef362c9be5f88a382b0956d4992b16879350851ce3711ac9",
2
+ "sha256": "2781a4e62937b5faedcefb2635981134a4f27ba058786b5d16b5017f41b017e9",
3
3
  "registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
4
- "packageVersion": "1.3.1149"
4
+ "packageVersion": "1.3.1151"
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.1149"
5
+ "packageVersion": "1.3.1151"
6
6
  }
@@ -0,0 +1,21 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ - Strengthened the unreachable messaging gate lint so it detects default-off `messaging.*` LiveConfig keys built from literal string concatenation.
9
+
10
+ ## What to Tell Your User
11
+
12
+ None — internal change (no user-facing surface).
13
+
14
+ ## Summary of New Capabilities
15
+
16
+ None — internal change (no user-facing surface).
17
+
18
+ ## Evidence
19
+
20
+ - `npx vitest run tests/unit/lint-no-unreachable-messaging-gate.test.ts`
21
+ - `node scripts/lint-no-unreachable-messaging-gate.js`
@@ -0,0 +1,62 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ `scripts/lint-no-unfunneled-topic-creation.js` now resolves the Telegram method name before applying
9
+ its rules.
10
+
11
+ That lint enforces the **Bounded Notification Surface** standard — the last-resort budget on
12
+ automatically-created forum topics, added after the third topic-spam incident. Its three patterns each
13
+ required `createForumTopic` as a string LITERAL adjacent to the seam, so all three of these reached the
14
+ Bot API uncounted while the build reported `clean`:
15
+
16
+ ```js
17
+ const M = 'createForumTopic'; apiCall(M, { name: 'x' });
18
+ apiCall('createForum' + 'Topic', { name: 'x' });
19
+ const M = 'createForumTopic'; ({ method: M, name: 'x' });
20
+ ```
21
+
22
+ Measured against the shipped lint with a positive control (the bare literal) firing in the same run:
23
+ control caught, all three EVADE.
24
+
25
+ None of that is evasive — lifting a repeated string into a named constant is ordinary tidying. Someone
26
+ could step around a notification-flood ceiling while making code *nicer*, and nothing would say a word.
27
+
28
+ Added: `foldAdjacentLiterals` (folds `'a' + 'b'` only, never across an identifier),
29
+ `collectStringConsts` (per-file identifier → literal; an identifier bound twice to different values is
30
+ dropped as unresolvable), `resolveLine` (substitutes ONLY at the two seam positions), and `scanFile`
31
+ (extracted so matching is testable). `PATTERNS`, `ALLOWLIST` and the violation message are unchanged.
32
+
33
+ Also added: a **direct-invocation guard**. Importing this module previously ran the whole repo scan and
34
+ called `process.exit(1)` on the first real violation, killing the importing process — which is why its
35
+ internals had never been unit-tested. Three other lints hit the same hazard this week.
36
+
37
+ ## What to Tell Your User
38
+
39
+ Nothing changes for you. A build-time check that stops features from creating unlimited Telegram topics
40
+ could be walked past by giving a string a name first — an ordinary bit of tidying, not a trick. It now
41
+ sees through that. Nothing you use behaves differently; a category of notification flood just got harder
42
+ to ship by accident.
43
+
44
+ ## Summary of New Capabilities
45
+
46
+ None. No new command, endpoint, setting, or runtime behaviour. A CI guard that was defeatable by a local
47
+ constant is no longer defeatable by one.
48
+
49
+ ## Evidence
50
+
51
+ - `tests/unit/topic-creation-lint-resolution.test.ts` — 18/18 green.
52
+ - **Negative control: 5 of 18 fail** against the shipped matching behaviour; the other 13 pass both ways
53
+ and are the controls. Source restored byte-exact afterwards (sha match, zero markers left).
54
+ - Six anti-over-block controls, because this lint blocks commits: a const bound elsewhere, an identifier
55
+ that never reaches a seam, an identifier with conflicting bindings, a concatenation involving an
56
+ identifier, a longer look-alike method name, and seam-local (not global) substitution.
57
+ - Real tree: `exit 0` before AND after — no new flags on existing code.
58
+ - Full `npm run lint` chain green (57 steps); lint-chain membership verified explicitly rather than
59
+ inferred from a `package.json` reference.
60
+ - **Declared open in the source:** cross-module names, runtime-built names, and anything needing
61
+ dataflow. Also named: the lint does not strip comments, which is pre-existing and neither fixed nor
62
+ worsened here.
@@ -0,0 +1,117 @@
1
+ # Side-Effects Review - Split-Key Messaging Gate Lint
2
+
3
+ **Version / slug:** `lint-split-key-messaging-gate`
4
+ **Date:** `2026-08-14`
5
+ **Author:** `Instar-codey`
6
+ **Second-pass reviewer:** `Locke`
7
+
8
+ ## Summary of the change
9
+
10
+ This change strengthens `scripts/lint-no-unreachable-messaging-gate.js` so the existing unreachable default-off `messaging.*` LiveConfig lint also recognizes first-argument string literals joined with `+`, for example `liveConfig.get('messaging.' + 'actionClaim.enabled', false)`. The focused unit test file `tests/unit/lint-no-unreachable-messaging-gate.test.ts` now covers the reproduced split-key miss and the required controls. The release fragment is `upgrades/next/lint-split-key-messaging-gate.md`. Build location was a fresh worktree at `.worktrees/codey-lint-split-key-messaging-gate` on `JKHeadley/instar`, version `1.3.1146`, branched from current `origin/main`.
11
+
12
+ ## Decision-point inventory
13
+
14
+ - `scripts/lint-no-unreachable-messaging-gate.js` - modify - build-time decision that flags source lines where `LiveConfig.get` uses a `messaging.*` key with literal `false` default.
15
+ - Release note internal-only classification - pass-through - this is a script/test/docs-only change with no shipped `src/` runtime surface.
16
+
17
+ ---
18
+
19
+ ## 1. Over-block
20
+
21
+ The main legitimate input risk is a dynamic backtick template such as `` liveConfig.get(`messaging.${featureKey}`, false) `` being treated as a constant key. The implementation rejects template expressions during literal parsing, and the unit suite includes that negative control. Second-pass review also found that the first implementation could flag a `.get(...)` example embedded inside another string literal; the scanner now only recognizes `.get` tokens outside ordinary string literal text, and a regression test covers `const example = "liveConfig.get('messaging.' + 'actionClaim.enabled', false)"`. A literal-concat non-messaging key such as `liveConfig.get('monitoring.' + 'burnDetection.enabled', false)` is explicitly covered and remains accepted. A default-true split key remains accepted because the lint is specifically about default-off gates staying unreachable.
22
+
23
+ ---
24
+
25
+ ## 2. Under-block
26
+
27
+ The lint still misses non-literal construction such as `liveConfig.get('messaging.' + featureKey, false)` or helper-returned keys. That is intentional for this change: widening into dataflow or general expression evaluation would raise false-positive risk and would be a different guard. The known reproduced bypass is literal-plus-literal construction, and that class is now covered.
28
+
29
+ ---
30
+
31
+ ## 3. Level-of-abstraction fit
32
+
33
+ This is the right layer for the fix. The problem is a static source spelling that hides an unreachable configuration key. A build lint can catch the enumerable literal shape cheaply before release. A runtime gate would be later, noisier, and would still allow dead code to ship. The implementation uses a narrow scanner rather than general JavaScript evaluation, which fits the lint's existing design.
34
+
35
+ ---
36
+
37
+ ## 4. Signal vs authority compliance
38
+
39
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
40
+
41
+ - [ ] No - this change produces a signal consumed by an existing smart gate.
42
+ - [ ] No - this change has no block/allow surface.
43
+ - [ ] Yes - but the logic is a smart gate with full conversational context (LLM-backed with recent history or equivalent).
44
+ - [x] Yes, but over a hard enumerable source invariant rather than a competing-signals judgment point.
45
+
46
+ This lint has blocking authority in the build, but it is not deciding from brittle runtime context. It enforces a concrete invariant: a literal `messaging.*` config key with literal `false` default is unreachable through the expected top-level config surface. The new part only folds literal string pieces before applying that same invariant.
47
+
48
+ ---
49
+
50
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
51
+
52
+ No new static heuristic at a competing-signals decision point. The domain is enumerable source text: literal string values, `+` separators, and the literal `false` default. There are no live signals such as ownership, urgency, recency, or user intent to arbitrate.
53
+
54
+ ---
55
+
56
+ ## 5. Interactions
57
+
58
+ - **Shadowing:** This replaces the prior single-regex detection path inside the same lint. It does not run before another checker or suppress another result.
59
+ - **Double-fire:** One line produces one hit through `scanText`; the scanner returns line numbers as before.
60
+ - **Races:** No shared runtime state. The script reads the source tree and exits.
61
+ - **Feedback loops:** No feedback loop. The output feeds developer/build action only.
62
+
63
+ ---
64
+
65
+ ## 6. External surfaces
66
+
67
+ No user-facing runtime surface changes. Other agents and users only see the effect when developing Instar: split-literal unreachable messaging gates fail the lint. No external service calls, no persistent state changes, no generated URLs, no timing dependency, and no operator-facing action surface.
68
+
69
+ ---
70
+
71
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
72
+
73
+ No operator surface - not applicable.
74
+
75
+ ---
76
+
77
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
78
+
79
+ Machine-local by design: this is a source-tree lint that runs in the checkout performing the build. It holds no durable agent state, emits no user-facing notices, and generates no URLs. Multi-machine coherence is handled by committing the script/tests/release notes to git so every machine receives the same lint behavior after update.
80
+
81
+ ---
82
+
83
+ ## 8. Rollback cost
84
+
85
+ Pure code/test/docs change. Rollback is a hot-fix revert of the lint parser and its tests plus the release fragment. No data migration, no agent state repair, and no user-visible runtime regression during rollback.
86
+
87
+ ---
88
+
89
+ ## Conclusion
90
+
91
+ The review narrowed the implementation to literal-only first-argument folding, added a negative test for template expressions, and resolved second-pass's string-example false-positive concern by scanning for `.get` only outside string literal text. The change is clear to ship with the known caveat that dynamic key construction remains out of scope for this lint.
92
+
93
+ ---
94
+
95
+ ## Second-pass review (if required)
96
+
97
+ **Reviewer:** `Locke`
98
+ **Independent read of the artifact:** `concern, resolved`
99
+
100
+ Initial concern: the first implementation scanned for `.get` across preserved string contents, so a non-executed example string such as `const example = "liveConfig.get('messaging.' + 'actionClaim.enabled', false)"` could be reported. Resolution: `lineHasUnreachableOffGate` now locates `.get` only while outside string literal text, and `tests/unit/lint-no-unreachable-messaging-gate.test.ts` includes the example-string negative control. Dynamic concatenation and template expressions remained out of scope as intended.
101
+
102
+ ---
103
+
104
+ ## Evidence pointers
105
+
106
+ - Shipped-lint reproduction before the fix: split key returned `hits: []`, counted as `failureCount: 1`.
107
+ - After fix direct probe: split key returns `hits: [1]`.
108
+ - Focused suite: `npx vitest run tests/unit/lint-no-unreachable-messaging-gate.test.ts` passed `15/15` after the second-pass fix.
109
+ - Real tree: `node scripts/lint-no-unreachable-messaging-gate.js` exited clean with no existing flags.
110
+ - Full lint: `npm run lint` exited clean.
111
+ - Full push suite: `npm run test:push` ran 44,154 tests; 44,121 passed, 27 skipped, 3 todo, and 3 failed. Two failures were live Gemini e2e tests requiring absent `GEMINI_API_KEY`; the third feedback-drain ordering failure reran green in isolation.
112
+
113
+ ---
114
+
115
+ ## Class-Closure Declaration (display-only mirror)
116
+
117
+ No agent-authored-artifact defect - not applicable. This fixes a source-code lint detection gap and adds direct regression tests for the reproduced shape.
@@ -0,0 +1,135 @@
1
+ # Side-Effects Review — topic-creation guard resolves the method name
2
+
3
+ **Version / slug:** `topic-creation-lint-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 is unchanged; the check now resolves one level of naming before applying the rules it already had.`
7
+
8
+ ## Summary of the change
9
+
10
+ `scripts/lint-no-unfunneled-topic-creation.js` enforces the **Bounded Notification Surface** standard —
11
+ the last-resort budget on automatically-created forum topics, added after the THIRD topic-spam incident
12
+ (2026-06-05). All three of its patterns required `createForumTopic` as a string LITERAL adjacent to the
13
+ seam, so a resolved name walked past a safety floor.
14
+
15
+ Measured against the shipped lint, with a positive control (the bare literal) firing in the same run:
16
+
17
+ | form | shipped |
18
+ |---|---|
19
+ | `apiCall("createForumTopic", …)` — POSITIVE CONTROL | exit 1 (caught) |
20
+ | `const M = "createForumTopic"; apiCall(M, …)` | **exit 0 — EVADES** |
21
+ | `apiCall("createForum" + "Topic", …)` | **exit 0 — EVADES** |
22
+ | `const M = "createForumTopic"; { method: M }` | **exit 0 — EVADES** |
23
+
24
+ The method name is now RESOLVED before the existing patterns are applied. The patterns are byte-identical;
25
+ only what they can see changed.
26
+
27
+ ## Decision-point inventory
28
+
29
+ - `foldAdjacentLiterals(text)` — ADD — folds `'a' + 'b'` only; never folds across an identifier.
30
+ - `collectStringConsts(lines)` — ADD — per-file identifier → string-literal map; an identifier bound twice
31
+ to DIFFERENT values is dropped as unresolvable.
32
+ - `resolveLine(line, consts)` — ADD — substitutes ONLY at the two seam positions (`apiCall(` and `method:`).
33
+ - `scanFile(normalized, content)` — ADD — extracted so the matching behaviour is testable without the CLI.
34
+ - Direct-invocation guard — ADD — the scan runs only when the script is invoked directly.
35
+ - `PATTERNS`, `ALLOWLIST`, `SCAN_DIRS`, `EXTENSIONS`, the violation message and exit codes — UNCHANGED.
36
+ - No runtime block/allow decision added or modified. CI-time only.
37
+
38
+ ## 1. Over-block
39
+
40
+ The failure that matters. This lint blocks commits, and a check that flags correct code gets switched
41
+ off — which would cost more than the hole it closes. Six controls, each with a test, all passing under
42
+ BOTH the old and new behaviour:
43
+
44
+ - **A const bound to a different method** (`"sendMessage"`) is not flagged.
45
+ - **An identifier that never reaches a seam** is not flagged — holding the value is not calling with it.
46
+ - **An identifier bound twice to different values** is unresolvable and never substituted. Ambiguity
47
+ fails toward NOT flagging, because a guess that fails someone's build is the expensive direction.
48
+ - **A concatenation involving an identifier** (`"createForum" + suffix`) is left unresolved. Folding it
49
+ would mean inventing text the source does not contain.
50
+ - **A longer name starting with the method** (`createForumTopicIconStickers`) is not flagged — the
51
+ existing quote-delimited patterns already bound this, and resolution preserves it.
52
+ - **Substitution is seam-local, not global** — an unrelated `logger.debug(M)` is untouched.
53
+
54
+ Resolution is per-file by construction: one file's names cannot affect another's.
55
+
56
+ **Verified against the real tree: exit 0 before AND after.** No new flags on existing code.
57
+
58
+ ## 2. Under-block
59
+
60
+ Stated in the source rather than implied:
61
+
62
+ - **Cross-module names** — a method name imported from another file is not followed.
63
+ - **Runtime-built names** — from a variable, a function call, or a template literal.
64
+ - **Anything needing dataflow** to resolve.
65
+
66
+ Guessing at those would over-match.
67
+
68
+ **One residual I want named rather than absorbed:** the lint does not strip comments, so a comment
69
+ containing the literal form is flagged. That is PRE-EXISTING behaviour (it is why this script is on its
70
+ own allowlist), and this change neither fixes nor worsens it — resolution runs on the same lines the
71
+ patterns already ran on. I am not changing it here because loosening a flood guard to be tidier is the
72
+ wrong direction, and doing it unrequested is the unrequested-tightening mistake in reverse.
73
+
74
+ ## 3. Level-of-abstraction fit
75
+
76
+ Same layer as the existing check — line-oriented regex over raw source, no AST, no type information, no
77
+ new dependency. The resolution map is the minimum needed to answer "what method name is at this seam?"
78
+ without climbing to a parser.
79
+
80
+ ## 4. Signal vs authority compliance
81
+
82
+ Unchanged. A CI guard, not a runtime authority. It pushes callers toward the budgeted funnel; the
83
+ allowlist still exempts the funnel itself, the lifeline's fixed-cardinality system topic, and the
84
+ setup-wizard doc string.
85
+
86
+ ## 5. Interactions
87
+
88
+ - `npm run lint` chain — membership verified explicitly (the script is in the `lint` chain CI runs, not
89
+ merely referenced by a standalone entry); full chain exit 0 across 57 steps.
90
+ - The direct-invocation guard changes import behaviour from "runs the repo scan and may exit(1)" to
91
+ "exports only". Nothing imported this module before, so no caller changes.
92
+ - No source module, route, config key, or state file touched.
93
+
94
+ ## 6. External surfaces
95
+
96
+ None. Developer tooling, not an agent capability; the Agent Awareness Standard does not apply.
97
+
98
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
99
+
100
+ **Machine-local BY DESIGN, and it is the correct posture — not an unexamined assumption.** This is a
101
+ CI-time source scan with no runtime surface: it reads files in one checkout and exits. It holds no
102
+ durable state, emits no user-facing notice, generates no URL, and makes no runtime decision, so there
103
+ is nothing to replicate, nothing to merge on read, and nothing that could strand on a topic transfer.
104
+
105
+ Every machine that runs the lint runs it over its own checkout of the same tracked source and reaches
106
+ the same verdict — determinism comes from the source tree, not from coordination. Resolution is
107
+ explicitly per-file, so it cannot even depend on the rest of the checkout, let alone another machine.
108
+
109
+ Worth stating because the audit that added this question found ~20 features shipped machine-blind: the
110
+ thing that makes THIS one genuinely local is that its input is version-controlled and its output is an
111
+ exit code, not that I could not think of a cross-machine concern.
112
+
113
+ ## 8. Rollback cost
114
+
115
+ `git revert` of one script plus the added test file. No migration, no state, no deployed artifact.
116
+
117
+ ## Conclusion
118
+
119
+ Ship. Three evasions closed on a safety floor, six anti-over-block controls added, real tree verified
120
+ clean in both directions, and the import hazard that blocked testing it removed.
121
+
122
+ ## Evidence pointers
123
+
124
+ - `tests/unit/topic-creation-lint-resolution.test.ts` — **18/18 green**.
125
+ - **Negative control: 5 of 18 fail** against the shipped matching behaviour (local const, concatenation,
126
+ `method:` const, let/var bindings, const-declared-after-use). The other 13 pass both ways — which is
127
+ what makes them controls. Source restored byte-exact after the mutation (sha match, 0 markers left).
128
+ - Reproduced by hand FIRST with a positive control firing in the same run; the control is what makes the
129
+ three EVADES verdicts mean anything.
130
+ - Real-tree verdict: `node scripts/lint-no-unfunneled-topic-creation.js` → exit 0, before and after.
131
+ - Full `npm run lint` chain green, 57 steps.
132
+ - Tier **1** declared: risk floor 1 (no safety-invariant path match — verified with two directional
133
+ controls, `SessionReaper.ts` → floor 2 with its reason named, `devClaimCheck.ts` → floor 1). The size
134
+ heuristic suggests 2 on added LOC alone; stated openly rather than left silent, since the tier I chose
135
+ is the one that lets the change through.