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.
- 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-no-unfunneled-topic-creation.js +123 -25
- package/scripts/lint-no-unreachable-messaging-gate.js +150 -4
- 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.1150.md +21 -0
- package/upgrades/1.3.1151.md +62 -0
- package/upgrades/side-effects/lint-split-key-messaging-gate.md +117 -0
- package/upgrades/side-effects/topic-creation-lint-resolution.md +135 -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.1151",
|
|
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": "2781a4e62937b5faedcefb2635981134a4f27ba058786b5d16b5017f41b017e9",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1151"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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 (!
|
|
51
|
-
if (SUPPRESS.test(
|
|
52
|
-
if (i > 0 && SUPPRESS.test(
|
|
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:
|
|
5
|
-
"instarVersion": "1.3.
|
|
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.
|
|
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": "
|
|
2
|
+
"sha256": "2781a4e62937b5faedcefb2635981134a4f27ba058786b5d16b5017f41b017e9",
|
|
3
3
|
"registrySha256": "81b53363a440e832672618965540b3e507ae0d93adcc67ec2b93daf7933b3ab4",
|
|
4
|
-
"packageVersion": "1.3.
|
|
4
|
+
"packageVersion": "1.3.1151"
|
|
5
5
|
}
|
|
@@ -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.
|