diff --git a/.claude/skills/visual-recap/SKILL.md b/.claude/skills/visual-recap/SKILL.md new file mode 100644 index 0000000..deb1c91 --- /dev/null +++ b/.claude/skills/visual-recap/SKILL.md @@ -0,0 +1,262 @@ +--- +name: visual-recap +description: + Generate and maintain the system recap block in a PR description - a + GitHub-rendered visual summary of which compiler pipeline primitives a + change touches, how risky it is, and what changed. Use when planning a + non-trivial change (plan mode), when creating or updating a pull request + (recap mode), or when the user asks for a visual recap, visual plan, system + review, or PR recap. +--- + +# System recap (visual plan / visual recap) + +Produce a high-altitude, visual review aid directly in the PR description. No +deployment, no third-party service: GitHub renders the block (including mermaid +diagrams), and the PR itself is the storage. A future viewer app can ingest the +same marker-delimited block via the GitHub API, so follow the format exactly. + +The recap is informational and non-blocking. It supplements the PR description +and normal code review; it never replaces reading the diff. + +## Two modes, one format + +- **Plan mode** (before/while implementing): describe the intended change + against the current system. If no PR exists yet, put the block in the plan + document or message; move it into the PR description once the PR exists. +- **Recap mode** (PR creation and every meaningful update): describe what the + diff actually does. Replaces a plan-mode block if one exists. + +## Source-of-truth rules (non-negotiable) + +1. **Recap mode reads the diff, not memory.** Generate the recap from + `git diff ...HEAD` (plus `git diff --stat`) against the PR base branch + (`main`). Session context may explain intent, but every claim about what + changed must be checkable against the diff. +2. **Classification uses the primitives taxonomy.** Read + [`docs/architecture/primitives.yaml`](../../../docs/architecture/primitives.yaml) + for stable `id` / `name` / `group` values. Prefer the classifier script over + hand-matching paths: + + ```bash + node .claude/skills/visual-recap/scripts/classify-primitives.mjs --base --head HEAD + # or: git diff --name-only ...HEAD | node .../classify-primitives.mjs --stdin --json + ``` + +3. **The taxonomy is not a feature changelog.** Update `primitives.yaml` only + when this PR **adds, removes, or materially reshapes** a primitive (new `id`, + renamed meaning, or code roots that must change) — e.g. adding a new + compilation stage, splitting the binder into two components, or renaming a + module. Do **not** edit `summary` for ordinary feature work — a new + language feature that lives inside the existing binder/parser/emitter is + still `extends`, not a taxonomy change. After editing the map, run: + + ```bash + node .claude/skills/visual-recap/scripts/check-primitives-map.mjs + ``` + +## Risk classification + +Classify each touched primitive, then roll up to the highest severity as the +overall classification (`adds` > `extends` > `composes`): + +| Classification | Meaning | Risk | +| -------------- | ---------------------------------------------------------- | ------ | +| `composes` | Uses existing primitives as-is; wiring and call sites only | Low | +| `extends` | Changes a primitive's behavior, shape, or contract | Medium | +| `adds` | Introduces a new primitive (must update primitives.yaml) | High | + +A change touching an invariant from `primitives.yaml` (for example +`transpile-sync` or `pncs-is-canonical`) is called out explicitly regardless of +classification — these are the things reviewers most often miss (e.g. a PR +that edits `pncs/src/main/scala/Binder.scala` but forgets to run +`sbt pncs/transpile` and commit the regenerated `pnc/src/Binder.pn`). + +The classifier reports which primitives' `code` roots the diff touches; you +still decide `composes` vs `extends` from the diff (and `adds` when you create a +new map entry). + +## Block format + +The block lives in the PR description between HTML comment markers, wrapped in +`
`. Fixed section order — a future ingestion process parses this +structure. Omit optional sections rather than leaving them empty. + +````markdown + + +
+System recap — extends existing primitives (medium risk) + +**Mode:** recap · **Base:** `main` @ `abc1234` · **Head:** `def5678` + +**Classification:** extends — bidirectional inference gains expected-type +propagation into match arms; no new primitives. + +### Primitives touched + +| Primitive | Group | Impact | +| --------- | --------- | ------------------------------------------------ | +| `binder` | semantics | extends — expected-type flows into match arms | +| `test-suite` | quality | composes — new TypeTests cases | + +### System map + +_One sentence: what this diagram shows and the main path through the change._ + +**Legend:** green = composes (wiring only) · amber = extended by this PR · red = +new primitive · gray = context (unchanged, included only when an edge crosses +it). + +```mermaid +flowchart LR + parser["parser
Parser & syntax tree"]:::untouched + binder["binder
Binder & type inference"]:::extended + testSuite["test-suite
Test suite"]:::touched + parser -->|"match expression AST"| binder + binder -->|"new TypeTests cases"| testSuite + classDef touched fill:#1a7f37,color:#fff + classDef extended fill:#9a6700,color:#fff + classDef added fill:#cf222e,color:#fff + classDef untouched fill:#57606a,color:#fff +``` + +### Change flow + +_Optional: a mermaid flowchart or sequence diagram of the specific change._ + +### Before / after + +_Optional: bytecode/AST shape, CLI flag, or diagnostic message changes as +compact before/after fenced blocks or tables._ + +### Invariants + +_Optional: only when the change touches an invariant from primitives.yaml — +e.g. note explicitly that `pnc/src/Binder.pn` was regenerated via +`sbt pncs/transpile` and committed alongside `Binder.scala`._ + +### Plan vs actual + +_Recap mode only, when a plan-mode block existed: what shipped as planned and +what drifted, in a short list._ + +
+ + +```` + +Format rules: + +- The `` line always carries the overall classification and risk in + bold so reviewers see it without expanding. +- Blank line after `` and around every fenced block, or GitHub will not + render the markdown/mermaid inside `
`. +- **System map**: a PR-scoped **change map** — how touched primitives interact + _because of this diff_, not the full compiler pipeline. Rules: + - Open with one sentence naming the main flow (e.g. "Match-expression type + checking flows from the parser through the binder into the new lowering + case."). + - Include the **legend** line (colors and what they mean) directly above the + diagram, using the fixed wording from the template. + - Include only primitives the diff touches plus neighbors needed to show a + crossing edge — not all 13 nodes, and no gray context nodes unless this PR + actually calls through them. + - **Label every edge** with what this PR does across that boundary: which + AST/bound-tree shape crosses it, which opcode or metadata table, which CLI + flag, which invariant. Unlabeled arrows are forbidden; they read as + meaningless topology and are the main failure mode reviewers report. + - Node labels: primitive `id` plus the `name` from `primitives.yaml` on a + second line via `
` (e.g. `binder["binder
Binder & type inference"]`). + - Color nodes with the four `classDef` styles (`touched` = composes, + `extended`, `added`, `untouched` for context-only nodes). + - Prefer fewer, labeled edges over chaining unlabeled `A --> B --> C` hops. + Split long chains only when each hop has its own label. + - Quote node labels containing spaces or special characters. + - When a sequence diagram already in **Change flow** covers the same path, the + system map may omit duplicate edges — but still include cross-cutting hops + (metadata format, transpile output, VM) that the sequence diagram skips. +- Keep the whole block scannable: prefer tables and diagrams over prose, and + keep it well under ~120 lines. + +## Workflow + +### Recap mode (PR create/update) + +1. Resolve base/head (`gh pr view --json baseRefName,headRefName`). +2. Classify paths: + + ```bash + node .claude/skills/visual-recap/scripts/classify-primitives.mjs --base --head HEAD --json + ``` + +3. Read the full diff for anything you did not author this session; decide + composes/extends/adds per matched primitive (and note important unmatched + paths if they introduce a new surface — e.g. a brand-new top-level module + that doesn't fit any existing `code` root). +4. Author the block following the format above. Use map `name` for diagram + labels; pull behavioral detail from the diff, not by rewriting map + summaries. +5. Upsert it into the PR description: + + ```bash + node .claude/skills/visual-recap/scripts/upsert-recap-block.mjs + ``` + + The script replaces the content between the markers, or appends the block to + the end of the description on first run. It never touches text outside the + markers. + +6. Re-run steps 2-5 after pushing significant new commits to the PR. + +### Plan mode + +Same steps, except: `**Mode:** plan`, no Base/Head commits required, "Primitives +touched" describes intended impact, and add a one-line note when the plan +requires **no** change to any primitive — that is the lowest-risk outcome and +worth stating explicitly. When implementation later diverges from the plan, the +recap's "Plan vs actual" section records the drift. + +## System map example (weak vs strong) + +The diagram must explain **this PR's** crossings, not restate the static +pipeline. Compare: + +**Weak** (unlabeled topology — reviewers cannot tell what the arrows mean): + +```mermaid +flowchart LR + parser["parser"]:::touched + binder["binder"]:::extended + loweringEmit["lowering-emit"]:::extended + parser --> binder --> loweringEmit +``` + +**Strong** (intro, legend, human names, labeled edges): + +Match-expression type checking flows from the parser's new pattern-binding AST +node through the binder's bidirectional inference into the lowering stage that +emits the branch dispatch. + +**Legend:** green = composes · amber = extended by this PR · red = new primitive +· gray = context. + +```mermaid +flowchart LR + parser["parser
Parser & syntax tree"]:::extended + binder["binder
Binder & type inference"]:::extended + loweringEmit["lowering-emit
Lowering & bytecode emission"]:::extended + testSuite["test-suite
Test suite"]:::touched + parser -->|"new pattern-binding AST node"| binder + binder -->|"expected-type flows into match arms"| loweringEmit + testSuite -->|"new TypeTests + VmTests cases"| binder + classDef touched fill:#1a7f37,color:#fff + classDef extended fill:#9a6700,color:#fff + classDef added fill:#cf222e,color:#fff + classDef untouched fill:#57606a,color:#fff +``` + +Derive edge labels from the diff (AST/bound-tree shapes, opcodes, metadata +tables, CLI flags, invariants). The **Change flow** sequence diagram can stay +for compilation-pass ordering; the system map answers "which primitives does +this PR connect, and how?" diff --git a/.claude/skills/visual-recap/scripts/check-primitives-map.mjs b/.claude/skills/visual-recap/scripts/check-primitives-map.mjs new file mode 100644 index 0000000..8b873ed --- /dev/null +++ b/.claude/skills/visual-recap/scripts/check-primitives-map.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +// Validate that every code/docs path in docs/architecture/primitives.yaml +// resolves on disk. Run after editing the map (adding/removing/reshaping a +// primitive) — see SKILL.md's source-of-truth rules. +import { checkPrimitivesMapPaths, loadPrimitivesMap } from './primitives-map.mjs' + +const map = loadPrimitivesMap() +const errors = checkPrimitivesMapPaths(map) + +if (errors.length === 0) { + console.log( + `OK: ${map.primitives.length} primitive(s), ${map.invariants.length} invariant(s), all code/docs paths resolve.`, + ) + process.exit(0) +} + +console.error(`${errors.length} problem(s) in docs/architecture/primitives.yaml:`) +for (const error of errors) { + console.error(` [${error.kind} ${error.id}] ${error.path}: ${error.reason}`) +} +process.exit(1) diff --git a/.claude/skills/visual-recap/scripts/classify-primitives.mjs b/.claude/skills/visual-recap/scripts/classify-primitives.mjs new file mode 100644 index 0000000..321936d --- /dev/null +++ b/.claude/skills/visual-recap/scripts/classify-primitives.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * Classify changed paths against the primitives taxonomy. + * + * Usage: + * node classify-primitives.mjs [--base ] [--head ] + * git diff --name-only main...HEAD | node classify-primitives.mjs --stdin + * + * Defaults: base=main, head=HEAD (git diff --name-only base...head). + */ +import { execFileSync } from 'node:child_process' +import { createInterface } from 'node:readline' +import { classifyPaths, loadPrimitivesMap } from './primitives-map.mjs' + +const args = process.argv.slice(2) + +function readFlag(name, fallback) { + const index = args.indexOf(name) + if (index === -1) return fallback + const value = args[index + 1] + if (!value || value.startsWith('--')) { + console.error(`missing value for ${name}`) + process.exit(1) + } + return value +} + +async function readStdinPaths() { + const paths = [] + const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }) + for await (const line of rl) { + const trimmed = line.trim() + if (trimmed) paths.push(trimmed) + } + return paths +} + +function readGitPaths(base, head) { + const output = execFileSync( + 'git', + ['diff', '--name-only', `${base}...${head}`], + { encoding: 'utf8' }, + ) + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) +} + +const useStdin = args.includes('--stdin') +const jsonOut = args.includes('--json') +const base = readFlag('--base', 'main') +const head = readFlag('--head', 'HEAD') + +const paths = useStdin ? await readStdinPaths() : readGitPaths(base, head) +const map = loadPrimitivesMap() +const { matched, unmatched } = classifyPaths(paths, map) + +if (jsonOut) { + console.log( + JSON.stringify( + { + base: useStdin ? null : base, + head: useStdin ? null : head, + pathCount: paths.length, + matched: matched.map((entry) => ({ + id: entry.primitive.id, + name: entry.primitive.name, + group: entry.primitive.group ?? null, + root: entry.root, + files: entry.files, + })), + unmatched, + }, + null, + 2, + ), + ) + process.exit(0) +} + +if (paths.length === 0) { + console.log('No changed paths.') + process.exit(0) +} + +console.log( + useStdin + ? `Classified ${paths.length} path(s) from stdin:\n` + : `Classified ${paths.length} path(s) from ${base}...${head}:\n`, +) + +for (const entry of matched) { + console.log( + `- ${entry.primitive.id} (${entry.primitive.group ?? 'ungrouped'}) via ${entry.root}`, + ) + for (const file of entry.files.slice(0, 8)) { + console.log(` ${file}`) + } + if (entry.files.length > 8) { + console.log(` … +${entry.files.length - 8} more`) + } +} + +if (unmatched.length > 0) { + console.log(`\nUnmatched (${unmatched.length}):`) + for (const file of unmatched.slice(0, 20)) { + console.log(` ${file}`) + } + if (unmatched.length > 20) { + console.log(` … +${unmatched.length - 20} more`) + } +} diff --git a/.claude/skills/visual-recap/scripts/primitives-map.mjs b/.claude/skills/visual-recap/scripts/primitives-map.mjs new file mode 100644 index 0000000..c9675a5 --- /dev/null +++ b/.claude/skills/visual-recap/scripts/primitives-map.mjs @@ -0,0 +1,336 @@ +/** + * Shared parser + path classifier for docs/architecture/primitives.yaml. + * + * Constrained YAML subset (version/groups/primitives/invariants with scalar fields + * and string lists). Not a general YAML parser. + */ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +export const defaultMapPath = resolve( + import.meta.dirname, + '../../../../docs/architecture/primitives.yaml', +) + +/** + * @typedef {{ + * id: string + * name: string + * group?: string + * summary?: string + * code: Array + * docs: Array + * }} Primitive + */ + +/** + * @typedef {{ + * version: number + * groups: Array<{ id: string, name: string }> + * primitives: Array + * invariants: Array + * }} PrimitivesMap + */ + +/** + * @param {string} text + * @returns {PrimitivesMap} + */ +export function parsePrimitivesMap(text) { + const lines = text.split(/\r?\n/) + /** @type {PrimitivesMap} */ + const map = { + version: 1, + groups: [], + primitives: [], + invariants: [], + } + + /** @type {'root' | 'groups' | 'primitives' | 'invariants'} */ + let section = 'root' + /** @type {Primitive | { id: string, name: string } | null} */ + let current = null + /** @type {'code' | 'docs' | null} */ + let listField = null + + function finishCurrent() { + if (!current || !('id' in current)) return + if (section === 'groups' && 'name' in current) { + map.groups.push({ id: current.id, name: current.name }) + } else if (section === 'primitives') { + const primitive = /** @type {Primitive} */ (current) + primitive.code ??= [] + primitive.docs ??= [] + map.primitives.push(primitive) + } else if (section === 'invariants') { + const invariant = /** @type {Primitive} */ (current) + invariant.code ??= [] + invariant.docs ??= [] + map.invariants.push(invariant) + } + current = null + listField = null + } + + for (const rawLine of lines) { + const commentIndex = rawLine.indexOf('#') + const line = + commentIndex === -1 + ? rawLine + : // Keep `#` inside quoted scalars; our map never quotes. + /^\s*#/.test(rawLine) + ? '' + : rawLine + if (!line.trim()) continue + + const sectionMatch = line.match(/^(groups|primitives|invariants):\s*$/) + if (sectionMatch) { + finishCurrent() + section = /** @type {'groups' | 'primitives' | 'invariants'} */ ( + sectionMatch[1] + ) + continue + } + + const versionMatch = line.match(/^version:\s+(\d+)\s*$/) + if (versionMatch) { + map.version = Number(versionMatch[1]) + continue + } + + const itemMatch = line.match(/^ {2}- id:\s+(.+?)\s*$/) + if (itemMatch) { + finishCurrent() + current = { + id: unquote(itemMatch[1]), + name: '', + code: [], + docs: [], + } + listField = null + continue + } + + if (!current) continue + + const listStart = line.match(/^ {4}(code|docs):\s*$/) + if (listStart) { + listField = /** @type {'code' | 'docs'} */ (listStart[1]) + continue + } + + const listItem = line.match(/^ {6}- (.+?)\s*$/) + if (listItem && listField && 'code' in current) { + const value = unquote(listItem[1]) + if (listField === 'code') current.code.push(value) + else current.docs.push(value) + continue + } + + const emptySummary = line.match(/^ {4}summary:\s*$/) + if (emptySummary) { + listField = null + current.summary = '' + continue + } + + const summaryContinuation = line.match(/^ {6}(.+?)\s*$/) + if ( + summaryContinuation && + listField === null && + 'code' in current && + current.summary === '' + ) { + current.summary = unquote(summaryContinuation[1]) + continue + } + + const fieldMatch = line.match(/^ {4}(group|name|summary):\s+(.+?)\s*$/) + if (fieldMatch) { + listField = null + const key = fieldMatch[1] + const value = unquote(fieldMatch[2]) + if (key === 'group') current.group = value + else if (key === 'name') current.name = value + else current.summary = value + continue + } + } + + finishCurrent() + return map +} + +/** + * @param {string} value + */ +function unquote(value) { + if ( + (value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"')) + ) { + return value.slice(1, -1) + } + return value +} + +/** + * @param {string} filePath + * @param {string} root + */ +export function pathMatchesRoot(filePath, root) { + const normalizedFile = filePath.replaceAll('\\', '/') + const isDirRoot = root.endsWith('/') + const normalizedRoot = root.replaceAll('\\', '/').replace(/\/$/, '') + if (!normalizedRoot) return false + if (isDirRoot) { + return ( + normalizedFile === normalizedRoot || + normalizedFile.startsWith(`${normalizedRoot}/`) + ) + } + return ( + normalizedFile === normalizedRoot || + normalizedFile.startsWith(normalizedRoot) + ) +} + +/** + * Longest-prefix match: each path maps to the primitive(s) whose matching + * `code` root is longest. Equal-length ties return every tied primitive. + * + * @param {Array} paths + * @param {PrimitivesMap} map + */ +export function classifyPaths(paths, map) { + /** @type {Map }>} */ + const byId = new Map() + /** @type {Array} */ + const unmatched = [] + + for (const rawPath of paths) { + const filePath = rawPath.replaceAll('\\', '/').replace(/^\.\//, '') + if (!filePath || filePath.endsWith('/')) continue + + /** @type {Array<{ primitive: Primitive, root: string }>} */ + const matches = [] + for (const primitive of map.primitives) { + for (const root of primitive.code) { + if (pathMatchesRoot(filePath, root)) { + matches.push({ primitive, root }) + } + } + } + + if (matches.length === 0) { + unmatched.push(filePath) + continue + } + + const maxLen = Math.max(...matches.map((match) => match.root.length)) + const winners = matches.filter((match) => match.root.length === maxLen) + const seen = new Set() + for (const winner of winners) { + if (seen.has(winner.primitive.id)) continue + seen.add(winner.primitive.id) + const existing = byId.get(winner.primitive.id) + if (existing) { + existing.files.push(filePath) + } else { + byId.set(winner.primitive.id, { + primitive: winner.primitive, + root: winner.root, + files: [filePath], + }) + } + } + } + + const matched = [...byId.values()].sort((a, b) => + a.primitive.id.localeCompare(b.primitive.id), + ) + return { matched, unmatched } +} + +/** + * @param {string} [mapPath] + */ +export function loadPrimitivesMap(mapPath = defaultMapPath) { + return parsePrimitivesMap(readFileSync(mapPath, 'utf8')) +} + +/** + * Validate that every `code` and `docs` path resolves on disk. + * Directory roots may end with `/`. Prefix roots (no trailing `/`) must match + * at least one existing file or directory under the repo root. + * + * @param {PrimitivesMap} map + * @param {string} [repoRoot] + */ +export function checkPrimitivesMapPaths( + map, + repoRoot = resolve(import.meta.dirname, '../../../..'), +) { + /** @type {Array<{ kind: string, id: string, path: string, reason: string }>} */ + const errors = [] + + /** + * @param {Primitive} entry + * @param {'primitive' | 'invariant'} kind + */ + function checkEntry(entry, kind) { + for (const docPath of entry.docs) { + const absolute = resolve(repoRoot, docPath) + if (!existsSync(absolute) || !statSync(absolute).isFile()) { + errors.push({ + kind, + id: entry.id, + path: docPath, + reason: 'docs path missing or not a file', + }) + } + } + + for (const codePath of entry.code) { + const absolute = resolve(repoRoot, codePath.replace(/\/$/, '')) + if (existsSync(absolute)) continue + + // Prefix roots (e.g. handlers/community, app/oauth-) must hit something. + if (!codePath.endsWith('/')) { + const parent = resolve( + repoRoot, + codePath.split('/').slice(0, -1).join('/'), + ) + const prefix = codePath.split('/').at(-1) ?? '' + if (existsSync(parent) && statSync(parent).isDirectory()) { + const hit = readdirSafe(parent).some((name) => + name.startsWith(prefix), + ) + if (hit) continue + } + } + + errors.push({ + kind, + id: entry.id, + path: codePath, + reason: 'code root missing on disk', + }) + } + } + + for (const primitive of map.primitives) checkEntry(primitive, 'primitive') + for (const invariant of map.invariants) checkEntry(invariant, 'invariant') + return errors +} + +/** + * @param {string} dir + */ +function readdirSafe(dir) { + try { + return readdirSync(dir) + } catch { + return [] + } +} diff --git a/.claude/skills/visual-recap/scripts/upsert-recap-block.mjs b/.claude/skills/visual-recap/scripts/upsert-recap-block.mjs new file mode 100644 index 0000000..dac8baf --- /dev/null +++ b/.claude/skills/visual-recap/scripts/upsert-recap-block.mjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +// Upsert a system-recap block into a PR description without touching any +// text outside the markers. Usage: +// node upsert-recap-block.mjs +// The block file must start with the start marker and end with the end +// marker. Requires the `gh` CLI to be authenticated. +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const startMarker = '' +const endMarker = '' + +const [prNumber, blockFile] = process.argv.slice(2) +if (!prNumber || !blockFile) { + console.error('usage: upsert-recap-block.mjs ') + process.exit(1) +} + +const block = readFileSync(blockFile, 'utf8').trim() +if (!block.startsWith(startMarker) || !block.endsWith(endMarker)) { + console.error( + `block file must start with "${startMarker}" and end with "${endMarker}"`, + ) + process.exit(1) +} + +const body = execFileSync( + 'gh', + ['pr', 'view', prNumber, '--json', 'body', '--jq', '.body'], + { encoding: 'utf8' }, +) + +const startIndex = body.indexOf(startMarker) +const endIndex = body.indexOf(endMarker) +const hasExistingBlock = + startIndex !== -1 && endIndex !== -1 && endIndex > startIndex + +const nextBody = hasExistingBlock + ? body.slice(0, startIndex) + block + body.slice(endIndex + endMarker.length) + : `${body.trimEnd()}\n\n${block}\n` + +execFileSync('gh', ['pr', 'edit', prNumber, '--body-file', '-'], { + input: nextBody, +}) + +console.log( + hasExistingBlock + ? `updated system-recap block on PR #${prNumber}` + : `added system-recap block to PR #${prNumber}`, +) diff --git a/docs/architecture/primitives.yaml b/docs/architecture/primitives.yaml new file mode 100644 index 0000000..6065979 --- /dev/null +++ b/docs/architecture/primitives.yaml @@ -0,0 +1,359 @@ +# Panther Compiler Kit system primitives map (taxonomy) +# +# Stable vocabulary for the visual-recap skill +# (.claude/skills/visual-recap/SKILL.md). This is NOT a feature changelog and +# NOT derived source-of-truth for behavior — the source under pncs/, pnc/, +# runtime/, metadata/, and text/ is. This file exists to give PR recaps and +# plans a shared, stable set of names for the moving parts of the compiler +# pipeline. +# +# Risk labels used in recaps: +# composes - wires existing primitives without changing them +# extends - changes a primitive's behavior, shape, or contract +# adds - introduces a new primitive (update this file in the same PR) +# +# Update this file only when adding, removing, or materially reshaping a +# primitive (id, name, group, code roots, or meaning). Do NOT edit `summary` +# for ordinary feature work — that's what commit messages and PR descriptions +# are for. +# +# `code` entries are coarse ownership roots for +# `classify-primitives.mjs` (longest-prefix match). Prefer directories over +# file lists, but pncs/ (hand-written Scala) and pnc/ (self-hosted Panther, +# generated from pncs/runtime/metadata/text via `sbt pncs/transpile`) are both +# flat directories of one file per concept with no shared prefix, so most +# primitives below list the Scala file and its generated .pn twin explicitly. +# Keep the map small — one primitive per pipeline concept, not per file. + +version: 1 + +groups: + - id: frontend + name: Lexing & parsing + - id: semantics + name: Binding & type checking + - id: backend + name: Lowering & code generation + - id: runtime + name: PVM execution + - id: stdlib + name: Shared libraries + - id: tooling + name: Build, CLI & self-hosting + - id: quality + name: Tests & docs + +primitives: + # --- Lexing & parsing ---------------------------------------------------- + - id: lexer + group: frontend + name: Lexer & tokens + summary: + Scans Panther source text into a token stream, including trivia + (whitespace/comments) and syntax-kind classification. + code: + - pncs/src/main/scala/Lexer.scala + - pnc/src/Lexer.pn + - pncs/src/main/scala/SimpleToken.scala + - pnc/src/SimpleToken.pn + - pncs/src/main/scala/SyntaxToken.scala + - pnc/src/SyntaxToken.pn + - pncs/src/main/scala/SyntaxTokenValue.scala + - pnc/src/SyntaxTokenValue.pn + - pncs/src/main/scala/SyntaxTrivia.scala + - pnc/src/SyntaxTrivia.pn + - pncs/src/main/scala/SyntaxTriviaList.scala + - pnc/src/SyntaxTriviaList.pn + - pncs/src/main/scala/TokenList.scala + - pnc/src/TokenList.pn + - pncs/src/main/scala/MakeTokenList.scala + - pnc/src/MakeTokenList.pn + - pncs/src/main/scala/SyntaxFacts.scala + - pnc/src/SyntaxFacts.pn + - pncs/src/main/scala/SyntaxKind.scala + - pnc/src/SyntaxKind.pn + + - id: parser + group: frontend + name: Parser & syntax tree + summary: + Builds the concrete syntax tree from the token stream using + operator-precedence parsing. + code: + - pncs/src/main/scala/Parser.scala + - pnc/src/Parser.pn + - pncs/src/main/scala/MakeSyntaxTree.scala + - pnc/src/MakeSyntaxTree.pn + - pncs/src/main/scala/SyntaxTree.scala + - pnc/src/SyntaxTree.pn + - pncs/src/main/scala/Ast.scala + - pnc/src/ast.pn + - pncs/src/main/scala/AstPrinter.scala + - pnc/src/AstPrinter.pn + - pncs/src/main/scala/OperatorPrecedence.scala + - pnc/src/OperatorPrecedence.pn + - pncs/src/main/scala/BinaryOperator.scala + - pnc/src/BinaryOperator.pn + - pncs/src/main/scala/BinaryOperatorKind.scala + - pnc/src/BinaryOperatorKind.pn + - pncs/src/main/scala/BinaryOperators.scala + - pnc/src/BinaryOperators.pn + - pncs/src/main/scala/UnaryOperator.scala + - pnc/src/UnaryOperator.pn + - pncs/src/main/scala/UnaryOperatorKind.scala + - pnc/src/UnaryOperatorKind.pn + + # --- Binding & type checking ---------------------------------------------- + - id: binder + group: semantics + name: Binder & type inference + summary: + Resolves symbols and scopes, checks types bidirectionally, and produces + the bound tree plus diagnostics. + code: + - pncs/src/main/scala/Binder.scala + - pnc/src/Binder.pn + - pncs/src/main/scala/ExprBinder.scala + - pnc/src/ExprBinder.pn + - pncs/src/main/scala/BoundNodes.scala + - pnc/src/BoundNodes.pn + - pncs/src/main/scala/BoundAssemblyPrinter.scala + - pnc/src/BoundAssemblyPrinter.pn + - pncs/src/main/scala/TypeInference.scala + - pnc/src/TypeInference.pn + - pncs/src/main/scala/Inference.scala + - pnc/src/Inference.pn + - pncs/src/main/scala/Type.scala + - pnc/src/Type.pn + - pncs/src/main/scala/Variance.scala + - pnc/src/Variance.pn + - pncs/src/main/scala/Conversion.scala + - pnc/src/Conversion.pn + - pncs/src/main/scala/Scope.scala + - pnc/src/Scope.pn + - pncs/src/main/scala/Symbol.scala + - pnc/src/Symbol.pn + - pncs/src/main/scala/SymbolKind.scala + - pnc/src/SymbolKind.pn + - pncs/src/main/scala/SymbolChain.scala + - pnc/src/SymbolChain.pn + - pncs/src/main/scala/SymbolLinks.scala + - pnc/src/SymbolLinks.pn + - pncs/src/main/scala/SymbolPrinter.scala + - pnc/src/SymbolPrinter.pn + - pncs/src/main/scala/DiagnosticBag.scala + - pnc/src/DiagnosticBag.pn + + # --- Lowering & code generation ------------------------------------------- + - id: lowering-emit + group: backend + name: Lowering & bytecode emission + summary: + Lowers the bound tree to a simplified IR and emits PVM bytecode from it. + code: + - pncs/src/main/scala/Lowered.scala + - pnc/src/Lowered.pn + - pncs/src/main/scala/LoweredAssemblyPrinter.scala + - pnc/src/LoweredAssemblyPrinter.pn + - pncs/src/main/scala/Emitter.scala + - pnc/src/Emitter.pn + + - id: metadata-format + group: backend + name: PVM metadata format + summary: + Binary metadata tables (types, methods, fields, params, strings, blobs, + signatures) shared by the emitter, disassembler, and VM loader. + code: + - metadata/src/main/scala/ + - pnc/src/BlobTable.pn + - pnc/src/Chunk.pn + - pnc/src/FieldMetadata.pn + - pnc/src/FieldTable.pn + - pnc/src/IntList.pn + - pnc/src/MetadataFlags.pn + - pnc/src/Metadata.pn + - pnc/src/MethodMetadata.pn + - pnc/src/MethodTable.pn + - pnc/src/Opcode.pn + - pnc/src/ParamMetadata.pn + - pnc/src/ParamTable.pn + - pnc/src/SignatureBuilder.pn + - pnc/src/Signature.pn + - pnc/src/StringTable.pn + - pnc/src/TypeDefMetadata.pn + - pnc/src/TypeDefTable.pn + + # --- PVM execution --------------------------------------------------------- + - id: vm-runtime + group: runtime + name: PVM virtual machine + summary: + Executes PVM bytecode against the heap and value model; includes the + disassembler used for bytecode inspection and debugging. + code: + - pncs/src/main/scala/VM.scala + - pnc/src/VM.pn + - pncs/src/main/scala/Heap.scala + - pnc/src/Heap.pn + - pncs/src/main/scala/Value.scala + - pnc/src/Value.pn + - pncs/src/main/scala/Disassembler.scala + - pnc/src/Disassembler.pn + + # --- Shared libraries -------------------------------------------------- + - id: panther-stdlib + group: stdlib + name: Panther standard library + summary: + Core runtime types (collections, Option/Result/Either, string/ANSI/hex + helpers) available to every Panther program, including the compiler + itself once self-hosted. + code: + - runtime/src/main/scala/ + - pnc/src/List.pn + - pnc/src/Dictionary.pn + - pnc/src/Option.pn + - pnc/src/Result.pn + - pnc/src/Either.pn + - pnc/src/Chain.pn + - pnc/src/NonEmptyList.pn + - pnc/src/Tuple2.pn + - pnc/src/ANSI.pn + - pnc/src/Hex.pn + + - id: text-source-model + group: stdlib + name: Source text model + summary: + Source file, line, and span/location tracking shared across lexing, + diagnostics, and printers. + code: + - text/src/main/scala/ + - pnc/src/MakeSourceFile.pn + - pnc/src/SourceFile.pn + - pnc/src/TextLineList.pn + - pnc/src/TextLineParser.pn + - pnc/src/TextLine.pn + - pnc/src/TextLocationFactory.pn + - pnc/src/TextLocation.pn + - pnc/src/TextSpanFactory.pn + - pnc/src/TextSpan.pn + + - id: shared-utils + group: stdlib + name: Formatting & string utilities + summary: + Small shared helpers (indentation, padding, trimming, string building, + math) used across the lexer, parser, binder, and printers. + code: + - pncs/src/main/scala/IndentedStringBuilder.scala + - pnc/src/IndentedStringBuilder.pn + - pncs/src/main/scala/StringBuilder.scala + - pnc/src/StringBuilder.pn + - pncs/src/main/scala/Pad.scala + - pnc/src/Pad.pn + - pncs/src/main/scala/Trim.scala + - pnc/src/Trim.pn + - pncs/src/main/scala/Math.scala + - pnc/src/Math.pn + + # --- Build, CLI & self-hosting ------------------------------------------- + - id: compiler-driver + group: tooling + name: Compiler driver & CLI + summary: + Entry point, argument parsing, and compiler settings that wire + lexer -> parser -> binder -> lowering -> emitter into one pipeline. + code: + - pncs/src/main/scala/Program.scala + - pnc/src/Program.pn + - pncs/src/main/scala/compilation.scala + - pnc/src/compilation.pn + - pncs/src/main/scala/CompilerSettings.scala + - pnc/src/CompilerSettings.pn + - pncs/src/main/scala/ArgsParser.scala + - pnc/src/ArgsParser.pn + - pncs/src/main/scala/ColorPalette.scala + - pnc/src/ColorPalette.pn + + - id: transpiler + group: tooling + name: Scala-to-Panther transpiler + summary: + Converts pncs/runtime/metadata/text Scala sources into the self-hosted + Panther (.pn) sources under pnc/src, driven by the sbt `transpile` task. + code: + - pncs/src/main/scala/Transpiler.scala + - pnc/src/Transpiler.pn + - build.sbt + - scripts/stage0.ps1 + - scripts/stage1.ps1 + - scripts/stage2.ps1 + docs: + - .github/instructions/copilot.instructions.md + + # --- Tests & docs ---------------------------------------------------------- + - id: test-suite + group: quality + name: Test suite + summary: + ScalaTest coverage for the lexer, parser, binder, type checker, VM, + args parser, and metadata format, run via `sbt test/test`. + code: + - test/ + + - id: language-docs + group: quality + name: Panther language docs (Astro site) + summary: + User-facing documentation for the Panther language, published from the + Astro/Starlight site in docs/. + code: + - docs/src/ + - docs/public/ + + - id: visual-recap-tooling + group: quality + name: Visual-recap skill & taxonomy + summary: + Contributor-facing PR review tooling: this primitives taxonomy itself + plus the Claude Code skill and automated PR-recap bot that classify a + diff against it. + code: + - docs/architecture/primitives.yaml + - .claude/skills/visual-recap/ + +invariants: + - id: transpile-sync + summary: + pnc/src/*.pn is generated from pncs/runtime/metadata/text via + `sbt pncs/transpile`; never hand-edit pnc/src directly. CI's `transpile` + job re-runs the transpile and fails the build if it produces a diff, so + a PR that edits a Scala source in a mirrored primitive must include the + regenerated .pn output in the same commit. + docs: + - .github/workflows/ci.yml + - .github/instructions/copilot.instructions.md + + - id: pncs-is-canonical + summary: + pncs/ (Scala) is the actively developed, buildable compiler; pnc/ (self- + hosted) is a generated mirror that currently does not build end-to-end + (`sbt pnc/compile` fails). Treat pnc/ changes as a byproduct of pncs/ + changes via transpile, not a separate implementation to hand-edit. + docs: + - .github/instructions/copilot.instructions.md + + - id: panther-scala-excluded-from-transpile + summary: + runtime/src/main/scala/panther.scala is a Scala-only interop prelude and + is explicitly excluded from transpilation (see the `sourceFiles` filter + in build.sbt); it has no pnc/src equivalent. + docs: + - build.sbt + + - id: diagnostics-not-exceptions + summary: + Compilation errors surface through DiagnosticBag, not thrown exceptions, + so the compiler can recover and report multiple errors from one run.