@veda-hq/pariksha 0.5.2 → 0.5.3
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 +68 -2
- package/dist/cli.js +63 -0
- package/dist/env-gate-switches.js +1041 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
test-quality ratchets. Part of [Veda](https://github.com/veda-hq/veda).
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
|
-
npx @veda-hq/pariksha@0.5.
|
|
8
|
-
npx @veda-hq/pariksha@0.5.
|
|
7
|
+
npx @veda-hq/pariksha@0.5.3 <check> [flags]
|
|
8
|
+
npx @veda-hq/pariksha@0.5.3 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
Every check exits 0 when the repository passes, 1 when it refuses something
|
|
@@ -155,6 +155,72 @@ row that is not `` | `path` | bytes | ``, since nothing would enforce it.
|
|
|
155
155
|
The table file is read on every check too, so it must budget itself: a row
|
|
156
156
|
for its own path (relative to `--root`) is required.
|
|
157
157
|
|
|
158
|
+
## `env-gate-switches`
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A hook that reads `PUSH_SKIP` from the environment has a bypass anyone can
|
|
165
|
+
type: `PUSH_SKIP=1 git push`. This check refuses gate code that READS an
|
|
166
|
+
environment variable whose name has a switch word (`X_SKIP`, `SKIP_X`,
|
|
167
|
+
`X_PROBE_Y`): writing or deleting one is not a read. The JSON `--config`
|
|
168
|
+
(relative to the root) says where gate code is:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"gatePaths": ["^\\.husky/", "^scripts/", "^\\.github/workflows/"],
|
|
173
|
+
"notGateCode": ["\\.test\\.[cm]?[jt]sx?$", "(?:^|/)fixtures?/", "\\.md$"],
|
|
174
|
+
"notAGate": {
|
|
175
|
+
"SETUP_SKIP_MOBILE": { "files": ["scripts/setup.sh"], "reason": "installs no mobile toolchain; checks nothing" }
|
|
176
|
+
},
|
|
177
|
+
"words": ["SKIP", "PROBE"]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- `gatePaths` (required) and `notGateCode` are path regexes: a file is gate
|
|
182
|
+
code when a gate path matches it and no `notGateCode` entry does.
|
|
183
|
+
- `notAGate` allows a switch that turns off something other than a check,
|
|
184
|
+
in the files listed only, with the reason a reviewer reads; the same name
|
|
185
|
+
anywhere else is still refused.
|
|
186
|
+
- `words` (default `SKIP` and `PROBE`) are upper-case words; a name made of
|
|
187
|
+
`_`-joined words, one of them a switch word, is a switch.
|
|
188
|
+
- Shell (`.sh`, and every file under `.husky/`) is read as bash reads it:
|
|
189
|
+
single quotes, escapes, comments and quoted heredoc bodies expand nothing,
|
|
190
|
+
and a bare name bash reads is a read: in arithmetic (`(( X_SKIP ))`,
|
|
191
|
+
`let`, `-eq`, a subscript), `printenv X_SKIP`, and `-v X_SKIP` in `[ ]`,
|
|
192
|
+
`[[ ]]` or `test`. A variable the file initialises at the top level before
|
|
193
|
+
reading it is the script's own decision, not a read; one initialised in a
|
|
194
|
+
branch, loop, function, group, subshell, pipeline or the end of a `&&`
|
|
195
|
+
list, or self-defaulted (`X_SKIP="${X_SKIP:-0}"`), is.
|
|
196
|
+
- TypeScript and JavaScript are read as a syntax tree, with a type checker
|
|
197
|
+
over the one file: dotted, bracketed, optional, destructured (nested too:
|
|
198
|
+
`const { env: { X_SKIP } } = process`) and `in` reads count, through
|
|
199
|
+
`process`, `globalThis.process`, `node:process` and any local bound to the
|
|
200
|
+
environment, with constant keys resolved (`env['X_' + 'SKIP']`); a mention
|
|
201
|
+
in a comment or string does not.
|
|
202
|
+
- Python reads are `os.environ[…]`, `.get(…)`, `.setdefault(…)`, `.pop(…)`,
|
|
203
|
+
`'X' in` / `'X' not in os.environ`, the same on `os.environ.copy()` or
|
|
204
|
+
`dict(os.environ)`, and `os.getenv(…)`, with the bytes forms (`environb`,
|
|
205
|
+
`getenvb`): under `os` by any name, through `from os import environ as e`
|
|
206
|
+
/ `getenv as g`, and through a name assigned one (`env = os.environ`,
|
|
207
|
+
`read = os.getenv`), outside strings and comments (f-string fields are
|
|
208
|
+
code). A key held in a variable is not resolved. Workflow YAML reads are
|
|
209
|
+
`$X`, `env.X` and `env['X']` outside comments.
|
|
210
|
+
|
|
211
|
+
`tree` (the default) reads every tracked file in the working tree, skipping
|
|
212
|
+
one deleted there; when every tracked gate file is deleted, nothing was read
|
|
213
|
+
and it exits 2. `staged` (for a pre-commit hook) reads the files the index
|
|
214
|
+
adds, copies, modifies, renames or changes the type of (a symlink replaced
|
|
215
|
+
by a file), as the index holds them. In either mode, a repository with no
|
|
216
|
+
tracked gate code exits 2, since the config would check nothing, and a gate
|
|
217
|
+
file that is a symlink (in the index for staged, in the working tree for
|
|
218
|
+
tree) exits 2: its text is a path, and its target goes unchecked. A gate
|
|
219
|
+
file that cannot be read (one under a folder the check cannot open
|
|
220
|
+
included), a git call that fails, or a failure while reading a file's code
|
|
221
|
+
exits 2: an unread gate is never clean. Needs `typescript` only when a
|
|
222
|
+
TypeScript or JavaScript file is gate code.
|
|
223
|
+
|
|
158
224
|
## `export-deletions`
|
|
159
225
|
|
|
160
226
|
```sh
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { findDenied, hash, isText, parseDenyList } from "./boundary.js";
|
|
|
9
9
|
import { DEFAULT_CONFIG, findGaps, formatGaps, listedPaths, parseConfig, treePaths } from "./coverage-by-type.js";
|
|
10
10
|
import { checkBuild, checkPackage, checkVitest, formatViolations, loaderFailure, loadVitestExclude, missingConfigs, ownedBy, parseConfig as parseExemptions } from "./coverage-exemptions.js";
|
|
11
11
|
import { checkBudgets } from "./doc-budgets.js";
|
|
12
|
+
import { diskReads, formatReads, listFiles, needsTypescript, parseConfig as parseGateConfig, readerFor, readGate } from "./env-gate-switches.js";
|
|
12
13
|
import { loadTypescript, typescriptLoaders } from "./typescript.js";
|
|
13
14
|
import { checkExportDeletions, formatReport } from "./export-deletions.js";
|
|
14
15
|
import { BASE_CANDIDATES, gitAt, gitAtTop, must, resolveBase, topOf } from "./git.js";
|
|
@@ -19,6 +20,7 @@ const USAGE = [
|
|
|
19
20
|
' pariksha coverage-by-type [--config <file>] [--files-from <file>|-] [--root <dir>]',
|
|
20
21
|
' pariksha coverage-exemptions --config <file> [--package <dir>] [--root <dir>]',
|
|
21
22
|
' pariksha doc-budgets --table <file> [--section <name>] [--root <dir>]',
|
|
23
|
+
' pariksha env-gate-switches --config <file> [--mode staged|tree] [--root <dir>]',
|
|
22
24
|
' pariksha export-deletions [--base <ref>] [--root <dir>]',
|
|
23
25
|
' pariksha hash <word>',
|
|
24
26
|
' pariksha --version',
|
|
@@ -254,12 +256,73 @@ const coverageExemptions = (f, io) => {
|
|
|
254
256
|
return 2;
|
|
255
257
|
}
|
|
256
258
|
};
|
|
259
|
+
const envGateSwitches = (f, io) => {
|
|
260
|
+
const P = 'pariksha env-gate-switches:';
|
|
261
|
+
const configPath = f.get('--config');
|
|
262
|
+
const mode = f.get('--mode') ?? 'tree';
|
|
263
|
+
if (configPath === undefined || (mode !== 'staged' && mode !== 'tree')) {
|
|
264
|
+
io.err(USAGE);
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
const dir = resolve(f.get('--root') ?? '.');
|
|
268
|
+
const root = topOf(dir);
|
|
269
|
+
if (root === undefined) {
|
|
270
|
+
io.err(`${P} ${dir} is not in a git repository`);
|
|
271
|
+
return 2;
|
|
272
|
+
}
|
|
273
|
+
let reader;
|
|
274
|
+
let words;
|
|
275
|
+
try {
|
|
276
|
+
const config = parseGateConfig(readFileSync(resolve(root, configPath), 'utf8'));
|
|
277
|
+
reader = readerFor(config);
|
|
278
|
+
words = config.words;
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
io.err(`${P} ${e.message}; nothing was checked, so this is not a pass`);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
const reads = (io.gateReads ?? diskReads)(root);
|
|
285
|
+
let result;
|
|
286
|
+
try {
|
|
287
|
+
const listed = listFiles(reads, mode).filter((l) => reader.isGateCode(l.file));
|
|
288
|
+
// A tree with no gate code is a config whose gatePaths match nothing (a
|
|
289
|
+
// typo): staged mode would pass every commit under it, so both refuse.
|
|
290
|
+
if (!(mode === 'tree' ? listed.length > 0 : listFiles(reads, 'tree').some((l) => reader.isGateCode(l.file)))) {
|
|
291
|
+
io.err(`${P} no tracked file is gate code under the config's gatePaths; nothing was checked, so this is not a pass`);
|
|
292
|
+
return 2;
|
|
293
|
+
}
|
|
294
|
+
const sources = listed.flatMap((entry) => {
|
|
295
|
+
const text = readGate(reads, mode, entry);
|
|
296
|
+
return text === undefined ? [] : [{ file: entry.file, text }];
|
|
297
|
+
});
|
|
298
|
+
if (mode === 'tree' && sources.length === 0) {
|
|
299
|
+
// Staged mode reads the index, which holds every file it lists.
|
|
300
|
+
io.err(`${P} every tracked gate file (${listed.length}) is gone from the working tree; nothing was checked, so this is not a pass`);
|
|
301
|
+
return 2;
|
|
302
|
+
}
|
|
303
|
+
const ts = sources.some((s) => needsTypescript(s.file)) ? (io.typescript ?? (() => loadTypescript(typescriptLoaders(root))))() : undefined;
|
|
304
|
+
if (typeof ts === 'string') {
|
|
305
|
+
io.err(`${P} ${ts}`);
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
result = formatReads(sources.flatMap((s) => reader.findSwitchReads(s.file, s.text, ts)), words, sources.length, configPath);
|
|
309
|
+
}
|
|
310
|
+
catch (e) {
|
|
311
|
+
// An unread gate, or one the reader failed on, is unchecked, never clean.
|
|
312
|
+
io.err(`${P} could not read gate code, so it is unchecked: ${e.message}`);
|
|
313
|
+
return 2;
|
|
314
|
+
}
|
|
315
|
+
result.out.forEach(io.out);
|
|
316
|
+
result.err.forEach(io.err);
|
|
317
|
+
return result.ok ? 0 : 1;
|
|
318
|
+
};
|
|
257
319
|
const COMMANDS = {
|
|
258
320
|
'coverage-exemptions': { allowed: ['--config', '--package', '--root'], run: coverageExemptions },
|
|
259
321
|
'authority-citations': { allowed: ['--mode', '--authority', '--issue-prefix', '--adr-dir', '--root'], run: authorityCitations },
|
|
260
322
|
boundary: { allowed: ['--root', '--deny'], run: boundary },
|
|
261
323
|
'coverage-by-type': { allowed: ['--config', '--files-from', '--root'], run: coverageByType },
|
|
262
324
|
'doc-budgets': { allowed: ['--table', '--section', '--root'], run: docBudgets },
|
|
325
|
+
'env-gate-switches': { allowed: ['--config', '--mode', '--root'], run: envGateSwitches },
|
|
263
326
|
'export-deletions': { allowed: ['--base', '--root'], run: exportDeletions },
|
|
264
327
|
};
|
|
265
328
|
export const run = (args, io, version = ownVersion(), env = process.env) => {
|
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
// env-gate-switches: no gate code reads an environment variable that switches
|
|
2
|
+
// it off.
|
|
3
|
+
//
|
|
4
|
+
// A hook that reads `FOO_SKIP` or `FOO_PROBE` from the environment has a
|
|
5
|
+
// bypass anyone can type: `FOO_SKIP=1 git push`. This check refuses READING an
|
|
6
|
+
// environment variable whose name has a switch word (by default SKIP or PROBE:
|
|
7
|
+
// `X_SKIP`, `SKIP_X`, `X_PROBE_Y`) in gate code: the paths the product's config
|
|
8
|
+
// names (git hooks, agent hooks, the scripts they run, CI workflows), as shell,
|
|
9
|
+
// TypeScript and JavaScript (read by the TypeScript checker), Python and
|
|
10
|
+
// workflow YAML.
|
|
11
|
+
//
|
|
12
|
+
// What is not a read: a shell variable the file itself initialises at the top
|
|
13
|
+
// level before reading it (`DOCKER_SKIP=""` … `$DOCKER_SKIP`) holds the
|
|
14
|
+
// script's own decision, not the caller's. A self-defaulting assignment
|
|
15
|
+
// (`X_SKIP="${X_SKIP:-0}"`) is a read, and so is an assignment inside a branch,
|
|
16
|
+
// loop, function or group (indented or not), which a run may never reach, and
|
|
17
|
+
// `X_SKIP=1 cmd`, which sets cmd's environment only.
|
|
18
|
+
import { closeSync, constants, openSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { isAbsolute, join, posix } from 'node:path';
|
|
20
|
+
import { gitAt, must, PLAIN_DIFF } from "./git.js";
|
|
21
|
+
export const DEFAULT_WORDS = ['SKIP', 'PROBE'];
|
|
22
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
23
|
+
const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s !== '');
|
|
24
|
+
/** A normalized relative POSIX path, as git prints one. */
|
|
25
|
+
const isRepoPath = (p) => !isAbsolute(p) && posix.normalize(p) === p && p !== '.' && p !== '..' && !p.startsWith('../') && !p.endsWith('/');
|
|
26
|
+
const patterns = (raw, key) => {
|
|
27
|
+
const v = raw[key] === undefined ? [] : raw[key];
|
|
28
|
+
if (!isStrings(v))
|
|
29
|
+
throw new Error(`"${key}" must be a list of non-empty regular expressions`);
|
|
30
|
+
return v.map((source) => {
|
|
31
|
+
try {
|
|
32
|
+
return new RegExp(source);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new Error(`${key}: "${source}" is not a regular expression: ${e.message}`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
/** A name made of words, one of them a switch word. */
|
|
40
|
+
const switchName = (words) => String.raw `(?:[A-Z][A-Z0-9]*_)*(?:${words.join('|')})(?:_[A-Z0-9]+)*`;
|
|
41
|
+
/** The config a JSON text names; throws naming the first bad key. */
|
|
42
|
+
export const parseConfig = (text) => {
|
|
43
|
+
let raw;
|
|
44
|
+
try {
|
|
45
|
+
raw = JSON.parse(text);
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
throw new Error(`not JSON: ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(raw))
|
|
51
|
+
throw new Error('the config must be a JSON object');
|
|
52
|
+
const unknown = Object.keys(raw).find((k) => !['gatePaths', 'notGateCode', 'notAGate', 'words'].includes(k));
|
|
53
|
+
if (unknown !== undefined)
|
|
54
|
+
throw new Error(`unknown key "${unknown}"`);
|
|
55
|
+
const gatePaths = patterns(raw, 'gatePaths');
|
|
56
|
+
if (gatePaths.length === 0)
|
|
57
|
+
throw new Error('"gatePaths" is empty, so nothing would be checked');
|
|
58
|
+
const notGateCode = patterns(raw, 'notGateCode');
|
|
59
|
+
const words = raw['words'] === undefined ? DEFAULT_WORDS : raw['words'];
|
|
60
|
+
if (!Array.isArray(words) || words.length === 0)
|
|
61
|
+
throw new Error('"words" must be a non-empty list');
|
|
62
|
+
const badWord = words.find((w) => typeof w !== 'string' || !/^[A-Z0-9]+$/.test(w));
|
|
63
|
+
if (badWord !== undefined)
|
|
64
|
+
throw new Error(`words: ${JSON.stringify(badWord)} is not an upper-case word ([A-Z0-9]+)`);
|
|
65
|
+
const isSwitch = new RegExp(`^${switchName(words)}$`);
|
|
66
|
+
const allowed = raw['notAGate'] === undefined ? {} : raw['notAGate'];
|
|
67
|
+
if (!isRecord(allowed))
|
|
68
|
+
throw new Error('"notAGate" must be an object');
|
|
69
|
+
const notAGate = {};
|
|
70
|
+
for (const [name, entry] of Object.entries(allowed)) {
|
|
71
|
+
if (!isSwitch.test(name))
|
|
72
|
+
throw new Error(`notAGate: "${name}" is not a switch name, so it allows nothing`);
|
|
73
|
+
if (!isRecord(entry))
|
|
74
|
+
throw new Error(`notAGate.${name} must be an object`);
|
|
75
|
+
const extra = Object.keys(entry).find((k) => k !== 'files' && k !== 'reason');
|
|
76
|
+
if (extra !== undefined)
|
|
77
|
+
throw new Error(`notAGate.${name}: unknown key "${extra}"`);
|
|
78
|
+
const { files, reason } = entry;
|
|
79
|
+
if (!isStrings(files) || files.length === 0)
|
|
80
|
+
throw new Error(`notAGate.${name}.files must be a non-empty list of paths`);
|
|
81
|
+
const bad = files.find((f) => !isRepoPath(f));
|
|
82
|
+
if (bad !== undefined)
|
|
83
|
+
throw new Error(`notAGate.${name}.files: "${bad}" is not a normalized root-relative path`);
|
|
84
|
+
// Allowing a switch is a decision a reviewer can see, not a baseline.
|
|
85
|
+
if (typeof reason !== 'string' || reason.trim() === '')
|
|
86
|
+
throw new Error(`notAGate.${name} needs a reason`);
|
|
87
|
+
notAGate[name] = { files, reason };
|
|
88
|
+
}
|
|
89
|
+
return { gatePaths, notGateCode, notAGate, words: words };
|
|
90
|
+
};
|
|
91
|
+
const kindOf = (file) => {
|
|
92
|
+
if (/\.[cm]?[jt]sx?$/.test(file))
|
|
93
|
+
return 'script';
|
|
94
|
+
if (file.endsWith('.py'))
|
|
95
|
+
return 'python';
|
|
96
|
+
if (/\.ya?ml$/.test(file))
|
|
97
|
+
return 'workflow';
|
|
98
|
+
if (file.endsWith('.sh') || file.startsWith('.husky/'))
|
|
99
|
+
return 'shell';
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
/** Whether reading `file` needs the TypeScript compiler. */
|
|
103
|
+
export const needsTypescript = (file) => kindOf(file) === 'script';
|
|
104
|
+
/** A match's group, as a string: an unmatched optional group is empty. */
|
|
105
|
+
const group = (match, index) => match[index] ?? '';
|
|
106
|
+
/**
|
|
107
|
+
* A type checker over this one file and nothing else (no lib, no imports, no
|
|
108
|
+
* `@types`): enough to resolve which declaration each identifier names. The
|
|
109
|
+
* host serves the parsed tree itself; the program asks it for nothing else,
|
|
110
|
+
* and emits nothing.
|
|
111
|
+
*/
|
|
112
|
+
const checkerOf = (ts, tree) => {
|
|
113
|
+
const options = { noLib: true, noResolve: true, allowJs: true, types: [] };
|
|
114
|
+
const host = ts.createCompilerHost(options);
|
|
115
|
+
host.getSourceFile = (name) => (name === tree.fileName ? tree : undefined);
|
|
116
|
+
return ts.createProgram({ rootNames: [tree.fileName], options, host }).getTypeChecker();
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* JavaScript and TypeScript are read as a syntax tree, not as text, so a
|
|
120
|
+
* comment or string that merely mentions `process.env.X_SKIP` is not a read,
|
|
121
|
+
* and every spelling of an access is: dotted, bracketed, optional-chained
|
|
122
|
+
* (`process?.['env']?.['X_SKIP']`), destructured, `'X_SKIP' in env`, and
|
|
123
|
+
* each through any local bound to the environment.
|
|
124
|
+
*/
|
|
125
|
+
const scriptSwitchReads = (ts, file, source, report) => {
|
|
126
|
+
const kind = /x$/.test(file) ? ts.ScriptKind.TSX : /\.[cm]?js$/.test(file) ? ts.ScriptKind.JS : ts.ScriptKind.TS;
|
|
127
|
+
const tree = ts.createSourceFile(`/gate/${file}`, source, ts.ScriptTarget.Latest, true, kind);
|
|
128
|
+
const lineOf = (node) => tree.getLineAndCharacterOfPosition(node.getStart(tree)).line + 1;
|
|
129
|
+
const strip = (node) => {
|
|
130
|
+
let current = node;
|
|
131
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isNonNullExpression(current) || ts.isSatisfiesExpression(current) || ts.isTypeAssertionExpression(current)) {
|
|
132
|
+
current = current.expression;
|
|
133
|
+
}
|
|
134
|
+
return current;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* The string a key expression always evaluates to, or null: a literal, a
|
|
138
|
+
* template or `+` of such parts, or a `const` bound to one, so
|
|
139
|
+
* `env['GATE_' + 'SKIP']` and `const KEY = 'GATE_SKIP'; env[KEY]` name
|
|
140
|
+
* their switch as surely as `env.GATE_SKIP` does.
|
|
141
|
+
*/
|
|
142
|
+
const constants = new Set();
|
|
143
|
+
const constantString = (node) => {
|
|
144
|
+
const target = strip(node);
|
|
145
|
+
if (ts.isStringLiteralLike(target))
|
|
146
|
+
return target.text;
|
|
147
|
+
if (ts.isTemplateExpression(target)) {
|
|
148
|
+
let text = target.head.text;
|
|
149
|
+
for (const span of target.templateSpans) {
|
|
150
|
+
const part = constantString(span.expression);
|
|
151
|
+
if (part === null)
|
|
152
|
+
return null;
|
|
153
|
+
text += part + span.literal.text;
|
|
154
|
+
}
|
|
155
|
+
return text;
|
|
156
|
+
}
|
|
157
|
+
if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
158
|
+
// One unknown operand leaves the sum unknown.
|
|
159
|
+
const parts = [constantString(target.left), constantString(target.right)];
|
|
160
|
+
return parts.includes(null) ? null : parts.join('');
|
|
161
|
+
}
|
|
162
|
+
if (!ts.isIdentifier(target))
|
|
163
|
+
return null;
|
|
164
|
+
const declaration = symbolOf(target)?.declarations?.at(0);
|
|
165
|
+
if (declaration === undefined ||
|
|
166
|
+
!ts.isVariableDeclaration(declaration) ||
|
|
167
|
+
declaration.initializer === undefined ||
|
|
168
|
+
(ts.getCombinedNodeFlags(declaration) & ts.NodeFlags.Const) === 0 ||
|
|
169
|
+
constants.has(declaration)) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
constants.add(declaration);
|
|
173
|
+
try {
|
|
174
|
+
return constantString(declaration.initializer);
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
constants.delete(declaration);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
/** The name an access or binding key names, or null. */
|
|
181
|
+
const literalKey = (node) => {
|
|
182
|
+
if (node === undefined)
|
|
183
|
+
return null;
|
|
184
|
+
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node))
|
|
185
|
+
return node.text;
|
|
186
|
+
if (ts.isStringLiteralLike(node))
|
|
187
|
+
return node.text;
|
|
188
|
+
if (ts.isComputedPropertyName(node))
|
|
189
|
+
return constantString(node.expression);
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
const accessed = (node) => (ts.isPropertyAccessExpression(node) ? node.name.text : constantString(node.argumentExpression));
|
|
193
|
+
/** Locals bound to the `process` module (`import proc from 'node:process'`). */
|
|
194
|
+
const processAliases = new Set();
|
|
195
|
+
/** The file declares something named `process`, which may shadow Node's. */
|
|
196
|
+
let declaresProcess = false;
|
|
197
|
+
/**
|
|
198
|
+
* A `process` destructured in a variable declaration is the global only
|
|
199
|
+
* when it takes the `process` member of `globalThis` or `global`; from any
|
|
200
|
+
* other initialiser it is that object's. Where the source is not in view
|
|
201
|
+
* (a parameter, a nested or array pattern) it is taken as the global.
|
|
202
|
+
*/
|
|
203
|
+
const destructuredGlobal = (element) => {
|
|
204
|
+
const pattern = element.parent;
|
|
205
|
+
const holder = pattern.parent;
|
|
206
|
+
if (!ts.isObjectBindingPattern(pattern) || !ts.isVariableDeclaration(holder) || holder.initializer === undefined) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
const from = strip(holder.initializer);
|
|
210
|
+
if (!ts.isIdentifier(from) || !['globalThis', 'global'].includes(from.text))
|
|
211
|
+
return false;
|
|
212
|
+
// A computed key that is not a constant may name `process` at run time.
|
|
213
|
+
const key = literalKey(element.propertyName ?? element.name);
|
|
214
|
+
return key === null || key === 'process';
|
|
215
|
+
};
|
|
216
|
+
/**
|
|
217
|
+
* `process`, or `globalThis.process`: not any object's `process` member,
|
|
218
|
+
* nor a local `process` that holds an ordinary object.
|
|
219
|
+
*/
|
|
220
|
+
const isProcess = (node) => {
|
|
221
|
+
const target = strip(node);
|
|
222
|
+
if (requiresProcess(target))
|
|
223
|
+
return true;
|
|
224
|
+
if (ts.isIdentifier(target)) {
|
|
225
|
+
if (target.text === 'process') {
|
|
226
|
+
if (!declaresProcess)
|
|
227
|
+
return true;
|
|
228
|
+
const declaration = symbolOf(target)?.declarations?.at(0);
|
|
229
|
+
if (declaration !== undefined && ts.isBindingElement(declaration))
|
|
230
|
+
return destructuredGlobal(declaration);
|
|
231
|
+
// Typed `NodeJS.Process` or `typeof globalThis.process`.
|
|
232
|
+
return namedIsGlobal(target, /\bprocess\b/i, isProcess);
|
|
233
|
+
}
|
|
234
|
+
if (processAliases.size === 0)
|
|
235
|
+
return false;
|
|
236
|
+
const symbol = symbolOf(target);
|
|
237
|
+
return symbol !== undefined && processAliases.has(symbol);
|
|
238
|
+
}
|
|
239
|
+
if (!(ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) || accessed(target) !== 'process') {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
const owner = strip(target.expression);
|
|
243
|
+
return ts.isIdentifier(owner) && ['globalThis', 'global'].includes(owner.text);
|
|
244
|
+
};
|
|
245
|
+
// Locals bound to the environment, by BINDING (the checker's symbol), so an
|
|
246
|
+
// alias in one function says nothing about a same-named local in another.
|
|
247
|
+
// A name `env` is always one (a parameter or a destructured `{ env }`).
|
|
248
|
+
// The checker is built only for a file that aliases the environment.
|
|
249
|
+
const receivers = new Set();
|
|
250
|
+
let checker = null;
|
|
251
|
+
const symbolOf = (identifier) => {
|
|
252
|
+
checker ??= checkerOf(ts, tree);
|
|
253
|
+
// A shorthand `{ env }` names the variable it assigns, not a property.
|
|
254
|
+
return ts.isShorthandPropertyAssignment(identifier.parent) ? checker.getShorthandAssignmentValueSymbol(identifier.parent) : checker.getSymbolAtLocation(identifier);
|
|
255
|
+
};
|
|
256
|
+
const bind = (identifier) => {
|
|
257
|
+
const symbol = symbolOf(identifier);
|
|
258
|
+
if (symbol !== undefined)
|
|
259
|
+
receivers.add(symbol);
|
|
260
|
+
};
|
|
261
|
+
/**
|
|
262
|
+
* A local named `env` is taken as the environment (`function gate(env)`
|
|
263
|
+
* called with `process.env`, `import { env } from 'node:process'`) unless
|
|
264
|
+
* its declaration gives it a type that is not `ProcessEnv`: then it is an
|
|
265
|
+
* ordinary object (`check(env: { PUSH_SKIP: boolean })`). A local named
|
|
266
|
+
* `process` is Node's `process` on the same terms, its type `Process`;
|
|
267
|
+
* where nothing in the file declares it, it is the global itself.
|
|
268
|
+
*/
|
|
269
|
+
const resolving = new Set();
|
|
270
|
+
const namedIsGlobal = (identifier, type, holds) => {
|
|
271
|
+
const declaration = symbolOf(identifier)?.declarations?.at(0);
|
|
272
|
+
if (declaration === undefined || !(ts.isParameter(declaration) || ts.isVariableDeclaration(declaration)))
|
|
273
|
+
return true;
|
|
274
|
+
// Initialised (or defaulted) with the global, it is the global, whatever
|
|
275
|
+
// its annotation.
|
|
276
|
+
const init = declaration.initializer;
|
|
277
|
+
if (init !== undefined && !resolving.has(declaration)) {
|
|
278
|
+
resolving.add(declaration);
|
|
279
|
+
try {
|
|
280
|
+
if (holds(init))
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
resolving.delete(declaration);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (declaration.type !== undefined)
|
|
288
|
+
return type.test(declaration.type.getText(tree));
|
|
289
|
+
// An untyped variable is what it is initialised with (`const env =
|
|
290
|
+
// { PUSH_SKIP: false }` is an object); an untyped parameter is taken as
|
|
291
|
+
// the global, which callers pass under its own name.
|
|
292
|
+
return ts.isParameter(declaration);
|
|
293
|
+
};
|
|
294
|
+
const isEnv = (node) => {
|
|
295
|
+
const target = strip(node);
|
|
296
|
+
if (ts.isIdentifier(target)) {
|
|
297
|
+
// A binding already shown to hold the environment is one, whatever
|
|
298
|
+
// its name or annotation (`const env: Record<…> = process.env`).
|
|
299
|
+
if (receivers.size > 0) {
|
|
300
|
+
const symbol = symbolOf(target);
|
|
301
|
+
if (symbol !== undefined && receivers.has(symbol))
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
return target.text === 'env' && namedIsGlobal(target, /\bProcessEnv\b/, isEnv);
|
|
305
|
+
}
|
|
306
|
+
if ((ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) && accessed(target) === 'env') {
|
|
307
|
+
return isProcess(target.expression);
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
};
|
|
311
|
+
const declarations = [];
|
|
312
|
+
// `settings = process.env` after `let settings;` aliases it as surely as an
|
|
313
|
+
// initialiser does (`||=`, `??=` and `&&=` too).
|
|
314
|
+
const assignments = [];
|
|
315
|
+
// `({ PUSH_SKIP } = process.env)` reads as a destructuring declaration
|
|
316
|
+
// does, and `({ env: settings } = process)` aliases the environment.
|
|
317
|
+
const assigned = [];
|
|
318
|
+
const ASSIGNING = new Set([ts.SyntaxKind.EqualsToken, ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.QuestionQuestionEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken]);
|
|
319
|
+
/** Each `key: target` of an assignment pattern, shorthand included. */
|
|
320
|
+
const patternEntries = (pattern) => pattern.properties.flatMap((property) => {
|
|
321
|
+
if (ts.isShorthandPropertyAssignment(property))
|
|
322
|
+
return [{ key: property.name.text, target: property.name }];
|
|
323
|
+
if (ts.isPropertyAssignment(property))
|
|
324
|
+
return [{ key: literalKey(property.name), target: property.initializer }];
|
|
325
|
+
return [];
|
|
326
|
+
});
|
|
327
|
+
const PROCESS_MODULES = new Set(['process', 'node:process']);
|
|
328
|
+
/** `require('node:process')`. */
|
|
329
|
+
const requiresProcess = (node) => {
|
|
330
|
+
const target = strip(node);
|
|
331
|
+
if (!ts.isCallExpression(target) || !ts.isIdentifier(target.expression) || target.expression.text !== 'require')
|
|
332
|
+
return false;
|
|
333
|
+
const [only, ...more] = target.arguments;
|
|
334
|
+
return only !== undefined && more.length === 0 && ts.isStringLiteralLike(only) && PROCESS_MODULES.has(only.text);
|
|
335
|
+
};
|
|
336
|
+
// The process module under another name is `process`; its `env` export
|
|
337
|
+
// under another name is the environment.
|
|
338
|
+
const aliasProcessModule = (clause) => {
|
|
339
|
+
if (clause === undefined)
|
|
340
|
+
return;
|
|
341
|
+
const addProcess = (identifier) => {
|
|
342
|
+
const symbol = symbolOf(identifier);
|
|
343
|
+
if (symbol !== undefined)
|
|
344
|
+
processAliases.add(symbol);
|
|
345
|
+
};
|
|
346
|
+
if (clause.name !== undefined)
|
|
347
|
+
addProcess(clause.name);
|
|
348
|
+
const bindings = clause.namedBindings;
|
|
349
|
+
if (bindings === undefined)
|
|
350
|
+
return;
|
|
351
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
352
|
+
addProcess(bindings.name);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
for (const element of bindings.elements) {
|
|
356
|
+
const imported = (element.propertyName ?? element.name).text;
|
|
357
|
+
if (imported === 'env')
|
|
358
|
+
bind(element.name);
|
|
359
|
+
if (imported === 'default')
|
|
360
|
+
addProcess(element.name);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const visit = (node) => {
|
|
364
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier) && PROCESS_MODULES.has(node.moduleSpecifier.text)) {
|
|
365
|
+
aliasProcessModule(node.importClause);
|
|
366
|
+
}
|
|
367
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined && requiresProcess(node.initializer)) {
|
|
368
|
+
const symbol = symbolOf(node.name);
|
|
369
|
+
if (symbol !== undefined)
|
|
370
|
+
processAliases.add(symbol);
|
|
371
|
+
}
|
|
372
|
+
if (ts.isVariableDeclaration(node))
|
|
373
|
+
declarations.push(node);
|
|
374
|
+
// Any name `process` (a parameter, a variable, a function, a member):
|
|
375
|
+
// only then can a bare `process` be something other than Node's.
|
|
376
|
+
const { parent } = node;
|
|
377
|
+
if (ts.isIdentifier(node) && node.text === 'process' && 'name' in parent && parent.name === node)
|
|
378
|
+
declaresProcess = true;
|
|
379
|
+
if (ts.isBinaryExpression(node) && ASSIGNING.has(node.operatorToken.kind)) {
|
|
380
|
+
const left = strip(node.left);
|
|
381
|
+
if (ts.isIdentifier(left))
|
|
382
|
+
assignments.push({ name: left, value: node.right });
|
|
383
|
+
if (ts.isObjectLiteralExpression(left))
|
|
384
|
+
assigned.push({ pattern: left, value: node.right });
|
|
385
|
+
}
|
|
386
|
+
ts.forEachChild(node, visit);
|
|
387
|
+
};
|
|
388
|
+
visit(tree);
|
|
389
|
+
// An alias of an alias is still the environment: grow to a fixed point.
|
|
390
|
+
let size = -1;
|
|
391
|
+
while (size !== receivers.size) {
|
|
392
|
+
size = receivers.size;
|
|
393
|
+
for (const { name, value } of assignments) {
|
|
394
|
+
if (isEnv(value))
|
|
395
|
+
bind(name);
|
|
396
|
+
}
|
|
397
|
+
for (const { pattern, value } of assigned) {
|
|
398
|
+
if (!isProcess(value))
|
|
399
|
+
continue;
|
|
400
|
+
for (const { key, target } of patternEntries(pattern)) {
|
|
401
|
+
const bound = strip(target);
|
|
402
|
+
if (key === 'env' && ts.isIdentifier(bound))
|
|
403
|
+
bind(bound);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const declaration of declarations) {
|
|
407
|
+
const init = declaration.initializer;
|
|
408
|
+
if (init === undefined)
|
|
409
|
+
continue;
|
|
410
|
+
if (ts.isIdentifier(declaration.name) && isEnv(init))
|
|
411
|
+
bind(declaration.name);
|
|
412
|
+
if (ts.isObjectBindingPattern(declaration.name) && isProcess(init)) {
|
|
413
|
+
for (const element of declaration.name.elements) {
|
|
414
|
+
const key = literalKey(element.propertyName ?? element.name);
|
|
415
|
+
if (key === 'env' && ts.isIdentifier(element.name))
|
|
416
|
+
bind(element.name);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const out = [];
|
|
422
|
+
const found = (node, name) => report(out, file, lineOf(node), name);
|
|
423
|
+
/** `env.X = v` and `delete env.X` write the variable; they never read it. */
|
|
424
|
+
const writesOnly = (node) => {
|
|
425
|
+
let child = node;
|
|
426
|
+
let parent = node.parent;
|
|
427
|
+
while (ts.isParenthesizedExpression(parent) || ts.isNonNullExpression(parent)) {
|
|
428
|
+
child = parent;
|
|
429
|
+
parent = parent.parent;
|
|
430
|
+
}
|
|
431
|
+
return (ts.isBinaryExpression(parent) && parent.left === child && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) || ts.isDeleteExpression(parent);
|
|
432
|
+
};
|
|
433
|
+
const reads = (node) => {
|
|
434
|
+
if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && isEnv(node.expression) && !writesOnly(node)) {
|
|
435
|
+
found(node, accessed(node));
|
|
436
|
+
}
|
|
437
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.InKeyword && isEnv(node.right)) {
|
|
438
|
+
found(node, constantString(node.left));
|
|
439
|
+
}
|
|
440
|
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer !== undefined) {
|
|
441
|
+
const level = levelOf(node.initializer);
|
|
442
|
+
if (level !== null)
|
|
443
|
+
bindingReads(node.name, level);
|
|
444
|
+
}
|
|
445
|
+
ts.forEachChild(node, reads);
|
|
446
|
+
};
|
|
447
|
+
const levelOf = (node) => {
|
|
448
|
+
if (isEnv(node))
|
|
449
|
+
return 'env';
|
|
450
|
+
if (isProcess(node))
|
|
451
|
+
return 'process';
|
|
452
|
+
const target = strip(node);
|
|
453
|
+
return ts.isIdentifier(target) && ['globalThis', 'global'].includes(target.text) ? 'global' : null;
|
|
454
|
+
};
|
|
455
|
+
/** The level below: `globalThis.process`, then its `env`. */
|
|
456
|
+
const NEXT = { global: { key: 'process', level: 'process' }, process: { key: 'env', level: 'env' } };
|
|
457
|
+
/**
|
|
458
|
+
* Each key a binding pattern takes from the environment, through nested
|
|
459
|
+
* patterns: `const { env: { X_SKIP } } = process` reads X_SKIP as surely as
|
|
460
|
+
* `const { X_SKIP } = process.env` does.
|
|
461
|
+
*/
|
|
462
|
+
const bindingReads = (pattern, level) => {
|
|
463
|
+
for (const element of pattern.elements) {
|
|
464
|
+
const key = literalKey(element.propertyName ?? element.name);
|
|
465
|
+
if (level === 'env')
|
|
466
|
+
found(element, key);
|
|
467
|
+
else if (key === NEXT[level].key && ts.isObjectBindingPattern(element.name))
|
|
468
|
+
bindingReads(element.name, NEXT[level].level);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
/** The same through an assignment pattern: `({ env: { X_SKIP: x } } = process)`. */
|
|
472
|
+
const assignedReads = (pattern, level) => {
|
|
473
|
+
for (const { key, target } of patternEntries(pattern)) {
|
|
474
|
+
if (level === 'env') {
|
|
475
|
+
found(pattern, key);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
const inner = strip(target);
|
|
479
|
+
if (key === NEXT[level].key && ts.isObjectLiteralExpression(inner))
|
|
480
|
+
assignedReads(inner, NEXT[level].level);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
reads(tree);
|
|
484
|
+
for (const { pattern, value } of assigned) {
|
|
485
|
+
const level = levelOf(value);
|
|
486
|
+
if (level !== null)
|
|
487
|
+
assignedReads(pattern, level);
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
};
|
|
491
|
+
const ARITHMETIC_PARTS = [
|
|
492
|
+
// `(( X ))` and `$(( X ))`, up to the closing `))`.
|
|
493
|
+
/\(\(((?:[^)]|\)(?!\)))*)\)\)/g,
|
|
494
|
+
// `let X+1`, to the end of its command.
|
|
495
|
+
/\blet\s+([^;&|#]*)/g,
|
|
496
|
+
// An integer comparison's operands (`[[ X -eq 1 ]]`).
|
|
497
|
+
/(\b[A-Za-z_]\w*\s+)-(?:eq|ne|lt|le|gt|ge)\b/g,
|
|
498
|
+
/-(?:eq|ne|lt|le|gt|ge)(\s+[A-Za-z_]\w*)\b/g,
|
|
499
|
+
// An indexed array's subscript (`${list[X]}`).
|
|
500
|
+
/\$\{\w+\[([^\]]*)\]/g,
|
|
501
|
+
];
|
|
502
|
+
/** `printenv A B`, to the end of its command: each name is read. */
|
|
503
|
+
const PRINTENV = /\bprintenv\b([^;&|#)]*)/g;
|
|
504
|
+
/** A `[ … ]` or `[[ … ]]` test, and a `test …` command, where `-v NAME` is a read. */
|
|
505
|
+
const TESTS = [/\[\[?\s([^\]]*)\]/g, /\btest\s([^;&|#]*)/g];
|
|
506
|
+
/**
|
|
507
|
+
* Shell code with each bare switch name bash reads written as the read it
|
|
508
|
+
* is: bash evaluates `(( PUSH_SKIP ))`, `let`, `[[ X -eq 1 ]]` and array
|
|
509
|
+
* subscripts by reading the variable, no `$` needed, and `printenv X`,
|
|
510
|
+
* `[[ -v X ]]`, `[ -v X ]` and `test -v X` read whether the caller set it.
|
|
511
|
+
* Each part is a stretch of code and the names in it that are reads.
|
|
512
|
+
*/
|
|
513
|
+
const bareAsReads = (code, parts) => {
|
|
514
|
+
let out = code;
|
|
515
|
+
for (const [part, names] of parts) {
|
|
516
|
+
out = out.replace(part, (whole, inner) => whole.replace(inner, inner.replace(names, '$$$1')));
|
|
517
|
+
}
|
|
518
|
+
return out;
|
|
519
|
+
};
|
|
520
|
+
const OPENS = /(?:^\s*|[;&|(]\s*|\b(?:then|do|else)\s+)(?:if|case|while|until|for|select)\b/g;
|
|
521
|
+
const CLOSES = /(?:^|[;&|\s])(?:fi|esac|done)(?=\s*(?:$|[;&|)#]))/g;
|
|
522
|
+
/**
|
|
523
|
+
* How deep in shell control flow each line starts: 0 is code every run
|
|
524
|
+
* reaches. An `if`, `case` or loop body, and a function or `{ … }` group, is
|
|
525
|
+
* deeper: an assignment there may never run (`if false; then X_SKIP=0; fi`).
|
|
526
|
+
*/
|
|
527
|
+
const shellDepths = (lines) => {
|
|
528
|
+
let depth = 0;
|
|
529
|
+
return lines.map((line) => {
|
|
530
|
+
// A heredoc line is data: it opens and closes nothing.
|
|
531
|
+
if (line === null)
|
|
532
|
+
return depth;
|
|
533
|
+
// `shellLines` has already dropped every comment.
|
|
534
|
+
const code = line.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""');
|
|
535
|
+
const start = depth;
|
|
536
|
+
const opens = (code.match(OPENS) ?? []).length;
|
|
537
|
+
const closes = (code.match(CLOSES) ?? []).length;
|
|
538
|
+
const braceOpens = (code.match(/(?:^|\s|\))\{(?=\s|$)/g) ?? []).length;
|
|
539
|
+
const braceCloses = (code.match(/(?:^|[;\s])\}(?=\s*(?:$|[;&|)#]))/g) ?? []).length;
|
|
540
|
+
// A `( … )` subshell: its assignments never reach the parent shell.
|
|
541
|
+
// Indented or after `then`/`do`/`else` too, as OPENS reads, and as a
|
|
542
|
+
// function's body (`gate() (`), as a `{` group can be.
|
|
543
|
+
const subshellOpens = (code.match(/(?:^\s*|[;&|)]\s*|\b(?:then|do|else)\s+)\((?!\()/g) ?? []).length;
|
|
544
|
+
const subshellCloses = (code.match(/(?:^|[;\s])\)(?=\s*(?:$|[;&|)#]))/g) ?? []).length;
|
|
545
|
+
depth = Math.max(0, depth + opens + braceOpens + subshellOpens - closes - braceCloses - subshellCloses);
|
|
546
|
+
return start;
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
/**
|
|
550
|
+
* A value that is one shell word, so `X=v` sets a variable, not `X=v cmd`'s
|
|
551
|
+
* env. Unquoted operators are refused: `X=0&` or `X=0 | cat` assigns in a
|
|
552
|
+
* background or pipeline subshell and leaves the caller's value in place.
|
|
553
|
+
* A `$( … )` substitution and one trailing `;` are allowed.
|
|
554
|
+
*/
|
|
555
|
+
const ONE_WORD = /^(?:"(?:[^"\\]|\\.)*"|'[^']*'|\$\([^()]*\)|[^\s'"&|;<>()`])*\s*;?\s*(?:#.*)?$/;
|
|
556
|
+
/**
|
|
557
|
+
* The line a shell file first initialises `name` where every run reaches it
|
|
558
|
+
* (top level, outside any branch, loop, function or group), or null. A
|
|
559
|
+
* heredoc body is data, so `X_SKIP=0` printed by `cat <<'EOF'` is none.
|
|
560
|
+
*/
|
|
561
|
+
const initialisedAt = (lines, shell, depths, name) => {
|
|
562
|
+
const assignment = new RegExp(`^(?:export\\s+|readonly\\s+)?${name}=(.*)$`);
|
|
563
|
+
// The command on the line above, and the last non-blank command before this line.
|
|
564
|
+
let above = null;
|
|
565
|
+
let prior;
|
|
566
|
+
for (const [index, line] of shell.entries()) {
|
|
567
|
+
const lineAbove = above;
|
|
568
|
+
const priorCommand = prior;
|
|
569
|
+
above = line.command;
|
|
570
|
+
if (line.command !== null && line.command.trim() !== '')
|
|
571
|
+
prior = line.command;
|
|
572
|
+
// Heredoc data, and text inside a string an earlier line opened, assign
|
|
573
|
+
// nothing.
|
|
574
|
+
if (line.command === null || line.quoted)
|
|
575
|
+
continue;
|
|
576
|
+
const match = assignment.exec(lines[index] ?? '');
|
|
577
|
+
if (match === null)
|
|
578
|
+
continue;
|
|
579
|
+
// `false && \` or `false &&` on the line above makes this line the end of
|
|
580
|
+
// a conditional list: the assignment may never run.
|
|
581
|
+
// After `&&`, `||` or `|` bash reads on past blank and comment lines
|
|
582
|
+
// (`false &&`, a blank line, `X_SKIP=0`) and past a heredoc's lines
|
|
583
|
+
// (`cat <<EOF &&`, its body, `EOF`); a `\` joins only the next one.
|
|
584
|
+
if (lineAbove !== null && /\\\s*$/.test(lineAbove))
|
|
585
|
+
continue;
|
|
586
|
+
if (priorCommand !== undefined && /(?:&&|\|\|?)\s*$/.test(priorCommand))
|
|
587
|
+
continue;
|
|
588
|
+
const value = group(match, 1);
|
|
589
|
+
// `X_SKIP="${X_SKIP:-0}"` takes the caller's value: a read, not a decision.
|
|
590
|
+
if (new RegExp(`\\$\\{?${name}\\b`).test(value))
|
|
591
|
+
return null;
|
|
592
|
+
if (depths[index] !== 0 || !ONE_WORD.test(value))
|
|
593
|
+
continue;
|
|
594
|
+
return index;
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* A heredoc operator and its delimiter word, matched where `lastIndex` points.
|
|
600
|
+
* `<<<` is a here-string, not a heredoc: in `cat <<<'EOF'` no `<` opens one.
|
|
601
|
+
*/
|
|
602
|
+
const HEREDOC_WORD = /(?<!<)<<(?!<)(-?)[ \t]*((?:\$'(?:[^'\\]|\\.)*'|'[^']*'|"(?:[^"\\]|\\.)*"|\\.|[^\s;&|<>()'"\\])+)/y;
|
|
603
|
+
/**
|
|
604
|
+
* A double-quoted stretch up to its closing `"`, the first no `\` escapes;
|
|
605
|
+
* matched where `lastIndex` points.
|
|
606
|
+
*/
|
|
607
|
+
const CLOSING_DOUBLE_QUOTE = /(?:[^"\\]|\\[\s\S])*"/y;
|
|
608
|
+
/** A `$'…'` quote's text up to its closing `'`: it takes `\` escapes too. */
|
|
609
|
+
const CLOSING_ANSI_C_QUOTE = /(?:[^'\\]|\\[\s\S])*'/y;
|
|
610
|
+
/**
|
|
611
|
+
* The index in `line` of the quote `closing` reaches from `from` on, or -1
|
|
612
|
+
* when the line ends inside the quote.
|
|
613
|
+
*/
|
|
614
|
+
const closingAt = (line, from, closing) => {
|
|
615
|
+
closing.lastIndex = from;
|
|
616
|
+
return closing.exec(line) === null ? -1 : closing.lastIndex - 1;
|
|
617
|
+
};
|
|
618
|
+
/**
|
|
619
|
+
* The end of a double-quoted stretch starting at `from` (the index of its
|
|
620
|
+
* closing `"`, or -1 when the line ends inside it), and the stretch with
|
|
621
|
+
* each escaped character neutralised: `"\$X_SKIP"` is the text `$X_SKIP`.
|
|
622
|
+
*/
|
|
623
|
+
const doubleQuoted = (line, from) => {
|
|
624
|
+
const end = closingAt(line, from, CLOSING_DOUBLE_QUOTE);
|
|
625
|
+
return { end, text: line.slice(from, end === -1 ? line.length : end).replace(/\\[\s\S]/g, '\\_') };
|
|
626
|
+
};
|
|
627
|
+
/**
|
|
628
|
+
* One physical line with what bash does not expand dropped (single-quoted
|
|
629
|
+
* text, escaped characters and the comment), the heredocs it opens, and the
|
|
630
|
+
* quote still open at its end. `open` is the quote an earlier line left
|
|
631
|
+
* open: a string may span lines. `<<` inside quotes, a comment or `$(( … ))`
|
|
632
|
+
* arithmetic opens no heredoc. Double-quoted text is kept in its quotes, so
|
|
633
|
+
* `"$"'x'` is a `$` and a plain quote, not `$'x'`.
|
|
634
|
+
*/
|
|
635
|
+
const scanLine = (line, open) => {
|
|
636
|
+
let code = '';
|
|
637
|
+
const heredocs = [];
|
|
638
|
+
let index = 0;
|
|
639
|
+
if (open === "'" || open === "$'") {
|
|
640
|
+
const end = open === "$'" ? closingAt(line, 0, CLOSING_ANSI_C_QUOTE) : line.indexOf("'");
|
|
641
|
+
if (end === -1)
|
|
642
|
+
return { code, heredocs, open };
|
|
643
|
+
index = end + 1;
|
|
644
|
+
}
|
|
645
|
+
else if (open === '"') {
|
|
646
|
+
const stretch = doubleQuoted(line, 0);
|
|
647
|
+
code += `"${stretch.text}"`;
|
|
648
|
+
if (stretch.end === -1)
|
|
649
|
+
return { code, heredocs, open };
|
|
650
|
+
index = stretch.end + 1;
|
|
651
|
+
}
|
|
652
|
+
while (index < line.length) {
|
|
653
|
+
const char = line.charAt(index);
|
|
654
|
+
if (char === '\\') {
|
|
655
|
+
// An escaped character is literal; a trailing `\` continues the line.
|
|
656
|
+
code += index + 1 < line.length ? '\\_' : '\\';
|
|
657
|
+
index += 2;
|
|
658
|
+
}
|
|
659
|
+
else if (char === "'") {
|
|
660
|
+
// `$'…'` takes backslash escapes, so `\'` does not close it, on this
|
|
661
|
+
// line or the next. An unescaped `$` opens one: `\$'` is a plain quote.
|
|
662
|
+
const quote = code.endsWith('$') ? "$'" : "'";
|
|
663
|
+
const end = quote === "$'" ? closingAt(line, index + 1, CLOSING_ANSI_C_QUOTE) : line.indexOf("'", index + 1);
|
|
664
|
+
code += "''";
|
|
665
|
+
if (end === -1)
|
|
666
|
+
return { code, heredocs, open: quote };
|
|
667
|
+
index = end + 1;
|
|
668
|
+
}
|
|
669
|
+
else if (char === '"') {
|
|
670
|
+
const stretch = doubleQuoted(line, index + 1);
|
|
671
|
+
code += `"${stretch.text}"`;
|
|
672
|
+
if (stretch.end === -1)
|
|
673
|
+
return { code, heredocs, open: '"' };
|
|
674
|
+
index = stretch.end + 1;
|
|
675
|
+
}
|
|
676
|
+
else if (char === '#' && (index === 0 || /\s/.test(line.charAt(index - 1)))) {
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
HEREDOC_WORD.lastIndex = index;
|
|
681
|
+
const match = HEREDOC_WORD.exec(line);
|
|
682
|
+
const arithmetic = (code.match(/\(\(/g) ?? []).length > (code.match(/\)\)/g) ?? []).length;
|
|
683
|
+
if (match === null || arithmetic) {
|
|
684
|
+
code += char;
|
|
685
|
+
index += 1;
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
const word = group(match, 2);
|
|
689
|
+
heredocs.push({
|
|
690
|
+
// `<<$'EOF'` ends at `EOF`: the `$` is quoting, not a character.
|
|
691
|
+
delimiter: word.replace(/\$'((?:[^'\\]|\\.)*)'|'([^']*)'|"((?:[^"\\]|\\.)*)"|\\(.)/g,
|
|
692
|
+
// One alternative matched: when none of the first three did, the
|
|
693
|
+
// fourth, an escaped character, did.
|
|
694
|
+
(_all, ansi, single, double, escaped) => ansi?.replace(/\\(.)/g, '$1') ?? single ?? double ?? escaped),
|
|
695
|
+
quoted: /['"\\]/.test(word),
|
|
696
|
+
tabs: match[1] === '-',
|
|
697
|
+
});
|
|
698
|
+
// The operator and its word are redirection syntax: nothing to expand.
|
|
699
|
+
index += match[0].length;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return { code, heredocs, open: null };
|
|
703
|
+
};
|
|
704
|
+
/**
|
|
705
|
+
* Each shell line as bash reads it. Outside heredocs, the code with
|
|
706
|
+
* single-quoted text, escaped characters and comments dropped, following a
|
|
707
|
+
* quoted string across lines; a quoted heredoc's body expands nothing; an
|
|
708
|
+
* unquoted heredoc's body expands, since nothing in it is a quote or a
|
|
709
|
+
* comment (only `\$` stays literal). What is left is where `$X_SKIP` really
|
|
710
|
+
* is a read; `printf '%s' 'no $X_SKIP'`, `echo \$X_SKIP` and a comment
|
|
711
|
+
* explaining the old switch are not.
|
|
712
|
+
*/
|
|
713
|
+
const shellLines = (lines) => {
|
|
714
|
+
const pending = [];
|
|
715
|
+
let open = null;
|
|
716
|
+
return lines.map((line) => {
|
|
717
|
+
const heredoc = pending.at(0);
|
|
718
|
+
if (heredoc !== undefined) {
|
|
719
|
+
if ((heredoc.tabs ? line.replace(/^\t+/, '') : line) === heredoc.delimiter) {
|
|
720
|
+
pending.shift();
|
|
721
|
+
return { expands: null, command: null };
|
|
722
|
+
}
|
|
723
|
+
return { expands: heredoc.quoted ? null : line.replace(/\\[\\$`]/g, '\\_'), command: null };
|
|
724
|
+
}
|
|
725
|
+
const quoted = open !== null;
|
|
726
|
+
const scanned = scanLine(line, open);
|
|
727
|
+
open = scanned.open;
|
|
728
|
+
pending.push(...scanned.heredocs);
|
|
729
|
+
return { expands: scanned.code, command: scanned.code, quoted };
|
|
730
|
+
});
|
|
731
|
+
};
|
|
732
|
+
/**
|
|
733
|
+
* A workflow line up to its comment: a `#` at the start or after whitespace,
|
|
734
|
+
* outside quotes, as in YAML and in the shell of a `run:` block. A double
|
|
735
|
+
* quote takes `\` escapes; a quote left open runs to the end of the line.
|
|
736
|
+
* `# Removed: ${{ env.CI_SKIP }}` documents a switch; it reads nothing.
|
|
737
|
+
*/
|
|
738
|
+
const BEFORE_WORKFLOW_COMMENT = /^(?:"(?:[^"\\]|\\(?:[\s\S]|$))*(?:"|$)|'[^']*(?:'|$)|(?<=\S)#|[^"'#])*/;
|
|
739
|
+
const withoutComment = (line) => BEFORE_WORKFLOW_COMMENT.exec(line)?.[0] ?? line;
|
|
740
|
+
/**
|
|
741
|
+
* A Python comment, or a string literal not glued to a word before it: its
|
|
742
|
+
* prefix (`r`, `b`, `f`, …), its quote, its text and its closing quote, if
|
|
743
|
+
* any. A `'''` or `"""` string spans lines; a `'` or `"` one ends at a
|
|
744
|
+
* newline it does not escape.
|
|
745
|
+
*/
|
|
746
|
+
const PYTHON_TOKEN = /#[^\n]*|(?<!\w)([rRbBuUfF]{0,2}(?:'''(?:\\[\s\S]|(?!''')[^\\])*(?:'''|)|"""(?:\\[\s\S]|(?!""")[^\\])*(?:"""|)|'(?:\\[\s\S]|[^'\\\n])*'?|"(?:\\[\s\S]|[^"\\\n])*"?))/g;
|
|
747
|
+
/** `from os import a, b as c` or `from os import (…)`, in code with strings and comments emptied. */
|
|
748
|
+
const FROM_OS_IMPORT = /\bfrom\s+os\s+import\s+(\([^)]*\)|[^\n#;]*)/g;
|
|
749
|
+
/** An f-string: its braces hold code. */
|
|
750
|
+
const F_STRING = /^[rRbB]?[fF]/;
|
|
751
|
+
/**
|
|
752
|
+
* Where the replacement field opened before `from` closes: the index of its
|
|
753
|
+
* `}`, past quoted text and nested braces at any depth (`{d['}']}`,
|
|
754
|
+
* `{x:>{width}}`, `{a and {'b': {'c': 1}}}`), or -1 if it never closes.
|
|
755
|
+
*/
|
|
756
|
+
const fieldEnd = (text, from) => {
|
|
757
|
+
let depth = 1;
|
|
758
|
+
let quote = null;
|
|
759
|
+
// UTF-16 units, the indexing `slice` uses.
|
|
760
|
+
for (const [offset, char] of text.slice(from).split('').entries()) {
|
|
761
|
+
if (quote !== null) {
|
|
762
|
+
if (char === quote)
|
|
763
|
+
quote = null;
|
|
764
|
+
}
|
|
765
|
+
else if (char === "'" || char === '"') {
|
|
766
|
+
quote = char;
|
|
767
|
+
}
|
|
768
|
+
else if (char === '{') {
|
|
769
|
+
depth += 1;
|
|
770
|
+
}
|
|
771
|
+
else if (char === '}') {
|
|
772
|
+
depth -= 1;
|
|
773
|
+
if (depth === 0)
|
|
774
|
+
return from + offset;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return -1;
|
|
778
|
+
};
|
|
779
|
+
/** The reader a config describes: every pattern built from its switch words. */
|
|
780
|
+
export const readerFor = (config) => {
|
|
781
|
+
const SWITCH_NAME = switchName(config.words);
|
|
782
|
+
const IS_SWITCH = new RegExp(`^${SWITCH_NAME}$`);
|
|
783
|
+
const allowed = new Map(Object.entries(config.notAGate).map(([name, { files }]) => [name, new Set(files)]));
|
|
784
|
+
/** Is `name` an approved non-gate switch in `file`? */
|
|
785
|
+
const notAGate = (name, file) => allowed.get(name)?.has(file) ?? false;
|
|
786
|
+
const report = (out, file, line, name) => {
|
|
787
|
+
if (name === null || !IS_SWITCH.test(name) || notAGate(name, file))
|
|
788
|
+
return;
|
|
789
|
+
out.push({ file, line, name });
|
|
790
|
+
};
|
|
791
|
+
const READS = {
|
|
792
|
+
shell: [new RegExp(String.raw `\$\{?(${SWITCH_NAME})\b`, 'g')],
|
|
793
|
+
workflow: [
|
|
794
|
+
new RegExp(String.raw `\$\{?(${SWITCH_NAME})\b`, 'g'),
|
|
795
|
+
new RegExp(String.raw `\benv\.(${SWITCH_NAME})\b`, 'g'),
|
|
796
|
+
// Actions expressions index contexts too: `env['CI_SKIP']`.
|
|
797
|
+
new RegExp(String.raw `\benv\s*\[\s*(['"])(${SWITCH_NAME})\1\s*\]`, 'g'),
|
|
798
|
+
],
|
|
799
|
+
};
|
|
800
|
+
const BARE_SWITCH = new RegExp(String.raw `(?<![\w$\{])(${SWITCH_NAME})\b`, 'g');
|
|
801
|
+
const TESTED_SWITCH = new RegExp(String.raw `(?<=(?:^|\s)-v\s+)(${SWITCH_NAME})\b`, 'g');
|
|
802
|
+
const BARE_READS = [...ARITHMETIC_PARTS.map((part) => [part, BARE_SWITCH]), [PRINTENV, BARE_SWITCH], ...TESTS.map((part) => [part, TESTED_SWITCH])];
|
|
803
|
+
/** A literal that is exactly a switch name: the key of a read. */
|
|
804
|
+
const IS_SWITCH_LITERAL = new RegExp(String.raw `^[rRbBuUfF]{0,2}(['"])${SWITCH_NAME}\1$`);
|
|
805
|
+
/**
|
|
806
|
+
* An f-string's code: each `{…}` replacement field, read as Python, and the
|
|
807
|
+
* newlines of its text; an escaped `{{` and every other character of the
|
|
808
|
+
* text read nothing. A `}` outside a field is text whether doubled or not,
|
|
809
|
+
* and a `{` that never closes is text.
|
|
810
|
+
*/
|
|
811
|
+
const fStringCode = (literal) => {
|
|
812
|
+
let out = '';
|
|
813
|
+
let at = 0;
|
|
814
|
+
// The empty string past the end stops the scan.
|
|
815
|
+
while (literal.charAt(at) !== '') {
|
|
816
|
+
if (literal.slice(at, at + 2) === '{{') {
|
|
817
|
+
at += 2;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
const end = literal.charAt(at) === '{' ? fieldEnd(literal, at + 1) : -1;
|
|
821
|
+
if (end !== -1) {
|
|
822
|
+
out += `{${pythonCode(literal.slice(at + 1, end))}}`;
|
|
823
|
+
at = end + 1;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (literal.charAt(at) === '\n')
|
|
827
|
+
out += '\n';
|
|
828
|
+
at += 1;
|
|
829
|
+
}
|
|
830
|
+
return out;
|
|
831
|
+
};
|
|
832
|
+
/**
|
|
833
|
+
* Python source with comments dropped and every string literal emptied,
|
|
834
|
+
* except a literal that is exactly a switch name (the key of a read, as in
|
|
835
|
+
* `os.environ.get('X_SKIP')`), which loses its prefix, and an f-string,
|
|
836
|
+
* which keeps only the code in its braces, read the same way. So
|
|
837
|
+
* `print("use os.environ.get('X_SKIP')")`, `f"os.getenv('X_SKIP') {task}"`
|
|
838
|
+
* and `# os.environ['X_SKIP']` read nothing. An emptied literal keeps its
|
|
839
|
+
* newlines, so lines stay aligned.
|
|
840
|
+
*/
|
|
841
|
+
const pythonCode = (source) => source.replace(PYTHON_TOKEN, (token, literal) => {
|
|
842
|
+
// A comment keeps only its `#`, which reads nothing.
|
|
843
|
+
if (literal === undefined)
|
|
844
|
+
return token.slice(0, 1);
|
|
845
|
+
if (IS_SWITCH_LITERAL.test(literal))
|
|
846
|
+
return literal.replace(/^[rRbBuUfF]+/, '');
|
|
847
|
+
if (F_STRING.test(literal))
|
|
848
|
+
return fStringCode(literal);
|
|
849
|
+
return '\n'.repeat(literal.split('\n').length - 1);
|
|
850
|
+
});
|
|
851
|
+
/**
|
|
852
|
+
* The reads of a mapping that holds the caller's environment, `holder` (a
|
|
853
|
+
* regex source naming it): an index, `.get`, `.setdefault` or `.pop` (each
|
|
854
|
+
* returns the caller's value), and `'X' in` / `'X' not in` it, on the
|
|
855
|
+
* mapping itself, its `.copy()` or `dict(…)` of it.
|
|
856
|
+
*/
|
|
857
|
+
const mappingReads = (holder) => {
|
|
858
|
+
const mapping = String.raw `(?:${holder}(?:\.copy\(\s*\))?|\bdict\(\s*(?:[\w.]*\.)?${holder}\s*\))`;
|
|
859
|
+
return [
|
|
860
|
+
new RegExp(String.raw `${mapping}(?:\s*\[|\.(?:get|setdefault|pop)\s*\()\s*['"](${SWITCH_NAME})['"]`, 'g'),
|
|
861
|
+
new RegExp(String.raw `['"](${SWITCH_NAME})['"]\s+(?:not\s+)?in\s+(?:[\w.]*\.)?${mapping}`, 'g'),
|
|
862
|
+
];
|
|
863
|
+
};
|
|
864
|
+
/** The reads of a function that returns the caller's value, `holder` (a regex source naming it). */
|
|
865
|
+
const callReads = (holder) => [new RegExp(String.raw `${holder}\s*\(\s*['"](${SWITCH_NAME})['"]`, 'g')];
|
|
866
|
+
/**
|
|
867
|
+
* Under `os` by any name (`os.environ`, `o.environ`, a star import's
|
|
868
|
+
* `environ`): the environment, its bytes form, and getenv. A literal key
|
|
869
|
+
* keeps its `b` prefix out (`pythonCode` drops it).
|
|
870
|
+
*/
|
|
871
|
+
const PYTHON_READS = [...mappingReads(String.raw `\benvironb?\b`), ...callReads(String.raw `\bgetenvb?`)];
|
|
872
|
+
/** A name, in a module-level `name = …` of the environment, a copy of it, or getenv. */
|
|
873
|
+
const ASSIGNED_ENVIRON = /^[ \t]*(\w+)\s*=\s*(?:(?:[\w.]*\.)?environb?(?:\.copy\(\s*\))?|dict\(\s*(?:[\w.]*\.)?environb?\s*\))\s*(?:#.*)?$/gm;
|
|
874
|
+
const ASSIGNED_GETENV = /^[ \t]*(\w+)\s*=\s*(?:[\w.]*\.)?getenvb?\s*(?:#.*)?$/gm;
|
|
875
|
+
/** A local name that `environ`'s own patterns do not already cover. */
|
|
876
|
+
const local = (name) => (/^(?:environb?|getenvb?)$/.test(name) ? null : String.raw `(?<![\w.])${name}\b`);
|
|
877
|
+
/**
|
|
878
|
+
* The reads through other names for the environment or getenv: those
|
|
879
|
+
* `from os import environ as env` / `getenv as g` binds (bytes forms too),
|
|
880
|
+
* and a name assigned one (`env = os.environ`, `snap = os.environ.copy()`,
|
|
881
|
+
* `read = os.getenv`), read file-wide.
|
|
882
|
+
*/
|
|
883
|
+
const pythonAliasReads = (code) => {
|
|
884
|
+
const environ = [];
|
|
885
|
+
const getenv = [];
|
|
886
|
+
for (const imported of code.matchAll(FROM_OS_IMPORT)) {
|
|
887
|
+
for (const name of group(imported, 1).replace(/[()]/g, '').split(',')) {
|
|
888
|
+
const alias = /^\s*(environb?|getenvb?)\s+as\s+(\w+)\s*$/.exec(name);
|
|
889
|
+
if (alias !== null)
|
|
890
|
+
(group(alias, 1).startsWith('environ') ? environ : getenv).push(group(alias, 2));
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
for (const assigned of code.matchAll(ASSIGNED_ENVIRON))
|
|
894
|
+
environ.push(group(assigned, 1));
|
|
895
|
+
for (const assigned of code.matchAll(ASSIGNED_GETENV))
|
|
896
|
+
getenv.push(group(assigned, 1));
|
|
897
|
+
// A name imported and assigned too is read once.
|
|
898
|
+
const holders = (names) => [...new Set(names)].map(local).filter((h) => h !== null);
|
|
899
|
+
return [...holders(environ).flatMap(mappingReads), ...holders(getenv).flatMap(callReads)];
|
|
900
|
+
};
|
|
901
|
+
/**
|
|
902
|
+
* Python reads, matched over the whole file: a call formatted across lines
|
|
903
|
+
* (`os.environ.get(\n "X_SKIP"\n)`) is still a read. `os.environ["X"] =
|
|
904
|
+
* …` and `del os.environ["X"]` overwrite or drop the caller's value, so they
|
|
905
|
+
* are writes, not reads.
|
|
906
|
+
*/
|
|
907
|
+
const pythonSwitchReads = (file, source) => {
|
|
908
|
+
const code = pythonCode(source);
|
|
909
|
+
const out = [];
|
|
910
|
+
for (const pattern of [...PYTHON_READS, ...pythonAliasReads(code)]) {
|
|
911
|
+
pattern.lastIndex = 0;
|
|
912
|
+
for (const match of code.matchAll(pattern)) {
|
|
913
|
+
const start = match.index;
|
|
914
|
+
const after = code.slice(start + match[0].length);
|
|
915
|
+
const before = code.slice(0, start);
|
|
916
|
+
if (/^\s*\]\s*=(?!=)/.test(after) || /\bdel\s+(?:[\w.]+\.)?$/.test(before))
|
|
917
|
+
continue;
|
|
918
|
+
report(out, file, before.split('\n').length, group(match, 1));
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return out.sort((a, b) => a.line - b.line);
|
|
922
|
+
};
|
|
923
|
+
const findSwitchReads = (file, source, ts) => {
|
|
924
|
+
const kind = kindOf(file);
|
|
925
|
+
if (kind === null)
|
|
926
|
+
return [];
|
|
927
|
+
if (kind === 'script') {
|
|
928
|
+
if (ts === undefined)
|
|
929
|
+
throw new Error(`${file}: reading TypeScript or JavaScript needs the typescript package`);
|
|
930
|
+
return scriptSwitchReads(ts, file, source, report);
|
|
931
|
+
}
|
|
932
|
+
if (kind === 'python')
|
|
933
|
+
return pythonSwitchReads(file, source);
|
|
934
|
+
const lines = source.split('\n');
|
|
935
|
+
const patterns = READS[kind];
|
|
936
|
+
const out = [];
|
|
937
|
+
const shell = kind === 'shell' ? shellLines(lines) : null;
|
|
938
|
+
const expanding = shell === null ? lines.map(withoutComment) : shell.map((line) => line.expands);
|
|
939
|
+
let depths = null;
|
|
940
|
+
for (const [index, expands] of expanding.entries()) {
|
|
941
|
+
if (expands === null)
|
|
942
|
+
continue;
|
|
943
|
+
const code = bareAsReads(expands, BARE_READS);
|
|
944
|
+
for (const pattern of patterns) {
|
|
945
|
+
pattern.lastIndex = 0;
|
|
946
|
+
for (const match of code.matchAll(pattern)) {
|
|
947
|
+
// The name is each pattern's last group.
|
|
948
|
+
const name = group(match, match.length - 1);
|
|
949
|
+
if (shell !== null && !notAGate(name, file)) {
|
|
950
|
+
// Control-flow depth counts commands only: heredoc bodies are data.
|
|
951
|
+
depths ??= shellDepths(shell.map((item) => item.command));
|
|
952
|
+
const init = initialisedAt(lines, shell, depths, name);
|
|
953
|
+
if (init !== null && init < index)
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
report(out, file, index + 1, name);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return out;
|
|
961
|
+
};
|
|
962
|
+
return {
|
|
963
|
+
isGateCode: (file) => config.gatePaths.some((p) => p.test(file)) && !config.notGateCode.some((p) => p.test(file)) && kindOf(file) !== null,
|
|
964
|
+
findSwitchReads,
|
|
965
|
+
};
|
|
966
|
+
};
|
|
967
|
+
export const diskReads = (root) => ({
|
|
968
|
+
git: gitAt(root),
|
|
969
|
+
read: (file) => {
|
|
970
|
+
const fd = openSync(join(root, file), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
971
|
+
try {
|
|
972
|
+
return readFileSync(fd, 'utf8');
|
|
973
|
+
}
|
|
974
|
+
finally {
|
|
975
|
+
closeSync(fd);
|
|
976
|
+
}
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
/** A file the check was asked to read and could not: never a clean result. */
|
|
980
|
+
export class UnreadableGateFile extends Error {
|
|
981
|
+
}
|
|
982
|
+
/** One `--raw -z` entry: the new mode, then the path. */
|
|
983
|
+
const RAW_ENTRY = /:\d+ (\d+) \S+ \S+ [A-Z]\d*\0([^\0]*)\0/g;
|
|
984
|
+
/**
|
|
985
|
+
* The files a mode judges: every tracked file, or the ones the index adds,
|
|
986
|
+
* copies, modifies, renames or changes the type of (T: a symlink replaced by
|
|
987
|
+
* a file brings in its every line), each with its index mode.
|
|
988
|
+
*/
|
|
989
|
+
export const listFiles = (reads, mode) => mode === 'staged'
|
|
990
|
+
? [...must(reads.git, ['diff', ...PLAIN_DIFF, '--cached', '--raw', '-z', '--diff-filter=ACMRT']).matchAll(RAW_ENTRY)].map((m) => ({ file: group(m, 2), symlink: m[1] === '120000' }))
|
|
991
|
+
: must(reads.git, ['ls-files', '-z'])
|
|
992
|
+
.split('\0')
|
|
993
|
+
.filter(Boolean)
|
|
994
|
+
.map((file) => ({ file, symlink: false }));
|
|
995
|
+
/** Errors that mean the file is gone from the working tree (deleted, or a parent replaced by a file). */
|
|
996
|
+
const GONE = ['ENOENT', 'ENOTDIR'];
|
|
997
|
+
/**
|
|
998
|
+
* A gate file's text: the index's for staged, the working tree's for tree
|
|
999
|
+
* (undefined when deleted there). A symlink is refused, not followed: its
|
|
1000
|
+
* text is a path, and the file it names is not what the index holds.
|
|
1001
|
+
*/
|
|
1002
|
+
export const readGate = (reads, mode, { file, symlink }) => {
|
|
1003
|
+
if (mode === 'staged') {
|
|
1004
|
+
if (symlink)
|
|
1005
|
+
throw new UnreadableGateFile(`${file} is a symlink in the index; its target is unchecked`);
|
|
1006
|
+
// `:0:` names stage 0 explicitly, so a path such as `0:a.sh` is read as itself.
|
|
1007
|
+
const text = reads.git.run(['show', `:0:${file}`]);
|
|
1008
|
+
if (text === undefined)
|
|
1009
|
+
throw new UnreadableGateFile(`${file}: git show :0:${file} failed`);
|
|
1010
|
+
return text;
|
|
1011
|
+
}
|
|
1012
|
+
try {
|
|
1013
|
+
return reads.read(file);
|
|
1014
|
+
}
|
|
1015
|
+
catch (e) {
|
|
1016
|
+
const code = e.code ?? '';
|
|
1017
|
+
if (code === 'ELOOP')
|
|
1018
|
+
throw new UnreadableGateFile(`${file} is a symlink in the working tree; its target is unchecked`);
|
|
1019
|
+
// Only a file that is not there is skipped: one under an unreadable
|
|
1020
|
+
// folder (EACCES), or a folder in its place (EISDIR), is unchecked.
|
|
1021
|
+
if (GONE.includes(code))
|
|
1022
|
+
return undefined;
|
|
1023
|
+
throw new UnreadableGateFile(`${file}: ${e.message}`);
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
const P = 'pariksha env-gate-switches:';
|
|
1027
|
+
/** `*_SKIP / *_PROBE`, for the words a config names. */
|
|
1028
|
+
const wordsShown = (words) => words.map((w) => `*_${w}`).join(' / ');
|
|
1029
|
+
export const formatReads = (violations, words, files, configPath) => {
|
|
1030
|
+
if (violations.length === 0)
|
|
1031
|
+
return { ok: true, out: [`${P} no gate reads a ${wordsShown(words)} environment switch (${files} gate file(s) read)`], err: [] };
|
|
1032
|
+
return {
|
|
1033
|
+
ok: false,
|
|
1034
|
+
out: [],
|
|
1035
|
+
err: [
|
|
1036
|
+
`${P} gate code reads an environment switch anyone can set to turn it off:`,
|
|
1037
|
+
...violations.map(({ file, line, name }) => ` ${file}:${line} reads ${name}`),
|
|
1038
|
+
`${P} ${violations.length} read(s). Decide inside the gate (a flag the gate owns, a file it checks), or, if the switch turns off no check at all, add it to notAGate in ${configPath} with the reason.`,
|
|
1039
|
+
],
|
|
1040
|
+
};
|
|
1041
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veda-hq/pariksha",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, coverage-by-type, coverage-exemptions, doc-budgets, export-deletions.",
|
|
3
|
+
"version": "0.5.3",
|
|
4
|
+
"description": "Repository checks: doc drift, rule enforcement, test-quality ratchets. Checks: authority-citations, boundary, coverage-by-type, coverage-exemptions, doc-budgets, env-gate-switches, export-deletions.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"repository checks",
|
|
7
7
|
"lint",
|