@veda-hq/kavach 0.0.0-stage → 0.3.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.
- package/LICENSE +21 -0
- package/README.md +107 -2
- package/corpus/corpus.json +1544 -0
- package/dist/rule-checks/args.js +26 -0
- package/dist/rule-checks/cli-entry.js +42 -0
- package/dist/rule-checks/cli.js +146 -0
- package/dist/rule-checks/edit-text.js +23 -0
- package/dist/rule-checks/fire-log.js +81 -0
- package/dist/rule-checks/latency.js +192 -0
- package/dist/rule-checks/live-runs.js +282 -0
- package/dist/rule-checks/registry.js +163 -0
- package/dist/rule-checks/runtime.js +353 -0
- package/dist/rule-checks/shell-comments.js +103 -0
- package/dist/rule-checks/shell-words.js +567 -0
- package/dist/rule-checks/spawn.js +27 -0
- package/package.json +51 -3
- package/python/kavach/__init__.py +9 -0
- package/python/kavach/bash_guard.py +1672 -0
- package/python/kavach/boundary.py +4030 -0
- package/python/kavach/command_guard.py +170 -0
- package/python/kavach/config.py +156 -0
- package/python/kavach/git_safety.py +1980 -0
- package/python/kavach/refusal_log.py +157 -0
- package/python/kavach/worktree_guard.py +482 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared runtime of the rule checks.
|
|
3
|
+
*
|
|
4
|
+
* A consumer hands it its own checks and registry (`runHook` here, or the CLI
|
|
5
|
+
* in `cli.ts` with the hook input on stdin). It runs every check that applies
|
|
6
|
+
* to the event and tool, and answers Claude Code on stdout. What every check
|
|
7
|
+
* gets from here:
|
|
8
|
+
*
|
|
9
|
+
* - It refuses or warns; it never rewrites the tool input.
|
|
10
|
+
* - A refusal names the rule, the cost it prevents, what to do instead and
|
|
11
|
+
* the memory notes it came from (titles and notes from the registry).
|
|
12
|
+
* - A Bash command passes one named check when it ends with
|
|
13
|
+
* `# rule-pass <id>: <reason>`; the pass is logged with the reason. File
|
|
14
|
+
* edits have no pass: the refusal says to ask the registry's owner.
|
|
15
|
+
* - A check that throws lets the action go ahead with a warning, and the
|
|
16
|
+
* crash is logged; so does a crash of this runtime itself.
|
|
17
|
+
* - The session's checkout comes from the input's `cwd`, because Claude Code
|
|
18
|
+
* runs hooks from the main checkout, not the session's worktree.
|
|
19
|
+
* - Every refusal, warning, pass and crash appends a fire-log row.
|
|
20
|
+
*
|
|
21
|
+
* Cost: nothing here spawns a process or reads a file until a check asks for
|
|
22
|
+
* the checkout or something fires, so a call no check fires on costs only
|
|
23
|
+
* Node's start and the checks themselves.
|
|
24
|
+
*/
|
|
25
|
+
import { appendFires, branchAt, clip, firesDir } from './fire-log.js';
|
|
26
|
+
import { DEFAULT_OWNER, loadRegistry } from './registry.js';
|
|
27
|
+
import { shellComments } from './shell-comments.js';
|
|
28
|
+
import { git } from './spawn.js';
|
|
29
|
+
export { git } from './spawn.js';
|
|
30
|
+
/** The shortest reason a `# rule-pass` may state. */
|
|
31
|
+
export const MIN_REASON = 12;
|
|
32
|
+
function field(value, key) {
|
|
33
|
+
const item = value[key];
|
|
34
|
+
return typeof item === 'string' ? item : '';
|
|
35
|
+
}
|
|
36
|
+
/** Events whose input names a tool and carries that tool's input. */
|
|
37
|
+
const TOOL_EVENTS = ['PreToolUse', 'PostToolUse'];
|
|
38
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
|
+
/**
|
|
40
|
+
* Read the hook input. Malformed input throws, so `runHook` logs a crash and
|
|
41
|
+
* lets the call go ahead rather than run checks on blanks: it must be a JSON
|
|
42
|
+
* object naming its event, a tool event must name its tool and carry an input
|
|
43
|
+
* object, and `cwd`, when present, must be text. A `session_id` that is not
|
|
44
|
+
* non-empty text is left out: only per-session checks read it, and they
|
|
45
|
+
* treat a missing one as a session they have not seen.
|
|
46
|
+
*/
|
|
47
|
+
export function parseInput(raw) {
|
|
48
|
+
const value = JSON.parse(raw);
|
|
49
|
+
if (!isRecord(value))
|
|
50
|
+
throw new Error('hook input is not a JSON object');
|
|
51
|
+
const { hook_event_name: event, tool_name: tool, tool_input: given } = value;
|
|
52
|
+
const { cwd = '', session_id: session, transcript_path: path } = value;
|
|
53
|
+
const { prompt } = value;
|
|
54
|
+
if (typeof event !== 'string' || event === '') {
|
|
55
|
+
throw new Error('hook input names no hook_event_name');
|
|
56
|
+
}
|
|
57
|
+
if (typeof cwd !== 'string')
|
|
58
|
+
throw new Error('hook input cwd is not text');
|
|
59
|
+
const id = {
|
|
60
|
+
...(typeof session === 'string' && session !== '' ? { session_id: session } : {}),
|
|
61
|
+
...(typeof path === 'string' && path !== '' ? { transcript_path: path } : {}),
|
|
62
|
+
...(typeof prompt === 'string' ? { prompt } : {}),
|
|
63
|
+
};
|
|
64
|
+
if (!TOOL_EVENTS.includes(event)) {
|
|
65
|
+
return {
|
|
66
|
+
hook_event_name: event,
|
|
67
|
+
tool_name: '',
|
|
68
|
+
tool_input: {},
|
|
69
|
+
cwd,
|
|
70
|
+
...id,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (typeof tool !== 'string' || tool === '' || !isRecord(given)) {
|
|
74
|
+
throw new Error(`${event} input names no tool_name and tool_input`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
hook_event_name: event,
|
|
78
|
+
tool_name: tool,
|
|
79
|
+
tool_input: given,
|
|
80
|
+
cwd,
|
|
81
|
+
...id,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const PASS = /^[ \t]*rule-pass[ \t]+([A-Z]-\d{2})[ \t]*:(.*)$/;
|
|
85
|
+
/**
|
|
86
|
+
* Bash only: the stated pass, check id → reason, from the `# rule-pass`
|
|
87
|
+
* comment that ends the command. One pass per command, and only at its end,
|
|
88
|
+
* so no command can follow it. Only a real shell comment counts
|
|
89
|
+
* (`shell-comments.ts`): a marker in a quoted string or heredoc body is data.
|
|
90
|
+
*/
|
|
91
|
+
export function statedPasses(command) {
|
|
92
|
+
const passes = new Map();
|
|
93
|
+
const text = command.trimEnd();
|
|
94
|
+
const last = text.split('\n').length - 1;
|
|
95
|
+
for (const comment of shellComments(text)) {
|
|
96
|
+
const match = PASS.exec(comment.text);
|
|
97
|
+
if (comment.line !== last || match === null)
|
|
98
|
+
continue;
|
|
99
|
+
const [, id = '', said = ''] = match;
|
|
100
|
+
const reason = said.trim();
|
|
101
|
+
if (reason.length >= MIN_REASON)
|
|
102
|
+
passes.set(id, reason);
|
|
103
|
+
}
|
|
104
|
+
return passes;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The checkout `cwd` lies in; `fallback` when it names none. Empty is checked
|
|
108
|
+
* first because `git -C ""` stays in the current folder; a missing path, a
|
|
109
|
+
* file and a folder outside git all make `git -C` fail.
|
|
110
|
+
*/
|
|
111
|
+
export function sessionRoot(cwd, fallback, git) {
|
|
112
|
+
if (cwd === '')
|
|
113
|
+
return fallback;
|
|
114
|
+
try {
|
|
115
|
+
return git(cwd, ['rev-parse', '--show-toplevel']) || fallback;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// fail-open: a cwd outside git names no checkout; the fallback stands in.
|
|
119
|
+
return fallback;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function applies(check, input) {
|
|
123
|
+
return (check.events.includes(input.hook_event_name) && (check.tools === undefined || check.tools.includes(input.tool_name)));
|
|
124
|
+
}
|
|
125
|
+
/** The rule's title and, when the registry records one, where it came from. */
|
|
126
|
+
function heading(verb, rule, entry) {
|
|
127
|
+
if (entry === undefined)
|
|
128
|
+
return `${verb} ${rule} (not in the rule registry).`;
|
|
129
|
+
const origin = entry.origin === undefined ? '' : ` (${entry.origin})`;
|
|
130
|
+
return `${verb} ${rule}, "${entry.title}"${origin}.`;
|
|
131
|
+
}
|
|
132
|
+
function memory(entry) {
|
|
133
|
+
return `Memory: ${entry === undefined ? 'none recorded' : entry.notes.join(', ')}`;
|
|
134
|
+
}
|
|
135
|
+
/** Search tools: their checks pass only for the same search run in the shell. */
|
|
136
|
+
const SEARCH_TOOLS = ['Grep', 'Glob'];
|
|
137
|
+
/** How to get past a refusal of `tool`. */
|
|
138
|
+
function way(rule, tool, owner) {
|
|
139
|
+
const pass = `# rule-pass ${rule}: <your reason> (logged)`;
|
|
140
|
+
if (tool === 'Bash') {
|
|
141
|
+
return `To pass it deliberately, end the command with: ${pass}.`;
|
|
142
|
+
}
|
|
143
|
+
if (tool === 'AskUserQuestion') {
|
|
144
|
+
return 'A question has no pass: do what "Instead" says first, then ask.';
|
|
145
|
+
}
|
|
146
|
+
return SEARCH_TOOLS.includes(tool)
|
|
147
|
+
? `${tool} has no pass: run the search in the shell ending with ${pass}, or ask ${owner}.`
|
|
148
|
+
: `File edits have no pass: ask ${owner}.`;
|
|
149
|
+
}
|
|
150
|
+
/** The refusal text, with the way past it. */
|
|
151
|
+
export function refusal(check, detail, entry, tool, owner = DEFAULT_OWNER) {
|
|
152
|
+
return [
|
|
153
|
+
heading('Refused by', check.rule, entry),
|
|
154
|
+
detail,
|
|
155
|
+
`Prevents: ${check.cost}`,
|
|
156
|
+
`Instead: ${check.instead}`,
|
|
157
|
+
memory(entry),
|
|
158
|
+
way(check.rule, tool, owner),
|
|
159
|
+
].join('\n');
|
|
160
|
+
}
|
|
161
|
+
export function warning(check, detail, entry) {
|
|
162
|
+
return [
|
|
163
|
+
heading('Warning from', check.rule, entry),
|
|
164
|
+
detail,
|
|
165
|
+
`Prevents: ${check.cost}`,
|
|
166
|
+
`Instead: ${check.instead}`,
|
|
167
|
+
memory(entry),
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
function message(error) {
|
|
171
|
+
return error instanceof Error ? error.message : String(error);
|
|
172
|
+
}
|
|
173
|
+
/** Run the checks that apply; one fire per check that refused, warned, passed or crashed. */
|
|
174
|
+
export function decide(context, checks, rules) {
|
|
175
|
+
const bash = context.input.tool_name === 'Bash';
|
|
176
|
+
const passes = bash ? statedPasses(context.command) : new Map();
|
|
177
|
+
const fires = [];
|
|
178
|
+
for (const check of checks) {
|
|
179
|
+
if (!applies(check, context.input))
|
|
180
|
+
continue;
|
|
181
|
+
let finding;
|
|
182
|
+
try {
|
|
183
|
+
finding = check.run(context);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// fail-open: a broken check never blocks work; the crash is logged.
|
|
187
|
+
const reason = message(error);
|
|
188
|
+
fires.push({
|
|
189
|
+
rule: check.rule,
|
|
190
|
+
outcome: 'crashed',
|
|
191
|
+
message: `Check ${check.rule} crashed (${reason}); the action goes ahead.`,
|
|
192
|
+
reason,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (finding === null)
|
|
197
|
+
continue;
|
|
198
|
+
const entry = rules.find(check.rule);
|
|
199
|
+
const reason = passes.get(check.rule);
|
|
200
|
+
if (finding.action === 'refuse' && reason !== undefined) {
|
|
201
|
+
fires.push({
|
|
202
|
+
rule: check.rule,
|
|
203
|
+
outcome: 'excepted',
|
|
204
|
+
message: `Passed ${check.rule} with your stated reason (logged): ${reason}`,
|
|
205
|
+
reason,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else if (finding.action === 'refuse') {
|
|
209
|
+
fires.push({
|
|
210
|
+
rule: check.rule,
|
|
211
|
+
outcome: 'refused',
|
|
212
|
+
message: refusal(check, finding.detail, entry, context.input.tool_name, rules.owner),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
fires.push({
|
|
217
|
+
rule: check.rule,
|
|
218
|
+
outcome: 'warned',
|
|
219
|
+
message: warning(check, finding.detail, entry),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return fires;
|
|
224
|
+
}
|
|
225
|
+
/** Events whose hook output can add context the model reads. */
|
|
226
|
+
const CONTEXT_EVENTS = [...TOOL_EVENTS, 'UserPromptSubmit'];
|
|
227
|
+
/**
|
|
228
|
+
* The hook's stdout for these fires, refusals first; `note` follows them.
|
|
229
|
+
* Empty when nothing needs saying.
|
|
230
|
+
*/
|
|
231
|
+
export function render(event, fires, note = '') {
|
|
232
|
+
if (fires.length === 0)
|
|
233
|
+
return '';
|
|
234
|
+
const refused = fires.filter((fire) => fire.outcome === 'refused');
|
|
235
|
+
const text = [...refused, ...fires.filter((fire) => fire.outcome !== 'refused')]
|
|
236
|
+
.map((fire) => fire.message)
|
|
237
|
+
.concat(note === '' ? [] : [note])
|
|
238
|
+
.join('\n\n');
|
|
239
|
+
if (refused.length > 0) {
|
|
240
|
+
return JSON.stringify(event === 'PreToolUse'
|
|
241
|
+
? {
|
|
242
|
+
hookSpecificOutput: {
|
|
243
|
+
hookEventName: event,
|
|
244
|
+
permissionDecision: 'deny',
|
|
245
|
+
permissionDecisionReason: text,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
: { decision: 'block', reason: text });
|
|
249
|
+
}
|
|
250
|
+
if (!CONTEXT_EVENTS.includes(event))
|
|
251
|
+
return '';
|
|
252
|
+
return JSON.stringify({
|
|
253
|
+
hookSpecificOutput: { hookEventName: event, additionalContext: text },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The excerpt a fire-log row keeps: the command, the file a tool touches, or
|
|
258
|
+
* a search tool's pattern and folder.
|
|
259
|
+
*/
|
|
260
|
+
export function excerptOf(input) {
|
|
261
|
+
const tool = input.tool_input;
|
|
262
|
+
const search = [field(tool, 'pattern'), field(tool, 'path')].filter(Boolean).join(' in ');
|
|
263
|
+
return clip(field(tool, 'command') || field(tool, 'file_path') || field(tool, 'notebook_path') || search);
|
|
264
|
+
}
|
|
265
|
+
function rows(fires, root, input, deps) {
|
|
266
|
+
const ts = deps.now().toISOString();
|
|
267
|
+
const branch = branchAt(root, deps.git);
|
|
268
|
+
const excerpt = input === null ? '' : excerptOf(input);
|
|
269
|
+
return fires.map((fire) => ({
|
|
270
|
+
ts,
|
|
271
|
+
rule: fire.rule,
|
|
272
|
+
outcome: fire.outcome,
|
|
273
|
+
branch,
|
|
274
|
+
excerpt,
|
|
275
|
+
...(fire.reason === undefined ? {} : { reason: clip(fire.reason) }),
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
function log(fires, root, input, deps) {
|
|
279
|
+
try {
|
|
280
|
+
appendFires(firesDir(deps.firesDir, root, deps.git), rows(fires, root, input, deps));
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
// fail-open: the answer still goes out, and says the log missed it.
|
|
285
|
+
return `The rule-check fire log could not be written (${message(error)}).`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** The registry loaded once, on the first question asked of it. */
|
|
289
|
+
function lazy(load) {
|
|
290
|
+
let loaded;
|
|
291
|
+
const rules = () => (loaded ??= load());
|
|
292
|
+
return {
|
|
293
|
+
find: (id) => rules().find(id),
|
|
294
|
+
get owner() {
|
|
295
|
+
return rules().owner;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** One hook call, end to end; never throws. Returns the stdout. */
|
|
300
|
+
export function runHook(stdin, options) {
|
|
301
|
+
let input = null;
|
|
302
|
+
let root = options.fallbackRoot;
|
|
303
|
+
try {
|
|
304
|
+
const parsed = parseInput(stdin);
|
|
305
|
+
input = parsed;
|
|
306
|
+
const checks = typeof options.checks === 'function' ? options.checks() : options.checks;
|
|
307
|
+
let resolved;
|
|
308
|
+
const context = {
|
|
309
|
+
input: parsed,
|
|
310
|
+
command: parsed.tool_name === 'Bash' ? field(parsed.tool_input, 'command') : '',
|
|
311
|
+
root: () => {
|
|
312
|
+
resolved ??= sessionRoot(parsed.cwd, options.fallbackRoot, options.git);
|
|
313
|
+
return resolved;
|
|
314
|
+
},
|
|
315
|
+
git: options.git,
|
|
316
|
+
};
|
|
317
|
+
const fires = decide(context, checks, lazy(options.rules));
|
|
318
|
+
if (fires.length === 0)
|
|
319
|
+
return '';
|
|
320
|
+
root = context.root();
|
|
321
|
+
return render(parsed.hook_event_name, fires, log(fires, root, parsed, options));
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// fail-open: a crash of the runtime itself lets the action go ahead.
|
|
325
|
+
const reason = message(error);
|
|
326
|
+
const fire = {
|
|
327
|
+
rule: 'runtime',
|
|
328
|
+
outcome: 'crashed',
|
|
329
|
+
message: `The rule checks crashed (${reason}); the action goes ahead.`,
|
|
330
|
+
reason,
|
|
331
|
+
};
|
|
332
|
+
return render(input?.hook_event_name ?? 'PreToolUse', [fire], log([fire], root, input, options));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** The runtime's view of a parsed registry. */
|
|
336
|
+
export function rulesOf(registry) {
|
|
337
|
+
const byId = new Map(registry.rules.map((rule) => [rule.id, rule]));
|
|
338
|
+
return { find: (id) => byId.get(id), owner: registry.owner ?? DEFAULT_OWNER };
|
|
339
|
+
}
|
|
340
|
+
/** The registry at `path`; none (every rule unregistered) when no path is given. */
|
|
341
|
+
export function registryRules(path) {
|
|
342
|
+
return rulesOf(path === undefined || path === '' ? { rules: [] } : loadRegistry(path));
|
|
343
|
+
}
|
|
344
|
+
/** What a real hook call runs with: this process's git and clock, the registry file, and `env`'s project folder. */
|
|
345
|
+
export function liveDeps(env, options = {}) {
|
|
346
|
+
return {
|
|
347
|
+
git,
|
|
348
|
+
now: () => new Date(),
|
|
349
|
+
rules: () => registryRules(options.registry),
|
|
350
|
+
fallbackRoot: env['CLAUDE_PROJECT_DIR'] ?? process.cwd(),
|
|
351
|
+
firesDir: options.firesDir,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The comments of a shell command line: what follows each `#` that starts a
|
|
3
|
+
* comment, outside quotes and heredoc bodies.
|
|
4
|
+
*
|
|
5
|
+
* A `# rule-pass` counts only as a real comment. Text inside a quoted string
|
|
6
|
+
* or a heredoc body is data the command passes on (a commit message, a file
|
|
7
|
+
* written with `cat <<EOF`), so a marker there must not pass a check.
|
|
8
|
+
*
|
|
9
|
+
* A static reading, like the command guard's. It models single, double and
|
|
10
|
+
* `$'…'` quotes (a quote may span lines), backslash escapes outside single
|
|
11
|
+
* quotes, backslash-newline joining two lines, and heredocs (`<<WORD`,
|
|
12
|
+
* `<<-WORD`, quoted words, several on one line; `<<<` is a here-string). A `#`
|
|
13
|
+
* starts a comment where it starts a word: at the start of the command or
|
|
14
|
+
* after a blank or an operator character. What this reading does not model
|
|
15
|
+
* ends it: a heredoc word it cannot settle (`<<\EOF`, `<<E"O"F`, `<<"$x"`),
|
|
16
|
+
* `${…}`, backquotes, and `$(…)` inside double quotes. No comment after that
|
|
17
|
+
* point counts, which can only withhold a pass, never grant one.
|
|
18
|
+
*/
|
|
19
|
+
/** Characters after which a `#` starts a word: bash's metacharacters. */
|
|
20
|
+
const WORD_BREAK = /[ \t\n;|&()<>]/;
|
|
21
|
+
const HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"\\$`]*)"|([A-Za-z_]\w*))(?=[ \t;|&()<>]|$)/;
|
|
22
|
+
/**
|
|
23
|
+
* Openers of text the shell reads with its own quoting and comments: a
|
|
24
|
+
* parameter expansion and a backquoted command anywhere, and a command
|
|
25
|
+
* substitution inside double quotes. Unquoted, `$(…)` reads flat: a comment
|
|
26
|
+
* inside it swallows its `)`, so a command ending in one never runs.
|
|
27
|
+
*/
|
|
28
|
+
const NESTING = ['${', '`'];
|
|
29
|
+
const QUOTED_NESTING = ['$(', ...NESTING];
|
|
30
|
+
const opensAt = (line, i, openers) => openers.some((opener) => line.startsWith(opener, i));
|
|
31
|
+
export function shellComments(command) {
|
|
32
|
+
const comments = [];
|
|
33
|
+
let quote = null;
|
|
34
|
+
let bodies = [];
|
|
35
|
+
let opened = [];
|
|
36
|
+
// The character the shell read last; an escaped one reads as a word's.
|
|
37
|
+
let before = '\n';
|
|
38
|
+
for (const [index, line] of command.split('\n').entries()) {
|
|
39
|
+
const [body] = bodies;
|
|
40
|
+
if (body !== undefined) {
|
|
41
|
+
const end = body.tabs ? line.replace(/^\t+/, '') : line;
|
|
42
|
+
if (end === body.word)
|
|
43
|
+
bodies.shift();
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// Characters already read, each standing for what the shell saw there.
|
|
47
|
+
let skip = '';
|
|
48
|
+
for (const [i, char] of line.split('').entries()) {
|
|
49
|
+
if (skip !== '') {
|
|
50
|
+
before = skip.charAt(0);
|
|
51
|
+
skip = skip.slice(1);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (quote === "'") {
|
|
55
|
+
if (char === "'")
|
|
56
|
+
quote = null;
|
|
57
|
+
}
|
|
58
|
+
else if (char === '\\') {
|
|
59
|
+
skip = 'x';
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
else if (quote !== null) {
|
|
63
|
+
if (quote === '"' && opensAt(line, i, QUOTED_NESTING))
|
|
64
|
+
return comments;
|
|
65
|
+
if (char === quote.slice(-1))
|
|
66
|
+
quote = null;
|
|
67
|
+
}
|
|
68
|
+
else if (char === "'" || char === '"') {
|
|
69
|
+
quote = char;
|
|
70
|
+
}
|
|
71
|
+
else if (line.startsWith("$'", i)) {
|
|
72
|
+
[quote, skip] = ["$'", "'"];
|
|
73
|
+
}
|
|
74
|
+
else if (opensAt(line, i, NESTING)) {
|
|
75
|
+
return comments;
|
|
76
|
+
}
|
|
77
|
+
else if (char === '#' && WORD_BREAK.test(before)) {
|
|
78
|
+
comments.push({ line: index, text: line.slice(i + 1) });
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
else if (line.startsWith('<<<', i)) {
|
|
82
|
+
skip = '<<';
|
|
83
|
+
}
|
|
84
|
+
else if (line.startsWith('<<', i)) {
|
|
85
|
+
const marker = HEREDOC.exec(line.slice(i));
|
|
86
|
+
if (marker === null)
|
|
87
|
+
return comments;
|
|
88
|
+
// The word's own characters are read on as usual: its quotes open and
|
|
89
|
+
// close, and the character after it breaks a word.
|
|
90
|
+
const [, dash, single, double, bare] = marker;
|
|
91
|
+
opened.push({ word: single ?? double ?? bare ?? '', tabs: dash === '-' });
|
|
92
|
+
}
|
|
93
|
+
before = char;
|
|
94
|
+
}
|
|
95
|
+
// A backslash ending the line joins the next one to it.
|
|
96
|
+
if (skip !== '')
|
|
97
|
+
continue;
|
|
98
|
+
before = '\n';
|
|
99
|
+
if (quote === null)
|
|
100
|
+
[bodies, opened] = [opened, []];
|
|
101
|
+
}
|
|
102
|
+
return comments;
|
|
103
|
+
}
|