backend-skeleton 1.5.0 → 1.6.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.
Files changed (37) hide show
  1. package/README.md +113 -8
  2. package/bin/bskel.mjs +331 -53
  3. package/contracts/openapi.mjs +125 -18
  4. package/handles/providers/java-spring/ast-bridge.mjs +85 -1
  5. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
  6. package/handles/providers/java-spring/emit.mjs +126 -6
  7. package/handles/providers/java-spring/plan.mjs +220 -74
  8. package/handles/providers/java-spring/source-splice.mjs +477 -0
  9. package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
  10. package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
  11. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
  12. package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
  13. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
  14. package/lib/attest.mjs +59 -1
  15. package/lib/cli.mjs +79 -7
  16. package/lib/doctor.mjs +23 -0
  17. package/lib/exit-codes.mjs +17 -0
  18. package/lib/gate-definitions.mjs +65 -2
  19. package/lib/gate-export.mjs +199 -0
  20. package/lib/impact-export-graphify.mjs +145 -0
  21. package/lib/impact-graph.mjs +194 -0
  22. package/lib/impact-surface.mjs +158 -0
  23. package/lib/impact.mjs +286 -0
  24. package/lib/patch-kinds.mjs +24 -0
  25. package/lib/repo.mjs +46 -0
  26. package/lib/workflow.mjs +16 -0
  27. package/package.json +1 -1
  28. package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
  29. package/schemas/gate-attestation.schema.json +6 -1
  30. package/schemas/gate-export.schema.json +530 -22
  31. package/schemas/handles-plan.schema.json +32 -0
  32. package/schemas/impact-baseline.schema.json +59 -0
  33. package/schemas/impact-graph.schema.json +53 -0
  34. package/schemas/impact-report.schema.json +86 -0
  35. package/schemas/impact-resolution.schema.json +33 -0
  36. package/schemas/java-source-splice.schema.json +84 -0
  37. package/schemas/patch-transaction.schema.json +87 -2
@@ -54,6 +54,24 @@ const SCHEMA_REF_PREFIX = '#/components/schemas/';
54
54
  // cyclic component, resolved into a top-level `$defs` map -- see inlineSchema()). Distinct from
55
55
  // SCHEMA_REF_PREFIX, which is what this module RESOLVES on the way IN from a source document.
56
56
  const DEFS_REF_PREFIX = '#/$defs/';
57
+ // D-openapi-request-response-refs: the two remaining Reference Object forms a real OpenAPI 3.1
58
+ // document can use besides a schema-level $ref -- a whole Request Body Object
59
+ // (`operation.requestBody: {$ref: "#/components/requestBodies/<Name>"}`) or a whole Response
60
+ // Object (`operation.responses.<status>: {$ref: "#/components/responses/<Name>"}`). Previously
61
+ // out of scope entirely (see the stale comments this change updates); see DECISIONS.md for the
62
+ // real-data finding that reopened it.
63
+ const REQUEST_BODY_REF_PREFIX = '#/components/requestBodies/';
64
+ const RESPONSE_REF_PREFIX = '#/components/responses/';
65
+ // D-openapi-request-response-refs: no dedicated real-corpus size measurement yet for these two
66
+ // specific component maps (unlike MAX_COMPONENT_SCHEMAS above, which has one) -- every real
67
+ // document measured so far (RealWorld/Conduit's own official spec; Team-IZ-Backend;
68
+ // polarsource/polar, neither of which uses this ref form at all) has far fewer requestBodies/
69
+ // responses components than schemas components. Reuses MAX_SECURITY_SCHEMES' own "small named
70
+ // component map, conservative round default" precedent rather than MAX_COMPONENT_SCHEMAS' much
71
+ // larger one. Revisit with real corpus data once a document large enough to test it exists, per
72
+ // this project's own data-first-numerics discipline.
73
+ const MAX_COMPONENT_REQUEST_BODIES = 512;
74
+ const MAX_COMPONENT_RESPONSES = 512;
57
75
 
58
76
  // A3: response/error JSON Schema projection. Reuses every inlineSchema() defense above
59
77
  // unchanged (keyword/format whitelist, MAX_SCHEMA_DEPTH/NODES/PATTERN_LENGTH) -- measured by
@@ -488,6 +506,38 @@ export function loadOpenApiDocument(filePath) {
488
506
  // route is ambiguous even within the document itself). `$ref` path items are skipped, not
489
507
  // resolved (out of scope for this vertical slice -- see DECISIONS.md).
490
508
  //
509
+ // D-openapi-request-response-refs: resolves ONE level of "#/components/requestBodies/<Name>" or
510
+ // "#/components/responses/<Name>" indirection against the document's own componentRequestBodies/
511
+ // componentResponses map -- the two remaining Reference Object forms a real OpenAPI 3.1 document
512
+ // uses besides a schema-level $ref (RealWorld/Conduit's own official spec uses this pervasively,
513
+ // for every single operation; see DECISIONS.md). Not a schema-level resolution (inlineSchema()'s
514
+ // own job, unchanged) -- this operates one level up, on the Request Body Object / Response Object
515
+ // itself, and returns a plain object exactly as if the source document had inlined it directly.
516
+ // - `node` not an object, or an object with no `$ref` key: returned unchanged (the overwhelming
517
+ // common case -- most operations never use this indirection at all).
518
+ // - a `$ref` with any sibling key, an unsupported prefix, a name failing COMPONENT_SCHEMA_NAME_RE
519
+ // (same prototype-pollution-safe whitelist inlineSchema() already applies one layer down), or a
520
+ // name not present in `componentMap`: resolution fails -- returns `null`, the exact same "skip
521
+ // gracefully, never fabricate" outcome this module already used for a genuinely bodyless
522
+ // operation or an undocumented response status, so every existing caller's null-handling already
523
+ // covers it with no further change.
524
+ // Deliberately NOT recursive -- no real document measured (RealWorld's official spec, its Java
525
+ // and Node reference implementations, Team-IZ-Backend, polarsource/polar) ever chains a
526
+ // requestBodies/responses $ref to ANOTHER requestBodies/responses $ref; a document that did would
527
+ // fail closed here rather than this module guessing at a chain no real case has ever needed.
528
+ function resolveComponentObjectRef(node, refPrefix, componentMap) {
529
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
530
+ if (!Object.hasOwn(node, '$ref')) return node;
531
+ const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
532
+ if (siblingKeys.length > 0) return null;
533
+ const ref = node['$ref'];
534
+ if (typeof ref !== 'string' || !ref.startsWith(refPrefix)) return null;
535
+ const name = ref.slice(refPrefix.length);
536
+ if (name.includes('~') || name.includes('%') || !COMPONENT_SCHEMA_NAME_RE.test(name)) return null;
537
+ const resolved = componentMap.get(name);
538
+ return resolved && typeof resolved === 'object' && !Array.isArray(resolved) ? resolved : null;
539
+ }
540
+
491
541
  // A2: also builds `componentSchemas` (Map<name, schemaNode>, from `doc.components.schemas`) and
492
542
  // retains each operation's raw `requestBody` node on its `entry` -- both were previously
493
543
  // discarded entirely (A1 only needed {verb, path, operationId}). Indexing stays O(top-level
@@ -503,10 +553,20 @@ export function indexOpenApiDocument(doc) {
503
553
  // security scheme name becomes an object key downstream, in the contract's own root-level
504
554
  // sourceSecuritySchemes).
505
555
  const securitySchemes = new Map();
556
+ // D-openapi-request-response-refs: Map<name, Request Body Object | Response Object>, same
557
+ // "Map, never a plain object" + COMPONENT_SCHEMA_NAME_RE whitelist reasoning as
558
+ // componentSchemas/securitySchemes above -- resolveComponentObjectRef() below is the one
559
+ // consumer, used to dereference an operation's own requestBody/responses entries in place,
560
+ // once, right here at index time, so every downstream function keeps reading a plain
561
+ // (already-resolved) object exactly as it always has.
562
+ const componentRequestBodies = new Map();
563
+ const componentResponses = new Map();
506
564
  const stats = {
507
565
  path_count: 0, operation_count: 0, skipped_path_refs: 0, rejected_operation_ids: 0,
508
566
  component_schema_count: 0, rejected_component_schemas: 0,
509
567
  security_scheme_count: 0, rejected_security_schemes: 0,
568
+ component_request_body_count: 0, rejected_component_request_bodies: 0,
569
+ component_response_count: 0, rejected_component_responses: 0,
510
570
  };
511
571
 
512
572
  const openapiVersion = typeof doc.openapi === 'string' ? doc.openapi : null;
@@ -545,9 +605,39 @@ export function indexOpenApiDocument(doc) {
545
605
  stats.security_scheme_count = securitySchemes.size;
546
606
  }
547
607
 
608
+ const rawComponentRequestBodies = rawComponents ? rawComponents.requestBodies : null;
609
+ if (rawComponentRequestBodies && typeof rawComponentRequestBodies === 'object' && !Array.isArray(rawComponentRequestBodies)) {
610
+ const names = Object.keys(rawComponentRequestBodies);
611
+ if (names.length > MAX_COMPONENT_REQUEST_BODIES) {
612
+ return { ok: false, error: `OpenAPI document has ${names.length} component request bodies, exceeds the ${MAX_COMPONENT_REQUEST_BODIES}-request-body limit` };
613
+ }
614
+ for (const name of names) {
615
+ const value = rawComponentRequestBodies[name];
616
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) continue;
617
+ if (!COMPONENT_SCHEMA_NAME_RE.test(name)) { stats.rejected_component_request_bodies++; continue; }
618
+ componentRequestBodies.set(name, value);
619
+ }
620
+ stats.component_request_body_count = componentRequestBodies.size;
621
+ }
622
+
623
+ const rawComponentResponses = rawComponents ? rawComponents.responses : null;
624
+ if (rawComponentResponses && typeof rawComponentResponses === 'object' && !Array.isArray(rawComponentResponses)) {
625
+ const names = Object.keys(rawComponentResponses);
626
+ if (names.length > MAX_COMPONENT_RESPONSES) {
627
+ return { ok: false, error: `OpenAPI document has ${names.length} component responses, exceeds the ${MAX_COMPONENT_RESPONSES}-response limit` };
628
+ }
629
+ for (const name of names) {
630
+ const value = rawComponentResponses[name];
631
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) continue;
632
+ if (!COMPONENT_SCHEMA_NAME_RE.test(name)) { stats.rejected_component_responses++; continue; }
633
+ componentResponses.set(name, value);
634
+ }
635
+ stats.component_response_count = componentResponses.size;
636
+ }
637
+
548
638
  const paths = doc.paths;
549
639
  if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
550
- return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, stats, servers: [], openapiVersion, schemaDialectSupported };
640
+ return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, componentRequestBodies, componentResponses, stats, servers: [], openapiVersion, schemaDialectSupported };
551
641
  }
552
642
 
553
643
  const pathKeys = Object.keys(paths);
@@ -589,17 +679,32 @@ export function indexOpenApiDocument(doc) {
589
679
  }
590
680
 
591
681
  // A2: raw requestBody node retained verbatim (bounded by the document's own
592
- // MAX_DOCUMENT_BYTES cap -- no new read, no new size limit needed). A `$ref` requestBody
593
- // (`#/components/requestBodies/*`) is out of scope -- reconcileModule treats it as "no
594
- // body to project" rather than resolving it, same as a genuinely bodyless operation.
595
- const requestBody = typeof operation.requestBody === 'object' && operation.requestBody !== null && !Array.isArray(operation.requestBody)
682
+ // MAX_DOCUMENT_BYTES cap -- no new read, no new size limit needed).
683
+ // D-openapi-request-response-refs: a `$ref` requestBody (`#/components/requestBodies/*`)
684
+ // is resolved HERE, once, at index time -- every downstream function (applyRequestBodySchema,
685
+ // applyRequestMediaTypes) keeps reading `entry.requestBody` as a plain Request Body Object
686
+ // exactly as before, unaware whether the source document inlined it or referenced it.
687
+ // resolveComponentObjectRef() falls back to null on any failure to resolve (missing/
688
+ // malformed $ref target, name not in componentRequestBodies, unsupported prefix, a sibling
689
+ // key alongside $ref) -- the same "skip gracefully, never fabricate" posture this module
690
+ // already takes for a genuinely bodyless operation.
691
+ const rawRequestBody = typeof operation.requestBody === 'object' && operation.requestBody !== null && !Array.isArray(operation.requestBody)
596
692
  ? operation.requestBody
597
693
  : null;
694
+ const requestBody = resolveComponentObjectRef(rawRequestBody, REQUEST_BODY_REF_PREFIX, componentRequestBodies);
598
695
  // A3: raw responses map retained verbatim, same "no new read, no new size cap" reasoning
599
696
  // as requestBody above -- bounded by MAX_DOCUMENT_BYTES already.
600
- const responses = typeof operation.responses === 'object' && operation.responses !== null && !Array.isArray(operation.responses)
697
+ // D-openapi-request-response-refs: each STATUS's own value is independently resolved the
698
+ // same way requestBody is above -- a Response Object `$ref` (`#/components/responses/*`)
699
+ // resolves to the real object; an unresolvable one becomes `null` at that status key,
700
+ // which every existing downstream consumer (projectResponseSchemas, applyPerStatusResponses)
701
+ // already treats as "nothing documented for this status", not a failure.
702
+ const rawResponses = typeof operation.responses === 'object' && operation.responses !== null && !Array.isArray(operation.responses)
601
703
  ? operation.responses
602
704
  : null;
705
+ const responses = rawResponses
706
+ ? Object.fromEntries(Object.keys(rawResponses).map((status) => [status, resolveComponentObjectRef(rawResponses[status], RESPONSE_REF_PREFIX, componentResponses)]))
707
+ : null;
603
708
  // A7: raw parameters/security/summary/tags retained verbatim, same "no new read, no new
604
709
  // size cap" reasoning as requestBody/responses above. `security` is deliberately
605
710
  // Array.isArray-checked rather than truthy-checked -- a real, explicit `[]` (11/148 real
@@ -632,7 +737,7 @@ export function indexOpenApiDocument(doc) {
632
737
  ? doc.servers.filter((s) => s && typeof s.url === 'string').map((s) => s.url)
633
738
  : [];
634
739
 
635
- return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, stats, servers, openapiVersion, schemaDialectSupported };
740
+ return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, componentRequestBodies, componentResponses, stats, servers, openapiVersion, schemaDialectSupported };
636
741
  }
637
742
 
638
743
  // `S` (scan path) always starts with "/" (scanners/adapters/java-spring.mjs's joinPath guarantees
@@ -1015,9 +1120,12 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
1015
1120
  // let alone body shape. `docEntry` is the OpenAPI-side entry (from byOperationId or byRoute) whose
1016
1121
  // `.requestBody` indexOpenApiDocument() retained. Never treats "nothing to project" as a failure --
1017
1122
  // only an actual unresolvable schema increments schema_unresolved / sets schemaUnresolvedReason.
1123
+ // D-openapi-request-response-refs: `docEntry.requestBody` is never `{$ref: ...}` by the time it
1124
+ // reaches here -- indexOpenApiDocument() already resolved a `#/components/requestBodies/*` $ref
1125
+ // (or fell back to null if it couldn't) at index time, once, for every consumer.
1018
1126
  function applyRequestBodySchema(result, docEntry, componentSchemas, stats, includeFieldDocs) {
1019
1127
  const requestBody = docEntry.requestBody;
1020
- if (!requestBody || Object.hasOwn(requestBody, '$ref')) {
1128
+ if (!requestBody) {
1021
1129
  stats.schema_none++;
1022
1130
  return;
1023
1131
  }
@@ -1471,16 +1579,13 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
1471
1579
  for (const key of Object.keys(responses)) {
1472
1580
  if (!RESPONSE_STATUS_KEY_RE.test(key)) continue; // not a legal status key -- dropped, not a failure
1473
1581
  const resp = responses[key];
1582
+ // D-openapi-request-response-refs: `resp` is never `{$ref: ...}` by the time it reaches here
1583
+ // -- indexOpenApiDocument() already resolved a `#/components/responses/*` $ref (or replaced
1584
+ // it with null if it couldn't) at index time, once, for every consumer. A genuinely
1585
+ // unresolvable ref lands here as `null` and is skipped by the check below, same as before:
1586
+ // still no fabricated description-only entry carrying the synthetic
1587
+ // PER_STATUS_NO_DESCRIPTION_STANDIN for a status this document didn't really document.
1474
1588
  if (typeof resp !== 'object' || resp === null || Array.isArray(resp)) continue;
1475
- // A8 follow-up (Codex review): a Response Object `$ref` (`components.responses.<Name>`, legal
1476
- // per the official 3.1 meta-schema's `response-or-reference`) is not resolved here -- 0 real
1477
- // occurrences against the Team-IZ-Backend oracle (694 response objects, 0 $ref), named rather
1478
- // than built, same "don't build for zero real cases" discipline as non-json-response-schemas/
1479
- // response-headers below. Skipping is the fail-closed choice: falling through with
1480
- // resp.description/resp.content both undefined would produce a description-only entry carrying
1481
- // the synthetic PER_STATUS_NO_DESCRIPTION_STANDIN as if the source truly documented this status
1482
- // with no description -- false. A referenced response is simply omitted for this status.
1483
- if (typeof resp.$ref === 'string') continue;
1484
1589
 
1485
1590
  const entry = {};
1486
1591
  if (typeof resp.description === 'string' && resp.description.length > 0 && resp.description !== PER_STATUS_NO_DESCRIPTION_STANDIN) {
@@ -1528,6 +1633,8 @@ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, sche
1528
1633
  // schemas/feature-contract.schema.json too). Gated on schemaProjectionEnabled for the same reason
1529
1634
  // as applyPerStatusResponses above -- a media-type schema resolves through the same inlineSchema()
1530
1635
  // path.
1636
+ // D-openapi-request-response-refs: `docEntry.requestBody` is never `{$ref: ...}` by the time it
1637
+ // reaches here -- see applyRequestBodySchema's own identical note.
1531
1638
  function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled, includeFieldDocs) {
1532
1639
  if (!schemaProjectionEnabled) {
1533
1640
  result.requestMediaTypesSkippedDialect = true;
@@ -1535,7 +1642,7 @@ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schem
1535
1642
  return;
1536
1643
  }
1537
1644
  const requestBody = docEntry.requestBody;
1538
- if (!requestBody || typeof requestBody !== 'object' || Array.isArray(requestBody) || Object.hasOwn(requestBody, '$ref')) {
1645
+ if (!requestBody || typeof requestBody !== 'object' || Array.isArray(requestBody)) {
1539
1646
  stats.request_media_types_none++;
1540
1647
  return;
1541
1648
  }
@@ -3,10 +3,12 @@
3
3
  // `bskel handles plan`, never a hard dependency of the base install, never invoked silently.
4
4
  // See DECISIONS.md.
5
5
  import fs from 'node:fs';
6
+ import os from 'node:os';
6
7
  import path from 'node:path';
7
- import { execFile, execFileSync } from 'node:child_process';
8
+ import { execFile, execFileSync, spawn } from 'node:child_process';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { promisify } from 'node:util';
11
+ import { randomUUID } from 'node:crypto';
10
12
 
11
13
  const execFileAsync = promisify(execFile);
12
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -57,3 +59,85 @@ export async function runAstClassify(dtoFilePath, srcRoot) {
57
59
  }
58
60
  return JSON.parse(jsonLine);
59
61
  }
62
+
63
+ // D-java-source-splice: writes `locators` (each {type_fqn, member_kind, member_name,
64
+ // erased_param_types}) to a scratch temp file in the plain, delimiter-free line format
65
+ // Main.java's readLocators() expects (deliberately not JSON -- the helper has no JSON parser
66
+ // dependency and none of these fields can ever contain a newline), invokes the helper's new
67
+ // "locate" mode, and returns its parsed `{topLevelTypes, results}`. The temp file is always
68
+ // removed, even on failure.
69
+ export async function runAstLocate(javaFilePath, srcRoot, locators) {
70
+ const detection = detectAstHelperAvailable();
71
+ if (!detection.available) {
72
+ throw new Error(detection.reason);
73
+ }
74
+ const lines = [String(locators.length)];
75
+ for (const loc of locators) {
76
+ lines.push(loc.type_fqn, loc.member_kind, loc.member_name, String((loc.erased_param_types ?? []).length));
77
+ for (const p of loc.erased_param_types ?? []) lines.push(p);
78
+ }
79
+ const tmpFile = path.join(os.tmpdir(), `bskel-ast-locators-${randomUUID()}.txt`);
80
+ fs.writeFileSync(tmpFile, `${lines.join('\n')}\n`);
81
+ try {
82
+ console.error('bskel: running the AST helper (locate mode)...');
83
+ let stdout;
84
+ try {
85
+ ({ stdout } = await execFileAsync(
86
+ gradlewPath(),
87
+ ['run', '--console=plain', '-q', `--args="locate" "${javaFilePath}" "${srcRoot}" "${tmpFile}"`],
88
+ { cwd: HELPER_DIR, maxBuffer: 16 * 1024 * 1024 },
89
+ ));
90
+ } catch (err) {
91
+ throw new Error(`AST helper (locate) invocation failed: ${err.stderr || err.message}`);
92
+ }
93
+ const jsonLine = stdout.split('\n').find((line) => line.trim().startsWith('{'));
94
+ if (!jsonLine) {
95
+ throw new Error(`AST helper (locate) produced no parseable JSON output:\n${stdout}`);
96
+ }
97
+ return JSON.parse(jsonLine);
98
+ } finally {
99
+ fs.rmSync(tmpFile, { force: true });
100
+ }
101
+ }
102
+
103
+ // D-java-source-splice: a plain syntax gate for RENDERED content that has not been written to
104
+ // disk yet -- the helper's "parse -" mode reads `sourceText` from stdin (no temp file, no
105
+ // classpath/src-root needed at all for a syntax-only check) and returns {ok:true} or
106
+ // {ok:false, problems:[...]}. Uses `spawn` directly, NOT the promisified `execFile` used
107
+ // elsewhere in this file -- confirmed live that Node's async execFile has no `input` option at
108
+ // all (only execFileSync does); passing one is silently ignored and the child process hangs
109
+ // reading from this process's OWN inherited stdin instead of the string ever intended for it.
110
+ export async function runAstParse(sourceText) {
111
+ const detection = detectAstHelperAvailable();
112
+ if (!detection.available) {
113
+ throw new Error(detection.reason);
114
+ }
115
+ return new Promise((resolve, reject) => {
116
+ const child = spawn(gradlewPath(), ['run', '--console=plain', '-q', '--args="parse" "-"'], {
117
+ cwd: HELPER_DIR,
118
+ });
119
+ let stdout = '';
120
+ let stderr = '';
121
+ child.stdout.on('data', (d) => { stdout += d; });
122
+ child.stderr.on('data', (d) => { stderr += d; });
123
+ child.on('error', (err) => reject(new Error(`AST helper (parse) invocation failed: ${err.message}`)));
124
+ child.on('close', (code) => {
125
+ if (code !== 0) {
126
+ reject(new Error(`AST helper (parse) invocation failed (exit ${code}): ${stderr || stdout}`));
127
+ return;
128
+ }
129
+ const jsonLine = stdout.split('\n').find((line) => line.trim().startsWith('{'));
130
+ if (!jsonLine) {
131
+ reject(new Error(`AST helper (parse) produced no parseable JSON output:\n${stdout}`));
132
+ return;
133
+ }
134
+ try {
135
+ resolve(JSON.parse(jsonLine));
136
+ } catch (err) {
137
+ reject(new Error(`AST helper (parse) produced unparseable JSON: ${err.message}\n${stdout}`));
138
+ }
139
+ });
140
+ child.stdin.write(sourceText);
141
+ child.stdin.end();
142
+ });
143
+ }