@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/phpastgen.js CHANGED
@@ -1,8 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { spawnSync } from "node:child_process";
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
- let PHP_PARSER_BIN =
25
- process.env.PHP_PARSER_BIN || join(PLUGINS_HOME, "bin", "php-parse");
26
- if (
27
- !existsSync(PHP_PARSER_BIN) &&
28
- existsSync(join(PARENT_NODE_PLUGINS_HOME, "bin", "php-parse"))
29
- ) {
30
- PHP_PARSER_BIN = join(PARENT_NODE_PLUGINS_HOME, "bin", "php-parse");
31
- }
32
- function main(argvs) {
33
- if (!detectPhp()) {
34
- console.warn("PHP is not installed!");
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
- argvs.splice(0, 1, PHP_PARSER_BIN);
39
- spawnSync(process.env.PHP_CMD || "php", argvs, {
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: process.env.ATOM_TIMEOUT || process.env.ASTGEN_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));