diff --git a/.agents/skills/better-logging/references/runtime-patterns-electron.md b/.agents/skills/better-logging/references/runtime-patterns-electron.md index 208a104..7e4a05f 100644 --- a/.agents/skills/better-logging/references/runtime-patterns-electron.md +++ b/.agents/skills/better-logging/references/runtime-patterns-electron.md @@ -17,57 +17,57 @@ Wrap the entrypoint once, enrich inside the operation, and finalize in `finally` ```ts type OperationOutcome = { - appVersion: string - actor?: { idHash?: string | undefined; type: string } | undefined - completedAt?: string | undefined - correlationId?: string | undefined - durationMs?: number | undefined - environment: 'development' | 'preview' | 'production' - errorCode?: null | string | undefined - errorMessage?: null | string | undefined - gitCommit?: string | undefined - metrics?: Record | undefined - operationId: string - operationName: string + appVersion: string; + actor?: { idHash?: string | undefined; type: string } | undefined; + completedAt?: string | undefined; + correlationId?: string | undefined; + durationMs?: number | undefined; + environment: "development" | "preview" | "production"; + errorCode?: null | string | undefined; + errorMessage?: null | string | undefined; + gitCommit?: string | undefined; + metrics?: Record | undefined; + operationId: string; + operationName: string; operationType: - | 'ipc_command' - | 'trpc_mutation' - | 'trpc_query' - | 'background_job' - | 'startup_step' - | 'queue_consumer' - resource?: { id?: string | undefined; type: string } | undefined - retryCount: number - rollout?: Record | undefined - sessionId?: string | undefined - startedAt: string - statusCode?: number | undefined - success: boolean - trigger?: 'manual' | 'startup' | 'background' | 'retry' | 'auto' | undefined -} + | "ipc_command" + | "trpc_mutation" + | "trpc_query" + | "background_job" + | "startup_step" + | "queue_consumer"; + resource?: { id?: string | undefined; type: string } | undefined; + retryCount: number; + rollout?: Record | undefined; + sessionId?: string | undefined; + startedAt: string; + statusCode?: number | undefined; + success: boolean; + trigger?: "manual" | "startup" | "background" | "retry" | "auto" | undefined; +}; type OperationOutcomeSeed = Omit< OperationOutcome, - | 'completedAt' - | 'durationMs' - | 'errorCode' - | 'errorMessage' - | 'operationId' - | 'startedAt' - | 'success' -> + | "completedAt" + | "durationMs" + | "errorCode" + | "errorMessage" + | "operationId" + | "startedAt" + | "success" +>; // Put these helpers in one shared file such as src/backend/lib/outcome.ts. const classifyError = (error: unknown): string => { // Classify domain errors before they reach this function. // Returning error.name is a last-resort fallback; prefer stable codes // like "update_feed_http_404" at the call site when possible. - return error instanceof Error ? error.name : 'unknown_error' -} + return error instanceof Error ? error.name : "unknown_error"; +}; const formatErrorMessage = (error: unknown): string => { - return error instanceof Error ? error.message : 'Unknown error' -} + return error instanceof Error ? error.message : "Unknown error"; +}; // Keep camelCase in memory and map to storage casing at the persistence boundary. const toStoredOutcome = (outcome: OperationOutcome) => ({ @@ -92,43 +92,43 @@ const toStoredOutcome = (outcome: OperationOutcome) => ({ success: outcome.success, trigger: outcome.trigger, actor: outcome.actor, -}) +}); const persistOutcome = async (outcome: OperationOutcome): Promise => { // Replace this with a Prisma, SQLite, or analytics-sink write in your app. // Example: await prisma.operationOutcome.create({ data: toStoredOutcome(outcome) }) - void outcome -} + void outcome; +}; const withOutcome = async ( seed: OperationOutcomeSeed, run: (outcome: OperationOutcome) => Promise, ): Promise => { - const startMs = Date.now() + const startMs = Date.now(); const outcome: OperationOutcome = { ...seed, operationId: crypto.randomUUID(), startedAt: new Date(startMs).toISOString(), errorCode: null, success: false, - } + }; try { - const result = await run(outcome) - outcome.success = true - return result + const result = await run(outcome); + outcome.success = true; + return result; } catch (error) { - outcome.errorCode = classifyError(error) - outcome.errorMessage = formatErrorMessage(error) - throw error + outcome.errorCode = classifyError(error); + outcome.errorMessage = formatErrorMessage(error); + throw error; } finally { - outcome.completedAt = new Date().toISOString() - outcome.durationMs = Date.now() - startMs + outcome.completedAt = new Date().toISOString(); + outcome.durationMs = Date.now() - startMs; await persistOutcome(outcome).catch((persistError: unknown) => { - console.error('[outcome] failed to persist outcome', persistError) - }) + console.error("[outcome] failed to persist outcome", persistError); + }); } -} +}; ``` If your store supports camelCase cleanly, standardize on camelCase end-to-end instead of mapping. The important rule is one casing per layer, not a forced snake_case database. diff --git a/.agents/skills/better-logging/references/runtime-patterns-node.md b/.agents/skills/better-logging/references/runtime-patterns-node.md index 862a264..74729c7 100644 --- a/.agents/skills/better-logging/references/runtime-patterns-node.md +++ b/.agents/skills/better-logging/references/runtime-patterns-node.md @@ -8,41 +8,41 @@ Instrument one outcome per request or per important handler. ```ts type OperationOutcome = { - appVersion: string - actor?: { idHash?: string | undefined; type: string } | undefined - completedAt?: string | undefined - correlationId?: string | undefined - durationMs?: number | undefined - environment: 'development' | 'preview' | 'production' | string - errorCode?: null | string | undefined - errorMessage?: null | string | undefined - gitCommit?: string | undefined - metrics?: Record | undefined - operationId: string - operationName: string - operationType: 'http_request' | 'queue_consumer' | 'cron_run' - resource?: { id?: string | undefined; type: string } | undefined - retryCount: number - rollout?: Record | undefined - sessionId?: string | undefined - startedAt: string - statusCode?: number | undefined - success: boolean - trigger?: 'manual' | 'startup' | 'background' | 'retry' | 'auto' | undefined -} + appVersion: string; + actor?: { idHash?: string | undefined; type: string } | undefined; + completedAt?: string | undefined; + correlationId?: string | undefined; + durationMs?: number | undefined; + environment: "development" | "preview" | "production" | string; + errorCode?: null | string | undefined; + errorMessage?: null | string | undefined; + gitCommit?: string | undefined; + metrics?: Record | undefined; + operationId: string; + operationName: string; + operationType: "http_request" | "queue_consumer" | "cron_run"; + resource?: { id?: string | undefined; type: string } | undefined; + retryCount: number; + rollout?: Record | undefined; + sessionId?: string | undefined; + startedAt: string; + statusCode?: number | undefined; + success: boolean; + trigger?: "manual" | "startup" | "background" | "retry" | "auto" | undefined; +}; const startOutcome = ( startMs: number, seed: Omit< OperationOutcome, - | 'completedAt' - | 'durationMs' - | 'errorCode' - | 'errorMessage' - | 'operationId' - | 'startedAt' - | 'statusCode' - | 'success' + | "completedAt" + | "durationMs" + | "errorCode" + | "errorMessage" + | "operationId" + | "startedAt" + | "statusCode" + | "success" >, ): OperationOutcome => ({ ...seed, @@ -50,63 +50,63 @@ const startOutcome = ( operationId: crypto.randomUUID(), startedAt: new Date(startMs).toISOString(), success: false, -}) +}); const classifyError = (error: unknown): string => { // Classify domain errors before they reach this function. // Returning error.name is a last-resort fallback; prefer stable codes // like "checkout_card_declined" at the call site when possible. - return error instanceof Error ? error.name : 'unknown_error' -} + return error instanceof Error ? error.name : "unknown_error"; +}; const formatErrorMessage = (error: unknown): string => { - return error instanceof Error ? error.message : 'Unknown error' -} + return error instanceof Error ? error.message : "Unknown error"; +}; const inferStatusCode = (error: unknown): number => { - return error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number' + return error instanceof Error && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode - : 500 -} + : 500; +}; // Put these helpers in a shared file such as src/lib/outcome.ts. const persistOutcome = async (outcome: OperationOutcome): Promise => { - void outcome + void outcome; // Replace this with your real DB or analytics write. -} +}; -app.post('/checkout', async (req, res, next) => { - const startMs = Date.now() - const requestId = req.headers['x-request-id'] +app.post("/checkout", async (req, res, next) => { + const startMs = Date.now(); + const requestId = req.headers["x-request-id"]; const outcome = startOutcome(startMs, { - appVersion: process.env.APP_VERSION ?? 'dev', + appVersion: process.env.APP_VERSION ?? "dev", correlationId: Array.isArray(requestId) ? requestId[0] : requestId, - environment: process.env.NODE_ENV ?? 'development', - operationName: 'checkout.submit', - operationType: 'http_request', + environment: process.env.NODE_ENV ?? "development", + operationName: "checkout.submit", + operationType: "http_request", retryCount: 0, - trigger: 'manual', - }) + trigger: "manual", + }); try { - const result = await runCheckout(req, outcome) - outcome.success = true - outcome.statusCode = 200 - res.json(result) + const result = await runCheckout(req, outcome); + outcome.success = true; + outcome.statusCode = 200; + res.json(result); } catch (error) { - outcome.success = false - outcome.errorCode = classifyError(error) - outcome.errorMessage = formatErrorMessage(error) - outcome.statusCode = inferStatusCode(error) - next(error) + outcome.success = false; + outcome.errorCode = classifyError(error); + outcome.errorMessage = formatErrorMessage(error); + outcome.statusCode = inferStatusCode(error); + next(error); } finally { - outcome.completedAt = new Date().toISOString() - outcome.durationMs = Date.now() - startMs + outcome.completedAt = new Date().toISOString(); + outcome.durationMs = Date.now() - startMs; await persistOutcome(outcome).catch((persistError: unknown) => { - console.error('[outcome] failed to persist outcome', persistError) - }) + console.error("[outcome] failed to persist outcome", persistError); + }); } -}) +}); ``` ## Queues and Workers diff --git a/.agents/skills/conductor-setup/SKILL.md b/.agents/skills/conductor-setup/SKILL.md index 8acbd41..c43d451 100644 --- a/.agents/skills/conductor-setup/SKILL.md +++ b/.agents/skills/conductor-setup/SKILL.md @@ -17,7 +17,7 @@ Use this skill when configuring a repository for Conductor workspaces. When invo - `references/settings-and-migration.md` for settings layers, schemas, supported repository fields, or `conductor.json` migration. - `references/scripts-and-environment.md` for setup/run/archive scripts, shells, variables, concurrency, Spotlight, or caches. - `references/files-layouts-and-troubleshooting.md` for Files to copy, `.worktreeinclude`, monorepos, linked repositories, MCP/privacy, or diagnosis. - Read more than one only when the task crosses those concerns. + Read more than one only when the task crosses those concerns. 3. Apply the selected reference's documented contract. Prefer team settings over machine-local configuration; preserve an existing deliberate script layout; use Conductor variables instead of hard-coded workspace paths, resources, and local ports. 4. Keep secrets and machine-specific credentials out of committed settings. Change MCP/privacy configuration only when asked or required by repository policy. 5. Validate TOML and run the narrowest relevant check for every script changed. Report when the existing setup already satisfies the requested outcome. diff --git a/.agents/skills/create-readme/SKILL.md b/.agents/skills/create-readme/SKILL.md new file mode 100644 index 0000000..686e10d --- /dev/null +++ b/.agents/skills/create-readme/SKILL.md @@ -0,0 +1,21 @@ +--- +name: create-readme +description: 'Create a README.md file for the project' +--- + +## Role + +You're a senior expert software engineer with extensive experience in open source projects. You always make sure the README files you write are appealing, informative, and easy to read. + +## Task + +1. Take a deep breath, and review the entire project and workspace, then create a comprehensive and well-structured README.md file for the project. +2. Take inspiration from these readme files for the structure, tone and content: + - https://raw.githubusercontent.com/Azure-Samples/serverless-chat-langchainjs/refs/heads/main/README.md + - https://raw.githubusercontent.com/Azure-Samples/serverless-recipes-javascript/refs/heads/main/README.md + - https://raw.githubusercontent.com/sinedied/run-on-output/refs/heads/main/README.md + - https://raw.githubusercontent.com/sinedied/smoke/refs/heads/main/README.md +3. Do not overuse emojis, and keep the readme concise and to the point. +4. Do not include sections like "LICENSE", "CONTRIBUTING", "CHANGELOG", etc. There are dedicated files for those sections. +5. Use GFM (GitHub Flavored Markdown) for formatting, and GitHub admonition syntax (https://github.com/orgs/community/discussions/16925) where appropriate. +6. If you find a logo or icon for the project, use it in the readme's header. diff --git a/.agents/skills/review-fix-address-bots/SKILL.md b/.agents/skills/review-fix-address-bots/SKILL.md index ad09fb9..adacb2a 100644 --- a/.agents/skills/review-fix-address-bots/SKILL.md +++ b/.agents/skills/review-fix-address-bots/SKILL.md @@ -13,6 +13,7 @@ Execute these phases in order. The primary agent alone owns judgment, edits, com - Include this boundary in every reviewer prompt: “Operate in read-only mode. You are advisory only. Never modify the workspace or Git/PR state, and never commit or push. Return findings and critique to the primary agent, who makes the final decision.” - Preserve unrelated user changes. Never use reset, automatic stashing, broad staging, history rewriting, or checkpoint commits to clear a dirty tree. - Logging is observational. If it fails, report the failure and continue the safe workflow; never change code, judgment, Git/PR state, or loop limits for telemetry. +- Treat `scripts/review-run-log.mjs` as an opaque executable. Never read its source during this workflow. Invoke its documented commands and use `templates`, `--help`, and the Markdown references; inspect or change its source only when the user explicitly asks to debug or modify the helper. ## 1. Prepare the integrated target @@ -24,15 +25,95 @@ Execute these phases in order. The primary agent alone owns judgment, edits, com 6. Do not let pre-existing staged changes enter a merge commit. If dirty work makes integration unsafe, stop. Resolve conflicts from both branches' intent, surrounding code, and tests; request input for material product, UX, public API, or architecture choices. The integrated tree, conflict resolutions included, is the review target. If integration happens after review starts, invalidate every report and rerun the cohort. 7. Before the first push, resolve authority to create a PR if none exists and the bot phase requires one. Stop before the remote mutation when authority is absent. -## 2. Run independent initial reviews +## Reviewer sessions + +Use the reviewer count, model mix, and reasoning levels explicitly requested by the user. Otherwise use this default cohort for every initial and remediation pass: + +| Reviewer ID | Model | Reasoning | +| ----------- | --------------- | --------- | +| `sol-1` | `gpt-5.6-sol` | `high` | +| `terra-1` | `gpt-5.6-terra` | `high` | +| `luna-1` | `gpt-5.6-luna` | `high` | +| `luna-2` | `gpt-5.6-luna` | `high` | +| `luna-3` | `gpt-5.6-luna` | `high` | + +For a count from one through five, use that order. For another explicit mix, assign stable IDs from model tier plus one-based ordinal. Ask for a mix when a count above five is otherwise underspecified. Normalize native task names from `sol-1` to `sol_1` for deterministic discovery. Keep the raw review prompt, target fingerprint, role boundary, reasoning, and service tier identical across the cohort. Queue over concurrency limits without editing the target. + +### Packet + +Do not send this skill, any reference, helper commands, run-log details, or the primary conversation to a reviewer. Give every reviewer the same self-contained packet containing only the review task: raw user prompt (or default criteria), target SHA and workspace fingerprint, relevant scope/diff or paths, conflict summary if applicable, read-only boundary, and required finding format (file, minimal line range, severity, scenario, rationale). Exclude other reviewers' findings, the primary's conclusions, remediation decisions, and telemetry instructions. + +### Launch and verify + +Prefer a native subagent only when it exposes the exact model, reasoning level, and a stable resumable handle. Launch every initial reviewer with `fork_turns: "none"` and its self-contained packet; do not fork the primary conversation. Verify applied settings from runtime evidence, not requested arguments alone. + +`fork_turns: "none"` does not make an unavailable native model available. If native launch omits an assigned model such as Luna, do not silently substitute or call it unavailable: use the persistent CLI fallback below. If the native schema hides routing fields, check whether the user already configured this fresh-session-only workaround; never change it without explicit authorization: + +```toml +[features.multi_agent_v2] +hide_spawn_agent_metadata = false +tool_namespace = "agents" +``` + +Use the helper for every CLI fallback; it captures the thread ID, keeps raw output in gitignored `.context`, and verifies persisted controls: + +```bash +node scripts/review-run-log.mjs launch-cli-reviewer \ + --log "$REVIEW_RUN_LOG" \ + --reviewer-id "$REVIEWER_ID" \ + --model "$REVIEWER_MODEL" \ + --reasoning "$REVIEWER_REASONING" \ + --prompt-file ".context/$REVIEWER_ID.packet.txt" \ + --output-file ".context/$REVIEWER_ID.initial.jsonl" +``` + +Launch concurrently where the runtime permits. Never use `--ephemeral`. A failed control verification blocks editing and finishes `blocked`; never treat flags alone as verification. Record `reviewer_session_started` as soon as a handle is available and `reviewer_session_controls_verified` only after persisted verification. Record completed-task `durationMs` only when the runtime exposes it. -Read [references/reviewer-sessions.md](references/reviewer-sessions.md) before launching. It defines cohort defaults, exact model/reasoning verification, isolated native and CLI launch patterns, stable IDs, persistent handles, and the continuity protocol. +### Observe, recover, and clear stalled workers + +After each bounded wait (30 seconds by default), run the cohort watcher. It is a maximum polling interval, not a runtime floor: handle completions immediately. + +```bash +node scripts/review-run-log.mjs inspect-reviewers \ + --log "$REVIEW_RUN_LOG" \ + --stale-after-ms 120000 \ + --soft-deadline-ms 600000 \ + --hard-deadline-ms 1200000 \ + --record +``` + +The soft deadline is a warning. The hard deadline begins at the current `task_started` (or session start when absent). Before classifying any native or CLI reviewer as failed, inspect its exact persisted session. `active`, `stalled`, and one `in_progress` result are not failures. The watcher does not interrupt workers. + +For a hard-exceeded native reviewer, immediately inspect the exact native session once more. If it remains non-terminal, call `agents.interrupt_agent` with the inspection's `nativeHandle`, never its persisted `sessionId`; confirm with `agents.list_agents` that it stopped; then append `reviewer_session_cancelled` with reviewer ID, persisted session ID, native handle, phase, reason, and deadline. Never use a broad kill, interrupt another reviewer, or probe an initial review with a follow-up. One fresh initial retry may use a distinct task name such as `sol_1_retry_1`, but retains reviewer ID `sol-1`; a second hard deadline finishes `partial` or `blocked`. A hard-exceeded continuity session follows the full-cohort restart rule after clearing the exact handle. A CLI session never consumes a native slot; end only its exact runtime wrapper when exposed, otherwise finish partial with its telemetry. + +For a missing or unreadable CLI result, recover before retrying: + +```bash +node scripts/review-run-log.mjs recover-cli-session \ + --log "$REVIEW_RUN_LOG" \ + --reviewer-id "$REVIEWER_ID" +``` + +The recovery must match exactly one captured thread, repository, applied controls, and completed final-answer event. Use its recovered answer but never log the review body. For `in_progress`, inspect the exact CLI session; for native UI lag, inspect the exact native session: + +```bash +node scripts/review-run-log.mjs inspect-cli-session --log "$REVIEW_RUN_LOG" --reviewer-id "$REVIEWER_ID" --stale-after-ms 120000 +node scripts/review-run-log.mjs inspect-native-session --log "$REVIEW_RUN_LOG" --reviewer-id "$REVIEWER_ID" --stale-after-ms 120000 +``` + +CLI retries are allowed only after inspection says `unavailable`. Native retries additionally follow the hard-deadline cleanup above. Keep raw results outside the run log; record only concise observations and outcomes. + +### Preserve session continuity + +Before fixes, create a gitignored `.context/reviewer-sessions.json` ledger with stable reviewer ID, requested/applied controls, launch mechanism, native handle or CLI thread ID, initial fingerprint, and continuity state. Never store credentials, prompts, or review bodies. Before editing, resume every initial session with its original controls and read-only boundary; require only `SESSION_CONTINUITY_OK`. Record a completed-task duration when available. If any handshake fails, discard every report and restart the full cohort once against the unchanged target; a second failure blocks editing. Remediation uses only these verified handles. + +## 2. Run independent initial reviews -1. Resolve the user-requested cohort or that reference's default and keep it fixed. Stop if the runtime cannot verify an exact requested/applied model, reasoning level, or persistent handle; never silently substitute. +1. Resolve the user-requested cohort or the default above and keep it fixed. Stop if the runtime cannot verify an exact requested/applied model, reasoning level, or persistent handle; never silently substitute. 2. Give each reviewer the same self-contained raw prompt, integrated target SHA and fingerprint, conflict summary, and role boundary. Do not expose another reviewer's findings or primary-agent conclusions. Require file, minimal line range, severity, scenario, and rationale for every finding. -3. Fingerprint `HEAD`, staged/unstaged diffs, status, and relevant untracked contents. Keep the target unchanged through all initial reports and continuity checks. Launch concurrently where possible, queue the rest unchanged, and retry a failed invocation once with the same identity and controls. -4. Log `reviewer_session_started` and every completed or failed pass using the helper's canonical fields. Use stable reviewer IDs and, after deduplication, stable finding IDs. Record real token usage only when exposed; otherwise use `null`. -5. Store non-secret handles and controls in a gitignored `.context` ledger. Before editing, resume every exact session with its original controls and require only `SESSION_CONTINUITY_OK`; log the result. If any fails, discard all reports and restart the full cohort once against the unchanged target. A second failure blocks editing. +3. Fingerprint `HEAD`, staged/unstaged diffs, status, and relevant untracked contents. Keep the target unchanged through all initial reports and continuity checks. Launch concurrently where possible and queue the rest unchanged. Apply the launch, watchdog, recovery, and hard-deadline cleanup rules above. +4. Log each launch, control verification, observation, cancellation, and completed or failed pass using the canonical fields above. Use stable reviewer and finding IDs. Record actual token usage and `durationMs` only when exposed. +5. Apply the continuity protocol above before editing. 6. Verify the target fingerprint after the handshakes. On unexpected mutation, inspect ownership and rerun the full cohort once against a stable target. Repeated instability is a blocker. ## 3. Verify findings and fix @@ -65,13 +146,12 @@ Resume every continuity-verified session with its original controls and read-onl ## Finish and report -Always attempt `finish`, even for a blocked/failed run, using event-derived reviewers/findings plus actual bot, validation, status, and SHA outcomes. For native Codex reviewers use `--collect-codex-usage`. Generate the usage section with `report`; do not manually calculate or reformat it. Treat model comparisons as one-run observations. +Always attempt `finish`, even for a blocked/failed run, using event-derived reviewers/findings plus actual bot, validation, status, and SHA outcomes. Use `partial`, `blocked`, or `failed` instead of `complete` when the cohort cannot finish. For native and CLI Codex reviewers use `--collect-codex-usage`; collection is per reviewer, so completed sessions still contribute real tokens, cost, and duration when another worker is unavailable. Do not report while any reviewer lacks both tokens and an exact duration. Run `diagnose-codex-usage`, resolve its per-reviewer session/ledger cause (including an allowed relaunch when needed), then run `finish --collect-codex-usage` again. Generate the usage section with `report` only after that gate passes; do not manually calculate or reformat it. Treat model comparisons as one-run observations. -Report the applied cohort/controls, persistent sessions and continuity/retries, log path and derived invocation/round/usage coverage, shared/unique findings and model comparison, base SHA/integration/conflicts, all finding dispositions, remediation rounds and disagreements, validation per push, commits/PR, bot-loop outcomes, and remaining blockers. End with the helper-generated `### Reviewer token usage` section copied verbatim, with `Estimated cost` immediately after `Total`; put nothing after it. +Report the applied cohort/controls, persistent sessions and continuity/retries, log path and derived invocation/round/usage coverage, shared/unique findings and model comparison, base SHA/integration/conflicts, all finding dispositions, remediation rounds and disagreements, validation per push, commits/PR, bot-loop outcomes, and remaining blockers. `report` refuses an incomplete-telemetry cohort, so append its table verbatim only after it succeeds. Do not add pricing or telemetry caveats. Keep `Estimated cost` immediately after `Total` and `Agent time` last; put nothing after it. ## Resources -- [references/reviewer-sessions.md](references/reviewer-sessions.md): cohort and persistent-session mechanics. - [references/review-guidelines.md](references/review-guidelines.md): default review criteria. - [references/run-logging.md](references/run-logging.md): logger troubleshooting, extension, and metric semantics. - `scripts/review-run-log.mjs`: canonical payloads, append-only log, metrics, and final report. diff --git a/.agents/skills/review-fix-address-bots/references/reviewer-sessions.md b/.agents/skills/review-fix-address-bots/references/reviewer-sessions.md deleted file mode 100644 index f4ac10a..0000000 --- a/.agents/skills/review-fix-address-bots/references/reviewer-sessions.md +++ /dev/null @@ -1,70 +0,0 @@ -# Persistent Reviewer Sessions - -Use the reviewer count, model mix, and reasoning levels explicitly requested by the user. Otherwise use this default cohort for every initial and remediation pass: - -| Reviewer ID | Model | Reasoning | -| --- | --- | --- | -| `sol-1` | `gpt-5.6-sol` | `high` | -| `terra-1` | `gpt-5.6-terra` | `high` | -| `luna-1` | `gpt-5.6-luna` | `high` | - -If the user requests only a count from one through five, take reviewers in this order: `sol-1`, `terra-1`, `luna-1`, `terra-2`, `luna-2`, so a one-reviewer override uses `sol-1` and a three-reviewer override preserves model-family coverage. Ask for a model mix when a count above five is otherwise underspecified. For another explicit model mix, assign stable IDs using the model tier and a one-based ordinal, such as `sol-1` or `luna-2`. For native launches, normalize each ID's hyphens to underscores in the task name so deterministic session discovery can map `sol_1` back to `sol-1`. - -Keep the raw review prompt, target fingerprint, reviewer role boundary, and any configurable service tier identical across the cohort. Keep reasoning identical unless the user explicitly requests per-reviewer differences. A runtime concurrency limit may require batches. Queue reviewers without editing the target, and do not start remediation until every initial report and continuity handshake finishes. - -## Choose a persistent launcher - -Prefer the native subagent launcher when it exposes exact model selection, the configured reasoning level, and a stable session handle that accepts follow-up turns. Launch each initial reviewer with `fork_turns: "none"` and a self-contained review packet; do not fork the primary agent's full conversation. Full-history forks must inherit the parent model, so they cannot preserve an independently pinned Sol/Terra/Luna cohort and add irrelevant parent context. Verify the applied settings from runtime evidence for every reviewer; requested arguments alone are not proof when the runtime does not confirm them. - -If a Codex native `spawn_agent` schema hides `model`, `reasoning_effort`, `agent_type`, or `service_tier`, check whether the user already configured the MultiAgent V2 routing-field workaround: - -```toml -[features.multi_agent_v2] -hide_spawn_agent_metadata = false -tool_namespace = "agents" -``` - -Do not change user-level Codex configuration without explicit authorization. The setting applies only to fresh Codex sessions, so never claim the current session gained routing fields after editing configuration. In a fresh session, verify that the actual launcher schema exposes the required controls before using it. - -When the native launcher lacks an exact model, reasoning control, or resumable handle, use a persistent Codex CLI session only if the runtime can verify the applied controls. Launch each cohort member with its assigned model using the equivalent of: - -```bash -codex exec \ - --model "$REVIEWER_MODEL" \ - -c "model_reasoning_effort=\"$REVIEWER_REASONING\"" \ - -c 'approval_policy="never"' \ - --strict-config \ - --sandbox read-only \ - --json - -``` - -Supply the shared review prompt on stdin. Never pass `--ephemeral` to an initial or follow-up reviewer command. Capture each `thread.started.thread_id` immediately. - -## Record and verify continuity - -Before fixes, write a gitignored `.context/reviewer-sessions.json` operational ledger containing, for each reviewer: - -- stable reviewer ID, -- requested and applied model and reasoning, -- launch mechanism and any configured service tier, -- native session handle or CLI thread ID, -- initial target fingerprint, -- continuity status. - -Do not store credentials, auth material, full prompts, or review bodies. Copy the non-secret reviewer ID, session identifier, applied controls, and continuity result into the structured run log. - -After every initial report returns, resume every session with the same explicit model and reasoning controls. For a CLI session, use the equivalent of: - -```bash -codex exec resume \ - --model "$REVIEWER_MODEL" \ - -c "model_reasoning_effort=\"$REVIEWER_REASONING\"" \ - -c 'approval_policy="never"' \ - -c 'sandbox_mode="read-only"' \ - --strict-config \ - --json "$THREAD_ID" - -``` - -Ask the reviewer to reply only `SESSION_CONTINUITY_OK` while keeping the read-only role boundary in force. Mark continuity successful only after receiving that exact reply from the expected handle with the expected applied controls. - -If any handshake fails, discard every report and restart the full cohort once against the unchanged fingerprint. If any second-cohort session fails its handshake, stop before editing. For remediation passes, resume these exact verified handles; never replace one silently or convert it to an ephemeral session. diff --git a/.agents/skills/review-fix-address-bots/references/run-logging.md b/.agents/skills/review-fix-address-bots/references/run-logging.md index 3fadc50..613b74f 100644 --- a/.agents/skills/review-fix-address-bots/references/run-logging.md +++ b/.agents/skills/review-fix-address-bots/references/run-logging.md @@ -17,7 +17,7 @@ Resolve the directory containing the skill's `SKILL.md`, then start the log befo ```bash node scripts/review-run-log.mjs start \ --repo-root "$PWD" \ - --data-json '{"requestedReviewerCount":3,"reviewerCohortRequested":[{"model":"gpt-5.6-sol","count":1},{"model":"gpt-5.6-terra","count":1},{"model":"gpt-5.6-luna","count":1}],"reasoningRequested":"high","remediationRoundLimit":3,"reviewBotLoopLimit":8}' + --data-json '{"requestedReviewerCount":5,"reviewerCohortRequested":[{"model":"gpt-5.6-sol","count":1},{"model":"gpt-5.6-terra","count":1},{"model":"gpt-5.6-luna","count":3}],"reasoningRequested":"high","watcherIntervalMs":30000,"softReviewerDeadlineMs":600000,"hardReviewerDeadlineMs":1200000,"remediationRoundLimit":3,"reviewBotLoopLimit":8}' ``` Keep the returned `logPath` in `.context`. Append an event immediately after each reviewer pass so partial runs remain useful if later work stops: @@ -29,7 +29,7 @@ node scripts/review-run-log.mjs append \ --data-file .context/reviewer-pass.json ``` -Useful events include `target_integrated`, `reviewer_session_started`, `reviewer_pass_completed`, `reviewer_continuity_verified`, `finding_classified`, `validation_completed`, `push_completed`, `review_bot_loop_completed`, and `run_blocked`. Events may evolve; keep names lower snake case. +Useful events include `target_integrated`, `reviewer_session_started`, `reviewer_session_controls_verified`, `reviewer_session_observed`, `reviewer_session_cancelled`, `reviewer_pass_completed`, `reviewer_pass_failed`, `reviewer_continuity_verified`, `reviewer_continuity_failed`, `finding_classified`, `validation_completed`, `push_completed`, `review_bot_loop_completed`, and `run_blocked`. Events may evolve; keep names lower snake case. Record experimental inputs when they become known: custom-versus-bundled review prompt source and SHA-256 fingerprint, target/base/head SHAs, diff size, the requested reviewer cohort, round limits, requested reasoning, launch mechanisms, retries, and relevant skill options. The helper fingerprints the skill instructions and logger automatically. Hash custom prompts instead of storing their contents. The `start` example shows the default cohort; replace its configuration with the resolved user override when applicable. @@ -42,13 +42,43 @@ For every reviewer pass, record: - stable deduplicated `findingIds` once available, - whether the pass found any issue and whether findings were new, repeated, or overlapping, - actual token usage when the runtime exposes it; otherwise use `null`, never an estimate, -- duration when observable and any failure or retry. +- the exact `durationMs` from the runtime's completed task when it exposes one, otherwise omit it, plus any failure or retry. -Use these exact event keys: `reviewerId`, `findingIds`, `sessionId`, and `tokenUsage`. +Use these exact event keys: `reviewerId`, `findingIds`, `sessionId`, `tokenUsage`, and `durationMs` when available. The finish helper reconstructs the canonical reviewer rounds and continuity checks from these events. This is the source of truth; do not hand-write aliases such as `reviewer`, `finding_ids`, `id`, `model`, or `initialFindingIds` in the finish summary. +If a persistent CLI reviewer finishes but its command output is unavailable, recover it before +retrying with: + +```bash +node scripts/review-run-log.mjs recover-cli-session \ + --log "$REVIEW_RUN_LOG" \ + --reviewer-id "$REVIEWER_ID" +``` + +The command validates the exact `codex exec` session ID, repository, applied model, applied +reasoning, and completed final answer. It emits the recovered result on stdout but does not write +the review body to the run log. Record a concise recovery outcome and then the normal pass event. + +If recovery is `in_progress`, use `inspect-cli-session` before deciding what happened. For a native +reviewer that appears in progress, use `inspect-native-session` with the same log and reviewer ID. +Both commands verify the exact persisted session and report a lifecycle, last activity/event, quiet +duration, and recommended action. Record only that concise diagnostic in `reviewer_session_observed`; +do not copy raw output or review text. `active` and `stalled` sessions must be polled again on the +same handle. `stalled` is observational, not a failure. A CLI retry is allowed only after inspection +is `unavailable`. A native retry is also allowed after its hard deadline, but only after the parent +re-inspects, interrupts, and confirms clear the exact native handle. + +During every bounded wait, run `inspect-reviewers --record` with the configured stale, soft, and hard +deadline values. It writes one concise `reviewer_session_observed` event per launched reviewer and +returns the IDs that crossed each deadline. The command never interrupts anything. On a hard-exceeded +native reviewer, repeat the exact native inspection, then use the agent runtime's exact-handle interrupt +and post-interrupt status check before appending `reviewer_session_cancelled`. Treat an interrupt as a +terminal event for that handle; never reuse it for continuity. Keep a retry's stable reviewer ID but log +its fresh session handle so collection and the final partial report remain accurate. + Do not log full prompts, full review bodies, code contents, credentials, environment variables, or auth material. Finding IDs and concise summaries are enough for later analysis. ## Finish Schema @@ -100,10 +130,11 @@ Always attempt `finish`, including for blocked or failed runs. Pass a summary wi Include one reviewer object for every configured reviewer, even when it found no issues. Preserve applied model and reasoning fields so comparisons and labels reflect what actually ran rather than what was merely requested; leave an unavailable `modelApplied` or `reasoningApplied` unset so the helper reports it as `unknown`. Record every continuity attempt in `continuityChecks`, including retries and `tokenUsage: null` when the runtime exposes no accounting. -The helper merges this summary with the run's reviewer events before collecting usage. It rejects a -summary that lacks a recorded reviewer round or continuity check, rather than rendering a -plausible-looking table with placeholder reviewers. Fix the missing event or session metadata and -rerun `finish`; never substitute a guessed token count. +Use `complete` only when every reviewer has a verified session, review round, and continuity check. +For an incomplete cohort, finish with `partial`, `blocked`, or `failed`; the helper retains its +events and every completed worker's telemetry. Before generating the user-facing report, however, +repair every reviewer that lacks both token usage and an exact duration rather than rendering it as +`n/a` or omitting the table. ```bash node scripts/review-run-log.mjs finish \ @@ -112,11 +143,24 @@ node scripts/review-run-log.mjs finish \ --data-file .context/review-run-summary.json ``` -For native Codex reviewers, `--collect-codex-usage` deterministically discovers the cohort under `${CODEX_HOME:-~/.codex}/sessions`, matches the run window, repository root, parent thread, and reviewer IDs, verifies that each session's completed task count equals its recorded review-plus-continuity invocation count, and copies the final cumulative `token_count` values into the finished log. It refuses ambiguous cohorts or mismatched invocation counts instead of guessing. Record exact session IDs in the summary whenever the runtime exposes them; they further constrain discovery. +For native Codex reviewers, `--collect-codex-usage` deterministically discovers the native cohort under `${CODEX_HOME:-~/.codex}/sessions`, matches the run window, repository root, parent thread, and reviewer IDs, verifies that each session's completed task count equals its recorded review-plus-continuity invocation count, and copies the final cumulative `token_count` values into the finished log. For persistent CLI reviewers it instead matches each exact captured thread ID, repository, and verified controls. Collection is independent per reviewer: a mixed native/CLI cohort or unavailable worker produces `partial` collection while retaining verified usage for every completed session. It refuses ambiguous sessions or mismatched invocation counts instead of guessing. Record exact session IDs in the summary whenever the runtime exposes them; they further constrain discovery. + +Inspect the collection returned by `finish`. If any reviewer has neither tokens nor duration, diagnose before calling `report`: + +```bash +node scripts/review-run-log.mjs diagnose-codex-usage \ + --log "$REVIEW_RUN_LOG" +``` + +Its per-reviewer reason distinguishes an absent/ambiguous session, a still-active session, and a +reviewer-ledger invocation mismatch. Inspect or wait for an active exact session; repair the missing +launch/pass/continuity ledger event or exact handle for a mismatch; use the allowed same-identity +relaunch path when no session exists. Re-run `finish --collect-codex-usage` after the repair. Do not +call `report` until the diagnostic returns `complete`. -The helper derives reviewer session and invocation counts, continuity-invocation counts, rounds per reviewer, initial and cumulative unique findings, pairwise shared/unique finding IDs with Jaccard overlap, reviewers that found issues, GitHub bot counts, and token totals with per-field coverage. Invocation and cumulative token metrics include both review rounds and continuity checks; initial token metrics remain limited to the initial review pass. The helper also groups reviewers only by applied model and derives initial finding classifications, valid and model-unique valid finding IDs, cross-model overlap, per-reviewer usage, and estimated costs. +The helper derives reviewer session and invocation counts, continuity-invocation counts, rounds per reviewer, initial and cumulative unique findings, pairwise shared/unique finding IDs with Jaccard overlap, reviewers that found issues, GitHub bot counts, token totals with per-field coverage, and exact completed-task duration when available. Invocation and cumulative token and duration metrics include both review rounds and continuity checks; initial token metrics remain limited to the initial review pass. The helper also groups reviewers only by applied model and derives initial finding classifications, valid and model-unique valid finding IDs, cross-model overlap, per-reviewer usage, and estimated costs. -Cost is an API-equivalent estimate based on the embedded, dated standard-service GPT-5.6 pricing snapshot. The helper prices `cachedInputTokens` as cache reads, prices the remaining input as uncached, and prices all output tokens at the output rate; reasoning tokens are already included in output and are not added again. It returns `null` rather than estimating when the applied model or any required token field is missing. The estimate is not an invoice: Codex plan billing may differ, cache-write premiums cannot be identified from the aggregate counters, and long-context or non-standard service-tier premiums are excluded. Cite the pricing date and source in the final report. +Cost is an API-equivalent estimate based on the embedded, dated standard-service GPT-5.6 pricing snapshot. The helper prices `cachedInputTokens` as cache reads, prices the remaining input as uncached, and prices all output tokens at the output rate; reasoning tokens are already included in output and are not added again. It returns `null` rather than estimating when the applied model or any required token field is missing. The estimate is not an invoice: Codex plan billing may differ, cache-write premiums cannot be identified from the aggregate counters, and long-context or non-standard service-tier premiums are excluded. After `finish`, generate the final usage section deterministically: @@ -125,14 +169,14 @@ node scripts/review-run-log.mjs report --log "$REVIEW_RUN_LOG" \ > .context/reviewer-usage-report.md ``` -Append `.context/reviewer-usage-report.md` verbatim as the final section of the user-facing workflow summary. Do not manually recompute, reorder, or reformat its values. The command renders this exact Markdown column order, with `Estimated cost` immediately after `Total`: +Append `.context/reviewer-usage-report.md` verbatim as the final content of the user-facing workflow summary only after `report` succeeds. Do not manually recompute, reorder, reformat, or add prose around it. `report` fails rather than rendering a table when any reviewer has neither tokens nor duration; diagnose and repair that reviewer first. A successful report renders this exact Markdown column order, with `Estimated cost` immediately after `Total` and runtime-derived cumulative `Agent time` last: ```markdown -| Reviewer | Input | Cached input | Output | Reasoning | Total | Estimated cost | -|---|---:|---:|---:|---:|---:|---:| -| Sol1 (high) | 100,000 | 90,000 | 2,000 | 1,200 | 102,000 | $0.1550 | +| Reviewer | Input | Cached input | Output | Reasoning | Total | Estimated cost | Agent time | +| ----------- | ------: | -----------: | -----: | --------: | ------: | -------------: | ---------: | +| Sol1 (high) | 100,000 | 90,000 | 2,000 | 1,200 | 102,000 | $0.1550 | 1m 42s | ``` -The generated table uses `n/a` for unavailable usage or estimates. If collection is unavailable, report the helper's reason before the final generated section, but do not have the parent model parse rollout files or invent replacement values. Preserve the raw reviewer/round/continuity/finding arrays so future analyses can compute different metrics without changing old logs. Treat these metrics as observations from one run, not a general model ranking. +The generated table uses `n/a` only for an unavailable field in a reviewer row that otherwise has real telemetry. `Agent time` is the sum of completed-task runtime across a reviewer's review, continuity, and remediation turns; concurrent reviewers can therefore have a total greater than elapsed wall time. Keep collection failures in the run log; do not add their reason, pricing notes, or replacement values to the user-facing summary. Preserve the raw reviewer/round/continuity/finding arrays so future analyses can compute different metrics without changing old logs. Treat these metrics as observations from one run, not a general model ranking. If logging fails, do not hide the failure or fabricate a record. Report it, but do not let telemetry failure cause unsafe Git, PR, or code mutations. diff --git a/.agents/skills/review-fix-address-bots/scripts/review-run-log.mjs b/.agents/skills/review-fix-address-bots/scripts/review-run-log.mjs index d47af97..e015e28 100755 --- a/.agents/skills/review-fix-address-bots/scripts/review-run-log.mjs +++ b/.agents/skills/review-fix-address-bots/scripts/review-run-log.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createHash, randomUUID } from 'node:crypto' +import { createHash, randomUUID } from "node:crypto"; import { appendFileSync, closeSync, @@ -11,201 +11,240 @@ import { readSync, readdirSync, writeFileSync, -} from 'node:fs' -import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' -import { spawnSync } from 'node:child_process' -import { fileURLToPath } from 'node:url' +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -export const SCHEMA_VERSION = 1 +export const SCHEMA_VERSION = 1; export const LOG_TEMPLATES = Object.freeze({ configuration: Object.freeze({ - requestedReviewerCount: 3, + requestedReviewerCount: 5, reviewerCohortRequested: Object.freeze([ - Object.freeze({ model: 'gpt-5.6-sol', count: 1 }), - Object.freeze({ model: 'gpt-5.6-terra', count: 1 }), - Object.freeze({ model: 'gpt-5.6-luna', count: 1 }), + Object.freeze({ model: "gpt-5.6-sol", count: 1 }), + Object.freeze({ model: "gpt-5.6-terra", count: 1 }), + Object.freeze({ model: "gpt-5.6-luna", count: 3 }), ]), - reasoningRequested: 'high', + reasoningRequested: "high", + watcherIntervalMs: 30_000, + softReviewerDeadlineMs: 600_000, + hardReviewerDeadlineMs: 1_200_000, remediationRoundLimit: 3, reviewBotLoopLimit: 8, }), events: Object.freeze({ targetIntegrated: Object.freeze({ - event: 'target_integrated', + event: "target_integrated", data: Object.freeze({ - targetRef: '/', - targetSha: '', - method: 'already_current', + targetRef: "/", + targetSha: "", + method: "already_current", }), }), reviewerSessionStarted: Object.freeze({ - event: 'reviewer_session_started', + event: "reviewer_session_started", data: Object.freeze({ - reviewerId: 'sol-1', - launchMechanism: 'native', - sessionId: '', - modelRequested: 'gpt-5.6-sol', - modelApplied: 'gpt-5.6-sol', - reasoningRequested: 'high', - reasoningApplied: 'high', + reviewerId: "sol-1", + launchMechanism: "native", + sessionId: "", + modelRequested: "gpt-5.6-sol", + modelApplied: "gpt-5.6-sol", + reasoningRequested: "high", + reasoningApplied: "high", + }), + }), + reviewerSessionControlsVerified: Object.freeze({ + event: "reviewer_session_controls_verified", + data: Object.freeze({ + reviewerId: "luna-1", + sessionId: "", + modelApplied: "gpt-5.6-luna", + reasoningApplied: "high", + }), + }), + reviewerSessionObserved: Object.freeze({ + event: "reviewer_session_observed", + data: Object.freeze({ + reviewerId: "luna-1", + lifecycle: "active", + lastActivityAt: "", + quietForMs: 0, + }), + }), + reviewerSessionCancelled: Object.freeze({ + event: "reviewer_session_cancelled", + data: Object.freeze({ + reviewerId: "sol-1", + sessionId: "", + nativeHandle: "/root/sol_1", + phase: "initial", + reason: "native reviewer exceeded the hard deadline", + deadlineMs: 1_200_000, }), }), initialPass: Object.freeze({ - event: 'reviewer_pass_completed', - data: Object.freeze({ reviewerId: 'sol-1', round: 1, findingIds: ['F1'], tokenUsage: null }), + event: "reviewer_pass_completed", + data: Object.freeze({ reviewerId: "sol-1", round: 1, findingIds: ["F1"], tokenUsage: null }), + }), + initialPassFailed: Object.freeze({ + event: "reviewer_pass_failed", + data: Object.freeze({ + reviewerId: "luna-1", + phase: "initial", + reason: "persistent CLI session unavailable after bounded polling", + }), }), continuity: Object.freeze({ - event: 'reviewer_continuity_verified', - data: Object.freeze({ reviewerId: 'sol-1', round: 1, verified: true, tokenUsage: null }), + event: "reviewer_continuity_verified", + data: Object.freeze({ reviewerId: "sol-1", round: 1, verified: true, tokenUsage: null }), }), remediationPass: Object.freeze({ - event: 'remediation_reviewer_pass_completed', - data: Object.freeze({ reviewerId: 'sol-1', round: 1, findingIds: [], tokenUsage: null }), + event: "remediation_reviewer_pass_completed", + data: Object.freeze({ reviewerId: "sol-1", round: 1, findingIds: [], tokenUsage: null }), }), findingResolved: Object.freeze({ - event: 'finding_resolved', + event: "finding_resolved", data: Object.freeze({ - findingId: 'F1', - classification: 'valid', - reportedBy: Object.freeze(['sol-1']), - action: 'fixed', + findingId: "F1", + classification: "valid", + reportedBy: Object.freeze(["sol-1"]), + action: "fixed", }), }), }), finishSummary: Object.freeze({ - status: 'complete', + status: "complete", githubReviewBots: Object.freeze([]), reviewBotLoopCount: 0, }), -}) +}); export const PRICING_SNAPSHOT = Object.freeze({ - currency: 'USD', - serviceTier: 'standard', - effectiveDate: '2026-07-09', - source: 'https://openai.com/index/gpt-5-6/', + currency: "USD", + serviceTier: "standard", + effectiveDate: "2026-07-09", + source: "https://openai.com/index/gpt-5-6/", cachedInputDiscount: 0.9, ratesPerMillionTokens: Object.freeze({ - 'gpt-5.6-sol': Object.freeze({ input: 5, cachedInput: 0.5, output: 30 }), - 'gpt-5.6-terra': Object.freeze({ input: 2.5, cachedInput: 0.25, output: 15 }), - 'gpt-5.6-luna': Object.freeze({ input: 1, cachedInput: 0.1, output: 6 }), + "gpt-5.6-sol": Object.freeze({ input: 5, cachedInput: 0.5, output: 30 }), + "gpt-5.6-terra": Object.freeze({ input: 2.5, cachedInput: 0.25, output: 15 }), + "gpt-5.6-luna": Object.freeze({ input: 1, cachedInput: 0.1, output: 6 }), }), limitations: Object.freeze([ - 'API-equivalent estimate; Codex plan billing may differ.', - 'Treats cachedInputTokens as cache reads and cannot identify cache-write premiums.', - 'Excludes long-context and non-standard service-tier premiums.', + "API-equivalent estimate; Codex plan billing may differ.", + "Treats cachedInputTokens as cache reads and cannot identify cache-write premiums.", + "Excludes long-context and non-standard service-tier premiums.", ]), -}) +}); -const scriptPath = fileURLToPath(import.meta.url) -const skillRoot = dirname(dirname(scriptPath)) +const scriptPath = fileURLToPath(import.meta.url); +const skillRoot = dirname(dirname(scriptPath)); const fail = (message) => { - throw new Error(message) -} + throw new Error(message); +}; const expandHome = (value) => { - if (value === '~') return homedir() - if (value.startsWith('~/')) return join(homedir(), value.slice(2)) - return value -} + if (value === "~") return homedir(); + if (value.startsWith("~/")) return join(homedir(), value.slice(2)); + return value; +}; const isoTimestamp = (value) => { - const date = value ? new Date(value) : new Date() - if (Number.isNaN(date.getTime())) fail(`Invalid timestamp: ${value}`) - return date.toISOString() -} + const date = value ? new Date(value) : new Date(); + if (Number.isNaN(date.getTime())) fail(`Invalid timestamp: ${value}`); + return date.toISOString(); +}; const runGit = (repoRoot, args) => { - const result = spawnSync('git', ['-C', repoRoot, ...args], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }) - return result.status === 0 ? result.stdout.trim() : undefined -} + const result = spawnSync("git", ["-C", repoRoot, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return result.status === 0 ? result.stdout.trim() : undefined; +}; export const sanitizeRemote = (remote, fallback) => { - if (!remote) return fallback + if (!remote) return fallback; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(remote)) { try { - const url = new URL(remote) - const path = url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '') - return [url.hostname, path].filter(Boolean).join('/') || fallback + const url = new URL(remote); + const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""); + return [url.hostname, path].filter(Boolean).join("/") || fallback; } catch { - return fallback + return fallback; } } - const scp = remote.match(/^(?:[^@]+@)?([^:]+):(.+)$/) - if (scp) return `${scp[1]}/${scp[2].replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')}` + const scp = remote.match(/^(?:[^@]+@)?([^:]+):(.+)$/); + if (scp) return `${scp[1]}/${scp[2].replace(/^\/+|\/+$/g, "").replace(/\.git$/, "")}`; - return fallback -} + return fallback; +}; const discoverRepo = (requestedRoot) => { - const candidate = resolve(expandHome(requestedRoot || process.cwd())) - const root = runGit(candidate, ['rev-parse', '--show-toplevel']) || candidate - const remotes = (runGit(root, ['remote']) || '').split('\n').filter(Boolean) - const remoteName = remotes.includes('origin') ? 'origin' : remotes[0] - const remote = remoteName ? runGit(root, ['remote', 'get-url', remoteName]) : undefined + const candidate = resolve(expandHome(requestedRoot || process.cwd())); + const root = runGit(candidate, ["rev-parse", "--show-toplevel"]) || candidate; + const remotes = (runGit(root, ["remote"]) || "").split("\n").filter(Boolean); + const remoteName = remotes.includes("origin") ? "origin" : remotes[0]; + const remote = remoteName ? runGit(root, ["remote", "get-url", remoteName]) : undefined; return { key: sanitizeRemote(remote, basename(root)), root, remoteName: remoteName || null, - } -} + }; +}; const discoverGitState = (repoRoot) => ({ - branch: runGit(repoRoot, ['branch', '--show-current']) || null, - head: runGit(repoRoot, ['rev-parse', 'HEAD']) || null, -}) + branch: runGit(repoRoot, ["branch", "--show-current"]) || null, + head: runGit(repoRoot, ["rev-parse", "HEAD"]) || null, +}); const skillFingerprint = () => { - const hash = createHash('sha256') + const hash = createHash("sha256"); for (const relativePath of [ - 'SKILL.md', - 'references/review-guidelines.md', - 'references/run-logging.md', - 'references/reviewer-sessions.md', - 'scripts/review-run-log.mjs', + "SKILL.md", + "references/review-guidelines.md", + "references/run-logging.md", + "scripts/review-run-log.mjs", ]) { - hash.update(relativePath) - hash.update('\0') - hash.update(readFileSync(join(skillRoot, relativePath))) - hash.update('\0') + hash.update(relativePath); + hash.update("\0"); + hash.update(readFileSync(join(skillRoot, relativePath))); + hash.update("\0"); } - return hash.digest('hex') -} + return hash.digest("hex"); +}; const assertObject = (value, label) => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - fail(`${label} must be a JSON object`) + if (!value || typeof value !== "object" || Array.isArray(value)) { + fail(`${label} must be a JSON object`); } - return value -} + return value; +}; const readEvents = (logPath) => { - const lines = readFileSync(logPath, 'utf8').split('\n').filter(Boolean) - if (lines.length === 0) fail(`Run log is empty: ${logPath}`) + const lines = readFileSync(logPath, "utf8").split("\n").filter(Boolean); + if (lines.length === 0) fail(`Run log is empty: ${logPath}`); return lines.map((line, index) => { try { - return JSON.parse(line) + return JSON.parse(line); } catch (error) { - fail(`Invalid JSON on line ${index + 1} of ${logPath}: ${error.message}`) + fail(`Invalid JSON on line ${index + 1} of ${logPath}: ${error.message}`); } - }) -} + }); +}; const runIdentity = (logPath) => { - const events = readEvents(logPath) - const first = events[0] - if (first.event !== 'run_started' || !first.runId) fail(`Missing run_started header: ${logPath}`) - return { events, runId: first.runId } -} + const events = readEvents(logPath); + const first = events[0]; + if (first.event !== "run_started" || !first.runId) fail(`Missing run_started header: ${logPath}`); + return { events, runId: first.runId }; +}; const tokenUsageFromCodex = (usage) => usage @@ -216,200 +255,1234 @@ const tokenUsageFromCodex = (usage) => reasoningOutputTokens: usage.reasoning_output_tokens, totalTokens: usage.total_tokens, } - : null + : null; const readFirstJsonLine = (path) => { - const descriptor = openSync(path, 'r') + const descriptor = openSync(path, "r"); try { - const buffer = Buffer.alloc(2 * 1024 * 1024) - const length = readSync(descriptor, buffer, 0, buffer.length, 0) - const text = buffer.subarray(0, length).toString('utf8') - const newline = text.indexOf('\n') - if (newline === -1) return null - return JSON.parse(text.slice(0, newline)) + const buffer = Buffer.alloc(2 * 1024 * 1024); + const length = readSync(descriptor, buffer, 0, buffer.length, 0); + const text = buffer.subarray(0, length).toString("utf8"); + const newline = text.indexOf("\n"); + if (newline === -1) return null; + return JSON.parse(text.slice(0, newline)); } catch { - return null + return null; } finally { - closeSync(descriptor) + closeSync(descriptor); } -} +}; const sessionFilesForWindow = (sessionsRoot, startedAt, endedAt) => { - const files = [] - const start = new Date(startedAt) - const end = new Date(endedAt) - start.setUTCDate(start.getUTCDate() - 1) - end.setUTCDate(end.getUTCDate() + 1) + const files = []; + const start = new Date(startedAt); + const end = new Date(endedAt); + start.setUTCDate(start.getUTCDate() - 1); + end.setUTCDate(end.getUTCDate() + 1); for (let date = start; date <= end; date = new Date(date.getTime() + 86_400_000)) { const directory = join( sessionsRoot, - String(date.getUTCFullYear()).padStart(4, '0'), - String(date.getUTCMonth() + 1).padStart(2, '0'), - String(date.getUTCDate()).padStart(2, '0'), - ) - if (!existsSync(directory)) continue + String(date.getUTCFullYear()).padStart(4, "0"), + String(date.getUTCMonth() + 1).padStart(2, "0"), + String(date.getUTCDate()).padStart(2, "0"), + ); + if (!existsSync(directory)) continue; for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isFile() && entry.name.endsWith('.jsonl')) files.push(join(directory, entry.name)) + if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(join(directory, entry.name)); } } - return files -} + return files; +}; const codexSessionUsage = (path) => { - let totalTokenUsage = null - let invocationCount = 0 - let completedInvocationCount = 0 - for (const line of readFileSync(path, 'utf8').split('\n')) { - if (!line) continue - let record + let totalTokenUsage = null; + let invocationCount = 0; + let completedInvocationCount = 0; + let completedDurationCount = 0; + let durationMs = 0; + for (const line of readFileSync(path, "utf8").split("\n")) { + if (!line) continue; + let record; try { - record = JSON.parse(line) + record = JSON.parse(line); } catch { - continue + continue; + } + if (record.type !== "event_msg") continue; + if (record.payload?.type === "task_started") invocationCount += 1; + if (record.payload?.type === "task_complete") { + completedInvocationCount += 1; + if ( + typeof record.payload.duration_ms === "number" && + Number.isFinite(record.payload.duration_ms) && + record.payload.duration_ms >= 0 + ) { + completedDurationCount += 1; + durationMs += record.payload.duration_ms; + } } - if (record.type !== 'event_msg') continue - if (record.payload?.type === 'task_started') invocationCount += 1 - if (record.payload?.type === 'task_complete') completedInvocationCount += 1 - if (record.payload?.type === 'token_count' && record.payload.info?.total_token_usage) { - totalTokenUsage = record.payload.info.total_token_usage + if (record.payload?.type === "token_count" && record.payload.info?.total_token_usage) { + totalTokenUsage = record.payload.info.total_token_usage; } } return { invocationCount, completedInvocationCount, + durationMs: completedDurationCount === completedInvocationCount ? durationMs : null, tokenUsage: tokenUsageFromCodex(totalTokenUsage), + }; +}; + +const expectedAgentName = (reviewerId) => reviewerId.replaceAll("-", "_"); + +const nativeCandidateMatchesReviewer = (candidate, reviewer, index = 0) => { + const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}`; + const sessionHandle = reviewer.sessionId || reviewer.sessionIdentifier; + if (sessionHandle) + return candidate.sessionId === sessionHandle || candidate.agentPath === sessionHandle; + return candidate.agentName === expectedAgentName(reviewerId); +}; + +const readJsonlRecords = (path) => { + const records = []; + for (const [index, line] of readFileSync(path, "utf8").split("\n").entries()) { + if (!line) continue; + try { + records.push(JSON.parse(line)); + } catch { + return { records, malformedLine: index + 1 }; + } + } + return { records, malformedLine: null }; +}; + +const sessionIdFromMeta = (record) => record?.payload?.id || record?.payload?.session_id || null; + +const cliSessionCandidate = (path) => { + const first = readFirstJsonLine(path); + if (first?.type !== "session_meta" || first.payload?.source !== "exec") return null; + return { + path, + cwd: first.payload.cwd || null, + model: first.payload.model || null, + sessionId: sessionIdFromMeta(first), + timestamp: first.payload.timestamp || first.timestamp || null, + }; +}; + +const nativeSessionCandidate = (path) => { + const first = readFirstJsonLine(path); + const spawn = first?.payload?.source?.subagent?.thread_spawn; + if (first?.type !== "session_meta" || !spawn) return null; + const agentPath = spawn.agent_path || first.payload.agent_path || ""; + return { + path, + cwd: first.payload.cwd || null, + model: first.payload.model || null, + sessionId: sessionIdFromMeta(first), + parentThreadId: spawn.parent_thread_id || first.payload.parent_thread_id || null, + agentPath, + agentName: basename(agentPath), + timestamp: first.payload.timestamp || first.timestamp || null, + }; +}; + +const appliedControlsForSession = (records, candidate) => { + const context = records.find((record) => record.type === "turn_context")?.payload || {}; + return { + model: context.model || candidate.model || null, + reasoning: context.effort || context.collaboration_mode?.settings?.reasoning_effort || null, + }; +}; + +const exactCliSessionData = ( + { sessionId, modelApplied, reasoningApplied } = {}, + { + sessionsRoot = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"), + startedAt, + endedAt = new Date().toISOString(), + repoRoot, + } = {}, +) => { + if (!sessionId || !startedAt || !repoRoot) { + return { + status: "unavailable", + reason: "sessionId, startedAt, and repoRoot are required", + }; + } + + const candidates = sessionFilesForWindow(resolve(expandHome(sessionsRoot)), startedAt, endedAt) + .map(cliSessionCandidate) + .filter(Boolean) + .filter( + (candidate) => + candidate.sessionId === sessionId && resolve(candidate.cwd || "/") === resolve(repoRoot), + ); + + if (candidates.length !== 1) { + return { + status: "unavailable", + reason: + candidates.length === 0 + ? "no exact persistent CLI session matched the reviewer session ID and repository" + : "multiple persistent CLI sessions matched the reviewer session ID and repository", + candidateCount: candidates.length, + }; + } + + const candidate = candidates[0]; + const { records, malformedLine } = readJsonlRecords(candidate.path); + if (malformedLine !== null) { + return { + status: "unavailable", + reason: `persistent CLI session contains malformed JSON at line ${malformedLine}`, + sessionId, + }; + } + + const controls = appliedControlsForSession(records, candidate); + if ( + (modelApplied && controls.model !== modelApplied) || + (reasoningApplied && controls.reasoning !== reasoningApplied) + ) { + return { + status: "unavailable", + reason: "persistent CLI session applied controls do not match the reviewer ledger", + sessionId, + controls, + }; + } + + return { status: "available", candidate, controls, records, sessionId }; +}; + +const exactNativeSessionData = ( + { sessionId, reviewerId, modelApplied, reasoningApplied } = {}, + { + sessionsRoot = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"), + startedAt, + endedAt = new Date().toISOString(), + repoRoot, + } = {}, +) => { + if (!reviewerId || !startedAt || !repoRoot) { + return { + status: "unavailable", + reason: "reviewerId, startedAt, and repoRoot are required", + }; + } + + const startTime = new Date(startedAt).getTime(); + const endTime = new Date(endedAt).getTime(); + const candidates = sessionFilesForWindow(resolve(expandHome(sessionsRoot)), startedAt, endedAt) + .map(nativeSessionCandidate) + .filter(Boolean) + .filter((candidate) => { + const timestamp = new Date(candidate.timestamp).getTime(); + const matchesHandle = + sessionId && (candidate.sessionId === sessionId || candidate.agentPath === sessionId); + return ( + (matchesHandle || (!sessionId && candidate.agentName === expectedAgentName(reviewerId))) && + resolve(candidate.cwd || "/") === resolve(repoRoot) && + timestamp >= startTime && + timestamp <= endTime + ); + }); + + if (candidates.length !== 1) { + return { + status: "unavailable", + reason: + candidates.length === 0 + ? "no exact native reviewer session matched the reviewer handle and repository" + : "multiple native reviewer sessions matched the reviewer handle and repository", + candidateCount: candidates.length, + }; + } + + const candidate = candidates[0]; + const { records, malformedLine } = readJsonlRecords(candidate.path); + if (malformedLine !== null) { + return { + status: "unavailable", + reason: `native reviewer session contains malformed JSON at line ${malformedLine}`, + sessionId: candidate.sessionId, + }; + } + + const controls = appliedControlsForSession(records, candidate); + if ( + (modelApplied && controls.model !== modelApplied) || + (reasoningApplied && controls.reasoning !== reasoningApplied) + ) { + return { + status: "unavailable", + reason: "native reviewer session applied controls do not match the reviewer ledger", + sessionId: candidate.sessionId, + controls, + }; + } + + return { status: "available", candidate, controls, records, sessionId: candidate.sessionId }; +}; + +const timestampForRecord = (record) => { + const value = record?.timestamp || record?.payload?.timestamp || null; + return value && Number.isFinite(new Date(value).getTime()) ? new Date(value).toISOString() : null; +}; + +const terminalFailureFor = (records) => + [...records].reverse().find((record) => { + const type = record?.payload?.type || record?.type || ""; + return ( + typeof record?.payload?.error === "string" || + /(?:^|_)(?:error|failed|failure|aborted|cancelled)(?:$|_)/i.test(type) + ); + }) || null; + +const eventDescriptor = (record) => + record + ? { + recordedAt: timestampForRecord(record), + recordType: record.type || null, + eventType: record.payload?.type || null, + } + : null; + +const latestReviewerSessionStart = (events, reviewerId, launchMechanism) => + [...events] + .reverse() + .find( + (event) => + event.event === "reviewer_session_started" && + canonicalReviewerId(event.data) === reviewerId && + event.data?.launchMechanism === launchMechanism, + ) || null; + +const terminalResultFromSession = ({ sessionId, controls, records, sessionLabel }) => { + const taskStarts = records.filter( + (record) => record.type === "event_msg" && record.payload?.type === "task_started", + ); + const taskCompletions = records.filter( + (record) => record.type === "event_msg" && record.payload?.type === "task_complete", + ); + if (taskCompletions.length > taskStarts.length) { + return { + status: "unavailable", + reason: `${sessionLabel} has more completed tasks than task starts`, + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + }; + } + if (taskStarts.length > taskCompletions.length) { + const lastTaskStartIndex = records.findLastIndex( + (record) => record.type === "event_msg" && record.payload?.type === "task_started", + ); + const failure = terminalFailureFor(records.slice(lastTaskStartIndex)); + if (failure) { + return { + status: "unavailable", + reason: `${sessionLabel} recorded a terminal failure before task completion`, + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + terminalFailure: eventDescriptor(failure), + }; + } + return { + status: "in_progress", + reason: `${sessionLabel} has an active task without a terminal response`, + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + }; + } + const terminal = taskCompletions.at(-1)?.payload; + const lastAgentMessage = terminal?.last_agent_message; + + if (!terminal || typeof lastAgentMessage !== "string" || lastAgentMessage.length === 0) { + const failure = terminalFailureFor(records); + if (failure) { + return { + status: "unavailable", + reason: `${sessionLabel} recorded a terminal failure before task completion`, + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + terminalFailure: eventDescriptor(failure), + }; + } + return { + status: "in_progress", + reason: `${sessionLabel} has no completed task with a terminal response`, + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + }; + } + + const hasMatchingFinalAnswer = records.some( + (record) => + record.type === "event_msg" && + record.payload?.type === "agent_message" && + record.payload?.phase === "final_answer" && + record.payload?.message === lastAgentMessage, + ); + if (!hasMatchingFinalAnswer) { + return { + status: "unavailable", + reason: `${sessionLabel} terminal response does not have a matching final-answer event`, + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + }; + } + + return { + status: "complete", + sessionId, + controls, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + completedAt: terminal.completed_at + ? new Date(terminal.completed_at * 1000).toISOString() + : null, + durationMs: typeof terminal.duration_ms === "number" ? terminal.duration_ms : null, + lastAgentMessage, + }; +}; + +/** + * Recovers a terminal response from an exact persistent `codex exec` session. + * + * This is deliberately separate from native usage collection: a CLI session is + * identified by its captured thread ID, not by subagent metadata. The returned + * response is for the parent to consume; callers must not add it to the JSONL + * telemetry log because review bodies are intentionally not logged. + */ +export const collectCodexCliSessionResult = ( + { sessionId, modelApplied, reasoningApplied } = {}, + { + sessionsRoot = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"), + startedAt, + endedAt = new Date().toISOString(), + repoRoot, + } = {}, +) => { + const exact = exactCliSessionData( + { sessionId, modelApplied, reasoningApplied }, + { sessionsRoot, startedAt, endedAt, repoRoot }, + ); + if (exact.status !== "available") return exact; + + return terminalResultFromSession({ + sessionId, + controls: exact.controls, + records: exact.records, + sessionLabel: "persistent CLI session", + }); +}; + +/** + * Recovers a terminal response from an exact native reviewer transcript. The + * session handle is the native agent path (or its rollout ID when exposed). + */ +export const collectCodexNativeSessionResult = ( + { sessionId, reviewerId, modelApplied, reasoningApplied } = {}, + { + sessionsRoot = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"), + startedAt, + endedAt = new Date().toISOString(), + repoRoot, + } = {}, +) => { + const exact = exactNativeSessionData( + { sessionId, reviewerId, modelApplied, reasoningApplied }, + { sessionsRoot, startedAt, endedAt, repoRoot }, + ); + if (exact.status !== "available") return exact; + return terminalResultFromSession({ + sessionId: exact.sessionId, + controls: exact.controls, + records: exact.records, + sessionLabel: "native reviewer session", + }); +}; + +const inspectExactSession = ({ + reviewerId, + exact, + recovered, + observedAt, + staleAfterMs, + activeAction, + stalledAction, +} = {}) => { + const { candidate, controls, records } = exact; + const taskStarts = records.filter( + (record) => record.type === "event_msg" && record.payload?.type === "task_started", + ); + const taskCompletions = records.filter( + (record) => record.type === "event_msg" && record.payload?.type === "task_complete", + ); + const lastRecord = records.at(-1) || null; + const lastActivityAt = timestampForRecord(lastRecord); + const quietForMs = lastActivityAt + ? Math.max(0, new Date(observedAt).getTime() - new Date(lastActivityAt).getTime()) + : null; + const activeTaskStartedAt = timestampForRecord(taskStarts.at(-1)) || candidate.timestamp || null; + const timingBasis = taskStarts.length > 0 ? "task_started" : "session_started"; + const activeTaskElapsedMs = activeTaskStartedAt + ? Math.max(0, new Date(observedAt).getTime() - new Date(activeTaskStartedAt).getTime()) + : null; + const shared = { + reviewerId, + sessionLogPath: candidate.path, + ...(candidate.agentPath ? { nativeHandle: candidate.agentPath } : {}), + observedAt, + lastActivityAt, + lastEvent: eventDescriptor(lastRecord), + }; + + if (recovered.status === "complete") { + return { + ...shared, + ...recovered, + lifecycle: "complete", + recommendedAction: "Use the recovered final answer and record the completed reviewer pass.", + }; + } + + if (recovered.status === "unavailable") { + return { + ...shared, + ...recovered, + lifecycle: "unavailable", + recommendedAction: + "Record the diagnostic. Retry once with the same reviewer identity and controls only after confirming this session cannot produce a completed result.", + }; + } + + const lifecycle = quietForMs !== null && quietForMs >= staleAfterMs ? "stalled" : "active"; + return { + ...shared, + ...recovered, + controls, + lifecycle, + quietForMs, + staleAfterMs, + activeTaskStartedAt, + activeTaskElapsedMs, + timingBasis, + taskStartedCount: taskStarts.length, + taskCompletedCount: taskCompletions.length, + recommendedAction: lifecycle === "active" ? activeAction : stalledAction, + }; +}; + +/** + * Inspects an exact CLI reviewer session without resuming it. An in-progress + * session is observationally active or stalled based on its last JSONL write; + * neither state claims that the underlying service has stopped. + */ +export const inspectCodexCliReviewerSession = ({ + logPath, + reviewerId, + sessionsRoot, + timestamp, + staleAfterMs = 120_000, +} = {}) => { + if (!logPath) fail("logPath is required"); + if (!reviewerId) fail("reviewerId is required"); + if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) + fail("staleAfterMs must be a non-negative finite number"); + + const { events } = runIdentity(logPath); + const start = latestReviewerSessionStart(events, reviewerId, "codex_cli"); + if (!start) { + return { + status: "unavailable", + reason: "no codex_cli reviewer session start exists for this reviewer", + reviewerId, + }; + } + + const data = start.data; + const observedAt = isoTimestamp(timestamp); + const exact = exactCliSessionData( + { + sessionId: data.sessionId, + modelApplied: data.modelApplied, + reasoningApplied: data.reasoningApplied, + }, + { + sessionsRoot, + startedAt: events[0].timestamp, + endedAt: observedAt, + repoRoot: events[0].repo?.root, + }, + ); + if (exact.status !== "available") return { reviewerId, ...exact }; + + const recovered = collectCodexCliSessionResult( + { + sessionId: data.sessionId, + modelApplied: data.modelApplied, + reasoningApplied: data.reasoningApplied, + }, + { + sessionsRoot, + startedAt: events[0].timestamp, + endedAt: observedAt, + repoRoot: events[0].repo?.root, + }, + ); + return inspectExactSession({ + reviewerId, + exact, + recovered, + observedAt, + staleAfterMs, + activeAction: + "Poll this exact session again after a bounded wait; do not resume or replace it.", + stalledAction: + "This session has been quiet past the threshold. Inspect its final events, record the diagnostic, then use the one permitted same-identity retry only if it remains unavailable.", + }); +}; + +/** + * Inspects a native Sol/Terra-style reviewer transcript without sending it a + * follow-up. Native launch status can lag its persisted task completion. + */ +export const inspectCodexNativeReviewerSession = ({ + logPath, + reviewerId, + sessionsRoot, + timestamp, + staleAfterMs = 120_000, +} = {}) => { + if (!logPath) fail("logPath is required"); + if (!reviewerId) fail("reviewerId is required"); + if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) + fail("staleAfterMs must be a non-negative finite number"); + + const { events } = runIdentity(logPath); + const start = latestReviewerSessionStart(events, reviewerId, "native"); + if (!start) { + return { + status: "unavailable", + reason: "no native reviewer session start exists for this reviewer", + reviewerId, + }; + } + + const data = start.data; + const observedAt = isoTimestamp(timestamp); + const exact = exactNativeSessionData( + { + sessionId: data.sessionId, + reviewerId, + modelApplied: data.modelApplied, + reasoningApplied: data.reasoningApplied, + }, + { + sessionsRoot, + startedAt: events[0].timestamp, + endedAt: observedAt, + repoRoot: events[0].repo?.root, + }, + ); + if (exact.status !== "available") return { reviewerId, ...exact }; + + const recovered = collectCodexNativeSessionResult( + { + sessionId: data.sessionId, + reviewerId, + modelApplied: data.modelApplied, + reasoningApplied: data.reasoningApplied, + }, + { + sessionsRoot, + startedAt: events[0].timestamp, + endedAt: observedAt, + repoRoot: events[0].repo?.root, + }, + ); + return inspectExactSession({ + reviewerId, + exact, + recovered, + observedAt, + staleAfterMs, + activeAction: + "Poll the same native reviewer handle after a bounded wait; do not send a follow-up or create a replacement reviewer.", + stalledAction: + "This native transcript has been quiet past the threshold. Compare the native handle status with this transcript, record the diagnostic, and retry once only after it is unavailable.", + }); +}; + +const deadlineStateFor = ({ inspection, softDeadlineMs, hardDeadlineMs }) => { + if (inspection.lifecycle === "complete" || inspection.lifecycle === "unavailable") + return { state: "terminal", elapsedMs: null }; + const elapsedMs = inspection.activeTaskElapsedMs; + if (!Number.isFinite(elapsedMs)) return { state: "unknown", elapsedMs: null }; + if (elapsedMs >= hardDeadlineMs) return { state: "hard_exceeded", elapsedMs }; + if (elapsedMs >= softDeadlineMs) return { state: "soft_exceeded", elapsedMs }; + return { state: "within_budget", elapsedMs }; +}; + +/** + * Inspects every launched reviewer at one instant and applies only + * observational soft/hard deadline states. It never interrupts a worker. + */ +export const inspectReviewerSessions = ({ + logPath, + sessionsRoot, + timestamp, + staleAfterMs = 120_000, + softDeadlineMs = 600_000, + hardDeadlineMs = 1_200_000, + recordObservations = false, +} = {}) => { + if (!logPath) fail("logPath is required"); + if (!Number.isFinite(staleAfterMs) || staleAfterMs < 0) + fail("staleAfterMs must be a non-negative finite number"); + if (!Number.isFinite(softDeadlineMs) || softDeadlineMs < 0) + fail("softDeadlineMs must be a non-negative finite number"); + if (!Number.isFinite(hardDeadlineMs) || hardDeadlineMs < softDeadlineMs) + fail("hardDeadlineMs must be a finite number no smaller than softDeadlineMs"); + + const { events } = runIdentity(logPath); + const observedAt = isoTimestamp(timestamp); + const latestStarts = new Map(); + for (const event of events) { + if (event.event !== "reviewer_session_started") continue; + const reviewerId = canonicalReviewerId(event.data); + const launchMechanism = event.data?.launchMechanism; + if (!reviewerId || !["native", "codex_cli"].includes(launchMechanism)) continue; + latestStarts.set(reviewerId, launchMechanism); + } + + const reviewers = [...latestStarts.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([reviewerId, launchMechanism]) => { + const inspection = + launchMechanism === "native" + ? inspectCodexNativeReviewerSession({ + logPath, + reviewerId, + sessionsRoot, + timestamp: observedAt, + staleAfterMs, + }) + : inspectCodexCliReviewerSession({ + logPath, + reviewerId, + sessionsRoot, + timestamp: observedAt, + staleAfterMs, + }); + const { lastAgentMessage, ...diagnostic } = inspection; + return { + launchMechanism, + ...diagnostic, + ...(typeof lastAgentMessage === "string" ? { hasRecoveredFinalAnswer: true } : {}), + deadline: deadlineStateFor({ inspection: diagnostic, softDeadlineMs, hardDeadlineMs }), + }; + }); + if (recordObservations) { + for (const reviewer of reviewers) { + appendEvent({ + logPath, + event: "reviewer_session_observed", + data: { + reviewerId: reviewer.reviewerId, + sessionId: reviewer.sessionId, + launchMechanism: reviewer.launchMechanism, + lifecycle: reviewer.lifecycle, + lastActivityAt: reviewer.lastActivityAt, + quietForMs: reviewer.quietForMs, + activeTaskElapsedMs: reviewer.activeTaskElapsedMs, + timingBasis: reviewer.timingBasis, + deadlineState: reviewer.deadline.state, + deadlineMs: + reviewer.deadline.state === "hard_exceeded" + ? hardDeadlineMs + : reviewer.deadline.state === "soft_exceeded" + ? softDeadlineMs + : null, + }, + }); + } + } + const count = (predicate) => reviewers.filter(predicate).length; + return { + observedAt, + staleAfterMs, + softDeadlineMs, + hardDeadlineMs, + observationsRecorded: recordObservations ? reviewers.length : 0, + reviewers, + summary: { + reviewerCount: reviewers.length, + activeCount: count((reviewer) => reviewer.lifecycle === "active"), + stalledCount: count((reviewer) => reviewer.lifecycle === "stalled"), + completeCount: count((reviewer) => reviewer.lifecycle === "complete"), + unavailableCount: count((reviewer) => reviewer.lifecycle === "unavailable"), + softExceededReviewerIds: reviewers + .filter((reviewer) => reviewer.deadline.state === "soft_exceeded") + .map((reviewer) => reviewer.reviewerId), + hardExceededReviewerIds: reviewers + .filter((reviewer) => reviewer.deadline.state === "hard_exceeded") + .map((reviewer) => reviewer.reviewerId), + }, + }; +}; + +/** + * Starts one persistent, read-only CLI reviewer and records its thread ID as + * soon as Codex emits it. The raw JSON stream stays outside the structured run + * log so review bodies and diagnostics are not copied into telemetry. + */ +export const launchCodexCliReviewer = async ({ + logPath, + reviewerId, + model, + reasoning, + promptFile, + outputFile, + sessionsRoot, + codexCommand = "codex", +} = {}) => { + if (!logPath || !reviewerId || !model || !reasoning || !promptFile || !outputFile) { + fail("logPath, reviewerId, model, reasoning, promptFile, and outputFile are required"); + } + if (!existsSync(promptFile)) fail(`promptFile does not exist: ${promptFile}`); + if (existsSync(outputFile)) fail(`outputFile already exists: ${outputFile}`); + + mkdirSync(dirname(outputFile), { recursive: true }); + writeFileSync(outputFile, "", { encoding: "utf8", flag: "wx" }); + const prompt = readFileSync(promptFile); + const args = [ + "exec", + "--model", + model, + "-c", + `model_reasoning_effort=${JSON.stringify(reasoning)}`, + "-c", + 'approval_policy="never"', + "--strict-config", + "--sandbox", + "read-only", + "--json", + "-", + ]; + const child = spawn(codexCommand, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdoutBuffer = ""; + let sessionId = null; + let launchRecorded = false; + let stderrBytes = 0; + + const recordThreadStarted = (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if ( + message?.type !== "thread.started" || + typeof message.thread_id !== "string" || + launchRecorded + ) + return; + sessionId = message.thread_id; + appendEvent({ + logPath, + event: "reviewer_session_started", + data: { + reviewerId, + launchMechanism: "codex_cli", + sessionId, + modelRequested: model, + reasoningRequested: reasoning, + }, + }); + launchRecorded = true; + }; + + child.stdout.on("data", (chunk) => { + const text = chunk.toString("utf8"); + appendFileSync(outputFile, text, "utf8"); + stdoutBuffer += text; + let newline; + while ((newline = stdoutBuffer.indexOf("\n")) !== -1) { + recordThreadStarted(stdoutBuffer.slice(0, newline)); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + } + }); + child.stderr.on("data", (chunk) => { + stderrBytes += chunk.length; + }); + child.stdin.end(prompt); + + const close = await new Promise((resolvePromise) => { + child.once("error", (error) => + resolvePromise({ exitCode: null, signal: null, errorMessage: error.message }), + ); + child.once("close", (exitCode, signal) => resolvePromise({ exitCode, signal })); + }); + if (stdoutBuffer.length > 0) recordThreadStarted(stdoutBuffer); + + if (!sessionId) { + const reason = close.errorMessage + ? "codex CLI could not start before emitting thread.started" + : "codex CLI exited without emitting thread.started"; + appendEvent({ + logPath, + event: "reviewer_session_failed", + data: { + reviewerId, + phase: "initial", + reason, + clientExitCode: close.exitCode, + clientSignal: close.signal, + }, + }); + return { + reviewerId, + status: "unavailable", + reason, + clientExitCode: close.exitCode, + clientSignal: close.signal, + outputFile, + stderrBytes, + }; + } + + const { events } = runIdentity(logPath); + const exact = exactCliSessionData( + { sessionId }, + { + sessionsRoot, + startedAt: events[0].timestamp, + repoRoot: events[0].repo?.root, + }, + ); + if (exact.status === "available") { + appendEvent({ + logPath, + event: "reviewer_session_controls_verified", + data: { + reviewerId, + sessionId, + modelApplied: exact.controls.model, + reasoningApplied: exact.controls.reasoning, + }, + }); + } else { + appendEvent({ + logPath, + event: "reviewer_session_observed", + data: { + reviewerId, + lifecycle: "unavailable", + reason: exact.reason, + }, + }); } -} -const expectedAgentName = (reviewerId) => reviewerId.replaceAll('-', '_') + const inspection = inspectCodexCliReviewerSession({ logPath, reviewerId, sessionsRoot }); + return { + reviewerId, + sessionId, + clientExitCode: close.exitCode, + clientSignal: close.signal, + outputFile, + stderrBytes, + inspection, + }; +}; + +export const recoverCodexCliReviewerResult = ({ + logPath, + reviewerId, + sessionsRoot, + timestamp, +} = {}) => { + if (!logPath) fail("logPath is required"); + if (!reviewerId) fail("reviewerId is required"); + const { events } = runIdentity(logPath); + const start = latestReviewerSessionStart(events, reviewerId, "codex_cli"); + if (!start) { + return { + status: "unavailable", + reason: "no codex_cli reviewer session start exists for this reviewer", + reviewerId, + }; + } + + const data = start.data; + return { + reviewerId, + ...collectCodexCliSessionResult( + { + sessionId: data.sessionId, + modelApplied: data.modelApplied, + reasoningApplied: data.reasoningApplied, + }, + { + sessionsRoot, + startedAt: events[0].timestamp, + endedAt: timestamp || new Date().toISOString(), + repoRoot: events[0].repo?.root, + }, + ), + }; +}; export const collectCodexSessionUsage = ( summary, { - sessionsRoot = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions'), + sessionsRoot = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions"), startedAt, endedAt = new Date().toISOString(), repoRoot, } = {}, ) => { - assertObject(summary, 'summary') - const reviewers = Array.isArray(summary.reviewers) ? summary.reviewers : [] + assertObject(summary, "summary"); + const reviewers = Array.isArray(summary.reviewers) ? summary.reviewers : []; if (!startedAt || !repoRoot || reviewers.length === 0) { return { summary, - collection: { status: 'unavailable', reason: 'startedAt, repoRoot, and reviewers are required' }, - } + collection: { + status: "unavailable", + reason: "startedAt, repoRoot, and reviewers are required", + }, + }; } - const startTime = new Date(startedAt).getTime() - const endTime = new Date(endedAt).getTime() - const reviewerNames = new Set(reviewers.map((reviewer, index) => expectedAgentName(reviewer.reviewerId || `reviewer-${index + 1}`))) - const candidates = sessionFilesForWindow(resolve(expandHome(sessionsRoot)), startedAt, endedAt) + const startTime = new Date(startedAt).getTime(); + const endTime = new Date(endedAt).getTime(); + const files = sessionFilesForWindow(resolve(expandHome(sessionsRoot)), startedAt, endedAt); + const nativeReviewers = reviewers.filter( + (reviewer) => + reviewer.launchMechanism === "native" || (!reviewer.launchMechanism && !reviewer.expected), + ); + const nativeCandidates = files .map((path) => ({ path, record: readFirstJsonLine(path) })) - .filter(({ record }) => record?.type === 'session_meta' && record.payload?.source?.subagent?.thread_spawn) + .filter( + ({ record }) => + record?.type === "session_meta" && record.payload?.source?.subagent?.thread_spawn, + ) .map(({ path, record }) => { - const payload = record.payload - const spawn = payload.source.subagent.thread_spawn + const payload = record.payload; + const spawn = payload.source.subagent.thread_spawn; return { path, sessionId: payload.id || null, parentThreadId: spawn.parent_thread_id || payload.parent_thread_id || null, - agentPath: spawn.agent_path || payload.agent_path || '', - agentName: basename(spawn.agent_path || payload.agent_path || ''), + agentPath: spawn.agent_path || payload.agent_path || "", + agentName: basename(spawn.agent_path || payload.agent_path || ""), cwd: payload.cwd, timestamp: payload.timestamp || record.timestamp, - } + }; }) .filter((candidate) => { - const timestamp = new Date(candidate.timestamp).getTime() + const timestamp = new Date(candidate.timestamp).getTime(); return ( - reviewerNames.has(candidate.agentName) && - resolve(candidate.cwd || '/') === resolve(repoRoot) && + nativeReviewers.some((reviewer, index) => + nativeCandidateMatchesReviewer(candidate, reviewer, index), + ) && + resolve(candidate.cwd || "/") === resolve(repoRoot) && timestamp >= startTime && timestamp <= endTime - ) - }) + ); + }); - const groups = new Map() - for (const candidate of candidates) { - if (!groups.has(candidate.parentThreadId)) groups.set(candidate.parentThreadId, []) - groups.get(candidate.parentThreadId).push(candidate) + const groups = new Map(); + for (const candidate of nativeCandidates) { + if (!groups.has(candidate.parentThreadId)) groups.set(candidate.parentThreadId, []); + groups.get(candidate.parentThreadId).push(candidate); } - const matchingGroups = [...groups.entries()].filter(([, group]) => - reviewers.every((reviewer, index) => { - const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}` - const exactSessionId = reviewer.sessionId || reviewer.sessionIdentifier - const matches = group.filter( - (candidate) => - candidate.agentName === expectedAgentName(reviewerId) && - (!exactSessionId || candidate.sessionId === exactSessionId || candidate.agentPath === exactSessionId), + const matchingGroups = nativeReviewers.length + ? [...groups.entries()].filter(([, group]) => + nativeReviewers.every((reviewer, index) => { + const matches = group.filter((candidate) => + nativeCandidateMatchesReviewer(candidate, reviewer, index), + ); + return matches.length === 1; + }), ) - return matches.length === 1 - }), - ) + : []; + + const nativeCandidatesByReviewer = new Map(); + let parentThreadId = null; + let nativeReason = null; + if (nativeReviewers.length > 0) { + if (matchingGroups.length === 1) { + [parentThreadId] = matchingGroups[0]; + for (const [index, reviewer] of nativeReviewers.entries()) { + const reviewerId = reviewer.reviewerId; + const candidate = matchingGroups[0][1].find((entry) => + nativeCandidateMatchesReviewer(entry, reviewer, index), + ); + nativeCandidatesByReviewer.set(reviewerId, candidate); + } + } else { + nativeReason = + matchingGroups.length === 0 + ? "no unambiguous native reviewer session cohort matched the run" + : "multiple native reviewer session cohorts matched the run"; + } + } - if (matchingGroups.length !== 1) { - return { - summary, - collection: { - status: 'unavailable', - reason: - matchingGroups.length === 0 - ? 'no unambiguous reviewer session cohort matched the run' - : 'multiple reviewer session cohorts matched the run', - candidateGroupCount: matchingGroups.length, + const cliCandidatesByReviewer = new Map(); + const cliReasonsByReviewer = new Map(); + for (const reviewer of reviewers.filter((entry) => entry.launchMechanism === "codex_cli")) { + const exact = exactCliSessionData( + { + sessionId: reviewer.sessionId || reviewer.sessionIdentifier, + modelApplied: reviewer.modelApplied, + reasoningApplied: reviewer.reasoningApplied, }, - } + { sessionsRoot, startedAt, endedAt, repoRoot }, + ); + if (exact.status === "available") + cliCandidatesByReviewer.set(reviewer.reviewerId, exact.candidate); + else cliReasonsByReviewer.set(reviewer.reviewerId, exact.reason); } - const [parentThreadId, group] = matchingGroups[0] - const collected = [] - const enrichedReviewers = reviewers.map((reviewer, index) => { - const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}` - const exactSessionId = reviewer.sessionId || reviewer.sessionIdentifier - const candidate = group.find( - (entry) => - entry.agentName === expectedAgentName(reviewerId) && - (!exactSessionId || entry.sessionId === exactSessionId || entry.agentPath === exactSessionId), - ) - const usage = codexSessionUsage(candidate.path) - const expectedInvocationCount = invocationsFor(reviewer).length + const enrichReviewer = (reviewer, index, candidate, source, reason) => { + const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}`; + const expectedInvocationCount = invocationsFor(reviewer).length; + if (!candidate) { + return { + reviewer, + collected: { + reviewerId, + source, + expectedInvocationCount, + observedInvocationCount: null, + completedInvocationCount: null, + collected: false, + durationCollected: false, + reason: reason || "reviewer has no captured session", + }, + }; + } + const usage = codexSessionUsage(candidate.path); const valid = usage.tokenUsage && + expectedInvocationCount > 0 && usage.invocationCount === expectedInvocationCount && - usage.completedInvocationCount === expectedInvocationCount - collected.push({ - reviewerId, - sessionId: candidate.sessionId, - expectedInvocationCount, - observedInvocationCount: usage.invocationCount, - completedInvocationCount: usage.completedInvocationCount, - collected: Boolean(valid), - }) - return valid - ? { - ...reviewer, - sessionId: candidate.sessionId, - sessionTokenUsage: usage.tokenUsage, - sessionTokenUsageSource: 'codex_rollout_token_count', - } - : reviewer - }) - - const collectedCount = collected.filter((reviewer) => reviewer.collected).length + usage.completedInvocationCount === expectedInvocationCount; + const durationValid = + usage.durationMs !== null && + expectedInvocationCount > 0 && + usage.invocationCount === expectedInvocationCount && + usage.completedInvocationCount === expectedInvocationCount; + return { + reviewer: { + ...reviewer, + sessionId: candidate.sessionId || reviewer.sessionId, + ...(valid + ? { + sessionTokenUsage: usage.tokenUsage, + sessionTokenUsageSource: "codex_rollout_token_count", + } + : {}), + ...(durationValid + ? { + sessionDurationMs: usage.durationMs, + sessionDurationSource: "codex_rollout_task_duration", + } + : {}), + }, + collected: { + reviewerId, + source, + sessionId: candidate.sessionId || reviewer.sessionId || null, + expectedInvocationCount, + observedInvocationCount: usage.invocationCount, + completedInvocationCount: usage.completedInvocationCount, + collected: Boolean(valid), + durationCollected: durationValid, + ...(valid || durationValid + ? {} + : { + reason: + expectedInvocationCount === 0 + ? "reviewer has no recorded completed or continuity invocation" + : "session invocation count does not match the reviewer ledger", + }), + }, + }; + }; + + const enriched = reviewers.map((reviewer, index) => { + const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}`; + if ( + reviewer.launchMechanism === "native" || + (!reviewer.launchMechanism && !reviewer.expected) + ) { + return enrichReviewer( + reviewer, + index, + nativeCandidatesByReviewer.get(reviewerId), + "native", + nativeReason, + ); + } + if (reviewer.launchMechanism === "codex_cli") { + return enrichReviewer( + reviewer, + index, + cliCandidatesByReviewer.get(reviewerId), + "codex_cli", + cliReasonsByReviewer.get(reviewerId), + ); + } + return enrichReviewer( + reviewer, + index, + null, + "unavailable", + reviewer.expected + ? "reviewer was expected but no reviewer_session_started event was recorded" + : undefined, + ); + }); + + const enrichedReviewers = enriched.map((entry) => entry.reviewer); + const collected = enriched.map((entry) => entry.collected); + const collectedCount = collected.filter((reviewer) => reviewer.collected).length; return { summary: { ...summary, reviewers: enrichedReviewers }, collection: { - status: collectedCount === reviewers.length ? 'complete' : 'partial', - parentThreadId, + status: + collectedCount === reviewers.length + ? "complete" + : collectedCount > 0 + ? "partial" + : "unavailable", + ...(parentThreadId ? { parentThreadId } : {}), reviewerCount: reviewers.length, collectedCount, + ...(nativeReason ? { nativeReason, nativeCandidateGroupCount: matchingGroups.length } : {}), reviewers: collected, }, - } -} + }; +}; export const startRun = ({ repoRoot, @@ -418,93 +1491,100 @@ export const startRun = ({ timestamp, runId = randomUUID(), } = {}) => { - assertObject(configuration, 'configuration') - if (!/^[A-Za-z0-9._-]+$/.test(runId)) fail('runId may contain only letters, numbers, dots, underscores, and hyphens') - - const createdAt = isoTimestamp(timestamp) - const date = new Date(createdAt) - const year = String(date.getUTCFullYear()).padStart(4, '0') - const month = String(date.getUTCMonth() + 1).padStart(2, '0') - const day = String(date.getUTCDate()).padStart(2, '0') + assertObject(configuration, "configuration"); + if (!/^[A-Za-z0-9._-]+$/.test(runId)) + fail("runId may contain only letters, numbers, dots, underscores, and hyphens"); + + const createdAt = isoTimestamp(timestamp); + const date = new Date(createdAt); + const year = String(date.getUTCFullYear()).padStart(4, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); const root = resolve( - expandHome(outputRoot || join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'log', 'review-fix-address-bots')), - ) - const directory = join(root, year, month, day) - const filenameTimestamp = createdAt.replace(/[:.]/g, '-') - const logPath = join(directory, `review-run-${filenameTimestamp}-${runId}.jsonl`) - const repo = discoverRepo(repoRoot) - - mkdirSync(directory, { recursive: true }) + expandHome( + outputRoot || + join(process.env.CODEX_HOME || join(homedir(), ".codex"), "log", "review-fix-address-bots"), + ), + ); + const directory = join(root, year, month, day); + const filenameTimestamp = createdAt.replace(/[:.]/g, "-"); + const logPath = join(directory, `review-run-${filenameTimestamp}-${runId}.jsonl`); + const repo = discoverRepo(repoRoot); + + mkdirSync(directory, { recursive: true }); writeFileSync( logPath, `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, runId, timestamp: createdAt, - event: 'run_started', + event: "run_started", skill: { - name: 'review-fix-address-bots', + name: "review-fix-address-bots", fingerprintSha256: skillFingerprint(), }, repo, git: discoverGitState(repo.root), configuration, })}\n`, - { encoding: 'utf8', flag: 'wx' }, - ) + { encoding: "utf8", flag: "wx" }, + ); - return { logPath, runId } -} + return { logPath, runId }; +}; export const appendEvent = ({ logPath, event, data = {}, timestamp } = {}) => { - if (!logPath) fail('logPath is required') - if (!event || !/^[a-z][a-z0-9_]*$/.test(event)) fail('event must be lower_snake_case') - if (event === 'run_started' || event === 'run_finished') fail(`Use the dedicated command for ${event}`) - assertObject(data, 'data') - - const { events, runId } = runIdentity(logPath) - if (events.some((item) => item.event === 'run_finished')) fail(`Run is already finished: ${logPath}`) + if (!logPath) fail("logPath is required"); + if (!event || !/^[a-z][a-z0-9_]*$/.test(event)) fail("event must be lower_snake_case"); + if (event === "run_started" || event === "run_finished") + fail(`Use the dedicated command for ${event}`); + assertObject(data, "data"); + + const { events, runId } = runIdentity(logPath); + if (events.some((item) => item.event === "run_finished")) + fail(`Run is already finished: ${logPath}`); const record = { schemaVersion: SCHEMA_VERSION, runId, timestamp: isoTimestamp(timestamp), event, data, - } - appendFileSync(logPath, `${JSON.stringify(record)}\n`, 'utf8') - return record -} + }; + appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8"); + return record; +}; const findingIdsFor = (reviewer, phase) => { - const rounds = Array.isArray(reviewer.rounds) ? reviewer.rounds : [] + const rounds = Array.isArray(reviewer.rounds) ? reviewer.rounds : []; const ids = rounds .filter((round) => !phase || round.phase === phase) .flatMap((round) => (Array.isArray(round.findingIds) ? round.findingIds : [])) - .filter((id) => typeof id === 'string' && id.length > 0) - return [...new Set(ids)].sort() -} + .filter((id) => typeof id === "string" && id.length > 0); + return [...new Set(ids)].sort(); +}; const overlapFor = (reviewers, phase) => { const entries = reviewers.map((reviewer, index) => ({ reviewerId: reviewer.reviewerId || `reviewer-${index + 1}`, findingIds: findingIdsFor(reviewer, phase), - })) - const frequency = new Map() + })); + const frequency = new Map(); for (const entry of entries) { - for (const findingId of entry.findingIds) frequency.set(findingId, (frequency.get(findingId) || 0) + 1) + for (const findingId of entry.findingIds) + frequency.set(findingId, (frequency.get(findingId) || 0) + 1); } - const pairs = [] + const pairs = []; for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { - const left = entries[leftIndex] - const right = entries[rightIndex] - const leftSet = new Set(left.findingIds) - const rightSet = new Set(right.findingIds) - const sharedFindingIds = left.findingIds.filter((id) => rightSet.has(id)) - const onlyLeftFindingIds = left.findingIds.filter((id) => !rightSet.has(id)) - const onlyRightFindingIds = right.findingIds.filter((id) => !leftSet.has(id)) - const unionSize = new Set([...left.findingIds, ...right.findingIds]).size + const left = entries[leftIndex]; + const right = entries[rightIndex]; + const leftSet = new Set(left.findingIds); + const rightSet = new Set(right.findingIds); + const sharedFindingIds = left.findingIds.filter((id) => rightSet.has(id)); + const onlyLeftFindingIds = left.findingIds.filter((id) => !rightSet.has(id)); + const onlyRightFindingIds = right.findingIds.filter((id) => !leftSet.has(id)); + const unionSize = new Set([...left.findingIds, ...right.findingIds]).size; pairs.push({ leftReviewerId: left.reviewerId, rightReviewerId: right.reviewerId, @@ -512,13 +1592,13 @@ const overlapFor = (reviewers, phase) => { onlyLeftFindingIds, onlyRightFindingIds, jaccard: unionSize === 0 ? null : Number((sharedFindingIds.length / unionSize).toFixed(4)), - }) + }); } } - const uniqueFindingIds = [...frequency.keys()].sort() + const uniqueFindingIds = [...frequency.keys()].sort(); return { - basis: phase ? `${phase} rounds` : 'all rounds', + basis: phase ? `${phase} rounds` : "all rounds", uniqueFindingIds, allReviewersSharedFindingIds: entries.length === 0 @@ -529,127 +1609,199 @@ const overlapFor = (reviewers, phase) => { findingIds: entry.findingIds.filter((findingId) => frequency.get(findingId) === 1), })), pairs, - } -} + }; +}; const invocationsFor = (reviewer) => { - const rounds = Array.isArray(reviewer.rounds) ? reviewer.rounds : [] + const rounds = Array.isArray(reviewer.rounds) ? reviewer.rounds : []; const continuityChecks = Array.isArray(reviewer.continuityChecks) - ? reviewer.continuityChecks.map((check) => ({ ...check, phase: 'continuity' })) - : [] - return [...rounds, ...continuityChecks] -} + ? reviewer.continuityChecks.map((check) => ({ ...check, phase: "continuity" })) + : []; + return [...rounds, ...continuityChecks]; +}; const stringArray = (value) => - Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.length > 0) : [] + Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : []; const canonicalReviewerId = (value) => - typeof value?.reviewerId === 'string' + typeof value?.reviewerId === "string" ? value.reviewerId - : typeof value?.reviewer === 'string' + : typeof value?.reviewer === "string" ? value.reviewer - : null + : null; + +const durationFrom = (value) => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? { durationMs: value } : {}; const canonicalFinding = (finding) => { - if (!finding || typeof finding !== 'object' || Array.isArray(finding)) return null + if (!finding || typeof finding !== "object" || Array.isArray(finding)) return null; const findingId = - typeof finding.findingId === 'string' + typeof finding.findingId === "string" ? finding.findingId - : typeof finding.id === 'string' + : typeof finding.id === "string" ? finding.id - : null - return findingId ? { ...finding, findingId } : null -} + : null; + return findingId ? { ...finding, findingId } : null; +}; export const canonicalSummaryFromEvents = (events, summary) => { - const suppliedReviewers = Array.isArray(summary.reviewers) ? summary.reviewers : [] - const reviewerById = new Map() + const suppliedReviewers = Array.isArray(summary.reviewers) ? summary.reviewers : []; + const reviewerById = new Map(); for (const reviewer of suppliedReviewers) { - const reviewerId = canonicalReviewerId(reviewer) - if (!reviewerId) continue + const reviewerId = canonicalReviewerId(reviewer); + if (!reviewerId) continue; reviewerById.set(reviewerId, { ...reviewer, reviewerId, - continuityChecks: Array.isArray(reviewer.continuityChecks) ? [...reviewer.continuityChecks] : [], + continuityChecks: Array.isArray(reviewer.continuityChecks) + ? [...reviewer.continuityChecks] + : [], rounds: Array.isArray(reviewer.rounds) ? [...reviewer.rounds] : [], - ...(reviewer.modelApplied === undefined && typeof reviewer.model === 'string' + ...(reviewer.modelApplied === undefined && typeof reviewer.model === "string" ? { modelApplied: reviewer.model } : {}), - ...(reviewer.reasoningApplied === undefined && typeof reviewer.reasoning === 'string' + ...(reviewer.reasoningApplied === undefined && typeof reviewer.reasoning === "string" ? { reasoningApplied: reviewer.reasoning } : {}), - }) + }); } const ensureReviewer = (reviewerId) => { - const existing = reviewerById.get(reviewerId) - if (existing) return existing - const reviewer = { reviewerId, continuityChecks: [], rounds: [] } - reviewerById.set(reviewerId, reviewer) - return reviewer - } + const existing = reviewerById.get(reviewerId); + if (existing) return existing; + const reviewer = { reviewerId, continuityChecks: [], rounds: [] }; + reviewerById.set(reviewerId, reviewer); + return reviewer; + }; const addRound = (reviewer, round) => { - const exists = reviewer.rounds.some( + const existing = reviewer.rounds.find( (entry) => entry.phase === round.phase && entry.round === round.round, - ) - if (!exists) reviewer.rounds.push(round) - } + ); + if (!existing) { + reviewer.rounds.push(round); + return; + } + if (round.findingIds.length > 0 || existing.findingIds.length === 0) + existing.findingIds = round.findingIds; + if (round.tokenUsage !== null) existing.tokenUsage = round.tokenUsage; + if (round.durationMs !== undefined) existing.durationMs = round.durationMs; + }; for (const event of events) { - const data = event.data || {} - const reviewerId = canonicalReviewerId(data) - if (!reviewerId) continue - const reviewer = ensureReviewer(reviewerId) + const data = event.data || {}; + const reviewerId = canonicalReviewerId(data); + if (!reviewerId) continue; + const reviewer = ensureReviewer(reviewerId); - if (event.event === 'reviewer_session_started') { + if (event.event === "reviewer_session_started") { Object.assign(reviewer, { - ...(typeof data.launchMechanism === 'string' ? { launchMechanism: data.launchMechanism } : {}), - ...(typeof data.sessionId === 'string' ? { sessionId: data.sessionId } : {}), - ...(typeof data.modelRequested === 'string' ? { modelRequested: data.modelRequested } : {}), - ...(typeof data.modelApplied === 'string' ? { modelApplied: data.modelApplied } : {}), - ...(typeof data.reasoningRequested === 'string' + ...(typeof data.launchMechanism === "string" + ? { launchMechanism: data.launchMechanism } + : {}), + ...(typeof data.sessionId === "string" ? { sessionId: data.sessionId } : {}), + ...(typeof data.modelRequested === "string" ? { modelRequested: data.modelRequested } : {}), + ...(typeof data.modelApplied === "string" ? { modelApplied: data.modelApplied } : {}), + ...(typeof data.reasoningRequested === "string" ? { reasoningRequested: data.reasoningRequested } : {}), - ...(typeof data.reasoningApplied === 'string' ? { reasoningApplied: data.reasoningApplied } : {}), - }) - continue + ...(typeof data.reasoningApplied === "string" + ? { reasoningApplied: data.reasoningApplied } + : {}), + }); + continue; } - if (event.event === 'reviewer_pass_completed' || event.event === 'remediation_reviewer_pass_completed') { + if (event.event === "reviewer_session_controls_verified") { + Object.assign(reviewer, { + ...(typeof data.sessionId === "string" ? { sessionId: data.sessionId } : {}), + ...(typeof data.modelApplied === "string" ? { modelApplied: data.modelApplied } : {}), + ...(typeof data.reasoningApplied === "string" + ? { reasoningApplied: data.reasoningApplied } + : {}), + }); + continue; + } + + if ( + event.event === "reviewer_pass_completed" || + event.event === "remediation_reviewer_pass_completed" + ) { addRound(reviewer, { - phase: event.event === 'reviewer_pass_completed' ? 'initial' : 'remediation', - round: typeof data.round === 'number' ? data.round : 1, + phase: event.event === "reviewer_pass_completed" ? "initial" : "remediation", + round: typeof data.round === "number" ? data.round : 1, findingIds: stringArray(data.findingIds ?? data.finding_ids), tokenUsage: data.tokenUsage ?? null, - }) - continue + ...durationFrom(data.durationMs), + }); + continue; } - if (event.event === 'reviewer_continuity_verified') { - reviewer.continuityVerified = true - const round = typeof data.round === 'number' ? data.round : 1 - if (!reviewer.continuityChecks.some((entry) => entry.round === round)) { - reviewer.continuityChecks.push({ round, verified: true, tokenUsage: data.tokenUsage ?? null }) + if (event.event === "reviewer_continuity_verified") { + reviewer.continuityVerified = true; + const round = typeof data.round === "number" ? data.round : 1; + const existing = reviewer.continuityChecks.find((entry) => entry.round === round); + if (!existing) { + reviewer.continuityChecks.push({ + round, + verified: true, + tokenUsage: data.tokenUsage ?? null, + ...durationFrom(data.durationMs), + }); + } else { + existing.verified = true; + if (data.tokenUsage !== null && data.tokenUsage !== undefined) + existing.tokenUsage = data.tokenUsage; + if (typeof data.durationMs === "number") existing.durationMs = data.durationMs; } + continue; + } + + if ( + event.event === "reviewer_pass_failed" || + event.event === "reviewer_continuity_failed" || + event.event === "reviewer_session_failed" || + event.event === "reviewer_session_cancelled" + ) { + reviewer.failure = { + phase: + typeof data.phase === "string" + ? data.phase + : event.event === "reviewer_continuity_failed" + ? "continuity" + : "initial", + reason: + typeof data.reason === "string" + ? data.reason + : typeof data.failureReason === "string" + ? data.failureReason + : "reviewer invocation failed", + }; + if (event.event === "reviewer_session_cancelled") reviewer.sessionLifecycle = "cancelled"; + continue; + } + + if (event.event === "reviewer_session_observed" && typeof data.lifecycle === "string") { + reviewer.sessionLifecycle = data.lifecycle; } } - const findingsById = new Map() + const findingsById = new Map(); for (const finding of Array.isArray(summary.findings) ? summary.findings : []) { - const canonical = canonicalFinding(finding) - if (canonical) findingsById.set(canonical.findingId, canonical) + const canonical = canonicalFinding(finding); + if (canonical) findingsById.set(canonical.findingId, canonical); } for (const event of events) { - if (event.event !== 'finding_resolved') continue + if (event.event !== "finding_resolved") continue; const finding = canonicalFinding({ ...event.data, findingId: event.data?.findingId ?? event.data?.finding_id, reportedBy: event.data?.reportedBy ?? event.data?.reporters, - action: event.data?.action ?? 'fixed', - }) - if (finding && !findingsById.has(finding.findingId)) findingsById.set(finding.findingId, finding) + action: event.data?.action ?? "fixed", + }); + if (finding && !findingsById.has(finding.findingId)) + findingsById.set(finding.findingId, finding); } return { @@ -658,60 +1810,136 @@ export const canonicalSummaryFromEvents = (events, summary) => { left.reviewerId.localeCompare(right.reviewerId), ), findings: [...findingsById.values()], + }; +}; + +const reviewerIdBaseForModel = (model) => { + const normalized = typeof model === "string" ? model.replace(/^gpt-[\d.]+-/, "") : ""; + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(normalized) ? normalized : "reviewer"; +}; + +const expectedReviewersFromConfiguration = (configuration = {}) => { + const requestedCohort = Array.isArray(configuration.reviewerCohortRequested) + ? configuration.reviewerCohortRequested + : []; + const expected = []; + const ordinals = new Map(); + for (const entry of requestedCohort) { + if (!entry || typeof entry.model !== "string") continue; + const count = Number.isInteger(entry.count) && entry.count > 0 ? entry.count : 0; + const base = reviewerIdBaseForModel(entry.model); + const ordinal = ordinals.get(base) || 0; + for (let index = 1; index <= count; index += 1) { + expected.push({ reviewerId: `${base}-${ordinal + index}`, modelRequested: entry.model }); + } + ordinals.set(base, ordinal + count); } -} + if (expected.length > 0) return expected; + + const count = + Number.isInteger(configuration.requestedReviewerCount) && + configuration.requestedReviewerCount > 0 + ? configuration.requestedReviewerCount + : 0; + return Array.from({ length: count }, (_, index) => ({ reviewerId: `reviewer-${index + 1}` })); +}; + +const includeExpectedReviewers = (summary, configuration) => { + const reviewers = Array.isArray(summary.reviewers) ? [...summary.reviewers] : []; + const reviewerById = new Map(reviewers.map((reviewer) => [reviewer.reviewerId, reviewer])); + for (const expected of expectedReviewersFromConfiguration(configuration)) { + const existing = reviewerById.get(expected.reviewerId); + if (existing) { + if (!existing.launchMechanism) { + existing.expected = true; + if (!existing.modelRequested) existing.modelRequested = expected.modelRequested; + if (!existing.reasoningRequested && typeof configuration.reasoningRequested === "string") + existing.reasoningRequested = configuration.reasoningRequested; + } + continue; + } + reviewers.push({ + ...expected, + expected: true, + ...(typeof configuration.reasoningRequested === "string" + ? { reasoningRequested: configuration.reasoningRequested } + : {}), + continuityChecks: [], + rounds: [], + }); + } + return { + ...summary, + reviewers: reviewers.sort((left, right) => left.reviewerId.localeCompare(right.reviewerId)), + }; +}; export const validateFinishSummary = (summary) => { - const reviewers = Array.isArray(summary.reviewers) ? summary.reviewers : [] - if (reviewers.length === 0) fail('finish summary must include at least one reviewer') + const status = summary.status || "complete"; + if (!new Set(["complete", "partial", "blocked", "failed"]).has(status)) { + fail("finish summary status must be complete, partial, blocked, or failed"); + } + const reviewers = Array.isArray(summary.reviewers) ? summary.reviewers : []; + if (reviewers.length === 0) fail("finish summary must include at least one reviewer"); for (const reviewer of reviewers) { + if (typeof reviewer.reviewerId !== "string" || reviewer.reviewerId.length === 0) { + fail("finish summary reviewer is missing reviewerId"); + } + if (status !== "complete") continue; const requiredFields = [ - 'reviewerId', - 'launchMechanism', - 'sessionId', - 'modelRequested', - 'modelApplied', - 'reasoningRequested', - 'reasoningApplied', - ] + "launchMechanism", + "sessionId", + "modelRequested", + "modelApplied", + "reasoningRequested", + "reasoningApplied", + ]; const missing = requiredFields.filter( - (field) => typeof reviewer[field] !== 'string' || reviewer[field].length === 0, - ) + (field) => typeof reviewer[field] !== "string" || reviewer[field].length === 0, + ); if (missing.length > 0) { - fail(`finish summary reviewer ${reviewer.reviewerId || ''} is missing ${missing.join(', ')}`) + fail( + `finish summary reviewer ${reviewer.reviewerId || ""} is missing ${missing.join(", ")}`, + ); } if (!Array.isArray(reviewer.rounds) || reviewer.rounds.length === 0) { - fail(`finish summary reviewer ${reviewer.reviewerId} has no recorded review rounds`) + fail(`finish summary reviewer ${reviewer.reviewerId} has no recorded review rounds`); } if (!Array.isArray(reviewer.continuityChecks) || reviewer.continuityChecks.length === 0) { - fail(`finish summary reviewer ${reviewer.reviewerId} has no continuity check`) + fail(`finish summary reviewer ${reviewer.reviewerId} has no continuity check`); } } -} +}; const tokenMetrics = (reviewers, phase) => { - const fields = ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens'] - const totals = Object.fromEntries(fields.map((field) => [field, 0])) - const fieldCoverage = Object.fromEntries(fields.map((field) => [field, 0])) - let invocationCount = 0 - let invocationsWithUsage = 0 + const fields = [ + "inputTokens", + "cachedInputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens", + ]; + const totals = Object.fromEntries(fields.map((field) => [field, 0])); + const fieldCoverage = Object.fromEntries(fields.map((field) => [field, 0])); + let invocationCount = 0; + let invocationsWithUsage = 0; for (const reviewer of reviewers) { for (const round of invocationsFor(reviewer)) { - if (phase && round.phase !== phase) continue - invocationCount += 1 - const usage = round.tokenUsage - if (!usage || typeof usage !== 'object' || Array.isArray(usage)) continue - let foundValue = false + if (phase && round.phase !== phase) continue; + invocationCount += 1; + const usage = round.tokenUsage; + if (!usage || typeof usage !== "object" || Array.isArray(usage)) continue; + let foundValue = false; for (const field of fields) { - if (typeof usage[field] === 'number' && Number.isFinite(usage[field])) { - totals[field] += usage[field] - fieldCoverage[field] += 1 - foundValue = true + if (typeof usage[field] === "number" && Number.isFinite(usage[field])) { + totals[field] += usage[field]; + fieldCoverage[field] += 1; + foundValue = true; } } - if (foundValue) invocationsWithUsage += 1 + if (foundValue) invocationsWithUsage += 1; } } @@ -722,71 +1950,141 @@ const tokenMetrics = (reviewers, phase) => { fieldCoverage, totals: invocationsWithUsage > 0 - ? Object.fromEntries(fields.filter((field) => fieldCoverage[field] > 0).map((field) => [field, totals[field]])) + ? Object.fromEntries( + fields + .filter((field) => fieldCoverage[field] > 0) + .map((field) => [field, totals[field]]), + ) : null, - } -} + }; +}; const metricsForSessionUsage = (usage) => { - const fields = ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens'] - const fieldCoverage = Object.fromEntries(fields.map((field) => [field, typeof usage?.[field] === 'number' ? 1 : 0])) - const totals = Object.fromEntries(fields.filter((field) => fieldCoverage[field]).map((field) => [field, usage[field]])) + const fields = [ + "inputTokens", + "cachedInputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens", + ]; + const fieldCoverage = Object.fromEntries( + fields.map((field) => [field, typeof usage?.[field] === "number" ? 1 : 0]), + ); + const totals = Object.fromEntries( + fields.filter((field) => fieldCoverage[field]).map((field) => [field, usage[field]]), + ); return { invocationCount: 1, invocationsWithUsage: Object.keys(totals).length > 0 ? 1 : 0, complete: fields.every((field) => fieldCoverage[field] === 1), fieldCoverage, totals: Object.keys(totals).length > 0 ? totals : null, - source: 'session', + source: "session", + }; +}; + +const reviewerDurationMs = (reviewer) => { + if ( + typeof reviewer.sessionDurationMs === "number" && + Number.isFinite(reviewer.sessionDurationMs) && + reviewer.sessionDurationMs >= 0 + ) { + return reviewer.sessionDurationMs; + } + + const invocations = invocationsFor(reviewer); + if (invocations.length === 0) return null; + let durationMs = 0; + for (const invocation of invocations) { + if ( + typeof invocation.durationMs !== "number" || + !Number.isFinite(invocation.durationMs) || + invocation.durationMs < 0 + ) { + return null; + } + durationMs += invocation.durationMs; + } + return durationMs; +}; + +const durationMetrics = (reviewers) => { + let invocationCount = 0; + let reviewersWithDuration = 0; + let durationMs = 0; + + for (const reviewer of reviewers) { + invocationCount += invocationsFor(reviewer).length; + const reviewerDuration = reviewerDurationMs(reviewer); + if (reviewerDuration !== null) { + reviewersWithDuration += 1; + durationMs += reviewerDuration; + } } -} + + return { + invocationCount, + reviewersWithDuration, + complete: reviewers.length > 0 && reviewersWithDuration === reviewers.length, + durationMs: reviewersWithDuration > 0 ? durationMs : null, + }; +}; export const estimateTokenCost = (model, metrics) => { - const rates = PRICING_SNAPSHOT.ratesPerMillionTokens[model] - const totals = metrics?.totals - if (!rates || !totals || !metrics || metrics.invocationCount === 0) return null + const rates = PRICING_SNAPSHOT.ratesPerMillionTokens[model]; + const totals = metrics?.totals; + if (!rates || !totals || !metrics || metrics.invocationCount === 0) return null; - const requiredFields = ['inputTokens', 'cachedInputTokens', 'outputTokens'] - if (requiredFields.some((field) => metrics.fieldCoverage?.[field] !== metrics.invocationCount)) return null + const requiredFields = ["inputTokens", "cachedInputTokens", "outputTokens"]; + if (requiredFields.some((field) => metrics.fieldCoverage?.[field] !== metrics.invocationCount)) + return null; - const { inputTokens, cachedInputTokens, outputTokens } = totals + const { inputTokens, cachedInputTokens, outputTokens } = totals; if ( - ![inputTokens, cachedInputTokens, outputTokens].every((value) => Number.isFinite(value) && value >= 0) || + ![inputTokens, cachedInputTokens, outputTokens].every( + (value) => Number.isFinite(value) && value >= 0, + ) || cachedInputTokens > inputTokens ) { - return null + return null; } - const uncachedInputTokens = inputTokens - cachedInputTokens + const uncachedInputTokens = inputTokens - cachedInputTokens; const estimatedUsd = - (uncachedInputTokens * rates.input + cachedInputTokens * rates.cachedInput + outputTokens * rates.output) / - 1_000_000 + (uncachedInputTokens * rates.input + + cachedInputTokens * rates.cachedInput + + outputTokens * rates.output) / + 1_000_000; - return Number(estimatedUsd.toFixed(6)) -} + return Number(estimatedUsd.toFixed(6)); +}; const reviewerUsage = (reviewers) => reviewers.map((reviewer, index) => { - const model = reviewer.modelApplied || 'unknown' + const model = reviewer.modelApplied || "unknown"; const tokenUsage = reviewer.sessionTokenUsage ? metricsForSessionUsage(reviewer.sessionTokenUsage) - : tokenMetrics([reviewer]) + : tokenMetrics([reviewer]); + const duration = reviewerDurationMs(reviewer); return { reviewerId: reviewer.reviewerId || `reviewer-${index + 1}`, model, - reasoning: reviewer.reasoningApplied || 'unknown', + reasoning: reviewer.reasoningApplied || "unknown", tokenUsage, estimatedCostUsd: estimateTokenCost(model, tokenUsage), - } - }) + durationMs: duration, + }; + }); const costMetrics = (usageByReviewer) => { - const estimates = usageByReviewer.filter((reviewer) => reviewer.estimatedCostUsd !== null) - const complete = usageByReviewer.length > 0 && estimates.length === usageByReviewer.length + const estimates = usageByReviewer.filter((reviewer) => reviewer.estimatedCostUsd !== null); + const complete = usageByReviewer.length > 0 && estimates.length === usageByReviewer.length; const estimatedKnownUsd = estimates.length > 0 - ? Number(estimates.reduce((total, reviewer) => total + reviewer.estimatedCostUsd, 0).toFixed(6)) - : null + ? Number( + estimates.reduce((total, reviewer) => total + reviewer.estimatedCostUsd, 0).toFixed(6), + ) + : null; return { currency: PRICING_SNAPSHOT.currency, pricing: PRICING_SNAPSHOT, @@ -795,115 +2093,160 @@ const costMetrics = (usageByReviewer) => { complete, estimatedKnownUsd, estimatedTotalUsd: complete ? estimatedKnownUsd : null, - } -} - -const formatInteger = (value) => (Number.isFinite(value) ? new Intl.NumberFormat('en-US').format(value) : 'n/a') -const formatCost = (value) => (Number.isFinite(value) ? `$${value.toFixed(4)}` : 'n/a') + }; +}; + +const formatInteger = (value) => + Number.isFinite(value) ? new Intl.NumberFormat("en-US").format(value) : "n/a"; +const formatCost = (value) => (Number.isFinite(value) ? `$${value.toFixed(4)}` : "n/a"); +const formatDuration = (value) => { + if (!Number.isFinite(value) || value < 0) return "n/a"; + const seconds = Math.round(value / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + if (hours > 0) return `${hours}h ${minutes}m ${remainingSeconds}s`; + if (minutes > 0) return `${minutes}m ${remainingSeconds}s`; + return `${remainingSeconds}s`; +}; const reviewerLabel = (reviewerId, reasoning) => { - const parts = reviewerId.split('-') + const parts = reviewerId.split("-"); if (parts.length > 1 && /^\d+$/.test(parts.at(-1))) { - parts.splice(-2, 2, `${parts.at(-2)}${parts.at(-1)}`) + parts.splice(-2, 2, `${parts.at(-2)}${parts.at(-1)}`); } - const name = parts.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(' ') - return `${name} (${reasoning || 'unknown'})` -} + const name = parts.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(" "); + return `${name} (${reasoning || "unknown"})`; +}; + +export const reviewersMissingTelemetry = (derived) => + (() => { + const reviewers = Array.isArray(derived?.reviewerUsage) ? derived.reviewerUsage : []; + if (reviewers.length === 0) return ["no-reviewers"]; + return reviewers + .filter((reviewer) => { + const usage = reviewer.tokenUsage?.totals || {}; + const hasTokens = [ + "inputTokens", + "cachedInputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens", + ].some((field) => Number.isFinite(usage[field])); + const hasDuration = Number.isFinite(reviewer.durationMs) && reviewer.durationMs >= 0; + return !hasTokens && !hasDuration; + }) + .map((reviewer) => reviewer.reviewerId || "unknown-reviewer"); + })(); export const renderUsageTable = (derived) => { - const reviewers = Array.isArray(derived?.reviewerUsage) ? derived.reviewerUsage : [] - const fields = ['inputTokens', 'cachedInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens'] - const totals = Object.fromEntries(fields.map((field) => [field, 0])) - const coverage = Object.fromEntries(fields.map((field) => [field, 0])) + const reviewers = Array.isArray(derived?.reviewerUsage) ? derived.reviewerUsage : []; + const fields = [ + "inputTokens", + "cachedInputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens", + ]; + if (reviewersMissingTelemetry(derived).length > 0) return ""; + const totals = Object.fromEntries(fields.map((field) => [field, 0])); + const coverage = Object.fromEntries(fields.map((field) => [field, 0])); + let totalDurationMs = 0; + let durationCoverage = 0; const rows = reviewers.map((reviewer) => { - const usage = reviewer.tokenUsage?.totals || {} + const usage = reviewer.tokenUsage?.totals || {}; for (const field of fields) { if (Number.isFinite(usage[field])) { - totals[field] += usage[field] - coverage[field] += 1 + totals[field] += usage[field]; + coverage[field] += 1; } } - return `| ${reviewerLabel(reviewer.reviewerId, reviewer.reasoning)} | ${formatInteger(usage.inputTokens)} | ${formatInteger(usage.cachedInputTokens)} | ${formatInteger(usage.outputTokens)} | ${formatInteger(usage.reasoningOutputTokens)} | ${formatInteger(usage.totalTokens)} | ${formatCost(reviewer.estimatedCostUsd)} |` - }) + if (Number.isFinite(reviewer.durationMs) && reviewer.durationMs >= 0) { + totalDurationMs += reviewer.durationMs; + durationCoverage += 1; + } + return `| ${reviewerLabel(reviewer.reviewerId, reviewer.reasoning)} | ${formatInteger(usage.inputTokens)} | ${formatInteger(usage.cachedInputTokens)} | ${formatInteger(usage.outputTokens)} | ${formatInteger(usage.reasoningOutputTokens)} | ${formatInteger(usage.totalTokens)} | ${formatCost(reviewer.estimatedCostUsd)} | ${formatDuration(reviewer.durationMs)} |`; + }); const totalCells = fields.map((field) => - formatInteger(reviewers.length > 0 && coverage[field] === reviewers.length ? totals[field] : null), - ) - const pricing = derived?.estimatedCost?.pricing || PRICING_SNAPSHOT - const limitations = Array.isArray(pricing.limitations) ? pricing.limitations.join(' ') : '' + formatInteger( + reviewers.length > 0 && coverage[field] === reviewers.length ? totals[field] : null, + ), + ); return [ - '### Reviewer token usage', - '', - '| Reviewer | Input | Cached input | Output | Reasoning | Total | Estimated cost |', - '|---|---:|---:|---:|---:|---:|---:|', + "| Reviewer | Input | Cached input | Output | Reasoning | Total | Estimated cost | Agent time |", + "|---|---:|---:|---:|---:|---:|---:|---:|", ...rows, - `| **Total** | **${totalCells[0]}** | **${totalCells[1]}** | **${totalCells[2]}** | **${totalCells[3]}** | **${totalCells[4]}** | **${formatCost(derived?.estimatedCost?.estimatedTotalUsd)}** |`, - '', - `Pricing: ${pricing.serviceTier} API-equivalent rates effective ${pricing.effectiveDate} ([source](${pricing.source})). ${limitations}`, - ].join('\n') -} + `| **Total** | **${totalCells[0]}** | **${totalCells[1]}** | **${totalCells[2]}** | **${totalCells[3]}** | **${totalCells[4]}** | **${formatCost(derived?.estimatedCost?.estimatedTotalUsd)}** | **${formatDuration(durationCoverage === reviewers.length && reviewers.length > 0 ? totalDurationMs : null)}** |`, + ].join("\n"); +}; const classificationCountsFor = (findingIds, findingsById) => { - const counts = {} + const counts = {}; for (const findingId of findingIds) { - const classification = findingsById.get(findingId)?.classification || 'unclassified' - counts[classification] = (counts[classification] || 0) + 1 + const classification = findingsById.get(findingId)?.classification || "unclassified"; + counts[classification] = (counts[classification] || 0) + 1; } - return Object.fromEntries(Object.entries(counts).sort(([left], [right]) => left.localeCompare(right))) -} + return Object.fromEntries( + Object.entries(counts).sort(([left], [right]) => left.localeCompare(right)), + ); +}; const modelComparison = (reviewers, findings) => { - const groups = new Map() + const groups = new Map(); reviewers.forEach((reviewer, index) => { - const model = reviewer.modelApplied || 'unknown' - const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}` - if (!groups.has(model)) groups.set(model, { model, reviewerIds: [], reviewers: [] }) - const group = groups.get(model) - group.reviewerIds.push(reviewerId) - group.reviewers.push(reviewer) - }) + const model = reviewer.modelApplied || "unknown"; + const reviewerId = reviewer.reviewerId || `reviewer-${index + 1}`; + if (!groups.has(model)) groups.set(model, { model, reviewerIds: [], reviewers: [] }); + const group = groups.get(model); + group.reviewerIds.push(reviewerId); + group.reviewers.push(reviewer); + }); const findingsById = new Map( findings - .filter((finding) => finding && typeof finding.findingId === 'string' && finding.findingId.length > 0) + .filter( + (finding) => + finding && typeof finding.findingId === "string" && finding.findingId.length > 0, + ) .map((finding) => [finding.findingId, finding]), - ) + ); const entries = [...groups.values()].map((group) => { const initialFindingIds = [ - ...new Set(group.reviewers.flatMap((reviewer) => findingIdsFor(reviewer, 'initial'))), - ].sort() + ...new Set(group.reviewers.flatMap((reviewer) => findingIdsFor(reviewer, "initial"))), + ].sort(); const cumulativeFindingIds = [ ...new Set(group.reviewers.flatMap((reviewer) => findingIdsFor(reviewer))), - ].sort() + ].sort(); return { ...group, initialFindingIds, cumulativeFindingIds, initialValidFindingIds: initialFindingIds.filter( - (findingId) => findingsById.get(findingId)?.classification === 'valid', + (findingId) => findingsById.get(findingId)?.classification === "valid", ), - } - }) + }; + }); - const initialFrequency = new Map() + const initialFrequency = new Map(); for (const entry of entries) { for (const findingId of entry.initialFindingIds) { - initialFrequency.set(findingId, (initialFrequency.get(findingId) || 0) + 1) + initialFrequency.set(findingId, (initialFrequency.get(findingId) || 0) + 1); } } const syntheticReviewers = entries.map((entry) => ({ reviewerId: entry.model, rounds: [ - { phase: 'initial', findingIds: entry.initialFindingIds }, - { phase: 'remediation', findingIds: entry.cumulativeFindingIds }, + { phase: "initial", findingIds: entry.initialFindingIds }, + { phase: "remediation", findingIds: entry.cumulativeFindingIds }, ], - })) + })); return { byModel: entries.map((entry) => { - const initialTokenUsage = tokenMetrics(entry.reviewers, 'initial') - const cumulativeTokenUsage = tokenMetrics(entry.reviewers) + const initialTokenUsage = tokenMetrics(entry.reviewers, "initial"); + const cumulativeTokenUsage = tokenMetrics(entry.reviewers); return { model: entry.model, reviewerIds: entry.reviewerIds, @@ -926,36 +2269,40 @@ const modelComparison = (reviewers, findings) => { cumulativeTokenUsage, initialEstimatedCostUsd: estimateTokenCost(entry.model, initialTokenUsage), cumulativeEstimatedCostUsd: estimateTokenCost(entry.model, cumulativeTokenUsage), - } + }; }), - initialOverlap: overlapFor(syntheticReviewers, 'initial'), + initialOverlap: overlapFor(syntheticReviewers, "initial"), cumulativeOverlap: overlapFor(syntheticReviewers), - } -} + }; +}; export const deriveMetrics = (summary = {}) => { - assertObject(summary, 'summary') - const reviewers = Array.isArray(summary.reviewers) ? summary.reviewers : [] - const findings = Array.isArray(summary.findings) ? summary.findings : [] - const initialOverlap = overlapFor(reviewers, 'initial') - const cumulativeOverlap = overlapFor(reviewers) - const githubReviewBots = Array.isArray(summary.githubReviewBots) ? summary.githubReviewBots : [] - const usageByReviewer = reviewerUsage(reviewers) + assertObject(summary, "summary"); + const reviewers = Array.isArray(summary.reviewers) ? summary.reviewers : []; + const findings = Array.isArray(summary.findings) ? summary.findings : []; + const initialOverlap = overlapFor(reviewers, "initial"); + const cumulativeOverlap = overlapFor(reviewers); + const githubReviewBots = Array.isArray(summary.githubReviewBots) ? summary.githubReviewBots : []; + const usageByReviewer = reviewerUsage(reviewers); return { + runStatus: typeof summary.status === "string" ? summary.status : "complete", reviewerSessionCount: reviewers.length, reviewerInvocationCount: reviewers.reduce( (count, reviewer) => count + invocationsFor(reviewer).length, 0, ), continuityInvocationCount: reviewers.reduce( - (count, reviewer) => count + (Array.isArray(reviewer.continuityChecks) ? reviewer.continuityChecks.length : 0), + (count, reviewer) => + count + (Array.isArray(reviewer.continuityChecks) ? reviewer.continuityChecks.length : 0), 0, ), roundsByReviewer: reviewers.map((reviewer, index) => ({ reviewerId: reviewer.reviewerId || `reviewer-${index + 1}`, roundCount: Array.isArray(reviewer.rounds) ? reviewer.rounds.length : 0, - continuityInvocationCount: Array.isArray(reviewer.continuityChecks) ? reviewer.continuityChecks.length : 0, + continuityInvocationCount: Array.isArray(reviewer.continuityChecks) + ? reviewer.continuityChecks.length + : 0, invocationCount: invocationsFor(reviewer).length, })), reviewersWhoFoundIssues: reviewers @@ -972,145 +2319,263 @@ export const deriveMetrics = (summary = {}) => { modelComparison: modelComparison(reviewers, findings), reviewerUsage: usageByReviewer, tokenUsage: tokenMetrics(reviewers), + duration: durationMetrics(reviewers), estimatedCost: costMetrics(usageByReviewer), githubReviewBotCount: githubReviewBots.length, reviewBotLoopCount: - typeof summary.reviewBotLoopCount === 'number' && Number.isFinite(summary.reviewBotLoopCount) + typeof summary.reviewBotLoopCount === "number" && Number.isFinite(summary.reviewBotLoopCount) ? summary.reviewBotLoopCount : null, - } -} + }; +}; -export const finishRun = ({ logPath, summary = {}, timestamp, collectCodexUsage = false, sessionsRoot } = {}) => { - if (!logPath) fail('logPath is required') - assertObject(summary, 'summary') - const { events, runId } = runIdentity(logPath) - if (events.some((item) => item.event === 'run_finished')) fail(`Run is already finished: ${logPath}`) - let finalSummary = canonicalSummaryFromEvents(events, summary) - validateFinishSummary(finalSummary) - let tokenUsageCollection = null +export const finishRun = ({ + logPath, + summary = {}, + timestamp, + collectCodexUsage = false, + sessionsRoot, +} = {}) => { + if (!logPath) fail("logPath is required"); + assertObject(summary, "summary"); + const { events, runId } = runIdentity(logPath); + if (events.some((item) => item.event === "run_finished")) + fail(`Run is already finished: ${logPath}`); + let finalSummary = canonicalSummaryFromEvents(events, summary); + finalSummary = includeExpectedReviewers(finalSummary, events[0].configuration); + validateFinishSummary(finalSummary); + let tokenUsageCollection = null; if (collectCodexUsage) { - const first = events[0] + const first = events[0]; const result = collectCodexSessionUsage(finalSummary, { sessionsRoot, startedAt: first.timestamp, endedAt: timestamp || new Date().toISOString(), repoRoot: first.repo?.root, - }) - finalSummary = result.summary - tokenUsageCollection = result.collection + }); + finalSummary = result.summary; + tokenUsageCollection = result.collection; } - const derived = deriveMetrics(finalSummary) + const derived = deriveMetrics(finalSummary); const record = { schemaVersion: SCHEMA_VERSION, runId, timestamp: isoTimestamp(timestamp), - event: 'run_finished', + event: "run_finished", data: { ...finalSummary, ...(tokenUsageCollection ? { tokenUsageCollection } : {}), derived }, - } - appendFileSync(logPath, `${JSON.stringify(record)}\n`, 'utf8') - return record -} + }; + appendFileSync(logPath, `${JSON.stringify(record)}\n`, "utf8"); + return record; +}; + +export const diagnoseCodexUsage = ({ logPath, sessionsRoot, timestamp } = {}) => { + if (!logPath) fail("logPath is required"); + const { events } = runIdentity(logPath); + const finished = [...events].reverse().find((event) => event.event === "run_finished"); + if (!finished) fail(`Run is not finished: ${logPath}`); + const result = collectCodexSessionUsage( + { reviewers: finished.data?.reviewers || [] }, + { + sessionsRoot, + startedAt: events[0].timestamp, + endedAt: timestamp || new Date().toISOString(), + repoRoot: events[0].repo?.root, + }, + ); + const missingReviewerIds = reviewersMissingTelemetry(deriveMetrics(result.summary)); + return { + status: missingReviewerIds.length === 0 ? "complete" : "incomplete", + missingReviewerIds, + collection: result.collection, + }; +}; const parseOptions = (args) => { - const options = {} + const options = {}; for (let index = 0; index < args.length; index += 1) { - const argument = args[index] - if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`) - const equals = argument.indexOf('=') + const argument = args[index]; + if (!argument.startsWith("--")) fail(`Unexpected argument: ${argument}`); + const equals = argument.indexOf("="); if (equals !== -1) { - options[argument.slice(2, equals)] = argument.slice(equals + 1) - continue + options[argument.slice(2, equals)] = argument.slice(equals + 1); + continue; } - const key = argument.slice(2) - const next = args[index + 1] - if (next === undefined || next.startsWith('--')) options[key] = true + const key = argument.slice(2); + const next = args[index + 1]; + if (next === undefined || next.startsWith("--")) options[key] = true; else { - options[key] = next - index += 1 + options[key] = next; + index += 1; } } - return options -} + return options; +}; const readDataOption = (options, label) => { - if (options['data-json'] && options['data-file']) fail('Use only one of --data-json or --data-file') - let raw = '{}' - if (options['data-json']) raw = options['data-json'] - if (options['data-file']) raw = readFileSync(options['data-file'] === '-' ? 0 : options['data-file'], 'utf8') + if (options["data-json"] && options["data-file"]) + fail("Use only one of --data-json or --data-file"); + let raw = "{}"; + if (options["data-json"]) raw = options["data-json"]; + if (options["data-file"]) + raw = readFileSync(options["data-file"] === "-" ? 0 : options["data-file"], "utf8"); try { - return assertObject(JSON.parse(raw), label) + return assertObject(JSON.parse(raw), label); } catch (error) { - fail(`${label} is not valid JSON: ${error.message}`) + fail(`${label} is not valid JSON: ${error.message}`); } -} +}; const help = `Usage: review-run-log.mjs templates review-run-log.mjs start [--repo-root ] [--output-root ] [--data-json ] review-run-log.mjs append --log --event [--data-json ] + review-run-log.mjs launch-cli-reviewer --log --reviewer-id --model --reasoning --prompt-file --output-file [--sessions-root ] + review-run-log.mjs recover-cli-session --log --reviewer-id [--sessions-root ] + review-run-log.mjs inspect-cli-session --log --reviewer-id [--sessions-root ] [--stale-after-ms ] + review-run-log.mjs inspect-native-session --log --reviewer-id [--sessions-root ] [--stale-after-ms ] + review-run-log.mjs inspect-reviewers --log [--sessions-root ] [--stale-after-ms ] [--soft-deadline-ms ] [--hard-deadline-ms ] [--record] review-run-log.mjs finish --log [--collect-codex-usage] [--sessions-root ] [--data-json ] + review-run-log.mjs diagnose-codex-usage --log [--sessions-root ] review-run-log.mjs report --log Use --data-file instead of --data-json, or --data-file - to read JSON from stdin. Each command prints JSON. templates prints canonical start, event, and finish payloads; -start prints logPath and runId; finish prints the derived metrics.` +start prints logPath and runId; finish prints the derived metrics.`; -const main = () => { - const [command, ...args] = process.argv.slice(2) - if (!command || command === '--help' || command === 'help') { - process.stdout.write(`${help}\n`) - return +const main = async () => { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help" || command === "help") { + process.stdout.write(`${help}\n`); + return; } - if (command === 'templates') { - process.stdout.write(`${JSON.stringify(LOG_TEMPLATES, null, 2)}\n`) - return + if (command === "templates") { + process.stdout.write(`${JSON.stringify(LOG_TEMPLATES, null, 2)}\n`); + return; } - const options = parseOptions(args) - if (command === 'start') { + const options = parseOptions(args); + if (command === "start") { const result = startRun({ - repoRoot: options['repo-root'], - outputRoot: options['output-root'], - configuration: readDataOption(options, 'configuration'), - }) - process.stdout.write(`${JSON.stringify(result)}\n`) - return + repoRoot: options["repo-root"], + outputRoot: options["output-root"], + configuration: readDataOption(options, "configuration"), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; } - if (command === 'append') { + if (command === "append") { const result = appendEvent({ logPath: options.log, event: options.event, - data: readDataOption(options, 'data'), - }) - process.stdout.write(`${JSON.stringify(result)}\n`) - return + data: readDataOption(options, "data"), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (command === "launch-cli-reviewer") { + const result = await launchCodexCliReviewer({ + logPath: options.log, + reviewerId: options["reviewer-id"], + model: options.model, + reasoning: options.reasoning, + promptFile: options["prompt-file"], + outputFile: options["output-file"], + sessionsRoot: options["sessions-root"], + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (command === "recover-cli-session") { + const result = recoverCodexCliReviewerResult({ + logPath: options.log, + reviewerId: options["reviewer-id"], + sessionsRoot: options["sessions-root"], + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (command === "inspect-cli-session") { + const staleAfterMs = + options["stale-after-ms"] === undefined ? undefined : Number(options["stale-after-ms"]); + const result = inspectCodexCliReviewerSession({ + logPath: options.log, + reviewerId: options["reviewer-id"], + sessionsRoot: options["sessions-root"], + staleAfterMs, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (command === "inspect-native-session") { + const staleAfterMs = + options["stale-after-ms"] === undefined ? undefined : Number(options["stale-after-ms"]); + const result = inspectCodexNativeReviewerSession({ + logPath: options.log, + reviewerId: options["reviewer-id"], + sessionsRoot: options["sessions-root"], + staleAfterMs, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; } - if (command === 'finish') { + if (command === "inspect-reviewers") { + const numberOption = (name) => + options[name] === undefined ? undefined : Number(options[name]); + const result = inspectReviewerSessions({ + logPath: options.log, + sessionsRoot: options["sessions-root"], + staleAfterMs: numberOption("stale-after-ms"), + softDeadlineMs: numberOption("soft-deadline-ms"), + hardDeadlineMs: numberOption("hard-deadline-ms"), + recordObservations: Boolean(options.record), + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (command === "finish") { const result = finishRun({ logPath: options.log, - summary: readDataOption(options, 'summary'), - collectCodexUsage: Boolean(options['collect-codex-usage']), - sessionsRoot: options['sessions-root'], - }) - process.stdout.write(`${JSON.stringify({ logPath: resolve(options.log), derived: result.data.derived })}\n`) - return + summary: readDataOption(options, "summary"), + collectCodexUsage: Boolean(options["collect-codex-usage"]), + sessionsRoot: options["sessions-root"], + }); + process.stdout.write( + `${JSON.stringify({ + logPath: resolve(options.log), + derived: result.data.derived, + tokenUsageCollection: result.data.tokenUsageCollection || null, + })}\n`, + ); + return; } - if (command === 'report') { - const events = readEvents(options.log) - const finished = [...events].reverse().find((event) => event.event === 'run_finished') - if (!finished) fail(`Run is not finished: ${options.log}`) - process.stdout.write(`${renderUsageTable(finished.data?.derived)}\n`) - return + if (command === "diagnose-codex-usage") { + const result = diagnoseCodexUsage({ + logPath: options.log, + sessionsRoot: options["sessions-root"], + }); + process.stdout.write(`${JSON.stringify(result)}\n`); + return; } - fail(`Unknown command: ${command}`) -} + if (command === "report") { + const events = readEvents(options.log); + const finished = [...events].reverse().find((event) => event.event === "run_finished"); + if (!finished) fail(`Run is not finished: ${options.log}`); + const missingReviewerIds = reviewersMissingTelemetry(finished.data?.derived); + if (missingReviewerIds.length > 0) { + fail( + `Reviewer telemetry is missing for ${missingReviewerIds.join(", ")}. ` + + `Run diagnose-codex-usage, repair the ledger or session collection, then finish with --collect-codex-usage again before reporting.`, + ); + } + process.stdout.write(`${renderUsageTable(finished.data?.derived)}\n`); + return; + } + fail(`Unknown command: ${command}`); +}; -const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined; if (invokedPath === scriptPath) { - try { - main() - } catch (error) { - process.stderr.write(`review-run-log: ${error.message}\n`) - process.exitCode = 1 - } + main().catch((error) => { + process.stderr.write(`review-run-log: ${error.message}\n`); + process.exitCode = 1; + }); } diff --git a/.claude/skills/create-readme b/.claude/skills/create-readme new file mode 120000 index 0000000..e79c90a --- /dev/null +++ b/.claude/skills/create-readme @@ -0,0 +1 @@ +../../.agents/skills/create-readme \ No newline at end of file diff --git a/.github/actions/setup-windows-toolchain/action.yml b/.github/actions/setup-windows-toolchain/action.yml new file mode 100644 index 0000000..34a861b --- /dev/null +++ b/.github/actions/setup-windows-toolchain/action.yml @@ -0,0 +1,66 @@ +name: Set up Windows native toolchain +description: Restore or install Swift and cache Node development files used by native builds. + +outputs: + swift-cache-hit: + description: Whether the installed Swift SDK was restored from cache. + value: ${{ steps.swift-cache.outputs.cache-hit }} + node-gyp-cache-hit: + description: Whether Node development files were restored from cache. + value: ${{ steps.node-gyp-cache.outputs.cache-hit }} + +runs: + using: composite + steps: + # gha-setup-swift's built-in cache stores only installer.exe. Cache the + # installed SDK instead: its quiet installer takes about 75 seconds on the + # hosted Windows runner, while the restored SDK only needs its environment + # variables reconstructed below. + - name: Restore installed Swift SDK + id: swift-cache + uses: actions/cache@v4 + with: + path: ~/AppData/Local/Programs/Swift + key: swift-node-swift-windows-${{ runner.arch }}-6.3.3-RELEASE-v1 + + - name: Configure restored Swift SDK + if: steps.swift-cache.outputs.cache-hit == 'true' + shell: pwsh + run: | + $swiftRoot = Join-Path $env:LOCALAPPDATA 'Programs\Swift' + $toolchainBin = Join-Path $swiftRoot 'Toolchains\6.3.3+Asserts\usr\bin' + $runtimeBin = Join-Path $swiftRoot 'Runtimes\6.3.3\usr\bin' + $sdkRoot = Join-Path $swiftRoot 'Platforms\6.3.3\Windows.platform\Developer\SDKs\Windows.sdk' + + foreach ($path in @($toolchainBin, $runtimeBin, $sdkRoot)) { + if (-not (Test-Path $path)) { + throw "The restored Swift SDK is incomplete: $path" + } + } + + $toolchainBin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + $runtimeBin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "SDKROOT=$sdkRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Install Swift toolchain + if: steps.swift-cache.outputs.cache-hit != 'true' + uses: compnerd/gha-setup-swift@v0.4.0 + with: + swift-version: swift-6.3.3-release + swift-build: 6.3.3-RELEASE + + # node-gyp downloads Windows Node headers and node.lib on demand into this + # directory. The swift-node CLI needs those files for every C++ build. + - name: Read Node version + id: node-version + shell: pwsh + run: | + $version = node -p 'process.versions.node' + "version=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Restore Node development files + id: node-gyp-cache + uses: actions/cache@v4 + with: + path: ~/.swift-node/node-gyp + key: swift-node-node-gyp-windows-${{ runner.arch }}-${{ steps.node-version.outputs.version }}-v1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 447819c..1dbd425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,25 @@ jobs: with: node-version: '24' - - run: node scripts/run-without-node-warnings.mjs node scripts/check-package-versions.mjs + - name: Fetch main version baseline + if: github.event_name == 'pull_request' + run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + - name: Fetch previous version baseline + if: >- + github.event_name == 'push' && + github.event.before != '0000000000000000000000000000000000000000' + run: git fetch --no-tags origin ${{ github.event.before }} + + - name: Check release package versions + env: + BASELINE_REF: >- + ${{ github.event_name == 'pull_request' && 'origin/main' || + (github.event.before != '0000000000000000000000000000000000000000' && github.event.before || '') }} + run: | + command=(node scripts/run-without-node-warnings.mjs node scripts/check-package-versions.mjs) + if [[ -n "$BASELINE_REF" ]]; then command+=(--baseline-ref "$BASELINE_REF"); fi + "${command[@]}" quality: name: Format, lint, and type check @@ -56,12 +74,9 @@ jobs: node-version: '24' cache: true - - name: Install Swift toolchain + - name: Prepare Windows native toolchain if: runner.os == 'Windows' - uses: compnerd/gha-setup-swift@v0.4.0 - with: - swift-version: swift-6.3.3-release - swift-build: 6.3.3-RELEASE + uses: ./.github/actions/setup-windows-toolchain - run: node scripts/run-without-node-warnings.mjs vp install - run: node scripts/run-without-node-warnings.mjs vp -C packages/swift-node pack @@ -92,12 +107,9 @@ jobs: node-version: '24' cache: true - - name: Install Swift toolchain + - name: Prepare Windows native toolchain if: runner.os == 'Windows' - uses: compnerd/gha-setup-swift@v0.4.0 - with: - swift-version: swift-6.3.3-release - swift-build: 6.3.3-RELEASE + uses: ./.github/actions/setup-windows-toolchain - name: Verify toolchain run: | @@ -147,13 +159,6 @@ jobs: node-version: '24' cache: true - - name: Install Swift toolchain - if: runner.os == 'Windows' - uses: compnerd/gha-setup-swift@v0.4.0 - with: - swift-version: swift-6.3.3-release - swift-build: 6.3.3-RELEASE - - name: Verify toolchain run: | swiftc --version @@ -201,12 +206,9 @@ jobs: node-version: '24' cache: true - - name: Install Swift toolchain + - name: Prepare Windows native toolchain if: runner.os == 'Windows' - uses: compnerd/gha-setup-swift@v0.4.0 - with: - swift-version: swift-6.3.3-release - swift-build: 6.3.3-RELEASE + uses: ./.github/actions/setup-windows-toolchain - name: Verify toolchain run: | @@ -250,12 +252,9 @@ jobs: node-version: '24' cache: true - - name: Install Swift toolchain + - name: Prepare Windows native toolchain if: runner.os == 'Windows' - uses: compnerd/gha-setup-swift@v0.4.0 - with: - swift-version: swift-6.3.3-release - swift-build: 6.3.3-RELEASE + uses: ./.github/actions/setup-windows-toolchain - name: Verify toolchain run: | diff --git a/examples/timer-callback/test/native.test.ts b/examples/timer-callback/test/native.test.ts index 0b91924..7418c10 100644 --- a/examples/timer-callback/test/native.test.ts +++ b/examples/timer-callback/test/native.test.ts @@ -239,8 +239,9 @@ describe('timer-callback', () => { it('delivers stream failures once and does not report normal completion', async () => { const values: string[] = [] + let subscription: { readonly closed: boolean } | undefined const error = await new Promise((resolve, reject) => { - addon.streamFailure( + subscription = addon.streamFailure( (value: string) => values.push(value), resolve, () => reject(new Error('unexpected completion')), @@ -249,6 +250,7 @@ describe('timer-callback', () => { expect(values).toEqual(['before-error']) expect(error.message).toBe('expected stream failure') + expect(subscription!.closed).toBe(true) }) it('cleans live subscriptions during addon teardown', () => { diff --git a/futureplans.md b/futureplans.md index 1317af3..4299c0d 100644 --- a/futureplans.md +++ b/futureplans.md @@ -1,23 +1,141 @@ # Future Plans -This file tracks work that is intentionally outside the supported surface. +This document records intentional gaps in the currently supported `swift-node` +surface. It is a planning document, not a release commitment or a compatibility +promise. The package README remains the source of truth for supported APIs. + +## Current baseline + +The plans below build on the following supported behavior: + +- `// @swift-node:codable` types cross the bridge as Foundation JSON, but their + generated TypeScript declarations are `unknown`. +- Public structs with scalar and `String` stored fields have a lightweight, + synchronous ABI bridge. More complex models should use `Codable`. +- Streams require both `// @swift-node:export` and `// @swift-node:stream`, + return `AsyncStream` or `AsyncThrowingStream`, and use JavaScript callback + subscriptions rather than `AsyncIterable`. +- Stream elements may be scalars, `String`, JSON-safe collections, or annotated + `Codable` values. Top-level `Data` and `[UInt8]` are not stream elements. +- One-shot callbacks support the documented scalar arguments. Long-lived, + Promise-returning callbacks support the deliberately narrow + `@escaping (String, ...) async throws -> String` shape: the bridge retains + the JavaScript callback, awaits its Promise, and releases it when Swift + releases the closure. + +The last item is intentionally not a future-plan item: it was delivered by +PR #4. The remaining callback work below is an expansion of that foundation. ## Bridge coverage -- Broaden generated TypeScript declarations for `// @swift-node:codable` models beyond `unknown` (for example by parsing Codable stored properties or accepting a schema file). -- Broaden struct support beyond public structs with scalar and string fields. -- Preserve Swift error domain, code, type, and user info in a structured JavaScript error type rather than exposing only an error message. -- Add exhaustive compiled-addon coverage for Foundation decoding failures, including missing keys, nullability mismatches, keyed/unkeyed mismatches, invalid enum values, and custom `Codable` implementations. +### Generated declarations for `Codable` models + +Generate useful TypeScript types for `// @swift-node:codable` models instead of +falling back to `unknown`. Decide on a durable source of shape information +before implementing this: + +- Parse the subset of stored properties that the Swift parser can represent, or + accept an explicit schema/type-definition input from the package author. +- Model optional properties, arrays, string-keyed dictionaries, nested Codable + models, enum representations, and concrete generic specializations. +- Keep the generated declaration aligned with the JSON representation actually + encoded and decoded by Foundation, including the existing base64 treatment of + nested `Data`. +- Reject or intentionally fall back for shapes whose JSON representation cannot + be determined safely; do not emit a confident but inaccurate declaration. + +### Richer direct struct support + +Broaden the direct ABI bridge beyond public structs whose stored fields are +scalars or `String`. First define a stable layout and ownership model for each +additional category, including optionals, nested structs, binary values, and +collections. Raw ABI structs must remain unavailable across async boundaries; +`Codable` remains the appropriate transport when a layout is variable or needs +to cross an asynchronous boundary. + +### Structured Swift errors in JavaScript + +Replace message-only failures with a JavaScript error type that preserves Swift +error metadata where it is available: + +- Foundation/NSError domain, numeric code, localized description, and user + info. +- The Swift type name for non-NSError failures when it can be represented + safely. +- A stable public TypeScript declaration and `instanceof`-usable JavaScript + class, with normal `Error` fields and stack behavior retained. + +This requires an error ABI that owns and frees every transmitted field exactly +once. It must work consistently for synchronous calls, Promise rejections, +streams, and Promise callbacks. + +### Compiled-addon coverage for Foundation decoding failures + +Add executable-addon tests for decoding failures that are presently easier to +exercise only at the parser/unit-test layer. Cover missing keys, nullability +mismatches, keyed-versus-unkeyed container mismatches, invalid enum values, and +custom `Codable` implementations. Assert the observable JavaScript failure +shape for synchronous functions, async functions, and stream inputs where the +transport is available. ## Streams and callbacks -- Support `AsyncSequence` returns in addition to `AsyncStream` and `AsyncThrowingStream`. -- Infer stream behavior from an exported stream return type, removing the separate `// @swift-node:stream` annotation. -- Expose streams as JavaScript `AsyncIterable` values with cancellation, completion, error propagation, and backpressure instead of callback subscriptions. -- Support every function-bridge value type as a stream element, including top-level `Data` and `[UInt8]`. -- Broaden callback support beyond one-shot scalar `@escaping (...) -> Void` signatures, with explicit ownership and cleanup for long-lived callbacks. +### `AsyncSequence` return support + +Accept exported `AsyncSequence` returns in addition to the concrete +`AsyncStream` and `AsyncThrowingStream` types. This needs a generated adapter +with a stable ABI for iteration, cancellation, completion, and thrown errors; +it cannot assume that every sequence has the concrete storage of an +`AsyncStream`. Establish which element and failure constraints are supported +before widening validation. + +### Infer stream exports from return types + +When an exported function returns a supported stream type, generate a stream +bridge without requiring the separate `// @swift-node:stream` annotation. The +parser, validator, generated Swift, Node-API wrapper, and declarations must all +make the same decision. Once inference is reliable, remove the redundant +annotation rather than maintaining two divergent ways to opt in. + +### JavaScript `AsyncIterable` streams + +Expose streams as JavaScript `AsyncIterable` values instead of callback +subscriptions. The resulting object should: + +- implement `next()`, iterator cleanup, and explicit cancellation; +- complete normally, reject on Swift errors, and release callback/task state on + every terminal path; +- communicate demand or apply a bounded queue so a fast Swift producer cannot + grow memory without bound when JavaScript is slow; and +- preserve the current ability to cancel the Swift producer through + `AsyncStream.Continuation.onTermination`. + +### Binary stream elements + +Extend the stream ABI and generated TypeScript declarations to carry top-level +`Data` and `[UInt8]` elements as `Uint8Array`/`Buffer` values. The bridge must +copy or otherwise retain bytes until Node has finished constructing the +JavaScript value; borrowed `UnsafeRawBufferPointer` is not a valid stream +element because its lifetime ends with the originating Swift call. + +### Broader callback signatures + +Build on the shipped Promise callback bridge rather than reintroducing +one-shot-only semantics. Expand deliberately, starting with supported scalar +and `String` arguments and return values, then evaluating structured and binary +values separately. Every long-lived callback shape must define: + +- when JavaScript callback references are retained and released; +- what happens when Swift calls after JavaScript environment teardown; +- cancellation, duplicate settlement, and Promise-rejection behavior; and +- isolation tests for concurrent invocations and cleanup on every terminal + path. -## Codebase maintenance +## Suggested sequencing -- Split `packages/swift-node/src/generator.ts` into smaller modules around type mapping, wrapper generation, and entrypoint generation. -- Add integration tests for remaining bridge paths that unit tests cover but the executable addon tests do not. +1. Establish the structured error contract so future transport work has + observable, regression-resistant behavior. +2. Complete stream inference and an `AsyncIterable` protocol before widening + streams to `AsyncSequence` and binary elements. +3. Broaden declaration and direct-struct support only after their representation + and ownership rules are explicit. diff --git a/packages/swift-node-unplugin/package.json b/packages/swift-node-unplugin/package.json index 823a803..d1f3ee3 100644 --- a/packages/swift-node-unplugin/package.json +++ b/packages/swift-node-unplugin/package.json @@ -1,6 +1,6 @@ { "name": "swift-node-unplugin", - "version": "0.1.5", + "version": "0.2.0", "description": "Unplugin adapters that build and bundle Swift Node native assets.", "type": "module", "types": "./dist/index.d.ts", @@ -102,7 +102,7 @@ "test": "vp test run" }, "peerDependencies": { - "swift-node": "^0.1.5" + "swift-node": "^0.2.0" }, "dependencies": { "unplugin": "3.3.0" diff --git a/packages/swift-node/package.json b/packages/swift-node/package.json index 625c956..db56b8d 100644 --- a/packages/swift-node/package.json +++ b/packages/swift-node/package.json @@ -1,6 +1,6 @@ { "name": "swift-node", - "version": "0.1.5", + "version": "0.2.0", "type": "module", "description": "Node-API bridge for Swift. Write Node native addons in Swift without C++ glue.", "bin": { diff --git a/packages/swift-node/src/cli.ts b/packages/swift-node/src/cli.ts index a69e8ee..8f67d22 100644 --- a/packages/swift-node/src/cli.ts +++ b/packages/swift-node/src/cli.ts @@ -42,7 +42,7 @@ import { generateEntryMjs, generateEntryCjs, generateSourceEntryTs, -} from './generator.js' +} from './generator/index.js' import { compileSwift, compileCpp, diff --git a/packages/swift-node/src/generator.ts b/packages/swift-node/src/generator/addon.ts similarity index 58% rename from packages/swift-node/src/generator.ts rename to packages/swift-node/src/generator/addon.ts index 12082f2..e2f197b 100644 --- a/packages/swift-node/src/generator.ts +++ b/packages/swift-node/src/generator/addon.ts @@ -1,549 +1,42 @@ -/** - * Generates C++ addon code, bridge header, and TypeScript definitions - * from parsed Swift function metadata. - */ - import { - BridgeTransport, - SwiftFunction, - SwiftParam, - SwiftStruct, - SwiftStructField, - PromiseCallbackInfo, - bridgeTransportForType, + type BridgeTransport, + type SwiftFunction, + type SwiftParam, + type SwiftStruct, classifySwiftType, - SwiftTypeCategory, + type SwiftTypeCategory, isCallbackType, parseCallbackType, - ExportedFunction, classifyNativeSwiftType, - parseSwiftStreamReturnType, - splitParams, -} from './parser.js' - -// Sanitize name for use as a C/C++ identifier -function sanitizeId(name: string): string { - return name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&') -} - -const cppKeywords = new Set([ - 'alignas', - 'alignof', - 'and', - 'and_eq', - 'asm', - 'atomic_cancel', - 'atomic_commit', - 'atomic_noexcept', - 'auto', - 'bitand', - 'bitor', - 'bool', - 'break', - 'case', - 'catch', - 'char', - 'char8_t', - 'char16_t', - 'char32_t', - 'class', - 'compl', - 'concept', - 'const', - 'consteval', - 'constexpr', - 'constinit', - 'const_cast', - 'continue', - 'co_await', - 'co_return', - 'co_yield', - 'decltype', - 'default', - 'delete', - 'do', - 'double', - 'dynamic_cast', - 'else', - 'enum', - 'explicit', - 'export', - 'extern', - 'false', - 'float', - 'for', - 'friend', - 'goto', - 'if', - 'inline', - 'int', - 'long', - 'mutable', - 'namespace', - 'new', - 'noexcept', - 'not', - 'not_eq', - 'nullptr', - 'operator', - 'or', - 'or_eq', - 'private', - 'protected', - 'public', - 'reflexpr', - 'register', - 'reinterpret_cast', - 'requires', - 'return', - 'short', - 'signed', - 'sizeof', - 'static', - 'static_assert', - 'static_cast', - 'struct', - 'switch', - 'synchronized', - 'template', - 'this', - 'thread_local', - 'throw', - 'true', - 'try', - 'typedef', - 'typeid', - 'typename', - 'union', - 'unsigned', - 'using', - 'virtual', - 'void', - 'volatile', - 'wchar_t', - 'while', - 'xor', - 'xor_eq', -]) - -export function cppIdentifier(name: string): string { - const identifier = sanitizeId(name) - return cppKeywords.has(identifier) ? `_swift_node_${identifier}` : identifier -} - -// Derive JS-facing name from a symbol like "ModuleName_funcName" -function jsName(symbolName: string, moduleName: string): string { - const sanitized = sanitizeId(moduleName) - if (symbolName.startsWith(sanitized + '_')) { - return symbolName.slice(sanitized.length + 1) - } - // No module prefix found — use full symbol name to avoid collisions - return symbolName -} - -// --- C++ type mapping --- - -function cppType(swiftType: string): string { - if (swiftType === 'UnsafeRawPointer' || swiftType === 'UnsafeRawPointer?') return 'const void*' - const cat = classifySwiftType(swiftType) - switch (cat) { - case 'int32': - return 'int32_t' - case 'int64': - return 'int64_t' - case 'double': - return 'double' - case 'bool': - return 'bool' - case 'string': - return 'const char*' - case 'buffer': - return 'const uint8_t*' - case 'void': - return 'void' - default: - return 'void*' - } -} - -function wireReturnType(fn: SwiftFunction): string { - return fn.nativeReturnType || fn.returnType -} - -// C++ type from a native Swift type category (used for export-generated bridge code) -function cppTypeFromCategory(cat: SwiftTypeCategory): string { - switch (cat) { - case 'int32': - return 'int32_t' - case 'int64': - return 'int64_t' - case 'double': - return 'double' - case 'bool': - return 'bool' - case 'string': - return 'const char*' - case 'void': - return 'void' - default: - return 'void*' - } -} - -function cppReturnType(swiftType: string): string { - if (swiftType.includes('UnsafeMutablePointer')) return 'char*' - return cppType(swiftType) -} - -function tsType(swiftType: string): string { - const cat = classifySwiftType(swiftType) - const nullable = swiftType.endsWith('?') - const base = (() => { - switch (cat) { - case 'int32': - return 'number' - case 'int64': - return 'number' - case 'double': - return 'number' - case 'bool': - return 'boolean' - case 'string': - return 'string' - case 'buffer': - return 'Buffer' - case 'void': - return 'void' - case 'callback': - return '(...args: any[]) => void' - default: - return 'unknown' - } - })() - return nullable && base !== 'void' ? `${base} | null` : base -} - -function isNullableType(swiftType: string): boolean { - return swiftType.replace(/\s+/g, ' ').trim().endsWith('?') -} - -function shorthandDictionaryValueType(type: string): string | null { - if (!type.startsWith('[') || !type.endsWith(']')) return null - - const contents = type.slice(1, -1) - let depth = 0 - for (let index = 0; index < contents.length; index++) { - const character = contents[index] - if (character === '[' || character === '<' || character === '(') depth++ - else if (character === ']' || character === '>' || character === ')') depth-- - else if (character === ':' && depth === 0) { - return contents.slice(0, index) === 'String' ? contents.slice(index + 1) : null - } - } - - return null -} - -// TypeScript type from native Swift type (for export-generated .d.ts) -function tsTypeFromNative(swiftType: string, dataAsBase64 = false): string { - const normalized = swiftType.replace(/\s+/g, '') - const nullable = normalized.endsWith('?') - const baseType = nullable ? normalized.slice(0, -1) : normalized - const genericDictionary = baseType.match(/^Dictionary<(.*)>$/) - const dictionaryArgs = genericDictionary ? splitParams(genericDictionary[1]) : [] - const dictionaryValue = - dictionaryArgs.length === 2 && dictionaryArgs[0].replace(/\s+/g, '') === 'String' - ? dictionaryArgs[1] - : shorthandDictionaryValueType(baseType) - if (dictionaryValue) { - const type = `Record` - return nullable ? `${type} | null` : type - } - - const arrayMatch = baseType.match(/^\[(.*)\]$/) || baseType.match(/^Array<(.*)>$/) - if (arrayMatch) { - const element = tsTypeFromNative(arrayMatch[1], dataAsBase64) - const type = `${element.includes(' | ') ? `(${element})` : element}[]` - return nullable ? `${type} | null` : type - } - if (baseType === 'Data') - return `${dataAsBase64 ? 'string' : 'Uint8Array'}${nullable ? ' | null' : ''}` - if (baseType === 'UnsafeRawBufferPointer') return 'Uint8Array' - const cat = classifyNativeSwiftType(swiftType) - const base = (() => { - switch (cat) { - case 'int32': - return 'number' - case 'int64': - return 'number' - case 'double': - return 'number' - case 'bool': - return 'boolean' - case 'string': - return 'string' - case 'buffer': - return 'Buffer' - case 'void': - return 'void' - case 'callback': - return '(...args: any[]) => void' - default: - return 'unknown' - } - })() - return nullable && base !== 'void' ? `${base} | null` : base -} - -function tsParamType(param: SwiftParam): string { - if (param.transport === 'data' || param.transport === 'borrowed') return 'Uint8Array' - return param.nativeType - ? tsTypeFromNative(param.nativeType, param.transport === 'json') - : tsType(param.type) -} - -function tsReturnType(fn: SwiftFunction, structs: SwiftStruct[]): string { - const nativeType = wireReturnType(fn) - if (fn.returnTransport === 'data') return 'Uint8Array' - if (fn.returnTransport) - return fn.nativeReturnType - ? tsTypeFromNative(nativeType, fn.returnTransport === 'json') - : tsType(fn.returnType) - const struct = findStruct(nativeType, structs) - if (struct) return struct.name - return fn.nativeReturnType ? tsTypeFromNative(nativeType) : tsType(fn.returnType) -} - -function tsCallbackType(swiftType: string): string { - const cb = parseCallbackType(swiftType) - if (!cb) return '(...args: any[]) => void' +} from '../parser.js' - const params = cb.params.map((p, i) => { - const cat = p.type - let tsT = 'any' - if (cat === 'string') tsT = isNullableType(p.swiftType) ? 'string | null' : 'string' - else if (cat === 'int32') tsT = 'number' - else if (cat === 'int64') tsT = 'number' - else if (cat === 'double') tsT = 'number' - else if (cat === 'bool') tsT = 'boolean' - else if (cat === 'buffer') tsT = 'Float32Array' - return `arg${i}: ${tsT}` - }) +import { + cppIdentifier, + cppReturnType, + cppType, + cppTypeFromCategory, + findStruct, + isNullableType, + jsName, + jsParams, + streamElementUsesStringLength, +} from './shared.js' - if (cb.isAsync && cb.throws && cb.returnType === 'String') { - return `(${params.join(', ')}) => string | Promise` - } +// --- C++ addon generation --- - return `(${params.join(', ')}) => void` +function callbackParamCategory(type: string): SwiftTypeCategory { + const generated = classifySwiftType(type) + return generated === 'unknown' ? classifyNativeSwiftType(type) : generated } -// Does this function use an error output parameter? function hasErrorOutParam(fn: SwiftFunction): boolean { return fn.params.some((p) => p.type.includes('UnsafeMutablePointer?>')) } -// Filter out error output params and callback params for the JS-facing signature count -function jsParams(fn: SwiftFunction): SwiftParam[] { - return fn.params.filter( - (p) => - !p.type.includes('UnsafeMutablePointer?>') && - !p.bridgeStringLengthFor && - !p.bridgeStringResultLength && - !p.bridgeBorrowedBufferLengthFor && - !p.callbackContext && - !p.promiseCallbackRelease, - ) -} - -// Check if function has a callback parameter function getCallbackParam(fn: SwiftFunction): SwiftParam | null { return fn.params.find((p) => isCallbackType(p.type)) || null } -function promiseCallbackInfo(type: string): PromiseCallbackInfo | null { - const callback = parseCallbackType(type) - if (!callback || !callback.isAsync || !callback.throws || callback.returnType !== 'String') { - return null - } - - if (callback.params.some((parameter) => parameter.swiftType.replace(/\s+/g, '') !== 'String')) { - return null - } - - return { params: callback.params, returnType: callback.returnType } -} - -// --- Bridge header generation --- - -function findStruct(typeName: string, structs: SwiftStruct[]): SwiftStruct | undefined { - // Match by Swift name (Point) or C name (swift_node_Point) - return structs.find((s) => s.name === typeName || `swift_node_${s.name}` === typeName) -} - -// Generate C struct typedefs for bridge header -function generateCStructDefs(structs: SwiftStruct[]): string[] { - const lines: string[] = [] - for (const s of structs) { - lines.push(`typedef struct {`) - for (const f of s.fields) { - const fieldName = cppIdentifier(f.name) - switch (f.category) { - case 'int32': - lines.push(` int32_t ${fieldName};`) - break - case 'int64': - lines.push(` int64_t ${fieldName};`) - break - case 'double': - lines.push(` double ${fieldName};`) - break - case 'bool': - lines.push(` bool ${fieldName};`) - break - case 'string': - lines.push(` const char* ${fieldName};`) - lines.push(` size_t ${fieldName}_len;`) - break - } - } - lines.push(`} swift_node_${s.name};`) - lines.push('') - } - return lines -} - -// Standalone header with just struct typedefs — imported into Swift via -import-objc-header -export function generateStructsHeader(structs: SwiftStruct[]): string { - const lines = [ - '// Generated by swift-node — do not edit', - '#ifndef SWIFT_NODE_STRUCTS_H', - '#define SWIFT_NODE_STRUCTS_H', - '', - '#include ', - '#include ', - '#include ', - '', - ...generateCStructDefs(structs), - '#endif', - ] - return lines.join('\n') -} - -export function generateBridgeH( - functions: SwiftFunction[], - moduleName: string, - structs: SwiftStruct[] = [], -): string { - const guard = `${sanitizeId(moduleName).toUpperCase()}_BRIDGE_H` - const lines: string[] = [ - `#ifndef ${guard}`, - `#define ${guard}`, - '', - '#include ', - '#include ', - '#include ', - '', - '#ifdef __cplusplus', - 'extern "C" {', - '#endif', - '', - ] - - // Struct typedefs (before function declarations that reference them) - if (structs.length > 0) { - lines.push(...generateCStructDefs(structs)) - } - - for (const fn of functions) { - if (fn.stream) { - const params = fn.params.map((p) => { - const paramName = cppIdentifier(p.name) - if (p.type.includes('UnsafeMutablePointer')) return `char* ${paramName}` - const pStruct = findStruct(p.type, structs) - if (pStruct) return `swift_node_${pStruct.name} ${paramName}` - return `${cppType(p.type)} ${paramName}` - }) - const valueType = - fn.stream.transport === 'json' - ? 'const char*' - : cppTypeFromCategory(classifyNativeSwiftType(fn.stream.elementType)) - const valueHasLength = streamElementUsesStringLength( - fn.stream.elementType, - fn.stream.transport, - ) - params.push('int64_t subscription_id') - params.push(`void (*on_value)(int64_t, ${valueType}${valueHasLength ? ', int64_t' : ''})`) - params.push('void (*on_complete)(int64_t, const char*)') - lines.push(`void ${fn.symbolName}(${params.join(', ')});`) - lines.push(`void ${fn.symbolName}_cancel(int64_t subscription_id);`) - continue - } - - const ret = cppReturnType(fn.returnType) - const params = fn.params - .map((p) => { - const paramName = cppIdentifier(p.name) - if (p.bridgeStringResultLength) return `int64_t* ${paramName}` - if (p.type.includes('UnsafeMutablePointer?>')) { - return 'const char** out_error' - } - if (p.type.includes('UnsafeMutablePointer')) { - return `char* ${paramName}` - } - if (p.promiseCallback) { - const callbackParams = p.promiseCallback.params - .flatMap((parameter) => { - const type = parameter.swiftType - return classifyNativeSwiftType(type) === 'string' - ? [`const char*`, 'int64_t'] - : [cppType(type)] - }) - .join(', ') - return `void (*${paramName})(void*, ${callbackParams}${callbackParams ? ', ' : ''}void (*)(void*, const char*, int64_t, const char*, int64_t), void*)` - } - if (p.promiseCallbackRelease) { - return `void (*${paramName})(void*)` - } - if (isCallbackType(p.type)) { - // Generate the C callback function pointer signature - const cb = parseCallbackType(p.type) - if (cb) { - const cbParams = cb.params.map((cp) => cppType(cp.swiftType)).join(', ') - return `void (*${paramName})(${cbParams})` - } - return `void (*${paramName})(void)` - } - // Check for struct type - const pStruct = findStruct(p.type, structs) - if (pStruct) { - return `swift_node_${pStruct.name} ${paramName}` - } - const t = cppType(p.type) - return `${t} ${paramName}` - }) - .join(', ') - - // Handle struct return types - let retType = ret - const retStruct2 = findStruct(fn.returnType, structs) - if (retStruct2) { - retType = `swift_node_${retStruct2.name}` - } - - lines.push(`${retType} ${fn.symbolName}(${params});`) - } - - lines.push('', '#ifdef __cplusplus', '}', '#endif', '', `#endif`) - return lines.join('\n') -} - -// --- C++ addon generation --- - -function callbackParamCategory(type: string): SwiftTypeCategory { - const generated = classifySwiftType(type) - return generated === 'unknown' ? classifyNativeSwiftType(type) : generated -} - function generatePromiseCallbackTrampoline(fn: SwiftFunction, cbParam: SwiftParam): string { const info = cbParam.promiseCallback if (!info) return '' @@ -977,11 +470,7 @@ function generateCallbackTrampoline(fn: SwiftFunction, cbParam: SwiftParam): str return lines.join('\n') } -function generateJsWrapper( - fn: SwiftFunction, - moduleName: string, - structs: SwiftStruct[] = [], -): string { +function generateJsWrapper(fn: SwiftFunction, structs: SwiftStruct[] = []): string { if (getCallbackParam(fn)) { return generateCallbackWrapper(fn) } @@ -2186,1244 +1675,42 @@ function generateStructFree(s: SwiftStruct): string { return lines.join('\n') } -// --- Swift wrapper generation for export annotations --- - -// Map a native Swift type to its C-compatible equivalent for @_cdecl wrappers -function nativeToCdeclType(type: string, isReturn: boolean): string { - const cat = classifyNativeSwiftType(type) - const nullable = type.endsWith('?') - switch (cat) { - case 'string': - if (isReturn) return nullable ? 'UnsafeMutablePointer?' : 'UnsafeMutablePointer' - return nullable ? 'UnsafePointer?' : 'UnsafePointer' - case 'buffer': - return 'UnsafePointer' - case 'int32': - return 'Int32' - case 'int64': - return swiftBaseType(type) === 'Int64' ? 'Int64' : 'Int' - case 'double': - return 'Double' - case 'bool': - return 'Bool' - case 'void': - return 'Void' - default: - return type - } -} - -function swiftBaseType(type: string): string { - return type.replace(/\s+/g, ' ').trim().replace(/\?$/, '').trim() -} - -function isNativeStringField(field: SwiftStructField): boolean { - return swiftBaseType(field.type) === 'String' -} - -function swiftStructInputValue(paramName: string, field: SwiftStructField): string { - const fieldName = cppIdentifier(field.name) - if (field.category === 'string') { - return isNativeStringField(field) - ? `swiftNodeDecodeUTF8(${paramName}.${fieldName}, ${paramName}.${fieldName}_len)` - : `${paramName}.${fieldName}` - } - if (field.category === 'int64' && swiftBaseType(field.type) === 'Int') { - return `Int(${paramName}.${fieldName})` - } - if (field.category === 'double' && swiftBaseType(field.type) === 'Float') { - return `Float(${paramName}.${fieldName})` - } - return `${paramName}.${fieldName}` -} - -function swiftStructReturnValue(field: SwiftStructField): string { - if (field.category === 'int64' && swiftBaseType(field.type) === 'Int') { - return `Int64(result.${field.name})` - } - if (field.category === 'double' && swiftBaseType(field.type) === 'Float') { - return `Double(result.${field.name})` - } - return `result.${field.name}` -} - -// Generate the Swift call expression with proper argument labels -function generateSwiftCall(fn: ExportedFunction): string { - if (fn.params.length === 0) return `${fn.name}()` - const args = fn.params.map((p) => { - // Escape Swift keywords with backticks - const callName = `swift_${p.name}` - const cat = classifyNativeSwiftType(p.type) - const conversion = cat === 'string' ? callName : `swift_${p.name}` - if (p.label === '_') return conversion - if (p.label === p.name) return `${p.label}: ${conversion}` - return `${p.label}: ${conversion}` - }) - return `${fn.name}(${args.join(', ')})` -} - -function emitSwiftCallbackBridgeCall( - lines: string[], - callbackName: string, - callbackContext: string, - cbParamTypes: string[], - indent: string, - startIndex = 0, - cArgs: string[][] = [], -): void { - const nextStringIndex = cbParamTypes.findIndex( - (t, i) => i >= startIndex && classifyNativeSwiftType(t) === 'string', - ) - - if (nextStringIndex === -1) { - const callArgs = [ - callbackContext, - ...cbParamTypes.flatMap( - (type, i) => - cArgs[i] ?? - (classifyNativeSwiftType(type) === 'string' - ? [`cbArg${i}`, `cbArg${i}.utf8.count`] - : [`cbArg${i}`]), - ), - ] - lines.push(`${indent}${callbackName}(${callArgs.join(', ')})`) - return - } - - const argName = `cbArg${nextStringIndex}` - const cName = `cStr${nextStringIndex}` - const type = cbParamTypes[nextStringIndex] - - if (isNullableType(type)) { - lines.push(`${indent}if let ${argName} = ${argName} {`) - lines.push(`${indent} ${argName}.withCString { ${cName} in`) - const withArgs = [...cArgs] - withArgs[nextStringIndex] = [cName, `${argName}.utf8.count`] - emitSwiftCallbackBridgeCall( - lines, - callbackName, - callbackContext, - cbParamTypes, - `${indent} `, - nextStringIndex + 1, - withArgs, - ) - lines.push(`${indent} }`) - lines.push(`${indent}} else {`) - const nilArgs = [...cArgs] - nilArgs[nextStringIndex] = ['nil', '0'] - emitSwiftCallbackBridgeCall( - lines, - callbackName, - callbackContext, - cbParamTypes, - `${indent} `, - nextStringIndex + 1, - nilArgs, - ) - lines.push(`${indent}}`) - return - } - - lines.push(`${indent}${argName}.withCString { ${cName} in`) - const withArgs = [...cArgs] - withArgs[nextStringIndex] = [cName, `${argName}.utf8.count`] - emitSwiftCallbackBridgeCall( - lines, - callbackName, - callbackContext, - cbParamTypes, - `${indent} `, - nextStringIndex + 1, - withArgs, - ) - lines.push(`${indent}}`) -} - -// The generated Swift half of a stream owns the Task that iterates the source -// AsyncStream. The C++ half owns JavaScript callback references. Both sides use -// the same subscription id, so cancellation can race safely with completion. -function generateSwiftStreamRuntime(): string { - return `private final class SwiftNodeStreamTask: @unchecked Sendable { - private let lock = NSLock() - private var task: Task? - private var cancelled = false - - func install(_ task: Task) { - lock.lock() - self.task = task - let shouldCancel = cancelled - lock.unlock() - if shouldCancel { task.cancel() } - } - - func cancel() { - lock.lock() - cancelled = true - let task = task - lock.unlock() - task?.cancel() - } -} - -private enum SwiftNodeStreamRegistry { - private static let lock = NSLock() - nonisolated(unsafe) private static var entries: [Int64: SwiftNodeStreamTask] = [:] - - static func reserve(_ id: Int64) -> SwiftNodeStreamTask { - let entry = SwiftNodeStreamTask() - lock.lock() - entries[id] = entry - lock.unlock() - return entry - } - - static func finish(_ id: Int64) { - lock.lock() - entries.removeValue(forKey: id) - lock.unlock() - } - - static func cancel(_ id: Int64) { - lock.lock() - let entry = entries.removeValue(forKey: id) - lock.unlock() - entry?.cancel() - } -} - -private func swiftNodeStreamComplete( - _ subscriptionID: Int64, - _ callback: @convention(c) (Int64, UnsafePointer?) -> Void, - _ error: Error? = nil -) { - guard let error else { - callback(subscriptionID, nil) - return - } - let encoded = swiftNodeBridgeError(error) - defer { free(encoded) } - callback(subscriptionID, UnsafePointer(encoded)) -}` -} - -function generatePromiseCallbackSwiftRuntime( - fn: ExportedFunction, - parameter: ExportedFunction['params'][number], -): string { - const info = promiseCallbackInfo(parameter.type) - if (!info) return '' - - const prefix = `${sanitizeId(fn.name)}_${sanitizeId(parameter.name)}` - const cParameters = info.params.flatMap(() => ['UnsafePointer', 'Int']).join(', ') +function generateStreamSubscriptionCpp(fn: SwiftFunction, structs: SwiftStruct[]): string { + const stream = fn.stream! + const prefix = fn.symbolName + const params = fn.params + const sourceParams = jsParams(fn) + const usesJsonTransport = stream.transport === 'json' + const valueCategory = classifyNativeSwiftType(stream.elementType) + const valueType = usesJsonTransport ? 'const char*' : cppTypeFromCategory(valueCategory) + const valueHasLength = streamElementUsesStringLength(stream.elementType, stream.transport) const lines: string[] = [] lines.push( - `public typealias SwiftNodePromiseCompletion_${prefix} = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int, UnsafePointer?, Int) -> Void`, - ) - lines.push( - `public typealias SwiftNodePromiseInvoke_${prefix} = @convention(c) (UnsafeMutableRawPointer?${cParameters ? `, ${cParameters}` : ''}, SwiftNodePromiseCompletion_${prefix}, UnsafeMutableRawPointer?) -> Void`, - ) - lines.push( - `public typealias SwiftNodePromiseRelease_${prefix} = @convention(c) (UnsafeMutableRawPointer?) -> Void`, - ) - lines.push(`private final class SwiftNodePromiseContinuation_${prefix}: @unchecked Sendable {`) - lines.push(` private let lock = NSLock()`) - lines.push(` private var continuation: CheckedContinuation?`) - lines.push( - ` init(_ continuation: CheckedContinuation) { self.continuation = continuation }`, + `enum StreamMessageKind_${prefix} { stream_message_value_${prefix}, stream_message_error_${prefix}, stream_message_complete_${prefix} };`, ) + lines.push(`struct StreamMessage_${prefix} {`) + lines.push(` StreamMessageKind_${prefix} kind;`) + if (usesJsonTransport || valueCategory === 'string') lines.push(' char* value;') + else lines.push(` ${valueType} value;`) + if (valueHasLength) lines.push(' size_t value_len;') + lines.push(' char* error;') + lines.push('};') + lines.push('') + lines.push(`struct StreamState_${prefix} {`) + lines.push(' int64_t subscription_id;') + lines.push(' std::atomic closed{false};') + lines.push(' std::atomic cancelled{false};') + lines.push(' napi_threadsafe_function tsfn = nullptr;') + lines.push(' napi_ref on_value = nullptr;') + lines.push(' napi_ref on_error = nullptr;') + lines.push(' napi_ref on_complete = nullptr;') + lines.push('};') + lines.push(`struct StreamHandle_${prefix} { std::shared_ptr state; };`) + lines.push(`static std::atomic next_stream_id_${prefix}{1};`) + lines.push(`static std::mutex streams_mutex_${prefix};`) lines.push( - ` func resume(_ value: UnsafePointer?, _ valueLength: Int, _ error: UnsafePointer?, _ errorLength: Int) {`, - ) - lines.push(` lock.lock()`) - lines.push(` let continuation = self.continuation`) - lines.push(` self.continuation = nil`) - lines.push(` lock.unlock()`) - lines.push(` guard let continuation else { return }`) - lines.push( - ` if let error { continuation.resume(throwing: NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: swiftNodeDecodeUTF8(error, errorLength)])); return }`, - ) - lines.push( - ` guard let value else { continuation.resume(throwing: NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "JavaScript callback resolved without a value"])); return }`, - ) - lines.push(` continuation.resume(returning: swiftNodeDecodeUTF8(value, valueLength))`) - lines.push(` }`) - lines.push(`}`) - lines.push( - `private func swiftNodePromiseComplete_${prefix}(_ context: UnsafeMutableRawPointer?, _ value: UnsafePointer?, _ valueLength: Int, _ error: UnsafePointer?, _ errorLength: Int) {`, - ) - lines.push(` guard let context else { return }`) - lines.push( - ` Unmanaged.fromOpaque(context).takeRetainedValue().resume(value, valueLength, error, errorLength)`, - ) - lines.push(`}`) - lines.push(`private final class SwiftNodePromiseHandler_${prefix}: @unchecked Sendable {`) - lines.push(` let invoke: SwiftNodePromiseInvoke_${prefix}`) - lines.push(` let context: UnsafeMutableRawPointer?`) - lines.push(` let release: SwiftNodePromiseRelease_${prefix}`) - lines.push( - ` init(invoke: @escaping SwiftNodePromiseInvoke_${prefix}, context: UnsafeMutableRawPointer?, release: @escaping SwiftNodePromiseRelease_${prefix}) { self.invoke = invoke; self.context = context; self.release = release }`, - ) - lines.push(` deinit { release(context) }`) - lines.push( - ` func call(${info.params.map((_, index) => `_ callbackArg${index}: String`).join(', ')}) async throws -> String {`, - ) - lines.push(` try await withCheckedThrowingContinuation { continuation in`) - lines.push( - ` let pending = Unmanaged.passRetained(SwiftNodePromiseContinuation_${prefix}(continuation)).toOpaque()`, - ) - if (info.params.length === 0) { - lines.push(` invoke(context, swiftNodePromiseComplete_${prefix}, pending)`) - } else { - const emit = (index: number, indent: string): void => { - if (index === info.params.length) { - const callArguments = Array.from({ length: info.params.length }, (_, argumentIndex) => [ - `cArg${argumentIndex}`, - `callbackArg${argumentIndex}.utf8.count`, - ]).flat() - lines.push( - `${indent}invoke(context, ${callArguments.join(', ')}, swiftNodePromiseComplete_${prefix}, pending)`, - ) - return - } - lines.push(`${indent}callbackArg${index}.withCString { cArg${index} in`) - emit(index + 1, `${indent} `) - lines.push(`${indent}}`) - } - emit(0, ' ') - } - lines.push(` }`) - lines.push(` }`) - lines.push(`}`) - - return lines.join('\n') -} - -function streamElementCdeclType(type: string, transport?: BridgeTransport): string { - if (transport === 'json') return 'UnsafePointer' - const cdeclType = nativeToCdeclType(type, false) - return classifyNativeSwiftType(type) === 'string' ? `${cdeclType}, Int` : cdeclType -} - -function streamElementUsesStringLength(type: string, transport?: BridgeTransport): boolean { - return transport !== 'json' && classifyNativeSwiftType(type) === 'string' -} - -function streamElementCallValue(type: string, valueName: string): string { - const normalized = type.replace(/\s+/g, '') - const category = classifyNativeSwiftType(type) - if (category === 'double' && normalized === 'Float') return `Double(${valueName})` - return valueName -} - -function emitSwiftStreamValue( - lines: string[], - elementType: string, - indent: string, - transport?: BridgeTransport, -): void { - if (transport === 'json') { - lines.push(`${indent}guard let encoded = try? JSONEncoder().encode(value) else {`) - lines.push( - `${indent} swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not encode stream value"]))`, - ) - lines.push(`${indent} return`) - lines.push(`${indent}}`) - lines.push( - `${indent}String(decoding: encoded, as: UTF8.self).withCString { on_value(subscription_id, $0) }`, - ) - return - } - const category = classifyNativeSwiftType(elementType) - if (category === 'string') { - if (isNullableType(elementType)) { - lines.push(`${indent}if let value {`) - lines.push( - `${indent} value.withCString { on_value(subscription_id, $0, value.utf8.count) }`, - ) - lines.push(`${indent}} else {`) - lines.push(`${indent} on_value(subscription_id, nil, 0)`) - lines.push(`${indent}}`) - } else { - lines.push(`${indent}value.withCString { on_value(subscription_id, $0, value.utf8.count) }`) - } - return - } - lines.push(`${indent}on_value(subscription_id, ${streamElementCallValue(elementType, 'value')})`) -} - -function generateSingleStreamWrapper( - fn: ExportedFunction, - moduleName: string, - structs: SwiftStruct[] = [], - codableTypes: Iterable = [], -): string { - const stream = parseSwiftStreamReturnType(fn.returnType) - if (!stream) - throw new Error(`Stream export '${fn.name}' has an unsupported return type '${fn.returnType}'.`) - - const lines: string[] = [] - const symbol = `${sanitizeId(moduleName)}_${fn.name}` - const wrapperName = `_sn_${sanitizeId(moduleName)}_${fn.name}` - const paramTransports = new Map( - fn.params.map((p) => [p.name, generatedTransport(p.type, codableTypes)]), - ) - const elementTransport = - generatedTransport(stream.elementType, codableTypes) === 'json' ? 'json' : undefined - const cdeclParams: string[] = fn.params.map((p) => { - const category = classifyNativeSwiftType(p.type) - const transport = paramTransports.get(p.name) - if (transport) return `_ ${p.name}: UnsafePointer` - const struct = findStruct(p.type, structs) - if (struct) return `_ ${p.name}: swift_node_${struct.name}` - if (category === 'buffer') return `_ ${p.name}: UnsafePointer, _ ${p.name}Len: Int` - if (category === 'string') - return `_ ${p.name}: ${nativeToCdeclType(p.type, false)}, _ ${p.name}Len: Int` - return `_ ${p.name}: ${nativeToCdeclType(p.type, false)}` - }) - cdeclParams.push('_ subscription_id: Int64') - cdeclParams.push( - `_ on_value: @convention(c) (Int64, ${streamElementCdeclType(stream.elementType, elementTransport)}) -> Void`, - ) - cdeclParams.push('_ on_complete: @convention(c) (Int64, UnsafePointer?) -> Void') - - lines.push(`@_cdecl("${symbol}")`) - lines.push(`public func ${wrapperName}(${cdeclParams.join(', ')}) {`) - - // Decode parameters before reserving the generated Task. A malformed JS - // value is still reported through the subscription's onError callback. - for (const p of fn.params) { - const category = classifyNativeSwiftType(p.type) - const struct = findStruct(p.type, structs) - const transport = paramTransports.get(p.name) - if (transport === 'json') { - lines.push( - ` guard let swift_${p.name} = try? JSONDecoder().decode(${p.type}.self, from: Data(String(cString: ${p.name}).utf8)) else {`, - ) - lines.push( - ` swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not decode stream argument '${p.name}'"]))`, - ) - lines.push(' return') - lines.push(' }') - } else if (transport === 'data') { - const binaryName = - p.type.replace(/\s+/g, '') === '[UInt8]' ? `binary_${p.name}` : `swift_${p.name}` - lines.push( - ` guard let ${binaryName} = Data(base64Encoded: String(cString: ${p.name})) else {`, - ) - lines.push( - ` swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not decode stream argument '${p.name}'"]))`, - ) - lines.push(' return') - lines.push(' }') - if (p.type.replace(/\s+/g, '') === '[UInt8]') - lines.push(` let swift_${p.name} = [UInt8](${binaryName})`) - } else if (struct) { - const fields = struct.fields.map( - (field) => `${field.name}: ${swiftStructInputValue(p.name, field)}`, - ) - lines.push(` let swift_${p.name} = ${struct.name}(${fields.join(', ')})`) - } else if (category === 'buffer') { - lines.push(` let swift_${p.name} = Data(bytes: ${p.name}, count: ${p.name}Len)`) - } else if (category === 'string') { - if (p.type.endsWith('?')) - lines.push( - ` let swift_${p.name}: String? = ${p.name}.map { swiftNodeDecodeUTF8($0, ${p.name}Len) }`, - ) - else lines.push(` let swift_${p.name} = swiftNodeDecodeUTF8(${p.name}, ${p.name}Len)`) - } else { - lines.push(` let swift_${p.name} = ${p.name}`) - } - } - - const call = generateSwiftCall(fn) - lines.push(' let registration = SwiftNodeStreamRegistry.reserve(subscription_id)') - lines.push(' let task = Task {') - lines.push(' do {') - lines.push( - ` let stream = ${fn.throws ? 'try ' : ''}${fn.isAsync ? 'await ' : ''}${call}`, - ) - if (stream.isThrowing) lines.push(' for try await value in stream {') - else lines.push(' for await value in stream {') - lines.push(' if Task.isCancelled { break }') - emitSwiftStreamValue(lines, stream.elementType, ' ', elementTransport) - lines.push(' }') - lines.push( - ' if !Task.isCancelled { swiftNodeStreamComplete(subscription_id, on_complete) }', - ) - lines.push(' } catch is CancellationError {') - lines.push(' // JS cancellation intentionally has no terminal callback.') - lines.push(' } catch {') - lines.push( - ' if !Task.isCancelled { swiftNodeStreamComplete(subscription_id, on_complete, error) }', - ) - lines.push(' }') - lines.push(' SwiftNodeStreamRegistry.finish(subscription_id)') - lines.push(' }') - lines.push(' registration.install(task)') - lines.push('}') - lines.push('') - lines.push(`@_cdecl("${symbol}_cancel")`) - lines.push(`public func ${wrapperName}_cancel(_ subscription_id: Int64) {`) - lines.push(' SwiftNodeStreamRegistry.cancel(subscription_id)') - lines.push('}') - return lines.join('\n') -} - -// Generate a single Swift wrapper function for an exported function -function generatedTransport(type: string, codableTypes: Iterable): BridgeTransport | null { - return bridgeTransportForType(type, codableTypes) -} - -function emitSwiftDummyReturn( - lines: string[], - retCat: SwiftTypeCategory, - transport: BridgeTransport | null, - indent: string, - returnStruct?: SwiftStruct, -): void { - if (retCat === 'void') lines.push(`${indent}return`) - else if (returnStruct) lines.push(`${indent}return swift_node_${returnStruct.name}()`) - else if (transport || retCat === 'string') - lines.push(`${indent}return UnsafeMutablePointer(mutating: strdup("")!)`) - else if (retCat === 'bool') lines.push(`${indent}return false`) - else if (retCat === 'int32' || retCat === 'int64' || retCat === 'double') - lines.push(`${indent}return 0`) -} - -function emitSwiftBridgeFailure( - lines: string[], - retCat: SwiftTypeCategory, - transport: BridgeTransport | null, - indent: string, - returnStruct?: SwiftStruct, -): void { - lines.push( - `${indent}out_error.pointee = swiftNodeBridgeError("swift-node could not encode or decode a bridged value")`, - ) - emitSwiftDummyReturn(lines, retCat, transport, indent, returnStruct) -} - -function generateSingleWrapper( - fn: ExportedFunction, - moduleName: string, - structs: SwiftStruct[] = [], - codableTypes: Iterable = [], -): string { - const lines: string[] = [] - const symbol = `${sanitizeId(moduleName)}_${fn.name}` - const wrapperName = `_sn_${sanitizeId(moduleName)}_${fn.name}` - const paramTransports = new Map( - fn.params.map((p) => [p.name, generatedTransport(p.type, codableTypes)]), - ) - const returnTransport = generatedTransport(fn.returnType, codableTypes) - const retCat = classifyNativeSwiftType(fn.returnType) - const retStruct = returnTransport ? undefined : findStruct(fn.returnType, structs) - const directStringReturn = !returnTransport && retCat === 'string' - const actorRunsAsync = !!fn.actorIsolation && fn.actorIsolation !== 'MainActor' - const needsErrorBridge = - fn.throws || - fn.isAsync || - actorRunsAsync || - returnTransport !== null || - Array.from(paramTransports.values()).some(Boolean) - - // Build @_cdecl parameter list - const cdeclParams: string[] = fn.params.map((p) => { - const cat = classifyNativeSwiftType(p.type) - const transport = paramTransports.get(p.name) - if (transport === 'borrowed') return `_ ${p.name}: UnsafeRawPointer?, _ ${p.name}Len: Int` - if (transport) return `_ ${p.name}: UnsafePointer` - if (cat === 'callback') { - const asyncInfo = promiseCallbackInfo(p.type) - if (asyncInfo) { - const prefix = `${sanitizeId(fn.name)}_${sanitizeId(p.name)}` - return `_ ${p.name}: SwiftNodePromiseInvoke_${prefix}, _ ${p.name}Context: UnsafeMutableRawPointer?, _ ${p.name}Release: SwiftNodePromiseRelease_${prefix}` - } - const cleaned = p.type.replace(/@escaping\s+/g, '').trim() - const match = cleaned.match(/^\(([^)]*)\)\s*->\s*(.+)$/) - if (match) { - const cbParams = match[1] ? splitExportCallbackParams(match[1]) : [] - const cParams = cbParams - .flatMap((cp) => { - const type = cp.trim() - return classifyNativeSwiftType(type) === 'string' - ? [nativeToCdeclType(type, false), 'Int'] - : [nativeToCdeclType(type, false)] - }) - .join(', ') - return `_ ${p.name}: @convention(c) (UnsafeMutableRawPointer?${cParams ? `, ${cParams}` : ''}) -> Void, _ ${p.name}Context: UnsafeMutableRawPointer?` - } - return `_ ${p.name}: @convention(c) (UnsafeMutableRawPointer?) -> Void, _ ${p.name}Context: UnsafeMutableRawPointer?` - } - // Check if it's a known struct type - const pStruct = findStruct(p.type, structs) - if (pStruct) { - return `_ ${p.name}: swift_node_${pStruct.name}` - } - // Buffer types need a pointer + length pair - if (cat === 'buffer') { - return `_ ${p.name}: UnsafePointer, _ ${p.name}Len: Int` - } - if (cat === 'string') { - return `_ ${p.name}: ${nativeToCdeclType(p.type, false)}, _ ${p.name}Len: Int` - } - const cdeclType = nativeToCdeclType(p.type, false) - return `_ ${p.name}: ${cdeclType}` - }) - - if (directStringReturn) { - cdeclParams.push('_ out_result_len: UnsafeMutablePointer') - } - - // Add error out param if function throws - if (needsErrorBridge) { - cdeclParams.push('_ out_error: UnsafeMutablePointer?>') - } - - // Return type - const cdeclReturn = - fn.returnType === 'Void' - ? '' - : returnTransport - ? ' -> UnsafeMutablePointer' - : retStruct - ? ` -> swift_node_${retStruct.name}` - : ` -> ${nativeToCdeclType(fn.returnType, true)}` - - lines.push(`@_cdecl("${symbol}")`) - lines.push(`public func ${wrapperName}(${cdeclParams.join(', ')})${cdeclReturn} {`) - if (directStringReturn) lines.push(' out_result_len.pointee = 0') - - // Convert input params from C types to Swift types - for (const p of fn.params) { - const cat = classifyNativeSwiftType(p.type) - const pStruct = findStruct(p.type, structs) - const transport = paramTransports.get(p.name) - if (transport === 'borrowed') { - lines.push( - ` let swift_${p.name} = UnsafeRawBufferPointer(start: ${p.name}, count: ${p.name}Len)`, - ) - } else if (transport === 'json') { - lines.push(` let swift_${p.name}: ${p.type}`) - lines.push(' do {') - lines.push( - ` swift_${p.name} = try JSONDecoder().decode(${p.type}.self, from: Data(String(cString: ${p.name}).utf8))`, - ) - lines.push(' } catch {') - emitSwiftBridgeFailure(lines, retCat, returnTransport, ' ', retStruct) - lines.push(' }') - } else if (transport === 'data') { - const binaryName = - p.type.replace(/\s+/g, '') === '[UInt8]' ? `binary_${p.name}` : `swift_${p.name}` - lines.push( - ` guard let ${binaryName} = Data(base64Encoded: String(cString: ${p.name})) else {`, - ) - emitSwiftBridgeFailure(lines, retCat, returnTransport, ' ', retStruct) - lines.push(' }') - if (p.type.replace(/\s+/g, '') === '[UInt8]') { - lines.push(` let swift_${p.name} = [UInt8](${binaryName})`) - } - } else if (pStruct) { - // Convert C struct to Swift struct via init - const fieldArgs = pStruct.fields.map((f) => `${f.name}: ${swiftStructInputValue(p.name, f)}`) - lines.push(` let swift_${p.name} = ${pStruct.name}(${fieldArgs.join(', ')})`) - } else if (cat === 'buffer') { - lines.push(` let swift_${p.name} = Data(bytes: ${p.name}, count: ${p.name}Len)`) - } else if (cat === 'string') { - const nullable = p.type.endsWith('?') - if (nullable) { - lines.push( - ` let swift_${p.name}: String? = ${p.name}.map { swiftNodeDecodeUTF8($0, ${p.name}Len) }`, - ) - } else { - lines.push(` let swift_${p.name} = swiftNodeDecodeUTF8(${p.name}, ${p.name}Len)`) - } - } else if (cat === 'double' && swiftBaseType(p.type) === 'Float') { - lines.push(` let swift_${p.name} = Float(${p.name})`) - } else if (cat === 'callback') { - const asyncInfo = promiseCallbackInfo(p.type) - if (asyncInfo) { - const prefix = `${sanitizeId(fn.name)}_${sanitizeId(p.name)}` - const cleaned = p.type.replace(/@escaping\s+/g, '').trim() - const callbackArguments = asyncInfo.params - .map((_, index) => `callbackArg${index}`) - .join(', ') - lines.push( - ` let handler_${prefix} = SwiftNodePromiseHandler_${prefix}(invoke: ${p.name}, context: ${p.name}Context, release: ${p.name}Release)`, - ) - lines.push( - ` let swift_${p.name}: ${cleaned} = { ${callbackArguments} in try await handler_${prefix}.call(${callbackArguments}) }`, - ) - continue - } - // Create a bridging closure: user's function expects Swift types (String), - // but we have a @convention(c) function pointer that takes C types (UnsafePointer). - // The closure accepts Swift types, converts them to C, and calls the C function. - const cleaned = p.type.replace(/@escaping\s+/g, '').trim() - const cbMatch = cleaned.match(/^\(([^)]*)\)\s*->\s*(.+)$/) - if (cbMatch && cbMatch[1]) { - const cbParamTypes = splitExportCallbackParams(cbMatch[1]).map((t) => t.trim()) - lines.push( - ` let swift_${p.name}: ${cleaned} = { ${cbParamTypes.map((_, i) => `cbArg${i}`).join(', ')} in`, - ) - emitSwiftCallbackBridgeCall(lines, p.name, `${p.name}Context`, cbParamTypes, ' ') - lines.push(` }`) - } else { - // No-param callback — pass through directly - lines.push(` let swift_${p.name} = ${p.name}`) - } - } else { - lines.push(` let swift_${p.name} = ${p.name}`) - } - } - - // Call the user's function and handle return - const callExpr = generateSwiftCall(fn) - const isolatedCallExpr = fn.actorIsolation - ? `${fn.actorIsolation}.assumeIsolated { ${fn.throws ? 'try ' : ''}${callExpr} }` - : callExpr - - if (fn.isAsync || actorRunsAsync) { - lines.push(' let semaphore = DispatchSemaphore(value: 0)') - if (retCat !== 'void') lines.push(` var asyncResult: ${fn.returnType}?`) - lines.push(' var asyncError: Error?') - lines.push(fn.actorIsolation ? ` Task { @${fn.actorIsolation} in` : ' Task {') - lines.push(' do {') - if (retCat === 'void') { - lines.push(` ${fn.throws ? 'try ' : ''}${fn.isAsync ? 'await ' : ''}${callExpr}`) - } else { - lines.push( - ` asyncResult = ${fn.throws ? 'try ' : ''}${fn.isAsync ? 'await ' : ''}${callExpr}`, - ) - } - lines.push(' } catch {') - lines.push(' asyncError = error') - lines.push(' }') - lines.push(' semaphore.signal()') - lines.push(' }') - lines.push(' semaphore.wait()') - lines.push(' if let asyncError {') - lines.push(' out_error.pointee = swiftNodeBridgeError(asyncError)') - emitSwiftDummyReturn(lines, retCat, returnTransport, ' ', retStruct) - lines.push(' }') - if (retCat !== 'void') { - lines.push(' guard let result = asyncResult else {') - emitSwiftBridgeFailure(lines, retCat, returnTransport, ' ', retStruct) - lines.push(' }') - generateSwiftReturnConversion( - lines, - fn.returnType, - retCat, - ' ', - structs, - returnTransport, - directStringReturn ? 'out_result_len' : undefined, - ) - } - } else if (fn.throws) { - lines.push(' do {') - if (retCat === 'void') { - lines.push(` try ${isolatedCallExpr}`) - } else { - lines.push(` let result = try ${isolatedCallExpr}`) - generateSwiftReturnConversion( - lines, - fn.returnType, - retCat, - ' ', - structs, - returnTransport, - directStringReturn ? 'out_result_len' : undefined, - ) - } - lines.push(' } catch {') - lines.push(' out_error.pointee = swiftNodeBridgeError(error)') - // Return a dummy value on error — the C++ side checks out_error first and throws a JS exception - if (retCat === 'string' && fn.returnType.endsWith('?')) lines.push(' return nil') - else if (returnTransport || retCat === 'string') - lines.push(' return UnsafeMutablePointer(mutating: strdup("")!)') - else if (retStruct) lines.push(` return swift_node_${retStruct.name}()`) - else if (retCat === 'bool') lines.push(' return false') - else if (retCat === 'int32' || retCat === 'int64' || retCat === 'double') - lines.push(' return 0') - lines.push(' }') - } else { - if (retCat === 'void') { - lines.push(` ${isolatedCallExpr}`) - } else { - lines.push(` let result = ${isolatedCallExpr}`) - generateSwiftReturnConversion( - lines, - fn.returnType, - retCat, - ' ', - structs, - returnTransport, - directStringReturn ? 'out_result_len' : undefined, - ) - } - } - - lines.push('}') - return lines.join('\n') -} - -function generateSwiftReturnConversion( - lines: string[], - returnType: string, - retCat: SwiftTypeCategory, - indent: string, - structs: SwiftStruct[] = [], - transport: BridgeTransport | null = null, - stringResultLength?: string, -): void { - const nullable = returnType.endsWith('?') - - if (transport === 'json') { - lines.push(`${indent}guard let encoded = try? JSONEncoder().encode(result) else {`) - emitSwiftBridgeFailure(lines, retCat, transport, `${indent} `) - lines.push(`${indent}}`) - lines.push( - `${indent}return UnsafeMutablePointer(mutating: strdup(String(decoding: encoded, as: UTF8.self))!)`, - ) - return - } - if (transport === 'data') { - const dataResult = returnType.replace(/\s+/g, '') === '[UInt8]' ? 'Data(result)' : 'result' - lines.push( - `${indent}return UnsafeMutablePointer(mutating: strdup(${dataResult}.base64EncodedString())!)`, - ) - return - } - - const retStruct = findStruct(returnType, structs) - if (retStruct) { - lines.push(`${indent}var cResult = swift_node_${retStruct.name}()`) - for (const f of retStruct.fields) { - const fieldName = cppIdentifier(f.name) - if (f.category === 'string') { - if (isNativeStringField(f)) { - lines.push( - `${indent}cResult.${fieldName} = UnsafePointer(swiftNodeCopyUTF8(result.${f.name})!)`, - ) - lines.push(`${indent}cResult.${fieldName}_len = result.${f.name}.utf8.count`) - } else { - lines.push(`${indent}cResult.${fieldName} = result.${f.name}`) - lines.push(`${indent}cResult.${fieldName}_len = strlen(result.${f.name})`) - } - } else { - lines.push(`${indent}cResult.${fieldName} = ${swiftStructReturnValue(f)}`) - } - } - lines.push(`${indent}return cResult`) - return - } - - switch (retCat) { - case 'string': - if (nullable) { - lines.push(`${indent}guard let result = result else { return nil }`) - if (stringResultLength) - lines.push(`${indent}${stringResultLength}.pointee = result.utf8.count`) - lines.push(`${indent}return swiftNodeCopyUTF8(result)!`) - } else { - if (stringResultLength) - lines.push(`${indent}${stringResultLength}.pointee = result.utf8.count`) - lines.push(`${indent}return swiftNodeCopyUTF8(result)!`) - } - break - case 'int32': - case 'int64': - case 'bool': - lines.push(`${indent}return result`) - break - case 'double': - lines.push( - `${indent}return ${swiftBaseType(returnType) === 'Float' ? 'Double(result)' : 'result'}`, - ) - break - } -} - -// Split callback param types (simple comma split for native types) -function splitExportCallbackParams(str: string): string[] { - return splitParams(str) -} - -// Convert ExportedFunction[] to SwiftFunction[] for feeding the existing C++ generator. -// This avoids re-parsing the generated Swift wrappers. -export function exportedToSwiftFunctions( - exported: ExportedFunction[], - moduleName: string, - structs: SwiftStruct[] = [], - codableTypes: Iterable = [], -): SwiftFunction[] { - const mod = sanitizeId(moduleName) - return exported.map((fn) => { - const symbolName = `${mod}_${fn.name}` - const params: SwiftParam[] = fn.params.flatMap((p) => { - const cat = classifyNativeSwiftType(p.type) - const transport = generatedTransport(p.type, codableTypes) - if (transport === 'borrowed') { - return [ - { - name: p.name, - type: 'UnsafeRawPointer?', - nativeType: p.type, - transport, - }, - { - name: `${p.name}Len`, - type: 'Int', - bridgeBorrowedBufferLengthFor: p.name, - }, - ] - } - if (transport) { - return [ - { - name: p.name, - type: 'UnsafePointer', - nativeType: p.type, - transport, - }, - ] - } - switch (cat) { - case 'string': { - const nullable = p.type.endsWith('?') - return [ - { name: p.name, type: nullable ? 'UnsafePointer?' : 'UnsafePointer' }, - { name: `${p.name}Len`, type: 'Int', bridgeStringLengthFor: p.name }, - ] - } - case 'buffer': - return [ - { name: p.name, type: 'UnsafePointer' }, - { name: `${p.name}Len`, type: 'Int' }, - ] - case 'int32': - return [{ name: p.name, type: 'Int32' }] - case 'int64': - return [{ name: p.name, type: swiftBaseType(p.type) === 'Int64' ? 'Int64' : 'Int' }] - case 'double': - return [{ name: p.name, type: 'Double' }] - case 'bool': - return [{ name: p.name, type: 'Bool' }] - case 'callback': { - const asyncInfo = promiseCallbackInfo(p.type) - if (asyncInfo) { - const cParams = asyncInfo.params - .flatMap(() => ['UnsafePointer', 'Int']) - .join(', ') - const signature = `@escaping @convention(c) (UnsafeMutableRawPointer?${cParams ? `, ${cParams}` : ''}, @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int, UnsafePointer?, Int) -> Void, UnsafeMutableRawPointer?) -> Void` - return [ - { - name: p.name, - type: signature, - nativeType: p.type, - promiseCallback: asyncInfo, - }, - { name: `${p.name}Context`, type: 'UnsafeMutableRawPointer?', callbackContext: true }, - { - name: `${p.name}Release`, - type: '@escaping @convention(c) (UnsafeMutableRawPointer?) -> Void', - promiseCallbackRelease: true, - }, - ] - } - const cleaned = p.type.replace(/@escaping\s+/g, '').trim() - const match = cleaned.match(/^\(([^)]*)\)\s*->\s*(.+)$/) - if (match) { - const cbParams = match[1] ? splitExportCallbackParams(match[1]) : [] - const cParams = cbParams - .flatMap((cp) => { - const type = cp.trim() - return classifyNativeSwiftType(type) === 'string' - ? [nativeToCdeclType(type, false), 'Int'] - : [nativeToCdeclType(type, false)] - }) - .join(', ') - const signature = `@escaping @convention(c) (UnsafeMutableRawPointer?${cParams ? `, ${cParams}` : ''}) -> Void` - return [ - { name: p.name, type: signature, nativeType: p.type }, - { name: `${p.name}Context`, type: 'UnsafeMutableRawPointer?', callbackContext: true }, - ] - } - return [ - { - name: p.name, - type: '@escaping @convention(c) (UnsafeMutableRawPointer?) -> Void', - nativeType: p.type, - }, - { name: `${p.name}Context`, type: 'UnsafeMutableRawPointer?', callbackContext: true }, - ] - } - default: { - // Check if it's a known struct type - const pStruct = findStruct(p.type, structs) - if (pStruct) return [{ name: p.name, type: `swift_node_${pStruct.name}` }] - return [{ name: p.name, type: p.type }] - } - } - }) - - if (fn.isStream) { - const stream = parseSwiftStreamReturnType(fn.returnType) - // validateExports reports malformed stream declarations before codegen. - // Keeping this guard makes the public generator safe to call directly in - // tests and other tooling as well. - if (!stream) { - throw new Error( - `Stream export '${fn.name}' has an unsupported return type '${fn.returnType}'.`, - ) - } - return { - symbolName, - params, - returnType: 'Void', - isAsync: false, - nativeReturnType: 'Void', - stream: { - ...stream, - ...(generatedTransport(stream.elementType, codableTypes) === 'json' - ? { transport: 'json' as const } - : {}), - }, - } - } - - const returnTransport = generatedTransport(fn.returnType, codableTypes) - const actorRunsAsync = !!fn.actorIsolation && fn.actorIsolation !== 'MainActor' - const needsErrorBridge = - fn.throws || - fn.isAsync || - actorRunsAsync || - returnTransport !== null || - params.some((p) => p.transport) - - // Generated Codable conversion can fail before the user's function runs, - // so it needs the same error channel as a Swift `throws` declaration. - const directStringReturn = - !returnTransport && classifyNativeSwiftType(fn.returnType) === 'string' - if (directStringReturn) { - params.push({ name: 'outResultLen', type: 'Int', bridgeStringResultLength: true }) - } - if (needsErrorBridge) { - params.push({ name: 'outError', type: 'UnsafeMutablePointer?>' }) - } - - // Map return type to C-compatible - const retCat = classifyNativeSwiftType(fn.returnType) - let returnType: string - if (returnTransport) { - returnType = 'UnsafeMutablePointer' - } else - switch (retCat) { - case 'string': { - const nullable = fn.returnType.endsWith('?') - returnType = nullable ? 'UnsafeMutablePointer?' : 'UnsafeMutablePointer' - break - } - case 'int32': - returnType = 'Int32' - break - case 'int64': - returnType = swiftBaseType(fn.returnType) === 'Int64' ? 'Int64' : 'Int' - break - case 'double': - returnType = 'Double' - break - case 'bool': - returnType = 'Bool' - break - case 'void': - returnType = 'Void' - break - default: { - const retStruct = findStruct(fn.returnType, structs) - returnType = retStruct ? `swift_node_${retStruct.name}` : fn.returnType - } - } - - return { - symbolName, - params, - returnType, - isAsync: fn.isAsync || actorRunsAsync, - nativeReturnType: fn.returnType, - returnTransport: returnTransport || undefined, - } - }) -} - -// Generate Swift wrapper functions containing @_cdecl exports. -export function generateWrappersSwift( - exported: ExportedFunction[], - moduleName: string, - structs: SwiftStruct[] = [], - codableTypes: Iterable = [], -): string { - if (exported.length === 0) return '' - - const lines: string[] = [ - '// Generated by swift-node — do not edit', - `// Source annotation: // @swift-node:export`, - '', - 'import Foundation', - '', - `public indirect enum SwiftNodeJSONValue: Sendable { - case null - case bool(Bool) - case number(Double) - case string(String) - case array([SwiftNodeJSONValue]) - case object([String: SwiftNodeJSONValue]) -} - -extension SwiftNodeJSONValue: Encodable { - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .null: - try container.encodeNil() - case let .bool(value): - try container.encode(value) - case let .number(value): - try container.encode(value) - case let .string(value): - try container.encode(value) - case let .array(value): - try container.encode(value) - case let .object(value): - try container.encode(value) - } - } -} - -public protocol SwiftNodeStructuredError: Error { - var code: String { get } - var message: String { get } - var details: [String: SwiftNodeJSONValue] { get } -} - -public extension SwiftNodeStructuredError { - var message: String { localizedDescription } - var details: [String: SwiftNodeJSONValue] { [:] } -} - -private struct SwiftNodeErrorEnvelope: Encodable { - let message: String - let code: String? - let details: [String: SwiftNodeJSONValue]? -} -`, - '', - `private func swiftNodeCopyUTF8(_ value: String) -> UnsafeMutablePointer? { - let bytes = Array(value.utf8) - guard let destination = malloc(bytes.count + 1)?.assumingMemoryBound(to: CChar.self) else { return nil } - bytes.withUnsafeBytes { source in - if !bytes.isEmpty { memcpy(destination, source.baseAddress!, bytes.count) } - } - destination[bytes.count] = 0 - return destination -}`, - '', - `private func swiftNodeEncodeError(_ envelope: SwiftNodeErrorEnvelope) -> UnsafeMutablePointer { - let fallback = #"{"message":"swift-node failed to encode an error"}"# - let encoded = (try? JSONEncoder().encode(envelope)).flatMap { String(data: $0, encoding: .utf8) } ?? fallback - return swiftNodeCopyUTF8(encoded)! -} - -private func swiftNodeBridgeError(_ error: any Error) -> UnsafeMutablePointer { - if let structured = error as? any SwiftNodeStructuredError { - return swiftNodeEncodeError( - SwiftNodeErrorEnvelope( - message: structured.message, - code: structured.code, - details: structured.details - ) - ) - } - return swiftNodeEncodeError( - SwiftNodeErrorEnvelope(message: error.localizedDescription, code: nil, details: nil) - ) -} - -private func swiftNodeBridgeError(_ message: String) -> UnsafeMutablePointer { - swiftNodeEncodeError(SwiftNodeErrorEnvelope(message: message, code: nil, details: nil)) -}`, - '', - `private func swiftNodeDecodeUTF8(_ value: UnsafePointer, _ length: Int) -> String { - String(decoding: UnsafeRawBufferPointer(start: value, count: length).bindMemory(to: UInt8.self), as: UTF8.self) -}`, - '', - ] - - if (exported.some((fn) => fn.isStream)) { - lines.push(generateSwiftStreamRuntime()) - lines.push('') - } - - for (const fn of exported) { - for (const parameter of fn.params) { - if (promiseCallbackInfo(parameter.type)) { - lines.push(generatePromiseCallbackSwiftRuntime(fn, parameter)) - lines.push('') - } - } - } - - for (const fn of exported) { - lines.push( - fn.isStream - ? generateSingleStreamWrapper(fn, moduleName, structs, codableTypes) - : generateSingleWrapper(fn, moduleName, structs, codableTypes), - ) - lines.push('') - } - - return lines.join('\n') -} - -function generateStreamSubscriptionCpp(fn: SwiftFunction, structs: SwiftStruct[]): string { - const stream = fn.stream! - const prefix = fn.symbolName - const params = fn.params - const sourceParams = jsParams(fn) - const usesJsonTransport = stream.transport === 'json' - const valueCategory = classifyNativeSwiftType(stream.elementType) - const valueType = usesJsonTransport ? 'const char*' : cppTypeFromCategory(valueCategory) - const valueHasLength = streamElementUsesStringLength(stream.elementType, stream.transport) - const lines: string[] = [] - - lines.push( - `enum StreamMessageKind_${prefix} { stream_message_value_${prefix}, stream_message_error_${prefix}, stream_message_complete_${prefix} };`, - ) - lines.push(`struct StreamMessage_${prefix} {`) - lines.push(` StreamMessageKind_${prefix} kind;`) - if (usesJsonTransport || valueCategory === 'string') lines.push(' char* value;') - else lines.push(` ${valueType} value;`) - if (valueHasLength) lines.push(' size_t value_len;') - lines.push(' char* error;') - lines.push('};') - lines.push('') - lines.push(`struct StreamState_${prefix} {`) - lines.push(' int64_t subscription_id;') - lines.push(' std::atomic closed{false};') - lines.push(' std::atomic cancelled{false};') - lines.push(' napi_threadsafe_function tsfn = nullptr;') - lines.push(' napi_ref on_value = nullptr;') - lines.push(' napi_ref on_error = nullptr;') - lines.push(' napi_ref on_complete = nullptr;') - lines.push('};') - lines.push(`struct StreamHandle_${prefix} { std::shared_ptr state; };`) - lines.push(`static std::atomic next_stream_id_${prefix}{1};`) - lines.push(`static std::mutex streams_mutex_${prefix};`) - lines.push( - `static std::unordered_map> streams_${prefix};`, + `static std::unordered_map> streams_${prefix};`, ) lines.push('') lines.push(`static void cleanup_stream_message_${prefix}(StreamMessage_${prefix}* message) {`) @@ -3804,7 +2091,7 @@ export function generateAddonCpp( if (fn.stream) { lines.push(`// Stream wrapper for ${fn.symbolName} was generated above.`) } else { - lines.push(generateJsWrapper(fn, moduleName, structs)) + lines.push(generateJsWrapper(fn, structs)) } lines.push('') } @@ -3834,278 +2121,3 @@ export function generateAddonCpp( return lines.join('\n') } - -// --- TypeScript definition generation --- - -function tsStructType(s: SwiftStruct): string { - const fields = s.fields.map((f) => { - let t = 'unknown' - switch (f.category) { - case 'int32': - case 'int64': - case 'double': - t = 'number' - break - case 'bool': - t = 'boolean' - break - case 'string': - t = 'string' - break - } - return ` ${f.name}: ${t}` - }) - return `{\n${fields.join('\n')}\n}` -} - -function indentLines(text: string, spaces: number): string { - const prefix = ' '.repeat(spaces) - return text - .split('\n') - .map((line) => (line.length > 0 ? prefix + line : line)) - .join('\n') -} - -export function generateDts( - functions: SwiftFunction[], - moduleName: string, - structs: SwiftStruct[] = [], -): string { - const lines: string[] = ['// Generated by swift-node — do not edit', ''] - - lines.push('export type SwiftNodeJSONValue =') - lines.push(' | null') - lines.push(' | boolean') - lines.push(' | number') - lines.push(' | string') - lines.push(' | readonly SwiftNodeJSONValue[]') - lines.push(' | { readonly [key: string]: SwiftNodeJSONValue }') - lines.push('') - lines.push('export interface SwiftNodeStructuredError extends Error {') - lines.push(' readonly code: string') - lines.push(' readonly details: { readonly [key: string]: SwiftNodeJSONValue }') - lines.push('}') - lines.push('') - - if (functions.some((fn) => fn.stream)) { - lines.push('declare global {') - lines.push(' interface SymbolConstructor {') - lines.push(' readonly dispose: unique symbol') - lines.push(' }') - lines.push('}') - lines.push('') - lines.push('export interface SwiftNodeSubscription {') - lines.push(' readonly closed: boolean') - lines.push(' cancel(): void') - lines.push(' [Symbol.dispose](): void') - lines.push('}') - lines.push('') - } - - // Generate TypeScript interfaces for structs - for (const s of structs) { - lines.push(`export interface ${s.name} ${tsStructType(s)}`) - lines.push('') - } - - for (const [index, fn] of functions.entries()) { - const name = jsName(fn.symbolName, moduleName) - - const jp = jsParams(fn) - const params = jp.map((p) => { - if (isCallbackType(p.type)) { - return `${p.name}: ${tsCallbackType(p.nativeType || p.type)}` - } - // Check for struct type — use the clean Swift name, not the C swift_node_ prefix - const pStructDts = findStruct(p.type, structs) - if (pStructDts) { - return `${p.name}: ${pStructDts.name}` - } - return `${p.name}: ${tsParamType(p)}` - }) - if (fn.stream) { - const value = tsTypeFromNative(fn.stream.elementType, fn.stream.transport === 'json') - params.push(`onValue: (value: ${value}) => void`) - params.push('onError?: (error: Error) => void') - params.push('onComplete?: () => void') - } - - // Return type: check struct first — use clean Swift name - const ret = tsReturnType(fn, structs) - const asyncRet = fn.stream ? 'SwiftNodeSubscription' : fn.isAsync ? `Promise<${ret}>` : ret - - const binding = `__swift_node_${index}` - lines.push(`declare const ${binding}: (${params.join(', ')}) => ${asyncRet}`) - lines.push(`export { ${binding} as ${name} }`) - } - - return lines.join('\n') -} - -export function generateDtsCjs( - functions: SwiftFunction[], - moduleName: string, - structs: SwiftStruct[] = [], -): string { - const lines: string[] = ['// Generated by swift-node — do not edit', ''] - - if (functions.some((fn) => fn.stream)) { - lines.push('declare global {') - lines.push(' interface SymbolConstructor {') - lines.push(' readonly dispose: unique symbol') - lines.push(' }') - lines.push('}') - lines.push('') - } - - lines.push('declare namespace native {') - - lines.push(' type SwiftNodeJSONValue =') - lines.push(' | null') - lines.push(' | boolean') - lines.push(' | number') - lines.push(' | string') - lines.push(' | readonly SwiftNodeJSONValue[]') - lines.push(' | { readonly [key: string]: SwiftNodeJSONValue }') - lines.push('') - lines.push(' interface SwiftNodeStructuredError extends Error {') - lines.push(' readonly code: string') - lines.push(' readonly details: { readonly [key: string]: SwiftNodeJSONValue }') - lines.push(' }') - lines.push('') - - if (functions.some((fn) => fn.stream)) { - lines.push(' interface SwiftNodeSubscription {') - lines.push(' readonly closed: boolean') - lines.push(' cancel(): void') - lines.push(' [Symbol.dispose](): void') - lines.push(' }') - lines.push('') - } - - for (const s of structs) { - lines.push(indentLines(`interface ${s.name} ${tsStructType(s)}`, 2)) - lines.push('') - } - - const iface: string[] = [] - for (const fn of functions) { - const name = jsName(fn.symbolName, moduleName) - - const jp = jsParams(fn) - const params = jp.map((p) => { - if (isCallbackType(p.type)) { - return `${p.name}: ${tsCallbackType(p.nativeType || p.type)}` - } - const pStructDts = findStruct(p.type, structs) - if (pStructDts) { - return `${p.name}: ${pStructDts.name}` - } - return `${p.name}: ${tsParamType(p)}` - }) - if (fn.stream) { - const value = tsTypeFromNative(fn.stream.elementType, fn.stream.transport === 'json') - params.push(`onValue: (value: ${value}) => void`) - params.push('onError?: (error: Error) => void') - params.push('onComplete?: () => void') - } - - const ret = tsReturnType(fn, structs) - const asyncRet = fn.stream ? 'SwiftNodeSubscription' : fn.isAsync ? `Promise<${ret}>` : ret - - iface.push(` ${name}(${params.join(', ')}): ${asyncRet}`) - } - - lines.push(' interface NativeBindings {') - lines.push(iface.map((line) => ` ${line}`).join('\n')) - lines.push(' }') - lines.push('}') - lines.push('') - lines.push('declare const native: native.NativeBindings') - lines.push('export = native') - - return lines.join('\n') -} - -// Emitted verbatim into both module formats so generated packages load only -// their declared binary without a runtime dependency. -function generatedAddonResolverLines(): string[] { - return [ - `function resolveAddonPath(dir, moduleName) {`, - ` const isMusl = process.platform === 'linux' && !process.report?.getReport?.().header?.glibcVersionRuntime`, - ` const target = process.platform + '-' + process.arch + (isMusl ? '-musl' : '')`, - ` const binaryName = moduleName + '.' + target + '.node'`, - ` const binaryPath = path.join(dir, ...(process.platform === 'darwin' ? [] : [target]), binaryName)`, - ` if (existsSync(binaryPath)) return binaryPath`, - '', - ` throw new Error(`, - ` 'No .node binary found for ' + target + '.\\n' +`, - ` 'Checked:\\n' +`, - ` ' ' + binaryPath + '\\n' +`, - ` "Run 'swift-node build' for this platform and architecture."`, - ` )`, - `}`, - ] -} - -// Generate dist_swift-node/index.mjs — ESM entry point. Pure JS (no TS syntax) so -// Node can load it without a TypeScript loader. Types come from index.d.ts. -// .mjs extension ensures ESM parsing regardless of package "type" field. -export function generateEntryMjs(functions: SwiftFunction[], moduleName: string): string { - const lines = [ - '// Generated by swift-node — do not edit', - '', - `import { createRequire } from 'node:module'`, - `import { fileURLToPath } from 'node:url'`, - `import path from 'node:path'`, - '', - `const require = createRequire(import.meta.url)`, - `const __dirname = path.dirname(fileURLToPath(import.meta.url))`, - `const { existsSync } = require('node:fs')`, - '', - ...generatedAddonResolverLines(), - '', - `const native = require(resolveAddonPath(__dirname, ${JSON.stringify(moduleName)}))`, - '', - ] - for (const [index, fn] of functions.entries()) { - const name = jsName(fn.symbolName, moduleName) - lines.push(`const __swift_node_${index} = native[${JSON.stringify(name)}]`) - } - if (functions.length > 0) { - lines.push('') - } - for (const [index, fn] of functions.entries()) { - const name = jsName(fn.symbolName, moduleName) - lines.push(`export { __swift_node_${index} as ${name} }`) - } - return lines.join('\n') -} - -// Generate dist_swift-node/index.cjs — CJS entry point for require() consumers. -export function generateEntryCjs(functions: SwiftFunction[], moduleName: string): string { - const lines = [ - '// Generated by swift-node — do not edit', - `const path = require('node:path')`, - `const { existsSync } = require('node:fs')`, - '', - ...generatedAddonResolverLines(), - '', - `const native = require(resolveAddonPath(__dirname, ${JSON.stringify(moduleName)}))`, - '', - `module.exports = {`, - ] - for (const fn of functions) { - const name = jsName(fn.symbolName, moduleName) - lines.push(` ${JSON.stringify(name)}: native[${JSON.stringify(name)}],`) - } - lines.push('}') - return lines.join('\n') -} - -// Generate the TypeScript source entry created by `swift-node init`. Package -// exports point straight at dist_swift-node; this file is the convenient source -// entry for a project's own TypeScript code. -export function generateSourceEntryTs(): string { - return "export * from '../dist_swift-node/index.mjs'\n" -} diff --git a/packages/swift-node/src/generator/bridge-header.ts b/packages/swift-node/src/generator/bridge-header.ts new file mode 100644 index 0000000..eb7f8f8 --- /dev/null +++ b/packages/swift-node/src/generator/bridge-header.ts @@ -0,0 +1,174 @@ +import { + type SwiftFunction, + type SwiftStruct, + isCallbackType, + parseCallbackType, + classifyNativeSwiftType, +} from '../parser.js' + +import { + cppIdentifier, + cppReturnType, + cppType, + cppTypeFromCategory, + findStruct, + sanitizeId, + streamElementUsesStringLength, +} from './shared.js' + +// Generate C struct typedefs for bridge header +function generateCStructDefs(structs: SwiftStruct[]): string[] { + const lines: string[] = [] + for (const s of structs) { + lines.push(`typedef struct {`) + for (const f of s.fields) { + const fieldName = cppIdentifier(f.name) + switch (f.category) { + case 'int32': + lines.push(` int32_t ${fieldName};`) + break + case 'int64': + lines.push(` int64_t ${fieldName};`) + break + case 'double': + lines.push(` double ${fieldName};`) + break + case 'bool': + lines.push(` bool ${fieldName};`) + break + case 'string': + lines.push(` const char* ${fieldName};`) + lines.push(` size_t ${fieldName}_len;`) + break + } + } + lines.push(`} swift_node_${s.name};`) + lines.push('') + } + return lines +} + +// Standalone header with just struct typedefs — imported into Swift via -import-objc-header +export function generateStructsHeader(structs: SwiftStruct[]): string { + const lines = [ + '// Generated by swift-node — do not edit', + '#ifndef SWIFT_NODE_STRUCTS_H', + '#define SWIFT_NODE_STRUCTS_H', + '', + '#include ', + '#include ', + '#include ', + '', + ...generateCStructDefs(structs), + '#endif', + ] + return lines.join('\n') +} + +export function generateBridgeH( + functions: SwiftFunction[], + moduleName: string, + structs: SwiftStruct[] = [], +): string { + const guard = `${sanitizeId(moduleName).toUpperCase()}_BRIDGE_H` + const lines: string[] = [ + `#ifndef ${guard}`, + `#define ${guard}`, + '', + '#include ', + '#include ', + '#include ', + '', + '#ifdef __cplusplus', + 'extern "C" {', + '#endif', + '', + ] + + // Struct typedefs (before function declarations that reference them) + if (structs.length > 0) { + lines.push(...generateCStructDefs(structs)) + } + + for (const fn of functions) { + if (fn.stream) { + const params = fn.params.map((p) => { + const paramName = cppIdentifier(p.name) + if (p.type.includes('UnsafeMutablePointer')) return `char* ${paramName}` + const pStruct = findStruct(p.type, structs) + if (pStruct) return `swift_node_${pStruct.name} ${paramName}` + return `${cppType(p.type)} ${paramName}` + }) + const valueType = + fn.stream.transport === 'json' + ? 'const char*' + : cppTypeFromCategory(classifyNativeSwiftType(fn.stream.elementType)) + const valueHasLength = streamElementUsesStringLength( + fn.stream.elementType, + fn.stream.transport, + ) + params.push('int64_t subscription_id') + params.push(`void (*on_value)(int64_t, ${valueType}${valueHasLength ? ', int64_t' : ''})`) + params.push('void (*on_complete)(int64_t, const char*)') + lines.push(`void ${fn.symbolName}(${params.join(', ')});`) + lines.push(`void ${fn.symbolName}_cancel(int64_t subscription_id);`) + continue + } + + const ret = cppReturnType(fn.returnType) + const params = fn.params + .map((p) => { + const paramName = cppIdentifier(p.name) + if (p.bridgeStringResultLength) return `int64_t* ${paramName}` + if (p.type.includes('UnsafeMutablePointer?>')) { + return 'const char** out_error' + } + if (p.type.includes('UnsafeMutablePointer')) { + return `char* ${paramName}` + } + if (p.promiseCallback) { + const callbackParams = p.promiseCallback.params + .flatMap((parameter) => { + const type = parameter.swiftType + return classifyNativeSwiftType(type) === 'string' + ? [`const char*`, 'int64_t'] + : [cppType(type)] + }) + .join(', ') + return `void (*${paramName})(void*, ${callbackParams}${callbackParams ? ', ' : ''}void (*)(void*, const char*, int64_t, const char*, int64_t), void*)` + } + if (p.promiseCallbackRelease) { + return `void (*${paramName})(void*)` + } + if (isCallbackType(p.type)) { + // Generate the C callback function pointer signature + const cb = parseCallbackType(p.type) + if (cb) { + const cbParams = cb.params.map((cp) => cppType(cp.swiftType)).join(', ') + return `void (*${paramName})(${cbParams})` + } + return `void (*${paramName})(void)` + } + // Check for struct type + const pStruct = findStruct(p.type, structs) + if (pStruct) { + return `swift_node_${pStruct.name} ${paramName}` + } + const t = cppType(p.type) + return `${t} ${paramName}` + }) + .join(', ') + + // Handle struct return types + let retType = ret + const retStruct2 = findStruct(fn.returnType, structs) + if (retStruct2) { + retType = `swift_node_${retStruct2.name}` + } + + lines.push(`${retType} ${fn.symbolName}(${params});`) + } + + lines.push('', '#ifdef __cplusplus', '}', '#endif', '', `#endif`) + return lines.join('\n') +} diff --git a/packages/swift-node/src/generator/declarations.ts b/packages/swift-node/src/generator/declarations.ts new file mode 100644 index 0000000..c8dc642 --- /dev/null +++ b/packages/swift-node/src/generator/declarations.ts @@ -0,0 +1,341 @@ +import { + type SwiftFunction, + type SwiftParam, + type SwiftStruct, + classifyNativeSwiftType, + classifySwiftType, + isCallbackType, + parseCallbackType, + splitParams, +} from '../parser.js' + +import { findStruct, isNullableType, jsName, jsParams } from './shared.js' + +// --- TypeScript definition generation --- + +function tsType(swiftType: string): string { + const cat = classifySwiftType(swiftType) + const nullable = swiftType.endsWith('?') + const base = (() => { + switch (cat) { + case 'int32': + case 'int64': + case 'double': + return 'number' + case 'bool': + return 'boolean' + case 'string': + return 'string' + case 'buffer': + return 'Buffer' + case 'void': + return 'void' + case 'callback': + return '(...args: any[]) => void' + default: + return 'unknown' + } + })() + return nullable && base !== 'void' ? `${base} | null` : base +} + +function shorthandDictionaryValueType(type: string): string | null { + if (!type.startsWith('[') || !type.endsWith(']')) return null + + const contents = type.slice(1, -1) + let depth = 0 + for (let index = 0; index < contents.length; index++) { + const character = contents[index] + if (character === '[' || character === '<' || character === '(') depth++ + else if (character === ']' || character === '>' || character === ')') depth-- + else if (character === ':' && depth === 0) { + return contents.slice(0, index) === 'String' ? contents.slice(index + 1) : null + } + } + + return null +} + +// TypeScript type from native Swift type (for export-generated .d.ts) +function tsTypeFromNative(swiftType: string, dataAsBase64 = false): string { + const normalized = swiftType.replace(/\s+/g, '') + const nullable = normalized.endsWith('?') + const baseType = nullable ? normalized.slice(0, -1) : normalized + const genericDictionary = baseType.match(/^Dictionary<(.*)>$/) + const dictionaryArgs = genericDictionary ? splitParams(genericDictionary[1]) : [] + const dictionaryValue = + dictionaryArgs.length === 2 && dictionaryArgs[0].replace(/\s+/g, '') === 'String' + ? dictionaryArgs[1] + : shorthandDictionaryValueType(baseType) + if (dictionaryValue) { + const type = `Record` + return nullable ? `${type} | null` : type + } + + const arrayMatch = baseType.match(/^\[(.*)\]$/) || baseType.match(/^Array<(.*)>$/) + if (arrayMatch) { + const element = tsTypeFromNative(arrayMatch[1], dataAsBase64) + const type = `${element.includes(' | ') ? `(${element})` : element}[]` + return nullable ? `${type} | null` : type + } + if (baseType === 'Data') + return `${dataAsBase64 ? 'string' : 'Uint8Array'}${nullable ? ' | null' : ''}` + if (baseType === 'UnsafeRawBufferPointer') return 'Uint8Array' + const cat = classifyNativeSwiftType(swiftType) + const base = (() => { + switch (cat) { + case 'int32': + case 'int64': + case 'double': + return 'number' + case 'bool': + return 'boolean' + case 'string': + return 'string' + case 'buffer': + return 'Buffer' + case 'void': + return 'void' + case 'callback': + return '(...args: any[]) => void' + default: + return 'unknown' + } + })() + return nullable && base !== 'void' ? `${base} | null` : base +} + +function tsParamType(param: SwiftParam): string { + if (param.transport === 'data' || param.transport === 'borrowed') return 'Uint8Array' + return param.nativeType + ? tsTypeFromNative(param.nativeType, param.transport === 'json') + : tsType(param.type) +} + +function tsReturnType(fn: SwiftFunction, structs: SwiftStruct[]): string { + const nativeType = fn.nativeReturnType || fn.returnType + if (fn.returnTransport === 'data') return 'Uint8Array' + if (fn.returnTransport) + return fn.nativeReturnType + ? tsTypeFromNative(nativeType, fn.returnTransport === 'json') + : tsType(fn.returnType) + const struct = findStruct(nativeType, structs) + if (struct) return struct.name + return fn.nativeReturnType ? tsTypeFromNative(nativeType) : tsType(fn.returnType) +} + +function tsCallbackType(swiftType: string): string { + const cb = parseCallbackType(swiftType) + if (!cb) return '(...args: any[]) => void' + + const params = cb.params.map((p, i) => { + const cat = p.type + let tsT = 'any' + if (cat === 'string') tsT = isNullableType(p.swiftType) ? 'string | null' : 'string' + else if (cat === 'int32') tsT = 'number' + else if (cat === 'int64') tsT = 'number' + else if (cat === 'double') tsT = 'number' + else if (cat === 'bool') tsT = 'boolean' + else if (cat === 'buffer') tsT = 'Float32Array' + return `arg${i}: ${tsT}` + }) + + if (cb.isAsync && cb.throws && cb.returnType === 'String') { + return `(${params.join(', ')}) => string | Promise` + } + + return `(${params.join(', ')}) => void` +} + +function tsStructType(s: SwiftStruct): string { + const fields = s.fields.map((f) => { + let t = 'unknown' + switch (f.category) { + case 'int32': + case 'int64': + case 'double': + t = 'number' + break + case 'bool': + t = 'boolean' + break + case 'string': + t = 'string' + break + } + return ` ${f.name}: ${t}` + }) + return `{\n${fields.join('\n')}\n}` +} + +function indentLines(text: string, spaces: number): string { + const prefix = ' '.repeat(spaces) + return text + .split('\n') + .map((line) => (line.length > 0 ? prefix + line : line)) + .join('\n') +} + +export function generateDts( + functions: SwiftFunction[], + moduleName: string, + structs: SwiftStruct[] = [], +): string { + const lines: string[] = ['// Generated by swift-node — do not edit', ''] + + lines.push('export type SwiftNodeJSONValue =') + lines.push(' | null') + lines.push(' | boolean') + lines.push(' | number') + lines.push(' | string') + lines.push(' | readonly SwiftNodeJSONValue[]') + lines.push(' | { readonly [key: string]: SwiftNodeJSONValue }') + lines.push('') + lines.push('export interface SwiftNodeStructuredError extends Error {') + lines.push(' readonly code: string') + lines.push(' readonly details: { readonly [key: string]: SwiftNodeJSONValue }') + lines.push('}') + lines.push('') + + if (functions.some((fn) => fn.stream)) { + lines.push('declare global {') + lines.push(' interface SymbolConstructor {') + lines.push(' readonly dispose: unique symbol') + lines.push(' }') + lines.push('}') + lines.push('') + lines.push('export interface SwiftNodeSubscription {') + lines.push(' readonly closed: boolean') + lines.push(' cancel(): void') + lines.push(' [Symbol.dispose](): void') + lines.push('}') + lines.push('') + } + + // Generate TypeScript interfaces for structs + for (const s of structs) { + lines.push(`export interface ${s.name} ${tsStructType(s)}`) + lines.push('') + } + + for (const [index, fn] of functions.entries()) { + const name = jsName(fn.symbolName, moduleName) + + const jp = jsParams(fn) + const params = jp.map((p) => { + if (isCallbackType(p.type)) { + return `${p.name}: ${tsCallbackType(p.nativeType || p.type)}` + } + // Check for struct type — use the clean Swift name, not the C swift_node_ prefix + const pStructDts = findStruct(p.type, structs) + if (pStructDts) { + return `${p.name}: ${pStructDts.name}` + } + return `${p.name}: ${tsParamType(p)}` + }) + if (fn.stream) { + const value = tsTypeFromNative(fn.stream.elementType, fn.stream.transport === 'json') + params.push(`onValue: (value: ${value}) => void`) + params.push('onError?: (error: Error) => void') + params.push('onComplete?: () => void') + } + + // Return type: check struct first — use clean Swift name + const ret = tsReturnType(fn, structs) + const asyncRet = fn.stream ? 'SwiftNodeSubscription' : fn.isAsync ? `Promise<${ret}>` : ret + + const binding = `__swift_node_${index}` + lines.push(`declare const ${binding}: (${params.join(', ')}) => ${asyncRet}`) + lines.push(`export { ${binding} as ${name} }`) + } + + return lines.join('\n') +} + +export function generateDtsCjs( + functions: SwiftFunction[], + moduleName: string, + structs: SwiftStruct[] = [], +): string { + const lines: string[] = ['// Generated by swift-node — do not edit', ''] + + if (functions.some((fn) => fn.stream)) { + lines.push('declare global {') + lines.push(' interface SymbolConstructor {') + lines.push(' readonly dispose: unique symbol') + lines.push(' }') + lines.push('}') + lines.push('') + } + + lines.push('declare namespace native {') + + lines.push(' type SwiftNodeJSONValue =') + lines.push(' | null') + lines.push(' | boolean') + lines.push(' | number') + lines.push(' | string') + lines.push(' | readonly SwiftNodeJSONValue[]') + lines.push(' | { readonly [key: string]: SwiftNodeJSONValue }') + lines.push('') + lines.push(' interface SwiftNodeStructuredError extends Error {') + lines.push(' readonly code: string') + lines.push(' readonly details: { readonly [key: string]: SwiftNodeJSONValue }') + lines.push(' }') + lines.push('') + + if (functions.some((fn) => fn.stream)) { + lines.push(' interface SwiftNodeSubscription {') + lines.push(' readonly closed: boolean') + lines.push(' cancel(): void') + lines.push(' [Symbol.dispose](): void') + lines.push(' }') + lines.push('') + } + + for (const s of structs) { + lines.push(indentLines(`interface ${s.name} ${tsStructType(s)}`, 2)) + lines.push('') + } + + const iface: string[] = [] + for (const fn of functions) { + const name = jsName(fn.symbolName, moduleName) + + const jp = jsParams(fn) + const params = jp.map((p) => { + if (isCallbackType(p.type)) { + return `${p.name}: ${tsCallbackType(p.nativeType || p.type)}` + } + const pStructDts = findStruct(p.type, structs) + if (pStructDts) { + return `${p.name}: ${pStructDts.name}` + } + return `${p.name}: ${tsParamType(p)}` + }) + if (fn.stream) { + const value = tsTypeFromNative(fn.stream.elementType, fn.stream.transport === 'json') + params.push(`onValue: (value: ${value}) => void`) + params.push('onError?: (error: Error) => void') + params.push('onComplete?: () => void') + } + + const ret = tsReturnType(fn, structs) + const asyncRet = fn.stream ? 'SwiftNodeSubscription' : fn.isAsync ? `Promise<${ret}>` : ret + + iface.push(` ${name}(${params.join(', ')}): ${asyncRet}`) + } + + lines.push(' interface NativeBindings {') + lines.push(iface.map((line) => ` ${line}`).join('\n')) + lines.push(' }') + lines.push('}') + lines.push('') + lines.push('declare const native: native.NativeBindings') + lines.push('export = native') + + return lines.join('\n') +} + +// Emitted verbatim into both module formats so generated packages load only +// their declared binary without a runtime dependency. diff --git a/packages/swift-node/src/generator/entrypoints.ts b/packages/swift-node/src/generator/entrypoints.ts new file mode 100644 index 0000000..4b9ccb2 --- /dev/null +++ b/packages/swift-node/src/generator/entrypoints.ts @@ -0,0 +1,83 @@ +import { SwiftFunction } from '../parser.js' +import { jsName } from './shared.js' + +function generatedAddonResolverLines(): string[] { + return [ + `function resolveAddonPath(dir, moduleName) {`, + ` const isMusl = process.platform === 'linux' && !process.report?.getReport?.().header?.glibcVersionRuntime`, + ` const target = process.platform + '-' + process.arch + (isMusl ? '-musl' : '')`, + ` const binaryName = moduleName + '.' + target + '.node'`, + ` const binaryPath = path.join(dir, ...(process.platform === 'darwin' ? [] : [target]), binaryName)`, + ` if (existsSync(binaryPath)) return binaryPath`, + '', + ` throw new Error(`, + ` 'No .node binary found for ' + target + '.\\n' +`, + ` 'Checked:\\n' +`, + ` ' ' + binaryPath + '\\n' +`, + ` "Run 'swift-node build' for this platform and architecture."`, + ` )`, + `}`, + ] +} + +// Generate dist_swift-node/index.mjs — ESM entry point. Pure JS (no TS syntax) so +// Node can load it without a TypeScript loader. Types come from index.d.ts. +// .mjs extension ensures ESM parsing regardless of package "type" field. +export function generateEntryMjs(functions: SwiftFunction[], moduleName: string): string { + const lines = [ + '// Generated by swift-node — do not edit', + '', + `import { createRequire } from 'node:module'`, + `import { fileURLToPath } from 'node:url'`, + `import path from 'node:path'`, + '', + `const require = createRequire(import.meta.url)`, + `const __dirname = path.dirname(fileURLToPath(import.meta.url))`, + `const { existsSync } = require('node:fs')`, + '', + ...generatedAddonResolverLines(), + '', + `const native = require(resolveAddonPath(__dirname, ${JSON.stringify(moduleName)}))`, + '', + ] + for (const [index, fn] of functions.entries()) { + const name = jsName(fn.symbolName, moduleName) + lines.push(`const __swift_node_${index} = native[${JSON.stringify(name)}]`) + } + if (functions.length > 0) { + lines.push('') + } + for (const [index, fn] of functions.entries()) { + const name = jsName(fn.symbolName, moduleName) + lines.push(`export { __swift_node_${index} as ${name} }`) + } + return lines.join('\n') +} + +// Generate dist_swift-node/index.cjs — CJS entry point for require() consumers. +export function generateEntryCjs(functions: SwiftFunction[], moduleName: string): string { + const lines = [ + '// Generated by swift-node — do not edit', + `const path = require('node:path')`, + `const { existsSync } = require('node:fs')`, + '', + ...generatedAddonResolverLines(), + '', + `const native = require(resolveAddonPath(__dirname, ${JSON.stringify(moduleName)}))`, + '', + `module.exports = {`, + ] + for (const fn of functions) { + const name = jsName(fn.symbolName, moduleName) + lines.push(` ${JSON.stringify(name)}: native[${JSON.stringify(name)}],`) + } + lines.push('}') + return lines.join('\n') +} + +// Generate the TypeScript source entry created by `swift-node init`. Package +// exports point straight at dist_swift-node; this file is the convenient source +// entry for a project's own TypeScript code. +export function generateSourceEntryTs(): string { + return "export * from '../dist_swift-node/index.mjs'\n" +} diff --git a/packages/swift-node/src/generator/index.ts b/packages/swift-node/src/generator/index.ts new file mode 100644 index 0000000..e74fb84 --- /dev/null +++ b/packages/swift-node/src/generator/index.ts @@ -0,0 +1,13 @@ +/** + * Stable public facade for swift-node code generation. + * + * Generator implementation modules are intentionally private so callers keep + * importing this path while output families evolve independently. + */ + +export { cppIdentifier } from './shared.js' +export { generateBridgeH, generateStructsHeader } from './bridge-header.js' +export { generateAddonCpp } from './addon.js' +export { exportedToSwiftFunctions, generateWrappersSwift } from './swift-wrapper/index.js' +export { generateDts, generateDtsCjs } from './declarations.js' +export { generateEntryCjs, generateEntryMjs, generateSourceEntryTs } from './entrypoints.js' diff --git a/packages/swift-node/src/generator/shared.ts b/packages/swift-node/src/generator/shared.ts new file mode 100644 index 0000000..e255a92 --- /dev/null +++ b/packages/swift-node/src/generator/shared.ts @@ -0,0 +1,227 @@ +/** + * Generates C++ addon code, bridge header, and TypeScript definitions + * from parsed Swift function metadata. + */ + +import { + type BridgeTransport, + type SwiftFunction, + type SwiftParam, + type SwiftStruct, + type PromiseCallbackInfo, + classifySwiftType, + type SwiftTypeCategory, + parseCallbackType, + classifyNativeSwiftType, +} from '../parser.js' + +// Sanitize name for use as a C/C++ identifier +export function sanitizeId(name: string): string { + return name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^[0-9]/, '_$&') +} + +const cppKeywords = new Set([ + 'alignas', + 'alignof', + 'and', + 'and_eq', + 'asm', + 'atomic_cancel', + 'atomic_commit', + 'atomic_noexcept', + 'auto', + 'bitand', + 'bitor', + 'bool', + 'break', + 'case', + 'catch', + 'char', + 'char8_t', + 'char16_t', + 'char32_t', + 'class', + 'compl', + 'concept', + 'const', + 'consteval', + 'constexpr', + 'constinit', + 'const_cast', + 'continue', + 'co_await', + 'co_return', + 'co_yield', + 'decltype', + 'default', + 'delete', + 'do', + 'double', + 'dynamic_cast', + 'else', + 'enum', + 'explicit', + 'export', + 'extern', + 'false', + 'float', + 'for', + 'friend', + 'goto', + 'if', + 'inline', + 'int', + 'long', + 'mutable', + 'namespace', + 'new', + 'noexcept', + 'not', + 'not_eq', + 'nullptr', + 'operator', + 'or', + 'or_eq', + 'private', + 'protected', + 'public', + 'reflexpr', + 'register', + 'reinterpret_cast', + 'requires', + 'return', + 'short', + 'signed', + 'sizeof', + 'static', + 'static_assert', + 'static_cast', + 'struct', + 'switch', + 'synchronized', + 'template', + 'this', + 'thread_local', + 'throw', + 'true', + 'try', + 'typedef', + 'typeid', + 'typename', + 'union', + 'unsigned', + 'using', + 'virtual', + 'void', + 'volatile', + 'wchar_t', + 'while', + 'xor', + 'xor_eq', +]) + +export function cppIdentifier(name: string): string { + const identifier = sanitizeId(name) + return cppKeywords.has(identifier) ? `_swift_node_${identifier}` : identifier +} + +// Derive JS-facing name from a symbol like "ModuleName_funcName" +export function jsName(symbolName: string, moduleName: string): string { + const sanitized = sanitizeId(moduleName) + if (symbolName.startsWith(sanitized + '_')) { + return symbolName.slice(sanitized.length + 1) + } + // No module prefix found — use full symbol name to avoid collisions + return symbolName +} + +// --- C++ type mapping --- + +export function cppType(swiftType: string): string { + if (swiftType === 'UnsafeRawPointer' || swiftType === 'UnsafeRawPointer?') return 'const void*' + const cat = classifySwiftType(swiftType) + switch (cat) { + case 'int32': + return 'int32_t' + case 'int64': + return 'int64_t' + case 'double': + return 'double' + case 'bool': + return 'bool' + case 'string': + return 'const char*' + case 'buffer': + return 'const uint8_t*' + case 'void': + return 'void' + default: + return 'void*' + } +} + +// C++ type from a native Swift type category (used for export-generated bridge code) +export function cppTypeFromCategory(cat: SwiftTypeCategory): string { + switch (cat) { + case 'int32': + return 'int32_t' + case 'int64': + return 'int64_t' + case 'double': + return 'double' + case 'bool': + return 'bool' + case 'string': + return 'const char*' + case 'void': + return 'void' + default: + return 'void*' + } +} + +export function cppReturnType(swiftType: string): string { + if (swiftType.includes('UnsafeMutablePointer')) return 'char*' + return cppType(swiftType) +} + +export function isNullableType(swiftType: string): boolean { + return swiftType.replace(/\s+/g, ' ').trim().endsWith('?') +} + +// Filter out error output params and callback params for the JS-facing signature count +export function jsParams(fn: SwiftFunction): SwiftParam[] { + return fn.params.filter( + (p) => + !p.type.includes('UnsafeMutablePointer?>') && + !p.bridgeStringLengthFor && + !p.bridgeStringResultLength && + !p.bridgeBorrowedBufferLengthFor && + !p.callbackContext && + !p.promiseCallbackRelease, + ) +} + +export function promiseCallbackInfo(type: string): PromiseCallbackInfo | null { + const callback = parseCallbackType(type) + if (!callback || !callback.isAsync || !callback.throws || callback.returnType !== 'String') { + return null + } + + if (callback.params.some((parameter) => parameter.swiftType.replace(/\s+/g, '') !== 'String')) { + return null + } + + return { params: callback.params, returnType: callback.returnType } +} + +// --- Bridge header generation --- + +export function findStruct(typeName: string, structs: SwiftStruct[]): SwiftStruct | undefined { + // Match by Swift name (Point) or C name (swift_node_Point) + return structs.find((s) => s.name === typeName || `swift_node_${s.name}` === typeName) +} + +export function streamElementUsesStringLength(type: string, transport?: BridgeTransport): boolean { + return transport !== 'json' && classifyNativeSwiftType(type) === 'string' +} diff --git a/packages/swift-node/src/generator/swift-wrapper/callbacks.ts b/packages/swift-node/src/generator/swift-wrapper/callbacks.ts new file mode 100644 index 0000000..e6bc380 --- /dev/null +++ b/packages/swift-node/src/generator/swift-wrapper/callbacks.ts @@ -0,0 +1,173 @@ +import { ExportedFunction, classifyNativeSwiftType } from '../../parser.js' +import { isNullableType, promiseCallbackInfo, sanitizeId } from '../shared.js' + +export function emitSwiftCallbackBridgeCall( + lines: string[], + callbackName: string, + callbackContext: string, + cbParamTypes: string[], + indent: string, + startIndex = 0, + cArgs: string[][] = [], +): void { + const nextStringIndex = cbParamTypes.findIndex( + (t, i) => i >= startIndex && classifyNativeSwiftType(t) === 'string', + ) + + if (nextStringIndex === -1) { + const callArgs = [ + callbackContext, + ...cbParamTypes.flatMap( + (type, i) => + cArgs[i] ?? + (classifyNativeSwiftType(type) === 'string' + ? [`cbArg${i}`, `cbArg${i}.utf8.count`] + : [`cbArg${i}`]), + ), + ] + lines.push(`${indent}${callbackName}(${callArgs.join(', ')})`) + return + } + + const argName = `cbArg${nextStringIndex}` + const cName = `cStr${nextStringIndex}` + const type = cbParamTypes[nextStringIndex] + + if (isNullableType(type)) { + lines.push(`${indent}if let ${argName} = ${argName} {`) + lines.push(`${indent} ${argName}.withCString { ${cName} in`) + const withArgs = [...cArgs] + withArgs[nextStringIndex] = [cName, `${argName}.utf8.count`] + emitSwiftCallbackBridgeCall( + lines, + callbackName, + callbackContext, + cbParamTypes, + `${indent} `, + nextStringIndex + 1, + withArgs, + ) + lines.push(`${indent} }`) + lines.push(`${indent}} else {`) + const nilArgs = [...cArgs] + nilArgs[nextStringIndex] = ['nil', '0'] + emitSwiftCallbackBridgeCall( + lines, + callbackName, + callbackContext, + cbParamTypes, + `${indent} `, + nextStringIndex + 1, + nilArgs, + ) + lines.push(`${indent}}`) + return + } + + lines.push(`${indent}${argName}.withCString { ${cName} in`) + const withArgs = [...cArgs] + withArgs[nextStringIndex] = [cName, `${argName}.utf8.count`] + emitSwiftCallbackBridgeCall( + lines, + callbackName, + callbackContext, + cbParamTypes, + `${indent} `, + nextStringIndex + 1, + withArgs, + ) + lines.push(`${indent}}`) +} + +export function generatePromiseCallbackSwiftRuntime( + fn: ExportedFunction, + parameter: ExportedFunction['params'][number], +): string { + const info = promiseCallbackInfo(parameter.type) + if (!info) return '' + + const prefix = `${sanitizeId(fn.name)}_${sanitizeId(parameter.name)}` + const cParameters = info.params.flatMap(() => ['UnsafePointer', 'Int']).join(', ') + const lines: string[] = [] + + lines.push( + `public typealias SwiftNodePromiseCompletion_${prefix} = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int, UnsafePointer?, Int) -> Void`, + ) + lines.push( + `public typealias SwiftNodePromiseInvoke_${prefix} = @convention(c) (UnsafeMutableRawPointer?${cParameters ? `, ${cParameters}` : ''}, SwiftNodePromiseCompletion_${prefix}, UnsafeMutableRawPointer?) -> Void`, + ) + lines.push( + `public typealias SwiftNodePromiseRelease_${prefix} = @convention(c) (UnsafeMutableRawPointer?) -> Void`, + ) + lines.push(`private final class SwiftNodePromiseContinuation_${prefix}: @unchecked Sendable {`) + lines.push(` private let lock = NSLock()`) + lines.push(` private var continuation: CheckedContinuation?`) + lines.push( + ` init(_ continuation: CheckedContinuation) { self.continuation = continuation }`, + ) + lines.push( + ` func resume(_ value: UnsafePointer?, _ valueLength: Int, _ error: UnsafePointer?, _ errorLength: Int) {`, + ) + lines.push(` lock.lock()`) + lines.push(` let continuation = self.continuation`) + lines.push(` self.continuation = nil`) + lines.push(` lock.unlock()`) + lines.push(` guard let continuation else { return }`) + lines.push( + ` if let error { continuation.resume(throwing: NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: swiftNodeDecodeUTF8(error, errorLength)])); return }`, + ) + lines.push( + ` guard let value else { continuation.resume(throwing: NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "JavaScript callback resolved without a value"])); return }`, + ) + lines.push(` continuation.resume(returning: swiftNodeDecodeUTF8(value, valueLength))`) + lines.push(` }`) + lines.push(`}`) + lines.push( + `private func swiftNodePromiseComplete_${prefix}(_ context: UnsafeMutableRawPointer?, _ value: UnsafePointer?, _ valueLength: Int, _ error: UnsafePointer?, _ errorLength: Int) {`, + ) + lines.push(` guard let context else { return }`) + lines.push( + ` Unmanaged.fromOpaque(context).takeRetainedValue().resume(value, valueLength, error, errorLength)`, + ) + lines.push(`}`) + lines.push(`private final class SwiftNodePromiseHandler_${prefix}: @unchecked Sendable {`) + lines.push(` let invoke: SwiftNodePromiseInvoke_${prefix}`) + lines.push(` let context: UnsafeMutableRawPointer?`) + lines.push(` let release: SwiftNodePromiseRelease_${prefix}`) + lines.push( + ` init(invoke: @escaping SwiftNodePromiseInvoke_${prefix}, context: UnsafeMutableRawPointer?, release: @escaping SwiftNodePromiseRelease_${prefix}) { self.invoke = invoke; self.context = context; self.release = release }`, + ) + lines.push(` deinit { release(context) }`) + lines.push( + ` func call(${info.params.map((_, index) => `_ callbackArg${index}: String`).join(', ')}) async throws -> String {`, + ) + lines.push(` try await withCheckedThrowingContinuation { continuation in`) + lines.push( + ` let pending = Unmanaged.passRetained(SwiftNodePromiseContinuation_${prefix}(continuation)).toOpaque()`, + ) + if (info.params.length === 0) { + lines.push(` invoke(context, swiftNodePromiseComplete_${prefix}, pending)`) + } else { + const emit = (index: number, indent: string): void => { + if (index === info.params.length) { + const callArguments = Array.from({ length: info.params.length }, (_, argumentIndex) => [ + `cArg${argumentIndex}`, + `callbackArg${argumentIndex}.utf8.count`, + ]).flat() + lines.push( + `${indent}invoke(context, ${callArguments.join(', ')}, swiftNodePromiseComplete_${prefix}, pending)`, + ) + return + } + lines.push(`${indent}callbackArg${index}.withCString { cArg${index} in`) + emit(index + 1, `${indent} `) + lines.push(`${indent}}`) + } + emit(0, ' ') + } + lines.push(` }`) + lines.push(` }`) + lines.push(`}`) + + return lines.join('\n') +} diff --git a/packages/swift-node/src/generator/swift-wrapper/common.ts b/packages/swift-node/src/generator/swift-wrapper/common.ts new file mode 100644 index 0000000..fbcb672 --- /dev/null +++ b/packages/swift-node/src/generator/swift-wrapper/common.ts @@ -0,0 +1,95 @@ +import { + BridgeTransport, + ExportedFunction, + SwiftStructField, + bridgeTransportForType, + classifyNativeSwiftType, + splitParams, +} from '../../parser.js' +import { cppIdentifier } from '../shared.js' + +// Map a native Swift type to its C-compatible equivalent for @_cdecl wrappers +export function nativeToCdeclType(type: string, isReturn: boolean): string { + const cat = classifyNativeSwiftType(type) + const nullable = type.endsWith('?') + switch (cat) { + case 'string': + if (isReturn) return nullable ? 'UnsafeMutablePointer?' : 'UnsafeMutablePointer' + return nullable ? 'UnsafePointer?' : 'UnsafePointer' + case 'buffer': + return 'UnsafePointer' + case 'int32': + return 'Int32' + case 'int64': + return swiftBaseType(type) === 'Int64' ? 'Int64' : 'Int' + case 'double': + return 'Double' + case 'bool': + return 'Bool' + case 'void': + return 'Void' + default: + return type + } +} + +export function swiftBaseType(type: string): string { + return type.replace(/\s+/g, ' ').trim().replace(/\?$/, '').trim() +} + +function isNativeStringField(field: SwiftStructField): boolean { + return swiftBaseType(field.type) === 'String' +} + +export function swiftStructInputValue(paramName: string, field: SwiftStructField): string { + const fieldName = cppIdentifier(field.name) + if (field.category === 'string') { + return isNativeStringField(field) + ? `swiftNodeDecodeUTF8(${paramName}.${fieldName}, ${paramName}.${fieldName}_len)` + : `${paramName}.${fieldName}` + } + if (field.category === 'int64' && swiftBaseType(field.type) === 'Int') { + return `Int(${paramName}.${fieldName})` + } + if (field.category === 'double' && swiftBaseType(field.type) === 'Float') { + return `Float(${paramName}.${fieldName})` + } + return `${paramName}.${fieldName}` +} + +export function swiftStructReturnValue(field: SwiftStructField): string { + if (field.category === 'int64' && swiftBaseType(field.type) === 'Int') { + return `Int64(result.${field.name})` + } + if (field.category === 'double' && swiftBaseType(field.type) === 'Float') { + return `Double(result.${field.name})` + } + return `result.${field.name}` +} + +// Generate the Swift call expression with proper argument labels +export function generateSwiftCall(fn: ExportedFunction): string { + if (fn.params.length === 0) return `${fn.name}()` + const args = fn.params.map((p) => { + // Escape Swift keywords with backticks + const callName = `swift_${p.name}` + const cat = classifyNativeSwiftType(p.type) + const conversion = cat === 'string' ? callName : `swift_${p.name}` + if (p.label === '_') return conversion + if (p.label === p.name) return `${p.label}: ${conversion}` + return `${p.label}: ${conversion}` + }) + return `${fn.name}(${args.join(', ')})` +} + +export function generatedTransport( + type: string, + codableTypes: Iterable, +): BridgeTransport | null { + return bridgeTransportForType(type, codableTypes) +} + +// Split callback param types (simple comma split for native types) +export function splitExportCallbackParams(str: string): string[] { + return splitParams(str) +} diff --git a/packages/swift-node/src/generator/swift-wrapper/function-wrapper.ts b/packages/swift-node/src/generator/swift-wrapper/function-wrapper.ts new file mode 100644 index 0000000..d5a62c4 --- /dev/null +++ b/packages/swift-node/src/generator/swift-wrapper/function-wrapper.ts @@ -0,0 +1,389 @@ +import { + BridgeTransport, + ExportedFunction, + SwiftStruct, + SwiftTypeCategory, + classifyNativeSwiftType, +} from '../../parser.js' +import { cppIdentifier, findStruct, promiseCallbackInfo, sanitizeId } from '../shared.js' +import { emitSwiftCallbackBridgeCall } from './callbacks.js' +import { + generateSwiftCall, + generatedTransport, + nativeToCdeclType, + splitExportCallbackParams, + swiftBaseType, + swiftStructInputValue, + swiftStructReturnValue, +} from './common.js' + +function emitSwiftDummyReturn( + lines: string[], + retCat: SwiftTypeCategory, + transport: BridgeTransport | null, + indent: string, + returnStruct?: SwiftStruct, +): void { + if (retCat === 'void') lines.push(`${indent}return`) + else if (returnStruct) lines.push(`${indent}return swift_node_${returnStruct.name}()`) + else if (transport || retCat === 'string') + lines.push(`${indent}return UnsafeMutablePointer(mutating: strdup("")!)`) + else if (retCat === 'bool') lines.push(`${indent}return false`) + else if (retCat === 'int32' || retCat === 'int64' || retCat === 'double') + lines.push(`${indent}return 0`) +} + +function emitSwiftBridgeFailure( + lines: string[], + retCat: SwiftTypeCategory, + transport: BridgeTransport | null, + indent: string, + returnStruct?: SwiftStruct, +): void { + lines.push( + `${indent}out_error.pointee = swiftNodeBridgeError("swift-node could not encode or decode a bridged value")`, + ) + emitSwiftDummyReturn(lines, retCat, transport, indent, returnStruct) +} + +export function generateSingleWrapper( + fn: ExportedFunction, + moduleName: string, + structs: SwiftStruct[] = [], + codableTypes: Iterable = [], +): string { + const lines: string[] = [] + const symbol = `${sanitizeId(moduleName)}_${fn.name}` + const wrapperName = `_sn_${sanitizeId(moduleName)}_${fn.name}` + const paramTransports = new Map( + fn.params.map((p) => [p.name, generatedTransport(p.type, codableTypes)]), + ) + const returnTransport = generatedTransport(fn.returnType, codableTypes) + const retCat = classifyNativeSwiftType(fn.returnType) + const retStruct = returnTransport ? undefined : findStruct(fn.returnType, structs) + const directStringReturn = !returnTransport && retCat === 'string' + const actorRunsAsync = !!fn.actorIsolation && fn.actorIsolation !== 'MainActor' + const needsErrorBridge = + fn.throws || + fn.isAsync || + actorRunsAsync || + returnTransport !== null || + Array.from(paramTransports.values()).some(Boolean) + + // Build @_cdecl parameter list + const cdeclParams: string[] = fn.params.map((p) => { + const cat = classifyNativeSwiftType(p.type) + const transport = paramTransports.get(p.name) + if (transport === 'borrowed') return `_ ${p.name}: UnsafeRawPointer?, _ ${p.name}Len: Int` + if (transport) return `_ ${p.name}: UnsafePointer` + if (cat === 'callback') { + const asyncInfo = promiseCallbackInfo(p.type) + if (asyncInfo) { + const prefix = `${sanitizeId(fn.name)}_${sanitizeId(p.name)}` + return `_ ${p.name}: SwiftNodePromiseInvoke_${prefix}, _ ${p.name}Context: UnsafeMutableRawPointer?, _ ${p.name}Release: SwiftNodePromiseRelease_${prefix}` + } + const cleaned = p.type.replace(/@escaping\s+/g, '').trim() + const match = cleaned.match(/^\(([^)]*)\)\s*->\s*(.+)$/) + if (match) { + const cbParams = match[1] ? splitExportCallbackParams(match[1]) : [] + const cParams = cbParams + .flatMap((cp) => { + const type = cp.trim() + return classifyNativeSwiftType(type) === 'string' + ? [nativeToCdeclType(type, false), 'Int'] + : [nativeToCdeclType(type, false)] + }) + .join(', ') + return `_ ${p.name}: @convention(c) (UnsafeMutableRawPointer?${cParams ? `, ${cParams}` : ''}) -> Void, _ ${p.name}Context: UnsafeMutableRawPointer?` + } + return `_ ${p.name}: @convention(c) (UnsafeMutableRawPointer?) -> Void, _ ${p.name}Context: UnsafeMutableRawPointer?` + } + // Check if it's a known struct type + const pStruct = findStruct(p.type, structs) + if (pStruct) { + return `_ ${p.name}: swift_node_${pStruct.name}` + } + // Buffer types need a pointer + length pair + if (cat === 'buffer') { + return `_ ${p.name}: UnsafePointer, _ ${p.name}Len: Int` + } + if (cat === 'string') { + return `_ ${p.name}: ${nativeToCdeclType(p.type, false)}, _ ${p.name}Len: Int` + } + const cdeclType = nativeToCdeclType(p.type, false) + return `_ ${p.name}: ${cdeclType}` + }) + + if (directStringReturn) { + cdeclParams.push('_ out_result_len: UnsafeMutablePointer') + } + + // Add error out param if function throws + if (needsErrorBridge) { + cdeclParams.push('_ out_error: UnsafeMutablePointer?>') + } + + // Return type + const cdeclReturn = + fn.returnType === 'Void' + ? '' + : returnTransport + ? ' -> UnsafeMutablePointer' + : retStruct + ? ` -> swift_node_${retStruct.name}` + : ` -> ${nativeToCdeclType(fn.returnType, true)}` + + lines.push(`@_cdecl("${symbol}")`) + lines.push(`public func ${wrapperName}(${cdeclParams.join(', ')})${cdeclReturn} {`) + if (directStringReturn) lines.push(' out_result_len.pointee = 0') + + // Convert input params from C types to Swift types + for (const p of fn.params) { + const cat = classifyNativeSwiftType(p.type) + const pStruct = findStruct(p.type, structs) + const transport = paramTransports.get(p.name) + if (transport === 'borrowed') { + lines.push( + ` let swift_${p.name} = UnsafeRawBufferPointer(start: ${p.name}, count: ${p.name}Len)`, + ) + } else if (transport === 'json') { + lines.push(` let swift_${p.name}: ${p.type}`) + lines.push(' do {') + lines.push( + ` swift_${p.name} = try JSONDecoder().decode(${p.type}.self, from: Data(String(cString: ${p.name}).utf8))`, + ) + lines.push(' } catch {') + emitSwiftBridgeFailure(lines, retCat, returnTransport, ' ', retStruct) + lines.push(' }') + } else if (transport === 'data') { + const binaryName = + p.type.replace(/\s+/g, '') === '[UInt8]' ? `binary_${p.name}` : `swift_${p.name}` + lines.push( + ` guard let ${binaryName} = Data(base64Encoded: String(cString: ${p.name})) else {`, + ) + emitSwiftBridgeFailure(lines, retCat, returnTransport, ' ', retStruct) + lines.push(' }') + if (p.type.replace(/\s+/g, '') === '[UInt8]') { + lines.push(` let swift_${p.name} = [UInt8](${binaryName})`) + } + } else if (pStruct) { + // Convert C struct to Swift struct via init + const fieldArgs = pStruct.fields.map((f) => `${f.name}: ${swiftStructInputValue(p.name, f)}`) + lines.push(` let swift_${p.name} = ${pStruct.name}(${fieldArgs.join(', ')})`) + } else if (cat === 'buffer') { + lines.push(` let swift_${p.name} = Data(bytes: ${p.name}, count: ${p.name}Len)`) + } else if (cat === 'string') { + const nullable = p.type.endsWith('?') + if (nullable) { + lines.push( + ` let swift_${p.name}: String? = ${p.name}.map { swiftNodeDecodeUTF8($0, ${p.name}Len) }`, + ) + } else { + lines.push(` let swift_${p.name} = swiftNodeDecodeUTF8(${p.name}, ${p.name}Len)`) + } + } else if (cat === 'double' && swiftBaseType(p.type) === 'Float') { + lines.push(` let swift_${p.name} = Float(${p.name})`) + } else if (cat === 'callback') { + const asyncInfo = promiseCallbackInfo(p.type) + if (asyncInfo) { + const prefix = `${sanitizeId(fn.name)}_${sanitizeId(p.name)}` + const cleaned = p.type.replace(/@escaping\s+/g, '').trim() + const callbackArguments = asyncInfo.params + .map((_, index) => `callbackArg${index}`) + .join(', ') + lines.push( + ` let handler_${prefix} = SwiftNodePromiseHandler_${prefix}(invoke: ${p.name}, context: ${p.name}Context, release: ${p.name}Release)`, + ) + lines.push( + ` let swift_${p.name}: ${cleaned} = { ${callbackArguments} in try await handler_${prefix}.call(${callbackArguments}) }`, + ) + continue + } + // Create a bridging closure: user's function expects Swift types (String), + // but we have a @convention(c) function pointer that takes C types (UnsafePointer). + // The closure accepts Swift types, converts them to C, and calls the C function. + const cleaned = p.type.replace(/@escaping\s+/g, '').trim() + const cbMatch = cleaned.match(/^\(([^)]*)\)\s*->\s*(.+)$/) + if (cbMatch && cbMatch[1]) { + const cbParamTypes = splitExportCallbackParams(cbMatch[1]).map((t) => t.trim()) + lines.push( + ` let swift_${p.name}: ${cleaned} = { ${cbParamTypes.map((_, i) => `cbArg${i}`).join(', ')} in`, + ) + emitSwiftCallbackBridgeCall(lines, p.name, `${p.name}Context`, cbParamTypes, ' ') + lines.push(` }`) + } else { + // No-param callback — pass through directly + lines.push(` let swift_${p.name} = ${p.name}`) + } + } else { + lines.push(` let swift_${p.name} = ${p.name}`) + } + } + + // Call the user's function and handle return + const callExpr = generateSwiftCall(fn) + const isolatedCallExpr = fn.actorIsolation + ? `${fn.actorIsolation}.assumeIsolated { ${fn.throws ? 'try ' : ''}${callExpr} }` + : callExpr + + if (fn.isAsync || actorRunsAsync) { + lines.push(' let semaphore = DispatchSemaphore(value: 0)') + if (retCat !== 'void') lines.push(` var asyncResult: ${fn.returnType}?`) + lines.push(' var asyncError: Error?') + lines.push(fn.actorIsolation ? ` Task { @${fn.actorIsolation} in` : ' Task {') + lines.push(' do {') + if (retCat === 'void') { + lines.push(` ${fn.throws ? 'try ' : ''}${fn.isAsync ? 'await ' : ''}${callExpr}`) + } else { + lines.push( + ` asyncResult = ${fn.throws ? 'try ' : ''}${fn.isAsync ? 'await ' : ''}${callExpr}`, + ) + } + lines.push(' } catch {') + lines.push(' asyncError = error') + lines.push(' }') + lines.push(' semaphore.signal()') + lines.push(' }') + lines.push(' semaphore.wait()') + lines.push(' if let asyncError {') + lines.push(' out_error.pointee = swiftNodeBridgeError(asyncError)') + emitSwiftDummyReturn(lines, retCat, returnTransport, ' ', retStruct) + lines.push(' }') + if (retCat !== 'void') { + lines.push(' guard let result = asyncResult else {') + emitSwiftBridgeFailure(lines, retCat, returnTransport, ' ', retStruct) + lines.push(' }') + generateSwiftReturnConversion( + lines, + fn.returnType, + retCat, + ' ', + structs, + returnTransport, + directStringReturn ? 'out_result_len' : undefined, + ) + } + } else if (fn.throws) { + lines.push(' do {') + if (retCat === 'void') { + lines.push(` try ${isolatedCallExpr}`) + } else { + lines.push(` let result = try ${isolatedCallExpr}`) + generateSwiftReturnConversion( + lines, + fn.returnType, + retCat, + ' ', + structs, + returnTransport, + directStringReturn ? 'out_result_len' : undefined, + ) + } + lines.push(' } catch {') + lines.push(' out_error.pointee = swiftNodeBridgeError(error)') + // Return a dummy value on error — the C++ side checks out_error first and throws a JS exception + if (retCat === 'string' && fn.returnType.endsWith('?')) lines.push(' return nil') + else if (returnTransport || retCat === 'string') + lines.push(' return UnsafeMutablePointer(mutating: strdup("")!)') + else if (retStruct) lines.push(` return swift_node_${retStruct.name}()`) + else if (retCat === 'bool') lines.push(' return false') + else if (retCat === 'int32' || retCat === 'int64' || retCat === 'double') + lines.push(' return 0') + lines.push(' }') + } else { + if (retCat === 'void') { + lines.push(` ${isolatedCallExpr}`) + } else { + lines.push(` let result = ${isolatedCallExpr}`) + generateSwiftReturnConversion( + lines, + fn.returnType, + retCat, + ' ', + structs, + returnTransport, + directStringReturn ? 'out_result_len' : undefined, + ) + } + } + + lines.push('}') + return lines.join('\n') +} + +function generateSwiftReturnConversion( + lines: string[], + returnType: string, + retCat: SwiftTypeCategory, + indent: string, + structs: SwiftStruct[] = [], + transport: BridgeTransport | null = null, + stringResultLength?: string, +): void { + const nullable = returnType.endsWith('?') + + if (transport === 'json') { + lines.push(`${indent}guard let encoded = try? JSONEncoder().encode(result) else {`) + emitSwiftBridgeFailure(lines, retCat, transport, `${indent} `) + lines.push(`${indent}}`) + lines.push( + `${indent}return UnsafeMutablePointer(mutating: strdup(String(decoding: encoded, as: UTF8.self))!)`, + ) + return + } + if (transport === 'data') { + const dataResult = returnType.replace(/\s+/g, '') === '[UInt8]' ? 'Data(result)' : 'result' + lines.push( + `${indent}return UnsafeMutablePointer(mutating: strdup(${dataResult}.base64EncodedString())!)`, + ) + return + } + + const retStruct = findStruct(returnType, structs) + if (retStruct) { + lines.push(`${indent}var cResult = swift_node_${retStruct.name}()`) + for (const f of retStruct.fields) { + const fieldName = cppIdentifier(f.name) + if (f.category === 'string') { + if (swiftBaseType(f.type) === 'String') { + lines.push( + `${indent}cResult.${fieldName} = UnsafePointer(swiftNodeCopyUTF8(result.${f.name})!)`, + ) + lines.push(`${indent}cResult.${fieldName}_len = result.${f.name}.utf8.count`) + } else { + lines.push(`${indent}cResult.${fieldName} = result.${f.name}`) + lines.push(`${indent}cResult.${fieldName}_len = strlen(result.${f.name})`) + } + } else { + lines.push(`${indent}cResult.${fieldName} = ${swiftStructReturnValue(f)}`) + } + } + lines.push(`${indent}return cResult`) + return + } + + switch (retCat) { + case 'string': + if (nullable) { + lines.push(`${indent}guard let result = result else { return nil }`) + if (stringResultLength) + lines.push(`${indent}${stringResultLength}.pointee = result.utf8.count`) + lines.push(`${indent}return swiftNodeCopyUTF8(result)!`) + } else { + if (stringResultLength) + lines.push(`${indent}${stringResultLength}.pointee = result.utf8.count`) + lines.push(`${indent}return swiftNodeCopyUTF8(result)!`) + } + break + case 'int32': + case 'int64': + case 'bool': + lines.push(`${indent}return result`) + break + case 'double': + lines.push( + `${indent}return ${swiftBaseType(returnType) === 'Float' ? 'Double(result)' : 'result'}`, + ) + break + } +} diff --git a/packages/swift-node/src/generator/swift-wrapper/index.ts b/packages/swift-node/src/generator/swift-wrapper/index.ts new file mode 100644 index 0000000..e1fa68d --- /dev/null +++ b/packages/swift-node/src/generator/swift-wrapper/index.ts @@ -0,0 +1,136 @@ +import { ExportedFunction, SwiftStruct } from '../../parser.js' +import { promiseCallbackInfo } from '../shared.js' +import { generatePromiseCallbackSwiftRuntime } from './callbacks.js' +import { generateSingleWrapper } from './function-wrapper.js' +import { generateSingleStreamWrapper, generateSwiftStreamRuntime } from './streams.js' + +export { exportedToSwiftFunctions } from './ir.js' + +// Generate Swift wrapper functions containing @_cdecl exports. +export function generateWrappersSwift( + exported: ExportedFunction[], + moduleName: string, + structs: SwiftStruct[] = [], + codableTypes: Iterable = [], +): string { + if (exported.length === 0) return '' + + const lines: string[] = [ + '// Generated by swift-node — do not edit', + `// Source annotation: // @swift-node:export`, + '', + 'import Foundation', + '', + `public indirect enum SwiftNodeJSONValue: Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([SwiftNodeJSONValue]) + case object([String: SwiftNodeJSONValue]) +} + +extension SwiftNodeJSONValue: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } +} + +public protocol SwiftNodeStructuredError: Error { + var code: String { get } + var message: String { get } + var details: [String: SwiftNodeJSONValue] { get } +} + +public extension SwiftNodeStructuredError { + var message: String { localizedDescription } + var details: [String: SwiftNodeJSONValue] { [:] } +} + +private struct SwiftNodeErrorEnvelope: Encodable { + let message: String + let code: String? + let details: [String: SwiftNodeJSONValue]? +} +`, + '', + `private func swiftNodeCopyUTF8(_ value: String) -> UnsafeMutablePointer? { + let bytes = Array(value.utf8) + guard let destination = malloc(bytes.count + 1)?.assumingMemoryBound(to: CChar.self) else { return nil } + bytes.withUnsafeBytes { source in + if !bytes.isEmpty { memcpy(destination, source.baseAddress!, bytes.count) } + } + destination[bytes.count] = 0 + return destination +}`, + '', + `private func swiftNodeEncodeError(_ envelope: SwiftNodeErrorEnvelope) -> UnsafeMutablePointer { + let fallback = #"{"message":"swift-node failed to encode an error"}"# + let encoded = (try? JSONEncoder().encode(envelope)).flatMap { String(data: $0, encoding: .utf8) } ?? fallback + return swiftNodeCopyUTF8(encoded)! +} + +private func swiftNodeBridgeError(_ error: any Error) -> UnsafeMutablePointer { + if let structured = error as? any SwiftNodeStructuredError { + return swiftNodeEncodeError( + SwiftNodeErrorEnvelope( + message: structured.message, + code: structured.code, + details: structured.details + ) + ) + } + return swiftNodeEncodeError( + SwiftNodeErrorEnvelope(message: error.localizedDescription, code: nil, details: nil) + ) +} + +private func swiftNodeBridgeError(_ message: String) -> UnsafeMutablePointer { + swiftNodeEncodeError(SwiftNodeErrorEnvelope(message: message, code: nil, details: nil)) +}`, + '', + `private func swiftNodeDecodeUTF8(_ value: UnsafePointer, _ length: Int) -> String { + String(decoding: UnsafeRawBufferPointer(start: value, count: length).bindMemory(to: UInt8.self), as: UTF8.self) +}`, + '', + ] + + if (exported.some((fn) => fn.isStream)) { + lines.push(generateSwiftStreamRuntime()) + lines.push('') + } + + for (const fn of exported) { + for (const parameter of fn.params) { + if (promiseCallbackInfo(parameter.type)) { + lines.push(generatePromiseCallbackSwiftRuntime(fn, parameter)) + lines.push('') + } + } + } + + for (const fn of exported) { + lines.push( + fn.isStream + ? generateSingleStreamWrapper(fn, moduleName, structs, codableTypes) + : generateSingleWrapper(fn, moduleName, structs, codableTypes), + ) + lines.push('') + } + + return lines.join('\n') +} diff --git a/packages/swift-node/src/generator/swift-wrapper/ir.ts b/packages/swift-node/src/generator/swift-wrapper/ir.ts new file mode 100644 index 0000000..8b74ce2 --- /dev/null +++ b/packages/swift-node/src/generator/swift-wrapper/ir.ts @@ -0,0 +1,222 @@ +import { + ExportedFunction, + SwiftFunction, + SwiftParam, + SwiftStruct, + classifyNativeSwiftType, + parseSwiftStreamReturnType, +} from '../../parser.js' +import { findStruct, promiseCallbackInfo, sanitizeId } from '../shared.js' +import { + generatedTransport, + nativeToCdeclType, + splitExportCallbackParams, + swiftBaseType, +} from './common.js' + +// Convert ExportedFunction[] to SwiftFunction[] for feeding the existing C++ generator. +// This avoids re-parsing the generated Swift wrappers. +export function exportedToSwiftFunctions( + exported: ExportedFunction[], + moduleName: string, + structs: SwiftStruct[] = [], + codableTypes: Iterable = [], +): SwiftFunction[] { + const mod = sanitizeId(moduleName) + return exported.map((fn) => { + const symbolName = `${mod}_${fn.name}` + const params: SwiftParam[] = fn.params.flatMap((p) => { + const cat = classifyNativeSwiftType(p.type) + const transport = generatedTransport(p.type, codableTypes) + if (transport === 'borrowed') { + return [ + { + name: p.name, + type: 'UnsafeRawPointer?', + nativeType: p.type, + transport, + }, + { + name: `${p.name}Len`, + type: 'Int', + bridgeBorrowedBufferLengthFor: p.name, + }, + ] + } + if (transport) { + return [ + { + name: p.name, + type: 'UnsafePointer', + nativeType: p.type, + transport, + }, + ] + } + switch (cat) { + case 'string': { + const nullable = p.type.endsWith('?') + return [ + { name: p.name, type: nullable ? 'UnsafePointer?' : 'UnsafePointer' }, + { name: `${p.name}Len`, type: 'Int', bridgeStringLengthFor: p.name }, + ] + } + case 'buffer': + return [ + { name: p.name, type: 'UnsafePointer' }, + { name: `${p.name}Len`, type: 'Int' }, + ] + case 'int32': + return [{ name: p.name, type: 'Int32' }] + case 'int64': + return [{ name: p.name, type: swiftBaseType(p.type) === 'Int64' ? 'Int64' : 'Int' }] + case 'double': + return [{ name: p.name, type: 'Double' }] + case 'bool': + return [{ name: p.name, type: 'Bool' }] + case 'callback': { + const asyncInfo = promiseCallbackInfo(p.type) + if (asyncInfo) { + const cParams = asyncInfo.params + .flatMap(() => ['UnsafePointer', 'Int']) + .join(', ') + const signature = `@escaping @convention(c) (UnsafeMutableRawPointer?${cParams ? `, ${cParams}` : ''}, @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int, UnsafePointer?, Int) -> Void, UnsafeMutableRawPointer?) -> Void` + return [ + { + name: p.name, + type: signature, + nativeType: p.type, + promiseCallback: asyncInfo, + }, + { name: `${p.name}Context`, type: 'UnsafeMutableRawPointer?', callbackContext: true }, + { + name: `${p.name}Release`, + type: '@escaping @convention(c) (UnsafeMutableRawPointer?) -> Void', + promiseCallbackRelease: true, + }, + ] + } + const cleaned = p.type.replace(/@escaping\s+/g, '').trim() + const match = cleaned.match(/^\(([^)]*)\)\s*->\s*(.+)$/) + if (match) { + const cbParams = match[1] ? splitExportCallbackParams(match[1]) : [] + const cParams = cbParams + .flatMap((cp) => { + const type = cp.trim() + return classifyNativeSwiftType(type) === 'string' + ? [nativeToCdeclType(type, false), 'Int'] + : [nativeToCdeclType(type, false)] + }) + .join(', ') + const signature = `@escaping @convention(c) (UnsafeMutableRawPointer?${cParams ? `, ${cParams}` : ''}) -> Void` + return [ + { name: p.name, type: signature, nativeType: p.type }, + { name: `${p.name}Context`, type: 'UnsafeMutableRawPointer?', callbackContext: true }, + ] + } + return [ + { + name: p.name, + type: '@escaping @convention(c) (UnsafeMutableRawPointer?) -> Void', + nativeType: p.type, + }, + { name: `${p.name}Context`, type: 'UnsafeMutableRawPointer?', callbackContext: true }, + ] + } + default: { + // Check if it's a known struct type + const pStruct = findStruct(p.type, structs) + if (pStruct) return [{ name: p.name, type: `swift_node_${pStruct.name}` }] + return [{ name: p.name, type: p.type }] + } + } + }) + + if (fn.isStream) { + const stream = parseSwiftStreamReturnType(fn.returnType) + // validateExports reports malformed stream declarations before codegen. + // Keeping this guard makes the public generator safe to call directly in + // tests and other tooling as well. + if (!stream) { + throw new Error( + `Stream export '${fn.name}' has an unsupported return type '${fn.returnType}'.`, + ) + } + return { + symbolName, + params, + returnType: 'Void', + isAsync: false, + nativeReturnType: 'Void', + stream: { + ...stream, + ...(generatedTransport(stream.elementType, codableTypes) === 'json' + ? { transport: 'json' as const } + : {}), + }, + } + } + + const returnTransport = generatedTransport(fn.returnType, codableTypes) + const actorRunsAsync = !!fn.actorIsolation && fn.actorIsolation !== 'MainActor' + const needsErrorBridge = + fn.throws || + fn.isAsync || + actorRunsAsync || + returnTransport !== null || + params.some((p) => p.transport) + + // Generated Codable conversion can fail before the user's function runs, + // so it needs the same error channel as a Swift `throws` declaration. + const directStringReturn = + !returnTransport && classifyNativeSwiftType(fn.returnType) === 'string' + if (directStringReturn) { + params.push({ name: 'outResultLen', type: 'Int', bridgeStringResultLength: true }) + } + if (needsErrorBridge) { + params.push({ name: 'outError', type: 'UnsafeMutablePointer?>' }) + } + + // Map return type to C-compatible + const retCat = classifyNativeSwiftType(fn.returnType) + let returnType: string + if (returnTransport) { + returnType = 'UnsafeMutablePointer' + } else + switch (retCat) { + case 'string': { + const nullable = fn.returnType.endsWith('?') + returnType = nullable ? 'UnsafeMutablePointer?' : 'UnsafeMutablePointer' + break + } + case 'int32': + returnType = 'Int32' + break + case 'int64': + returnType = swiftBaseType(fn.returnType) === 'Int64' ? 'Int64' : 'Int' + break + case 'double': + returnType = 'Double' + break + case 'bool': + returnType = 'Bool' + break + case 'void': + returnType = 'Void' + break + default: { + const retStruct = findStruct(fn.returnType, structs) + returnType = retStruct ? `swift_node_${retStruct.name}` : fn.returnType + } + } + + return { + symbolName, + params, + returnType, + isAsync: fn.isAsync || actorRunsAsync, + nativeReturnType: fn.returnType, + returnTransport: returnTransport || undefined, + } + }) +} diff --git a/packages/swift-node/src/generator/swift-wrapper/streams.ts b/packages/swift-node/src/generator/swift-wrapper/streams.ts new file mode 100644 index 0000000..57cb3a9 --- /dev/null +++ b/packages/swift-node/src/generator/swift-wrapper/streams.ts @@ -0,0 +1,248 @@ +import { + BridgeTransport, + ExportedFunction, + SwiftStruct, + classifyNativeSwiftType, + parseSwiftStreamReturnType, +} from '../../parser.js' +import { findStruct, isNullableType, sanitizeId } from '../shared.js' +import { + generateSwiftCall, + generatedTransport, + nativeToCdeclType, + swiftStructInputValue, +} from './common.js' + +// The generated Swift half of a stream owns the Task that iterates the source +// AsyncStream. The C++ half owns JavaScript callback references. Both sides use +// the same subscription id, so cancellation can race safely with completion. +export function generateSwiftStreamRuntime(): string { + return `private final class SwiftNodeStreamTask: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var cancelled = false + + func install(_ task: Task) { + lock.lock() + self.task = task + let shouldCancel = cancelled + lock.unlock() + if shouldCancel { task.cancel() } + } + + func cancel() { + lock.lock() + cancelled = true + let task = task + lock.unlock() + task?.cancel() + } +} + +private enum SwiftNodeStreamRegistry { + private static let lock = NSLock() + nonisolated(unsafe) private static var entries: [Int64: SwiftNodeStreamTask] = [:] + + static func reserve(_ id: Int64) -> SwiftNodeStreamTask { + let entry = SwiftNodeStreamTask() + lock.lock() + entries[id] = entry + lock.unlock() + return entry + } + + static func finish(_ id: Int64) { + lock.lock() + entries.removeValue(forKey: id) + lock.unlock() + } + + static func cancel(_ id: Int64) { + lock.lock() + let entry = entries.removeValue(forKey: id) + lock.unlock() + entry?.cancel() + } +} + +private func swiftNodeStreamComplete( + _ subscriptionID: Int64, + _ callback: @convention(c) (Int64, UnsafePointer?) -> Void, + _ error: Error? = nil +) { + guard let error else { + callback(subscriptionID, nil) + return + } + let encoded = swiftNodeBridgeError(error) + defer { free(encoded) } + callback(subscriptionID, UnsafePointer(encoded)) +}` +} + +function streamElementCdeclType(type: string, transport?: BridgeTransport): string { + if (transport === 'json') return 'UnsafePointer' + const cdeclType = nativeToCdeclType(type, false) + return classifyNativeSwiftType(type) === 'string' ? `${cdeclType}, Int` : cdeclType +} + +function streamElementCallValue(type: string, valueName: string): string { + const normalized = type.replace(/\s+/g, '') + const category = classifyNativeSwiftType(type) + if (category === 'double' && normalized === 'Float') return `Double(${valueName})` + return valueName +} + +function emitSwiftStreamValue( + lines: string[], + elementType: string, + indent: string, + transport?: BridgeTransport, +): void { + if (transport === 'json') { + lines.push(`${indent}guard let encoded = try? JSONEncoder().encode(value) else {`) + lines.push( + `${indent} swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not encode stream value"]))`, + ) + lines.push(`${indent} return`) + lines.push(`${indent}}`) + lines.push( + `${indent}String(decoding: encoded, as: UTF8.self).withCString { on_value(subscription_id, $0) }`, + ) + return + } + const category = classifyNativeSwiftType(elementType) + if (category === 'string') { + if (isNullableType(elementType)) { + lines.push(`${indent}if let value {`) + lines.push( + `${indent} value.withCString { on_value(subscription_id, $0, value.utf8.count) }`, + ) + lines.push(`${indent}} else {`) + lines.push(`${indent} on_value(subscription_id, nil, 0)`) + lines.push(`${indent}}`) + } else { + lines.push(`${indent}value.withCString { on_value(subscription_id, $0, value.utf8.count) }`) + } + return + } + lines.push(`${indent}on_value(subscription_id, ${streamElementCallValue(elementType, 'value')})`) +} + +export function generateSingleStreamWrapper( + fn: ExportedFunction, + moduleName: string, + structs: SwiftStruct[] = [], + codableTypes: Iterable = [], +): string { + const stream = parseSwiftStreamReturnType(fn.returnType) + if (!stream) + throw new Error(`Stream export '${fn.name}' has an unsupported return type '${fn.returnType}'.`) + + const lines: string[] = [] + const symbol = `${sanitizeId(moduleName)}_${fn.name}` + const wrapperName = `_sn_${sanitizeId(moduleName)}_${fn.name}` + const paramTransports = new Map( + fn.params.map((p) => [p.name, generatedTransport(p.type, codableTypes)]), + ) + const elementTransport = + generatedTransport(stream.elementType, codableTypes) === 'json' ? 'json' : undefined + const cdeclParams: string[] = fn.params.map((p) => { + const category = classifyNativeSwiftType(p.type) + const transport = paramTransports.get(p.name) + if (transport) return `_ ${p.name}: UnsafePointer` + const struct = findStruct(p.type, structs) + if (struct) return `_ ${p.name}: swift_node_${struct.name}` + if (category === 'buffer') return `_ ${p.name}: UnsafePointer, _ ${p.name}Len: Int` + if (category === 'string') + return `_ ${p.name}: ${nativeToCdeclType(p.type, false)}, _ ${p.name}Len: Int` + return `_ ${p.name}: ${nativeToCdeclType(p.type, false)}` + }) + cdeclParams.push('_ subscription_id: Int64') + cdeclParams.push( + `_ on_value: @convention(c) (Int64, ${streamElementCdeclType(stream.elementType, elementTransport)}) -> Void`, + ) + cdeclParams.push('_ on_complete: @convention(c) (Int64, UnsafePointer?) -> Void') + + lines.push(`@_cdecl("${symbol}")`) + lines.push(`public func ${wrapperName}(${cdeclParams.join(', ')}) {`) + + // Decode parameters before reserving the generated Task. A malformed JS + // value is still reported through the subscription's onError callback. + for (const p of fn.params) { + const category = classifyNativeSwiftType(p.type) + const struct = findStruct(p.type, structs) + const transport = paramTransports.get(p.name) + if (transport === 'json') { + lines.push( + ` guard let swift_${p.name} = try? JSONDecoder().decode(${p.type}.self, from: Data(String(cString: ${p.name}).utf8)) else {`, + ) + lines.push( + ` swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not decode stream argument '${p.name}'"]))`, + ) + lines.push(' return') + lines.push(' }') + } else if (transport === 'data') { + const binaryName = + p.type.replace(/\s+/g, '') === '[UInt8]' ? `binary_${p.name}` : `swift_${p.name}` + lines.push( + ` guard let ${binaryName} = Data(base64Encoded: String(cString: ${p.name})) else {`, + ) + lines.push( + ` swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not decode stream argument '${p.name}'"]))`, + ) + lines.push(' return') + lines.push(' }') + if (p.type.replace(/\s+/g, '') === '[UInt8]') + lines.push(` let swift_${p.name} = [UInt8](${binaryName})`) + } else if (struct) { + const fields = struct.fields.map( + (field) => `${field.name}: ${swiftStructInputValue(p.name, field)}`, + ) + lines.push(` let swift_${p.name} = ${struct.name}(${fields.join(', ')})`) + } else if (category === 'buffer') { + lines.push(` let swift_${p.name} = Data(bytes: ${p.name}, count: ${p.name}Len)`) + } else if (category === 'string') { + if (p.type.endsWith('?')) + lines.push( + ` let swift_${p.name}: String? = ${p.name}.map { swiftNodeDecodeUTF8($0, ${p.name}Len) }`, + ) + else lines.push(` let swift_${p.name} = swiftNodeDecodeUTF8(${p.name}, ${p.name}Len)`) + } else { + lines.push(` let swift_${p.name} = ${p.name}`) + } + } + + const call = generateSwiftCall(fn) + lines.push(' let registration = SwiftNodeStreamRegistry.reserve(subscription_id)') + lines.push(' let task = Task {') + lines.push(' do {') + lines.push( + ` let stream = ${fn.throws ? 'try ' : ''}${fn.isAsync ? 'await ' : ''}${call}`, + ) + if (stream.isThrowing) lines.push(' for try await value in stream {') + else lines.push(' for await value in stream {') + lines.push(' if Task.isCancelled { break }') + emitSwiftStreamValue(lines, stream.elementType, ' ', elementTransport) + lines.push(' }') + lines.push( + ' if !Task.isCancelled { swiftNodeStreamComplete(subscription_id, on_complete) }', + ) + lines.push(' } catch is CancellationError {') + lines.push(' // JS cancellation intentionally has no terminal callback.') + lines.push(' } catch {') + lines.push( + ' if !Task.isCancelled { swiftNodeStreamComplete(subscription_id, on_complete, error) }', + ) + lines.push(' }') + lines.push(' SwiftNodeStreamRegistry.finish(subscription_id)') + lines.push(' }') + lines.push(' registration.install(task)') + lines.push('}') + lines.push('') + lines.push(`@_cdecl("${symbol}_cancel")`) + lines.push(`public func ${wrapperName}_cancel(_ subscription_id: Int64) {`) + lines.push(' SwiftNodeStreamRegistry.cancel(subscription_id)') + lines.push('}') + return lines.join('\n') +} diff --git a/packages/swift-node/src/validator.ts b/packages/swift-node/src/validator.ts index 5e0d1cc..9435ae6 100644 --- a/packages/swift-node/src/validator.ts +++ b/packages/swift-node/src/validator.ts @@ -14,7 +14,7 @@ import { parseCallbackType, parseSwiftStreamReturnType, } from './parser.js' -import { cppIdentifier } from './generator.js' +import { cppIdentifier } from './generator/index.js' export interface ValidationError { message: string diff --git a/packages/swift-node/test/__snapshots__/generator.test.ts.snap b/packages/swift-node/test/__snapshots__/generator.test.ts.snap new file mode 100644 index 0000000..3747d2b --- /dev/null +++ b/packages/swift-node/test/__snapshots__/generator.test.ts.snap @@ -0,0 +1,1377 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generator facade regression contract > keeps every public generator output byte-for-byte stable for a mixed bridge surface 1`] = ` +{ + "addonCpp": "// Generated by swift-node — do not edit +#include "bridge.h" +#include "swift-node-runtime.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static swift_node_Profile Profile_from_js(napi_env env, napi_value obj, bool* ok) { + swift_node_Profile result{}; + *ok = false; + auto fail = [&]() { + free((void*)result.name); + result.name = nullptr; + return result; + }; + if (!swift_node_expect_type(env, obj, napi_object, "Expected struct argument 'Profile' to be an object")) return fail(); + napi_value prop; + if (!swift_node_napi_ok(env, napi_get_named_property(env, obj, "id", &prop), "Failed to read struct property 'id'")) return fail(); + if (!swift_node_expect_type(env, prop, napi_number, "Expected struct property 'id' to be a number")) return fail(); + if (!swift_node_get_int64(env, prop, &result.id, "Failed to read struct property 'id'")) return fail(); + if (!swift_node_napi_ok(env, napi_get_named_property(env, obj, "name", &prop), "Failed to read struct property 'name'")) return fail(); + if (!swift_node_expect_type(env, prop, napi_string, "Expected struct property 'name' to be a string")) return fail(); + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, prop, nullptr, 0, &result.name_len), "Failed to read struct string length 'name'")) return fail(); + char* name_buf = (char*)malloc(result.name_len + 1); + if (!name_buf) { + swift_node_throw_type_error(env, "Out of memory"); + return fail(); + } + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, prop, name_buf, result.name_len + 1, &result.name_len), "Failed to read struct string 'name'")) { + free(name_buf); + return fail(); + } + result.name = name_buf; + *ok = true; + return result; +} + +static napi_value Profile_to_js(napi_env env, swift_node_Profile s) { + napi_value obj; + if (!swift_node_napi_ok(env, napi_create_object(env, &obj), "Failed to create object return value")) return nullptr; + napi_value prop; + if (!swift_node_napi_ok(env, napi_create_int64(env, s.id, &prop), "Failed to create struct property 'id'")) return nullptr; + if (!swift_node_napi_ok(env, napi_set_named_property(env, obj, "id", prop), "Failed to set struct property 'id'")) return nullptr; + if (!swift_node_napi_ok(env, napi_create_string_utf8(env, s.name, s.name_len, &prop), "Failed to create struct property 'name'")) return nullptr; + if (!swift_node_napi_ok(env, napi_set_named_property(env, obj, "name", prop), "Failed to set struct property 'name'")) return nullptr; + return obj; +} + +static void Profile_free_strings(swift_node_Profile& s) { + free((void*)s.name); + s.name = nullptr; +} + +enum StreamMessageKind_regression_contract_events { stream_message_value_regression_contract_events, stream_message_error_regression_contract_events, stream_message_complete_regression_contract_events }; +struct StreamMessage_regression_contract_events { + StreamMessageKind_regression_contract_events kind; + char* value; + char* error; +}; + +struct StreamState_regression_contract_events { + int64_t subscription_id; + std::atomic closed{false}; + std::atomic cancelled{false}; + napi_threadsafe_function tsfn = nullptr; + napi_ref on_value = nullptr; + napi_ref on_error = nullptr; + napi_ref on_complete = nullptr; +}; +struct StreamHandle_regression_contract_events { std::shared_ptr state; }; +static std::atomic next_stream_id_regression_contract_events{1}; +static std::mutex streams_mutex_regression_contract_events; +static std::unordered_map> streams_regression_contract_events; + +static void cleanup_stream_message_regression_contract_events(StreamMessage_regression_contract_events* message) { + if (!message) return; + free(message->value); + free(message->error); + delete message; +} + +static void cleanup_stream_refs_regression_contract_events(napi_env env, const std::shared_ptr& state) { + if (!env) return; + if (state->on_value) { napi_delete_reference(env, state->on_value); state->on_value = nullptr; } + if (state->on_error) { napi_delete_reference(env, state->on_error); state->on_error = nullptr; } + if (state->on_complete) { napi_delete_reference(env, state->on_complete); state->on_complete = nullptr; } +} + +static void finalize_stream_tsfn_regression_contract_events(napi_env env, void* data, void*) { + auto* owner = static_cast*>(data); + if (owner) { cleanup_stream_refs_regression_contract_events(env, *owner); delete owner; } +} + +static void invoke_stream_handler_regression_contract_events(napi_env env, napi_ref handler, size_t argc, napi_value* argv) { + if (!handler) return; + napi_value fn; + if (napi_get_reference_value(env, handler, &fn) != napi_ok) return; + napi_value global; + if (napi_get_global(env, &global) != napi_ok) return; + napi_status status = napi_call_function(env, global, fn, argc, argv, nullptr); + if (status != napi_ok) { napi_value ignored; napi_get_and_clear_last_exception(env, &ignored); } +} + +static void call_js_stream_regression_contract_events(napi_env env, napi_value, void* context, void* data) { + auto* owner = static_cast*>(context); + auto* message = static_cast(data); + if (!message) return; + if (!env || !owner) { cleanup_stream_message_regression_contract_events(message); return; } + const auto& state = *owner; + if (message->kind == stream_message_value_regression_contract_events && state->cancelled.load()) { cleanup_stream_message_regression_contract_events(message); return; } + if (message->kind == stream_message_value_regression_contract_events) { + napi_value argument; + if (!swift_node_json_parse(env, message->value, &argument)) { cleanup_stream_message_regression_contract_events(message); return; } + invoke_stream_handler_regression_contract_events(env, state->on_value, 1, &argument); + } else if (message->kind == stream_message_error_regression_contract_events) { + napi_value error = swift_node_error_from_swift_payload(env, message->error); + if (error) { + invoke_stream_handler_regression_contract_events(env, state->on_error, 1, &error); + } + } else { + invoke_stream_handler_regression_contract_events(env, state->on_complete, 0, nullptr); + } + cleanup_stream_message_regression_contract_events(message); +} + +static void cancel_stream_regression_contract_events(const std::shared_ptr& state) { + if (!state || state->closed.exchange(true)) return; + state->cancelled.store(true); + regression_contract_events_cancel(state->subscription_id); + if (state->tsfn) napi_release_threadsafe_function(state->tsfn, napi_tsfn_abort); +} + +static void stream_value_regression_contract_events(int64_t subscription_id, const char* value) { + std::shared_ptr state; + napi_status acquire_status = napi_generic_failure; + { std::lock_guard lock(streams_mutex_regression_contract_events); auto found = streams_regression_contract_events.find(subscription_id); if (found != streams_regression_contract_events.end() && !found->second->closed.load()) { state = found->second; acquire_status = napi_acquire_threadsafe_function(state->tsfn); } } + if (!state || acquire_status != napi_ok) return; + auto* message = new StreamMessage_regression_contract_events{}; + message->kind = stream_message_value_regression_contract_events; + message->value = value ? strdup(value) : nullptr; + napi_status status = napi_call_threadsafe_function(state->tsfn, message, napi_tsfn_nonblocking); + if (status != napi_ok) cleanup_stream_message_regression_contract_events(message); + napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); +} + +static void stream_complete_regression_contract_events(int64_t subscription_id, const char* error) { + std::shared_ptr state; + { std::lock_guard lock(streams_mutex_regression_contract_events); auto found = streams_regression_contract_events.find(subscription_id); if (found == streams_regression_contract_events.end()) return; state = found->second; streams_regression_contract_events.erase(found); } + state->closed.store(true); + auto* message = new StreamMessage_regression_contract_events{}; + message->kind = error ? stream_message_error_regression_contract_events : stream_message_complete_regression_contract_events; + message->error = error ? strdup(error) : nullptr; + napi_status status = napi_call_threadsafe_function(state->tsfn, message, napi_tsfn_nonblocking); + if (status != napi_ok) cleanup_stream_message_regression_contract_events(message); + napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); +} + +static void finalize_stream_handle_regression_contract_events(napi_env, void* data, void*) { + auto* handle = static_cast(data); + if (handle) { { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.erase(handle->state->subscription_id); } cancel_stream_regression_contract_events(handle->state); delete handle; } +} + +static napi_value js_cancel_stream_regression_contract_events(napi_env env, napi_callback_info info) { + napi_value this_arg; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, nullptr, nullptr, &this_arg, nullptr), "Failed to read stream subscription")) return nullptr; + StreamHandle_regression_contract_events* handle = nullptr; + if (!swift_node_napi_ok(env, napi_unwrap(env, this_arg, (void**)&handle), "Invalid stream subscription")) return nullptr; + if (handle) { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.erase(handle->state->subscription_id); cancel_stream_regression_contract_events(handle->state); } + napi_value undefined; + if (!swift_node_napi_ok(env, napi_get_undefined(env, &undefined), "Failed to create undefined")) return nullptr; + return undefined; +} + +static napi_value js_stream_closed_regression_contract_events(napi_env env, napi_callback_info info) { + napi_value this_arg; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, nullptr, nullptr, &this_arg, nullptr), "Failed to read stream subscription")) return nullptr; + StreamHandle_regression_contract_events* handle = nullptr; + if (!swift_node_napi_ok(env, napi_unwrap(env, this_arg, (void**)&handle), "Invalid stream subscription")) return nullptr; + napi_value result; + if (!swift_node_napi_ok(env, napi_get_boolean(env, !handle || handle->state->closed.load(), &result), "Failed to create closed state")) return nullptr; + return result; +} + +static napi_value js_regression_contract_events(napi_env env, napi_callback_info info) { + size_t argc = 4; + napi_value argv[4]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 2)) return nullptr; + + char* topic = nullptr; + auto cleanup_args = [&]() { + delete[] topic; + }; + if (!swift_node_expect_type(env, argv[0], napi_string, "Expected argument 'topic' to be a string")) { cleanup_args(); return nullptr; } + size_t topic_len; + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, argv[0], nullptr, 0, &topic_len), "Failed to read string argument length")) { cleanup_args(); return nullptr; } + topic = new char[topic_len + 1]; + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, argv[0], topic, topic_len + 1, &topic_len), "Failed to read string argument")) { cleanup_args(); return nullptr; } + + if (!swift_node_expect_type(env, argv[1], napi_function, "Expected stream onValue callback to be a function")) { cleanup_args(); return nullptr; } + auto state = std::make_shared(); + state->subscription_id = next_stream_id_regression_contract_events.fetch_add(1); + if (!swift_node_napi_ok(env, napi_create_reference(env, argv[1], 1, &state->on_value), "Failed to retain stream callback")) { cleanup_args(); return nullptr; } + if (argc > 2) { + napi_valuetype on_error_type; + if (!swift_node_napi_ok(env, napi_typeof(env, argv[2], &on_error_type), "Failed to inspect onError callback")) { cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + if (on_error_type != napi_undefined) { + if (!swift_node_expect_type(env, argv[2], napi_function, "Expected stream onError callback to be a function")) { cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + if (!swift_node_napi_ok(env, napi_create_reference(env, argv[2], 1, &state->on_error), "Failed to retain stream onError callback")) { cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + } + } + if (argc > 3) { + napi_valuetype on_complete_type; + if (!swift_node_napi_ok(env, napi_typeof(env, argv[3], &on_complete_type), "Failed to inspect onComplete callback")) { cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + if (on_complete_type != napi_undefined) { + if (!swift_node_expect_type(env, argv[3], napi_function, "Expected stream onComplete callback to be a function")) { cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + if (!swift_node_napi_ok(env, napi_create_reference(env, argv[3], 1, &state->on_complete), "Failed to retain stream onComplete callback")) { cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + } + } + auto* tsfn_context = new std::shared_ptr(state); + napi_value resource_name; + if (!swift_node_napi_ok(env, napi_create_string_utf8(env, "swift_node_stream_regression_contract_events", NAPI_AUTO_LENGTH, &resource_name), "Failed to create stream resource name")) { delete tsfn_context; cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + if (!swift_node_napi_ok(env, napi_create_threadsafe_function(env, argv[1], nullptr, resource_name, 0, 1, tsfn_context, finalize_stream_tsfn_regression_contract_events, tsfn_context, call_js_stream_regression_contract_events, &state->tsfn), "Failed to create stream callback")) { delete tsfn_context; cleanup_stream_refs_regression_contract_events(env, state); cleanup_args(); return nullptr; } + { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.emplace(state->subscription_id, state); } + regression_contract_events(topic, topic_len, state->subscription_id, stream_value_regression_contract_events, stream_complete_regression_contract_events); + cleanup_args(); + napi_value subscription; + if (!swift_node_napi_ok(env, napi_create_object(env, &subscription), "Failed to create stream subscription")) { + { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.erase(state->subscription_id); } + cancel_stream_regression_contract_events(state); + return nullptr; + } + auto* handle = new StreamHandle_regression_contract_events{ state }; + if (!swift_node_napi_ok(env, napi_wrap(env, subscription, handle, finalize_stream_handle_regression_contract_events, nullptr, nullptr), "Failed to wrap stream subscription")) { delete handle; { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.erase(state->subscription_id); } cancel_stream_regression_contract_events(state); return nullptr; } + napi_value cancel; + if (!swift_node_napi_ok(env, napi_create_function(env, "cancel", NAPI_AUTO_LENGTH, js_cancel_stream_regression_contract_events, nullptr, &cancel), "Failed to create stream cancel method") || !swift_node_napi_ok(env, napi_set_named_property(env, subscription, "cancel", cancel), "Failed to set stream cancel method")) { { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.erase(state->subscription_id); } cancel_stream_regression_contract_events(state); return nullptr; } + napi_property_descriptor closed = { "closed", nullptr, nullptr, js_stream_closed_regression_contract_events, nullptr, nullptr, napi_default, nullptr }; + if (!swift_node_napi_ok(env, napi_define_properties(env, subscription, 1, &closed), "Failed to define stream closed property")) { { std::lock_guard lock(streams_mutex_regression_contract_events); streams_regression_contract_events.erase(state->subscription_id); } cancel_stream_regression_contract_events(state); return nullptr; } + napi_value global; napi_value symbol; napi_value dispose; + if (swift_node_napi_ok(env, napi_get_global(env, &global), "Failed to read global object") && swift_node_napi_ok(env, napi_get_named_property(env, global, "Symbol", &symbol), "Failed to read Symbol") && swift_node_napi_ok(env, napi_get_named_property(env, symbol, "dispose", &dispose), "Failed to read Symbol.dispose")) { napi_set_property(env, subscription, dispose, cancel); } + return subscription; +} + +static void cleanup_streams_regression_contract_events(void*) { + std::vector> states; + { std::lock_guard lock(streams_mutex_regression_contract_events); for (auto& entry : streams_regression_contract_events) states.push_back(entry.second); streams_regression_contract_events.clear(); } + for (const auto& state : states) cancel_stream_regression_contract_events(state); +} + +struct CallbackState_regression_contract_notify { napi_env env = nullptr; napi_threadsafe_function tsfn = nullptr; }; +static std::mutex callbacks_mutex_regression_contract_notify; +static std::unordered_set callbacks_regression_contract_notify; +static void finalize_callback_regression_contract_notify(napi_env, void* data, void*) { + auto* state = static_cast(data); + if (!state) return; + { std::lock_guard lock(callbacks_mutex_regression_contract_notify); callbacks_regression_contract_notify.erase(state); } + delete state; +} +static void cleanup_callbacks_regression_contract_notify(void*) { + std::vector states; + { std::lock_guard lock(callbacks_mutex_regression_contract_notify); states.assign(callbacks_regression_contract_notify.begin(), callbacks_regression_contract_notify.end()); } + for (auto* state : states) napi_release_threadsafe_function(state->tsfn, napi_tsfn_abort); +} +static void unref_callback_delivery_regression_contract_notify(napi_env env, CallbackState_regression_contract_notify* state) { + if (env && state) napi_unref_threadsafe_function(env, state->tsfn); +} + +struct TrampolineData_regression_contract_notify { + char* arg0; + size_t arg0_len; + int64_t arg1; + bool arg2; + double arg3; +}; + +static void cleanup_trampoline_data_regression_contract_notify(TrampolineData_regression_contract_notify* packed) { + if (!packed) return; + free(packed->arg0); + delete packed; +} + +static void call_js_regression_contract_notify(napi_env env, napi_value js_callback, void* context, void* data) { + auto* state = static_cast(context); + auto* packed = (TrampolineData_regression_contract_notify*)data; + if (!packed) { unref_callback_delivery_regression_contract_notify(env, state); return; } + if (env == nullptr) { cleanup_trampoline_data_regression_contract_notify(packed); return; } + napi_value global; + if (!swift_node_napi_ok(env, napi_get_global(env, &global), "Failed to read global object")) { cleanup_trampoline_data_regression_contract_notify(packed); unref_callback_delivery_regression_contract_notify(env, state); return; } + napi_value argv[4]; + if (!swift_node_napi_ok(env, swift_node_create_string(env, packed->arg0, packed->arg0_len, &argv[0]), "Failed to create callback argument")) { cleanup_trampoline_data_regression_contract_notify(packed); unref_callback_delivery_regression_contract_notify(env, state); return; } + if (!swift_node_napi_ok(env, napi_create_int64(env, packed->arg1, &argv[1]), "Failed to create callback argument")) { cleanup_trampoline_data_regression_contract_notify(packed); unref_callback_delivery_regression_contract_notify(env, state); return; } + if (!swift_node_napi_ok(env, napi_get_boolean(env, packed->arg2, &argv[2]), "Failed to create callback argument")) { cleanup_trampoline_data_regression_contract_notify(packed); unref_callback_delivery_regression_contract_notify(env, state); return; } + if (!swift_node_napi_ok(env, napi_create_double(env, packed->arg3, &argv[3]), "Failed to create callback argument")) { cleanup_trampoline_data_regression_contract_notify(packed); unref_callback_delivery_regression_contract_notify(env, state); return; } + swift_node_call_function_without_propagating_exception(env, global, js_callback, 4, argv); + cleanup_trampoline_data_regression_contract_notify(packed); + unref_callback_delivery_regression_contract_notify(env, state); +} + +static void trampoline_regression_contract_notify(void* callback_context, const char* arg0, int64_t arg0_len, int64_t arg1, bool arg2, double arg3) { + auto* state = static_cast(callback_context); + if (!state || napi_acquire_threadsafe_function(state->tsfn) != napi_ok) return; + if (napi_ref_threadsafe_function(state->env, state->tsfn) != napi_ok) { napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); return; } + auto* packed = new TrampolineData_regression_contract_notify(); + packed->arg0_len = static_cast(arg0_len); + packed->arg0 = arg0 ? (char*)malloc(packed->arg0_len + 1) : nullptr; + if (arg0 && !packed->arg0) { cleanup_trampoline_data_regression_contract_notify(packed); napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); return; } + if (arg0) { memcpy(packed->arg0, arg0, packed->arg0_len); packed->arg0[packed->arg0_len] = '\\0'; } + packed->arg1 = arg1; + packed->arg2 = arg2; + packed->arg3 = arg3; + napi_status call_status = napi_call_threadsafe_function(state->tsfn, packed, napi_tsfn_nonblocking); + if (call_status != napi_ok) { cleanup_trampoline_data_regression_contract_notify(packed); unref_callback_delivery_regression_contract_notify(state->env, state); } + napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); +} + +struct CallbackState_regression_contract_installPromiseCallback { napi_env env = nullptr; napi_threadsafe_function tsfn = nullptr; std::atomic released{false}; }; +static std::mutex callbacks_mutex_regression_contract_installPromiseCallback; +static std::unordered_set callbacks_regression_contract_installPromiseCallback; +static void finalize_callback_regression_contract_installPromiseCallback(napi_env, void* data, void*) { + auto* state = static_cast(data); + if (!state) return; + { std::lock_guard lock(callbacks_mutex_regression_contract_installPromiseCallback); callbacks_regression_contract_installPromiseCallback.erase(state); } + delete state; +} +static void cleanup_callbacks_regression_contract_installPromiseCallback(void*) { + std::vector states; + { std::lock_guard lock(callbacks_mutex_regression_contract_installPromiseCallback); states.assign(callbacks_regression_contract_installPromiseCallback.begin(), callbacks_regression_contract_installPromiseCallback.end()); } + for (auto* state : states) { if (!state->released.exchange(true)) napi_release_threadsafe_function(state->tsfn, napi_tsfn_abort); } +} +static void release_callback_regression_contract_installPromiseCallback(void* callback_context) { + auto* state = static_cast(callback_context); + if (!state || state->released.exchange(true)) return; + napi_release_threadsafe_function(state->tsfn, napi_tsfn_abort); +} +struct PromiseCallbackData_regression_contract_installPromiseCallback { + char* arg0 = nullptr; + size_t arg0_len = 0; + void (*complete)(void*, const char*, int64_t, const char*, int64_t) = nullptr; + void* completion_context = nullptr; +}; +struct PromiseResolution_regression_contract_installPromiseCallback { + void (*complete)(void*, const char*, int64_t, const char*, int64_t) = nullptr; + void* completion_context = nullptr; + std::atomic settled{false}; +}; +static void cleanup_promise_callback_data_regression_contract_installPromiseCallback(PromiseCallbackData_regression_contract_installPromiseCallback* data) { + if (!data) return; + free(data->arg0); + delete data; +} +static void settle_promise_callback_regression_contract_installPromiseCallback(PromiseResolution_regression_contract_installPromiseCallback* resolution, const char* value, size_t value_len, const char* error, size_t error_len) { + if (!resolution || resolution->settled.exchange(true)) return; + resolution->complete(resolution->completion_context, value, static_cast(value_len), error, static_cast(error_len)); + delete resolution; +} +static napi_value resolve_promise_callback_regression_contract_installPromiseCallback(napi_env env, napi_callback_info info) { + size_t argc = 1; napi_value argv[1]; void* raw = nullptr; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, &raw) != napi_ok) return nullptr; + auto* resolution = static_cast(raw); + if (!resolution) return nullptr; + if (argc != 1) { const char* error = "JavaScript callback resolved without a value"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return nullptr; } + napi_valuetype type; + if (napi_typeof(env, argv[0], &type) != napi_ok || type != napi_string) { const char* error = "JavaScript callback must resolve to a string"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return nullptr; } + size_t length = 0; + if (napi_get_value_string_utf8(env, argv[0], nullptr, 0, &length) != napi_ok) { const char* error = "Could not read JavaScript callback result"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return nullptr; } + std::string value(length, '\\0'); + if (length > 0 && napi_get_value_string_utf8(env, argv[0], value.data(), length + 1, &length) != napi_ok) { const char* error = "Could not read JavaScript callback result"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return nullptr; } + settle_promise_callback_regression_contract_installPromiseCallback(resolution, value.data(), length, nullptr, 0); return nullptr; +} +static napi_value reject_promise_callback_regression_contract_installPromiseCallback(napi_env env, napi_callback_info info) { + size_t argc = 1; napi_value argv[1]; void* raw = nullptr; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, &raw) != napi_ok) return nullptr; + auto* resolution = static_cast(raw); + if (!resolution) return nullptr; + const char* fallback = "JavaScript callback rejected"; + if (argc != 1) { settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, fallback, strlen(fallback)); return nullptr; } + napi_value text; + if (napi_coerce_to_string(env, argv[0], &text) != napi_ok) { settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, fallback, strlen(fallback)); return nullptr; } + size_t length = 0; + if (napi_get_value_string_utf8(env, text, nullptr, 0, &length) != napi_ok) { settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, fallback, strlen(fallback)); return nullptr; } + std::string error(length, '\\0'); + if (length > 0 && napi_get_value_string_utf8(env, text, error.data(), length + 1, &length) != napi_ok) { settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, fallback, strlen(fallback)); return nullptr; } + settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error.data(), length); return nullptr; +} +static void call_js_regression_contract_installPromiseCallback(napi_env env, napi_value js_callback, void*, void* raw) { + auto* data = static_cast(raw); + if (!data) return; + auto* resolution = new PromiseResolution_regression_contract_installPromiseCallback(); + resolution->complete = data->complete; resolution->completion_context = data->completion_context; + if (!env) { const char* error = "JavaScript environment was released"; cleanup_promise_callback_data_regression_contract_installPromiseCallback(data); settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } + napi_value global; + napi_value argv[1]; + if (napi_get_global(env, &global) != napi_ok) { const char* error = "Could not read JavaScript global object"; cleanup_promise_callback_data_regression_contract_installPromiseCallback(data); settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } + if (!swift_node_napi_ok(env, swift_node_create_string(env, data->arg0, data->arg0_len, &argv[0]), "Failed to create Promise callback argument")) { const char* error = "Could not create JavaScript callback argument"; cleanup_promise_callback_data_regression_contract_installPromiseCallback(data); settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } + napi_value result; + napi_status call_status = napi_call_function(env, global, js_callback, 1, argv, &result); + cleanup_promise_callback_data_regression_contract_installPromiseCallback(data); + if (call_status != napi_ok) { napi_value ignored; napi_get_and_clear_last_exception(env, &ignored); const char* error = "JavaScript callback threw"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } + napi_value promise_constructor; napi_value resolve; napi_value promise; + if (napi_get_named_property(env, global, "Promise", &promise_constructor) != napi_ok || napi_get_named_property(env, promise_constructor, "resolve", &resolve) != napi_ok || napi_call_function(env, promise_constructor, resolve, 1, &result, &promise) != napi_ok) { const char* error = "Could not await JavaScript callback"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } + napi_value then; napi_value fulfilled; napi_value rejected; napi_value handlers[2]; + if (napi_get_named_property(env, promise, "then", &then) != napi_ok || napi_create_function(env, "swift_node_promise_fulfilled", NAPI_AUTO_LENGTH, resolve_promise_callback_regression_contract_installPromiseCallback, resolution, &fulfilled) != napi_ok || napi_create_function(env, "swift_node_promise_rejected", NAPI_AUTO_LENGTH, reject_promise_callback_regression_contract_installPromiseCallback, resolution, &rejected) != napi_ok) { const char* error = "Could not attach JavaScript Promise handlers"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } + handlers[0] = fulfilled; handlers[1] = rejected; + if (napi_call_function(env, promise, then, 2, handlers, nullptr) != napi_ok) { napi_value ignored; napi_get_and_clear_last_exception(env, &ignored); const char* error = "Could not await JavaScript callback"; settle_promise_callback_regression_contract_installPromiseCallback(resolution, nullptr, 0, error, strlen(error)); return; } +} +static void trampoline_regression_contract_installPromiseCallback(void* callback_context, const char* arg0, int64_t arg0_len, void (*complete)(void*, const char*, int64_t, const char*, int64_t), void* completion_context) { + auto* state = static_cast(callback_context); + if (!state || state->released.load() || napi_acquire_threadsafe_function(state->tsfn) != napi_ok) { const char* error = "JavaScript callback is unavailable"; complete(completion_context, nullptr, 0, error, strlen(error)); return; } + auto* data = new PromiseCallbackData_regression_contract_installPromiseCallback(); data->complete = complete; data->completion_context = completion_context; + data->arg0_len = static_cast(arg0_len); data->arg0 = arg0 ? static_cast(malloc(data->arg0_len + 1)) : nullptr; + if (arg0 && !data->arg0) { const char* error = "Out of memory"; cleanup_promise_callback_data_regression_contract_installPromiseCallback(data); complete(completion_context, nullptr, 0, error, strlen(error)); napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); return; } + if (arg0) { memcpy(data->arg0, arg0, data->arg0_len); data->arg0[data->arg0_len] = '\\0'; } + napi_status status = napi_call_threadsafe_function(state->tsfn, data, napi_tsfn_nonblocking); + if (status != napi_ok) { const char* error = "Could not schedule JavaScript callback"; cleanup_promise_callback_data_regression_contract_installPromiseCallback(data); complete(completion_context, nullptr, 0, error, strlen(error)); } + napi_release_threadsafe_function(state->tsfn, napi_tsfn_release); +} + +static napi_value js_regression_contract_renameProfile(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 1)) return nullptr; + + swift_node_Profile profile{}; + bool profile_ok = false; + auto swift_node_cleanup_args = [&]() { + if (profile_ok) Profile_free_strings(profile); + }; + profile = Profile_from_js(env, argv[0], &profile_ok); + if (!profile_ok) { swift_node_cleanup_args(); return nullptr; } + + swift_node_Profile result = regression_contract_renameProfile(profile); + swift_node_cleanup_args(); + + napi_value js_result = Profile_to_js(env, result); + Profile_free_strings(result); + return js_result; +} + +static napi_value js_regression_contract_respond(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 1)) return nullptr; + + char* request = nullptr; + auto swift_node_cleanup_args = [&]() { + delete[] request; + }; + napi_value request_json; + if (!swift_node_json_stringify(env, argv[0], &request_json)) { swift_node_cleanup_args(); return nullptr; } + size_t request_len; + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, request_json, nullptr, 0, &request_len), "Failed to read JSON argument length")) { swift_node_cleanup_args(); return nullptr; } + request = new char[request_len + 1]; + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, request_json, request, request_len + 1, &request_len), "Failed to read JSON argument")) { swift_node_cleanup_args(); return nullptr; } + + const char* swift_error = nullptr; + char* result = regression_contract_respond(request, &swift_error); + swift_node_cleanup_args(); + + if (swift_error) { + free(const_cast(result)); + return throw_swift_error(env, swift_error); + } + + AutoFreeStr guard(result); + napi_value js_result; + if (!swift_node_json_parse(env, result, &js_result)) return nullptr; + return js_result; +} + +static napi_value js_regression_contract_checksum(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 1)) return nullptr; + + void* bytes = nullptr; + size_t bytes_len = 0; + if (!swift_node_is_buffer_or_typedarray(env, argv[0], "Expected argument 'bytes' to be a Uint8Array or Buffer")) return nullptr; + if (!swift_node_get_borrowed_binary_data(env, argv[0], &bytes, &bytes_len)) return nullptr; + if (bytes_len > static_cast(std::numeric_limits::max())) { napi_throw_range_error(env, nullptr, "Borrowed buffer is too large"); return nullptr; } + + const char* swift_error = nullptr; + int64_t result = regression_contract_checksum(bytes, static_cast(bytes_len), &swift_error); + + if (swift_error) { + return throw_swift_error(env, swift_error); + } + + napi_value js_result; + if (!swift_node_napi_ok(env, napi_create_int64(env, result, &js_result), "Failed to create integer return value")) return nullptr; + return js_result; +} + +struct AsyncData_regression_contract_reverseData { + napi_async_work work; + napi_deferred deferred; + const char* swift_error; + char* input; + size_t input_len; + char* result; +}; + +static void execute_regression_contract_reverseData(napi_env env, void* data) { + auto* ctx = (AsyncData_regression_contract_reverseData*)data; + ctx->result = regression_contract_reverseData(ctx->input, &ctx->swift_error); +} + +static void complete_regression_contract_reverseData(napi_env env, napi_status status, void* data) { + auto* ctx = (AsyncData_regression_contract_reverseData*)data; + free(ctx->input); + if (ctx->swift_error) { + swift_node_reject_swift_error(env, ctx->deferred, ctx->swift_error); + free(const_cast(ctx->result)); + napi_delete_async_work(env, ctx->work); + delete ctx; + return; + } + napi_value js_result; + bool result_ok; + std::vector swift_node_result_bytes; + result_ok = swift_node_base64_decode(ctx->result, &swift_node_result_bytes) && swift_node_napi_ok(env, napi_create_buffer_copy(env, swift_node_result_bytes.size(), swift_node_result_bytes.data(), nullptr, &js_result), "Failed to create Data return buffer"); + free(const_cast(ctx->result)); + if (result_ok) { + napi_resolve_deferred(env, ctx->deferred, js_result); + } else { + napi_value err; + if (napi_get_and_clear_last_exception(env, &err) == napi_ok) { + napi_reject_deferred(env, ctx->deferred, err); + } + } + napi_delete_async_work(env, ctx->work); + delete ctx; +} + +static void destroy_async_regression_contract_reverseData(AsyncData_regression_contract_reverseData* ctx) { + free(ctx->input); + delete ctx; +} + +static napi_value reject_async_regression_contract_reverseData(napi_env env, AsyncData_regression_contract_reverseData* ctx, napi_value promise) { + napi_value err; + if (napi_get_and_clear_last_exception(env, &err) == napi_ok) { + napi_reject_deferred(env, ctx->deferred, err); + } + destroy_async_regression_contract_reverseData(ctx); + return promise; +} + +static napi_value js_regression_contract_reverseData(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 1)) return nullptr; + + auto* ctx = new AsyncData_regression_contract_reverseData{}; + ctx->swift_error = nullptr; + if (!swift_node_is_buffer_or_typedarray(env, argv[0], "Expected argument 'input' to be a Uint8Array or Buffer")) { destroy_async_regression_contract_reverseData(ctx); return nullptr; } + void* input_data; size_t input_len; + if (!swift_node_get_binary_data(env, argv[0], &input_data, &input_len)) { destroy_async_regression_contract_reverseData(ctx); return nullptr; } + std::string input_base64 = swift_node_base64_encode((const uint8_t*)input_data, input_len); + ctx->input = (char*)malloc(input_base64.size() + 1); + if (!ctx->input) { destroy_async_regression_contract_reverseData(ctx); return swift_node_throw_type_error(env, "Out of memory"); } + memcpy(ctx->input, input_base64.c_str(), input_base64.size() + 1); + + napi_value promise; + napi_deferred deferred; + if (!swift_node_napi_ok(env, napi_create_promise(env, &deferred, &promise), "Failed to create promise")) { destroy_async_regression_contract_reverseData(ctx); return nullptr; } + ctx->deferred = deferred; + + napi_value resource_name; + if (!swift_node_napi_ok(env, napi_create_string_utf8(env, "swift_node_async", NAPI_AUTO_LENGTH, &resource_name), "Failed to create async resource name")) return reject_async_regression_contract_reverseData(env, ctx, promise); + if (!swift_node_napi_ok(env, napi_create_async_work(env, nullptr, resource_name, execute_regression_contract_reverseData, complete_regression_contract_reverseData, ctx, &ctx->work), "Failed to create async work")) return reject_async_regression_contract_reverseData(env, ctx, promise); + if (!swift_node_napi_ok(env, napi_queue_async_work(env, ctx->work), "Failed to queue async work")) { napi_delete_async_work(env, ctx->work); return reject_async_regression_contract_reverseData(env, ctx, promise); } + + return promise; +} + +// Stream wrapper for regression_contract_events was generated above. + +static napi_value js_regression_contract_notify(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value argv[2]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 2)) return nullptr; + + char* value = nullptr; + size_t value_len = 0; + CallbackState_regression_contract_notify* callback_state = nullptr; + bool tsfn_created = false; + auto cleanup_args = [&]() { + delete[] value; + if (tsfn_created) { + napi_release_threadsafe_function(callback_state->tsfn, napi_tsfn_abort); + callback_state = nullptr; + } + }; + + if (!swift_node_expect_type(env, argv[0], napi_string, "Expected argument 'value' to be a string")) { cleanup_args(); return nullptr; } + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, argv[0], nullptr, 0, &value_len), "Failed to read string argument length")) { cleanup_args(); return nullptr; } + value = new char[value_len + 1]; + if (!swift_node_napi_ok(env, napi_get_value_string_utf8(env, argv[0], value, value_len + 1, &value_len), "Failed to read string argument")) { cleanup_args(); return nullptr; } + if (!swift_node_expect_type(env, argv[1], napi_function, "Expected argument 'callback' to be a function")) { cleanup_args(); return nullptr; } + napi_value resource_name; + if (!swift_node_napi_ok(env, napi_create_string_utf8(env, "swift_node_cb_regression_contract_notify", NAPI_AUTO_LENGTH, &resource_name), "Failed to create callback resource name")) { cleanup_args(); return nullptr; } + callback_state = new CallbackState_regression_contract_notify(); + callback_state->env = env; + if (!swift_node_napi_ok(env, napi_create_threadsafe_function(env, argv[1], nullptr, resource_name, 0, 1, callback_state, finalize_callback_regression_contract_notify, callback_state, call_js_regression_contract_notify, &callback_state->tsfn), "Failed to create threadsafe callback")) { delete callback_state; callback_state = nullptr; cleanup_args(); return nullptr; } + tsfn_created = true; + if (!swift_node_napi_ok(env, napi_unref_threadsafe_function(env, callback_state->tsfn), "Failed to unref threadsafe callback")) { cleanup_args(); return nullptr; } + + { std::lock_guard lock(callbacks_mutex_regression_contract_notify); callbacks_regression_contract_notify.insert(callback_state); } + tsfn_created = false; + + regression_contract_notify(value, value_len, trampoline_regression_contract_notify, callback_state); + delete[] value; + + napi_value js_undefined; + if (!swift_node_napi_ok(env, napi_get_undefined(env, &js_undefined), "Failed to create undefined return value")) return nullptr; + return js_undefined; +} + +static napi_value js_regression_contract_installPromiseCallback(napi_env env, napi_callback_info info) { + size_t argc = 1; + napi_value argv[1]; + if (!swift_node_napi_ok(env, napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr), "Failed to read callback info")) return nullptr; + if (!swift_node_expect_argc(env, argc, 1)) return nullptr; + + CallbackState_regression_contract_installPromiseCallback* callback_state = nullptr; + bool tsfn_created = false; + auto cleanup_args = [&]() { + if (tsfn_created) { + napi_release_threadsafe_function(callback_state->tsfn, napi_tsfn_abort); + callback_state = nullptr; + } + }; + + if (!swift_node_expect_type(env, argv[0], napi_function, "Expected argument 'callback' to be a function")) { cleanup_args(); return nullptr; } + napi_value resource_name; + if (!swift_node_napi_ok(env, napi_create_string_utf8(env, "swift_node_cb_regression_contract_installPromiseCallback", NAPI_AUTO_LENGTH, &resource_name), "Failed to create callback resource name")) { cleanup_args(); return nullptr; } + callback_state = new CallbackState_regression_contract_installPromiseCallback(); + callback_state->env = env; + if (!swift_node_napi_ok(env, napi_create_threadsafe_function(env, argv[0], nullptr, resource_name, 0, 1, callback_state, finalize_callback_regression_contract_installPromiseCallback, callback_state, call_js_regression_contract_installPromiseCallback, &callback_state->tsfn), "Failed to create threadsafe callback")) { delete callback_state; callback_state = nullptr; cleanup_args(); return nullptr; } + tsfn_created = true; + if (!swift_node_napi_ok(env, napi_unref_threadsafe_function(env, callback_state->tsfn), "Failed to unref threadsafe callback")) { cleanup_args(); return nullptr; } + + { std::lock_guard lock(callbacks_mutex_regression_contract_installPromiseCallback); callbacks_regression_contract_installPromiseCallback.insert(callback_state); } + tsfn_created = false; + + regression_contract_installPromiseCallback(trampoline_regression_contract_installPromiseCallback, callback_state, release_callback_regression_contract_installPromiseCallback); + + napi_value js_undefined; + if (!swift_node_napi_ok(env, napi_get_undefined(env, &js_undefined), "Failed to create undefined return value")) return nullptr; + return js_undefined; +} + +static napi_value init(napi_env env, napi_value exports) { + napi_value fn; + + napi_create_function(env, "renameProfile", NAPI_AUTO_LENGTH, js_regression_contract_renameProfile, nullptr, &fn); + napi_set_named_property(env, exports, "renameProfile", fn); + + napi_create_function(env, "respond", NAPI_AUTO_LENGTH, js_regression_contract_respond, nullptr, &fn); + napi_set_named_property(env, exports, "respond", fn); + + napi_create_function(env, "checksum", NAPI_AUTO_LENGTH, js_regression_contract_checksum, nullptr, &fn); + napi_set_named_property(env, exports, "checksum", fn); + + napi_create_function(env, "reverseData", NAPI_AUTO_LENGTH, js_regression_contract_reverseData, nullptr, &fn); + napi_set_named_property(env, exports, "reverseData", fn); + + napi_create_function(env, "events", NAPI_AUTO_LENGTH, js_regression_contract_events, nullptr, &fn); + napi_set_named_property(env, exports, "events", fn); + napi_add_env_cleanup_hook(env, cleanup_streams_regression_contract_events, nullptr); + + napi_create_function(env, "notify", NAPI_AUTO_LENGTH, js_regression_contract_notify, nullptr, &fn); + napi_set_named_property(env, exports, "notify", fn); + napi_add_env_cleanup_hook(env, cleanup_callbacks_regression_contract_notify, nullptr); + + napi_create_function(env, "installPromiseCallback", NAPI_AUTO_LENGTH, js_regression_contract_installPromiseCallback, nullptr, &fn); + napi_set_named_property(env, exports, "installPromiseCallback", fn); + napi_add_env_cleanup_hook(env, cleanup_callbacks_regression_contract_installPromiseCallback, nullptr); + + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, init)", + "bridgeHeader": "#ifndef REGRESSION_CONTRACT_BRIDGE_H +#define REGRESSION_CONTRACT_BRIDGE_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + int64_t id; + const char* name; + size_t name_len; +} swift_node_Profile; + +swift_node_Profile regression_contract_renameProfile(swift_node_Profile profile); +char* regression_contract_respond(const char* request, const char** out_error); +int64_t regression_contract_checksum(const void* bytes, int64_t bytesLen, const char** out_error); +char* regression_contract_reverseData(const char* input, const char** out_error); +void regression_contract_events(const char* topic, int64_t topicLen, int64_t subscription_id, void (*on_value)(int64_t, const char*), void (*on_complete)(int64_t, const char*)); +void regression_contract_events_cancel(int64_t subscription_id); +void regression_contract_notify(const char* value, int64_t valueLen, void (*callback)(void*, const char*, int64_t, int64_t, bool, double), void* callbackContext); +void regression_contract_installPromiseCallback(void (*callback)(void*, const char*, int64_t, void (*)(void*, const char*, int64_t, const char*, int64_t), void*), void* callbackContext, void (*callbackRelease)(void*)); + +#ifdef __cplusplus +} +#endif + +#endif", + "cdeclFunctions": "[ + { + "symbolName": "regression_contract_renameProfile", + "params": [ + { + "name": "profile", + "type": "swift_node_Profile" + } + ], + "returnType": "swift_node_Profile", + "isAsync": false, + "nativeReturnType": "Profile" + }, + { + "symbolName": "regression_contract_respond", + "params": [ + { + "name": "request", + "type": "UnsafePointer", + "nativeType": "Request", + "transport": "json" + }, + { + "name": "outError", + "type": "UnsafeMutablePointer?>" + } + ], + "returnType": "UnsafeMutablePointer", + "isAsync": false, + "nativeReturnType": "Response", + "returnTransport": "json" + }, + { + "symbolName": "regression_contract_checksum", + "params": [ + { + "name": "bytes", + "type": "UnsafeRawPointer?", + "nativeType": "UnsafeRawBufferPointer", + "transport": "borrowed" + }, + { + "name": "bytesLen", + "type": "Int", + "bridgeBorrowedBufferLengthFor": "bytes" + }, + { + "name": "outError", + "type": "UnsafeMutablePointer?>" + } + ], + "returnType": "Int", + "isAsync": false, + "nativeReturnType": "Int" + }, + { + "symbolName": "regression_contract_reverseData", + "params": [ + { + "name": "input", + "type": "UnsafePointer", + "nativeType": "Data", + "transport": "data" + }, + { + "name": "outError", + "type": "UnsafeMutablePointer?>" + } + ], + "returnType": "UnsafeMutablePointer", + "isAsync": true, + "nativeReturnType": "Data", + "returnTransport": "data" + }, + { + "symbolName": "regression_contract_events", + "params": [ + { + "name": "topic", + "type": "UnsafePointer" + }, + { + "name": "topicLen", + "type": "Int", + "bridgeStringLengthFor": "topic" + } + ], + "returnType": "Void", + "isAsync": false, + "nativeReturnType": "Void", + "stream": { + "elementType": "Event", + "isThrowing": true, + "transport": "json" + } + }, + { + "symbolName": "regression_contract_notify", + "params": [ + { + "name": "value", + "type": "UnsafePointer" + }, + { + "name": "valueLen", + "type": "Int", + "bridgeStringLengthFor": "value" + }, + { + "name": "callback", + "type": "@escaping @convention(c) (UnsafeMutableRawPointer?, UnsafePointer, Int, Int, Bool, Double) -> Void", + "nativeType": "@escaping (String, Int, Bool, Double) -> Void" + }, + { + "name": "callbackContext", + "type": "UnsafeMutableRawPointer?", + "callbackContext": true + } + ], + "returnType": "Void", + "isAsync": false, + "nativeReturnType": "Void" + }, + { + "symbolName": "regression_contract_installPromiseCallback", + "params": [ + { + "name": "callback", + "type": "@escaping @convention(c) (UnsafeMutableRawPointer?, UnsafePointer, Int, @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int, UnsafePointer?, Int) -> Void, UnsafeMutableRawPointer?) -> Void", + "nativeType": "@escaping (String) async throws -> String", + "promiseCallback": { + "params": [ + { + "type": "unknown", + "swiftType": "String" + } + ], + "returnType": "String" + } + }, + { + "name": "callbackContext", + "type": "UnsafeMutableRawPointer?", + "callbackContext": true + }, + { + "name": "callbackRelease", + "type": "@escaping @convention(c) (UnsafeMutableRawPointer?) -> Void", + "promiseCallbackRelease": true + } + ], + "returnType": "Void", + "isAsync": false, + "nativeReturnType": "Void" + } +]", + "dts": "// Generated by swift-node — do not edit + +export type SwiftNodeJSONValue = + | null + | boolean + | number + | string + | readonly SwiftNodeJSONValue[] + | { readonly [key: string]: SwiftNodeJSONValue } + +export interface SwiftNodeStructuredError extends Error { + readonly code: string + readonly details: { readonly [key: string]: SwiftNodeJSONValue } +} + +declare global { + interface SymbolConstructor { + readonly dispose: unique symbol + } +} + +export interface SwiftNodeSubscription { + readonly closed: boolean + cancel(): void + [Symbol.dispose](): void +} + +export interface Profile { + id: number + name: string +} + +declare const __swift_node_0: (profile: Profile) => Profile +export { __swift_node_0 as renameProfile } +declare const __swift_node_1: (request: unknown) => unknown +export { __swift_node_1 as respond } +declare const __swift_node_2: (bytes: Uint8Array) => number +export { __swift_node_2 as checksum } +declare const __swift_node_3: (input: Uint8Array) => Promise +export { __swift_node_3 as reverseData } +declare const __swift_node_4: (topic: string, onValue: (value: unknown) => void, onError?: (error: Error) => void, onComplete?: () => void) => SwiftNodeSubscription +export { __swift_node_4 as events } +declare const __swift_node_5: (value: string, callback: (arg0: any, arg1: number, arg2: boolean, arg3: number) => void) => void +export { __swift_node_5 as notify } +declare const __swift_node_6: (callback: (arg0: any) => string | Promise) => void +export { __swift_node_6 as installPromiseCallback }", + "dtsCjs": "// Generated by swift-node — do not edit + +declare global { + interface SymbolConstructor { + readonly dispose: unique symbol + } +} + +declare namespace native { + type SwiftNodeJSONValue = + | null + | boolean + | number + | string + | readonly SwiftNodeJSONValue[] + | { readonly [key: string]: SwiftNodeJSONValue } + + interface SwiftNodeStructuredError extends Error { + readonly code: string + readonly details: { readonly [key: string]: SwiftNodeJSONValue } + } + + interface SwiftNodeSubscription { + readonly closed: boolean + cancel(): void + [Symbol.dispose](): void + } + + interface Profile { + id: number + name: string + } + + interface NativeBindings { + renameProfile(profile: Profile): Profile + respond(request: unknown): unknown + checksum(bytes: Uint8Array): number + reverseData(input: Uint8Array): Promise + events(topic: string, onValue: (value: unknown) => void, onError?: (error: Error) => void, onComplete?: () => void): SwiftNodeSubscription + notify(value: string, callback: (arg0: any, arg1: number, arg2: boolean, arg3: number) => void): void + installPromiseCallback(callback: (arg0: any) => string | Promise): void + } +} + +declare const native: native.NativeBindings +export = native", + "entryCjs": "// Generated by swift-node — do not edit +const path = require('node:path') +const { existsSync } = require('node:fs') + +function resolveAddonPath(dir, moduleName) { + const isMusl = process.platform === 'linux' && !process.report?.getReport?.().header?.glibcVersionRuntime + const target = process.platform + '-' + process.arch + (isMusl ? '-musl' : '') + const binaryName = moduleName + '.' + target + '.node' + const binaryPath = path.join(dir, ...(process.platform === 'darwin' ? [] : [target]), binaryName) + if (existsSync(binaryPath)) return binaryPath + + throw new Error( + 'No .node binary found for ' + target + '.\\n' + + 'Checked:\\n' + + ' ' + binaryPath + '\\n' + + "Run 'swift-node build' for this platform and architecture." + ) +} + +const native = require(resolveAddonPath(__dirname, "regression_contract")) + +module.exports = { + "renameProfile": native["renameProfile"], + "respond": native["respond"], + "checksum": native["checksum"], + "reverseData": native["reverseData"], + "events": native["events"], + "notify": native["notify"], + "installPromiseCallback": native["installPromiseCallback"], +}", + "entryMjs": "// Generated by swift-node — do not edit + +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const require = createRequire(import.meta.url) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const { existsSync } = require('node:fs') + +function resolveAddonPath(dir, moduleName) { + const isMusl = process.platform === 'linux' && !process.report?.getReport?.().header?.glibcVersionRuntime + const target = process.platform + '-' + process.arch + (isMusl ? '-musl' : '') + const binaryName = moduleName + '.' + target + '.node' + const binaryPath = path.join(dir, ...(process.platform === 'darwin' ? [] : [target]), binaryName) + if (existsSync(binaryPath)) return binaryPath + + throw new Error( + 'No .node binary found for ' + target + '.\\n' + + 'Checked:\\n' + + ' ' + binaryPath + '\\n' + + "Run 'swift-node build' for this platform and architecture." + ) +} + +const native = require(resolveAddonPath(__dirname, "regression_contract")) + +const __swift_node_0 = native["renameProfile"] +const __swift_node_1 = native["respond"] +const __swift_node_2 = native["checksum"] +const __swift_node_3 = native["reverseData"] +const __swift_node_4 = native["events"] +const __swift_node_5 = native["notify"] +const __swift_node_6 = native["installPromiseCallback"] + +export { __swift_node_0 as renameProfile } +export { __swift_node_1 as respond } +export { __swift_node_2 as checksum } +export { __swift_node_3 as reverseData } +export { __swift_node_4 as events } +export { __swift_node_5 as notify } +export { __swift_node_6 as installPromiseCallback }", + "sourceEntryTs": "export * from '../dist_swift-node/index.mjs' +", + "structsHeader": "// Generated by swift-node — do not edit +#ifndef SWIFT_NODE_STRUCTS_H +#define SWIFT_NODE_STRUCTS_H + +#include +#include +#include + +typedef struct { + int64_t id; + const char* name; + size_t name_len; +} swift_node_Profile; + +#endif", + "swiftWrappers": "// Generated by swift-node — do not edit +// Source annotation: // @swift-node:export + +import Foundation + +public indirect enum SwiftNodeJSONValue: Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([SwiftNodeJSONValue]) + case object([String: SwiftNodeJSONValue]) +} + +extension SwiftNodeJSONValue: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .number(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } +} + +public protocol SwiftNodeStructuredError: Error { + var code: String { get } + var message: String { get } + var details: [String: SwiftNodeJSONValue] { get } +} + +public extension SwiftNodeStructuredError { + var message: String { localizedDescription } + var details: [String: SwiftNodeJSONValue] { [:] } +} + +private struct SwiftNodeErrorEnvelope: Encodable { + let message: String + let code: String? + let details: [String: SwiftNodeJSONValue]? +} + + +private func swiftNodeCopyUTF8(_ value: String) -> UnsafeMutablePointer? { + let bytes = Array(value.utf8) + guard let destination = malloc(bytes.count + 1)?.assumingMemoryBound(to: CChar.self) else { return nil } + bytes.withUnsafeBytes { source in + if !bytes.isEmpty { memcpy(destination, source.baseAddress!, bytes.count) } + } + destination[bytes.count] = 0 + return destination +} + +private func swiftNodeEncodeError(_ envelope: SwiftNodeErrorEnvelope) -> UnsafeMutablePointer { + let fallback = #"{"message":"swift-node failed to encode an error"}"# + let encoded = (try? JSONEncoder().encode(envelope)).flatMap { String(data: $0, encoding: .utf8) } ?? fallback + return swiftNodeCopyUTF8(encoded)! +} + +private func swiftNodeBridgeError(_ error: any Error) -> UnsafeMutablePointer { + if let structured = error as? any SwiftNodeStructuredError { + return swiftNodeEncodeError( + SwiftNodeErrorEnvelope( + message: structured.message, + code: structured.code, + details: structured.details + ) + ) + } + return swiftNodeEncodeError( + SwiftNodeErrorEnvelope(message: error.localizedDescription, code: nil, details: nil) + ) +} + +private func swiftNodeBridgeError(_ message: String) -> UnsafeMutablePointer { + swiftNodeEncodeError(SwiftNodeErrorEnvelope(message: message, code: nil, details: nil)) +} + +private func swiftNodeDecodeUTF8(_ value: UnsafePointer, _ length: Int) -> String { + String(decoding: UnsafeRawBufferPointer(start: value, count: length).bindMemory(to: UInt8.self), as: UTF8.self) +} + +private final class SwiftNodeStreamTask: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + private var cancelled = false + + func install(_ task: Task) { + lock.lock() + self.task = task + let shouldCancel = cancelled + lock.unlock() + if shouldCancel { task.cancel() } + } + + func cancel() { + lock.lock() + cancelled = true + let task = task + lock.unlock() + task?.cancel() + } +} + +private enum SwiftNodeStreamRegistry { + private static let lock = NSLock() + nonisolated(unsafe) private static var entries: [Int64: SwiftNodeStreamTask] = [:] + + static func reserve(_ id: Int64) -> SwiftNodeStreamTask { + let entry = SwiftNodeStreamTask() + lock.lock() + entries[id] = entry + lock.unlock() + return entry + } + + static func finish(_ id: Int64) { + lock.lock() + entries.removeValue(forKey: id) + lock.unlock() + } + + static func cancel(_ id: Int64) { + lock.lock() + let entry = entries.removeValue(forKey: id) + lock.unlock() + entry?.cancel() + } +} + +private func swiftNodeStreamComplete( + _ subscriptionID: Int64, + _ callback: @convention(c) (Int64, UnsafePointer?) -> Void, + _ error: Error? = nil +) { + guard let error else { + callback(subscriptionID, nil) + return + } + let encoded = swiftNodeBridgeError(error) + defer { free(encoded) } + callback(subscriptionID, UnsafePointer(encoded)) +} + +public typealias SwiftNodePromiseCompletion_installPromiseCallback_callback = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int, UnsafePointer?, Int) -> Void +public typealias SwiftNodePromiseInvoke_installPromiseCallback_callback = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer, Int, SwiftNodePromiseCompletion_installPromiseCallback_callback, UnsafeMutableRawPointer?) -> Void +public typealias SwiftNodePromiseRelease_installPromiseCallback_callback = @convention(c) (UnsafeMutableRawPointer?) -> Void +private final class SwiftNodePromiseContinuation_installPromiseCallback_callback: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + init(_ continuation: CheckedContinuation) { self.continuation = continuation } + func resume(_ value: UnsafePointer?, _ valueLength: Int, _ error: UnsafePointer?, _ errorLength: Int) { + lock.lock() + let continuation = self.continuation + self.continuation = nil + lock.unlock() + guard let continuation else { return } + if let error { continuation.resume(throwing: NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: swiftNodeDecodeUTF8(error, errorLength)])); return } + guard let value else { continuation.resume(throwing: NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "JavaScript callback resolved without a value"])); return } + continuation.resume(returning: swiftNodeDecodeUTF8(value, valueLength)) + } +} +private func swiftNodePromiseComplete_installPromiseCallback_callback(_ context: UnsafeMutableRawPointer?, _ value: UnsafePointer?, _ valueLength: Int, _ error: UnsafePointer?, _ errorLength: Int) { + guard let context else { return } + Unmanaged.fromOpaque(context).takeRetainedValue().resume(value, valueLength, error, errorLength) +} +private final class SwiftNodePromiseHandler_installPromiseCallback_callback: @unchecked Sendable { + let invoke: SwiftNodePromiseInvoke_installPromiseCallback_callback + let context: UnsafeMutableRawPointer? + let release: SwiftNodePromiseRelease_installPromiseCallback_callback + init(invoke: @escaping SwiftNodePromiseInvoke_installPromiseCallback_callback, context: UnsafeMutableRawPointer?, release: @escaping SwiftNodePromiseRelease_installPromiseCallback_callback) { self.invoke = invoke; self.context = context; self.release = release } + deinit { release(context) } + func call(_ callbackArg0: String) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let pending = Unmanaged.passRetained(SwiftNodePromiseContinuation_installPromiseCallback_callback(continuation)).toOpaque() + callbackArg0.withCString { cArg0 in + invoke(context, cArg0, callbackArg0.utf8.count, swiftNodePromiseComplete_installPromiseCallback_callback, pending) + } + } + } +} + +@_cdecl("regression_contract_renameProfile") +public func _sn_regression_contract_renameProfile(_ profile: swift_node_Profile) -> swift_node_Profile { + let swift_profile = Profile(id: Int(profile.id), name: swiftNodeDecodeUTF8(profile.name, profile.name_len)) + let result = renameProfile(swift_profile) + var cResult = swift_node_Profile() + cResult.id = Int64(result.id) + cResult.name = UnsafePointer(swiftNodeCopyUTF8(result.name)!) + cResult.name_len = result.name.utf8.count + return cResult +} + +@_cdecl("regression_contract_respond") +public func _sn_regression_contract_respond(_ request: UnsafePointer, _ out_error: UnsafeMutablePointer?>) -> UnsafeMutablePointer { + let swift_request: Request + do { + swift_request = try JSONDecoder().decode(Request.self, from: Data(String(cString: request).utf8)) + } catch { + out_error.pointee = swiftNodeBridgeError("swift-node could not encode or decode a bridged value") + return UnsafeMutablePointer(mutating: strdup("")!) + } + do { + let result = try respond(swift_request) + guard let encoded = try? JSONEncoder().encode(result) else { + out_error.pointee = swiftNodeBridgeError("swift-node could not encode or decode a bridged value") + return UnsafeMutablePointer(mutating: strdup("")!) + } + return UnsafeMutablePointer(mutating: strdup(String(decoding: encoded, as: UTF8.self))!) + } catch { + out_error.pointee = swiftNodeBridgeError(error) + return UnsafeMutablePointer(mutating: strdup("")!) + } +} + +@_cdecl("regression_contract_checksum") +public func _sn_regression_contract_checksum(_ bytes: UnsafeRawPointer?, _ bytesLen: Int, _ out_error: UnsafeMutablePointer?>) -> Int { + let swift_bytes = UnsafeRawBufferPointer(start: bytes, count: bytesLen) + let result = checksum(swift_bytes) + return result +} + +@_cdecl("regression_contract_reverseData") +public func _sn_regression_contract_reverseData(_ input: UnsafePointer, _ out_error: UnsafeMutablePointer?>) -> UnsafeMutablePointer { + guard let swift_input = Data(base64Encoded: String(cString: input)) else { + out_error.pointee = swiftNodeBridgeError("swift-node could not encode or decode a bridged value") + return UnsafeMutablePointer(mutating: strdup("")!) + } + let semaphore = DispatchSemaphore(value: 0) + var asyncResult: Data? + var asyncError: Error? + Task { + do { + asyncResult = await reverseData(swift_input) + } catch { + asyncError = error + } + semaphore.signal() + } + semaphore.wait() + if let asyncError { + out_error.pointee = swiftNodeBridgeError(asyncError) + return UnsafeMutablePointer(mutating: strdup("")!) + } + guard let result = asyncResult else { + out_error.pointee = swiftNodeBridgeError("swift-node could not encode or decode a bridged value") + return UnsafeMutablePointer(mutating: strdup("")!) + } + return UnsafeMutablePointer(mutating: strdup(result.base64EncodedString())!) +} + +@_cdecl("regression_contract_events") +public func _sn_regression_contract_events(_ topic: UnsafePointer, _ topicLen: Int, _ subscription_id: Int64, _ on_value: @convention(c) (Int64, UnsafePointer) -> Void, _ on_complete: @convention(c) (Int64, UnsafePointer?) -> Void) { + let swift_topic = swiftNodeDecodeUTF8(topic, topicLen) + let registration = SwiftNodeStreamRegistry.reserve(subscription_id) + let task = Task { + do { + let stream = events(swift_topic) + for try await value in stream { + if Task.isCancelled { break } + guard let encoded = try? JSONEncoder().encode(value) else { + swiftNodeStreamComplete(subscription_id, on_complete, NSError(domain: "swift-node", code: 1, userInfo: [NSLocalizedDescriptionKey: "Could not encode stream value"])) + return + } + String(decoding: encoded, as: UTF8.self).withCString { on_value(subscription_id, $0) } + } + if !Task.isCancelled { swiftNodeStreamComplete(subscription_id, on_complete) } + } catch is CancellationError { + // JS cancellation intentionally has no terminal callback. + } catch { + if !Task.isCancelled { swiftNodeStreamComplete(subscription_id, on_complete, error) } + } + SwiftNodeStreamRegistry.finish(subscription_id) + } + registration.install(task) +} + +@_cdecl("regression_contract_events_cancel") +public func _sn_regression_contract_events_cancel(_ subscription_id: Int64) { + SwiftNodeStreamRegistry.cancel(subscription_id) +} + +@_cdecl("regression_contract_notify") +public func _sn_regression_contract_notify(_ value: UnsafePointer, _ valueLen: Int, _ callback: @convention(c) (UnsafeMutableRawPointer?, UnsafePointer, Int, Int, Bool, Double) -> Void, _ callbackContext: UnsafeMutableRawPointer?) { + let swift_value = swiftNodeDecodeUTF8(value, valueLen) + let swift_callback: (String, Int, Bool, Double) -> Void = { cbArg0, cbArg1, cbArg2, cbArg3 in + cbArg0.withCString { cStr0 in + callback(callbackContext, cStr0, cbArg0.utf8.count, cbArg1, cbArg2, cbArg3) + } + } + notify(swift_value, swift_callback) +} + +@_cdecl("regression_contract_installPromiseCallback") +public func _sn_regression_contract_installPromiseCallback(_ callback: SwiftNodePromiseInvoke_installPromiseCallback_callback, _ callbackContext: UnsafeMutableRawPointer?, _ callbackRelease: SwiftNodePromiseRelease_installPromiseCallback_callback) { + let handler_installPromiseCallback_callback = SwiftNodePromiseHandler_installPromiseCallback_callback(invoke: callback, context: callbackContext, release: callbackRelease) + let swift_callback: (String) async throws -> String = { callbackArg0 in try await handler_installPromiseCallback_callback.call(callbackArg0) } + installPromiseCallback(swift_callback) +} +", +} +`; diff --git a/packages/swift-node/test/generator.test.ts b/packages/swift-node/test/generator.test.ts index 84c44ae..d8d915c 100644 --- a/packages/swift-node/test/generator.test.ts +++ b/packages/swift-node/test/generator.test.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module' import { runInNewContext } from 'node:vm' import { describe, it, expect } from 'vite-plus/test' import { + cppIdentifier, generateBridgeH, generateAddonCpp, generateDts, @@ -15,7 +16,7 @@ import { generateEntryMjs, generateEntryCjs, generateSourceEntryTs, -} from '../src/generator' +} from '../src/generator/index' import { nativeTargetId } from '../src/prebuild' import type { SwiftFunction, SwiftStruct, ExportedFunction } from '../src/parser' @@ -1951,3 +1952,115 @@ describe('generated local entry point', () => { expect(generateSourceEntryTs()).toBe("export * from '../dist_swift-node/index.mjs'\n") }) }) + +describe('generator facade regression contract', () => { + it('keeps every public generator output byte-for-byte stable for a mixed bridge surface', () => { + const exported: ExportedFunction[] = [ + { + name: 'renameProfile', + params: [{ label: '_', name: 'profile', type: 'Profile' }], + returnType: 'Profile', + throws: false, + isAsync: false, + line: 1, + }, + { + name: 'respond', + params: [{ label: '_', name: 'request', type: 'Request' }], + returnType: 'Response', + throws: true, + isAsync: false, + line: 2, + }, + { + name: 'checksum', + params: [{ label: '_', name: 'bytes', type: 'UnsafeRawBufferPointer' }], + returnType: 'Int', + throws: false, + isAsync: false, + line: 3, + }, + { + name: 'reverseData', + params: [{ label: '_', name: 'input', type: 'Data' }], + returnType: 'Data', + throws: false, + isAsync: true, + line: 4, + }, + { + name: 'events', + params: [{ label: '_', name: 'topic', type: 'String' }], + returnType: 'AsyncThrowingStream', + throws: false, + isAsync: false, + isStream: true, + line: 5, + }, + { + name: 'notify', + params: [ + { label: '_', name: 'value', type: 'String' }, + { + label: '_', + name: 'callback', + type: '@escaping (String, Int, Bool, Double) -> Void', + }, + ], + returnType: 'Void', + throws: false, + isAsync: false, + line: 6, + }, + { + name: 'installPromiseCallback', + params: [ + { + label: '_', + name: 'callback', + type: '@escaping (String) async throws -> String', + }, + ], + returnType: 'Void', + throws: false, + isAsync: false, + line: 7, + }, + ] + const structs = [profileStruct] + const codableTypes = ['Request', 'Response', 'Event'] + const functions = exportedToSwiftFunctions( + exported, + 'regression_contract', + structs, + codableTypes, + ) + + expect({ + cdeclFunctions: JSON.stringify(functions, null, 2), + structsHeader: generateStructsHeader(structs), + bridgeHeader: generateBridgeH(functions, 'regression_contract', structs), + swiftWrappers: generateWrappersSwift(exported, 'regression_contract', structs, codableTypes), + addonCpp: generateAddonCpp(functions, 'regression_contract', structs), + dts: generateDts(functions, 'regression_contract', structs), + dtsCjs: generateDtsCjs(functions, 'regression_contract', structs), + entryMjs: generateEntryMjs(functions, 'regression_contract'), + entryCjs: generateEntryCjs(functions, 'regression_contract'), + sourceEntryTs: generateSourceEntryTs(), + }).toMatchSnapshot() + }) + + it('keeps the complete public facade available at the established import path', () => { + expect(cppIdentifier('class')).toBe('_swift_node_class') + expect(typeof generateBridgeH).toBe('function') + expect(typeof generateAddonCpp).toBe('function') + expect(typeof generateDts).toBe('function') + expect(typeof generateDtsCjs).toBe('function') + expect(typeof generateStructsHeader).toBe('function') + expect(typeof generateWrappersSwift).toBe('function') + expect(typeof exportedToSwiftFunctions).toBe('function') + expect(typeof generateEntryMjs).toBe('function') + expect(typeof generateEntryCjs).toBe('function') + expect(typeof generateSourceEntryTs).toBe('function') + }) +}) diff --git a/scripts/check-package-versions.mjs b/scripts/check-package-versions.mjs index 7cfc797..1ba454b 100644 --- a/scripts/check-package-versions.mjs +++ b/scripts/check-package-versions.mjs @@ -1,8 +1,11 @@ +import { execFile } from 'node:child_process' import { readFile } from 'node:fs/promises' import path from 'node:path' +import { promisify } from 'node:util' import { fileURLToPath } from 'node:url' const rootDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const execFileAsync = promisify(execFile) export const releasePackageManifestPaths = [ 'packages/swift-node/package.json', @@ -38,10 +41,93 @@ export function assertSwiftNodeUnpluginPeerVersion(packages) { } } -export async function checkReleasePackageVersions(manifestPaths = releasePackageManifestPaths) { +function parseSemanticVersion(version) { + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec( + version, + ) + if (!match) throw new Error(`Invalid semantic version: ${version}`) + + const prerelease = match[4]?.split('.') ?? [] + if ( + prerelease.some((identifier) => /^\d+$/.test(identifier) && !/^(0|[1-9]\d*)$/.test(identifier)) + ) { + throw new Error(`Invalid semantic version: ${version}`) + } + + return { core: [match[1], match[2], match[3]], prerelease } +} + +function compareNumericIdentifiers(left, right) { + if (left.length !== right.length) return left.length < right.length ? -1 : 1 + if (left === right) return 0 + return left < right ? -1 : 1 +} + +export function compareSemanticVersions(left, right) { + const leftVersion = parseSemanticVersion(left) + const rightVersion = parseSemanticVersion(right) + + for (let index = 0; index < leftVersion.core.length; index += 1) { + const comparison = compareNumericIdentifiers(leftVersion.core[index], rightVersion.core[index]) + if (comparison !== 0) return comparison + } + + if (leftVersion.prerelease.length === 0 || rightVersion.prerelease.length === 0) { + if (leftVersion.prerelease.length === rightVersion.prerelease.length) return 0 + return leftVersion.prerelease.length === 0 ? 1 : -1 + } + + const identifiers = Math.max(leftVersion.prerelease.length, rightVersion.prerelease.length) + for (let index = 0; index < identifiers; index += 1) { + const leftIdentifier = leftVersion.prerelease[index] + const rightIdentifier = rightVersion.prerelease[index] + if (leftIdentifier === undefined) return -1 + if (rightIdentifier === undefined) return 1 + + const leftIsNumeric = /^\d+$/.test(leftIdentifier) + const rightIsNumeric = /^\d+$/.test(rightIdentifier) + if (leftIsNumeric && rightIsNumeric) { + const comparison = compareNumericIdentifiers(leftIdentifier, rightIdentifier) + if (comparison !== 0) return comparison + continue + } + if (leftIsNumeric !== rightIsNumeric) return leftIsNumeric ? -1 : 1 + if (leftIdentifier !== rightIdentifier) return leftIdentifier < rightIdentifier ? -1 : 1 + } + + return 0 +} + +export function assertPackageVersionsNotLowerThanBaseline(packages, baselinePackages) { + const baselineByName = new Map( + baselinePackages.map((packageManifest) => [packageManifest.name, packageManifest]), + ) + const lowerVersions = [] + + for (const packageManifest of packages) { + const baseline = baselineByName.get(packageManifest.name) + if (!baseline) { + throw new Error(`Baseline release packages must include ${packageManifest.name}`) + } + if (compareSemanticVersions(packageManifest.version, baseline.version) < 0) { + lowerVersions.push( + ` - ${packageManifest.name}: ${packageManifest.version} < ${baseline.version}`, + ) + } + } + + if (lowerVersions.length > 0) { + throw new Error( + `Release package versions must not be lower than main:\n${lowerVersions.join('\n')}`, + ) + } +} + +async function readReleasePackageManifests(manifestPaths, readManifest) { const packages = await Promise.all( manifestPaths.map(async (manifestPath) => { - const contents = await readFile(path.join(rootDirectory, manifestPath), 'utf8') + const contents = await readManifest(manifestPath) const { name, version, peerDependencies } = JSON.parse(contents) if (typeof name !== 'string' || typeof version !== 'string') { @@ -52,15 +138,52 @@ export async function checkReleasePackageVersions(manifestPaths = releasePackage }), ) + return packages +} + +async function readManifestFromWorkingTree(manifestPath) { + return readFile(path.join(rootDirectory, manifestPath), 'utf8') +} + +async function readManifestFromGitRef(ref, manifestPath) { + const { stdout } = await execFileAsync('git', ['show', `${ref}:${manifestPath}`], { + cwd: rootDirectory, + }) + return stdout +} + +export async function checkReleasePackageVersions( + manifestPaths = releasePackageManifestPaths, + { baselineRef } = {}, +) { + const packages = await readReleasePackageManifests(manifestPaths, readManifestFromWorkingTree) + const version = assertMatchingPackageVersions(packages) assertSwiftNodeUnpluginPeerVersion(packages) + if (baselineRef) { + const baselinePackages = await readReleasePackageManifests(manifestPaths, (manifestPath) => + readManifestFromGitRef(baselineRef, manifestPath), + ) + assertPackageVersionsNotLowerThanBaseline(packages, baselinePackages) + } return version } +function baselineRefFromArguments(argumentsList) { + if (argumentsList.length === 0) return undefined + if (argumentsList.length === 2 && argumentsList[0] === '--baseline-ref') return argumentsList[1] + throw new Error('Usage: node scripts/check-package-versions.mjs [--baseline-ref ]') +} + if (process.argv[1] === fileURLToPath(import.meta.url)) { try { - const version = await checkReleasePackageVersions() - console.log(`Release package versions match: ${version}`) + const baselineRef = baselineRefFromArguments(process.argv.slice(2)) + const version = await checkReleasePackageVersions(releasePackageManifestPaths, { baselineRef }) + console.log( + baselineRef + ? `Release package versions match and are not lower than ${baselineRef}: ${version}` + : `Release package versions match: ${version}`, + ) } catch (error) { console.error(error.message) process.exitCode = 1 diff --git a/skills-lock.json b/skills-lock.json index 073000b..36189b2 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -5,13 +5,13 @@ "source": "biw/skills", "sourceType": "github", "skillPath": "skills/better-logging/SKILL.md", - "computedHash": "ed9108ee92982cc0bc6a232d43e350c48b65556e6b79805d96d18b02fcdc7690" + "computedHash": "4f3342f51ea8fd14777436433804d26e0ba37cc3d5e5d099ba6e9fdf83524bd1" }, "conductor-setup": { "source": "biw/skills", "sourceType": "github", "skillPath": "skills/conductor-setup/SKILL.md", - "computedHash": "722fa45163cba78ebcf7af0d6ded4dc92848073098db16c1b65702a6f420f3ea" + "computedHash": "946ccb7c75b0d1edbd64a88b5701ded25988d16a987835843aec651f02e855ef" }, "create-readme": { "source": "github/awesome-copilot", @@ -23,7 +23,7 @@ "source": "biw/skills", "sourceType": "github", "skillPath": "skills/review-fix-address-bots/SKILL.md", - "computedHash": "57ce080372a87fdf03b514098dd55be0319996a0a6fa2d368bb00f40ad59886a" + "computedHash": "47c01eb738ad570ee9269f8c60590a1d1c2f7e394df3b763d4979c13da045986" } } } diff --git a/test/bridge-matrix.mjs b/test/bridge-matrix.mjs new file mode 100644 index 0000000..e3684b4 --- /dev/null +++ b/test/bridge-matrix.mjs @@ -0,0 +1,93 @@ +// The bridge matrix names the supported runtime behaviours that must be +// exercised through a compiled addon. Keep this list in sync with the public +// bridge surface: a new transport or execution mode needs an executable row +// before it is considered covered. + +export const executableBridgeMatrix = Object.freeze([ + { + id: 'synchronous-exports-and-throws', + description: 'Synchronous values and thrown Swift errors cross the addon boundary.', + caseNames: ['supported-type-matrix'], + }, + { + id: 'asynchronous-exports-and-rejections', + description: 'Async Swift exports resolve values and reject JavaScript Promises.', + caseNames: ['supported-type-matrix'], + }, + { + id: 'actor-hops', + description: 'MainActor and custom global-actor exports retain their execution semantics.', + caseNames: ['supported-type-matrix'], + }, + { + id: 'direct-structs', + description: 'Direct ABI structs, including Float fields, compile and round-trip.', + caseNames: ['supported-type-matrix', 'float-struct-round-trip'], + }, + { + id: 'codable-transport', + description: 'Codable values, nested binary data, and cross-file models round-trip.', + caseNames: ['supported-type-matrix'], + }, + { + id: 'binary-transports', + description: 'Owned Data/[UInt8] and borrowed UnsafeRawBufferPointer inputs preserve bytes.', + caseNames: ['supported-type-matrix', 'borrowed-buffer-input'], + }, + { + id: 'one-shot-callbacks', + description: 'Callbacks work both during a call and after Swift returns to JavaScript.', + caseNames: ['supported-type-matrix', 'threadsafe-callback-lifetime'], + }, + { + id: 'long-lived-promise-callbacks', + description: + 'Swift retains Promise-returning callbacks across calls and releases them explicitly.', + caseNames: ['long-lived-promise-callback'], + }, + { + id: 'streams', + description: 'Streams deliver values, errors, completion, cancellation, and cleanup.', + caseNames: ['supported-type-matrix'], + }, +]) + +export function assertExecutableBridgeMatrix(productionCases) { + const caseNames = new Set() + const duplicatedCases = new Set() + + for (const { name } of productionCases) { + if (caseNames.has(name)) duplicatedCases.add(name) + caseNames.add(name) + } + + const rowIds = new Set() + const duplicatedRows = new Set() + const emptyRows = new Set() + const missingCases = [] + + for (const { id, caseNames: rowCaseNames } of executableBridgeMatrix) { + if (rowIds.has(id)) duplicatedRows.add(id) + rowIds.add(id) + + if (!Array.isArray(rowCaseNames) || rowCaseNames.length === 0) { + emptyRows.add(id) + continue + } + + for (const caseName of rowCaseNames) { + if (!caseNames.has(caseName)) missingCases.push(`${id} -> ${caseName}`) + } + } + + const failures = [] + if (duplicatedCases.size > 0) + failures.push(`duplicate production cases: ${[...duplicatedCases].join(', ')}`) + if (duplicatedRows.size > 0) + failures.push(`duplicate matrix rows: ${[...duplicatedRows].join(', ')}`) + if (emptyRows.size > 0) + failures.push(`matrix rows without executable cases: ${[...emptyRows].join(', ')}`) + if (missingCases.length > 0) + failures.push(`matrix rows without a compiled-addon case: ${missingCases.join(', ')}`) + if (failures.length > 0) throw new Error(failures.join('\n')) +} diff --git a/test/package-version-parity.test.mjs b/test/package-version-parity.test.mjs index bb7f8dd..2cf0432 100644 --- a/test/package-version-parity.test.mjs +++ b/test/package-version-parity.test.mjs @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vite-plus/test' import { + assertPackageVersionsNotLowerThanBaseline, assertMatchingPackageVersions, assertSwiftNodeUnpluginPeerVersion, + compareSemanticVersions, } from '../scripts/check-package-versions.mjs' describe('release package version parity', () => { @@ -46,4 +48,42 @@ describe('release package version parity', () => { ]), ).not.toThrow() }) + + it('compares semantic versions, including prereleases', () => { + expect(compareSemanticVersions('0.1.4', '0.1.4')).toBe(0) + expect(compareSemanticVersions('0.1.5', '0.1.4')).toBeGreaterThan(0) + expect(compareSemanticVersions('1.0.0', '0.9.9')).toBeGreaterThan(0) + expect(compareSemanticVersions('1.0.0-beta.2', '1.0.0-beta.11')).toBeLessThan(0) + expect(compareSemanticVersions('1.0.0', '1.0.0-rc.1')).toBeGreaterThan(0) + }) + + it('rejects release package versions lower than main', () => { + expect(() => + assertPackageVersionsNotLowerThanBaseline( + [ + { name: 'swift-node', version: '0.1.3' }, + { name: 'swift-node-unplugin', version: '0.1.3' }, + ], + [ + { name: 'swift-node', version: '0.1.4' }, + { name: 'swift-node-unplugin', version: '0.1.4' }, + ], + ), + ).toThrow(/swift-node: 0\.1\.3 < 0\.1\.4[\s\S]*swift-node-unplugin: 0\.1\.3 < 0\.1\.4/) + }) + + it('accepts release package versions equal to or higher than main', () => { + expect(() => + assertPackageVersionsNotLowerThanBaseline( + [ + { name: 'swift-node', version: '0.2.0' }, + { name: 'swift-node-unplugin', version: '0.2.0' }, + ], + [ + { name: 'swift-node', version: '0.1.4' }, + { name: 'swift-node-unplugin', version: '0.1.4' }, + ], + ), + ).not.toThrow() + }) }) diff --git a/test/packaged-production-bridge.test.mjs b/test/packaged-production-bridge.test.mjs index 555a49f..76aeab5 100644 --- a/test/packaged-production-bridge.test.mjs +++ b/test/packaged-production-bridge.test.mjs @@ -5,7 +5,15 @@ // second consumer. Both module systems and the generated declarations must // work without relying on this workspace's source layout. -import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { execFileSync } from 'node:child_process' @@ -78,6 +86,11 @@ test('installs a packed, prebuild-only addon in ESM and CommonJS consumers', () path.join(addonDir, 'src', 'native.swift'), `import Foundation +public struct Profile: Codable, Sendable { + public let id: Int + public let name: String +} + // @swift-node:export func identify(_ value: String) -> String { "packed:" + value } @@ -89,13 +102,29 @@ func reverseData(_ value: Data) -> Data { Data(value.reversed()) } // @swift-node:export func echoOptional(_ value: String?) -> String? { value } + +// @swift-node:export +func rename(_ profile: Profile) -> Profile { + Profile(id: profile.id + 1, name: profile.name.uppercased()) +} `, ) // The generated build script uses its locally installed swift-node and // creates the target-qualified binary that the release workflow packages. run('npm', ['install', '--save-dev', swiftNodeTarball], addonDir) + const applicationDistFile = path.join(addonDir, 'dist', 'application.mjs') + mkdirSync(path.dirname(applicationDistFile), { recursive: true }) + writeFileSync(applicationDistFile, 'export const applicationBuild = true\n') run('npm', ['run', 'build'], addonDir) + if (existsSync(path.join(addonDir, 'gen')) || existsSync(path.join(addonDir, 'build'))) { + throw new Error( + 'swift-node build should keep bridge sources and object files out of the project', + ) + } + if (readFileSync(applicationDistFile, 'utf-8') !== 'export const applicationBuild = true\n') { + throw new Error('swift-node build should leave an application-owned dist directory untouched') + } const expectedTargetBinary = path.join( addonDir, 'dist_swift-node', @@ -140,6 +169,8 @@ func echoOptional(_ value: String?) -> String? { value } if (addon.add64(4_000_000_000) !== 4_000_000_001) throw new Error('ESM Int64 bridge failed') if (!addon.reverseData(new Uint8Array([1, 2])).equals(Buffer.from([2, 1]))) throw new Error('ESM binary bridge failed') if (addon.echoOptional(null) !== null) throw new Error('ESM optional bridge failed') + const profile = addon.rename({ id: 41, name: 'ben' }) + if (profile.id !== 42 || profile.name !== 'BEN') throw new Error('ESM struct bridge failed') `, ], consumerDir, @@ -155,6 +186,8 @@ func echoOptional(_ value: String?) -> String? { value } if (addon.add64(4_000_000_000) !== 4_000_000_001) throw new Error('CJS Int64 bridge failed') if (!addon.reverseData(new Uint8Array([3, 4])).equals(Buffer.from([4, 3]))) throw new Error('CJS binary bridge failed') if (addon.echoOptional(null) !== null) throw new Error('CJS optional bridge failed') + const profile = addon.rename({ id: 41, name: 'ben' }) + if (profile.id !== 42 || profile.name !== 'BEN') throw new Error('CJS struct bridge failed') `, ], consumerDir, @@ -162,14 +195,15 @@ func echoOptional(_ value: String?) -> String? { value } writeFileSync( path.join(consumerDir, 'type-smoke.ts'), - `import { add64, echoOptional, identify, reverseData } from '@matrix/packed-bridge' + `import { add64, echoOptional, identify, rename, reverseData, type Profile } from '@matrix/packed-bridge' const label: string = identify('typed') const value: number = add64(4_000_000_000) const bytes: Uint8Array = reverseData(new Uint8Array([1])) const optional: string | null = echoOptional(null) +const profile: Profile = rename({ id: 41, name: 'ben' }) -void label; void value; void bytes; void optional +void label; void value; void bytes; void optional; void profile // @ts-expect-error generated Int64 declarations use number const wrong: string = add64(1) @@ -184,8 +218,9 @@ const label: string = native.identify('typed') const value: number = native.add64(4_000_000_000) const bytes: Uint8Array = native.reverseData(new Uint8Array([1])) const optional: string | null = native.echoOptional(null) +const profile: native.Profile = native.rename({ id: 41, name: 'ben' }) -void label; void value; void bytes; void optional +void label; void value; void bytes; void optional; void profile // @ts-expect-error generated Int64 declarations use number const wrong: string = native.add64(1) diff --git a/test/production-bridge.test.mjs b/test/production-bridge.test.mjs index 6558276..d7ea267 100644 --- a/test/production-bridge.test.mjs +++ b/test/production-bridge.test.mjs @@ -12,6 +12,7 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath, pathToFileURL } from 'node:url' import { beforeAll, describe, it } from 'vite-plus/test' import { commandInvocation } from './command.mjs' +import { assertExecutableBridgeMatrix, executableBridgeMatrix } from './bridge-matrix.mjs' const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const cli = path.join(rootDir, 'packages', 'swift-node', 'bin', 'swift-node.js') @@ -287,6 +288,113 @@ setTimeout(() => { process.exit(0) }, 100) }, 100) +`, + }, + { + // The stored callback must survive the JavaScript call that installs it, + // and Swift must await the Promise it returns on later, concurrent calls. + name: 'long-lived-promise-callback', + source: `import Foundation + +private let promiseCallbackLock = NSLock() +private var installedPromiseCallback: ((String) async throws -> String)? + +// @swift-node:export +func installPromiseCallback(_ callback: @escaping (String) async throws -> String) { + promiseCallbackLock.lock() + installedPromiseCallback = callback + promiseCallbackLock.unlock() +} + +// @swift-node:export +func clearPromiseCallback() { + promiseCallbackLock.lock() + installedPromiseCallback = nil + promiseCallbackLock.unlock() +} + +// @swift-node:export +func invokeInstalledPromiseCallback(_ value: String, _ onResult: @escaping (String) -> Void) { + promiseCallbackLock.lock() + let callback = installedPromiseCallback + promiseCallbackLock.unlock() + + Task { + do { + onResult(try await callback?(value) ?? "no callback") + } catch { + onResult("error:\\(error.localizedDescription)") + } + } +} +`, + typeAssertion: `import { + clearPromiseCallback, + installPromiseCallback, + invokeInstalledPromiseCallback, +} from './dist_swift-node/index.mjs' + +installPromiseCallback(async (value: string): Promise => value.toUpperCase()) +invokeInstalledPromiseCallback('value', (result: string): void => { void result }) +clearPromiseCallback() +`, + assertion: ` + const addon = await import('./dist_swift-node/index.mjs') + const invoke = value => new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Promise callback timed out')), 1_000) + addon.invokeInstalledPromiseCallback(value, result => { + clearTimeout(timeout) + resolve(result) + }) + }) + + addon.installPromiseCallback(async value => { + await new Promise(resolve => setTimeout(resolve, value === 'slow' ? 20 : 5)) + return value.toUpperCase() + }) + const values = await Promise.all([invoke('slow'), invoke('fast')]) + if (values.join(',') !== 'SLOW,FAST') { + throw new Error('long-lived Promise callbacks lost concurrent results: ' + JSON.stringify(values)) + } + + addon.installPromiseCallback(async () => { + throw new Error('handler failed') + }) + const rejected = await invoke('failure') + if (!rejected.includes('handler failed')) { + throw new Error('Promise callback rejection did not reach Swift: ' + rejected) + } + + addon.clearPromiseCallback() + if (await invoke('after-clear') !== 'no callback') { + throw new Error('clearing a long-lived Promise callback did not release it') + } + `, + postAssertion: `const addon = require('./dist_swift-node/index.cjs') + +let callback = async value => value.toUpperCase() +const callbackRef = new WeakRef(callback) +addon.installPromiseCallback(callback) +callback = null +addon.clearPromiseCallback() + +function fail(message) { + console.error(message) + process.exit(1) +} + +function verifyReleasedCallback(attempt = 0) { + global.gc() + setTimeout(() => { + if (callbackRef.deref()) { + if (attempt < 20) return setTimeout(() => verifyReleasedCallback(attempt + 1), 0) + return fail('cleared Promise callback was retained by the addon') + } + process.exit(0) + }, 0) +} + +verifyReleasedCallback() `, }, { @@ -1127,6 +1235,15 @@ if (selectedCase && selectedCases.length === 0) { describe.sequential('production bridge', () => { beforeAll(() => run('vp', ['-C', 'packages/swift-node', 'pack'], rootDir), 180_000) + if (!selectedCase) { + it('maintains the executable bridge-test matrix', () => { + assertExecutableBridgeMatrix(cases) + if (executableBridgeMatrix.length === 0) { + throw new Error('the executable bridge-test matrix must not be empty') + } + }) + } + for (const testCase of selectedCases) { it(testCase.name, () => runCase(testCase), 180_000) } diff --git a/test/quickstart.test.mjs b/test/quickstart.test.mjs index cc0c0d7..8ee779c 100644 --- a/test/quickstart.test.mjs +++ b/test/quickstart.test.mjs @@ -1,12 +1,4 @@ -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { execFileSync } from 'node:child_process' @@ -52,64 +44,7 @@ function findTarball(prefix) { return path.join(packDir, match) } -function runTypeScriptSmoke() { - const tsgo = path.join(rootDir, 'node_modules', '@typescript', 'native-preview', 'bin', 'tsgo') - writeFileSync( - path.join(appDir, 'type-smoke.ts'), - ` -import { greet, rename, type Profile } from 'app' - -const profile: Profile = { id: 41, name: 'ben' } -const renamed: Profile = rename(profile) -const greeting: string = greet(renamed.name) - -if (!greeting || renamed.id !== 42) { - throw new Error('keep values used') -} - -// @ts-expect-error Profile.id must be a number -const wrong: Profile = { id: '41', name: 'ben' } -void wrong -`, - ) - writeFileSync( - path.join(appDir, 'type-smoke-cjs.cts'), - ` -import native = require('app') - -const profile: native.Profile = { id: 41, name: 'ben' } -const renamed: native.Profile = native.rename(profile) -const greeting: string = native.greet(renamed.name) - -if (!greeting || renamed.id !== 42) { - throw new Error('keep values used') -} - -// @ts-expect-error Profile.name must be a string -const wrong: native.Profile = { id: 41, name: 123 } -void wrong -`, - ) - run( - process.execPath, - [ - tsgo, - '--module', - 'NodeNext', - '--moduleResolution', - 'NodeNext', - '--target', - 'ES2022', - '--strict', - '--noEmit', - 'type-smoke.ts', - 'type-smoke-cjs.cts', - ], - appDir, - ) -} - -test('initializes and builds a project from the packaged CLI', () => { +test('initializes package-name and in-place projects from the packaged CLI', () => { tmpRoot = mkdtempSync(path.join(tmpdir(), 'swift-node-quickstart-')) packDir = path.join(tmpRoot, 'packs') appParentDir = path.join(tmpRoot, 'project-root') @@ -216,68 +151,7 @@ test('initializes and builds a project from the packaged CLI', () => { ) { throw new Error('swift-node init should create the documented hello-world starter export') } - writeFileSync( - path.join(appDir, 'src', 'native.swift'), - `import Foundation - -public struct Profile: Codable, Sendable { - public let id: Int - public let name: String -} - -// @swift-node:export -func greet(_ name: String) -> String { - return "Hello, \\(name)!" -} - -// @swift-node:export -func rename(_ profile: Profile) -> Profile { - return Profile(id: profile.id + 1, name: profile.name.uppercased()) -} -`, - ) - const applicationDistFile = path.join(appDir, 'dist', 'app.mjs') - mkdirSync(path.dirname(applicationDistFile), { recursive: true }) - writeFileSync(applicationDistFile, 'export const applicationBuild = true\n') - run('npx', ['--yes', '--package', swiftNodeTarball, 'swift-node', 'build'], appDir) - if (existsSync(path.join(appDir, 'gen')) || existsSync(path.join(appDir, 'build'))) { - throw new Error( - 'swift-node build should keep bridge sources and object files out of the project', - ) - } - for (const output of ['index.cjs', 'index.mjs', 'index.d.ts', 'index.d.cts', 'index.d.mts']) { - if (!existsSync(path.join(appDir, 'dist_swift-node', output))) { - throw new Error(`swift-node build should create dist_swift-node/${output}`) - } - } - if (readFileSync(applicationDistFile, 'utf-8') !== 'export const applicationBuild = true\n') { - throw new Error('swift-node build should leave an application-owned dist directory untouched') - } - const targetBinary = path.join( - appDir, - 'dist_swift-node', - ...(process.platform === 'darwin' ? [] : [`${process.platform}-${process.arch}`]), - `app.${process.platform}-${process.arch}.node`, - ) - if (!readFileSync(targetBinary).length) { - throw new Error(`swift-node build should create ${targetBinary}`) - } - runTypeScriptSmoke() - run( - process.execPath, - [ - '--input-type=module', - '-e', - ` - const { greet, rename } = await import('./dist_swift-node/index.mjs') - if (greet('World') !== 'Hello, World!') process.exit(1) - const profile = rename({ id: 41, name: 'ben' }) - if (profile.id !== 42 || profile.name !== 'BEN') process.exit(1) - `, - ], - appDir, - ) } finally { rmSync(tmpRoot, { recursive: true, force: true }) } -}, 180_000) +}, 90_000)