diff --git a/docs/trajectories/typescript-bun-migration/RESUME.md b/docs/trajectories/typescript-bun-migration/RESUME.md index 7face72e3..d28b51595 100644 --- a/docs/trajectories/typescript-bun-migration/RESUME.md +++ b/docs/trajectories/typescript-bun-migration/RESUME.md @@ -1,9 +1,9 @@ # Trajectory — TypeScript / Bun migration -**Status**: Active (Lane B slice 19 merged — [#902](https://github.com/Lucent-Financial-Group/Zeta/pull/902)) -**Milestone**: 40 ported. Budget cluster (14/18/19) and peer-call cluster (15/16/17) both complete. Bucket B reduced to 2 unported files (`tools/git/batch-resolve-pr-threads.sh` 390L + `tools/pr-preservation/archive-pr.sh` 674L) — both mutating-side, deserve careful porting. Bucket C reduced to 2 (`tools/hygiene/check-github-settings-drift.sh` + `tools/hygiene/snapshot-github-settings.sh`). +**Status**: Active (Lane B slice 19 merged — [#902](https://github.com/Lucent-Financial-Group/Zeta/pull/902); slice 20 in flight — `lane-b/ts-bun-slice-20-batch-resolve-pr-threads-2026-04-30`) +**Milestone**: 40 ported + 1 in-flight = 41 total. Budget cluster (14/18/19) and peer-call cluster (15/16/17) both complete. After slice 20 lands, Bucket B reduces to 1 unported file (`tools/pr-preservation/archive-pr.sh` 674L — bash+Python mix). Bucket C: 2 (`tools/hygiene/check-github-settings-drift.sh` + `tools/hygiene/snapshot-github-settings.sh`). **Current blocker**: None. -**Next concrete action**: Two natural next slices: (a) slice 20 — `tools/git/batch-resolve-pr-threads.sh` (390 lines, mutates PR thread state via gh GraphQL), or (b) slice 20 — `tools/pr-preservation/archive-pr.sh` (674 lines, mutates gh API). Both are state-mutating and warrant extra equivalence-test discipline. Per Gate B: read-only scope first when possible — but neither remaining Bucket B file is purely read-only. +**Next concrete action**: After slice 20 merges, slice 21 = `tools/pr-preservation/archive-pr.sh` is the last Bucket B file (674 lines, bash+Python mix — most complex remaining). Per Gate B: read-only scope first when possible — but the remaining file is state-mutating (gh API + writes drain logs). **Last updated**: 2026-04-30 ## Why this trajectory exists diff --git a/docs/trajectories/typescript-bun-migration/slice-audits.md b/docs/trajectories/typescript-bun-migration/slice-audits.md index e716f0b20..24da77a90 100644 --- a/docs/trajectories/typescript-bun-migration/slice-audits.md +++ b/docs/trajectories/typescript-bun-migration/slice-audits.md @@ -411,6 +411,40 @@ Per-port pattern checklist: Slice 6 passes audit. No new patterns recorded — all reused from prior slices. +## Slice 20 — 1 port (git/batch-resolve-pr-threads — last git-cluster port) (PR pending — `lane-b/ts-bun-slice-20-batch-resolve-pr-threads-2026-04-30`) + +**Slice files**: + +- `tools/git/batch-resolve-pr-threads.{sh→ts}` (the batch-classifier + resolver for PR review threads matching dangling-ref + name-attribution patterns) + +**Comparison points**: identical to slice 19. Within Gate B 30-day window. tsc gate active per #890. + +### Code-pattern audit (per-port) + +- **`batch-resolve-pr-threads.ts`** (390 → 415 lines): bash GraphQL pagination loop preserved 1:1 — same `first: 50, after: $cursor` shape, same `pageInfo.hasNextPage`/`endCursor` termination. Bash `gh api graphql -F owner=$x -F name=$y ...` shape preserved verbatim via `spawnSync("gh", ["api", "graphql", "-F", "owner=...", ...])` — positional `-F` args avoid the bash parameter-expansion-quote pitfall and the TS shape avoids the same string-concat-into-GraphQL footgun. jq pipelines (`[.comments.nodes[].body] | join("\n---\n")`) become typed `commentNodes.map(c => c.body ?? "").join("\n---\n")`. Pattern classification splits into three pattern arrays (DANGLING_REF_PATTERNS / NAME_ATTRIBUTION_DIRECT_PATTERNS / NAME_ATTRIBUTION_FUZZY_NAME × NAME_ATTRIBUTION_FUZZY_RULE) — same shape as the bash `for pat in "..." "..." ; do [[ ]]; done` loops; the fuzzy-combination check uses `.some()` × 2 rather than the bash `&&` of two `||`-OR groups. Same conservative semantics: unknown threads left unresolved. +- **GraphQL response error checking**: bash `graphql_check_errors` (inspect `.errors // [] | length`) maps to TS `if (page.errors !== undefined && page.errors.length > 0)`. Same fail-fast on partial-failure responses where gh exits 0 but GraphQL carried errors. +- **Reply templates verbatim**: the two reply markdown strings (REPLY_DANGLING_REF + REPLY_NAME_ATTRIBUTION) preserved character-for-character from bash. These get posted to live PR threads, so byte-equivalence matters. +- **Apply-mode mutations**: `addPullRequestReviewThreadReply` then `resolveReviewThread` mutations preserved 1:1. Per-thread error handling: `ResolveError` discriminated record with `stage: "reply" | "resolve"` so callers see exactly which step failed. +- **Truncation warning**: bash counts threads with `comments.totalCount > 50` (per-thread comment fetch limit); TS mirror via `truncationWarnings++` in classifyThreads. Same stderr warning format. + +### Equivalence audit + +Diff'd against bash output on this repo state (2026-04-30 main, run against PR #902 with 4 unresolved threads): + +- **Argument-validation paths**: byte-equivalent across 3 sampled paths — no args (exit 2 + usage), `abc` (exit 2 + bad-pr-number message), `906 --aply` (exit 2 + unknown-second-arg message). +- **Live dry-run on PR #902** (4 unresolved threads): byte-equivalent — `diff <(bun ...) <(./...sh)` empty diff. Same thread classification (0 dangling-ref / 0 name-attribution / 4 unknown), same thread IDs printed in same order. +- **Apply-mode**: not exercised in this audit (would mutate live PR state). Code path verified by inspection — reply-mutation + resolve-mutation calls match bash; per-mutation error classification preserved. + +### Behavioural note vs bash original + +- The bash `command -v gh && command -v jq` dependency probe drops to just `command -v gh` in TS — `jq` is not needed because JSON parsing is native (`JSON.parse` replaces all jq pipelines). +- All bash safety rails preserved: positive-integer pr-number validation, exact-`--apply` second-arg check, GraphQL `errors` array inspection, null-pullRequest detection, paginated thread fetch, paginated per-thread comment fetch (50 max — same truncation warning). +- Exit-code contract identical (0 success / 1 API failures / 2 argument errors). + +### Outcome + +Slice 20 passes audit. **Last git-cluster port** (slice 13 push-with-retry + slice 20 batch-resolve-pr-threads — both gh-API-mutating ports now TS). Bucket B 2 → 1 (only `tools/pr-preservation/archive-pr.sh` 674L remains; bash+Python mix — slice 21). Shape lessons reusable for slice 21: GraphQL pagination + classification + apply-mode mutation. + ## Slice 19 — 1 port (budget/project-runway — budget cluster closes) (PR #902, merged 2026-04-30, commit `bfdadd9`) **Slice files**: diff --git a/tools/git/batch-resolve-pr-threads.ts b/tools/git/batch-resolve-pr-threads.ts new file mode 100644 index 000000000..686ee35d6 --- /dev/null +++ b/tools/git/batch-resolve-pr-threads.ts @@ -0,0 +1,541 @@ +#!/usr/bin/env bun +// batch-resolve-pr-threads.ts — batch-classify and resolve PR review threads +// by pattern. TypeScript+Bun port of batch-resolve-pr-threads.sh, slice 20 +// of the TS+Bun migration. See docs/best-practices/repo-scripting.md. +// +// Built to drain the stacked-PR thread backlog (per the operational-gap- +// assessment "merge over invent" direction, 2026-04-23 round; hardened per +// bash PR #199 Copilot/Codex findings, all preserved in this port). +// +// Two disposition classes, both auto-resolvable: +// +// 1. dangling-ref — thread body matches "does not exist" / "path does +// not exist" / "artifact not in this commit" / etc. Acceptable during +// stacked-PR queue-drain; self-heals as queue drains. Blanket- +// acknowledge + resolve. +// +// 2. name-attribution — thread body matches "direct contributor names" +// / "no name attribution" / "standing rule" combined with "name". +// Legitimate per the named-agents-get-attribution discipline. +// Acknowledge + resolve with policy-pointer. +// +// Unknown threads are LEFT UNRESOLVED and reported (with thread IDs) for +// manual review. The conservative default keeps substantive findings visible. +// +// Usage: +// bun tools/git/batch-resolve-pr-threads.ts # dry-run +// bun tools/git/batch-resolve-pr-threads.ts --apply # resolve +// +// Exit codes: +// 0 — successful (dry-run summary or actual resolves) +// 1 — classification errors / API failures +// 2 — argument errors + +import { spawnSync } from "node:child_process"; + +const SPAWN_MAX_BUFFER = 64 * 1024 * 1024; + +interface ParsedArgs { + readonly prNumber: number; + readonly applyMode: boolean; +} + +interface ArgError { + readonly error: string; + readonly exitCode: 1 | 2; +} + +function parseArgs(argv: readonly string[]): ParsedArgs | ArgError { + if (argv.length < 1 || argv.length > 2) { + return { + error: "usage: bun tools/git/batch-resolve-pr-threads.ts [--apply]", + exitCode: 2, + }; + } + // Reject anything other than exactly '--apply' as the second arg + // (catches typos like '--aply' that would otherwise silently dry-run). + let applyMode = false; + if (argv.length === 2) { + if (argv[1] === "--apply") { + applyMode = true; + } else { + return { + error: `error: unknown second argument '${argv[1] ?? ""}' (only '--apply' is accepted)`, + exitCode: 2, + }; + } + } + // PR number must be a positive integer (reject 0 explicitly; the regex + // alone admits 0 which is not a valid PR number on GitHub). + const prNumberRaw = argv[0] ?? ""; + if (!/^[0-9]+$/.test(prNumberRaw)) { + return { + error: `error: pr-number must be a positive integer (>0); got '${prNumberRaw}'`, + exitCode: 2, + }; + } + const prNumber = Number.parseInt(prNumberRaw, 10); + if (prNumber <= 0) { + return { + error: `error: pr-number must be a positive integer (>0); got '${prNumberRaw}'`, + exitCode: 2, + }; + } + return { prNumber, applyMode }; +} + +function commandAvailable(cmd: string): boolean { + // Match bash `command -v ` semantics (PATH existence) — same shape + // as siblings in tools/peer-call/. + const result = spawnSync("/bin/sh", ["-c", `command -v "${cmd}"`], { + stdio: "ignore", + }); + return result.status === 0; +} + +interface RepoInfo { + readonly owner: string; + readonly name: string; +} + +interface RepoViewResponse { + readonly owner?: { readonly login?: string }; + readonly name?: string; +} + +function detectRepo(): RepoInfo | ArgError { + // Detect current repo (portable: works on forks / renamed orgs). Mirrors + // the bash `gh repo view --json owner,name` shape. + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("gh", ["repo", "view", "--json", "owner,name"], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) { + return { + error: "error: could not detect repo via 'gh repo view'. Run inside a repo with a GitHub remote.", + exitCode: 1, + }; + } + const parsed = JSON.parse(result.stdout) as RepoViewResponse; + const owner = parsed.owner?.login ?? ""; + const name = parsed.name ?? ""; + if (owner.length === 0 || name.length === 0) { + return { error: "error: could not parse repo owner/name from gh repo view", exitCode: 1 }; + } + return { owner, name }; +} + +// Reply templates — markdown content posted to PR threads. Preserved +// VERBATIM from the bash original so the user-visible behavior is identical. +const REPLY_DANGLING_REF = + 'Acknowledged and accepted during Phase 1 queue-drain (per the "merge over invent" operational-gap-assessment direction from the 2026-04-23 round). Referenced artifacts are in-flight across adjacent PRs; cross-PR dangling refs are a known side-effect of stacked-PR state and self-heal as the queue drains. Resolving to unblock merge; opportunistic cleanup of any permanent refs in follow-up tick if gaps remain visible after queue drain.'; + +const REPLY_NAME_ATTRIBUTION = + "Acknowledged; the name appearance here is legitimate per the named-agents-get-attribution policy (see `memory/CURRENT-aaron.md` attribution table + `docs/EXPERT-REGISTRY.md` persona roster). Named personas are factory-level attribution surfaces; their names in ADRs / config / collaborator registries are the factory's structural record of who contributed what. Resolving; the name-attribution rule applies to personal human names outside persona-scope, not to persona names in structural attribution contexts."; + +interface CommentNode { + readonly body?: string; +} + +interface ThreadNode { + readonly id?: string; + readonly isResolved?: boolean; + readonly comments?: { + readonly totalCount?: number; + readonly nodes?: readonly CommentNode[]; + }; +} + +interface PageInfo { + readonly hasNextPage?: boolean; + readonly endCursor?: string | null; +} + +interface ReviewThreadsResponse { + readonly data?: { + readonly repository?: { + readonly pullRequest?: { + readonly reviewThreads?: { + readonly pageInfo?: PageInfo; + readonly nodes?: readonly ThreadNode[]; + }; + } | null; + }; + }; + readonly errors?: readonly unknown[]; +} + +interface FetchError { + readonly error: string; + readonly exitCode: 1; +} + +const QUERY = ` + query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 50, after: $after) { + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + comments(first: 50) { + totalCount + nodes { body } + } + } + } + } + } + } +`; + +function fetchOnePage(args: { + readonly owner: string; + readonly name: string; + readonly prNumber: number; + readonly cursor: string | null; +}): ReviewThreadsResponse | FetchError { + // Build positional -F args; matches the bash `args+=( -F "after=$cursor" )` + // shape and avoids the parameter-expansion-quote pitfall. + const ghArgs: string[] = [ + "api", + "graphql", + "-F", + `owner=${args.owner}`, + "-F", + `name=${args.name}`, + "-F", + `number=${String(args.prNumber)}`, + ]; + if (args.cursor !== null && args.cursor.length > 0) { + ghArgs.push("-F", `after=${args.cursor}`); + } + ghArgs.push("-f", `query=${QUERY}`); + // eslint-disable-next-line sonarjs/no-os-command-from-path + const result = spawnSync("gh", ghArgs, { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (result.status !== 0) { + return { + error: `error: GraphQL fetch failed for PR #${String(args.prNumber)}`, + exitCode: 1, + }; + } + return JSON.parse(result.stdout) as ReviewThreadsResponse; +} + +interface FetchAllResult { + readonly threads: readonly ThreadNode[]; +} + +function fetchAllThreads(args: { + readonly owner: string; + readonly name: string; + readonly prNumber: number; +}): FetchAllResult | FetchError { + // Paginated GraphQL fetch: 50 threads per page with up to 50 comments each; + // loops via endCursor until !hasNextPage. Matches bash original. + let cursor: string | null = null; + const all: ThreadNode[] = []; + for (;;) { + const page = fetchOnePage({ ...args, cursor }); + if ("error" in page) return page; + if (page.errors !== undefined && page.errors.length > 0) { + return { + error: `error: GraphQL response carried errors for fetch PR #${String(args.prNumber)}: ${JSON.stringify(page.errors)}`, + exitCode: 1, + }; + } + // Null pullRequest = nonexistent or inaccessible. + const pr = page.data?.repository?.pullRequest; + if (pr === null || pr === undefined) { + return { + error: `error: PR #${String(args.prNumber)} not found in ${args.owner}/${args.name} (or not accessible)`, + exitCode: 1, + }; + } + const reviewThreads = pr.reviewThreads; + if (reviewThreads === undefined) break; + const nodes = reviewThreads.nodes ?? []; + for (const n of nodes) all.push(n); + const pageInfo = reviewThreads.pageInfo; + if (pageInfo?.hasNextPage !== true) break; + cursor = pageInfo.endCursor ?? null; + if (cursor === null) break; + } + return { threads: all }; +} + +const DANGLING_REF_PATTERNS: readonly string[] = [ + "does not exist", + "path does not exist", + "artifact not in this commit", + "file/path does not exist", + "not in the repository at this commit", + "not yet on main", + "doesn't exist in-repo", + "doesn't exist in the repository", + "point protocol references", + "point references to existing", + "not present in-repo", + "aren't resolvable", +]; + +const NAME_ATTRIBUTION_DIRECT_PATTERNS: readonly string[] = [ + "direct contributor name attribution", + "contributor name attribution", + "direct contributor names", + "direct names in code", + "direct names in doc", + "prohibits direct names", + "name attribution rule", + "repo convention prohibits", + "repo's standing rule", +]; + +const NAME_ATTRIBUTION_FUZZY_NAME: readonly string[] = [ + "name attribution", + "contributor names", + "no name", +]; + +const NAME_ATTRIBUTION_FUZZY_RULE: readonly string[] = [ + "rule", + "standing", + "policy", + "conflicts with", + "prohibits", +]; + +type Classification = "dangling-ref" | "name-attribution" | "unknown"; + +function classifyBody(bodyLower: string): Classification { + // Dangling-ref patterns — conservative; only match when the text clearly + // refers to cross-PR reference problems. + if (DANGLING_REF_PATTERNS.some((p) => bodyLower.includes(p))) { + return "dangling-ref"; + } + // Name-attribution patterns — direct match first, then fuzzy combination. + if (NAME_ATTRIBUTION_DIRECT_PATTERNS.some((p) => bodyLower.includes(p))) { + return "name-attribution"; + } + // Fuzzy: any name-related phrase combined with any rule-related phrase. + if ( + NAME_ATTRIBUTION_FUZZY_NAME.some((p) => bodyLower.includes(p)) && + NAME_ATTRIBUTION_FUZZY_RULE.some((p) => bodyLower.includes(p)) + ) { + return "name-attribution"; + } + return "unknown"; +} + +interface ClassifiedThreads { + readonly dangling: readonly string[]; + readonly nameAttribution: readonly string[]; + readonly unknown: readonly string[]; + readonly truncationWarnings: number; +} + +function classifyThreads(threads: readonly ThreadNode[]): ClassifiedThreads { + const dangling: string[] = []; + const nameAttribution: string[] = []; + const unknown: string[] = []; + let truncationWarnings = 0; + for (const t of threads) { + if (t.isResolved === true) continue; + if (t.id === undefined) continue; + const totalCount = t.comments?.totalCount ?? 0; + if (totalCount > 50) truncationWarnings++; + const commentNodes = t.comments?.nodes ?? []; + // Bash builds the body via `[.comments.nodes[].body] | join("\n---\n")` + // — TS mirror. + const body = commentNodes + .map((c) => c.body ?? "") + .join("\n---\n"); + const classification = classifyBody(body.toLowerCase()); + if (classification === "dangling-ref") dangling.push(t.id); + else if (classification === "name-attribution") nameAttribution.push(t.id); + else unknown.push(t.id); + } + return { dangling, nameAttribution, unknown, truncationWarnings }; +} + +const REPLY_MUTATION = ` + mutation($thread_id: ID!, $body: String!) { + addPullRequestReviewThreadReply(input: { + pullRequestReviewThreadId: $thread_id, + body: $body + }) { comment { id } } + } +`; + +const RESOLVE_MUTATION = ` + mutation($thread_id: ID!) { + resolveReviewThread(input: { threadId: $thread_id }) { + thread { isResolved } + } + } +`; + +interface ResolveError { + readonly threadId: string; + readonly stage: "reply" | "resolve"; + readonly message: string; +} + +function resolveThread(threadId: string, replyBody: string): ResolveError | null { + // Reply via -F body=... (gh handles JSON escaping properly). + const replyArgs: string[] = [ + "api", + "graphql", + "-F", + `thread_id=${threadId}`, + "-F", + `body=${replyBody}`, + "-f", + `query=${REPLY_MUTATION}`, + ]; + // eslint-disable-next-line sonarjs/no-os-command-from-path + const replyResult = spawnSync("gh", replyArgs, { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (replyResult.status !== 0) { + return { threadId, stage: "reply", message: replyResult.stderr ?? "" }; + } + const replyParsed = JSON.parse(replyResult.stdout) as { errors?: readonly unknown[] }; + if (replyParsed.errors !== undefined && replyParsed.errors.length > 0) { + return { + threadId, + stage: "reply", + message: `GraphQL errors: ${JSON.stringify(replyParsed.errors)}`, + }; + } + + const resolveArgs: string[] = [ + "api", + "graphql", + "-F", + `thread_id=${threadId}`, + "-f", + `query=${RESOLVE_MUTATION}`, + ]; + // eslint-disable-next-line sonarjs/no-os-command-from-path + const resolveResult = spawnSync("gh", resolveArgs, { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + }); + if (resolveResult.status !== 0) { + return { threadId, stage: "resolve", message: resolveResult.stderr ?? "" }; + } + const resolveParsed = JSON.parse(resolveResult.stdout) as { errors?: readonly unknown[] }; + if (resolveParsed.errors !== undefined && resolveParsed.errors.length > 0) { + return { + threadId, + stage: "resolve", + message: `GraphQL errors: ${JSON.stringify(resolveParsed.errors)}`, + }; + } + return null; +} + +function emitSummary(args: { + readonly prNumber: number; + readonly owner: string; + readonly name: string; + readonly classified: ClassifiedThreads; +}): void { + const c = args.classified; + if (c.truncationWarnings > 0) { + process.stderr.write( + `warning: ${String(c.truncationWarnings)} thread(s) have >50 comments; only first 50 inspected for classification\n`, + ); + } + process.stdout.write( + `PR #${String(args.prNumber)} (${args.owner}/${args.name}) unresolved thread classification:\n` + + ` dangling-ref: ${String(c.dangling.length)}\n` + + ` name-attribution: ${String(c.nameAttribution.length)}\n` + + ` unknown (left unresolved): ${String(c.unknown.length)}\n`, + ); + if (c.unknown.length > 0) { + process.stdout.write("\nunknown thread IDs (manual review):\n"); + for (const tid of c.unknown) { + process.stdout.write(` - ${tid}\n`); + } + } +} + +function applyResolutions(classified: ClassifiedThreads): number { + const total = classified.dangling.length + classified.nameAttribution.length; + process.stdout.write(`\nAPPLY MODE — resolving ${String(total)} threads...\n`); + for (const tid of classified.dangling) { + process.stdout.write(` resolving dangling-ref: ${tid}\n`); + const err = resolveThread(tid, REPLY_DANGLING_REF); + if (err !== null) { + process.stderr.write( + `error: could not ${err.stage} thread ${err.threadId}: ${err.message}\n`, + ); + return 1; + } + } + for (const tid of classified.nameAttribution) { + process.stdout.write(` resolving name-attribution: ${tid}\n`); + const err = resolveThread(tid, REPLY_NAME_ATTRIBUTION); + if (err !== null) { + process.stderr.write( + `error: could not ${err.stage} thread ${err.threadId}: ${err.message}\n`, + ); + return 1; + } + } + process.stdout.write( + `\ndone. ${String(total)} resolved. ${String(classified.unknown.length)} unknown threads left for manual review.\n`, + ); + return 0; +} + +export function main(argv: readonly string[]): number { + const parsed = parseArgs(argv); + if ("error" in parsed) { + process.stderr.write(`${parsed.error}\n`); + return parsed.exitCode; + } + // Dependency probe. + if (!commandAvailable("gh")) { + process.stderr.write("error: required dependency 'gh' not found on PATH\n"); + return 1; + } + const repo = detectRepo(); + if ("error" in repo) { + process.stderr.write(`${repo.error}\n`); + return repo.exitCode; + } + const fetched = fetchAllThreads({ + owner: repo.owner, + name: repo.name, + prNumber: parsed.prNumber, + }); + if ("error" in fetched) { + process.stderr.write(`${fetched.error}\n`); + return fetched.exitCode; + } + const classified = classifyThreads(fetched.threads); + emitSummary({ + prNumber: parsed.prNumber, + owner: repo.owner, + name: repo.name, + classified, + }); + if (!parsed.applyMode) { + process.stdout.write("\ndry-run mode — no changes. Re-run with --apply to resolve.\n"); + return 0; + } + return applyResolutions(classified); +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +}