@appthreat/atom-parsetools 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -1
- package/phpastgen.js +1764 -18
- package/plugins/autoload.php +1 -1
- package/plugins/composer/autoload_classmap.php +1 -0
- package/plugins/composer/autoload_real.php +4 -4
- package/plugins/composer/autoload_static.php +5 -4
- package/plugins/composer/installed.json +7 -8
- package/plugins/composer/installed.php +9 -9
- package/plugins/composer.json +1 -1
- package/plugins/composer.lock +7 -8
- package/plugins/nikic/php-parser/composer.json +1 -2
- package/plugins/nikic/php-parser/lib/PhpParser/ConstExprEvaluator.php +1 -3
- package/plugins/nikic/php-parser/lib/PhpParser/Lexer/Emulative.php +2 -0
- package/plugins/nikic/php-parser/lib/PhpParser/Lexer/TokenEmulator/FnTokenEmulator.php +20 -0
- package/plugins/nikic/php-parser/lib/PhpParser/Lexer/TokenEmulator/KeywordEmulator.php +5 -4
- package/plugins/nikic/php-parser/lib/PhpParser/ParserAbstract.php +7 -0
- package/plugins/nikic/php-parser/lib/PhpParser/PrettyPrinterAbstract.php +20 -4
package/phpastgen.js
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
openSync,
|
|
7
|
+
closeSync,
|
|
8
|
+
readSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
statSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
realpathSync
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { basename, dirname, extname, join, relative } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
22
|
import { fileURLToPath } from "node:url";
|
|
7
23
|
import { detectPhp } from "@appthreat/atom-common";
|
|
8
24
|
|
|
@@ -21,28 +37,1758 @@ export const PARENT_NODE_PLUGINS_HOME = join(
|
|
|
21
37
|
"atom-parsetools",
|
|
22
38
|
"plugins"
|
|
23
39
|
);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generator version string. This is the single source of truth for the phpastgen wrapper
|
|
43
|
+
* version. `--version` prints exactly this string, and `--parser-info` reports the same string
|
|
44
|
+
* on its "Generator version:" line (design §2.1, Requirement 1.6).
|
|
45
|
+
*/
|
|
46
|
+
export const GENERATOR_VERSION = "2.0.0";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default output directory for batch mode (mirrors ruby_ast_gen's `.ast`).
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_OUTPUT = ".ast";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default exclusion regex applied to the path relative to the input.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_EXCLUDE = "^(tests?|vendor|Tests?)";
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default worker-pool size for batch runs (bounded concurrent `php-parse` subprocesses).
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_THREADS = 10;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Bounds for `--threads`: values outside this inclusive range fall back to DEFAULT_THREADS.
|
|
65
|
+
*/
|
|
66
|
+
export const MIN_THREADS = 1;
|
|
67
|
+
export const MAX_THREADS = 64;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Default depth cap before truncation.
|
|
71
|
+
*/
|
|
72
|
+
export const DEFAULT_MAX_DEPTH = 250;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Bounds for `--max-depth` (inclusive).
|
|
76
|
+
*/
|
|
77
|
+
export const MIN_MAX_DEPTH = 1;
|
|
78
|
+
export const MAX_MAX_DEPTH = 10000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Directory names whose subtrees are skipped wholesale during discovery (design §2.2). Matched as
|
|
82
|
+
* exact path components, never followed even if reachable via a symlink.
|
|
83
|
+
*/
|
|
84
|
+
export const VENDOR_DIRS = new Set([
|
|
85
|
+
".git",
|
|
86
|
+
".svn",
|
|
87
|
+
".hg",
|
|
88
|
+
"vendor",
|
|
89
|
+
"node_modules",
|
|
90
|
+
".idea",
|
|
91
|
+
".vscode"
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* File extensions recognized as PHP by name alone (design §2.2). Compared lower-cased and
|
|
96
|
+
* including the leading dot.
|
|
97
|
+
*/
|
|
98
|
+
export const PHP_EXTENSIONS = new Set([
|
|
99
|
+
".php",
|
|
100
|
+
".phtml",
|
|
101
|
+
".php3",
|
|
102
|
+
".php4",
|
|
103
|
+
".php5",
|
|
104
|
+
".phps",
|
|
105
|
+
".inc"
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Supported PHP grammar target versions (8.0 through 8.5 inclusive). A `--target-version` outside
|
|
110
|
+
* this set is rejected (Requirement 1.7).
|
|
111
|
+
*/
|
|
112
|
+
export const SUPPORTED_TARGET_VERSIONS = [
|
|
113
|
+
"8.0",
|
|
114
|
+
"8.1",
|
|
115
|
+
"8.2",
|
|
116
|
+
"8.3",
|
|
117
|
+
"8.4",
|
|
118
|
+
"8.5"
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve the vendored `php-parse` binary path, preferring an explicit PHP_PARSER_BIN override,
|
|
123
|
+
* then the bundled plugins, then a parent node_modules install.
|
|
124
|
+
*
|
|
125
|
+
* @returns {string} path to the php-parse binary
|
|
126
|
+
*/
|
|
127
|
+
export function resolvePhpParseBin() {
|
|
128
|
+
let bin = process.env.PHP_PARSER_BIN || join(PLUGINS_HOME, "bin", "php-parse");
|
|
129
|
+
if (
|
|
130
|
+
!existsSync(bin) &&
|
|
131
|
+
existsSync(join(PARENT_NODE_PLUGINS_HOME, "bin", "php-parse"))
|
|
132
|
+
) {
|
|
133
|
+
bin = join(PARENT_NODE_PLUGINS_HOME, "bin", "php-parse");
|
|
134
|
+
}
|
|
135
|
+
return bin;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Read the vendored nikic/php-parser version from `plugins/composer/installed.php` so provenance
|
|
140
|
+
* is not hard-coded (design §Milestone A task 1). Falls back to reading the package composer.json
|
|
141
|
+
* and finally to "unknown" if neither is readable.
|
|
142
|
+
*
|
|
143
|
+
* @returns {string} the vendored parser version (e.g. "5.8.0") or "unknown"
|
|
144
|
+
*/
|
|
145
|
+
export function vendoredParserVersion() {
|
|
146
|
+
for (const home of [PLUGINS_HOME, PARENT_NODE_PLUGINS_HOME]) {
|
|
147
|
+
const installed = join(home, "composer", "installed.php");
|
|
148
|
+
if (existsSync(installed)) {
|
|
149
|
+
try {
|
|
150
|
+
const text = readFileSync(installed, "utf-8");
|
|
151
|
+
// Locate the nikic/php-parser entry, then its pretty_version.
|
|
152
|
+
const idx = text.indexOf("nikic/php-parser");
|
|
153
|
+
if (idx !== -1) {
|
|
154
|
+
const slice = text.slice(idx);
|
|
155
|
+
const match = slice.match(
|
|
156
|
+
/'pretty_version'\s*=>\s*'v?([^']+)'/
|
|
157
|
+
);
|
|
158
|
+
if (match) {
|
|
159
|
+
return match[1];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// fall through to next candidate
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return "unknown";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `timeout` must be a number: spawnSync throws ERR_INVALID_ARG_TYPE on the raw string an
|
|
172
|
+
* environment variable gives us. Unset or unparseable means no timeout.
|
|
173
|
+
*
|
|
174
|
+
* @returns {number | undefined}
|
|
175
|
+
*/
|
|
176
|
+
export function spawnTimeout() {
|
|
177
|
+
const timeout = Number.parseInt(
|
|
178
|
+
process.env.ATOM_TIMEOUT || process.env.ASTGEN_TIMEOUT,
|
|
179
|
+
10
|
|
180
|
+
);
|
|
181
|
+
return Number.isNaN(timeout) ? undefined : timeout;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Parse the phpastgen CLI arguments into an options object. Mirrors the ruby_ast_gen surface.
|
|
186
|
+
*
|
|
187
|
+
* Validation performed here (design §2.1, Requirements 1.7, 2.11):
|
|
188
|
+
* - `--threads` outside [1, 64] warns and falls back to the default of 10.
|
|
189
|
+
* - `--max-depth` outside [1, 10000] warns and falls back to the default of 250.
|
|
190
|
+
* - `--target-version` (alias `--parser-target`) outside 8.0–8.5 is flagged as invalid so the
|
|
191
|
+
* caller can reject the invocation, emit no AST, and exit non-zero.
|
|
192
|
+
*
|
|
193
|
+
* @param {string[]} argv arguments (already sliced past node + script)
|
|
194
|
+
* @returns {{
|
|
195
|
+
* input: (string|undefined),
|
|
196
|
+
* output: string,
|
|
197
|
+
* exclude: string,
|
|
198
|
+
* log: string,
|
|
199
|
+
* debug: boolean,
|
|
200
|
+
* targetVersion: (string|undefined),
|
|
201
|
+
* invalidTargetVersion: (string|undefined),
|
|
202
|
+
* maxDepth: number,
|
|
203
|
+
* threads: number,
|
|
204
|
+
* failOnError: boolean,
|
|
205
|
+
* parserInfo: boolean,
|
|
206
|
+
* showVersion: boolean,
|
|
207
|
+
* help: boolean,
|
|
208
|
+
* rest: string[]
|
|
209
|
+
* }}
|
|
210
|
+
*/
|
|
211
|
+
export function parseArgs(argv) {
|
|
212
|
+
const opts = {
|
|
213
|
+
input: undefined,
|
|
214
|
+
output: DEFAULT_OUTPUT,
|
|
215
|
+
exclude: DEFAULT_EXCLUDE,
|
|
216
|
+
log: "info",
|
|
217
|
+
debug: false,
|
|
218
|
+
targetVersion: undefined,
|
|
219
|
+
invalidTargetVersion: undefined,
|
|
220
|
+
maxDepth: DEFAULT_MAX_DEPTH,
|
|
221
|
+
threads: DEFAULT_THREADS,
|
|
222
|
+
failOnError: false,
|
|
223
|
+
parserInfo: false,
|
|
224
|
+
showVersion: false,
|
|
225
|
+
help: false,
|
|
226
|
+
rest: []
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const next = (i) => {
|
|
230
|
+
if (i + 1 >= argv.length) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
return argv[i + 1];
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < argv.length; i++) {
|
|
237
|
+
const arg = argv[i];
|
|
238
|
+
switch (arg) {
|
|
239
|
+
case "-i":
|
|
240
|
+
case "--input":
|
|
241
|
+
opts.input = next(i);
|
|
242
|
+
i++;
|
|
243
|
+
break;
|
|
244
|
+
case "-o":
|
|
245
|
+
case "--output":
|
|
246
|
+
opts.output = next(i) ?? DEFAULT_OUTPUT;
|
|
247
|
+
i++;
|
|
248
|
+
break;
|
|
249
|
+
case "-e":
|
|
250
|
+
case "--exclude":
|
|
251
|
+
opts.exclude = next(i) ?? DEFAULT_EXCLUDE;
|
|
252
|
+
i++;
|
|
253
|
+
break;
|
|
254
|
+
case "-l":
|
|
255
|
+
case "--log":
|
|
256
|
+
opts.log = next(i) ?? "info";
|
|
257
|
+
i++;
|
|
258
|
+
break;
|
|
259
|
+
case "-d":
|
|
260
|
+
case "--debug":
|
|
261
|
+
opts.debug = true;
|
|
262
|
+
opts.log = "debug";
|
|
263
|
+
break;
|
|
264
|
+
case "--target-version":
|
|
265
|
+
case "--parser-target": {
|
|
266
|
+
const value = next(i);
|
|
267
|
+
i++;
|
|
268
|
+
if (value !== undefined && SUPPORTED_TARGET_VERSIONS.includes(value)) {
|
|
269
|
+
opts.targetVersion = value;
|
|
270
|
+
} else {
|
|
271
|
+
// Record the offending value so main() can reject the invocation.
|
|
272
|
+
opts.invalidTargetVersion = value ?? "";
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
case "--max-depth": {
|
|
277
|
+
const value = Number.parseInt(next(i), 10);
|
|
278
|
+
i++;
|
|
279
|
+
if (
|
|
280
|
+
Number.isNaN(value) ||
|
|
281
|
+
value < MIN_MAX_DEPTH ||
|
|
282
|
+
value > MAX_MAX_DEPTH
|
|
283
|
+
) {
|
|
284
|
+
console.warn(
|
|
285
|
+
`Ignoring out-of-range --max-depth value; falling back to ${DEFAULT_MAX_DEPTH} (allowed ${MIN_MAX_DEPTH}-${MAX_MAX_DEPTH}).`
|
|
286
|
+
);
|
|
287
|
+
opts.maxDepth = DEFAULT_MAX_DEPTH;
|
|
288
|
+
} else {
|
|
289
|
+
opts.maxDepth = value;
|
|
290
|
+
}
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
case "--threads": {
|
|
294
|
+
const value = Number.parseInt(next(i), 10);
|
|
295
|
+
i++;
|
|
296
|
+
if (
|
|
297
|
+
Number.isNaN(value) ||
|
|
298
|
+
value < MIN_THREADS ||
|
|
299
|
+
value > MAX_THREADS
|
|
300
|
+
) {
|
|
301
|
+
console.warn(
|
|
302
|
+
`Ignoring out-of-range --threads value; falling back to ${DEFAULT_THREADS} (allowed ${MIN_THREADS}-${MAX_THREADS}).`
|
|
303
|
+
);
|
|
304
|
+
opts.threads = DEFAULT_THREADS;
|
|
305
|
+
} else {
|
|
306
|
+
opts.threads = value;
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "--fail-on-error":
|
|
311
|
+
opts.failOnError = true;
|
|
312
|
+
break;
|
|
313
|
+
case "--parser-info":
|
|
314
|
+
opts.parserInfo = true;
|
|
315
|
+
break;
|
|
316
|
+
case "--version":
|
|
317
|
+
opts.showVersion = true;
|
|
318
|
+
break;
|
|
319
|
+
case "-h":
|
|
320
|
+
case "--help":
|
|
321
|
+
opts.help = true;
|
|
322
|
+
break;
|
|
323
|
+
default:
|
|
324
|
+
opts.rest.push(arg);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return opts;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Detected PHP runtime version, or undefined when PHP is not on the PATH.
|
|
333
|
+
*
|
|
334
|
+
* @returns {string | undefined}
|
|
335
|
+
*/
|
|
336
|
+
export function detectedPhpVersion() {
|
|
337
|
+
const result = spawnSync(
|
|
338
|
+
process.env.PHP_CMD || "php",
|
|
339
|
+
["-r", "echo PHP_VERSION;"],
|
|
340
|
+
{ encoding: "utf-8", timeout: spawnTimeout() }
|
|
341
|
+
);
|
|
342
|
+
if (result.status === 0 && result.stdout) {
|
|
343
|
+
return result.stdout.trim();
|
|
344
|
+
}
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Print usage text. Owned/expanded by the CLI reporting task; kept minimal here so dispatch works.
|
|
350
|
+
*/
|
|
351
|
+
export function printUsage() {
|
|
352
|
+
console.log(
|
|
353
|
+
[
|
|
354
|
+
"Usage: phpastgen [options] [-- <legacy php-parse args>]",
|
|
355
|
+
"",
|
|
356
|
+
"Options:",
|
|
357
|
+
" -i, --input <path> input file or directory (batch mode)",
|
|
358
|
+
` -o, --output <dir> output directory (default: '${DEFAULT_OUTPUT}')`,
|
|
359
|
+
` -e, --exclude <regex> exclusion regex (default: '${DEFAULT_EXCLUDE}')`,
|
|
360
|
+
" -l, --log <level> debug | info | warn | error (default: info)",
|
|
361
|
+
" -d, --debug same as --log debug",
|
|
362
|
+
" --target-version <x.y> pin PHP grammar (alias: --parser-target)",
|
|
363
|
+
` --max-depth <n> depth cap before truncation (default: ${DEFAULT_MAX_DEPTH})`,
|
|
364
|
+
` --threads <n> worker processes for directory runs (default: ${DEFAULT_THREADS})`,
|
|
365
|
+
" --fail-on-error exit non-zero if any file failed",
|
|
366
|
+
" --parser-info print parser/runtime capability report and exit 0",
|
|
367
|
+
" --version print generator version and exit 0",
|
|
368
|
+
" --help print usage"
|
|
369
|
+
].join("\n")
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The newest grammar the vendored parser supports. Used as the default Target_Version when none is
|
|
375
|
+
* provided (Requirement 1.4) and surfaced in the capability report.
|
|
376
|
+
*
|
|
377
|
+
* @returns {string} the newest supported target grammar (e.g. "8.5")
|
|
378
|
+
*/
|
|
379
|
+
export function newestTargetVersion() {
|
|
380
|
+
return SUPPORTED_TARGET_VERSIONS[SUPPORTED_TARGET_VERSIONS.length - 1];
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Print the parser/runtime capability report (design §2.5, Requirements 1.2, 1.4, 1.5, 1.8).
|
|
385
|
+
*
|
|
386
|
+
* The report contains exactly:
|
|
387
|
+
* - `Parser backend:` — the vendored nikic/php-parser and its version.
|
|
388
|
+
* - `PHP version:` — the detected PHP runtime version.
|
|
389
|
+
* - `Generator version:` — the generator version string (identical to `--version`).
|
|
390
|
+
* - `Supported target versions:` — the target grammars covering 8.0 through 8.5 inclusive, with
|
|
391
|
+
* the default (newest) grammar marked.
|
|
392
|
+
* - `Token emulation:` — indicates that the vendored parser can parse a newer requested target
|
|
393
|
+
* grammar even on an older PHP runtime, so the runtime need not be upgraded (Requirement 1.2).
|
|
394
|
+
*
|
|
395
|
+
* Consumers (the chen capability probe and the atom version gate) parse the `Generator version:`
|
|
396
|
+
* and `Parser backend:` lines, mirroring how the Ruby regression test parses `Parser backend:`.
|
|
397
|
+
*
|
|
398
|
+
* When no PHP runtime is on the PATH, print a report indicating that PHP is not installed and
|
|
399
|
+
* return a non-zero exit code (Requirement 1.8).
|
|
400
|
+
*
|
|
401
|
+
* @returns {number} process exit code (0 on success, non-zero when PHP is not installed)
|
|
402
|
+
*/
|
|
403
|
+
export function printParserInfo() {
|
|
404
|
+
const phpVersion = detectedPhpVersion();
|
|
405
|
+
const backend = `nikic/php-parser@${vendoredParserVersion()}`;
|
|
406
|
+
if (!phpVersion) {
|
|
407
|
+
// Requirement 1.8: no PHP runtime on the PATH. Still surface the backend + generator + grammar
|
|
408
|
+
// provenance (which do not depend on a runtime) so downstream gates get useful context, then
|
|
409
|
+
// report the missing runtime and exit non-zero.
|
|
410
|
+
console.log(`Parser backend: ${backend}`);
|
|
411
|
+
console.log("PHP version: PHP is not installed");
|
|
412
|
+
console.log(`Generator version: ${GENERATOR_VERSION}`);
|
|
413
|
+
console.log(
|
|
414
|
+
`Supported target versions: ${SUPPORTED_TARGET_VERSIONS.join(", ")}`
|
|
415
|
+
);
|
|
416
|
+
console.log(
|
|
417
|
+
`Token emulation: enabled (parse target grammars up to ${newestTargetVersion()} without a matching PHP runtime)`
|
|
418
|
+
);
|
|
419
|
+
console.log("PHP is not installed");
|
|
420
|
+
return 1;
|
|
421
|
+
}
|
|
422
|
+
const grammars = SUPPORTED_TARGET_VERSIONS.map((v) =>
|
|
423
|
+
v === newestTargetVersion() ? `${v} (default)` : v
|
|
424
|
+
).join(", ");
|
|
425
|
+
console.log(`Parser backend: ${backend}`);
|
|
426
|
+
console.log(`PHP version: ${phpVersion}`);
|
|
427
|
+
console.log(`Generator version: ${GENERATOR_VERSION}`);
|
|
428
|
+
console.log(`Supported target versions: ${grammars}`);
|
|
429
|
+
// Requirement 1.2: token emulation lets an older PHP runtime parse a newer requested grammar.
|
|
430
|
+
console.log(
|
|
431
|
+
`Token emulation: enabled (parse target grammars up to ${newestTargetVersion()} without a matching PHP runtime)`
|
|
432
|
+
);
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Cheap best-effort sniff for a PHP open tag (`<?php` or `<?=`) in the leading bytes of a file.
|
|
438
|
+
* Only the first chunk is read so the check stays inexpensive on large files.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} filePath
|
|
441
|
+
* @returns {boolean} true when an open tag is found near the start of the file
|
|
442
|
+
*/
|
|
443
|
+
export function hasPhpOpenTag(filePath) {
|
|
444
|
+
let fd;
|
|
445
|
+
try {
|
|
446
|
+
fd = openSync(filePath, "r");
|
|
447
|
+
const buffer = Buffer.alloc(512);
|
|
448
|
+
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
|
449
|
+
if (bytesRead <= 0) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
const head = buffer.toString("utf-8", 0, bytesRead);
|
|
453
|
+
return head.includes("<?php") || head.includes("<?=");
|
|
454
|
+
} catch {
|
|
35
455
|
return false;
|
|
456
|
+
} finally {
|
|
457
|
+
if (fd !== undefined) {
|
|
458
|
+
try {
|
|
459
|
+
closeSync(fd);
|
|
460
|
+
} catch {
|
|
461
|
+
// ignore close failures
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Decide whether a file is recognized as PHP (design §2.2): any of the known PHP extensions, or an
|
|
469
|
+
* extensionless file whose leading bytes contain a `<?php`/`<?=` open tag.
|
|
470
|
+
*
|
|
471
|
+
* @param {string} filePath
|
|
472
|
+
* @returns {boolean}
|
|
473
|
+
*/
|
|
474
|
+
export function isRecognizedPhp(filePath) {
|
|
475
|
+
const ext = extname(filePath).toLowerCase();
|
|
476
|
+
if (ext !== "") {
|
|
477
|
+
return PHP_EXTENSIONS.has(ext);
|
|
478
|
+
}
|
|
479
|
+
// Extensionless: fall back to a cheap open-tag sniff.
|
|
480
|
+
return hasPhpOpenTag(filePath);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Discover PHP files under the input path (design §2.2).
|
|
485
|
+
*
|
|
486
|
+
* For a single-file input the exclusion regex is matched against the file's basename, so a
|
|
487
|
+
* parent-directory name can never silently drop the file. For a directory input the tree is
|
|
488
|
+
* walked, `VENDOR_DIRS` subtrees are skipped wholesale, symlinked directories are not followed,
|
|
489
|
+
* and the exclusion regex is matched against each entry's path relative to the input.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} inputPath file or directory to scan
|
|
492
|
+
* @param {RegExp} excludeRegex regex matched against the path relative to the input
|
|
493
|
+
* @returns {{ included: string[], excludedCount: number, skippedNonPhpCount: number }}
|
|
494
|
+
*/
|
|
495
|
+
export function discoverFiles(inputPath, excludeRegex) {
|
|
496
|
+
// Use lstat so a symlinked file/dir is classified by the link itself, not its target.
|
|
497
|
+
let rootStat;
|
|
498
|
+
try {
|
|
499
|
+
rootStat = lstatSync(inputPath);
|
|
500
|
+
} catch {
|
|
501
|
+
return { included: [], excludedCount: 0, skippedNonPhpCount: 0 };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Single-file input: match exclusion against the basename.
|
|
505
|
+
if (rootStat.isFile()) {
|
|
506
|
+
const rel = basename(inputPath);
|
|
507
|
+
if (excludeRegex.test(rel)) {
|
|
508
|
+
return { included: [], excludedCount: 1, skippedNonPhpCount: 0 };
|
|
509
|
+
}
|
|
510
|
+
if (isRecognizedPhp(inputPath)) {
|
|
511
|
+
return { included: [inputPath], excludedCount: 0, skippedNonPhpCount: 0 };
|
|
512
|
+
}
|
|
513
|
+
return { included: [], excludedCount: 0, skippedNonPhpCount: 1 };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Anything that is neither a regular file nor a directory (e.g. a symlink to a file, socket) is
|
|
517
|
+
// treated by walking only when it is a directory; otherwise nothing to discover.
|
|
518
|
+
if (!rootStat.isDirectory()) {
|
|
519
|
+
return { included: [], excludedCount: 0, skippedNonPhpCount: 0 };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const included = [];
|
|
523
|
+
let excludedCount = 0;
|
|
524
|
+
let skippedNonPhpCount = 0;
|
|
525
|
+
|
|
526
|
+
// Iterative walk to avoid deep recursion on large trees; do not follow symlinked directories.
|
|
527
|
+
const stack = [inputPath];
|
|
528
|
+
while (stack.length > 0) {
|
|
529
|
+
const dir = stack.pop();
|
|
530
|
+
let entries;
|
|
531
|
+
try {
|
|
532
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
533
|
+
} catch {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
for (const entry of entries) {
|
|
537
|
+
const fullPath = join(dir, entry.name);
|
|
538
|
+
|
|
539
|
+
// Determine the entry kind without following symlinks. Dirent flags from withFileTypes do
|
|
540
|
+
// not dereference symlinks, which is exactly what we want.
|
|
541
|
+
if (entry.isSymbolicLink()) {
|
|
542
|
+
// Never follow symlinked directories; classify a symlink by its own target only if it
|
|
543
|
+
// resolves to a regular file, matching the "do not follow symlinked dirs" rule.
|
|
544
|
+
let targetStat;
|
|
545
|
+
try {
|
|
546
|
+
targetStat = statSync(fullPath);
|
|
547
|
+
} catch {
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (targetStat.isDirectory()) {
|
|
551
|
+
// Skip symlinked directories entirely.
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
// Symlink to a file: treat as a candidate file below.
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (entry.isDirectory()) {
|
|
558
|
+
if (VENDOR_DIRS.has(entry.name)) {
|
|
559
|
+
// Skip the whole vendor subtree.
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
stack.push(fullPath);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// At this point the entry is a regular file (or a symlink to a file).
|
|
567
|
+
const rel = relative(inputPath, fullPath);
|
|
568
|
+
if (excludeRegex.test(rel)) {
|
|
569
|
+
excludedCount += 1;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (isRecognizedPhp(fullPath)) {
|
|
573
|
+
included.push(fullPath);
|
|
574
|
+
} else {
|
|
575
|
+
skippedNonPhpCount += 1;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return { included, excludedCount, skippedNonPhpCount };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Node children below a truncation boundary are cut and replaced by this marker so the surviving
|
|
585
|
+
* tree still serializes to valid JSON. The `truncated` flag lets consumers detect a cut boundary.
|
|
586
|
+
*/
|
|
587
|
+
export const TRUNCATION_MARKER = "__phpastgen_truncated__";
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Maximum bytes of a leading chunk inspected for an encoding declaration. A `declare(encoding=...)`
|
|
591
|
+
* statement or a byte-order mark is always near the start of the file, so a small window suffices.
|
|
592
|
+
*/
|
|
593
|
+
const ENCODING_SNIFF_BYTES = 4096;
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Map a PHP-flavored encoding label to a Node.js {@link Buffer} encoding, or undefined when the
|
|
597
|
+
* label is unknown/unsupported so the caller falls back to UTF-8 scrubbing.
|
|
598
|
+
*
|
|
599
|
+
* @param {string} label the declared encoding label (case-insensitive)
|
|
600
|
+
* @returns {BufferEncoding | undefined}
|
|
601
|
+
*/
|
|
602
|
+
function normalizeEncoding(label) {
|
|
603
|
+
const normalized = label.trim().toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
604
|
+
switch (normalized) {
|
|
605
|
+
case "utf8":
|
|
606
|
+
return "utf-8";
|
|
607
|
+
case "usascii":
|
|
608
|
+
case "ascii":
|
|
609
|
+
return "ascii";
|
|
610
|
+
case "iso88591":
|
|
611
|
+
case "latin1":
|
|
612
|
+
case "cp1252":
|
|
613
|
+
case "windows1252":
|
|
614
|
+
// Node treats latin1/binary as a single-byte pass-through, the closest built-in match.
|
|
615
|
+
return "latin1";
|
|
616
|
+
case "utf16":
|
|
617
|
+
case "utf16le":
|
|
618
|
+
case "ucs2":
|
|
619
|
+
return "utf16le";
|
|
620
|
+
default:
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Best-effort read of a declared source encoding from the leading bytes of a PHP file. Honors a
|
|
627
|
+
* UTF-8/UTF-16 byte-order mark and a `declare(encoding='...')` statement. Returns undefined when no
|
|
628
|
+
* declaration is found so the caller defaults to UTF-8 (design §2.3.1).
|
|
629
|
+
*
|
|
630
|
+
* @param {Buffer} bytes raw file bytes
|
|
631
|
+
* @returns {string | undefined} a declared encoding label, or undefined
|
|
632
|
+
*/
|
|
633
|
+
export function readMagicEncodingComment(bytes) {
|
|
634
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0) {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
// Byte-order marks take precedence over any textual declaration.
|
|
638
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
639
|
+
return "utf-8";
|
|
640
|
+
}
|
|
641
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
642
|
+
return "utf-16le";
|
|
643
|
+
}
|
|
644
|
+
// Sniff a leading window for a declare(encoding=...) statement.
|
|
645
|
+
const head = bytes.toString("latin1", 0, Math.min(bytes.length, ENCODING_SNIFF_BYTES));
|
|
646
|
+
const match = head.match(
|
|
647
|
+
/declare\s*\(\s*encoding\s*=\s*['"]([^'"]+)['"]\s*\)/i
|
|
648
|
+
);
|
|
649
|
+
if (match) {
|
|
650
|
+
return match[1];
|
|
651
|
+
}
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Decode raw file bytes into text, honoring a declared encoding and falling back to UTF-8 with
|
|
657
|
+
* invalid byte sequences replaced (design §2.3.1, Requirement 2.5).
|
|
658
|
+
*
|
|
659
|
+
* When the declared encoding decodes cleanly the result is returned with `scrubbed=false`. When
|
|
660
|
+
* there is no usable declaration, or decoding produces the Unicode replacement character
|
|
661
|
+
* (U+FFFD) — i.e. the bytes were not valid for the declared encoding — the content is decoded as
|
|
662
|
+
* UTF-8 with invalid sequences replaced and `scrubbed` is set to true. This makes the operation
|
|
663
|
+
* idempotent: text that has already been scrubbed contains only valid UTF-8 (no invalid bytes to
|
|
664
|
+
* replace), so re-decoding its UTF-8 bytes yields the same text (Property 5 / P5).
|
|
665
|
+
*
|
|
666
|
+
* @param {Buffer} bytes raw file bytes
|
|
667
|
+
* @returns {{ text: string, scrubbed: boolean }}
|
|
668
|
+
*/
|
|
669
|
+
export function decodeAndScrub(bytes) {
|
|
670
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes ?? "");
|
|
671
|
+
const declared = readMagicEncodingComment(buffer);
|
|
672
|
+
const enc = normalizeEncoding(declared ?? "UTF-8");
|
|
673
|
+
|
|
674
|
+
// The declared label was recognized: try a strict decode. Node's decoders insert the U+FFFD
|
|
675
|
+
// replacement character for byte sequences that are invalid for the target encoding, so its
|
|
676
|
+
// presence signals that a clean decode was not possible.
|
|
677
|
+
if (enc !== undefined) {
|
|
678
|
+
const text = buffer.toString(enc);
|
|
679
|
+
if (!text.includes("\uFFFD")) {
|
|
680
|
+
return { text, scrubbed: false };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// No usable declaration, or the declared encoding produced invalid sequences: scrub as UTF-8.
|
|
685
|
+
return { text: buffer.toString("utf-8"), scrubbed: true };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Return the array of child AST nodes reachable from `node` (design §2.3.2). nikic ASTs are plain
|
|
690
|
+
* objects/arrays: children are the object-or-array valued properties (skipping the `nodeType`,
|
|
691
|
+
* `attributes` metadata and the truncation marker itself), plus array elements.
|
|
692
|
+
*
|
|
693
|
+
* @param {*} node
|
|
694
|
+
* @returns {Array<{ container: object, key: (string|number), value: object }>}
|
|
695
|
+
*/
|
|
696
|
+
function childNodeEntries(node) {
|
|
697
|
+
const entries = [];
|
|
698
|
+
if (Array.isArray(node)) {
|
|
699
|
+
for (let i = 0; i < node.length; i++) {
|
|
700
|
+
const value = node[i];
|
|
701
|
+
if (value !== null && typeof value === "object") {
|
|
702
|
+
entries.push({ container: node, key: i, value });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return entries;
|
|
706
|
+
}
|
|
707
|
+
if (node !== null && typeof node === "object") {
|
|
708
|
+
for (const key of Object.keys(node)) {
|
|
709
|
+
if (key === "nodeType" || key === TRUNCATION_MARKER) {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
const value = node[key];
|
|
713
|
+
if (value !== null && typeof value === "object") {
|
|
714
|
+
entries.push({ container: node, key, value });
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return entries;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Truncate an AST in place so no node survives at or below the depth cap, marking each cut boundary
|
|
723
|
+
* and returning the number of truncation points (design §2.3.2, Requirement 2.6).
|
|
724
|
+
*
|
|
725
|
+
* A node reached at `depth >= maxDepth` is a truncation point: its descendant children are
|
|
726
|
+
* detached and it is tagged with `truncated: true` (a boundary marker). The marker is placed on
|
|
727
|
+
* object nodes; the count of boundaries is returned so the caller can record `truncated_nodes`.
|
|
728
|
+
*
|
|
729
|
+
* Postcondition: no node deeper than `maxDepth` survives, and the surviving tree still serializes
|
|
730
|
+
* (children are removed rather than left dangling). Correctness Property 6 (P6).
|
|
731
|
+
*
|
|
732
|
+
* @param {*} node the AST root (object or array)
|
|
733
|
+
* @param {number} maxDepth the depth cap (1-based; root is depth 0)
|
|
734
|
+
* @param {number} [depth=0] current recursion depth
|
|
735
|
+
* @returns {number} the number of truncation points introduced
|
|
736
|
+
*/
|
|
737
|
+
export function truncateDeep(node, maxDepth, depth = 0) {
|
|
738
|
+
if (node === null || typeof node !== "object") {
|
|
739
|
+
return 0;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (depth >= maxDepth) {
|
|
743
|
+
let count = 0;
|
|
744
|
+
const entries = childNodeEntries(node);
|
|
745
|
+
if (entries.length > 0) {
|
|
746
|
+
// Detach every child subtree; mark the boundary on object nodes.
|
|
747
|
+
for (const { container, key } of entries) {
|
|
748
|
+
if (Array.isArray(container)) {
|
|
749
|
+
container[key] = null;
|
|
750
|
+
} else {
|
|
751
|
+
delete container[key];
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (!Array.isArray(node)) {
|
|
755
|
+
node.truncated = true;
|
|
756
|
+
}
|
|
757
|
+
count += 1;
|
|
758
|
+
}
|
|
759
|
+
// Compact away the nulls introduced into arrays so the serialized tree stays clean.
|
|
760
|
+
if (Array.isArray(node) && count > 0) {
|
|
761
|
+
for (let i = node.length - 1; i >= 0; i--) {
|
|
762
|
+
if (node[i] === null) {
|
|
763
|
+
node.splice(i, 1);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return count;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let count = 0;
|
|
771
|
+
for (const { value } of childNodeEntries(node)) {
|
|
772
|
+
count += truncateDeep(value, maxDepth, depth + 1);
|
|
773
|
+
}
|
|
774
|
+
return count;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Additive per-node key under which syntactic framework facts are emitted (design Decision 3,
|
|
779
|
+
* §2.7, Requirement 6.1). The generator only surfaces *syntactic* facts already visible in the AST
|
|
780
|
+
* — attribute groups (e.g. `#[Route(...)]`) and PHP superglobal references (`$_GET`/`$_POST`/
|
|
781
|
+
* `$_REQUEST`) — leaving taint semantics to chen. This key is emitted strictly on nodes where such
|
|
782
|
+
* a fact holds and is omitted everywhere else, so the contract stays additive (invariant 1) and
|
|
783
|
+
* every pre-existing node field (including nikic's own `attrGroups`) is left byte-for-byte
|
|
784
|
+
* unchanged.
|
|
785
|
+
*/
|
|
786
|
+
export const FRAMEWORK_FACTS_KEY = "framework_facts";
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* PHP superglobal variable names the generator tags as syntactic framework facts (design §2.7,
|
|
790
|
+
* Requirement 6.1). These are the request-borne superglobals chen's WordPress/plain-PHP taint
|
|
791
|
+
* rules key off of; the generator merely records that a variable *is* one of them so downstream
|
|
792
|
+
* consumers do not have to re-derive it. Matched against an `Expr_Variable` node's `name` string.
|
|
793
|
+
*/
|
|
794
|
+
export const SUPERGLOBAL_NAMES = new Set([
|
|
795
|
+
"_GET",
|
|
796
|
+
"_POST",
|
|
797
|
+
"_REQUEST",
|
|
798
|
+
"_SERVER",
|
|
799
|
+
"_COOKIE",
|
|
800
|
+
"_SESSION",
|
|
801
|
+
"_FILES",
|
|
802
|
+
"_ENV",
|
|
803
|
+
"GLOBALS"
|
|
804
|
+
]);
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* The subset of {@link SUPERGLOBAL_NAMES} that carry externally controlled request data — the
|
|
808
|
+
* primary framework taint sources (design §2.7). Recorded on the fact so a consumer can cheaply
|
|
809
|
+
* distinguish a request superglobal (`$_GET`) from an ambient one (`$_SERVER`) without a second
|
|
810
|
+
* lookup table.
|
|
811
|
+
*/
|
|
812
|
+
const REQUEST_SUPERGLOBALS = new Set(["_GET", "_POST", "_REQUEST", "_COOKIE", "_FILES"]);
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Extract the attribute names declared on a node's nikic `attrGroups` array (design §2.7).
|
|
816
|
+
*
|
|
817
|
+
* nikic emits `attrGroups: [{ nodeType: "AttributeGroup", attrs: [{ nodeType: "Attribute",
|
|
818
|
+
* name: { name: "Fully\\Qualified\\Route" }, ... }] }]` on declaration nodes, and an empty array
|
|
819
|
+
* when a declaration carries no attributes. This returns the flat list of attribute names across
|
|
820
|
+
* every group, or an empty array when the node carries none. The pre-existing `attrGroups` field
|
|
821
|
+
* is read but never mutated.
|
|
822
|
+
*
|
|
823
|
+
* @param {*} node an AST node (may be any value)
|
|
824
|
+
* @returns {string[]} attribute names in declaration order (empty when there are none)
|
|
825
|
+
*/
|
|
826
|
+
export function attributeNames(node) {
|
|
827
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
828
|
+
return [];
|
|
829
|
+
}
|
|
830
|
+
const groups = node.attrGroups;
|
|
831
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
832
|
+
return [];
|
|
833
|
+
}
|
|
834
|
+
const names = [];
|
|
835
|
+
for (const group of groups) {
|
|
836
|
+
const attrs = group && typeof group === "object" ? group.attrs : undefined;
|
|
837
|
+
if (!Array.isArray(attrs)) {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
for (const attr of attrs) {
|
|
841
|
+
const name = attr && typeof attr === "object" ? attr.name : undefined;
|
|
842
|
+
// nikic models the attribute name as a Name node with a string `name` property.
|
|
843
|
+
if (name && typeof name === "object" && typeof name.name === "string") {
|
|
844
|
+
names.push(name.name);
|
|
845
|
+
} else if (typeof name === "string") {
|
|
846
|
+
names.push(name);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return names;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Compute the syntactic framework fact(s) for a single node, or `undefined` when the node carries
|
|
855
|
+
* none (design Decision 3, §2.7, Requirement 6.1).
|
|
856
|
+
*
|
|
857
|
+
* Two syntactic facts are recognized, mirroring the design's "syntactic, cheap,
|
|
858
|
+
* framework-agnostic-to-detect" set:
|
|
859
|
+
* - **attribute groups** — any node with a non-empty `attrGroups` yields
|
|
860
|
+
* `{ attributes: [<name>, ...] }`, the flat list of declared attribute names (e.g.
|
|
861
|
+
* `Symfony\\Component\\Routing\\Annotation\\Route`). This is what atom's policy maps to routed
|
|
862
|
+
* entrypoints and what chen keys Symfony routing off of.
|
|
863
|
+
* - **superglobal reference** — an `Expr_Variable` whose `name` is a known superglobal yields
|
|
864
|
+
* `{ superglobal: "_GET", request: true }`, where `request` flags the request-borne
|
|
865
|
+
* superglobals chen treats as taint sources.
|
|
866
|
+
*
|
|
867
|
+
* A node can carry both facts (they live under distinct keys), so the returned object may hold
|
|
868
|
+
* either or both. Returning `undefined` (rather than an empty object) lets the caller omit the key
|
|
869
|
+
* entirely, preserving the additive-contract invariant.
|
|
870
|
+
*
|
|
871
|
+
* @param {*} node an AST node
|
|
872
|
+
* @returns {object | undefined} the fact object, or undefined when the node carries no fact
|
|
873
|
+
*/
|
|
874
|
+
export function frameworkFactFor(node) {
|
|
875
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
876
|
+
return undefined;
|
|
877
|
+
}
|
|
878
|
+
let fact;
|
|
879
|
+
|
|
880
|
+
const attrs = attributeNames(node);
|
|
881
|
+
if (attrs.length > 0) {
|
|
882
|
+
fact = fact ?? {};
|
|
883
|
+
fact.attributes = attrs;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (node.nodeType === "Expr_Variable" && typeof node.name === "string" && SUPERGLOBAL_NAMES.has(node.name)) {
|
|
887
|
+
fact = fact ?? {};
|
|
888
|
+
fact.superglobal = node.name;
|
|
889
|
+
fact.request = REQUEST_SUPERGLOBALS.has(node.name);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return fact;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Walk a parsed AST in place and enrich each node that carries a syntactic framework fact with an
|
|
897
|
+
* additive {@link FRAMEWORK_FACTS_KEY} key (design Decision 3, §2.7, Requirement 6.1).
|
|
898
|
+
*
|
|
899
|
+
* Enrichment is strictly additive: for a node with a fact, a new `framework_facts` key is set and
|
|
900
|
+
* *no* pre-existing field is read-modified or removed (nikic's own `attrGroups`, `name`,
|
|
901
|
+
* `attributes`, children, etc. are left untouched). Nodes with no fact are not touched at all, so
|
|
902
|
+
* the emitted shape is byte-for-byte identical to the un-enriched AST except for the added keys —
|
|
903
|
+
* satisfying the additive contract (invariant 1). The traversal mirrors the reachability rules
|
|
904
|
+
* used elsewhere (object/array-valued properties plus array elements), skips the `attributes`
|
|
905
|
+
* metadata blob and the truncation marker, and tolerates cycles defensively via a visited set.
|
|
906
|
+
*
|
|
907
|
+
* @param {*} node the AST root (nikic emits a top-level array of statements)
|
|
908
|
+
* @returns {number} the number of nodes enriched (0 when no framework facts were found)
|
|
909
|
+
*/
|
|
910
|
+
export function enrichFrameworkFacts(node) {
|
|
911
|
+
let enriched = 0;
|
|
912
|
+
const seen = new Set();
|
|
913
|
+
const stack = [node];
|
|
914
|
+
while (stack.length > 0) {
|
|
915
|
+
const current = stack.pop();
|
|
916
|
+
if (current === null || typeof current !== "object" || seen.has(current)) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
seen.add(current);
|
|
920
|
+
|
|
921
|
+
if (!Array.isArray(current)) {
|
|
922
|
+
const fact = frameworkFactFor(current);
|
|
923
|
+
if (fact !== undefined) {
|
|
924
|
+
// Additive only: set the new key without disturbing any existing field.
|
|
925
|
+
current[FRAMEWORK_FACTS_KEY] = fact;
|
|
926
|
+
enriched += 1;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Descend into child nodes, matching the traversal used by truncateDeep: object/array-valued
|
|
931
|
+
// properties (excluding the nodeType tag, the framework-facts key we just wrote, and the
|
|
932
|
+
// truncation marker) plus array elements.
|
|
933
|
+
if (Array.isArray(current)) {
|
|
934
|
+
for (const value of current) {
|
|
935
|
+
if (value !== null && typeof value === "object") {
|
|
936
|
+
stack.push(value);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
for (const key of Object.keys(current)) {
|
|
941
|
+
if (key === "nodeType" || key === FRAMEWORK_FACTS_KEY || key === TRUNCATION_MARKER) {
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const value = current[key];
|
|
945
|
+
if (value !== null && typeof value === "object") {
|
|
946
|
+
stack.push(value);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return enriched;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Attach additive top-level provenance keys to a parsed AST (design §2.3, Tier 2 contract).
|
|
956
|
+
*
|
|
957
|
+
* Chosen JSON shape — wrapper object, not sibling keys on the array:
|
|
958
|
+
* `nikic/php-parser --json-dump` emits a top-level JSON *array* of statements. A JSON array cannot
|
|
959
|
+
* carry sibling object keys, so provenance is attached by wrapping that array under an `ast` key in
|
|
960
|
+
* a new object and placing the provenance keys as its siblings. The emitted shape is therefore:
|
|
961
|
+
*
|
|
962
|
+
* { ast, parser_backend, generator_version, php_version, target_version,
|
|
963
|
+
* [rel_file_path], [encoding_scrubbed], [truncated_nodes] }
|
|
964
|
+
*
|
|
965
|
+
* The four required keys (`parser_backend`, `generator_version`, `php_version`, `target_version`)
|
|
966
|
+
* are always present; `target_version` is `null` when unset. The optional keys are emitted strictly
|
|
967
|
+
* when their fact holds — `encoding_scrubbed: true` only when a scrub occurred and
|
|
968
|
+
* `truncated_nodes: <count>` only when the count is > 0 — satisfying the additive-contract
|
|
969
|
+
* invariant (invariant 1, Requirements 4.1 / 2.5 / 2.6 / 6.1). chen's Milestone B `Domain` decoder
|
|
970
|
+
* reads the `ast` array via `Domain.fromJson` and treats every provenance key as optional
|
|
971
|
+
* (Requirement 5.5), so an older decoder tolerates a newer wrapper and vice versa. The concrete
|
|
972
|
+
* shape is pinned by the contract snapshot task (tasks 15/24).
|
|
973
|
+
*
|
|
974
|
+
* @param {object} ast the parsed AST (nikic emits a top-level array of statements)
|
|
975
|
+
* @param {object} provenance provenance fields to attach
|
|
976
|
+
* @returns {object} an object carrying the AST plus provenance keys
|
|
977
|
+
*/
|
|
978
|
+
export function attachProvenance(ast, provenance) {
|
|
979
|
+
const out = {
|
|
980
|
+
ast,
|
|
981
|
+
parser_backend: provenance.parser_backend,
|
|
982
|
+
generator_version: provenance.generator_version,
|
|
983
|
+
php_version: provenance.php_version,
|
|
984
|
+
target_version: provenance.target_version ?? null
|
|
985
|
+
};
|
|
986
|
+
if (provenance.rel_file_path !== undefined) {
|
|
987
|
+
out.rel_file_path = provenance.rel_file_path;
|
|
988
|
+
}
|
|
989
|
+
// Additive-only: emit optional keys strictly when the fact holds.
|
|
990
|
+
if (provenance.encoding_scrubbed) {
|
|
991
|
+
out.encoding_scrubbed = true;
|
|
992
|
+
}
|
|
993
|
+
if (
|
|
994
|
+
provenance.truncated_nodes !== undefined &&
|
|
995
|
+
provenance.truncated_nodes > 0
|
|
996
|
+
) {
|
|
997
|
+
out.truncated_nodes = provenance.truncated_nodes;
|
|
36
998
|
}
|
|
999
|
+
return out;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Build a diagnostic record for a file that failed to parse (design §2.3, §2.4). The record's
|
|
1004
|
+
* `parse_error` captures the parser's message, a best-effort line/column extracted from that
|
|
1005
|
+
* message, and a short machine-readable reason.
|
|
1006
|
+
*
|
|
1007
|
+
* @param {string} file absolute path of the failed file
|
|
1008
|
+
* @param {string} stderr the parser's stderr output (may be empty)
|
|
1009
|
+
* @param {string} [relFilePath] path relative to the run input
|
|
1010
|
+
* @returns {{ file_path: string, rel_file_path: string, parse_error: { message: string, line: number, column: number, reason: string } }}
|
|
1011
|
+
*/
|
|
1012
|
+
export function buildDiagnostic(file, stderr, relFilePath) {
|
|
1013
|
+
const message = (stderr || "").trim() || "Failed to parse PHP file";
|
|
1014
|
+
// nikic emits "... on line N" for syntax errors; extract it when present.
|
|
1015
|
+
const lineMatch = message.match(/on line (\d+)/i);
|
|
1016
|
+
const colMatch = message.match(/column (\d+)/i);
|
|
1017
|
+
return {
|
|
1018
|
+
file_path: file,
|
|
1019
|
+
rel_file_path: relFilePath ?? basename(file),
|
|
1020
|
+
parse_error: {
|
|
1021
|
+
message,
|
|
1022
|
+
line: lineMatch ? Number.parseInt(lineMatch[1], 10) : 0,
|
|
1023
|
+
column: colMatch ? Number.parseInt(colMatch[1], 10) : 0,
|
|
1024
|
+
reason: "parse-error"
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* File name of the per-run manifest side-record (design §2.4). Always written on a batch run.
|
|
1031
|
+
* MUST end in `.jsonl` — chen reads every `*.json` under the output dir as an AST, so a side-record
|
|
1032
|
+
* named `*.json` would corrupt the consumer (non-negotiable invariant 2).
|
|
1033
|
+
*/
|
|
1034
|
+
export const MANIFEST_FILENAME = "phpastgen_manifest.jsonl";
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* File name of the per-run diagnostics side-record (design §2.4). Written only when at least one
|
|
1038
|
+
* file failed to parse; removed on a clean (zero-failure) run so stale diagnostics never linger
|
|
1039
|
+
* (Requirement 2.7). MUST end in `.jsonl` for the same reason as the manifest.
|
|
1040
|
+
*/
|
|
1041
|
+
export const DIAGNOSTICS_FILENAME = "phpastgen_diagnostics.jsonl";
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* The ordered set of manifest fields (design §2.4, authoritative). The manifest is a single JSONL
|
|
1045
|
+
* line containing exactly these keys.
|
|
1046
|
+
*/
|
|
1047
|
+
const MANIFEST_FIELDS = [
|
|
1048
|
+
"input",
|
|
1049
|
+
"output",
|
|
1050
|
+
"php_version",
|
|
1051
|
+
"parser_backend",
|
|
1052
|
+
"generator_version",
|
|
1053
|
+
"generated_at",
|
|
1054
|
+
"target_version",
|
|
1055
|
+
"files_parsed",
|
|
1056
|
+
"files_failed",
|
|
1057
|
+
"files_skipped_nonphp",
|
|
1058
|
+
"files_excluded",
|
|
1059
|
+
"truncated_files",
|
|
1060
|
+
"threads",
|
|
1061
|
+
"max_depth"
|
|
1062
|
+
];
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Guard against ever naming a side-record `*.json`: chen treats every `*.json` under the output dir
|
|
1066
|
+
* as an AST, so a side-record with that extension would be mis-consumed (invariant 2). The naming
|
|
1067
|
+
* rule is `.jsonl`, never `.json` (design §2.4).
|
|
1068
|
+
*
|
|
1069
|
+
* @param {string} fileName the side-record file name
|
|
1070
|
+
* @throws {Error} when the name does not end in `.jsonl`
|
|
1071
|
+
*/
|
|
1072
|
+
function assertJsonlName(fileName) {
|
|
1073
|
+
if (!fileName.endsWith(".jsonl")) {
|
|
1074
|
+
throw new Error(
|
|
1075
|
+
`Side-record '${fileName}' must end in '.jsonl', never '.json' (design §2.4).`
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Write the per-run manifest side-record as a single JSONL line under the output directory
|
|
1082
|
+
* (design §2.4). The manifest is emitted on every batch run and contains exactly the authoritative
|
|
1083
|
+
* field set in the documented order; missing numeric fields default to 0 and `target_version`
|
|
1084
|
+
* defaults to null so the line is always complete. A `generated_at` timestamp is filled in when the
|
|
1085
|
+
* caller does not supply one.
|
|
1086
|
+
*
|
|
1087
|
+
* The file is named {@link MANIFEST_FILENAME} (`phpastgen_manifest.jsonl`); a `.json` name is
|
|
1088
|
+
* rejected so the manifest is never mistaken for an AST (invariant 2).
|
|
1089
|
+
*
|
|
1090
|
+
* @param {string} outputDir directory the manifest is written under
|
|
1091
|
+
* @param {object} entries manifest field values (see design §2.4)
|
|
1092
|
+
* @returns {string} the absolute path of the written manifest
|
|
1093
|
+
*/
|
|
1094
|
+
export function writeManifest(outputDir, entries = {}) {
|
|
1095
|
+
assertJsonlName(MANIFEST_FILENAME);
|
|
1096
|
+
const record = {};
|
|
1097
|
+
for (const field of MANIFEST_FIELDS) {
|
|
1098
|
+
if (field === "target_version") {
|
|
1099
|
+
record[field] = entries[field] ?? null;
|
|
1100
|
+
} else if (field === "generated_at") {
|
|
1101
|
+
record[field] = entries[field] ?? new Date().toISOString();
|
|
1102
|
+
} else if (field === "php_version") {
|
|
1103
|
+
// A-L1: `null` here is deliberately ambiguous-looking but is only reachable in ONE way.
|
|
1104
|
+
// "PHP absent" cannot reach a batch run: `main` returns early on `detectPhp()` before
|
|
1105
|
+
// `runBatch` is ever called, so by construction the runtime exists whenever a manifest is
|
|
1106
|
+
// written. A null therefore means "runtime present, but its version string could not be
|
|
1107
|
+
// extracted" (`php -r 'echo PHP_VERSION;'` failed or printed nothing). The distinction is
|
|
1108
|
+
// recorded here rather than in the record itself on purpose: the manifest key set is a pinned
|
|
1109
|
+
// contract (test-fixtures/phpastgen-contract-snapshot.js, design Decision 4), so adding a
|
|
1110
|
+
// discriminator key would be a non-additive contract change. Emit `null` and keep the shape.
|
|
1111
|
+
record[field] = entries[field] ?? null;
|
|
1112
|
+
} else if (
|
|
1113
|
+
field === "input" ||
|
|
1114
|
+
field === "output" ||
|
|
1115
|
+
field === "parser_backend" ||
|
|
1116
|
+
field === "generator_version"
|
|
1117
|
+
) {
|
|
1118
|
+
record[field] = entries[field] ?? null;
|
|
1119
|
+
} else {
|
|
1120
|
+
// Numeric count/config fields default to 0 when absent.
|
|
1121
|
+
record[field] = entries[field] ?? 0;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const path = join(outputDir, MANIFEST_FILENAME);
|
|
1125
|
+
writeFileSync(path, `${JSON.stringify(record)}\n`, "utf-8");
|
|
1126
|
+
return path;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Write the per-run diagnostics side-record under the output directory (design §2.4), one JSONL
|
|
1131
|
+
* line per failed file. Each line carries `file_path`, `rel_file_path`, and the `parse_error`
|
|
1132
|
+
* object as produced by {@link buildDiagnostic}.
|
|
1133
|
+
*
|
|
1134
|
+
* When there were zero failures (empty or missing `diagnostics`), no diagnostics file is written
|
|
1135
|
+
* and any pre-existing diagnostics file from an earlier run is removed so stale diagnostics never
|
|
1136
|
+
* linger (Requirement 2.7). The file is named {@link DIAGNOSTICS_FILENAME}; a `.json` name is
|
|
1137
|
+
* rejected (invariant 2).
|
|
1138
|
+
*
|
|
1139
|
+
* @param {string} outputDir directory the diagnostics record is written under
|
|
1140
|
+
* @param {object[]} diagnostics one diagnostic object per failed file
|
|
1141
|
+
* @returns {string | null} the absolute path written, or null on a clean run
|
|
1142
|
+
*/
|
|
1143
|
+
export function writeDiagnostics(outputDir, diagnostics = []) {
|
|
1144
|
+
assertJsonlName(DIAGNOSTICS_FILENAME);
|
|
1145
|
+
const path = join(outputDir, DIAGNOSTICS_FILENAME);
|
|
1146
|
+
|
|
1147
|
+
if (!Array.isArray(diagnostics) || diagnostics.length === 0) {
|
|
1148
|
+
// Clean run: remove any stale diagnostics file so it does not linger (Requirement 2.7).
|
|
1149
|
+
if (existsSync(path)) {
|
|
1150
|
+
try {
|
|
1151
|
+
unlinkSync(path);
|
|
1152
|
+
} catch {
|
|
1153
|
+
// ignore removal failure; a stale file is preferable to aborting a successful run
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const lines = diagnostics.map((d) => JSON.stringify(d)).join("\n");
|
|
1160
|
+
writeFileSync(path, `${lines}\n`, "utf-8");
|
|
1161
|
+
return path;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* The JSON nesting limit `JSON.stringify` must tolerate is unbounded in Node (it has no depth
|
|
1166
|
+
* limit), but chen and other consumers cap nesting. Derive a serializer nesting budget from the
|
|
1167
|
+
* depth cap plus headroom for the provenance wrapper so any surviving (already-truncated) tree
|
|
1168
|
+
* serializes (design §2.3.2 postcondition).
|
|
1169
|
+
*
|
|
1170
|
+
* @param {number} maxDepth
|
|
1171
|
+
* @returns {number}
|
|
1172
|
+
*/
|
|
1173
|
+
export function serializerNestingLimit(maxDepth) {
|
|
1174
|
+
return maxDepth + 8;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Extract the JSON payload from `php-parse --json-dump` stdout. The binary may print a leading
|
|
1179
|
+
* banner before the JSON array, so lines are dropped until the first line that begins the JSON
|
|
1180
|
+
* document (`[` for the statement array, or `{` defensively).
|
|
1181
|
+
*
|
|
1182
|
+
* @param {string} stdout
|
|
1183
|
+
* @returns {string} the JSON text (empty when no JSON was found)
|
|
1184
|
+
*/
|
|
1185
|
+
export function extractJsonPayload(stdout) {
|
|
1186
|
+
const text = stdout || "";
|
|
1187
|
+
const startArr = text.indexOf("[");
|
|
1188
|
+
const startObj = text.indexOf("{");
|
|
1189
|
+
let start = -1;
|
|
1190
|
+
if (startArr === -1) {
|
|
1191
|
+
start = startObj;
|
|
1192
|
+
} else if (startObj === -1) {
|
|
1193
|
+
start = startArr;
|
|
1194
|
+
} else {
|
|
1195
|
+
start = Math.min(startArr, startObj);
|
|
1196
|
+
}
|
|
1197
|
+
if (start === -1) {
|
|
1198
|
+
return "";
|
|
1199
|
+
}
|
|
1200
|
+
return text.slice(start).trim();
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Cap on the bytes of parser output buffered per stream. Mirrors the `maxBuffer: 256MB` intent of
|
|
1205
|
+
* the previous `spawnSync` call: a runaway parser must not grow the Node heap without bound. With
|
|
1206
|
+
* the async `spawn` path the chunks are accumulated here, so the cap is enforced explicitly — once
|
|
1207
|
+
* exceeded the child is killed and the file yields a diagnostic instead of an AST.
|
|
1208
|
+
*/
|
|
1209
|
+
export const MAX_PARSER_OUTPUT_BYTES = 1024 * 1024 * 256;
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Run a command asynchronously and capture its output, resolving (never rejecting) with the same
|
|
1213
|
+
* shape the previous `spawnSync` call produced: `{ status, stdout, stderr, error }`.
|
|
1214
|
+
*
|
|
1215
|
+
* This is the piece that makes `--threads` real (design §2.1): `spawnSync` blocks the single Node
|
|
1216
|
+
* thread for the whole parse, so a pool of awaited `spawnSync` calls still runs strictly one file
|
|
1217
|
+
* at a time. `spawn` returns immediately and the parse completes on the event loop, so N pool
|
|
1218
|
+
* runners genuinely hold N concurrent `php php-parse` children.
|
|
1219
|
+
*
|
|
1220
|
+
* Failure handling mirrors `spawnSync` so callers need no new branches:
|
|
1221
|
+
* - a spawn failure (e.g. PHP not on the PATH) resolves with `error` set,
|
|
1222
|
+
* - a timeout (`ATOM_TIMEOUT`/`ASTGEN_TIMEOUT`) kills the child and resolves with `error` set,
|
|
1223
|
+
* - output exceeding {@link MAX_PARSER_OUTPUT_BYTES} on either stream kills the child and resolves
|
|
1224
|
+
* with `error` set, so unbounded output can never exhaust memory.
|
|
1225
|
+
*
|
|
1226
|
+
* @param {string} command executable to run
|
|
1227
|
+
* @param {string[]} args argv array (never a shell string: no injection surface)
|
|
1228
|
+
* @param {{ timeout?: number, maxBuffer?: number }} [options]
|
|
1229
|
+
* @returns {Promise<{ status: (number|null), stdout: string, stderr: string, error?: Error }>}
|
|
1230
|
+
*/
|
|
1231
|
+
export function spawnCapture(command, args, options = {}) {
|
|
1232
|
+
const timeout = options.timeout;
|
|
1233
|
+
const maxBuffer = options.maxBuffer ?? MAX_PARSER_OUTPUT_BYTES;
|
|
1234
|
+
|
|
1235
|
+
return new Promise((resolve) => {
|
|
1236
|
+
let child;
|
|
1237
|
+
try {
|
|
1238
|
+
child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
resolve({ status: null, stdout: "", stderr: "", error: err });
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const stdoutChunks = [];
|
|
1245
|
+
const stderrChunks = [];
|
|
1246
|
+
let stdoutBytes = 0;
|
|
1247
|
+
let stderrBytes = 0;
|
|
1248
|
+
let settled = false;
|
|
1249
|
+
let error;
|
|
1250
|
+
let timer;
|
|
1251
|
+
|
|
1252
|
+
const kill = () => {
|
|
1253
|
+
try {
|
|
1254
|
+
child.kill("SIGKILL");
|
|
1255
|
+
} catch {
|
|
1256
|
+
// the child may already be gone
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
const finish = (status) => {
|
|
1261
|
+
if (settled) {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
settled = true;
|
|
1265
|
+
if (timer !== undefined) {
|
|
1266
|
+
clearTimeout(timer);
|
|
1267
|
+
}
|
|
1268
|
+
resolve({
|
|
1269
|
+
status,
|
|
1270
|
+
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
|
|
1271
|
+
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
|
|
1272
|
+
error
|
|
1273
|
+
});
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
if (timeout !== undefined && timeout > 0) {
|
|
1277
|
+
timer = setTimeout(() => {
|
|
1278
|
+
error = error ?? new Error(`php-parse timed out after ${timeout}ms`);
|
|
1279
|
+
kill();
|
|
1280
|
+
}, timeout);
|
|
1281
|
+
// Do not hold the event loop open on the timer alone.
|
|
1282
|
+
if (typeof timer.unref === "function") {
|
|
1283
|
+
timer.unref();
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
child.stdout.on("data", (chunk) => {
|
|
1288
|
+
stdoutBytes += chunk.length;
|
|
1289
|
+
if (stdoutBytes > maxBuffer) {
|
|
1290
|
+
// Same intent as spawnSync's maxBuffer guard: stop buffering and kill the child rather
|
|
1291
|
+
// than growing without bound. Drop the chunk so the retained bytes stay under the cap.
|
|
1292
|
+
error =
|
|
1293
|
+
error ??
|
|
1294
|
+
new Error(
|
|
1295
|
+
`php-parse stdout exceeded the ${maxBuffer} byte buffer limit`
|
|
1296
|
+
);
|
|
1297
|
+
kill();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
stdoutChunks.push(chunk);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
child.stderr.on("data", (chunk) => {
|
|
1304
|
+
stderrBytes += chunk.length;
|
|
1305
|
+
if (stderrBytes > maxBuffer) {
|
|
1306
|
+
error =
|
|
1307
|
+
error ??
|
|
1308
|
+
new Error(
|
|
1309
|
+
`php-parse stderr exceeded the ${maxBuffer} byte buffer limit`
|
|
1310
|
+
);
|
|
1311
|
+
kill();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
stderrChunks.push(chunk);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
// A pipe can error (e.g. EPIPE/EIO after the child is killed for a timeout or buffer overrun).
|
|
1318
|
+
// An unhandled 'error' event on a stream is an uncaught exception, which would abort the whole
|
|
1319
|
+
// batch over one file — exactly what per-file failure isolation forbids (Requirement 2.4).
|
|
1320
|
+
// Record it and force settlement; the recorded error becomes this file's diagnostic.
|
|
1321
|
+
const onStreamError = (err) => {
|
|
1322
|
+
error = error ?? err;
|
|
1323
|
+
kill();
|
|
1324
|
+
finish(null);
|
|
1325
|
+
};
|
|
1326
|
+
child.stdout.on("error", onStreamError);
|
|
1327
|
+
child.stderr.on("error", onStreamError);
|
|
1328
|
+
|
|
1329
|
+
child.on("error", (err) => {
|
|
1330
|
+
error = error ?? err;
|
|
1331
|
+
finish(null);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
child.on("close", (code) => {
|
|
1335
|
+
finish(code);
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Parse a single PHP file (design §2.3). Reads and decodes/scrubs the bytes, spawns the vendored
|
|
1342
|
+
* `php-parse` with recovery flags (adding `--target-php-version` when a target grammar is pinned),
|
|
1343
|
+
* parses the emitted JSON, truncates descendants below the depth cap, and attaches additive
|
|
1344
|
+
* provenance. On failure it returns a diagnostic instead of throwing so a bad file never aborts a
|
|
1345
|
+
* batch run (Requirement 2.4).
|
|
1346
|
+
*
|
|
1347
|
+
* **Async**: the parse subprocess is spawned non-blocking (see {@link spawnCapture}) so
|
|
1348
|
+
* `runInPool` can hold `--threads` parses in flight at once. This function therefore returns a
|
|
1349
|
+
* Promise; callers must await it. Everything else — argv, temp-file staging of the scrubbed text,
|
|
1350
|
+
* JSON payload extraction, truncation → framework-facts enrichment → provenance ordering, the
|
|
1351
|
+
* diagnostics emitted on failure, and the `{ok, ast, truncated, scrubbed}` result shape — is
|
|
1352
|
+
* unchanged.
|
|
1353
|
+
*
|
|
1354
|
+
* Provenance values that are invariant for a whole run (`php_version`, `parser_backend`) are taken
|
|
1355
|
+
* from `opts.phpVersion`/`opts.parserBackend` when the caller resolved them once (`runBatch` does),
|
|
1356
|
+
* and computed here otherwise so a direct caller still gets complete provenance.
|
|
1357
|
+
*
|
|
1358
|
+
* @param {string} file absolute path of the PHP file to parse
|
|
1359
|
+
* @param {object} opts parsed CLI options (input, output, targetVersion, maxDepth, phpVersion, parserBackend)
|
|
1360
|
+
* @returns {Promise<{ ok: boolean, ast?: object, truncated?: boolean, scrubbed?: boolean, diagnostic?: object }>}
|
|
1361
|
+
*/
|
|
1362
|
+
export async function parseOneFile(file, opts = {}) {
|
|
1363
|
+
const inputRoot = opts.input ?? dirname(file);
|
|
1364
|
+
const relFilePath = relative(inputRoot, file) || basename(file);
|
|
1365
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
1366
|
+
|
|
1367
|
+
let bytes;
|
|
1368
|
+
try {
|
|
1369
|
+
bytes = readFileSync(file);
|
|
1370
|
+
} catch (err) {
|
|
1371
|
+
return {
|
|
1372
|
+
ok: false,
|
|
1373
|
+
diagnostic: buildDiagnostic(file, `Unable to read file: ${err.message}`, relFilePath)
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const { text, scrubbed } = decodeAndScrub(bytes);
|
|
1378
|
+
|
|
1379
|
+
// The parser reads from a file path, so write the (possibly scrubbed) text to a temp file. Using
|
|
1380
|
+
// a temp file keeps the on-disk source intact and lets scrubbing take effect for the parser.
|
|
1381
|
+
let tempDir;
|
|
1382
|
+
let tempFile;
|
|
1383
|
+
try {
|
|
1384
|
+
tempDir = mkdtempSync(join(tmpdir(), "phpastgen-"));
|
|
1385
|
+
tempFile = join(tempDir, basename(file) || "source.php");
|
|
1386
|
+
writeFileSync(tempFile, text, "utf-8");
|
|
1387
|
+
} catch (err) {
|
|
1388
|
+
if (tempDir) {
|
|
1389
|
+
try {
|
|
1390
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
1391
|
+
} catch {
|
|
1392
|
+
// ignore cleanup failure
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
return {
|
|
1396
|
+
ok: false,
|
|
1397
|
+
diagnostic: buildDiagnostic(file, `Unable to stage file: ${err.message}`, relFilePath)
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const args = ["--with-recovery", "--resolve-names", "-P", "--json-dump"];
|
|
1402
|
+
if (opts.targetVersion) {
|
|
1403
|
+
// The vendored php-parse binary pins the target grammar via `--version=VERSION` (design §2.3
|
|
1404
|
+
// names this `--target-php-version`; the binary's actual flag is `--version`). Token emulation
|
|
1405
|
+
// then parses the requested grammar regardless of the installed runtime (Requirement 1.2).
|
|
1406
|
+
args.push(`--version=${opts.targetVersion}`);
|
|
1407
|
+
}
|
|
1408
|
+
args.push(tempFile);
|
|
1409
|
+
|
|
1410
|
+
let out;
|
|
1411
|
+
try {
|
|
1412
|
+
out = await spawnCapture(
|
|
1413
|
+
process.env.PHP_CMD || "php",
|
|
1414
|
+
[resolvePhpParseBin(), ...args],
|
|
1415
|
+
{
|
|
1416
|
+
timeout: spawnTimeout(),
|
|
1417
|
+
maxBuffer: MAX_PARSER_OUTPUT_BYTES
|
|
1418
|
+
}
|
|
1419
|
+
);
|
|
1420
|
+
} finally {
|
|
1421
|
+
try {
|
|
1422
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
1423
|
+
} catch {
|
|
1424
|
+
// ignore cleanup failure
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (out.error) {
|
|
1429
|
+
return {
|
|
1430
|
+
ok: false,
|
|
1431
|
+
diagnostic: buildDiagnostic(file, out.error.message, relFilePath)
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const payload = extractJsonPayload(out.stdout);
|
|
1436
|
+
if (payload === "") {
|
|
1437
|
+
return {
|
|
1438
|
+
ok: false,
|
|
1439
|
+
diagnostic: buildDiagnostic(file, out.stderr, relFilePath)
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
let parsed;
|
|
1444
|
+
try {
|
|
1445
|
+
parsed = JSON.parse(payload);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
return {
|
|
1448
|
+
ok: false,
|
|
1449
|
+
diagnostic: buildDiagnostic(
|
|
1450
|
+
file,
|
|
1451
|
+
`${out.stderr || ""}\nInvalid JSON from parser: ${err.message}`.trim(),
|
|
1452
|
+
relFilePath
|
|
1453
|
+
)
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const truncatedCount = truncateDeep(parsed, maxDepth);
|
|
1458
|
+
|
|
1459
|
+
// Enrich surviving nodes with additive syntactic framework facts (attribute groups, superglobal
|
|
1460
|
+
// references) before wrapping in provenance. Runs after truncation so cut subtrees are never
|
|
1461
|
+
// re-walked, and leaves every pre-existing node field unchanged (design Decision 3, §2.7,
|
|
1462
|
+
// Requirement 6.1).
|
|
1463
|
+
enrichFrameworkFacts(parsed);
|
|
1464
|
+
|
|
1465
|
+
// Both provenance values below are invariant for a whole run, so a batch resolves them ONCE and
|
|
1466
|
+
// threads them through opts (design §2.1; avoids a `php -r` subprocess and a composer file read
|
|
1467
|
+
// per file). A direct caller that did not pre-resolve them still gets identical values, computed
|
|
1468
|
+
// here. `!== undefined` rather than `??` so a deliberately-null php_version is preserved instead
|
|
1469
|
+
// of triggering a per-file re-detect.
|
|
1470
|
+
const phpVersion =
|
|
1471
|
+
opts.phpVersion !== undefined ? opts.phpVersion : detectedPhpVersion();
|
|
1472
|
+
const parserBackend =
|
|
1473
|
+
opts.parserBackend !== undefined
|
|
1474
|
+
? opts.parserBackend
|
|
1475
|
+
: `nikic/php-parser@${vendoredParserVersion()}`;
|
|
1476
|
+
|
|
1477
|
+
const ast = attachProvenance(parsed, {
|
|
1478
|
+
parser_backend: parserBackend,
|
|
1479
|
+
generator_version: GENERATOR_VERSION,
|
|
1480
|
+
php_version: phpVersion,
|
|
1481
|
+
target_version: opts.targetVersion ?? null,
|
|
1482
|
+
rel_file_path: relFilePath,
|
|
1483
|
+
encoding_scrubbed: scrubbed || undefined,
|
|
1484
|
+
truncated_nodes: truncatedCount > 0 ? truncatedCount : undefined
|
|
1485
|
+
});
|
|
1486
|
+
|
|
1487
|
+
return {
|
|
1488
|
+
ok: true,
|
|
1489
|
+
ast,
|
|
1490
|
+
truncated: truncatedCount > 0,
|
|
1491
|
+
scrubbed
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Compute the output path for a parsed file's AST JSON, mirroring the input-relative directory
|
|
1497
|
+
* layout under the output directory (design §2.3 batch mode). The written file is always a
|
|
1498
|
+
* `*.json` (invariant 2: only AST files are `*.json`; side-records are `*.jsonl`).
|
|
1499
|
+
*
|
|
1500
|
+
* The relative layout is preserved by appending `.json` to the file's path relative to the input
|
|
1501
|
+
* root, so `<input>/src/a.php` becomes `<output>/src/a.php.json`. For a single-file input the
|
|
1502
|
+
* basename is used so the file lands directly under the output directory.
|
|
1503
|
+
*
|
|
1504
|
+
* @param {string} outputDir the batch output directory
|
|
1505
|
+
* @param {string} inputRoot the run input path (file or directory)
|
|
1506
|
+
* @param {string} file the absolute path of the parsed file
|
|
1507
|
+
* @returns {string} the absolute AST JSON path
|
|
1508
|
+
*/
|
|
1509
|
+
export function astFilePath(outputDir, inputRoot, file) {
|
|
1510
|
+
let rel = relative(inputRoot, file);
|
|
1511
|
+
// A single-file input yields an empty relative path; use the basename instead so the AST lands
|
|
1512
|
+
// directly under the output dir rather than at the output dir itself.
|
|
1513
|
+
if (rel === "" || rel.startsWith("..")) {
|
|
1514
|
+
rel = basename(file);
|
|
1515
|
+
}
|
|
1516
|
+
return join(outputDir, `${rel}.json`);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Drive `worker` over `items` with at most `limit` invocations outstanding at once — a bounded
|
|
1521
|
+
* concurrency pool (design §2.1 threading note).
|
|
1522
|
+
*
|
|
1523
|
+
* Concurrency approach: `limit` runners are started, each pulling the next item from a shared
|
|
1524
|
+
* cursor and awaiting it before taking another. Because `parseOneFile` now spawns `php-parse`
|
|
1525
|
+
* non-blocking (see {@link spawnCapture}), the awaits overlap and up to `limit` `php php-parse`
|
|
1526
|
+
* children are genuinely resident at once — never more, so `--threads` is a real upper bound on
|
|
1527
|
+
* concurrent subprocesses rather than a bound on outstanding bookkeeping. A worker that blocks the
|
|
1528
|
+
* thread (e.g. `spawnSync`) would collapse this back to serial execution, which is why the parse
|
|
1529
|
+
* path must stay async. Long-running work is bounded by `ATOM_TIMEOUT` inside `parseOneFile`.
|
|
1530
|
+
*
|
|
1531
|
+
* @template T
|
|
1532
|
+
* @param {T[]} items work items to process in order
|
|
1533
|
+
* @param {number} limit maximum outstanding invocations (>= 1)
|
|
1534
|
+
* @param {(item: T, index: number) => (void | Promise<void>)} worker per-item callback
|
|
1535
|
+
* @returns {Promise<void>} resolves once every item has been processed
|
|
1536
|
+
*/
|
|
1537
|
+
export async function runInPool(items, limit, worker) {
|
|
1538
|
+
const total = items.length;
|
|
1539
|
+
const poolSize = Math.max(1, Math.min(limit || 1, total || 1));
|
|
1540
|
+
let cursor = 0;
|
|
1541
|
+
|
|
1542
|
+
const runOne = async () => {
|
|
1543
|
+
while (true) {
|
|
1544
|
+
const index = cursor;
|
|
1545
|
+
if (index >= total) {
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
cursor += 1;
|
|
1549
|
+
await worker(items[index], index);
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
|
|
1553
|
+
const runners = [];
|
|
1554
|
+
for (let i = 0; i < poolSize; i++) {
|
|
1555
|
+
runners.push(runOne());
|
|
1556
|
+
}
|
|
1557
|
+
await Promise.all(runners);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* Batch-mode orchestration (design §2.1 batch mode, §2.3/§2.4).
|
|
1562
|
+
*
|
|
1563
|
+
* Steps:
|
|
1564
|
+
* 1. Create the output directory (default `.ast`) if missing.
|
|
1565
|
+
* 2. Discover included/excluded/non-PHP files under the input.
|
|
1566
|
+
* 3. Parse the included files through a bounded worker pool (size `opts.threads`), writing exactly
|
|
1567
|
+
* one `*.json` AST per successfully parsed file under the output directory, mirroring the
|
|
1568
|
+
* input-relative directory layout. A file that fails parsing yields a diagnostic and never
|
|
1569
|
+
* aborts the run (per-file failure isolation, Requirement 2.4).
|
|
1570
|
+
* 4. Always write the manifest side-record; write the diagnostics side-record only when failures
|
|
1571
|
+
* occurred and remove any stale diagnostics file on a clean run (Requirement 2.7).
|
|
1572
|
+
* 5. Return a non-zero exit only under `--fail-on-error` when failures occurred; 0 otherwise.
|
|
1573
|
+
*
|
|
1574
|
+
* Loop invariant: `parsed + failed == number of included files processed so far`.
|
|
1575
|
+
*
|
|
1576
|
+
* `runInPool` is async, so this function returns a Promise resolving to the exit code. `main`
|
|
1577
|
+
* awaits it (or, when called synchronously, the returned Promise settles the process exit code).
|
|
1578
|
+
*
|
|
1579
|
+
* @param {object} opts parsed CLI options (input, output, exclude, threads, maxDepth, targetVersion, failOnError)
|
|
1580
|
+
* @returns {Promise<number>} process exit code
|
|
1581
|
+
*/
|
|
1582
|
+
export async function runBatch(opts) {
|
|
1583
|
+
const inputRoot = opts.input;
|
|
1584
|
+
const outputDir = opts.output ?? DEFAULT_OUTPUT;
|
|
1585
|
+
|
|
1586
|
+
// 1. Ensure the output directory exists.
|
|
1587
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1588
|
+
|
|
1589
|
+
// 2. Discover files. The exclude option is a source string; compile it once here.
|
|
1590
|
+
const excludeRegex = new RegExp(opts.exclude ?? DEFAULT_EXCLUDE);
|
|
1591
|
+
const { included, excludedCount, skippedNonPhpCount } = discoverFiles(
|
|
1592
|
+
inputRoot,
|
|
1593
|
+
excludeRegex
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
const counters = {
|
|
1597
|
+
parsed: 0,
|
|
1598
|
+
failed: 0,
|
|
1599
|
+
truncatedFiles: 0
|
|
1600
|
+
};
|
|
1601
|
+
const diagnostics = [];
|
|
1602
|
+
|
|
1603
|
+
// Resolve the run-invariant provenance values ONCE (A-M1): `detectedPhpVersion()` shells out to
|
|
1604
|
+
// `php -r` and `vendoredParserVersion()` reads + regexes a composer file, and neither can change
|
|
1605
|
+
// during a run. They are threaded through `opts` to `parseOneFile` (the same way `targetVersion`
|
|
1606
|
+
// and `maxDepth` already are) and reused for the manifest below, so a 10k-file tree pays for them
|
|
1607
|
+
// once instead of 10k times. The emitted values are byte-identical to computing them per file.
|
|
1608
|
+
const phpVersion = opts.phpVersion ?? detectedPhpVersion();
|
|
1609
|
+
const parserBackend =
|
|
1610
|
+
opts.parserBackend ?? `nikic/php-parser@${vendoredParserVersion()}`;
|
|
1611
|
+
const runOpts = { ...opts, phpVersion, parserBackend };
|
|
1612
|
+
|
|
1613
|
+
// 3. Parse through a bounded pool, writing one *.json AST per parsed file with per-file failure
|
|
1614
|
+
// isolation (Requirement 2.4). The loop invariant parsed + failed == processed holds because
|
|
1615
|
+
// every processed item lands in exactly one of the two branches below.
|
|
1616
|
+
await runInPool(included, opts.threads ?? DEFAULT_THREADS, async (file) => {
|
|
1617
|
+
let result;
|
|
1618
|
+
try {
|
|
1619
|
+
result = await parseOneFile(file, runOpts);
|
|
1620
|
+
} catch (err) {
|
|
1621
|
+
// Defensive: parseOneFile is designed not to throw, but an unexpected throw must still be
|
|
1622
|
+
// isolated to this file so it never aborts the batch.
|
|
1623
|
+
const relFilePath = relative(inputRoot, file) || basename(file);
|
|
1624
|
+
diagnostics.push(
|
|
1625
|
+
buildDiagnostic(file, `Unexpected parse failure: ${err.message}`, relFilePath)
|
|
1626
|
+
);
|
|
1627
|
+
counters.failed += 1;
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (result.ok) {
|
|
1632
|
+
const astPath = astFilePath(outputDir, inputRoot, file);
|
|
1633
|
+
try {
|
|
1634
|
+
mkdirSync(dirname(astPath), { recursive: true });
|
|
1635
|
+
writeFileSync(astPath, JSON.stringify(result.ast), "utf-8");
|
|
1636
|
+
counters.parsed += 1;
|
|
1637
|
+
if (result.truncated) {
|
|
1638
|
+
counters.truncatedFiles += 1;
|
|
1639
|
+
}
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
// A write failure is treated as a per-file failure so the run continues.
|
|
1642
|
+
const relFilePath = relative(inputRoot, file) || basename(file);
|
|
1643
|
+
diagnostics.push(
|
|
1644
|
+
buildDiagnostic(file, `Unable to write AST: ${err.message}`, relFilePath)
|
|
1645
|
+
);
|
|
1646
|
+
counters.failed += 1;
|
|
1647
|
+
}
|
|
1648
|
+
} else {
|
|
1649
|
+
diagnostics.push(result.diagnostic);
|
|
1650
|
+
counters.failed += 1;
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
// 4. Write side-records. Manifest is always written; diagnostics only when failures occurred
|
|
1655
|
+
// (writeDiagnostics removes any stale file on a clean run — Requirement 2.7).
|
|
1656
|
+
// The manifest reuses the once-resolved values above rather than re-detecting (A-M1). A null
|
|
1657
|
+
// `php_version` here means the runtime was present but its version string could not be read —
|
|
1658
|
+
// "PHP absent" cannot reach this point, since `main` gates on `detectPhp()` first (see
|
|
1659
|
+
// writeManifest's note on that distinction).
|
|
1660
|
+
writeManifest(outputDir, {
|
|
1661
|
+
input: inputRoot,
|
|
1662
|
+
output: outputDir,
|
|
1663
|
+
php_version: phpVersion ?? null,
|
|
1664
|
+
parser_backend: parserBackend,
|
|
1665
|
+
generator_version: GENERATOR_VERSION,
|
|
1666
|
+
target_version: opts.targetVersion ?? null,
|
|
1667
|
+
files_parsed: counters.parsed,
|
|
1668
|
+
files_failed: counters.failed,
|
|
1669
|
+
files_skipped_nonphp: skippedNonPhpCount,
|
|
1670
|
+
files_excluded: excludedCount,
|
|
1671
|
+
truncated_files: counters.truncatedFiles,
|
|
1672
|
+
threads: opts.threads ?? DEFAULT_THREADS,
|
|
1673
|
+
max_depth: opts.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
1674
|
+
});
|
|
1675
|
+
writeDiagnostics(outputDir, diagnostics);
|
|
1676
|
+
|
|
1677
|
+
// 5. Exit code: non-zero only under --fail-on-error with failures.
|
|
1678
|
+
if (opts.failOnError && counters.failed > 0) {
|
|
1679
|
+
return 1;
|
|
1680
|
+
}
|
|
1681
|
+
return 0;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/**
|
|
1685
|
+
* Legacy single-file passthrough. Implemented by task 2.2; a working forward is provided here so
|
|
1686
|
+
* dispatch keeps the pre-upgrade behavior.
|
|
1687
|
+
*
|
|
1688
|
+
* @param {string[]} argv the original arguments to forward to php-parse
|
|
1689
|
+
*/
|
|
1690
|
+
export function runLegacyPassthrough(argv) {
|
|
37
1691
|
const cwd = process.env.ATOM_CWD || process.cwd();
|
|
38
|
-
|
|
39
|
-
|
|
1692
|
+
// Prepend the php-parse bin so the spawned command is `php <php-parse-bin> <all original flags>`.
|
|
1693
|
+
// Using splice(0, 1, bin) would drop the first forwarded flag (e.g. --with-recovery); prepend
|
|
1694
|
+
// instead so every flag chen forwards is preserved.
|
|
1695
|
+
const forwarded = [resolvePhpParseBin(), ...argv];
|
|
1696
|
+
spawnSync(process.env.PHP_CMD || "php", forwarded, {
|
|
40
1697
|
encoding: "utf-8",
|
|
41
1698
|
cwd,
|
|
42
1699
|
stdio: "inherit",
|
|
43
1700
|
stderr: "inherit",
|
|
44
1701
|
env: process.env,
|
|
45
|
-
timeout:
|
|
1702
|
+
timeout: spawnTimeout()
|
|
46
1703
|
});
|
|
1704
|
+
return 0;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
/**
|
|
1708
|
+
* Top-level dispatch (design §2.1).
|
|
1709
|
+
* - If PHP is missing, warn and return a non-success result.
|
|
1710
|
+
* - Handle `--version`, `--parser-info`, `--help`.
|
|
1711
|
+
* - Reject an unsupported `--target-version`, emitting no AST and returning non-zero.
|
|
1712
|
+
* - Dispatch to batch mode when `-i/--input` is present, else legacy passthrough.
|
|
1713
|
+
*
|
|
1714
|
+
* `runBatch` is async, so `main` is async too and resolves to the batch exit code; the synchronous
|
|
1715
|
+
* paths (`--version`, `--parser-info`, `--help`, missing PHP, invalid target, legacy passthrough)
|
|
1716
|
+
* resolve immediately.
|
|
1717
|
+
*
|
|
1718
|
+
* @param {string[]} argv arguments sliced past node + script
|
|
1719
|
+
* @returns {Promise<number | boolean>} exit code, or false when PHP is unavailable
|
|
1720
|
+
*/
|
|
1721
|
+
export async function main(argv) {
|
|
1722
|
+
const opts = parseArgs(argv);
|
|
1723
|
+
|
|
1724
|
+
// `--version` prints exactly the string `--parser-info` reports and exits 0. This is checked
|
|
1725
|
+
// before the PHP presence gate so version reporting works without a PHP runtime.
|
|
1726
|
+
if (opts.showVersion) {
|
|
1727
|
+
console.log(GENERATOR_VERSION);
|
|
1728
|
+
return 0;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// `--parser-info` reports capability (and, when PHP is missing, says so and exits non-zero).
|
|
1732
|
+
if (opts.parserInfo) {
|
|
1733
|
+
return printParserInfo();
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (opts.help) {
|
|
1737
|
+
printUsage();
|
|
1738
|
+
return 0;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
if (!detectPhp()) {
|
|
1742
|
+
console.warn("PHP is not installed!");
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Reject an unsupported target grammar before doing any work: emit no AST, exit non-zero.
|
|
1747
|
+
if (opts.invalidTargetVersion !== undefined) {
|
|
1748
|
+
console.error(
|
|
1749
|
+
`Unsupported --target-version '${opts.invalidTargetVersion}'. Supported grammars: ${SUPPORTED_TARGET_VERSIONS.join(", ")}.`
|
|
1750
|
+
);
|
|
1751
|
+
return 1;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (opts.input !== undefined) {
|
|
1755
|
+
return runBatch(opts);
|
|
1756
|
+
}
|
|
1757
|
+
return runLegacyPassthrough(argv);
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Only run when invoked directly (not when imported by tests).
|
|
1761
|
+
//
|
|
1762
|
+
// A plain `fileURLToPath(url) === process.argv[1]` comparison breaks under a global symlink
|
|
1763
|
+
// install: the bin on PATH (e.g. /opt/homebrew/bin/phpastgen) is a symlink, so process.argv[1] is
|
|
1764
|
+
// the symlink path while fileURLToPath(url) is the real file path, and the equality is false — so
|
|
1765
|
+
// main() never runs. Resolve symlinks on BOTH sides (realpathSync) before comparing so a symlinked
|
|
1766
|
+
// invocation is still recognized as the entry point, while an `import(...)` of this module (where
|
|
1767
|
+
// argv[1] points at the test runner) does not trigger main().
|
|
1768
|
+
export function isMainModule() {
|
|
1769
|
+
if (!process.argv[1]) {
|
|
1770
|
+
return false;
|
|
1771
|
+
}
|
|
1772
|
+
try {
|
|
1773
|
+
const invoked = realpathSync(process.argv[1]);
|
|
1774
|
+
const self = realpathSync(fileURLToPath(url));
|
|
1775
|
+
return invoked === self;
|
|
1776
|
+
} catch {
|
|
1777
|
+
return false;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
if (isMainModule()) {
|
|
1782
|
+
Promise.resolve(main(process.argv.slice(2)))
|
|
1783
|
+
.then((rc) => {
|
|
1784
|
+
if (typeof rc === "number") {
|
|
1785
|
+
process.exitCode = rc;
|
|
1786
|
+
} else if (rc === false) {
|
|
1787
|
+
process.exitCode = 1;
|
|
1788
|
+
}
|
|
1789
|
+
})
|
|
1790
|
+
.catch((err) => {
|
|
1791
|
+
console.error(err?.stack || String(err));
|
|
1792
|
+
process.exitCode = 1;
|
|
1793
|
+
});
|
|
47
1794
|
}
|
|
48
|
-
main(process.argv.slice(2));
|