@shidesheng0218/dsh-plugin-lint 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,291 @@
1
+ {
2
+ "$comment": "Session event types observed in the official DeepSeek Harness packages. Regenerate with scripts/harvest-event-types.mjs against an installed DSH.",
3
+ "source": "DeepSeek Harness (MIT); harvested from @deepseek-ai/* lib bundles",
4
+ "harvestedAt": "2026-10-13",
5
+ "filesScanned": 470,
6
+ "appendSiteTypes": 45,
7
+ "observedTypes": 280,
8
+ "types": [
9
+ "agent-loop/config-start-failed",
10
+ "agent-preset/conflict",
11
+ "agent-preset/invalid",
12
+ "agent-preset/locked",
13
+ "agent-preset/not-found",
14
+ "agent-preset/selected",
15
+ "agent/assistant-stream",
16
+ "agent/created",
17
+ "agent/disposed",
18
+ "agent/error",
19
+ "agent/pre-step",
20
+ "agent/request",
21
+ "agent/request-error",
22
+ "agent/status",
23
+ "agent/turn-stopping",
24
+ "api-session/activity",
25
+ "api-session/added",
26
+ "api-session/error",
27
+ "api-session/removed",
28
+ "api-session/status",
29
+ "app-boot/config-reload",
30
+ "approval/asked",
31
+ "approval/decided",
32
+ "approval/policy",
33
+ "approval/request",
34
+ "assistant/attempt",
35
+ "assistant/chunk",
36
+ "assistant/live-chunk",
37
+ "assistant/message",
38
+ "authorization/settled",
39
+ "command/done",
40
+ "command/executed",
41
+ "command/run",
42
+ "commands/change",
43
+ "compact/end",
44
+ "compact/prune",
45
+ "compact/start",
46
+ "compact/summary",
47
+ "compaction/end",
48
+ "compaction/prune",
49
+ "compaction/start",
50
+ "compaction/summary",
51
+ "compaction/summary-error",
52
+ "connection/request",
53
+ "connection/reset",
54
+ "cordis/dynamic-package",
55
+ "cordis/dynamic-retract",
56
+ "cordis/inspect-query",
57
+ "cordis/inspect-query-resolved",
58
+ "cordis/request-run",
59
+ "cordis/request-run-resolved",
60
+ "credential/rejected",
61
+ "credentials/describe",
62
+ "credentials/record-updated",
63
+ "credentials/reference-updated",
64
+ "credentials/set",
65
+ "deepseek-account/model-sign-in-required",
66
+ "deepseek-account/session-expired",
67
+ "deepseek-account/signed-out",
68
+ "deliverables/presented",
69
+ "developer/message",
70
+ "directory-picker/create-failed",
71
+ "directory-picker/exists",
72
+ "directory-picker/unavailable",
73
+ "directory-picker/unreadable",
74
+ "document-render/failed",
75
+ "domain/changed",
76
+ "dsh-commands/brand",
77
+ "dsh-llm/brand",
78
+ "ecdsa/rsa",
79
+ "eventsource-parser/stream",
80
+ "f/aqlbg",
81
+ "f/gfp",
82
+ "feedback/committed",
83
+ "feedback/message-delete",
84
+ "feedback/message-put",
85
+ "feedback/record",
86
+ "fmwvqz/sqb",
87
+ "fmwvqz/sqlefppjlmbopmf",
88
+ "foo/bar",
89
+ "fs/edit-intent",
90
+ "fs/observed",
91
+ "fs/promises",
92
+ "fs/write-intent",
93
+ "gateway/ambiguous-endpoint",
94
+ "gateway/arguments-invalid",
95
+ "gateway/bad-request",
96
+ "gateway/binding-invalid",
97
+ "gateway/cancelled",
98
+ "gateway/context-failed",
99
+ "gateway/context-not-found",
100
+ "gateway/context-unavailable",
101
+ "gateway/definition-unavailable",
102
+ "gateway/input-invalid",
103
+ "gateway/internal",
104
+ "gateway/invocation-unavailable",
105
+ "gateway/lookup-failed",
106
+ "gateway/lookup-not-found",
107
+ "gateway/lookup-unavailable",
108
+ "gateway/method-unavailable",
109
+ "gateway/protocol",
110
+ "gateway/provider-mismatch",
111
+ "gateway/result-invalid",
112
+ "gateway/service-unavailable",
113
+ "gateway/signature-invalid",
114
+ "gateway/uplink-overflow",
115
+ "goal/activation-changed",
116
+ "goal/change",
117
+ "goal/changed",
118
+ "hmr/change",
119
+ "hmr/reload",
120
+ "hook/invoked",
121
+ "hook/result",
122
+ "image/offload",
123
+ "internal/config",
124
+ "internal/dispatch",
125
+ "internal/get",
126
+ "internal/listener",
127
+ "internal/plugin",
128
+ "internal/service",
129
+ "internal/set",
130
+ "internal/status",
131
+ "internal/update",
132
+ "job/not-found",
133
+ "kb/mqfbgj",
134
+ "llm/adapters-updated",
135
+ "llm/model-discovery-rejected",
136
+ "llm/retry",
137
+ "llm/retry-started",
138
+ "llm/stream",
139
+ "loader/config-update",
140
+ "loader/entry-init",
141
+ "loader/partial-dispose",
142
+ "loader/patch-context",
143
+ "loader/volatile-update",
144
+ "locale/change",
145
+ "m/h",
146
+ "m/hr",
147
+ "m/s",
148
+ "m/sec",
149
+ "mm/dd",
150
+ "mode/set",
151
+ "model/selection",
152
+ "permission-presets/catalog-changed",
153
+ "permission/preset",
154
+ "plan/invalid-address",
155
+ "plan/mode",
156
+ "plan/not-found",
157
+ "plan/read-failed",
158
+ "plan/unavailable",
159
+ "plugin-manager/changed",
160
+ "plugin-manager/install-log",
161
+ "plugin-manager/install-state",
162
+ "request/context",
163
+ "request/header",
164
+ "request/header-delta",
165
+ "resources/list",
166
+ "resources/read",
167
+ "sandbox/mode",
168
+ "schedule/change",
169
+ "schedule/changed",
170
+ "schedule/list",
171
+ "session-log-deepseek/delivery-accepted",
172
+ "session-telemetry/record",
173
+ "session/agent-busy",
174
+ "session/attachment-invalid",
175
+ "session/conflict",
176
+ "session/created",
177
+ "session/disposed",
178
+ "session/end-seed",
179
+ "session/event",
180
+ "session/flush",
181
+ "session/fork-unavailable",
182
+ "session/invalid-time-zone",
183
+ "session/model-unavailable",
184
+ "session/not-found",
185
+ "session/projections-unavailable",
186
+ "session/prompt",
187
+ "session/provider-credentials-unavailable",
188
+ "session/provider-models-unavailable",
189
+ "session/queue-item-not-found",
190
+ "session/steer-unavailable",
191
+ "session/title",
192
+ "session/title-invalid",
193
+ "session/title-llm-request",
194
+ "session/workspace-attach-failed",
195
+ "session/writer-held",
196
+ "settings/conflict",
197
+ "settings/document-updated",
198
+ "settings/rejected",
199
+ "shiki/core",
200
+ "skills/change",
201
+ "slash/input-begin-command",
202
+ "slash/input-consume-token",
203
+ "slash/input-insert-reference",
204
+ "slash/input-insert-text",
205
+ "slots/changed",
206
+ "speech/invalid-audio",
207
+ "speech/transcription-failed",
208
+ "steering/message",
209
+ "step/end",
210
+ "step/start",
211
+ "subagent/attachment-invalid",
212
+ "subagent/catalog",
213
+ "subagent/catalog-diagnostic",
214
+ "subagent/delivery-unavailable",
215
+ "subagent/descriptor",
216
+ "subagent/end",
217
+ "subagent/invalid-time-zone",
218
+ "subagent/model-selection-policy",
219
+ "subagent/not-found",
220
+ "subagent/not-resumable",
221
+ "subagent/parent-unavailable",
222
+ "subagent/provider-added",
223
+ "subagent/provider-removed",
224
+ "subagent/start",
225
+ "subagent/unauthorized",
226
+ "system-prompt/assemble",
227
+ "system-prompt/change",
228
+ "system/message",
229
+ "team/member",
230
+ "team/task",
231
+ "terminal/control-unavailable",
232
+ "terminal/limit-reached",
233
+ "terminal/unavailable",
234
+ "terminal/view",
235
+ "theme/change",
236
+ "todo/write",
237
+ "tool-workflow/agent-end",
238
+ "tool-workflow/agent-start",
239
+ "tool-workflow/run-end",
240
+ "tool-workflow/run-start",
241
+ "tool/call",
242
+ "tool/code-dispatch",
243
+ "tool/code-dispatch-start",
244
+ "tool/ptc-dispatch",
245
+ "tool/ptc-dispatch-start",
246
+ "tool/result",
247
+ "tools/call",
248
+ "tools/change",
249
+ "tools/execute",
250
+ "tools/list",
251
+ "tools/post-execute",
252
+ "tools/pre-execute",
253
+ "tools/ptc-dispatch-log",
254
+ "tools/result",
255
+ "tsx/esm",
256
+ "turn/end",
257
+ "turn/start",
258
+ "user-questions/request",
259
+ "user/message",
260
+ "v3/opaque-released-event",
261
+ "web/deepseek-search-llm-request",
262
+ "webserver/index-inject",
263
+ "workflow/agent-end",
264
+ "workflow/agent-start",
265
+ "workflow/end",
266
+ "workflow/log",
267
+ "workflow/phase",
268
+ "workflow/start",
269
+ "workspace-file/not-directory",
270
+ "workspace-file/not-found",
271
+ "workspace-file/not-regular-file",
272
+ "workspace-file/not-text",
273
+ "workspace-file/outside-workspace",
274
+ "workspace-file/too-large",
275
+ "workspace-file/unknown-workspace",
276
+ "workspace-file/unsupported-address",
277
+ "workspace-file/watch-unsupported",
278
+ "workspace/changes",
279
+ "workspace/invalid-path",
280
+ "workspace/move-invalid",
281
+ "workspace/name-conflict",
282
+ "workspace/not-found",
283
+ "workspace/session-active",
284
+ "workspace/session-activity",
285
+ "workspace/session-stop",
286
+ "x/name",
287
+ "zustand/middleware",
288
+ "zustand/shallow",
289
+ "zustand/vanilla"
290
+ ]
291
+ }
package/src/index.js ADDED
@@ -0,0 +1,76 @@
1
+ import { loadPackage } from './load.js';
2
+ import { RULES, EVENT_TYPES, localeMeta, DOC } from './rules.js';
3
+
4
+ export { RULES, EVENT_TYPES, DOC };
5
+ export { maskSource } from './mask.js';
6
+ export { loadPackage, filesCover } from './load.js';
7
+ export { formatText, toSarif } from './report.js';
8
+
9
+ const LEVEL_ORDER = { error: 0, warn: 1, info: 2 };
10
+
11
+ /** Statements every report carries, so a clean run is never read as an endorsement. */
12
+ const LIMITS = [
13
+ 'Static analysis of files on disk: it reports what it detected and makes no security judgement.',
14
+ 'Line numbers in bundled or minified artifacts point at the bundle, not at the original source.',
15
+ 'A rule cites the upstream document it encodes; the installed Harness remains the authority.',
16
+ ];
17
+
18
+ export function lintPackage(dir, options = {}) {
19
+ const pkg = loadPackage(dir);
20
+ const manifestText = pkg.text('package.json') || '';
21
+ const ctx = Object.assign({}, pkg, {
22
+ manifestText,
23
+ byPath: (rel) => pkg.jsFiles.find((f) => f.path === rel),
24
+ localeFiles: () => (pkg.entries.localeDir ? localeMeta(dir, pkg.entries.localeDir) : []),
25
+ });
26
+
27
+ const findings = [];
28
+ for (const rule of RULES) {
29
+ const push = (finding) => {
30
+ findings.push(Object.assign({ rule: rule.id, level: rule.level, title: rule.title, doc: rule.doc }, finding));
31
+ };
32
+ try {
33
+ rule.check(ctx, options, push);
34
+ } catch (error) {
35
+ findings.push({
36
+ rule: rule.id,
37
+ level: 'info',
38
+ title: rule.title,
39
+ doc: rule.doc,
40
+ file: 'package.json',
41
+ line: 1,
42
+ message: 'rule did not complete: ' + error.message,
43
+ evidence: '',
44
+ hint: 'This is a bug in dsh-plugin-lint; please report it.',
45
+ });
46
+ }
47
+ }
48
+
49
+ findings.sort((a, b) => LEVEL_ORDER[a.level] - LEVEL_ORDER[b.level]
50
+ || String(a.file).localeCompare(String(b.file))
51
+ || (a.line || 0) - (b.line || 0)
52
+ || a.rule.localeCompare(b.rule));
53
+
54
+ const summary = { error: 0, warn: 0, info: 0 };
55
+ for (const f of findings) summary[f.level] += 1;
56
+
57
+ const declared = (pkg.manifest.dsh && pkg.manifest.dsh.compatibility
58
+ && pkg.manifest.dsh.compatibility.dshReleases) || undefined;
59
+
60
+ return {
61
+ schemaVersion: 1,
62
+ target: {
63
+ name: pkg.manifest.name,
64
+ version: pkg.manifest.version,
65
+ path: dir,
66
+ },
67
+ dsh: {
68
+ declared: declared ? Object.keys(declared) : [],
69
+ verified: [],
70
+ },
71
+ checks: RULES.length,
72
+ summary,
73
+ findings,
74
+ limits: LIMITS,
75
+ };
76
+ }
package/src/load.js ADDED
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+ import { maskSource } from './mask.js';
4
+
5
+ /** Directories that never hold shipped plugin code. */
6
+ const SKIP_DIRS = new Set([
7
+ 'node_modules', '.git', '.github', '.husky', '.dsh-testkit', 'coverage', 'fixtures',
8
+ 'test', 'tests', '__tests__', '__snapshots__', 'dist-test',
9
+ ]);
10
+
11
+ /** Guard rails so one huge vendored file cannot stall a run. */
12
+ const MAX_FILE_BYTES = 3 * 1024 * 1024;
13
+ const MAX_FILES = 3000;
14
+
15
+ export function loadPackage(dir) {
16
+ const manifestPath = join(dir, 'package.json');
17
+ if (!existsSync(manifestPath)) throw new Error('not a package directory (no package.json): ' + dir);
18
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
19
+
20
+ const norm = (p) => (p === undefined ? undefined : String(p).replace(/^\.\//, ''));
21
+ // Node resolves an undeclared package entry to index.js, so the fallback is
22
+ // correct for resolution — but "nothing was declared" is not the same as
23
+ // "index.js was declared and is missing". A static asset package (measured:
24
+ // @deepseek-ai/dsh-web-frontend exports only ./dist/*) has no host entry at all,
25
+ // and flagging it would be a false positive.
26
+ const declaredHost = firstString(manifest.main) || exportTarget(manifest.exports, '.');
27
+ const entries = {
28
+ host: norm(declaredHost || 'index.js'),
29
+ hostDeclared: Boolean(declaredHost || (manifest.dsh && manifest.dsh.bundle)),
30
+ client: norm(exportTarget(manifest.exports, './client') || (manifest.dsh && manifest.dsh.client ? 'client.js' : undefined)),
31
+ patch: norm(manifest.dsh && manifest.dsh.bundle ? firstString(manifest.dsh.bundle.patch) : undefined),
32
+ icon: norm(firstString(manifest.icon)),
33
+ localeDir: existsSync(join(dir, 'locale')) ? 'locale' : undefined,
34
+ };
35
+
36
+ const jsFiles = [];
37
+ collect(dir, dir, jsFiles);
38
+
39
+ return {
40
+ dir,
41
+ manifest,
42
+ manifestPath,
43
+ entries,
44
+ jsFiles,
45
+ files: Array.isArray(manifest.files) ? manifest.files : undefined,
46
+ exists: (rel) => rel !== undefined && existsSync(join(dir, rel)),
47
+ text: (rel) => (existsSync(join(dir, rel)) ? readFileSync(join(dir, rel), 'utf8') : undefined),
48
+ json: (rel) => {
49
+ const t = existsSync(join(dir, rel)) ? readFileSync(join(dir, rel), 'utf8') : undefined;
50
+ if (t === undefined) return undefined;
51
+ try { return JSON.parse(t); } catch { return undefined; }
52
+ },
53
+ };
54
+ }
55
+
56
+ function collect(root, current, out) {
57
+ let names;
58
+ try { names = readdirSync(current, { withFileTypes: true }); } catch { return; }
59
+ for (const dirent of names) {
60
+ const abs = join(current, dirent.name);
61
+ if (dirent.isDirectory()) {
62
+ if (SKIP_DIRS.has(dirent.name)) continue;
63
+ collect(root, abs, out);
64
+ continue;
65
+ }
66
+ if (!/\.(js|mjs|cjs|jsx)$/.test(dirent.name)) continue;
67
+ let size = 0;
68
+ try { size = statSync(abs).size; } catch { continue; }
69
+ if (size > MAX_FILE_BYTES) continue;
70
+ if (out.length >= MAX_FILES) return;
71
+ const text = readFileSync(abs, 'utf8');
72
+ const rel = relative(root, abs).split(sep).join('/');
73
+ const { masked, structural, strings, lineStarts } = maskSource(text);
74
+ out.push({ path: rel, text, masked, structural, strings, lineStarts, bytes: size });
75
+ }
76
+ }
77
+
78
+ function firstString(value) {
79
+ return typeof value === 'string' && value !== '' ? value : undefined;
80
+ }
81
+
82
+ /** Resolve an exports subpath to a file path, accepting the string and {default} forms. */
83
+ export function exportTarget(exportsField, subpath) {
84
+ if (exportsField === undefined || exportsField === null) return undefined;
85
+ if (typeof exportsField === 'string') return subpath === '.' ? exportsField : undefined;
86
+ const entry = exportsField[subpath];
87
+ if (entry === undefined) return undefined;
88
+ if (typeof entry === 'string') return entry;
89
+ if (entry && typeof entry === 'object') return firstString(entry.default) || firstString(entry.import) || firstString(entry.require);
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * npm packing semantics, simplified: an entry covers a path when it is the same
95
+ * file, a parent directory, or a glob whose pattern matches. \`files\` absent means
96
+ * everything is published.
97
+ */
98
+ export function filesCover(files, relPath) {
99
+ if (files === undefined || files.length === 0) return true;
100
+ relPath = String(relPath).replace(/^\.\//, '');
101
+ for (const raw of files) {
102
+ const pattern = String(raw).replace(/^\.\//, '').replace(/\/+$/, '');
103
+ if (pattern === '' ) continue;
104
+ if (pattern === relPath) return true;
105
+ if (relPath.startsWith(pattern + '/')) return true;
106
+ if (pattern.includes('*')) {
107
+ const re = new RegExp('^' + pattern
108
+ .replace(/[.+^$()|[\]{}\\]/g, '\\$&')
109
+ .replace(/\*\*\//g, '(?:.*/)?')
110
+ .replace(/\*\*/g, '.*')
111
+ .replace(/(?<!\.)\*/g, '[^/]*') + '$');
112
+ if (re.test(relPath)) return true;
113
+ }
114
+ }
115
+ return false;
116
+ }
package/src/mask.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Comment- and string-aware scanner.
3
+ *
4
+ * Rules must never match text inside a comment (real plugins document the very
5
+ * patterns we look for, e.g. "this listener returns next()"). Two views:
6
+ *
7
+ * masked comments blanked, string literals INTACT — for matching quoted
8
+ * event names and reading literal values with exact line numbers.
9
+ * structural masked AND string interiors blanked — for brace-depth analysis.
10
+ */
11
+
12
+ const BACKTICK = '\u0060';
13
+
14
+ /** @returns {{ masked: string, structural: string, strings: Array<{value:string,start:number,end:number,line:number}>, lineStarts: number[] }} */
15
+ export function maskSource(text) {
16
+ const maskedChars = text.split('');
17
+ const structuralChars = text.slice().split('');
18
+ const strings = [];
19
+ const lineStarts = [0];
20
+ for (let k = 0; k < text.length; k++) if (text[k] === '\n') lineStarts.push(k + 1);
21
+
22
+ const NL = '\n';
23
+ const SP = ' ';
24
+ const blank = (from, to, chars) => {
25
+ for (let k = from; k < to; k++) if (chars[k] !== NL) chars[k] = SP;
26
+ };
27
+ // A '/' starts a regex only where a value may begin; otherwise it is division.
28
+ const regexAllowed = (prev) => prev === '' || '(,=:[!&|?{};+-*%<>~^'.indexOf(prev) >= 0;
29
+ const lastMeaningful = (idx) => {
30
+ for (let k = idx - 1; k >= 0; k--) {
31
+ const c = maskedChars[k];
32
+ if (c === SP || c === NL || c === '\t') continue;
33
+ return c;
34
+ }
35
+ return '';
36
+ };
37
+
38
+ let i = 0;
39
+ const n = text.length;
40
+ while (i < n) {
41
+ const c = text[i];
42
+ const next = text[i + 1];
43
+
44
+ if (c === '/' && next === '/') {
45
+ const from = i;
46
+ while (i < n && text[i] !== NL) i++;
47
+ blank(from, i, maskedChars);
48
+ blank(from, i, structuralChars);
49
+ continue;
50
+ }
51
+ if (c === '/' && next === '*') {
52
+ const from = i;
53
+ i += 2;
54
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
55
+ i = Math.min(n, i + 2);
56
+ blank(from, i, maskedChars);
57
+ blank(from, i, structuralChars);
58
+ continue;
59
+ }
60
+
61
+ // The literal delimiter itself, not a boolean: the closing delimiter must
62
+ // compare equal to it.
63
+ const quote = c === '"' ? '"' : c === "'" ? "'" : c === BACKTICK ? BACKTICK : undefined;
64
+ if (quote !== undefined) {
65
+ const from = i;
66
+ i += 1;
67
+ const contentFrom = i;
68
+ while (i < n) {
69
+ if (text[i] === '\\') { i += 2; continue; }
70
+ if (text[i] === quote) break;
71
+ if (text[i] === NL && quote !== BACKTICK) break; // unterminated literal
72
+ i += 1;
73
+ }
74
+ const contentTo = Math.min(i, n);
75
+ const end = Math.min(i + 1, n);
76
+ strings.push({ value: text.slice(contentFrom, contentTo), start: from, end, line: lineOfIndex(lineStarts, from) });
77
+ blank(contentFrom, contentTo, structuralChars);
78
+ i = end;
79
+ continue;
80
+ }
81
+
82
+ if (c === '/' && regexAllowed(lastMeaningful(i))) {
83
+ i += 1;
84
+ let inClass = false;
85
+ while (i < n) {
86
+ if (text[i] === '\\') { i += 2; continue; }
87
+ if (text[i] === '[') inClass = true;
88
+ else if (text[i] === ']') inClass = false;
89
+ else if (text[i] === '/' && !inClass) { i += 1; break; }
90
+ else if (text[i] === NL) break;
91
+ i += 1;
92
+ }
93
+ continue;
94
+ }
95
+ i += 1;
96
+ }
97
+ return { masked: maskedChars.join(''), structural: structuralChars.join(''), strings, lineStarts };
98
+ }
99
+
100
+ export function lineOfIndex(lineStarts, index) {
101
+ let lo = 0;
102
+ let hi = lineStarts.length - 1;
103
+ while (lo < hi) {
104
+ const mid = (lo + hi + 1) >> 1;
105
+ if (lineStarts[mid] <= index) lo = mid;
106
+ else hi = mid - 1;
107
+ }
108
+ return lo + 1;
109
+ }
110
+
111
+ /** The first string literal starting at or after \`from\` within \`window\` characters. */
112
+ export function stringAfter(strings, from, window) {
113
+ for (const s of strings) {
114
+ if (s.start >= from) return s.start - from <= window ? s : undefined;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ /** Literal types passed to session.append("type", data) call sites. */
120
+ export function appendCallSites(masked, strings) {
121
+ const out = [];
122
+ const re = /\.append\(\s*/g;
123
+ let m;
124
+ while ((m = re.exec(masked)) !== null) {
125
+ const s = stringAfter(strings, m.index + m[0].length, 32);
126
+ if (s) out.push({ type: s.value, index: s.start, line: s.line });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ /** The balanced brace block that starts at or after `index`, on the structural view. */
132
+ export function blockAfter(structural, index) {
133
+ const open = structural.indexOf('{', index);
134
+ if (open < 0) return undefined;
135
+ let depth = 0;
136
+ for (let i = open; i < structural.length; i++) {
137
+ const c = structural[i];
138
+ if (c === '{') depth += 1;
139
+ else if (c === '}') {
140
+ depth -= 1;
141
+ if (depth === 0) return { start: open, end: i + 1, body: structural.slice(open + 1, i) };
142
+ }
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ /** Brace depth at a character index, computed on the structural view. */
148
+ export function braceDepthAt(structural, index) {
149
+ let depth = 0;
150
+ for (let k = 0; k < index && k < structural.length; k++) {
151
+ const c = structural[k];
152
+ if (c === '{' || c === '(' || c === '[') depth += 1;
153
+ else if (c === '}' || c === ')' || c === ']') depth -= 1;
154
+ }
155
+ return depth;
156
+ }