copilot-operator 0.1.3 → 0.1.5
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/README.md +8 -2
- package/dist/src/api/operator.controller.js +6 -1
- package/dist/src/api/operator.service.js +69 -22
- package/dist/src/exec/authorizer.js +2 -2
- package/dist/src/exec/commandHeads.js +799 -0
- package/dist/src/exec/policy.js +1 -1
- package/dist/src/exec/programs.js +22 -82
- package/dist/src/exec/reportFile.js +5 -2
- package/dist/src/orchestrator/review.js +4 -3
- package/dist/src/orchestrator/taskRunner.js +33 -8
- package/dist/src/plan/brief.js +1248 -1221
- package/dist/src/plan/schema.js +19 -0
- package/dist/src/plan/systemGuide.js +40 -0
- package/dist/src/protocol/reporter.js +8 -6
- package/dist/src/session/exports.js +131 -1
- package/dist/src/session/store.js +2 -0
- package/dist/src/vcs/taskVcs.js +153 -11
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/867-0cd2c3cfd0ff0a45.js +1 -0
- package/dist/web/_next/static/chunks/89-382e9b29486d2747.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-83543937cecde5de.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-4631bdf5113a73b2.js +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +10 -10
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +10 -10
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +10 -10
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +10 -10
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +10 -10
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +10 -10
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +10 -10
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +10 -10
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +10 -10
- package/package.json +4 -3
- package/dist/web/_next/static/chunks/867-55627c5122ff6336.js +0 -1
- package/dist/web/_next/static/chunks/89-5585315f51e4c481.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-4ff25cafe6de779c.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-2232949a2ff8559e.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-9dda275f4b1b0423.js +0 -1
- /package/dist/web/_next/static/{QwjyyBjkO5xlKFU5O6Wux → 7TE2rEuHqGdGx14If8TEb}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{QwjyyBjkO5xlKFU5O6Wux → 7TE2rEuHqGdGx14If8TEb}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which programs a command line invokes: the name at the head of every command, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* The allowlist (`programs.ts`) judges the programs a line starts, so it needs the one thing this
|
|
5
|
+
* file finds — the command invocations — and it needs nothing else mistaken for one. The version
|
|
6
|
+
* before this split the line at every `(`, `{`, `|`, `;`, `&` and newline and took the first token
|
|
7
|
+
* after each as a command. That is how PowerShell reads a statement and not how it reads the
|
|
8
|
+
* inside of one, and it failed both ways:
|
|
9
|
+
*
|
|
10
|
+
* refusing honest work `if (!(Test-Path x))` gave the program `!`; `("a" + "b")` gave `+`;
|
|
11
|
+
* `@('a','b')` and `.Replace('\', '/')` gave `,` or the string itself;
|
|
12
|
+
* `('\bTODO')` gave `\b`, which the path-stripping then turned into `b`;
|
|
13
|
+
* a hashtable's keys, a `2>&1` redirection's `1`, a `# comment`, an
|
|
14
|
+
* index `[0]` — each "a program not on the list", each refused.
|
|
15
|
+
* letting work through `$out = nmap ...` (a command after an assignment), `& "C:\x\y.exe"`
|
|
16
|
+
* (a quoted call), `"$(nmap ...)"` (a subexpression in a string) and
|
|
17
|
+
* `foreach ($f in nmap ...)` were never seen at all.
|
|
18
|
+
*
|
|
19
|
+
* So this is a lexer that follows PowerShell's two parsing modes, which is the whole distinction
|
|
20
|
+
* the old splitter lacked. At the start of a statement, after a pipe and after `&&`, `||` or `=`,
|
|
21
|
+
* PowerShell is at a *command position*: a bare word there is a command, and anything else — a
|
|
22
|
+
* string, a number, a variable, an operator, `(`, `[`, `@(` — begins an *expression*, whose bare
|
|
23
|
+
* words are never commands. Strings, here-strings, comments, method-call arguments, index and
|
|
24
|
+
* type brackets and hashtable keys are read and stepped over as the data they are; script blocks,
|
|
25
|
+
* `( )`, `$( )` and `@( )` — including a `$( )` inside a double-quoted string — hold statements
|
|
26
|
+
* again and are read as such, so a command nested anywhere is still found.
|
|
27
|
+
*
|
|
28
|
+
* Why a lexer and not PowerShell's own parser, which would be exact: the parser is a .NET call
|
|
29
|
+
* (`[System.Management.Automation.Language.Parser]::ParseInput`), reachable only by starting a
|
|
30
|
+
* PowerShell process for every step — a process per command on a machine whose security team
|
|
31
|
+
* counts them — and blocked outright under Constrained Language Mode, which the corporate machines
|
|
32
|
+
* this runs on commonly enforce. A gate that works only where nobody locked the machine down is
|
|
33
|
+
* not a gate. This one is synchronous, starts nothing, and reads the same everywhere.
|
|
34
|
+
*
|
|
35
|
+
* Fail-closed where it cannot be sure. An unterminated string or here-string, a bracket that is
|
|
36
|
+
* never closed or closed by the wrong thing, a program chosen at run time (`& $exe`, `& (...)`, a
|
|
37
|
+
* name built from a variable) — each makes the scan `uncertain`, and the allowlist refuses an
|
|
38
|
+
* uncertain line rather than guess which programs it would start. None of those is something an
|
|
39
|
+
* honest build or test step needs: PowerShell itself rejects the first three.
|
|
40
|
+
*
|
|
41
|
+
* `cmd` is read by its own, much smaller rules (`scanCmd`), because none of PowerShell's apply to
|
|
42
|
+
* it — `'` is not a quote there, `^` escapes, `(` groups only at a command position.
|
|
43
|
+
*/
|
|
44
|
+
/** Scans a command line as the shell that will run it reads it. */
|
|
45
|
+
export function scanCommandHeads(command, shell = 'pwsh') {
|
|
46
|
+
return shell === 'cmd' ? scanCmd(command) : new PowerShellScan(command).run();
|
|
47
|
+
}
|
|
48
|
+
// ------------------------------------------------------------------------------------------
|
|
49
|
+
// PowerShell
|
|
50
|
+
// ------------------------------------------------------------------------------------------
|
|
51
|
+
/** How deep brackets, blocks and subexpressions may nest before the line is called unreadable. */
|
|
52
|
+
const MAX_DEPTH = 64;
|
|
53
|
+
/** Keywords followed by conditions, blocks, flags or labels — none of whose bare words run. */
|
|
54
|
+
const BLOCK_KEYWORDS = new Set([
|
|
55
|
+
'if', 'elseif', 'else', 'while', 'until', 'do', 'for', 'foreach', 'try', 'catch', 'finally', 'trap',
|
|
56
|
+
'begin', 'process', 'end', 'clean', 'dynamicparam', 'data', 'param', 'default', 'using', 'exit', 'break',
|
|
57
|
+
'continue', 'in', 'parallel', 'sequence', 'inlinescript',
|
|
58
|
+
]);
|
|
59
|
+
/** Keywords followed by a pipeline: the next bare word is a command. */
|
|
60
|
+
const PIPELINE_KEYWORDS = new Set(['return', 'throw']);
|
|
61
|
+
/** Keywords followed by a name that is being defined, not run. */
|
|
62
|
+
const NAMING_KEYWORDS = new Set(['function', 'filter', 'workflow', 'configuration']);
|
|
63
|
+
/** Keywords whose body is members, not statements. */
|
|
64
|
+
const TYPE_KEYWORDS = new Set(['class', 'enum', 'interface']);
|
|
65
|
+
/** What ends a bare word in command mode, besides white space. */
|
|
66
|
+
const WORD_BREAK = new Set([';', '|', '&', '(', ')', '{', '}', ',', '>', '<', '\n']);
|
|
67
|
+
// PowerShell takes the typographic quotes as quotes, and a model copying text writes them.
|
|
68
|
+
const isSingleQuote = (c) => c === "'" || c === '\u2018' || c === '\u2019' || c === '\u201a' || c === '\u201b';
|
|
69
|
+
const isDoubleQuote = (c) => c === '"' || c === '\u201c' || c === '\u201d' || c === '\u201e';
|
|
70
|
+
const isSpace = (c) => c === ' ' || c === '\t' || c === '\r' || c === '\f' || c === '\v' || c === '\u00a0' || c === '\ufeff';
|
|
71
|
+
const isIdent = (c) => /[A-Za-z0-9_]/.test(c);
|
|
72
|
+
/** The characters that end a statement or a pipeline element, where every reader stops. */
|
|
73
|
+
const isTerminator = (c) => c === '' || c === ';' || c === '\n' || c === '|' || c === ')' || c === '}' || c === '&';
|
|
74
|
+
class PowerShellScan {
|
|
75
|
+
s;
|
|
76
|
+
heads = [];
|
|
77
|
+
uncertain;
|
|
78
|
+
depth = 0;
|
|
79
|
+
constructor(s) {
|
|
80
|
+
this.s = s;
|
|
81
|
+
}
|
|
82
|
+
run() {
|
|
83
|
+
this.statements(0, null);
|
|
84
|
+
return this.uncertain ? { heads: this.heads, uncertain: this.uncertain } : { heads: this.heads };
|
|
85
|
+
}
|
|
86
|
+
fail(why) {
|
|
87
|
+
this.uncertain ??= why;
|
|
88
|
+
}
|
|
89
|
+
at(i) {
|
|
90
|
+
return this.s[i] ?? '';
|
|
91
|
+
}
|
|
92
|
+
/** Enters one level of nesting; false (and uncertain) past the limit. */
|
|
93
|
+
enter() {
|
|
94
|
+
this.depth += 1;
|
|
95
|
+
if (this.depth > MAX_DEPTH) {
|
|
96
|
+
this.fail('it nests deeper than the runner reads');
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Steps over white space, line continuations and comments; with `lines`, over newlines too, and
|
|
103
|
+
* with `statements` over `;` as well — the separators between statements.
|
|
104
|
+
*/
|
|
105
|
+
skip(i, over = 'inline') {
|
|
106
|
+
for (;;) {
|
|
107
|
+
const c = this.at(i);
|
|
108
|
+
if (c === '')
|
|
109
|
+
return i;
|
|
110
|
+
if (isSpace(c)) {
|
|
111
|
+
i += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (c === '`' && (this.at(i + 1) === '\n' || (this.at(i + 1) === '\r' && this.at(i + 2) === '\n'))) {
|
|
115
|
+
i += this.at(i + 1) === '\r' ? 3 : 2;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// A `#` where a token starts begins a comment to the end of the line; inside a word it is a
|
|
119
|
+
// character of the word, which is why this is only ever asked at a token's start.
|
|
120
|
+
if (c === '#') {
|
|
121
|
+
const nl = this.s.indexOf('\n', i);
|
|
122
|
+
i = nl < 0 ? this.s.length : nl;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (c === '<' && this.at(i + 1) === '#') {
|
|
126
|
+
const close = this.s.indexOf('#>', i + 2);
|
|
127
|
+
if (close < 0) {
|
|
128
|
+
this.fail('a <# comment is never closed');
|
|
129
|
+
return this.s.length;
|
|
130
|
+
}
|
|
131
|
+
i = close + 2;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (c === '\n' && over !== 'inline') {
|
|
135
|
+
i += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === ';' && over === 'statements') {
|
|
139
|
+
i += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
return i;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Statements up to `closer` (the `)` or `}` the caller opened) or the end of the line. Returns
|
|
147
|
+
* the index just past the closer. `kind` says what the statements are: ordinary ones, the
|
|
148
|
+
* entries of a hashtable, or the clauses of a switch.
|
|
149
|
+
*/
|
|
150
|
+
statements(i, closer, kind = 'statements') {
|
|
151
|
+
if (!this.enter())
|
|
152
|
+
return this.s.length;
|
|
153
|
+
try {
|
|
154
|
+
for (;;) {
|
|
155
|
+
i = this.skip(i, 'statements');
|
|
156
|
+
const c = this.at(i);
|
|
157
|
+
if (c === '') {
|
|
158
|
+
if (closer)
|
|
159
|
+
this.fail(`a "${closer === ')' ? '(' : '{'}" is never closed`);
|
|
160
|
+
return i;
|
|
161
|
+
}
|
|
162
|
+
if (c === ')' || c === '}') {
|
|
163
|
+
if (c === closer)
|
|
164
|
+
return i + 1;
|
|
165
|
+
this.fail(closer ? `"${c}" closes what "${closer}" should have` : `an unmatched "${c}"`);
|
|
166
|
+
return this.s.length;
|
|
167
|
+
}
|
|
168
|
+
const before = i;
|
|
169
|
+
i = kind === 'hashtable' ? this.hashEntry(i) : kind === 'switch' ? this.switchClause(i) : this.pipeline(i);
|
|
170
|
+
// Every reader consumes at least what it stops on or returns at a terminator the loop
|
|
171
|
+
// understands; this is the guarantee that a character nobody handles cannot spin it.
|
|
172
|
+
if (i === before)
|
|
173
|
+
i += 1;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
this.depth -= 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** One statement: pipeline elements joined by `|`, `&&`, `||`, or ended by a background `&`. */
|
|
181
|
+
pipeline(i) {
|
|
182
|
+
for (;;) {
|
|
183
|
+
i = this.element(i);
|
|
184
|
+
i = this.skip(i);
|
|
185
|
+
const c = this.at(i);
|
|
186
|
+
if (c === '|' || c === '&') {
|
|
187
|
+
i += this.at(i + 1) === c ? 2 : 1;
|
|
188
|
+
i = this.skip(i, 'lines');
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
return i;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** A command position: whatever starts here is either a command, a keyword or an expression. */
|
|
195
|
+
element(i) {
|
|
196
|
+
i = this.skip(i);
|
|
197
|
+
const c = this.at(i);
|
|
198
|
+
const n = this.at(i + 1);
|
|
199
|
+
if (c === '' || c === ';' || c === '\n' || c === '|' || c === ')' || c === '}')
|
|
200
|
+
return i;
|
|
201
|
+
if (c === '&' && n !== '&')
|
|
202
|
+
return this.invocation(i + 1);
|
|
203
|
+
if (c === '&')
|
|
204
|
+
return i;
|
|
205
|
+
// `. .\script.ps1` — the dot-source operator; `.\x` and `..\x` are paths and read as words.
|
|
206
|
+
if (c === '.' && (isSpace(n) || n === '{' || isSingleQuote(n) || isDoubleQuote(n) || n === '$' || n === '('))
|
|
207
|
+
return this.invocation(i + 1);
|
|
208
|
+
if (c === '{')
|
|
209
|
+
return this.expression(this.statements(i + 1, '}'), true);
|
|
210
|
+
// A loop label, `:outer foreach (...)`.
|
|
211
|
+
if (c === ':' && isIdent(n))
|
|
212
|
+
return this.element(this.readWord(i).end);
|
|
213
|
+
if (this.startsExpression(i))
|
|
214
|
+
return this.expression(i, true);
|
|
215
|
+
const word = this.readWord(i);
|
|
216
|
+
const lower = word.literal.toLowerCase();
|
|
217
|
+
if (!word.dynamic) {
|
|
218
|
+
if (lower === 'switch')
|
|
219
|
+
return this.switchStatement(word.end);
|
|
220
|
+
if (NAMING_KEYWORDS.has(lower))
|
|
221
|
+
return this.keywordRest(this.readWord(this.skip(word.end)).end);
|
|
222
|
+
if (TYPE_KEYWORDS.has(lower))
|
|
223
|
+
return this.typeDefinition(word.end);
|
|
224
|
+
if (PIPELINE_KEYWORDS.has(lower))
|
|
225
|
+
return this.element(word.end);
|
|
226
|
+
if (BLOCK_KEYWORDS.has(lower))
|
|
227
|
+
return this.keywordRest(word.end);
|
|
228
|
+
}
|
|
229
|
+
if (word.dynamic)
|
|
230
|
+
this.fail(`the command "${word.literal}" is built from a variable, so which program it starts is known only when it runs`);
|
|
231
|
+
else if (word.literal)
|
|
232
|
+
this.heads.push(word.literal);
|
|
233
|
+
return this.commandArgs(word.end);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Whether what starts at `i` is an expression rather than a command name: a variable, a string,
|
|
237
|
+
* a number, an operator, a bracket, an array or hashtable.
|
|
238
|
+
*/
|
|
239
|
+
startsExpression(i) {
|
|
240
|
+
const c = this.at(i);
|
|
241
|
+
if (c === '$' || c === '@' || c === '(' || c === '[' || c === '!' || c === '+' || c === '-' || c === ',' || c === '*' || c === '/')
|
|
242
|
+
return true;
|
|
243
|
+
if (isSingleQuote(c) || isDoubleQuote(c))
|
|
244
|
+
return true;
|
|
245
|
+
// A number, but not a program whose name begins with digits (`7z`).
|
|
246
|
+
const m = /^(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?|\.\d+)(?:[ld]|kb|mb|gb|tb|pb)?/i.exec(this.s.slice(i, i + 40));
|
|
247
|
+
if (m) {
|
|
248
|
+
const after = this.at(i + m[0].length);
|
|
249
|
+
return !/[A-Za-z0-9_\\/:]/.test(after);
|
|
250
|
+
}
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* After `&` or `.`: the thing invoked. A literal name or a quoted literal path is a command head;
|
|
255
|
+
* a script block is read as statements; anything else — a variable, a subexpression, a string
|
|
256
|
+
* with a variable in it — chooses the program at run time, and the scan is uncertain.
|
|
257
|
+
*/
|
|
258
|
+
invocation(i) {
|
|
259
|
+
i = this.skip(i);
|
|
260
|
+
const c = this.at(i);
|
|
261
|
+
if (c === '{')
|
|
262
|
+
return this.commandArgs(this.statements(i + 1, '}'));
|
|
263
|
+
if (isTerminator(c)) {
|
|
264
|
+
this.fail('a call operator has nothing after it');
|
|
265
|
+
return i;
|
|
266
|
+
}
|
|
267
|
+
if (c === '$' || c === '(' || c === '[' || c === '@') {
|
|
268
|
+
this.fail('the program after "&" or "." is chosen at run time, so which program it starts is known only when it runs');
|
|
269
|
+
return this.commandArgs(i);
|
|
270
|
+
}
|
|
271
|
+
const word = this.readWord(i);
|
|
272
|
+
if (word.dynamic)
|
|
273
|
+
this.fail(`the program "${word.literal}" is built from a variable, so which program it starts is known only when it runs`);
|
|
274
|
+
else if (word.literal)
|
|
275
|
+
this.heads.push(word.literal);
|
|
276
|
+
return this.commandArgs(word.end);
|
|
277
|
+
}
|
|
278
|
+
/** A command's arguments, up to the end of its pipeline element. Nested statements are read. */
|
|
279
|
+
commandArgs(i) {
|
|
280
|
+
for (;;) {
|
|
281
|
+
i = this.skip(i);
|
|
282
|
+
const c = this.at(i);
|
|
283
|
+
const n = this.at(i + 1);
|
|
284
|
+
if (isTerminator(c))
|
|
285
|
+
return i;
|
|
286
|
+
// `--%` hands the rest of the line to the program untouched, up to a pipe or a newline.
|
|
287
|
+
if (c === '-' && n === '-' && this.at(i + 2) === '%') {
|
|
288
|
+
while (i < this.s.length && this.at(i) !== '\n' && this.at(i) !== '|')
|
|
289
|
+
i += 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const redirect = this.redirection(i);
|
|
293
|
+
if (redirect > i) {
|
|
294
|
+
i = redirect;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (c === ',') {
|
|
298
|
+
i += 1;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const nested = this.nested(i, 'statements');
|
|
302
|
+
if (nested !== null) {
|
|
303
|
+
i = nested;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const word = this.readWord(i);
|
|
307
|
+
i = word.end > i ? word.end : i + 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* An expression, up to the end of its pipeline element. Its bare words are operators, member
|
|
312
|
+
* names and operands — never commands — with two exceptions that begin a command position: an
|
|
313
|
+
* assignment at the statement's own level (`$out = git status`), and the `in` of a `foreach`
|
|
314
|
+
* (`foreach ($f in Get-ChildItem)`), whose right-hand side is a pipeline.
|
|
315
|
+
*/
|
|
316
|
+
expression(i, statementLevel) {
|
|
317
|
+
for (;;) {
|
|
318
|
+
i = this.skip(i);
|
|
319
|
+
const c = this.at(i);
|
|
320
|
+
if (isTerminator(c))
|
|
321
|
+
return i;
|
|
322
|
+
if (c === '=' && statementLevel)
|
|
323
|
+
return this.element(i + 1);
|
|
324
|
+
const redirect = this.redirection(i);
|
|
325
|
+
if (redirect > i) {
|
|
326
|
+
i = redirect;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const nested = this.nested(i, 'expression');
|
|
330
|
+
if (nested !== null) {
|
|
331
|
+
i = nested;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (/[A-Za-z_]/.test(c)) {
|
|
335
|
+
let j = i;
|
|
336
|
+
while (isIdent(this.at(j)))
|
|
337
|
+
j += 1;
|
|
338
|
+
if (this.s.slice(i, j).toLowerCase() === 'in' && !isIdent(this.at(i - 1)) && this.at(i - 1) !== '-' && this.at(i - 1) !== '.') {
|
|
339
|
+
return this.element(j);
|
|
340
|
+
}
|
|
341
|
+
i = j;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
i += 1;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The inside of `[ ]`, of a method call's `( )`, or of a class or enum body: expressions and
|
|
349
|
+
* names, separated by commas and newlines, up to `closer`. Nothing here is at a command position
|
|
350
|
+
* except what nests statements again (a `( )` that is not a call, a script block, `$( )`).
|
|
351
|
+
*/
|
|
352
|
+
expressionList(i, closer) {
|
|
353
|
+
if (!this.enter())
|
|
354
|
+
return this.s.length;
|
|
355
|
+
try {
|
|
356
|
+
for (;;) {
|
|
357
|
+
i = this.skip(i, 'lines');
|
|
358
|
+
const c = this.at(i);
|
|
359
|
+
if (c === '') {
|
|
360
|
+
this.fail(`a "${closer === ')' ? '(' : closer === ']' ? '[' : '{'}" is never closed`);
|
|
361
|
+
return i;
|
|
362
|
+
}
|
|
363
|
+
if (c === closer)
|
|
364
|
+
return i + 1;
|
|
365
|
+
if (c === ')' || c === ']' || c === '}') {
|
|
366
|
+
this.fail(`"${c}" closes what "${closer}" should have`);
|
|
367
|
+
return this.s.length;
|
|
368
|
+
}
|
|
369
|
+
const nested = this.nested(i, 'expression');
|
|
370
|
+
if (nested !== null) {
|
|
371
|
+
i = nested;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
i += 1;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
this.depth -= 1;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* The constructs that read the same wherever they appear: strings, here-strings, subexpressions,
|
|
383
|
+
* arrays, hashtables, script blocks, brackets. Returns the index past the construct, or null
|
|
384
|
+
* when none starts at `i`. `mode` decides what a plain `( )` is: in command mode an argument that
|
|
385
|
+
* holds statements, in expression mode a call's arguments when it follows a member name and a
|
|
386
|
+
* grouping (statements) otherwise.
|
|
387
|
+
*/
|
|
388
|
+
nested(i, mode) {
|
|
389
|
+
const c = this.at(i);
|
|
390
|
+
const n = this.at(i + 1);
|
|
391
|
+
if (isSingleQuote(c))
|
|
392
|
+
return this.singleQuoted(i).end;
|
|
393
|
+
if (isDoubleQuote(c))
|
|
394
|
+
return this.doubleQuoted(i).end;
|
|
395
|
+
if (c === '@' && (isSingleQuote(n) || isDoubleQuote(n)))
|
|
396
|
+
return this.hereString(i);
|
|
397
|
+
if (c === '@' && n === '(')
|
|
398
|
+
return this.statements(i + 2, ')');
|
|
399
|
+
if (c === '@' && n === '{')
|
|
400
|
+
return this.statements(i + 2, '}', 'hashtable');
|
|
401
|
+
if (c === '$' && n === '(')
|
|
402
|
+
return this.statements(i + 2, ')');
|
|
403
|
+
if (c === '$' && n === '{') {
|
|
404
|
+
const close = this.s.indexOf('}', i + 2);
|
|
405
|
+
if (close < 0) {
|
|
406
|
+
this.fail('a ${ variable name is never closed');
|
|
407
|
+
return this.s.length;
|
|
408
|
+
}
|
|
409
|
+
return close + 1;
|
|
410
|
+
}
|
|
411
|
+
if (c === '{')
|
|
412
|
+
return this.statements(i + 1, '}');
|
|
413
|
+
if (c === '(')
|
|
414
|
+
return mode === 'expression' && this.isCallParen(i) ? this.expressionList(i + 1, ')') : this.statements(i + 1, ')');
|
|
415
|
+
if (c === '[' && mode === 'expression')
|
|
416
|
+
return this.expressionList(i + 1, ']');
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Whether the `(` at `i` opens a call's arguments: it follows a name directly, and the name
|
|
421
|
+
* follows `.` or `::` (a method: `$p.Replace(`, `[regex]::Match(`) or `[` (an attribute or a
|
|
422
|
+
* generic: `[Parameter(Mandatory)]`). `-not(` and `-join(` are operators followed by a
|
|
423
|
+
* grouping, and read as one.
|
|
424
|
+
*/
|
|
425
|
+
isCallParen(i) {
|
|
426
|
+
let j = i - 1;
|
|
427
|
+
while (j >= 0 && isIdent(this.at(j)))
|
|
428
|
+
j -= 1;
|
|
429
|
+
if (j === i - 1)
|
|
430
|
+
return false;
|
|
431
|
+
const before = this.at(j);
|
|
432
|
+
return before === '.' || before === ':' || before === '[';
|
|
433
|
+
}
|
|
434
|
+
/** A redirection at `i` (`>`, `>>`, `2>`, `2>&1`, `*>&1`), or `i` when there is none. */
|
|
435
|
+
redirection(i) {
|
|
436
|
+
const m = /^(?:[1-6*]?>>?(?:&[1-6])?)/.exec(this.s.slice(i, i + 5));
|
|
437
|
+
return m ? i + m[0].length : i;
|
|
438
|
+
}
|
|
439
|
+
/** A bare word, with the quoted parts and escapes inside it resolved. */
|
|
440
|
+
readWord(i) {
|
|
441
|
+
let literal = '';
|
|
442
|
+
let dynamic = false;
|
|
443
|
+
let j = i;
|
|
444
|
+
while (j < this.s.length) {
|
|
445
|
+
const c = this.at(j);
|
|
446
|
+
if (c === '`') {
|
|
447
|
+
const n = this.at(j + 1);
|
|
448
|
+
if (n === '' || n === '\n' || n === '\r')
|
|
449
|
+
break;
|
|
450
|
+
literal += n;
|
|
451
|
+
j += 2;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (isSingleQuote(c)) {
|
|
455
|
+
const q = this.singleQuoted(j);
|
|
456
|
+
literal += q.literal;
|
|
457
|
+
j = q.end;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (isDoubleQuote(c)) {
|
|
461
|
+
const q = this.doubleQuoted(j);
|
|
462
|
+
literal += q.literal;
|
|
463
|
+
dynamic ||= q.dynamic;
|
|
464
|
+
j = q.end;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (c === '$') {
|
|
468
|
+
dynamic = true;
|
|
469
|
+
const n = this.at(j + 1);
|
|
470
|
+
if (n === '(') {
|
|
471
|
+
j = this.statements(j + 2, ')');
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (n === '{') {
|
|
475
|
+
const close = this.s.indexOf('}', j + 2);
|
|
476
|
+
j = close < 0 ? this.s.length : close + 1;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
literal += c;
|
|
480
|
+
j += 1;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (isSpace(c) || WORD_BREAK.has(c))
|
|
484
|
+
break;
|
|
485
|
+
literal += c;
|
|
486
|
+
j += 1;
|
|
487
|
+
}
|
|
488
|
+
return { literal, end: j, dynamic };
|
|
489
|
+
}
|
|
490
|
+
/** `'...'`, where `''` is a quote. Literal: nothing inside expands. */
|
|
491
|
+
singleQuoted(i) {
|
|
492
|
+
let literal = '';
|
|
493
|
+
let j = i + 1;
|
|
494
|
+
while (j < this.s.length) {
|
|
495
|
+
const c = this.at(j);
|
|
496
|
+
if (isSingleQuote(c)) {
|
|
497
|
+
if (isSingleQuote(this.at(j + 1))) {
|
|
498
|
+
literal += "'";
|
|
499
|
+
j += 2;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
return { literal, end: j + 1 };
|
|
503
|
+
}
|
|
504
|
+
literal += c;
|
|
505
|
+
j += 1;
|
|
506
|
+
}
|
|
507
|
+
this.fail('a single-quoted string is never closed');
|
|
508
|
+
return { literal, end: this.s.length };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* `"..."`, where a back-tick escapes and `""` is a quote. A `$( )` inside runs, so it is read as
|
|
512
|
+
* statements; a variable inside makes the text dynamic.
|
|
513
|
+
*/
|
|
514
|
+
doubleQuoted(i) {
|
|
515
|
+
let literal = '';
|
|
516
|
+
let dynamic = false;
|
|
517
|
+
let j = i + 1;
|
|
518
|
+
while (j < this.s.length) {
|
|
519
|
+
const c = this.at(j);
|
|
520
|
+
if (c === '`') {
|
|
521
|
+
literal += this.at(j + 1);
|
|
522
|
+
j += 2;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (isDoubleQuote(c)) {
|
|
526
|
+
if (isDoubleQuote(this.at(j + 1))) {
|
|
527
|
+
literal += '"';
|
|
528
|
+
j += 2;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
return { literal, end: j + 1, dynamic };
|
|
532
|
+
}
|
|
533
|
+
if (c === '$' && this.at(j + 1) === '(') {
|
|
534
|
+
dynamic = true;
|
|
535
|
+
j = this.statements(j + 2, ')');
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (c === '$' && /[A-Za-z_{:?]/.test(this.at(j + 1)))
|
|
539
|
+
dynamic = true;
|
|
540
|
+
literal += c;
|
|
541
|
+
j += 1;
|
|
542
|
+
}
|
|
543
|
+
this.fail('a double-quoted string is never closed');
|
|
544
|
+
return { literal, end: this.s.length, dynamic };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* `@'...'@` and `@"..."@`: the header ends its line, the body runs to a line that begins with
|
|
548
|
+
* the closing quote and `@`. A double-quoted one expands, so its `$( )` are read as statements.
|
|
549
|
+
*/
|
|
550
|
+
hereString(i) {
|
|
551
|
+
const quote = this.at(i + 1);
|
|
552
|
+
const double = isDoubleQuote(quote);
|
|
553
|
+
let j = i + 2;
|
|
554
|
+
while (isSpace(this.at(j)))
|
|
555
|
+
j += 1;
|
|
556
|
+
if (this.at(j) !== '\n') {
|
|
557
|
+
this.fail('a here-string header must end its line');
|
|
558
|
+
return this.s.length;
|
|
559
|
+
}
|
|
560
|
+
const closing = new RegExp(`\\n[${double ? '"\u201c\u201d\u201e' : "'\u2018\u2019\u201a\u201b"}]@`, 'g');
|
|
561
|
+
closing.lastIndex = j;
|
|
562
|
+
const m = closing.exec(this.s);
|
|
563
|
+
if (!m) {
|
|
564
|
+
this.fail('a here-string is never closed');
|
|
565
|
+
return this.s.length;
|
|
566
|
+
}
|
|
567
|
+
const bodyEnd = m.index;
|
|
568
|
+
if (double) {
|
|
569
|
+
let k = j + 1;
|
|
570
|
+
while (k < bodyEnd) {
|
|
571
|
+
if (this.at(k) === '`') {
|
|
572
|
+
k += 2;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (this.at(k) === '$' && this.at(k + 1) === '(') {
|
|
576
|
+
k = this.statements(k + 2, ')');
|
|
577
|
+
if (k > bodyEnd) {
|
|
578
|
+
this.fail('a $( ) inside a here-string runs past its end');
|
|
579
|
+
return this.s.length;
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
k += 1;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return bodyEnd + m[0].length;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The rest of a statement that began with a keyword: conditions and parameter blocks are
|
|
590
|
+
* statements, blocks are statements, and the bare words between them — `else`, flags, labels,
|
|
591
|
+
* the name after `function` — are not commands.
|
|
592
|
+
*/
|
|
593
|
+
keywordRest(i) {
|
|
594
|
+
for (;;) {
|
|
595
|
+
i = this.skip(i);
|
|
596
|
+
const c = this.at(i);
|
|
597
|
+
if (isTerminator(c))
|
|
598
|
+
return i;
|
|
599
|
+
if (c === '(') {
|
|
600
|
+
i = this.statements(i + 1, ')');
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const nested = this.nested(i, 'expression');
|
|
604
|
+
if (nested !== null) {
|
|
605
|
+
i = nested;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
const word = this.readWord(i);
|
|
609
|
+
i = word.end > i ? word.end : i + 1;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/** `switch [-flags] (value) { clause { block } ... }`. */
|
|
613
|
+
switchStatement(i) {
|
|
614
|
+
for (;;) {
|
|
615
|
+
i = this.skip(i, 'lines');
|
|
616
|
+
const c = this.at(i);
|
|
617
|
+
if (isTerminator(c))
|
|
618
|
+
return i;
|
|
619
|
+
if (c === '(') {
|
|
620
|
+
i = this.statements(i + 1, ')');
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (c === '{')
|
|
624
|
+
return this.statements(i + 1, '}', 'switch');
|
|
625
|
+
const nested = this.nested(i, 'expression');
|
|
626
|
+
if (nested !== null) {
|
|
627
|
+
i = nested;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const word = this.readWord(i);
|
|
631
|
+
i = word.end > i ? word.end : i + 1;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** One clause of a switch: a condition (a value, a word, or a script block) and its block. */
|
|
635
|
+
switchClause(i) {
|
|
636
|
+
i = this.skip(i);
|
|
637
|
+
if (this.at(i) === '{') {
|
|
638
|
+
i = this.statements(i + 1, '}');
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
for (;;) {
|
|
642
|
+
i = this.skip(i);
|
|
643
|
+
const c = this.at(i);
|
|
644
|
+
if (c === '{' || isTerminator(c))
|
|
645
|
+
break;
|
|
646
|
+
const nested = this.nested(i, 'expression');
|
|
647
|
+
if (nested !== null) {
|
|
648
|
+
i = nested;
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
const word = this.readWord(i);
|
|
652
|
+
i = word.end > i ? word.end : i + 1;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
i = this.skip(i, 'lines');
|
|
656
|
+
return this.at(i) === '{' ? this.statements(i + 1, '}') : i;
|
|
657
|
+
}
|
|
658
|
+
/** `class Name : Base { members }` and `enum Name { A; B }`: the body is members, not statements. */
|
|
659
|
+
typeDefinition(i) {
|
|
660
|
+
for (;;) {
|
|
661
|
+
i = this.skip(i, 'lines');
|
|
662
|
+
const c = this.at(i);
|
|
663
|
+
if (isTerminator(c))
|
|
664
|
+
return i;
|
|
665
|
+
if (c === '{')
|
|
666
|
+
return this.expressionList(i + 1, '}');
|
|
667
|
+
const word = this.readWord(i);
|
|
668
|
+
i = word.end > i ? word.end : i + 1;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** One `key = value` of a hashtable. The key is data; the value is a statement. */
|
|
672
|
+
hashEntry(i) {
|
|
673
|
+
for (;;) {
|
|
674
|
+
i = this.skip(i);
|
|
675
|
+
const c = this.at(i);
|
|
676
|
+
if (c === '=')
|
|
677
|
+
return this.pipeline(i + 1);
|
|
678
|
+
if (isTerminator(c))
|
|
679
|
+
return i;
|
|
680
|
+
const nested = this.nested(i, 'expression');
|
|
681
|
+
if (nested !== null) {
|
|
682
|
+
i = nested;
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
i += 1;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// ------------------------------------------------------------------------------------------
|
|
690
|
+
// cmd
|
|
691
|
+
// ------------------------------------------------------------------------------------------
|
|
692
|
+
/**
|
|
693
|
+
* `cmd`'s rules, which are few: `"` quotes, `^` escapes, `&`, `&&`, `||`, `|` and newlines
|
|
694
|
+
* separate commands, and `(` groups only where a command could start or after `if`/`for ... do`/
|
|
695
|
+
* `else` — elsewhere, as in `echo (done)`, it is text. `rem` and `::` are comments; `call` runs
|
|
696
|
+
* the word after it; `if` and `for` are read past their conditions to the command they guard.
|
|
697
|
+
* A variable (`%x%`, `!x!`) in a command's name is a name known only at run time.
|
|
698
|
+
*/
|
|
699
|
+
function scanCmd(src) {
|
|
700
|
+
const heads = [];
|
|
701
|
+
let uncertain;
|
|
702
|
+
const fail = (why) => {
|
|
703
|
+
uncertain ??= why;
|
|
704
|
+
};
|
|
705
|
+
const segments = [];
|
|
706
|
+
let current = '';
|
|
707
|
+
let quoted = false;
|
|
708
|
+
let groups = 0;
|
|
709
|
+
const atCommandStart = () => /^\s*@?\s*$/.test(current) || /^\s*@?\s*(?:if\b.*|for\b.*\bdo|else)\s*$/i.test(current);
|
|
710
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
711
|
+
const c = src[i];
|
|
712
|
+
if (quoted) {
|
|
713
|
+
current += c;
|
|
714
|
+
if (c === '"')
|
|
715
|
+
quoted = false;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (c === '^') {
|
|
719
|
+
current += src[i + 1] ?? '';
|
|
720
|
+
i += 1;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (c === '"') {
|
|
724
|
+
quoted = true;
|
|
725
|
+
current += c;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
// `2>&1`: the `&` of a redirection names a stream, it does not separate commands.
|
|
729
|
+
if (c === '&' && (current.endsWith('>') || current.endsWith('<'))) {
|
|
730
|
+
current += c;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (c === '&' || c === '|' || c === '\n') {
|
|
734
|
+
if (src[i + 1] === c)
|
|
735
|
+
i += 1;
|
|
736
|
+
segments.push(current);
|
|
737
|
+
current = '';
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (c === '(' && atCommandStart()) {
|
|
741
|
+
groups += 1;
|
|
742
|
+
segments.push(current);
|
|
743
|
+
current = '';
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (c === ')' && groups > 0) {
|
|
747
|
+
groups -= 1;
|
|
748
|
+
segments.push(current);
|
|
749
|
+
current = '';
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
current += c;
|
|
753
|
+
}
|
|
754
|
+
segments.push(current);
|
|
755
|
+
if (quoted)
|
|
756
|
+
fail('a double-quoted string is never closed');
|
|
757
|
+
if (groups > 0)
|
|
758
|
+
fail('a "(" is never closed');
|
|
759
|
+
for (const segment of segments) {
|
|
760
|
+
let tokens = segment.replace(/^\s*@/, '').match(/"[^"]*"|\S+/g) ?? [];
|
|
761
|
+
// Redirections are not the command: `>nul`, `2>&1`, `<in.txt`, with or without a space.
|
|
762
|
+
tokens = tokens.filter((t, k) => !/^\d?[<>]/.test(t) && !(k > 0 && /^\d?[<>]>?$/.test(tokens[k - 1])));
|
|
763
|
+
let k = 0;
|
|
764
|
+
const word = () => (tokens[k] ?? '').toLowerCase();
|
|
765
|
+
if (word() === 'rem' || word().startsWith('::'))
|
|
766
|
+
continue;
|
|
767
|
+
if (word() === 'else')
|
|
768
|
+
k += 1;
|
|
769
|
+
if (word() === 'if') {
|
|
770
|
+
k += 1;
|
|
771
|
+
if (word() === '/i')
|
|
772
|
+
k += 1;
|
|
773
|
+
if (word() === 'not')
|
|
774
|
+
k += 1;
|
|
775
|
+
if (word() === 'exist' || word() === 'defined' || word() === 'errorlevel')
|
|
776
|
+
k += 2;
|
|
777
|
+
else if (tokens[k]?.includes('=='))
|
|
778
|
+
k += 1;
|
|
779
|
+
else
|
|
780
|
+
k += 3; // a EQU b
|
|
781
|
+
}
|
|
782
|
+
else if (word() === 'for') {
|
|
783
|
+
const doAt = tokens.findIndex((t) => t.toLowerCase() === 'do');
|
|
784
|
+
k = doAt < 0 ? tokens.length : doAt + 1;
|
|
785
|
+
}
|
|
786
|
+
if (word() === 'call')
|
|
787
|
+
k += 1;
|
|
788
|
+
const head = tokens[k];
|
|
789
|
+
if (!head || head.startsWith(':'))
|
|
790
|
+
continue;
|
|
791
|
+
const literal = head.replace(/"/g, '');
|
|
792
|
+
if (/%[^%\s]+%|![^!\s]+!/.test(literal)) {
|
|
793
|
+
fail(`the command "${literal}" is built from a variable, so which program it starts is known only when it runs`);
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
heads.push(literal);
|
|
797
|
+
}
|
|
798
|
+
return uncertain ? { heads, uncertain } : { heads };
|
|
799
|
+
}
|