From 10d133f005ca22c5cd97d91eccb0a819b586e64b Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Tue, 25 Aug 2026 11:19:26 -0400 Subject: [PATCH] feat(cli): add issue reconciliation scripts and GitHub helper --- apps/cli/close-solved.ts | 216 +++++++++++++++++++++ apps/cli/close-topics.ts | 150 +++++++++++++++ apps/cli/github.ts | 93 +++++++++ apps/cli/package.json | 17 ++ apps/cli/pick.ts | 97 ++++++++++ apps/cli/report.ts | 37 ++++ apps/cli/sync.ts | 407 +++++++++++++++++++++++++++++++++++++++ apps/cli/test.ts | 55 ++++++ 8 files changed, 1072 insertions(+) create mode 100755 apps/cli/close-solved.ts create mode 100755 apps/cli/close-topics.ts create mode 100644 apps/cli/github.ts create mode 100644 apps/cli/package.json create mode 100755 apps/cli/pick.ts create mode 100644 apps/cli/report.ts create mode 100755 apps/cli/sync.ts create mode 100755 apps/cli/test.ts diff --git a/apps/cli/close-solved.ts b/apps/cli/close-solved.ts new file mode 100755 index 0000000..17890db --- /dev/null +++ b/apps/cli/close-solved.ts @@ -0,0 +1,216 @@ +#!/usr/bin/env bun +/** + * Close the GitHub issue for every LeetCode problem actually solved under work/. + * + * Reconciles state instead of reacting to a push diff: any `problem`-labelled + * issue whose LC number has an *implemented* solution file in work/ gets + * closed. Only open issues are touched, so re-runs are no-ops and backfilling + * needs no special casing. Removing a solution never reopens an issue. + * + * `bun run pick` scaffolds a statement header plus an empty function body, so + * file existence alone means nothing — a stub must not close its issue. See + * isImplemented(). + * + * bun apps/cli/close-solved.ts # close matches + * bun apps/cli/close-solved.ts --dry-run # report only, touch nothing + * + * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. + */ +import { basename, join } from "node:path"; + +import { github } from "./github.ts"; +import { markdownTable, printTable, writeStepSummary } from "./report.ts"; + +const ROOT = join(import.meta.dir, "..", ".."); +const WORK = join(ROOT, "work"); + +const DRY = + process.argv.includes("--dry-run") || + process.env.DRY_RUN === "1" || + process.env.DRY_RUN === "true"; + +// ── is the file a real solution or just a scaffolded stub? ──────── + +/** Placeholder bodies that leetcode-cli / a human leaves behind. */ +const PLACEHOLDERS: Record = { pass: true, "...": true, TODO: true }; + +/** + * Decide whether a work/ file contains an implementation. + * + * The header comment is dropped the same way sync.ts splitSource() does it + * (duplicated rather than imported, because sync.ts runs its whole pipeline on + * import). Comments must go before any brace analysis: the scaffold's JSDoc + * carries `@param {number[]}`, whose braces would otherwise read as a body. + * + * A brace-language file counts as implemented when at least one *innermost* + * brace pair holds real content. That distinguishes a bare stub + * (`function(nums) {}`) and a class-shaped design stub (every method body + * empty) from any genuine solution, whose innermost block always has code. + */ +function isImplemented(src: string, lang: "js" | "py"): boolean { + if (lang === "py") { + const open = src.indexOf('"""'); + const close = src.indexOf('"""', open + 3); + const code = open === -1 || close === -1 ? src : src.slice(close + 3); + for (const raw of code.split("\n")) { + const line = raw.replace(/#.*$/, "").trim(); + if (!line || PLACEHOLDERS[line]) continue; + if (/^(?:@|def\s|class\s)/.test(line)) continue; + return true; // a statement inside some def body + } + return false; + } + + const headerEnd = src.indexOf("*/"); + const body = headerEnd === -1 ? src : src.slice(headerEnd + 2); + const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " "); + + let innermost = -1; + for (let i = 0; i < code.length; i++) { + if (code[i] === "{") { + innermost = i; + } else if (code[i] === "}" && innermost !== -1) { + const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, ""); + if (inner && !PLACEHOLDERS[inner]) return true; + innermost = -1; // measured; the enclosing pair is not innermost + } + } + return false; +} + +// ── repo + auth ────────────────────────────────────────────────── + +const gh = await github(); + +// ── work/ inventory ────────────────────────────────────────────── + +interface WorkEntry { + files: string[]; + implemented: boolean; +} + +/** LC number -> solution files (a problem may have both js and py). */ +const work = new Map(); +for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { + const m = basename(rel).match(/^(\d+)\.(.+)\.(?:js|py)$/); + if (!m) { + console.warn(`skip (unrecognized name): work/${rel}`); + continue; + } + const num = Number(m[1]); + const src = await Bun.file(join(WORK, rel)).text(); + const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js"); + const entry = work.get(num); + if (entry) { + entry.files.push(`work/${rel}`); + entry.implemented ||= implemented; + } else { + work.set(num, { files: [`work/${rel}`], implemented }); + } +} + +// ── open problem issues ────────────────────────────────────────── + +interface ProblemIssue { + number: number; + set: string; +} + +/** + * Narrow one element of the /issues payload to the fields this script needs. + * Returns undefined for pull requests and for anything whose title is not a + * `LC ...` problem, which is how non-curriculum rows get skipped. + */ +function readProblemIssue( + value: unknown, +): { lc: number; issue: ProblemIssue } | undefined { + if (!value || typeof value !== "object") return; + if ("pull_request" in value) return; // the /issues route also lists PRs + if (!("number" in value) || typeof value.number !== "number") return; + if (!("title" in value) || typeof value.title !== "string") return; + const lc = value.title.match(/^LC (\d+) /); + if (!lc) return; + + let set = "—"; + if ("labels" in value && Array.isArray(value.labels)) { + for (const label of value.labels) { + if ( + label && + typeof label === "object" && + "name" in label && + typeof label.name === "string" && + label.name.startsWith("set:") + ) { + set = label.name; + } + } + } + return { lc: Number(lc[1]), issue: { number: value.number, set } }; +} + +/** LC number -> open issue carrying the `problem` label. */ +const open = new Map(); +for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) { + const parsed = readProblemIssue(raw); + if (parsed) open.set(parsed.lc, parsed.issue); +} + +// ── reconcile ──────────────────────────────────────────────────── + +const sha = process.env.GITHUB_SHA; +const report: string[][] = []; +let closed = 0; + +for (const num of [...work.keys()].sort((a, b) => a - b)) { + const entry = work.get(num)!; + const issue = open.get(num); + + // Warm-ups and out-of-curriculum practice have no issue. Not an error. + if (!issue) { + report.push([`${num}`, "—", "—", "no open issue"]); + continue; + } + if (!entry.implemented) { + report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]); + continue; + } + if (DRY) { + report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]); + continue; + } + + const links = entry.files + .map((f) => + sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``, + ) + .join(", "); + await gh.closeIssue( + issue.number, + `Solved — solution committed at ${links}.\n\n` + + "Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.", + ); + report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]); + closed++; +} + +// ── report ─────────────────────────────────────────────────────── + +const rows = [["lc", "issue", "set", "status"], ...report]; +printTable(rows); + +const implemented = [...work.values()].filter((e) => e.implemented).length; +const actionable = report.filter((r) => r[3] !== "no open issue"); +console.log( + `\n${work.size} in work/ · ${implemented} implemented · ` + + `${actionable.length} matched an open issue · ` + + `${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}`, +); + +await writeStepSummary( + `### close-solved${DRY ? " (dry run)" : ""}\n\n` + + `${work.size} files in \`work/\`, ${implemented} implemented, ` + + `${actionable.length} matched an open issue.\n\n` + + (actionable.length + ? `${markdownTable([rows[0]!, ...actionable])}\n` + : "Nothing to close.\n"), +); diff --git a/apps/cli/close-topics.ts b/apps/cli/close-topics.ts new file mode 100755 index 0000000..df7680c --- /dev/null +++ b/apps/cli/close-topics.ts @@ -0,0 +1,150 @@ +#!/usr/bin/env bun +/** + * Close the GitHub issue for every topic whose required (core) problems are done. + * + * Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each + * problem carries exactly one `set:` label — `set:core` is required, while + * `set:optional` and `set:deferred` are extra credit. A topic is finished when + * every one of its core sub-issues is closed; optional/deferred state is + * ignored, matching the day rule in each topic body ("Core problems first"). + * + * Like close-solved.ts this reconciles state instead of reacting to an event + * payload: re-runs are no-ops, backfilling needs no special casing, and only + * open topics are touched, so reopening a problem never reopens its topic. + * + * bun apps/cli/close-topics.ts # close finished topics + * bun apps/cli/close-topics.ts --dry-run # report only, touch nothing + * + * Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`. + */ +import { github } from "./github.ts"; +import { markdownTable, printTable, writeStepSummary } from "./report.ts"; + +const DRY = + process.argv.includes("--dry-run") || + process.env.DRY_RUN === "1" || + process.env.DRY_RUN === "true"; + +const gh = await github(); + +// ── open topic issues ──────────────────────────────────────────── + +interface Topic { + number: number; + title: string; +} + +/** + * Narrow one element of the /issues payload to the fields this script needs. + * The `topic` label filter cannot exclude pull requests, so drop those here. + */ +function readTopic(value: unknown): Topic | undefined { + if (!value || typeof value !== "object") return; + if ("pull_request" in value) return; // the /issues route also lists PRs + if (!("number" in value) || typeof value.number !== "number") return; + if (!("title" in value) || typeof value.title !== "string") return; + return { number: value.number, title: value.title }; +} + +const topics: Topic[] = []; +for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) { + const topic = readTopic(raw); + if (topic) topics.push(topic); +} +topics.sort((a, b) => a.number - b.number); + +// ── core sub-issue state per topic ─────────────────────────────── + +interface Core { + /** Core sub-issues, closed and open alike, lowest number first. */ + all: number[]; + /** The core sub-issues still open — non-empty means the topic stays open. */ + pending: number[]; +} + +/** + * Read a topic's core sub-issue state. The sub_issues payload carries the full + * issue objects (state + labels), so no per-problem follow-up request is needed. + */ +async function readCore(topic: number): Promise { + const all: number[] = []; + const pending: number[] = []; + for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) { + if (!raw || typeof raw !== "object") continue; + if (!("number" in raw) || typeof raw.number !== "number") continue; + if (!("state" in raw) || typeof raw.state !== "string") continue; + if (!("labels" in raw) || !Array.isArray(raw.labels)) continue; + + const core = raw.labels.some( + (label) => + label && + typeof label === "object" && + "name" in label && + label.name === "set:core", + ); + if (!core) continue; + + all.push(raw.number); + if (raw.state === "open") pending.push(raw.number); + } + all.sort((a, b) => a - b); + pending.sort((a, b) => a - b); + return { all, pending }; +} + +// ── reconcile ──────────────────────────────────────────────────── + +const report: string[][] = []; +let closed = 0; + +for (const topic of topics) { + const core = await readCore(topic.number); + const progress = `${core.all.length - core.pending.length}/${core.all.length}`; + + // A topic with no core sub-issues has nothing to complete: never close it, + // since that would be indistinguishable from a mis-labelled problem set. + if (core.all.length === 0) { + report.push([`#${topic.number}`, topic.title, progress, "no core set"]); + continue; + } + if (core.pending.length > 0) { + report.push([ + `#${topic.number}`, + topic.title, + progress, + `open: ${core.pending.map((n) => `#${n}`).join(" ")}`, + ]); + continue; + } + if (DRY) { + report.push([`#${topic.number}`, topic.title, progress, "would close"]); + continue; + } + + await gh.closeIssue( + topic.number, + `Core set complete — all ${core.all.length} core problems closed ` + + `(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` + + "Closed automatically by `close-topics`. Optional and deferred problems " + + "stay open as extra credit.", + ); + report.push([`#${topic.number}`, topic.title, progress, "closed"]); + closed++; +} + +// ── report ─────────────────────────────────────────────────────── + +const rows = [["topic", "title", "core", "status"], ...report]; +printTable(rows); + +const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close"); +console.log( + `\n${topics.length} open topics · ` + + `${DRY ? `would close ${finished.length}` : `closed ${closed}`}`, +); + +await writeStepSummary( + `### close-topics${DRY ? " (dry run)" : ""}\n\n` + + `${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` + + (report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"), +); diff --git a/apps/cli/github.ts b/apps/cli/github.ts new file mode 100644 index 0000000..0472f93 --- /dev/null +++ b/apps/cli/github.ts @@ -0,0 +1,93 @@ +/** + * GitHub REST plumbing shared by the issue reconcilers (close-solved, + * close-topics). + * + * Importing this module is side-effect free — nothing resolves credentials or + * touches the network until github() is awaited — so a script can import it + * without inheriting another script's pipeline. + * + * Repo: GITHUB_REPOSITORY, else the `origin` remote. + * Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`. + */ +import { $ } from "bun"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); + +/** Page size used for every list endpoint; also the "more pages" threshold. */ +const PER_PAGE = 100; + +export interface GitHub { + /** `owner/name`. */ + repo: string; + /** One authenticated request against api.github.com; throws on non-2xx. */ + api(path: string, init?: RequestInit): Promise; + /** Every page of a list endpoint, flattened into one stream of elements. */ + list(path: string): AsyncGenerator; + /** Comment on an issue, then close it as completed. */ + closeIssue(issue: number, comment: string): Promise; +} + +async function resolveRepo(): Promise { + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; + const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim(); + const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/); + if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`); + return m[1]!; +} + +async function resolveToken(): Promise { + const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + if (env) return env; + const token = (await $`gh auth token`.text()).trim(); + if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`"); + return token; +} + +export async function github(): Promise { + const repo = await resolveRepo(); + const token = await resolveToken(); + + async function api(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2022-11-28", + ...(init.body ? { "content-type": "application/json" } : {}), + }, + }); + if (!res.ok) { + throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`); + } + return res.json(); + } + + async function* list(path: string): AsyncGenerator { + const sep = path.includes("?") ? "&" : "?"; + for (let page = 1; ; page++) { + const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`); + if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`); + yield* batch; + if (batch.length < PER_PAGE) return; + } + } + + /** + * Comment before closing: if the PATCH fails, the issue still carries a + * visible note of what the automation decided, instead of failing silently. + */ + async function closeIssue(issue: number, comment: string): Promise { + await api(`/repos/${repo}/issues/${issue}/comments`, { + method: "POST", + body: JSON.stringify({ body: comment }), + }); + await api(`/repos/${repo}/issues/${issue}`, { + method: "PATCH", + body: JSON.stringify({ state: "closed", state_reason: "completed" }), + }); + } + + return { repo, api, list, closeIssue }; +} diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..6856ac7 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,17 @@ +{ + "name": "cli", + "private": true, + "type": "module", + "scripts": { + "pick": "bun ./pick.ts", + "test": "bun ./test.ts", + "sync": "bun ./sync.ts", + "close-solved": "bun ./close-solved.ts", + "close-topics": "bun ./close-topics.ts" + }, + "devDependencies": { + "@clack/prompts": "^1.7.0", + "@night-slayer18/leetcode-cli": "^3.5.0", + "@types/bun": "^1.3.14" + } +} diff --git a/apps/cli/pick.ts b/apps/cli/pick.ts new file mode 100755 index 0000000..6b4b328 --- /dev/null +++ b/apps/cli/pick.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +/** + * Fuzzy-pick a LeetCode problem and scaffold it via leetcode-cli. + * Problem index is fetched from leetcode.com and cached for 24h in .cache/. + */ +import { autocomplete, cancel, isCancel, spinner } from "@clack/prompts"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); +const CACHE_FILE = join(ROOT, ".cache", "leetcode-problems.json"); +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +interface Problem { + id: number; + title: string; + slug: string; + difficulty: "Easy" | "Medium" | "Hard"; + paidOnly: boolean; +} + +const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const; + +async function loadProblems(): Promise { + const file = Bun.file(CACHE_FILE); + if (await file.exists()) { + const stale = Date.now() - file.lastModified > CACHE_TTL_MS; + if (!stale) return file.json(); + } + + const s = spinner(); + s.start("Fetching problem index from leetcode.com"); + const res = await fetch("https://leetcode.com/api/problems/all/", { + headers: { "user-agent": "Mozilla/5.0" }, + }); + if (!res.ok) { + s.error(`Fetch failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + const data = (await res.json()) as { + stat_status_pairs: Array<{ + stat: { + frontend_question_id: number; + question__title: string; + question__title_slug: string; + }; + difficulty: { level: 1 | 2 | 3 }; + paid_only: boolean; + }>; + }; + const problems: Problem[] = data.stat_status_pairs + .map((p) => ({ + id: p.stat.frontend_question_id, + title: p.stat.question__title, + slug: p.stat.question__title_slug, + difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"], + paidOnly: p.paid_only, + })) + .sort((a, b) => a.id - b.id); + s.stop(`Loaded ${problems.length} problems`); + + await mkdir(join(ROOT, ".cache"), { recursive: true }); + await Bun.write(CACHE_FILE, JSON.stringify(problems)); + return problems; +} + +const problems = await loadProblems(); + +const picked = await autocomplete({ + message: "Pick a problem", + placeholder: "Type to search by number or title...", + maxItems: 12, + options: problems.map((p) => ({ + value: p, + label: `${p.id}. ${p.title}`, + hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty, + })), + filter: (search, option) => { + const haystack = option.label!.toLowerCase(); + return search + .toLowerCase() + .split(/\s+/) + .every((token) => haystack.includes(token)); + }, +}); + +if (isCancel(picked)) { + cancel("Nothing picked."); + process.exit(0); +} + +const proc = Bun.spawn( + // bun installs workspace-dep bins into this package's node_modules, not the root's + [join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug], + { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, +); +process.exit(await proc.exited); diff --git a/apps/cli/report.ts b/apps/cli/report.ts new file mode 100644 index 0000000..ad77cc7 --- /dev/null +++ b/apps/cli/report.ts @@ -0,0 +1,37 @@ +/** + * Console + GitHub Actions reporting shared by the issue reconcilers. + * + * Both reconcilers end the same way: an aligned table on stdout, and the same + * table as Markdown in the step summary when running under Actions. + */ + +/** Print rows[0] as a header, a rule, then the body — every column padded. */ +export function printTable(rows: string[][]): void { + const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length))); + const render = (r: string[]) => + r + .map((cell, i) => cell.padEnd(widths[i]!)) + .join(" ") + .trimEnd(); + + console.log(render(rows[0]!)); + console.log(widths.map((n) => "─".repeat(n)).join(" ")); + for (const row of rows.slice(1)) console.log(render(row)); +} + +/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */ +export function markdownTable(rows: string[][]): string { + const [header, ...body] = rows; + return [ + `| ${header!.join(" | ")} |`, + `| ${header!.map(() => "---").join(" | ")} |`, + ...body.map((r) => `| ${r.join(" | ")} |`), + ].join("\n"); +} + +/** Append to the Actions step summary; a no-op outside Actions. */ +export async function writeStepSummary(markdown: string): Promise { + const path = process.env.GITHUB_STEP_SUMMARY; + if (!path) return; + await Bun.write(path, markdown); +} diff --git a/apps/cli/sync.ts b/apps/cli/sync.ts new file mode 100755 index 0000000..e9c8f77 --- /dev/null +++ b/apps/cli/sync.ts @@ -0,0 +1,407 @@ +#!/usr/bin/env bun +/** + * Sync work/ leetcode solutions into apps/docs/content/ pages. + * + * - New problems get a full page (frontmatter, badge, warning, examples, + * constraints, solution) in the gold-standard format. + * - Already-ported pages only get their `## Solution` section regenerated, + * so hand-curated prose is never touched. + * - When a problem is solved in both JavaScript and Python the solution + * renders as a (Python first); single-language solutions + * render as a plain fence. + */ +import { mkdir, rename } from "node:fs/promises"; +import { basename, join } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); +const WORK = join(ROOT, "work"); +const DOCS = join(ROOT, "apps", "docs", "content"); + +type Lang = "js" | "py"; + +interface Example { + input: string; + output: string; + explanation: string[]; // [] = none; 1 entry = inline; >1 = bullet list +} + +interface Header { + num: number; + title: string; + difficulty: string; + statement: string[]; // unwrapped paragraphs + examples: Example[]; + constraints: string[]; + followUp: string; +} + +interface Solution { + num: number; + slug: string; + category: string; + header: Header; + code: Partial>; +} + +// ── work/ parsing ──────────────────────────────────────────────── + +/** Split a source file into (header comment text, code). */ +function splitSource(src: string, lang: Lang): { header: string; code: string } { + let header: string, code: string; + if (lang === "js") { + const end = src.indexOf("*/"); + if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */"); + header = src + .slice(src.indexOf("/*") + 2, end) + .split("\n") + .map((l) => l.replace(/^\s*\* ?/, "")) + .join("\n"); + code = src.slice(end + 2); + } else { + const open = src.indexOf('"""'); + const close = src.indexOf('"""', open + 3); + if (open === -1 || close === -1) throw new Error('missing """ header """'); + header = src.slice(open + 3, close); + code = src.slice(close + 3); + } + return { header, code: code.replace(/^\s*\n/, "").trimEnd() }; +} + +/** Unwrap hard-wrapped lines into logical paragraphs / bullets. */ +function paragraphs(lines: string[]): string[] { + const out: string[] = []; + let cur = ""; + const flush = () => { + if (cur) out.push(cur); + cur = ""; + }; + for (const raw of lines) { + const line = raw.replace(/\t/g, " ").trimEnd(); + const text = line.trim(); + if (!text || /^[─-]{3,}$/.test(text)) { + flush(); + continue; + } + if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) { + flush(); + cur = text; + } else { + cur = cur ? `${cur} ${text}` : text; + } + } + flush(); + return out; +} + +function parseHeader(header: string): Header { + const lines = header.split("\n"); + const titleLine = lines.find((l) => /^\s*\d+\.\s/.test(l))?.trim(); + if (!titleLine) throw new Error("missing '. ' line"); + const num = Number.parseInt(titleLine, 10); + const title = titleLine.replace(/^\d+\.\s*/, ""); + const difficulty = + lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? ""; + + // Body: everything after the ───── separator. + const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim())); + const paras = paragraphs(lines.slice(sep + 1)); + + const statement: string[] = []; + const examples: Example[] = []; + const constraints: string[] = []; + let followUp = ""; + let section: "statement" | "example" | "constraints" = "statement"; + let ex: Example | null = null; + + for (const p of paras) { + if (/^Example \d+:?$/.test(p)) { + if (ex) examples.push(ex); + ex = { input: "", output: "", explanation: [] }; + section = "example"; + continue; + } + if (/^Constraints:$/.test(p)) { + if (ex) examples.push(ex); + ex = null; + section = "constraints"; + continue; + } + const fu = p.match(/^Follow[- ]?ups?:\s*(.*)$/i); + if (fu) { + followUp = fu[1]!; + continue; + } + if (section === "statement") statement.push(p.replace(/^• /, "")); + else if (section === "constraints") constraints.push(p.replace(/^• /, "")); + else if (ex) { + // Example paragraphs: Input/Output/Explanation, wrapped arbitrarily. + // A paragraph may fuse "Input: … Output: …" only across real lines, + // but source always keeps them on separate wrapped paragraphs. + if (p.startsWith("Input:")) ex.input = p.slice(6).trim(); + else if (p.startsWith("Output:")) ex.output = p.slice(7).trim(); + else if (p.startsWith("Explanation:")) { + const rest = p.slice(12).trim(); + if (rest) ex.explanation.push(rest); + } else if (p.startsWith("- ")) ex.explanation.push(p.slice(2)); + else if (ex.explanation.length) + ex.explanation[ex.explanation.length - 1] += ` ${p}`; + else ex.explanation.push(p); + } + } + if (ex) examples.push(ex); + return { num, title, difficulty, statement, examples, constraints, followUp }; +} + +// ── README topic map ───────────────────────────────────────────── + +const TOPIC_ALIASES: Record<string, string> = { + "Hash-Based Lookup": "Hash Table", +}; + +async function readmeTopics(): Promise<Map<number, string>> { + const md = await Bun.file(join(ROOT, "README.md")).text(); + const map = new Map<number, string>(); + let topic = ""; + for (const line of md.split("\n")) { + const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/); + if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim(); + if (!topic) continue; + const n = line.match(/<td align="center">(\d+)<\/td>/); + if (n) map.set(Number(n[1]), topic); + } + return map; +} + +// ── inline-code formatting heuristics ──────────────────────────── + +/** Identifiers worth backticking in prose, harvested from the code. */ +function identifiers(sol: Solution): Set<string> { + const ids = new Set<string>(["n", "m", "k"]); + for (const code of Object.values(sol.code)) { + for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!); + for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g)) + for (const arg of m[1]!.split(",")) + if (arg.trim()) ids.add(arg.split(":")[0]!.trim()); + for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g)) + for (const arg of m[2]!.split(",")) + if (arg.trim()) ids.add(arg.trim()); + } + return ids; +} + +function formatConstraint(c: string, ids: Set<string>): string { + if (/<=|>=|==|!=|<|>/.test(c)) return `\`${c}\``; + // Prose constraint: backtick identifier-ish tokens only. + return c + .split(" ") + .map((word) => { + const m = word.match(/^([\w.]+(?:\[[^\]]*\])?)([.,;:]?)$/); + if (!m) return word; + const [, tok, punct] = m; + if (/\[[^\]]*\]/.test(tok!) || ids.has(tok!)) return `\`${tok}\`${punct}`; + return word; + }) + .join(" "); +} + +/** Wrap value literals / expressions in an explanation sentence. */ +function backtickify(text: string, ids: Set<string>): string { + const words = text.split(" "); + type Tok = { pre: string; core: string; post: string; codey: boolean }; + const toks: Tok[] = words.map((w) => { + const m = w.match(/^([("']*)(.*?)([)"'.,;:!?]*)$/)!; + const core = m[2]!; + const codey = + /^-?\d+(\.\d+)?([+\-*/]-?\d+(\.\d+)?)*$/.test(core) || // number / compact arithmetic + /^\[[^\]]*\]$/.test(core) || // array literal + /^[a-zA-Z_]\w*\[[^\]]*\]$/.test(core) || // indexed identifier + /^[+\-*/=%]$/.test(core) || // operator + ids.has(core); + return { pre: m[1]!, core, post: m[3]!, codey }; + }); + + const out: string[] = []; + let i = 0; + while (i < toks.length) { + if (!toks[i]!.codey || /^[+\-*/=%]$/.test(toks[i]!.core)) { + out.push(words[i]!); // prose, or an operator with no codey run to its left + i++; + continue; + } + // Extend a run of codey tokens; only unbroken by punctuation. + let j = i; + while ( + j + 1 < toks.length && + toks[j + 1]!.codey && + !toks[j]!.post && // punctuation after a token ends the run + !toks[j + 1]!.pre.includes('"') + ) + j++; + // Trim trailing operators from the run (e.g. "5 and" keeps "and" out anyway). + while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--; + const span = toks + .slice(i, j + 1) + .map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`)) + .join(" "); + out.push(`\`${span}\`${toks[j]!.post}`); + i = j + 1; + } + return out.join(" "); +} + +// ── page rendering ─────────────────────────────────────────────── + +const FENCE: Record<Lang, { info: string; label: string }> = { + py: { info: "py", label: "Python" }, + js: { info: "js", label: "JavaScript" }, +}; + +function solutionSection(sol: Solution): string { + const langs = (["py", "js"] as const).filter((l) => sol.code[l]); + const fence = (l: Lang, titled: boolean) => + `\`\`\`${FENCE[l].info}${titled ? ` ${FENCE[l].label}` : ""}\n${sol.code[l]}\n\`\`\``; + if (langs.length === 1) return `## Solution\n\n${fence(langs[0]!, false)}\n`; + return `## Solution\n\n<CodeGroup>\n\n${langs.map((l) => fence(l, true)).join("\n\n")}\n\n</CodeGroup>\n`; +} + +function warningText(h: Header): string { + if (h.followUp) return h.followUp; + return ( + h.statement + .slice(1) + .find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? "" + ); +} + +function renderPage(sol: Solution, topic: string): string { + const h = sol.header; + const ids = identifiers(sol); + const description = h.statement[0]?.replace(/\.\s*$/, "") ?? ""; + const parts: string[] = []; + + parts.push( + "---", + `title: '${h.num}. ${h.title.replaceAll("'", "''")}'`, + /[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`, + "sidebar:", + ` label: '${h.title.replaceAll("'", "''")}'`, + ` badge: '${h.difficulty}'`, + "---", + "", + `<Badge variant="accent">${topic}</Badge>`, + "", + ); + + const warning = warningText(h); + if (warning) parts.push("::::warning", warning, "::::", ""); + + h.examples.forEach((ex, i) => { + parts.push(`### Example ${i + 1}:`); + parts.push(`- Input: \`${ex.input}\``); + parts.push(`- Output: \`${ex.output}\``); + if (ex.explanation.length === 1) + parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`); + else if (ex.explanation.length > 1) { + parts.push("- Explanation:"); + for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`); + } + parts.push(""); + }); + + parts.push("### Constraints:", ""); + for (const c of h.constraints) parts.push(`- ${formatConstraint(c, ids)}`); + parts.push("", solutionSection(sol)); + + return parts.join("\n"); +} + +// ── main ───────────────────────────────────────────────────────── + +const solutions = new Map<number, Solution>(); +for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) { + const lang = rel.endsWith(".py") ? "py" : ("js" as Lang); + const file = basename(rel); + const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/); + if (!m) { + console.warn(`skip (unrecognized name): work/${rel}`); + continue; + } + const num = Number(m[1]); + const slug = m[2]!; + const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang); + const parsed = parseHeader(header); + const existing = solutions.get(num); + if (existing) { + existing.code[lang] = code; + if (lang === "js") existing.header = parsed; // js header wins when both exist + } else { + solutions.set(num, { + num, + slug, + category: rel.split("/")[1] ?? "", + header: parsed, + code: { [lang]: code }, + }); + } +} + +const topics = await readmeTopics(); + +/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */ +function ensureLabel(page: string, h: Header): string { + const fm = page.match(/^---\n[\s\S]*?\n---/); + if (!fm || /^ {2}label: /m.test(fm[0])) return page; + const label = ` label: '${h.title.replaceAll("'", "''")}'`; + return page.replace(/^sidebar:$/m, `sidebar:\n${label}`); +} + +// Existing docs pages, keyed by leetcode number from the frontmatter title. +const pages = new Map<number, string>(); // num -> path relative to DOCS +for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) { + const text = await Bun.file(join(DOCS, rel)).text(); + const t = text.match(/^title: '(\d+)\./m); + if (t) pages.set(Number(t[1]), rel); +} + +const report: [string, string, string][] = []; +const ordered = [...solutions.values()].sort((a, b) => a.num - b.num); + +for (const sol of ordered) { + const langs = Object.keys(sol.code).sort().join("+"); + const workRef = `${sol.num}.${sol.slug} (${langs})`; + // (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL. + const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`; + const name = `${group}/${sol.num}-${sol.slug}.mdx`; + const existing = pages.get(sol.num); + await mkdir(join(DOCS, group), { recursive: true }); + + if (existing) { + const page = await Bun.file(join(DOCS, existing)).text(); + const at = page.indexOf("## Solution"); + if (at === -1) { + report.push([workRef, existing, "ERROR: no '## Solution' heading"]); + continue; + } + const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header); + const actions: string[] = []; + if (existing !== name) { + await rename(join(DOCS, existing), join(DOCS, name)); + actions.push("moved"); + } + if (next !== page) { + await Bun.write(join(DOCS, name), next); + actions.push("updated"); + } + report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]); + } else { + const topic = topics.get(sol.num) ?? sol.category; + await Bun.write(join(DOCS, name), renderPage(sol, topic)); + report.push([workRef, name, "created"]); + } +} + +const w0 = Math.max(...report.map((r) => r[0].length), 9); +const w1 = Math.max(...report.map((r) => r[1].length), 9); +console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`); +console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`); +for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`); diff --git a/apps/cli/test.ts b/apps/cli/test.ts new file mode 100755 index 0000000..17f7eed --- /dev/null +++ b/apps/cli/test.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env bun +/** + * Fuzzy-select a solution file from work/ and run leetcode-cli tests on it. + */ +import { autocomplete, cancel, isCancel, log } from "@clack/prompts"; +import { join, relative } from "node:path"; + +const ROOT = join(import.meta.dir, "..", ".."); +const WORK_DIR = join(ROOT, "work"); + +const files = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })] + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + +if (files.length === 0) { + log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`); + process.exit(1); +} + +const picked = await autocomplete<string>({ + message: "Test which solution?", + placeholder: "Type to search...", + maxItems: 12, + options: files.map((f) => { + // work layout: Difficulty/Category/<id>.<slug>.<ext> + const [difficulty, category, name] = f.split("/"); + return { + value: f, + label: name ?? f, + hint: category ? `${difficulty} · ${category}` : difficulty, + }; + }), + filter: (search, option) => { + const haystack = option.value.toLowerCase(); + return search + .toLowerCase() + .split(/\s+/) + .every((token) => haystack.includes(token)); + }, +}); + +if (isCancel(picked)) { + cancel("Nothing selected."); + process.exit(0); +} + +const proc = Bun.spawn( + [ + // bun installs workspace-dep bins into this package's node_modules, not the root's + join(import.meta.dir, "node_modules", ".bin", "leetcode"), + "test", + join(WORK_DIR, picked), + ], + { cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] }, +); +process.exit(await proc.exited);