Claude Skill

pr-lens

WHAT: Draws a code change or part of a codebase as an animated architecture or data-flow diagram, on its own or in a pull request. WHEN: asked to diagram, visualise or explain a change or a system, or when a pull request should carry a diagram. KEYWORDS: PR Lens, diagram, archite

LLM Mart · 0 points · 14 views 16 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download coldteadotai-pr-lens-packages_agent-skill-ce81274.zip · 26 KB
Part of coldteadotai/pr-lens — 2 skills

Install

skills CLI npx skills add https://github.com/coldteadotai/pr-lens/tree/main/packages/agent-skill
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install coldteadotai-pr-lens@llmmart
Git git clone https://github.com/coldteadotai/pr-lens.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole coldteadotai/pr-lens collection as a plugin from our marketplace. Git is the plain clone.

README

@coldtea/pr-lens-agent-skill

The PR Lens skill for coding agents. It teaches an agent to draw the change it just made: author a graph document from the diff, validate it against the contract, render it, attach it to the pull request, and to fix a repository's map by writing corrections rather than editing generated output.

MIT © Coldtea AI.

Install it

npm install --save-dev @coldtea/pr-lens-agent-skill

Claude Code: copy it where skills live, per project or per user:

mkdir -p .claude/skills/pr-lens
cp -R node_modules/@coldtea/pr-lens-agent-skill/{SKILL.md,references} .claude/skills/pr-lens/

Cursor: the same file works as a rule:

mkdir -p .cursor/rules
cp node_modules/@coldtea/pr-lens-agent-skill/SKILL.md .cursor/rules/pr-lens.mdc

Anything else: point your agent's instructions file at SKILL.md. It is plain markdown with YAML frontmatter, and it assumes nothing beyond a shell and npx.

What is in it

SKILL.md when to reach for PR Lens, and the write → validate → fix → render loop
references/graph-document.md the document, field by field, and what the validator will catch
references/config.md .github/pr-lens.yml corrections, with recipes

The agent is usually the model. Rather than spending a provider key to describe a diff it already understands, it writes the document itself and lets pr-lens validate hold it to the contract. Every failure is a path into the document, so the loop closes without a human in it.

Why this exists

A coding agent that opens a pull request is asking a person to review code the person did not write. A diagram of what moved is the cheapest thing the agent can add to make that review possible.


Part of PR Lens. Review what actually matters.

Skill manifest

PR Lens

PR Lens draws code as visually rich animated diagrams. It can represent diffs, architecture, data flows, and more.

The diff or code is represented as one JSON document (lanes, nodes, edges, ordered flows) and it renders the JSON as an animated SVG

Operating manual

Decide where the diagram lands before you write it: a canvas, or an SVG and a pull request comment. Only a canvas draws payload, the sample request and response on a flow step. A late decision costs another pass through steps 2 and 3.

  1. Read the diff. When asked to represent a code change: git diff --find-renames <base>...<head>. The base is the merge base, not the tip of the base branch.

    If not expressing a code diff, read the code to be visually represented

  2. Write the document to .pr-lens/graph.json, following references/graph-document.md. references/example.graph.json is valid reference with three lanes, all four delta states, a hero edge, a seven-step flow, a nested drill-down tree and a six-step walkthrough. Read it before you write your first one. It is quicker than reading the reference. If it is going to a canvas, give every flow step (messages) that moves data a payload as you write it. "Sample traffic on a flow step" below says what goes in one. Only a flow step carries one. Flows need the data-flow lens, so an architecture view draws none.

  3. Validate, and fix

    npx @coldtea/pr-lens-cli@latest validate .pr-lens/graph.json
    

    Fix every failure and run it again. Do not render an invalid document; do not "work around" a failure by deleting the element it names.

  4. Render.

    npx @coldtea/pr-lens-cli@latest render .pr-lens/graph.json --theme light
    

    Render light by default unless the user requests another theme. The SVGs, the manifest and drawn.graph.json land in .pr-lens/, which the CLI adds to the repository's .gitignore. Do not commit any of it. These files are rebuilt from the diff whenever anyone wants them again. Each SVG is named after its view, the theme and a content hash; manifest.json lists them by lens and view, so read the names from there or from the directory.

    If the user asked for a diagram, an explanation or a picture of the architecture and nothing more, put it on a canvas and hand back the link:

    npx @coldtea/pr-lens-cli@latest canvas push
    

    This pushes .pr-lens/drawn.graph.json and prints three links. Give the user the view link, https://prlens.dev/c/{id}: that is the diagram, full screen, every view on one page, and it opens without a login. The edit link, the one ending in #w=…, lets its holder push over the canvas, so leave it out of the reply unless they ask, and never paste it anywhere public. The embed link serves the top view as an SVG for a README.

    Pushing the same file again updates the same canvas, so a follow-up such as "rename that node" or "add the queue" is: edit the document, validate, render, push. The link stays the same. If the push fails, say so and tell them where the SVGs are and which one is the top view.

  5. Attach, when there is a pull request to attach to. That means the user asked you to open a PR, asked for a diagram on one that exists, or you are opening a PR as part of changes made. Otherwise skip this step.

    GitHub CLI uploads the diagram with the pull request. Write the body with a Markdown image pointing at the local file, then pass the same path to --attach. gh rewrites the reference to the uploaded asset and keeps the alt text you wrote:

    Moves bulk sending off the per-recipient trigger and onto a batch endpoint.
    
    ![Architecture after this change: the queue route, the new bulk sender and the retired per-recipient path](.pr-lens/overview-light-4f9bd6c1.svg)
    
    gh pr create --title "Batch broadcast sends" --body-file .pr-lens/body.md \
      --attach .pr-lens/overview-light-4f9bd6c1.svg
    

    On a pull request that already exists, gh pr edit <number> with the same two flags puts the diagram in the description, and gh pr comment <number> puts it in a comment. Repeat --attach for each diagram the body references.

    gh has three rules:

    • The reference has to be a Markdown image, ![alt](path). An HTML <img> or <picture> is left as written, and the file is appended at the bottom of the body instead.
    • The alt text is the caption a reader without images gets. Say what the diagram shows, in one line.
    • --attach arrived in GitHub CLI 2.99. Check with gh --version before you write a body around it.

    Attach the views a reviewer needs and leave the rest in .pr-lens/: the top architecture view first, then a data flow if the change has a sequence worth following. A body with four diagrams reads worse than one with two, except the four are really needed to understand the change e.g., in the case of a complex feature or refactor.

    When --attach is not an option, publish the SVGs somewhere durable and let the CLI compose the comment instead:

    npx @coldtea/pr-lens-cli@latest comment \
      --graph .pr-lens/drawn.graph.json \
      --manifest .pr-lens/manifest.json \
      --asset-base-url https://raw.githubusercontent.com/<owner>/<repo>/<branch>/<dir>
    

    --graph takes drawn.graph.json, not the document you wrote, because corrections change what the diagrams show and the CLI refuses a document its manifest does not describe. --asset-base-url is where you published the SVGs; leave it out and the markdown points at local paths no reader can fetch. The markdown goes to stdout, with each diagram as a <picture> pair; posting it is your business.

If you would rather not author the document yourself, npx @coldtea/pr-lens-cli@latest analyze --base <ref> does steps 1 and 2 by asking a provider — Gemini, OpenAI, or any endpoint speaking /chat/completions — with a key of your own. That is the only path here that needs one.

The pull request body, when there is one

A reviewer should understand the change before reading the diff, so the diagram goes where they look first: the description, not a trailing comment. Open with one sentence on why the change exists, then the architecture diagram, then whatever proves the change works, such as a screenshot of the result or a recording of the interaction. Use one visual per idea. A diagram that needs a paragraph of explanation has a document problem; go back to step 2.

What makes a document worth reading

  • Include what did not change. A diagram of only the changed nodes says nothing about blast radius. The unchanged neighbours a change touches are the context; mark them delta: "unchanged".
  • Lanes are the reader's mental model (a runtime, a tier, a boundary), not the folder tree.
  • One hero edge, two at the outside: the connection the change is really about.
  • Add a flow only when there is a sequence worth animating. One good flow beats three thin ones.
  • Attach file refs: they become the permalinks a reviewer clicks.
  • There is no findings lens. PR Lens is the comprehension layer, not another review bot. There is no field for a bug, a risk or a security note, and a document that invents one is rejected rather than trimmed.

Choosing architecture views

Treat architecture views as a C4-inspired decision tree, not a checklist. One useful view is enough for a small change. Start with system context when the change affects a user, an external system or a system boundary. Use a container view for the affected applications, services, jobs, data stores and runtimes. Add a component child only when an affected container's internals matter. Do not add code-level views by default.

Every child moves down one level and covers a materially narrower scope. Skip empty, repetitive or speculative levels, and do not infer architecture from folder names alone. Two views should not carry substantially the same nodes and edges. Keep the unchanged direct neighbours that explain blast radius.

Keep data-flow views as separate roots rather than nesting them in the architecture tree. Set defaultOpen: true on the highest useful architecture view. Lower levels should normally keep the default, false.

Writing a walkthrough

A walkthrough is a short guided tour of the diagrams. It has two to twelve steps. Each step shows one diagram, points at one part of it, and says a few words about it. A canvas plays it, and the reader scrolls through it.

The contract leaves a walkthrough optional. Write one anyway for anything that is not trivial: more than one diagram, a diagram with several changed parts, or any flow. Skip it only when the document is one small diagram whose single step would just repeat the title.

Aim for three to seven steps.

A walkthrough is the fastest read of a pull request. Each step is one change: something added, changed, removed or moved, in the order a reviewer needs it. A step is never a description of the diagram.

What counts as a step: a behaviour change, an API change, an architecture change, a data-flow change, or an addition. Unchanged parts appear only where a step needs them to make sense. The headline change is step one. An overview of everything touched, if there is one, is the last step.

"walkthrough": {
  "steps": [
    {
      "id": "four-batch-calls",
      "heading": "Postmark now gets 500 emails per call",
      "body": "One call per batch, and Postmark answers with a result for each message.",
      "stage": { "kind": "flow", "flow": "send-pipeline" },
      "focus": { "kind": "selection", "messages": ["batch-post", "batch-results"] }
    },
    {
      "id": "blast-radius",
      "heading": "4 parts added, 2 removed, across 3 lanes",
      "body": "A 2,000-person broadcast used to make 2,000 calls to Postmark. It now makes 4.",
      "stage": { "kind": "view", "view": "overview" }
    }
  ]
}

Each step has:

  • heading: the thing and what happened to it, up to 48 characters, in sentence case. Build it from change words: added, removed, replaced, now, moved, split. If a heading could have been true before the pull request, it is not a change heading.
  • body: one line under the heading, up to 140 characters, on what the change means for behaviour: what happens now that did not before, or what stops happening, with the numbers when they matter. Not a restatement of the heading, and not a description of the code. A heading with no body reads as unfinished, so the body is required.
  • stage: which diagram to show. A document can have several diagrams: its views (the drill-down diagrams) and its flows (the sequence diagrams). { "kind": "view", "view": "overview" } shows the view called overview. { "kind": "flow", "flow": "send-pipeline" } shows the flow called send-pipeline. Leave stage out and the step uses the diagram the reader is already on. Open on the widest view with the focus left out, so the reader sees the whole thing before it narrows.
  • focus: what to zoom in on inside that diagram. { "kind": "all" }, the default, means the whole diagram. A selection means "just these things": name any lanes, nodes, edges or flow steps (messages) by id, and the camera zooms to them while everything else dims. Focus the elements the step's change touched, so the veil lights the change. Point at two or three of them. A step that lights half the diagram has not said anything.

Write every word for a smart twelve-year-old: short common words, one idea per line, active voice, things named as the diagram names them, numbers as digits. If a line needs a second read, rewrite it. Words like leverages, orchestrates, asynchronous pipeline and fan-out never belong in a step. This holds in whatever language the document is written in.

The same three steps, written well and written badly. Heading first, then the body after the slash:

Write this Not this
Route now queues the job instead of sending / The API call finishes at once. A worker sends the mail later. Broadcast fan-out moves behind the queue / The API route now enqueues broadcast jobs for asynchronous batch processing instead of sending emails inline.
Postmark now gets 500 emails per call / One call per batch instead of one call per person. Batched delivery replaces single sends / The worker leverages the shared library to send emails in chunks of 500 via Postmark's batch endpoint.
processBroadcast and sendSingleEmail removed / sendBroadcastBulk does their job for whole batches. Single send functions are retired / sendBroadcastBulk replaces processBroadcast and sendSingleEmail to handle bulk deliveries in chunks.

Keep consecutive steps on the same stage together. Every change of stage flies the camera across the canvas, so a tour that alternates between two diagrams spends its time travelling.

The validator checks:

  • Every id you name exists in the document. A flow step you name must belong to the flow the stage shows, because flow step ids are only unique inside their own flow.
  • messages needs a stage that shows a flow. Leave it out when the stage is an architecture view.
  • Step ids are unique within the walkthrough. Two steps minimum, twelve maximum.
  • A stored map never carries a walkthrough. A map describes the system; a walkthrough tells the story of one change.

The field arrived with contract 0.1.1. A CLI older than 0.4.0 does not know it and rejects the whole document as an invented field, so validate with a current one.

Sample traffic on a flow step

A flow step can carry a payload: what travels on it. Only the canvas draws it, in the rail that opens when a reader clicks a step. Nothing in an SVG or a pull request comment changes. Write it when the document is going to a canvas (step 4, canvas push) and leave it out otherwise. Six payloads on the reference document add half its length again, so this is not a field to fill by default.

On a canvas document, add it to a step that moves data: a request body, a job record, a query, a result. Leave it off a step that only signals, such as a trigger with nothing attached.

{
  "id": "batch-post",
  "from": "send-broadcast-bulk",
  "to": "postmark",
  "label": "POST /email/batch",
  "kind": "sync",
  "delta": "added",
  "repeat": 4,
  "payload": {
    "request": {
      "type": "EmailBatch[500]",
      "shape": "Email[]  // max 500\nEmail = { From: string; To: string; Subject: string; HtmlBody: string; MessageStream: \"broadcast\"; Metadata: { campaignId: string; batchId: string } }",
      "sample": [
        {
          "From": "news@example.com",
          "To": "ada@example.com",
          "Subject": "The batching issue, fixed",
          "HtmlBody": "<!doctype html><html><body>…",
          "MessageStream": "broadcast",
          "Metadata": { "campaignId": "cmp_0001", "batchId": "b_0001" }
        }
      ],
      "before": [
        {
          "From": "news@example.com",
          "To": "ada@example.com",
          "Subject": "The batching issue, fixed",
          "HtmlBody": "<!doctype html><html><body>…",
          "Metadata": { "campaignId": "cmp_0001" }
        }
      ],
      "source": { "path": "tests/fixtures/postmark-batch.json" }
    },
    "response": {
      "type": "BatchResult[500]",
      "shape": "SendResult[]  // one per Email, same order",
      "sample": [{ "ErrorCode": 0, "Message": "OK", "To": "ada@example.com", "MessageID": "b7fa5c1e-…" }]
    }
  }
}

A payload has a request side, a response side, or both. Each side has:

  • type: the name a reader of the code would recognise. Put the count in it when the step carries a collection: EmailBatch[500], not EmailBatch. Write { "type": "void" } for a side that carries nothing, such as the answer to a fire and forget call.
  • shape: the type signature as text, taken from the code's own types. Up to 2048 bytes.
  • sample: one exemplar instance after the change, written inline as JSON. It is a JSON value, not a JSON string: "sample": [{ "To": "ada@example.com" }], never "sample": "[{\"To\": ...}]". A string here is rejected. Every key once, one element in any array, long strings cut with an ellipsis. At most 8 levels deep and 4096 bytes once serialised. The parser refuses a sample over either cap rather than trimming it.
  • before: the same exemplar as it was before the change, when it differs. Same rules as sample, and it needs a sample to differ from.
  • source: the fixture or type the shape and sample came from, as a file reference. It becomes the permalink.

Use placeholder values: ada@example.com, cmp_0001. Never copy a value from a fixture that could belong to a real person or unlock something, even in test data.

Do not write changedPaths. The paths that differ between before and sample are worked out when the document is stored. A list you write is discarded.

The field arrived with contract 0.2.0. A CLI built before it rejects the whole document as an invented field, so validate with a current one.

What the validator will catch

Read references/graph-document.md before writing. The four failures that account for nearly everything:

Code What you did
BROKEN_REFERENCE an edge, a flow step, a view or a walkthrough step names an id you never declared
INVALID_DOCUMENT an invented field; the schemas are strict, unknown keys are rejected
DUPLICATE_ID two nodes, edges or views sharing an id
UNSUPPORTED_SCHEMA_VERSION schemaVersion is not the contract version installed

Seven rules cannot be expressed in JSON Schema and are checked only by the parser, so structured output alone does not make a document valid: referential integrity, a line range that ends before it starts, a self message whose endpoints disagree, a patch whose two commits are the same, more views than a render manifest could describe, a walkthrough step focusing flow steps the diagram on its stage does not draw, and sample traffic past its depth or byte caps. Always validate.

Fixing a map instead of writing one

When someone says the diagram is wrong (a node is misnamed, a folder should not be on it, something sits in the wrong lane), do not edit the generated document. It is regenerated on every run. Write the correction into .github/pr-lens.yml, which is an overlay applied over fresh inference every time:

schemaVersion: 0.2.0
map:
  rename:
    - match: functions/src/broadcast/sendBroadcastBulk.ts
      to: Broadcast sender
  exclude:
    - "**/*.test.ts"
  lane:
    - match: packages/broadcast-lib/**
      lane: functions

references/config.md has the full format and the recipes. Validate it the same way: npx @coldtea/pr-lens-cli@latest validate .github/pr-lens.yml.

A match beginning with id: addresses one node exactly; anything else is a path glob matched against a node's file paths. Prefer the glob, because it keeps holding when the next run names the node differently. A lane pin may name a lane the document never declared: the band is created, and takes the id for its label, so give it one a reader would want to see.

pr-lens render says so when a correction matched nothing, which is how a config that has drifted, because the file it named moved or was deleted, becomes visible instead of quietly doing nothing.

What ships with this skill

Everything you need is beside this page. Nothing here asks you to install a package first.

references/graph-document.md the document, field by field: enums, limits, and where documents actually go wrong
references/config.md .github/pr-lens.yml, the correction overlay, in full
references/example.graph.json one complete document that validates, to read and to copy the shape of

The same document ships as postmark-refactor.graph.json in @coldtea/pr-lens-schema, and the JSON Schema the validator enforces is published at https://unpkg.com/@coldtea/pr-lens-schema/json-schema/graph-doc.schema.json. Neither is something you need to fetch to write a document.

Files (pr-lens)
  • references
    • config.md 3.8 KB
      # Correcting the map: `.github/pr-lens.yml`
      
      The generated document is regenerated on every run, so editing it is pointless. Corrections live in `.github/pr-lens.yml`, an overlay applied over fresh inference every time. Inference never writes back into this file, which is why a correction keeps holding as the code moves.
      
      ```yaml
      schemaVersion: 0.2.0          # required
      lenses: [architecture, data-flow]
      branding: true
      map:
        rename:
          - match: functions/src/broadcast/sendBroadcastBulk.ts
            to: Broadcast sender
        exclude:
          - "**/*.test.ts"
          - scripts/**
        lane:
          - match: packages/broadcast-lib/**
            lane: functions
        group:
          - match: id:build-bulk-payload
            group: broadcast-lib
      ```
      
      Every field except `schemaVersion` is optional, and the file itself is optional. For editor autocomplete, point at the published JSON Schema — no install needed:
      
      ```jsonc
      { "$ref": "https://unpkg.com/@coldtea/pr-lens-schema/json-schema/config.schema.json" }
      ```
      
      ## Selectors
      
      A `match` beginning with `id:` addresses exactly one node, as in `id:build-bulk-payload`. Anything else is a repository-relative path glob matched against the node's file paths.
      
      **Prefer the glob.** Ids come from inference and may change when the code does; a path correction survives that. Reach for `id:` only when no path distinguishes the node, or when the node has no files at all (an external service, a queue).
      
      ## The four corrections
      
      | | What it does |
      | --- | --- |
      | `rename` | replaces the inferred label |
      | `exclude` | drops matching nodes, and the edges and flow steps that hung from them |
      | `lane` | moves matching nodes into a lane, **creating it** when the document declares no such id |
      | `group` | clusters matching nodes under a sub-group inside their lane |
      
      Up to 128 of each. They are about intent rather than structure: there is no way to add a node or draw an edge here, and the one thing a correction can bring into existence is a lane, a band a repository wants that inference did not find. It takes the id for its label, because the id is the only name this file carries, so write `lane: infrastructure` rather than `lane: l3`. If the map is wrong in a way corrections cannot express, the fix belongs in the analysis, not in this file.
      
      ## Recipes
      
      **"Stop showing me the test files."**
      ```yaml
      map:
        exclude: ["**/*.test.ts", "**/__tests__/**"]
      ```
      
      **"That node is called the wrong thing."** Match the file it comes from, not its id:
      ```yaml
      map:
        rename:
          - match: server/lib/broadcast/createBroadcastSendTask.ts
            to: Send task
      ```
      
      **"These belong in a band of their own."** The lane need not exist yet:
      ```yaml
      map:
        lane:
          - match: infra/**
            lane: infrastructure
      ```
      
      **"Keep the shared library together."**
      ```yaml
      map:
        group:
          - match: packages/broadcast-lib/**
            group: broadcast-lib
      ```
      
      **"Only draw the architecture."**
      ```yaml
      lenses: [architecture]
      ```
      
      ## Hosted GitHub App comments
      
      The hosted App reads `github` settings from the PR's head commit. Other options apply to the CLI.
      
      | Setting | Default | Effect |
      | --- | --- | --- |
      | `github.comment.collapsed` | `false` | Start diagrams and details closed. Drawing still runs automatically. |
      | `github.draw` | `auto` | `on-demand` leaves a pull request undrawn, with a short notice, until someone comments `@pr-lens draw`. |
      | `github.comment.notice` | `true` | `false` drops that notice, so an on-demand repository hears nothing until someone asks. |
      
      ## Check it
      
      ```bash
      npx @coldtea/pr-lens-cli@latest validate .github/pr-lens.yml
      ```
      
      `pr-lens render` reports any correction that changed nothing about the document it drew. That is a config that has drifted out of date, usually because the file a selector named has moved or gone. It is not an error and nothing stops, but it is worth fixing: a correction that matches nothing is a correction nobody is getting.
      
    • example.graph.json 17.4 KB
      {
        "schemaVersion": "0.2.0",
        "kind": "graph",
        "generatedAt": "2026-08-19T18:24:00.000Z",
        "title": "Batch broadcast sending through Postmark",
        "summary": "Broadcast delivery moves from one Postmark request per recipient to batched requests of 500, with suppression filtering pulled in front of the send and the payload builder extracted into a shared library.",
        "lenses": [
          "architecture",
          "data-flow"
        ],
        "provenance": {
          "repo": {
            "owner": "ohansemmanuel",
            "name": "bestregards",
            "host": "github.com"
          },
          "base": {
            "sha": "3f5c1ab9d24e7f08c6b1a5d3e9074c2b8a6f1d40",
            "ref": "main"
          },
          "head": {
            "sha": "b71e0d4c8a92f5361de7c0b4a8f2593d6c1e8a77",
            "ref": "batch-broadcast-send"
          },
          "pullRequest": {
            "number": 128,
            "title": "Send broadcasts in batches of 500",
            "url": "https://github.com/ohansemmanuel/bestregards/pull/128"
          },
          "generator": {
            "name": "pr-lens-examples",
            "version": "0.1.0"
          }
        },
        "lanes": [
          {
            "id": "web",
            "label": "Next.js",
            "subtitle": "Vercel",
            "order": 0
          },
          {
            "id": "functions",
            "label": "Cloud Functions",
            "subtitle": "Firebase",
            "order": 1
          },
          {
            "id": "external",
            "label": "External",
            "subtitle": "Postmark",
            "order": 2
          }
        ],
        "nodes": [
          {
            "id": "broadcast-composer",
            "label": "Broadcast composer",
            "kind": "ui",
            "delta": "unchanged",
            "lane": "web",
            "subtitle": "app/broadcasts/new",
            "summary": "Where an author writes a broadcast and hits send. Untouched by this change.",
            "files": [
              {
                "path": "app/broadcasts/new/page.tsx"
              }
            ],
            "badges": []
          },
          {
            "id": "queue-route",
            "label": "POST /api/broadcasts/queue",
            "kind": "route",
            "delta": "modified",
            "lane": "web",
            "summary": "Writes the queue document. Now stamps the recipient count and batch size the sender will use instead of leaving batching to the worker.",
            "files": [
              {
                "path": "app/api/broadcasts/queue/route.ts",
                "startLine": 24,
                "endLine": 96
              }
            ],
            "badges": [
              "+38 / -12"
            ]
          },
          {
            "id": "broadcast-queue",
            "label": "broadcastQueue",
            "kind": "datastore",
            "delta": "modified",
            "lane": "functions",
            "subtitle": "Firestore collection",
            "summary": "Queue documents gained batchSize and suppressedCount fields, and results are now written back per batch rather than per recipient.",
            "files": [
              {
                "path": "functions/src/broadcast/schema.ts",
                "startLine": 12,
                "endLine": 48
              }
            ],
            "badges": []
          },
          {
            "id": "send-broadcast-bulk",
            "label": "sendBroadcastBulk",
            "kind": "function",
            "delta": "added",
            "lane": "functions",
            "subtitle": "onWrite trigger",
            "summary": "New trigger handler. Fetches suppressions once, builds batched payloads, and posts them to Postmark in chunks of 500.",
            "files": [
              {
                "path": "functions/src/broadcast/sendBroadcastBulk.ts",
                "startLine": 1,
                "endLine": 142
              }
            ],
            "badges": [
              "new"
            ]
          },
          {
            "id": "build-bulk-payload",
            "label": "buildBulkPayload",
            "kind": "function",
            "delta": "added",
            "lane": "functions",
            "summary": "Turns a broadcast and its recipient slice into a Postmark batch request body.",
            "files": [
              {
                "path": "packages/broadcast-lib/src/buildBulkPayload.ts",
                "startLine": 1,
                "endLine": 74
              }
            ],
            "badges": []
          },
          {
            "id": "get-suppressed-emails",
            "label": "getSuppressedEmails",
            "kind": "function",
            "delta": "added",
            "lane": "functions",
            "summary": "Pulls the Postmark suppression dump once per broadcast so suppressed addresses are filtered before any batch is sent.",
            "files": [
              {
                "path": "packages/broadcast-lib/src/getSuppressedEmails.ts",
                "startLine": 1,
                "endLine": 58
              }
            ],
            "badges": []
          },
          {
            "id": "broadcast-lib",
            "label": "broadcast-lib",
            "kind": "package",
            "delta": "added",
            "lane": "functions",
            "subtitle": "packages/broadcast-lib",
            "summary": "New shared package so the queue route and the sender agree on payload shape and batch size.",
            "files": [
              {
                "path": "packages/broadcast-lib/src/index.ts"
              }
            ],
            "badges": [
              "new package"
            ]
          },
          {
            "id": "process-broadcast",
            "label": "processBroadcast",
            "kind": "function",
            "delta": "removed",
            "lane": "functions",
            "subtitle": "onWrite trigger",
            "summary": "The per-recipient loop this change replaces.",
            "files": [
              {
                "path": "functions/src/broadcast/processBroadcast.ts",
                "startLine": 1,
                "endLine": 118,
                "revision": "base"
              }
            ],
            "badges": []
          },
          {
            "id": "send-single-email",
            "label": "sendSingleEmail",
            "kind": "function",
            "delta": "removed",
            "lane": "functions",
            "summary": "One Postmark request per recipient. Gone with the loop that called it.",
            "files": [
              {
                "path": "functions/src/broadcast/sendSingleEmail.ts",
                "startLine": 1,
                "endLine": 46,
                "revision": "base"
              }
            ],
            "badges": []
          },
          {
            "id": "postmark",
            "label": "Postmark",
            "kind": "external",
            "delta": "modified",
            "lane": "external",
            "subtitle": "Email API",
            "summary": "Same provider, different endpoints: the batch endpoint and the suppression dump replace repeated single sends.",
            "files": [],
            "badges": []
          }
        ],
        "edges": [
          {
            "id": "composer-to-queue",
            "from": "broadcast-composer",
            "to": "queue-route",
            "kind": "http",
            "delta": "unchanged",
            "label": "send broadcast",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "queue-to-firestore",
            "from": "queue-route",
            "to": "broadcast-queue",
            "kind": "data",
            "delta": "modified",
            "label": "enqueue job",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "queue-to-lib",
            "from": "queue-route",
            "to": "broadcast-lib",
            "kind": "dependency",
            "delta": "added",
            "label": "batch size",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "firestore-to-bulk",
            "from": "broadcast-queue",
            "to": "send-broadcast-bulk",
            "kind": "event",
            "delta": "added",
            "label": "onWrite",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "firestore-to-process",
            "from": "broadcast-queue",
            "to": "process-broadcast",
            "kind": "event",
            "delta": "removed",
            "label": "onWrite",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "process-to-single",
            "from": "process-broadcast",
            "to": "send-single-email",
            "kind": "call",
            "delta": "removed",
            "label": "per recipient",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "single-to-postmark",
            "from": "send-single-email",
            "to": "postmark",
            "kind": "http",
            "delta": "removed",
            "label": "POST /email · 1 msg/call",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "bulk-to-payload",
            "from": "send-broadcast-bulk",
            "to": "build-bulk-payload",
            "kind": "call",
            "delta": "added",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "bulk-to-suppressions",
            "from": "send-broadcast-bulk",
            "to": "get-suppressed-emails",
            "kind": "call",
            "delta": "added",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "bulk-to-lib",
            "from": "send-broadcast-bulk",
            "to": "broadcast-lib",
            "kind": "dependency",
            "delta": "added",
            "emphasis": "normal",
            "animated": false,
            "files": []
          },
          {
            "id": "suppressions-to-postmark",
            "from": "get-suppressed-emails",
            "to": "postmark",
            "kind": "http",
            "delta": "added",
            "label": "GET suppression dump",
            "emphasis": "normal",
            "animated": true,
            "files": []
          },
          {
            "id": "bulk-to-postmark",
            "from": "send-broadcast-bulk",
            "to": "postmark",
            "kind": "http",
            "delta": "added",
            "label": "500 msgs/call",
            "emphasis": "hero",
            "animated": true,
            "summary": "The change in one edge: a broadcast to 10,000 recipients drops from 10,000 requests to 20.",
            "files": []
          },
          {
            "id": "bulk-to-firestore",
            "from": "send-broadcast-bulk",
            "to": "broadcast-queue",
            "kind": "data",
            "delta": "added",
            "label": "write results",
            "emphasis": "normal",
            "animated": false,
            "files": []
          }
        ],
        "flows": [
          {
            "id": "send-pipeline",
            "title": "Sending a broadcast",
            "summary": "The path a queued broadcast takes now, from enqueue to per-message results.",
            "delta": "modified",
            "participants": [
              {
                "node": "queue-route",
                "label": "queue route"
              },
              {
                "node": "broadcast-queue",
                "label": "Firestore"
              },
              {
                "node": "send-broadcast-bulk",
                "label": "sendBroadcastBulk"
              },
              {
                "node": "postmark",
                "label": "Postmark"
              }
            ],
            "messages": [
              {
                "id": "enqueue",
                "from": "queue-route",
                "to": "broadcast-queue",
                "label": "enqueue broadcast job",
                "kind": "async",
                "delta": "modified",
                "animated": true,
                "files": []
              },
              {
                "id": "trigger",
                "from": "broadcast-queue",
                "to": "send-broadcast-bulk",
                "label": "onWrite trigger",
                "kind": "async",
                "delta": "added",
                "animated": true,
                "files": []
              },
              {
                "id": "suppressions-request",
                "from": "send-broadcast-bulk",
                "to": "postmark",
                "label": "GET suppression dump",
                "kind": "sync",
                "delta": "added",
                "animated": true,
                "files": []
              },
              {
                "id": "suppressions-response",
                "from": "postmark",
                "to": "send-broadcast-bulk",
                "label": "suppressed addresses",
                "kind": "return",
                "delta": "added",
                "animated": true,
                "note": "Fetched once per broadcast, not once per recipient.",
                "files": []
              },
              {
                "id": "batch-post",
                "from": "send-broadcast-bulk",
                "to": "postmark",
                "label": "POST /email/batch · 500 msgs",
                "kind": "sync",
                "delta": "added",
                "animated": true,
                "repeat": 4,
                "note": "One request per 500 recipients; four for this 2,000-recipient broadcast.",
                "files": []
              },
              {
                "id": "batch-results",
                "from": "postmark",
                "to": "send-broadcast-bulk",
                "label": "per-message results",
                "kind": "return",
                "delta": "added",
                "animated": true,
                "files": []
              },
              {
                "id": "write-results",
                "from": "send-broadcast-bulk",
                "to": "broadcast-queue",
                "label": "write results",
                "kind": "async",
                "delta": "added",
                "animated": true,
                "files": []
              }
            ]
          }
        ],
        "stats": {
          "filesChanged": 14,
          "additions": 486,
          "deletions": 212,
          "chips": [
            {
              "label": "Postmark calls",
              "value": "500× fewer",
              "tone": "hero"
            },
            {
              "label": "New",
              "value": "4 units",
              "tone": "added"
            },
            {
              "label": "Retired",
              "value": "2 units",
              "tone": "removed"
            }
          ]
        },
        "views": [
          {
            "id": "overview",
            "title": "Architecture — blast radius",
            "lens": "architecture",
            "summary": "Everything this change touches, across all three lanes.",
            "scope": {
              "kind": "all"
            },
            "defaultOpen": true,
            "children": [
              {
                "id": "new-batch-path",
                "title": "The new batch path",
                "lens": "architecture",
                "summary": "What replaced the per-recipient loop.",
                "scope": {
                  "kind": "selection",
                  "lanes": [],
                  "nodes": [
                    "send-broadcast-bulk",
                    "build-bulk-payload",
                    "get-suppressed-emails",
                    "broadcast-lib",
                    "postmark"
                  ],
                  "edges": [
                    "bulk-to-payload",
                    "bulk-to-suppressions",
                    "bulk-to-lib",
                    "suppressions-to-postmark",
                    "bulk-to-postmark",
                    "bulk-to-firestore"
                  ],
                  "flows": []
                },
                "defaultOpen": false,
                "children": []
              },
              {
                "id": "retired-path",
                "title": "What was retired",
                "lens": "architecture",
                "summary": "The single-send path, kept visible so a reviewer can confirm nothing else called it.",
                "scope": {
                  "kind": "selection",
                  "lanes": [],
                  "nodes": [
                    "process-broadcast",
                    "send-single-email"
                  ],
                  "edges": [
                    "firestore-to-process",
                    "process-to-single",
                    "single-to-postmark"
                  ],
                  "flows": []
                },
                "defaultOpen": false,
                "children": []
              }
            ]
          },
          {
            "id": "send-pipeline-view",
            "title": "Data flow — sending a broadcast",
            "lens": "data-flow",
            "scope": {
              "kind": "selection",
              "lanes": [],
              "nodes": [],
              "edges": [],
              "flows": [
                "send-pipeline"
              ]
            },
            "defaultOpen": false,
            "children": []
          }
        ],
        "walkthrough": {
          "steps": [
            {
              "id": "batches-of-500",
              "heading": "sendBroadcastBulk and buildBulkPayload added",
              "body": "Nothing loops over recipients any more. The sender works on a whole batch at a time.",
              "stage": {
                "kind": "view",
                "view": "overview"
              },
              "focus": {
                "kind": "selection",
                "lanes": [],
                "nodes": [
                  "send-broadcast-bulk",
                  "build-bulk-payload",
                  "postmark"
                ],
                "edges": [],
                "messages": []
              }
            },
            {
              "id": "suppression-first",
              "heading": "getSuppressedEmails added before the send",
              "body": "It pulls the blocked addresses once, before any batch is built.",
              "stage": {
                "kind": "view",
                "view": "new-batch-path"
              },
              "focus": {
                "kind": "selection",
                "lanes": [],
                "nodes": [
                  "get-suppressed-emails",
                  "postmark"
                ],
                "edges": [],
                "messages": []
              }
            },
            {
              "id": "old-path-goes-dark",
              "heading": "processBroadcast and sendSingleEmail removed",
              "body": "sendBroadcastBulk does their job for whole batches.",
              "stage": {
                "kind": "view",
                "view": "overview"
              },
              "focus": {
                "kind": "selection",
                "lanes": [],
                "nodes": [
                  "process-broadcast",
                  "send-single-email"
                ],
                "edges": [],
                "messages": []
              }
            },
            {
              "id": "sequence-start-to-finish",
              "heading": "The send sequence gained 6 new steps",
              "body": "The queue write is the only step that was there before, and it now stamps the batch size.",
              "stage": {
                "kind": "flow",
                "flow": "send-pipeline"
              },
              "focus": {
                "kind": "all"
              }
            },
            {
              "id": "four-batch-calls",
              "heading": "Postmark now gets 500 emails per call",
              "body": "One call per batch, and Postmark answers with a result for each message.",
              "stage": {
                "kind": "flow",
                "flow": "send-pipeline"
              },
              "focus": {
                "kind": "selection",
                "lanes": [],
                "nodes": [],
                "edges": [],
                "messages": [
                  "batch-post",
                  "batch-results"
                ]
              }
            },
            {
              "id": "blast-radius",
              "heading": "4 parts added, 2 removed, across 3 lanes",
              "body": "A 2,000-person broadcast used to make 2,000 calls to Postmark. It now makes 4.",
              "stage": {
                "kind": "view",
                "view": "overview"
              },
              "focus": {
                "kind": "all"
              }
            }
          ]
        },
        "layout": {
          "direction": "right",
          "laneOrder": [
            "web",
            "functions",
            "external"
          ],
          "rank": {
            "queue-route": 0,
            "send-broadcast-bulk": 1,
            "postmark": 2
          }
        }
      }
      
    • graph-document.md 15 KB
      # Authoring a graph document
      
      This page is the whole shape, and what a schema cannot tell you besides: which parts matter, and where documents actually go wrong. `references/example.graph.json` is one document that validates, if you would rather read than be told.
      
      The validator enforces the same thing from a JSON Schema, published at `https://unpkg.com/@coldtea/pr-lens-schema/json-schema/graph-doc.schema.json` if you want it machine-readable.
      
      Every schema here is **strict**: an unknown key is a rejection, not a warning. A field with a default may be left out.
      
      ## The document
      
      ```json
      {
        "schemaVersion": "0.2.0",
        "kind": "graph",
        "title": "Batch broadcast sending through Postmark",
        "summary": "One paragraph answering: what does this change do?",
        "lenses": ["architecture", "data-flow"],
        "provenance": { "repo": { "owner": "…", "name": "…" }, "base": { "sha": "…" }, "head": { "sha": "…" } },
        "lanes": [],
        "nodes": [],
        "edges": [],
        "flows": [],
        "stats": {},
        "views": []
      }
      ```
      
      `lenses` declares what the document carries enough detail to draw: `architecture`, `data-flow`, or both. A document carrying flows must declare `data-flow`.
      
      `provenance` is where the document came from: the repository, the base and head commit shas (lowercase hex, 7-40 characters), optionally the pull request and the generator. When you produce a document through the CLI these are filled in from the repository, so do not invent them.
      
      ## Ids
      
      `^[A-Za-z0-9][A-Za-z0-9._:/-]*$`, at most 128 characters, unique within their own collection. Use readable kebab-case: `broadcast-sender`, not `n1`. An id ends up in an SVG id, a URL fragment and a comment anchor, so nothing else is allowed through.
      
      ## Deltas
      
      Every node, edge, flow and flow step declares one: `added`, `modified`, `removed`, `unchanged`.
      
      `unchanged` is not padding. It is the neighbouring code the change touches, and it is what turns a diagram into a blast radius. A document whose every element is `added` describes a change nobody can place.
      
      ## Lanes
      
      1 to 16. Every node belongs to exactly one.
      
      ```json
      { "id": "functions", "label": "Cloud Functions", "subtitle": "Node 20", "order": 1 }
      ```
      
      `order` (0-64) places lanes left to right; ties fall back to array order. Give a lane a `delta` only when the lane itself is new or gone.
      
      ## Nodes
      
      1 to 256.
      
      ```json
      {
        "id": "send-broadcast-bulk",
        "label": "sendBroadcastBulk",
        "kind": "function",
        "delta": "added",
        "lane": "functions",
        "group": "broadcast-lib",
        "subtitle": "(broadcastId) => Promise<void>",
        "summary": "Claims the broadcast, builds one bulk payload and posts it.",
        "files": [{ "path": "functions/src/broadcast/sendBroadcastBulk.ts", "startLine": 1, "endLine": 142 }],
        "badges": ["retry"]
      }
      ```
      
      `kind` is one of `service app module function route job queue datastore cache external ui config test package other`. It drives the card's icon and shape and nothing else; when in doubt, `other` still renders.
      
      `group` clusters nodes inside a lane: a package, a folder that means something. `files` (up to 64) become diff permalinks. `badges` (up to 6) are extra chips; the delta badge is drawn for you, so do not restate it.
      
      ## Edges
      
      Up to 512.
      
      ```json
      {
        "id": "bulk-to-postmark",
        "from": "send-broadcast-bulk",
        "to": "postmark",
        "kind": "http",
        "delta": "added",
        "label": "POST /email/bulk",
        "emphasis": "hero",
        "animated": true
      }
      ```
      
      `kind` is one of `call http rpc event queue data dependency render other`. `emphasis` is `normal` (default), `hero` or `muted`. More than one or two heroes and the emphasis stops meaning anything. `from` and `to` must be node ids you declared. This is the single most common failure.
      
      ## Flows
      
      Up to 16, for the data-flow lens.
      
      ```json
      {
        "id": "send-pipeline",
        "title": "Sending a broadcast",
        "delta": "modified",
        "participants": [{ "node": "queue-route" }, { "node": "send-broadcast-bulk" }, { "node": "postmark" }],
        "messages": [
          { "id": "enqueue", "from": "queue-route", "to": "send-broadcast-bulk", "label": "enqueue job", "kind": "async", "delta": "modified" },
          { "id": "send", "from": "send-broadcast-bulk", "to": "postmark", "label": "POST /email/bulk", "kind": "sync", "delta": "added", "repeat": 4 },
          { "id": "accepted", "from": "postmark", "to": "send-broadcast-bulk", "label": "200 Accepted", "kind": "return", "delta": "added" }
        ]
      }
      ```
      
      - 2 to 12 participants, ordered by array position; each names a node id.
      - 1 to 64 messages. **Step order is array order**: there is no step number field, so a document cannot disagree with its own animation.
      - `kind` is `sync`, `async`, `return` or `self`. `self` requires `from === to`, and no other kind may have them equal.
      - Both endpoints must be participants of that flow, not merely nodes of the document.
      - `repeat` says a step happens more than once per run, e.g. 4 batched requests.
      - `payload`: what travels on the step. Optional. Only a canvas draws it, so leave it out of a document that is not going to one.
      
      ### Sample traffic
      
      ```json
      {
        "id": "send",
        "from": "send-broadcast-bulk",
        "to": "postmark",
        "label": "POST /email/bulk",
        "kind": "sync",
        "delta": "added",
        "payload": {
          "request": {
            "type": "EmailBatch[500]",
            "shape": "Email[]  // max 500\nEmail = { From: string; To: string; Subject: string }",
            "sample": [{ "From": "news@example.com", "To": "ada@example.com", "Subject": "The batching issue, fixed" }],
            "before": [{ "From": "news@example.com", "To": "ada@example.com", "Cc": "ops@example.com", "Subject": "The batching issue, fixed" }],
            "source": { "path": "tests/fixtures/postmark-batch.json" }
          },
          "response": { "type": "void" }
        }
      }
      ```
      
      - A payload has `request`, `response` or both. One with neither is rejected.
      - `type` is required on a side: a name a reader of the code would know, with the count in it for a collection (`EmailBatch[500]`). `void` for a side that carries nothing.
      - `shape` is the type signature as text, up to 2048 bytes.
      - `sample` and `before` are JSON values written inline, not JSON strings. A string where a value belongs is rejected. Each is at most 8 levels deep and 4096 bytes once serialised. The parser refuses a value over either cap rather than truncating it.
      - `before` needs a `sample` to differ from.
      - `source` is a file reference, the fixture or type the side was taken from.
      - `changedPaths` is filled in when the document is stored, from `before` and `sample`. Do not write it. Up to 64 paths of the form `Metadata.batchId`, `[0].Cc` or `headers["Content-Type"]`.
      - Use placeholder values in samples: `ada@example.com`, `cmp_0001`. Never one that could belong to a real person or unlock anything.
      
      ## Stats
      
      ```json
      { "filesChanged": 27, "additions": 1979, "deletions": 1370, "chips": [{ "label": "Postmark calls", "value": "500x fewer", "tone": "hero" }] }
      ```
      
      Up to 8 chips, `tone` one of `neutral added modified removed hero`. Per-delta element counts are deliberately absent from the schema: they are derivable from the document, and a stored copy can only go stale.
      
      ## Views
      
      The drill-down tree in the comment: up to 32 at the root, nesting up to 32 children each. A document with no views renders as one picture and nothing else.
      
      ```json
      {
        "id": "the-new-path",
        "title": "The new batch path",
        "lens": "architecture",
        "summary": "What replaced the per-recipient loop.",
        "defaultOpen": false,
        "scope": { "kind": "selection", "nodes": ["send-broadcast-bulk", "postmark"] },
        "children": []
      }
      ```
      
      `scope` is either `{ "kind": "all" }` (the default) or a selection naming at least one lane, node, edge or flow. The two are distinct states on purpose: removing the last element a view pointed at can never quietly turn it into a view of everything. A view's `lens` must be one the document declares.
      
      ### Choosing architecture views
      
      Treat the architecture tree as a set of decisions, not a quota:
      
      1. Ask whether the change affects a user, an external system or a system boundary. If it does, start with a system-context view. If it does not, leave that level out.
      2. Show affected applications, services, jobs, data stores and runtimes in a container view. Make it the root when there is no useful context view; otherwise make it a child of that context.
      3. Add a component child only when the internals of an affected container matter to the change. Components may be modules, routes or functions, but the view should explain their responsibilities and relationships rather than mirror folders.
      4. Stop at components unless someone explicitly asks for code-level detail.
      
      One architecture view may be the right answer for a small change. Each child must move down exactly one level and cover a materially narrower scope. Skip a level when it would be empty, speculative or a repeat of its parent. Do not create two views with substantially the same nodes and edges, and do not infer a boundary from a folder name alone. Keep unchanged direct neighbours when they make the blast radius clear.
      
      Set `defaultOpen: true` on the highest useful architecture view. Lower levels should normally stay collapsed. A data-flow view describes an ordered sequence, so keep it as a separate root instead of placing it inside the architecture hierarchy.
      
      This compact fragment shows the shape. The selected ids refer to elements declared elsewhere in the document:
      
      ```json
      {
        "views": [
          {
            "id": "checkout-context",
            "title": "Checkout in its environment",
            "lens": "architecture",
            "defaultOpen": true,
            "scope": {
              "kind": "selection",
              "nodes": ["shopper", "commerce-platform", "payment-provider", "fulfilment-system"],
              "edges": ["shopper-to-commerce", "commerce-to-payment", "commerce-to-fulfilment"]
            },
            "children": [
              {
                "id": "checkout-containers",
                "title": "Checkout containers",
                "lens": "architecture",
                "scope": {
                  "kind": "selection",
                  "nodes": ["storefront", "checkout-api", "orders-db", "payment-provider"],
                  "edges": ["storefront-to-checkout", "checkout-to-orders", "checkout-to-payment"]
                },
                "children": [
                  {
                    "id": "checkout-components",
                    "title": "Checkout API components",
                    "lens": "architecture",
                    "scope": {
                      "kind": "selection",
                      "nodes": ["checkout-route", "order-service", "payment-client"],
                      "edges": ["route-to-orders", "orders-to-payment-client"]
                    }
                  }
                ]
              }
            ]
          },
          {
            "id": "place-order-flow",
            "title": "Placing an order",
            "lens": "data-flow",
            "scope": { "kind": "selection", "flows": ["place-order"] }
          }
        ]
      }
      ```
      
      ## Walkthrough
      
      Optional in the format, but write one for anything that is not trivial: more than one diagram, a diagram with several changed parts, or any flow. Skip it only when the document is one small diagram whose single step would just repeat the title. A canvas or a share page plays it.
      
      ```json
      {
        "walkthrough": {
          "steps": [
            {
              "id": "four-batch-calls",
              "heading": "Postmark now gets 500 emails per call",
              "body": "One call per batch, and Postmark answers with a result for each message.",
              "stage": { "kind": "flow", "flow": "send-pipeline" },
              "focus": { "kind": "selection", "messages": ["batch-post", "batch-results"] }
            },
            {
              "id": "blast-radius",
              "heading": "4 parts added, 2 removed, across 3 lanes",
              "body": "A 2,000-person broadcast used to make 2,000 calls to Postmark. It now makes 4.",
              "stage": { "kind": "view", "view": "overview" },
              "focus": { "kind": "all" }
            }
          ]
        }
      }
      ```
      
      A walkthrough is a short guided tour of the diagrams. It has two to twelve steps. Each step shows one diagram, points at one part of it, and says a few words about it.
      
      Every step is one change, never a description of the diagram: the heading names the thing and what happened to it, built from change words such as added, removed, replaced, now, moved and split, and the body is one line on what that means for behaviour, with the numbers when they matter. The headline change is step one. Write it all for a smart twelve-year-old, in short common words and active voice. The skill page has the rule in full, with examples of a step written well and the same step written badly.
      
      Each step has:
      
      - `heading`: the thing and what happened to it, up to 48 characters, in sentence case. For example "Postmark now gets 500 emails per call".
      - `body`: one line under the heading, up to 140 characters, on what the change means for behaviour. For example "One call per batch instead of one call per person". Required: a heading with no body reads as unfinished.
      - `stage`: which diagram to show. A document can have several diagrams: its views (the drill-down diagrams) and its flows (the sequence diagrams). `{ "kind": "view", "view": "overview" }` shows the view called `overview`. `{ "kind": "flow", "flow": "send-pipeline" }` shows the flow called `send-pipeline`. Leave `stage` out and the step uses the diagram the reader is already on.
      - `focus`: what to zoom in on inside that diagram. `{ "kind": "all" }`, the default, means the whole diagram. A selection means "just these things": name any lanes, nodes, edges or flow steps (`messages`) by id, and the camera zooms to them while everything else dims. A selection must name at least one thing.
      
      The validator checks:
      
      - Every id you name exists in the document. A flow step you name must belong to the flow the stage shows, because flow step ids are only unique inside their own flow.
      - `messages` needs a stage that shows a flow. Leave it out when the stage is an architecture view.
      - Step ids are unique within the walkthrough. Two steps minimum, twelve maximum.
      - A stored map never carries a walkthrough. A map describes the system; a walkthrough tells the story of one change.
      
      ## Layout
      
      ```json
      { "direction": "right", "laneOrder": ["api", "functions", "external"], "rank": { "send-broadcast-bulk": 2 } }
      ```
      
      Hints, not instructions: the renderer owns final placement, so a diagram stays deterministic and a stale hint cannot break it. Absolute coordinates are not expressible. Omitting `layout` entirely is normal.
      
      ## File references
      
      ```json
      { "path": "functions/src/broadcast/sendBroadcastBulk.ts", "startLine": 1, "endLine": 142, "revision": "head" }
      ```
      
      Repository-relative POSIX paths: no leading `/`, no drive letter, no backslash, no `..` segment. Lines are 1-based, `endLine` requires `startLine` and may not precede it. `revision` defaults to `head`; use `base` on elements the change removes.
      
      ## Length limits
      
      Labels 120 characters, summaries 2000, chip values 32. They are display fields: a label that needs 120 characters is a label the diagram cannot draw. On a payload side, `shape` 2048 bytes, `sample` and `before` 4096 bytes each once serialised and 8 levels deep, `changedPaths` 64 entries.
      
      ## Then validate
      
      ```bash
      npx @coldtea/pr-lens-cli@latest validate .pr-lens/graph.json
      ```
      
      Every problem is reported at once, with a path into the document. Fix them all and run it again until it is clean.
      
  • scripts
    • mirror.ts 1.8 KB
      import { fileURLToPath } from "node:url";
      import { dirname, join } from "node:path";
      import { readdir } from "node:fs/promises";
      
      const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
      
      /** The files under `packages/agent-skill` that every other copy follows. */
      export const SKILL_SOURCE_DIR = packageRoot;
      
      /**
       * The copy `npx skills add coldteadotai/pr-lens` installs. The installer
       * reaches `skills/<name>/` before any SKILL.md nested deeper, then copies that
       * folder whole. So this directory may hold nothing a user should not receive:
       * a package.json, a tsconfig, or a test file placed here lands in their
       * repository, and a test file lands where their own runner will try to run it.
       */
      export const SKILL_MIRROR_DIR = join(packageRoot, "..", "..", "skills", "pr-lens");
      
      const REFERENCES = "references";
      
      /**
       * What a user receives, as paths relative to a skill root. The reference pages
       * are read from disk rather than listed here, so adding one carries it into
       * the mirror without anyone having to remember this file.
       */
      export const skillFiles = async (root: string): Promise<readonly string[]> => {
        const references = await readdir(join(root, REFERENCES));
      
        return ["LICENSE", "SKILL.md", ...references.sort().map((name) => `${REFERENCES}/${name}`)];
      };
      
      /** Every file actually present under `dir`, relative and slash-separated. */
      export const filesPresent = async (dir: string, prefix = ""): Promise<readonly string[]> => {
        const entries = await readdir(dir, { withFileTypes: true });
      
        const found = await Promise.all(
          entries.map(async (entry) => {
            const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
            return entry.isDirectory() ? filesPresent(join(dir, entry.name), path) : [path];
          }),
        );
      
        return found.flat().sort();
      };
      
    • sync.ts 2.1 KB
      /**
       * Rewrites the root-level `skills/pr-lens/` from this package, which is the
       * only copy the skills.sh installer hands a user. A drift test fails until
       * this has been run, so the two never disagree.
       *
       *   pnpm skill:sync
       */
      import { dirname, join } from "node:path";
      import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
      import { filesPresent, skillFiles, SKILL_MIRROR_DIR, SKILL_SOURCE_DIR } from "./mirror.js";
      
      const CLI_INVOCATION = "npx @coldtea/pr-lens-cli@latest";
      
      const forBundledCli = (content: string): string =>
        content.replaceAll(CLI_INVOCATION, "pr-lens");
      
      const wanted = await skillFiles(SKILL_SOURCE_DIR);
      
      await rm(SKILL_MIRROR_DIR, { recursive: true, force: true });
      
      for (const file of wanted) {
        const destination = join(SKILL_MIRROR_DIR, file);
        await mkdir(dirname(destination), { recursive: true });
        await cp(join(SKILL_SOURCE_DIR, file), destination);
      }
      
      const embeddedSkillPath = join(SKILL_SOURCE_DIR, "..", "cli", "src", "skill-content.generated.ts");
      const [manual, config, graphDocument, exampleDocument, graphDocumentJsonSchema] = await Promise.all([
        readFile(join(SKILL_SOURCE_DIR, "SKILL.md"), "utf8"),
        readFile(join(SKILL_SOURCE_DIR, "references", "config.md"), "utf8"),
        readFile(join(SKILL_SOURCE_DIR, "references", "graph-document.md"), "utf8"),
        readFile(join(SKILL_SOURCE_DIR, "references", "example.graph.json"), "utf8"),
        readFile(join(SKILL_SOURCE_DIR, "..", "schema", "json-schema", "graph-doc.schema.json"), "utf8"),
      ]);
      
      await writeFile(
        embeddedSkillPath,
        [
          "// Generated by `pnpm skill:sync`; edit packages/agent-skill instead.",
          `export const SKILL_MANUAL = ${JSON.stringify(forBundledCli(manual))};`,
          `export const CONFIG_REFERENCE = ${JSON.stringify(forBundledCli(config))};`,
          `export const GRAPH_DOCUMENT_REFERENCE = ${JSON.stringify(forBundledCli(graphDocument))};`,
          `export const EXAMPLE_GRAPH_DOCUMENT = ${JSON.stringify(exampleDocument)};`,
          `export const GRAPH_DOCUMENT_JSON_SCHEMA = ${JSON.stringify(graphDocumentJsonSchema)};`,
          "",
        ].join("\n"),
        "utf8",
      );
      
      console.log((await filesPresent(SKILL_MIRROR_DIR)).map((file) => `  ${file}`).join("\n"));
      
  • test
    • mirror.test.ts 1.2 KB
      import { join } from "node:path";
      import { expect, test } from "vitest";
      import { readFile } from "node:fs/promises";
      import { filesPresent, skillFiles, SKILL_MIRROR_DIR, SKILL_SOURCE_DIR } from "../scripts/mirror.js";
      
      test("the skill a user installs holds every source file and nothing else", async () => {
        const wanted = [...(await skillFiles(SKILL_SOURCE_DIR))].sort();
      
        expect(await filesPresent(SKILL_MIRROR_DIR)).toEqual(wanted);
      });
      
      test("every installed file is byte-identical to the one it came from", async () => {
        for (const file of await skillFiles(SKILL_SOURCE_DIR)) {
          const [source, mirrored] = await Promise.all([
            readFile(join(SKILL_SOURCE_DIR, file)),
            readFile(join(SKILL_MIRROR_DIR, file)),
          ]);
      
          expect(mirrored.equals(source), `${file} is stale; run pnpm skill:sync`).toBe(true);
        }
      });
      
      test("no packaging a user's own tooling would trip over ships with the skill", async () => {
        const installed = await filesPresent(SKILL_MIRROR_DIR);
      
        expect(installed).not.toContain("package.json");
        expect(installed).not.toContain("tsconfig.json");
        expect(installed).not.toContain("vitest.config.ts");
        expect(installed.some((file) => file.endsWith(".test.ts"))).toBe(false);
      });
      
    • skill.test.ts 4.6 KB
      import {
        DELTAS,
        EdgeEmphasis,
        EdgeKind,
        LENSES,
        MessageKind,
        NodeKind,
        parseConfig,
        parseGraphDoc,
        SCHEMA_VERSION,
      } from "@coldtea/pr-lens-schema";
      import { applyCorrections } from "@coldtea/pr-lens-renderer";
      import {
        minimalGraph,
        postmarkRefactorGraph,
      } from "@coldtea/pr-lens-schema/examples";
      import { access, readFile } from "node:fs/promises";
      import { expect, test } from "vitest";
      import { parse } from "yaml";
      
      const read = (name: string) =>
        readFile(new URL(`../${name}`, import.meta.url), "utf8");
      
      const skill = await read("SKILL.md");
      const graphGuide = await read("references/graph-document.md");
      const configGuide = await read("references/config.md");
      
      const fenced = (source: string, language: string): string[] =>
        [
          ...source.matchAll(
            new RegExp("```" + language + "\\n([\\s\\S]*?)```", "g"),
          ),
        ].flatMap((match) => (match[1] === undefined ? [] : [match[1]]));
      
      test("the skill declares a name and the situations it is for", () => {
        const frontmatter = /^---\n([\s\S]*?)\n---/.exec(skill)?.[1];
        expect(frontmatter).toBeDefined();
      
        const declared: unknown = parse(frontmatter ?? "");
        expect(declared).toMatchObject({ name: "pr-lens" });
        expect(declared).toHaveProperty("description");
      });
      
      test("every reference the skill sends an agent to ships beside it", async () => {
        const referenced = [...skill.matchAll(/references\/[\w.-]+/g)].map(
          (match) => match[0],
        );
      
        expect(referenced.length).toBeGreaterThan(0);
        for (const path of new Set(referenced)) {
          await expect(
            access(new URL(`../${path}`, import.meta.url)),
          ).resolves.toBeUndefined();
        }
      });
      
      /**
       * `npx skills add` copies the skill folder and nothing else, so a page that
       * sends its reader into node_modules sends them somewhere that install never
       * creates. The packages may be named — an agent that happens to have them is
       * welcome to read them — but never as a path to open.
       */
      test("the skill names no file outside the folder a user installs", () => {
        for (const page of [skill, graphGuide, configGuide]) {
          expect(page).not.toContain("node_modules/");
        }
      });
      
      test("the worked example the skill ships is the contract's own, and it validates", async () => {
        const shipped: unknown = JSON.parse(
          await read("references/example.graph.json"),
        );
      
        expect(() => parseGraphDoc(shipped)).not.toThrow();
        expect(shipped).toEqual(postmarkRefactorGraph);
      });
      
      test("every config the pages teach is a config the contract accepts", () => {
        const configs = [
          ...fenced(skill, "yaml"),
          ...fenced(configGuide, "yaml"),
        ].filter((block) => block.includes("schemaVersion"));
      
        expect(configs.length).toBeGreaterThan(0);
        for (const config of configs)
          expect(() => parseConfig(parse(config))).not.toThrow();
      });
      
      test("the enums quoted to an agent are the enums the contract implements", () => {
        const quoted = (values: readonly string[]) => values.join(" ");
      
        expect(graphGuide).toContain(quoted(NodeKind.options));
        expect(graphGuide).toContain(quoted(EdgeKind.options));
        for (const value of [
          ...EdgeEmphasis.options,
          ...MessageKind.options,
          ...DELTAS,
        ])
          expect(graphGuide).toContain(`\`${value}\``);
      
        for (const lens of LENSES) expect(skill).toContain(lens);
      });
      
      test("the lane rule the pages teach is the lane rule the renderer implements", () => {
        const declared = minimalGraph.lanes.map((lane) => lane.id);
        const corrected = applyCorrections(minimalGraph, {
          rename: [],
          exclude: [],
          lane: [
            {
              match: `id:${minimalGraph.nodes[0]?.id ?? ""}`,
              lane: "infrastructure",
            },
          ],
          group: [],
        });
      
        expect(declared).not.toContain("infrastructure");
        expect(corrected.lanes.map((lane) => lane.id)).toContain("infrastructure");
        expect(
          corrected.lanes.find((lane) => lane.id === "infrastructure")?.label,
        ).toBe("infrastructure");
      
        expect(configGuide).toContain("creating it");
        expect(skill).toContain("may name a lane the document never declared");
      });
      
      test("the skill counts the parser-only rules the contract counts", async () => {
        const counted = (page: string): string | undefined =>
          /\b(\w+) rules cannot be (?:stated|expressed) in JSON Schema\b/
            .exec(page)?.[1]
            ?.toLowerCase();
      
        const contract = await read("../schema/README.md");
      
        expect(counted(skill)).toBeDefined();
        expect(counted(contract)).toBe(counted(skill));
      });
      
      test("the contract version the pages tell an agent to write is the one that ships", () => {
        for (const page of [skill, graphGuide, configGuide]) {
          for (const version of page.match(
            /schemaVersion["']?\s*[:=]\s*["']?([\d.]+)/g,
          ) ?? []) {
            expect(version).toContain(SCHEMA_VERSION);
          }
        }
      });
      
  • LICENSE 1 KB · in bundle
  • package.json 1.1 KB
    {
      "name": "@coldtea/pr-lens-agent-skill",
      "version": "0.3.0",
      "description": "The PR Lens skill for coding agents: author a graph document from a diff, validate it, render it, and correct a repository's map.",
      "license": "MIT",
      "author": "Coldtea AI",
      "repository": {
        "type": "git",
        "url": "git+https://github.com/coldteadotai/pr-lens.git",
        "directory": "packages/agent-skill"
      },
      "homepage": "https://github.com/coldteadotai/pr-lens/tree/main/packages/agent-skill#readme",
      "keywords": [
        "pr-lens",
        "agent-skill",
        "claude-code",
        "cursor",
        "code-review",
        "architecture-diagram"
      ],
      "type": "module",
      "files": [
        "SKILL.md",
        "references",
        "README.md",
        "LICENSE"
      ],
      "publishConfig": {
        "access": "public"
      },
      "scripts": {
        "skill:sync": "tsx scripts/sync.ts",
        "test": "vitest run",
        "typecheck": "tsc -p tsconfig.json --noEmit"
      },
      "devDependencies": {
        "@coldtea/pr-lens-renderer": "workspace:^",
        "@coldtea/pr-lens-schema": "workspace:^",
        "@types/node": "^20.19.0",
        "tsx": "4.23.12",
        "typescript": "7.0.2",
        "vitest": "4.1.11",
        "yaml": "^2.8.1"
      }
    }
    
  • README.md 1.8 KB
    # @coldtea/pr-lens-agent-skill
    
    The PR Lens skill for coding agents. It teaches an agent to draw the change it just made: author a graph document from the diff, validate it against the contract, render it, attach it to the pull request, and to fix a repository's map by writing corrections rather than editing generated output.
    
    MIT © Coldtea AI.
    
    ## Install it
    
    ```bash
    npm install --save-dev @coldtea/pr-lens-agent-skill
    ```
    
    **Claude Code**: copy it where skills live, per project or per user:
    
    ```bash
    mkdir -p .claude/skills/pr-lens
    cp -R node_modules/@coldtea/pr-lens-agent-skill/{SKILL.md,references} .claude/skills/pr-lens/
    ```
    
    **Cursor**: the same file works as a rule:
    
    ```bash
    mkdir -p .cursor/rules
    cp node_modules/@coldtea/pr-lens-agent-skill/SKILL.md .cursor/rules/pr-lens.mdc
    ```
    
    **Anything else**: point your agent's instructions file at `SKILL.md`. It is plain markdown with YAML frontmatter, and it assumes nothing beyond a shell and `npx`.
    
    ## What is in it
    
    | | |
    | --- | --- |
    | `SKILL.md` | when to reach for PR Lens, and the write → validate → fix → render loop |
    | `references/graph-document.md` | the document, field by field, and what the validator will catch |
    | `references/config.md` | `.github/pr-lens.yml` corrections, with recipes |
    
    The agent is usually the model. Rather than spending a provider key to describe a diff it already understands, it writes the document itself and lets `pr-lens validate` hold it to the contract. Every failure is a path into the document, so the loop closes without a human in it.
    
    ## Why this exists
    
    A coding agent that opens a pull request is asking a person to review code the person did not write. A diagram of what moved is the cheapest thing the agent can add to make that review possible.
    
    ---
    
    Part of [PR Lens](https://prlens.dev). Review what actually matters.
    
  • SKILL.md 20.5 KB
    ---
    name: pr-lens
    description: "WHAT: Draws a code change or part of a codebase as an animated architecture or data-flow diagram, on its own or in a pull request. WHEN: asked to diagram, visualise or explain a change or a system, or when a pull request should carry a diagram. KEYWORDS: PR Lens, diagram, architecture, data flow, visualise, visualize, pull request"
    ---
    
    # PR Lens
    
    PR Lens draws code as visually rich animated diagrams. It can represent diffs, architecture, data flows, and more.
    
    The diff or code is represented as one JSON document (lanes, nodes, edges, ordered flows) and it renders the JSON as an animated SVG
    
    ## Operating manual
    
    Decide where the diagram lands before you write it: a canvas, or an SVG and a pull request comment. Only a canvas draws `payload`, the sample request and response on a flow step. A late decision costs another pass through steps 2 and 3.
    
    1. **Read the diff.** When asked to represent a code change: `git diff --find-renames <base>...<head>`. The base is the merge base, not the tip of the base branch.
    
       If not expressing a code diff, read the code to be visually represented
    
    2. **Write the document** to `.pr-lens/graph.json`, following `references/graph-document.md`. `references/example.graph.json` is valid reference with three lanes, all four delta states, a hero edge, a seven-step flow, a nested drill-down tree and a six-step walkthrough. Read it before you write your first one. It is quicker than reading the reference. If it is going to a canvas, give every flow step (`messages`) that moves data a `payload` as you write it. "Sample traffic on a flow step" below says what goes in one. Only a flow step carries one. Flows need the `data-flow` lens, so an architecture view draws none.
    
    3. **Validate, and fix**
    
       ```bash
       npx @coldtea/pr-lens-cli@latest validate .pr-lens/graph.json
       ```
    
       Fix every failure and run it again. Do not render an invalid document; do not "work around" a failure by deleting the element it names.
    
    4. **Render.**
    
       ```bash
       npx @coldtea/pr-lens-cli@latest render .pr-lens/graph.json --theme light
       ```
    
       Render light by default unless the user requests another theme. The SVGs, the manifest and `drawn.graph.json` land in `.pr-lens/`, which the CLI adds to the repository's .gitignore. Do not commit any of it. These files are rebuilt from the diff whenever anyone wants them again. Each SVG is named after its view, the theme and a content hash; `manifest.json` lists them by lens and view, so read the names from there or from the directory.
    
       If the user asked for a diagram, an explanation or a picture of the architecture and nothing more, put it on a canvas and hand back the link:
    
       ```bash
       npx @coldtea/pr-lens-cli@latest canvas push
       ```
    
       This pushes `.pr-lens/drawn.graph.json` and prints three links. Give the user the view link, `https://prlens.dev/c/{id}`: that is the diagram, full screen, every view on one page, and it opens without a login. The edit link, the one ending in `#w=…`, lets its holder push over the canvas, so leave it out of the reply unless they ask, and never paste it anywhere public. The embed link serves the top view as an SVG for a README.
    
       Pushing the same file again updates the same canvas, so a follow-up such as "rename that node" or "add the queue" is: edit the document, validate, render, push. The link stays the same. If the push fails, say so and tell them where the SVGs are and which one is the top view.
    
    5. **Attach, when there is a pull request to attach to.** That means the user asked you to open a PR, asked for a diagram on one that exists, or you are opening a PR as part of changes made. Otherwise skip this step.
    
       GitHub CLI uploads the diagram with the pull request. Write the body with a Markdown image pointing at the local file, then pass the same path to `--attach`. `gh` rewrites the reference to the uploaded asset and keeps the alt text you wrote:
    
       ```markdown
       Moves bulk sending off the per-recipient trigger and onto a batch endpoint.
    
       ![Architecture after this change: the queue route, the new bulk sender and the retired per-recipient path](.pr-lens/overview-light-4f9bd6c1.svg)
       ```
    
       ```bash
       gh pr create --title "Batch broadcast sends" --body-file .pr-lens/body.md \
         --attach .pr-lens/overview-light-4f9bd6c1.svg
       ```
    
       On a pull request that already exists, `gh pr edit <number>` with the same two flags puts the diagram in the description, and `gh pr comment <number>` puts it in a comment. Repeat `--attach` for each diagram the body references.
    
       gh has three rules:
       - The reference has to be a Markdown image, `![alt](path)`. An HTML `<img>` or `<picture>` is left as written, and the file is appended at the bottom of the body instead.
       - The alt text is the caption a reader without images gets. Say what the diagram shows, in one line.
       - `--attach` arrived in GitHub CLI 2.99. Check with `gh --version` before you write a body around it.
    
       Attach the views a reviewer needs and leave the rest in `.pr-lens/`: the top architecture view first, then a data flow if the change has a sequence worth following. A body with four diagrams reads worse than one with two, except the four are really needed to understand the change e.g., in the case of a complex feature or refactor.
    
       When `--attach` is not an option, publish the SVGs somewhere durable and let the CLI compose the comment instead:
    
       ```bash
       npx @coldtea/pr-lens-cli@latest comment \
         --graph .pr-lens/drawn.graph.json \
         --manifest .pr-lens/manifest.json \
         --asset-base-url https://raw.githubusercontent.com/<owner>/<repo>/<branch>/<dir>
       ```
    
       `--graph` takes `drawn.graph.json`, not the document you wrote, because corrections change what the diagrams show and the CLI refuses a document its manifest does not describe. `--asset-base-url` is where you published the SVGs; leave it out and the markdown points at local paths no reader can fetch. The markdown goes to stdout, with each diagram as a `<picture>` pair; posting it is your business.
    
    If you would rather not author the document yourself, `npx @coldtea/pr-lens-cli@latest analyze --base <ref>` does steps 1 and 2 by asking a provider — Gemini, OpenAI, or any endpoint speaking `/chat/completions` — with a key of your own. That is the only path here that needs one.
    
    ## The pull request body, when there is one
    
    A reviewer should understand the change before reading the diff, so the diagram goes where they look first: the description, not a trailing comment. Open with one sentence on why the change exists, then the architecture diagram, then whatever proves the change works, such as a screenshot of the result or a recording of the interaction. Use one visual per idea. A diagram that needs a paragraph of explanation has a document problem; go back to step 2.
    
    ## What makes a document worth reading
    
    - **Include what did not change.** A diagram of only the changed nodes says nothing about blast radius. The unchanged neighbours a change touches are the context; mark them `delta: "unchanged"`.
    - **Lanes are the reader's mental model** (a runtime, a tier, a boundary), not the folder tree.
    - **One hero edge**, two at the outside: the connection the change is really about.
    - **Add a flow only when there is a sequence** worth animating. One good flow beats three thin ones.
    - **Attach file refs**: they become the permalinks a reviewer clicks.
    - **There is no findings lens.** PR Lens is the comprehension layer, not another review bot. There is no field for a bug, a risk or a security note, and a document that invents one is rejected rather than trimmed.
    
    ## Choosing architecture views
    
    Treat architecture views as a C4-inspired decision tree, not a checklist. One useful view is enough for a small change. Start with system context when the change affects a user, an external system or a system boundary. Use a container view for the affected applications, services, jobs, data stores and runtimes. Add a component child only when an affected container's internals matter. Do not add code-level views by default.
    
    Every child moves down one level and covers a materially narrower scope. Skip empty, repetitive or speculative levels, and do not infer architecture from folder names alone. Two views should not carry substantially the same nodes and edges. Keep the unchanged direct neighbours that explain blast radius.
    
    Keep data-flow views as separate roots rather than nesting them in the architecture tree. Set `defaultOpen: true` on the highest useful architecture view. Lower levels should normally keep the default, `false`.
    
    ## Writing a walkthrough
    
    A walkthrough is a short guided tour of the diagrams. It has two to twelve steps. Each step shows one diagram, points at one part of it, and says a few words about it. A canvas plays it, and the reader scrolls through it.
    
    The contract leaves a walkthrough optional. Write one anyway for anything that is not trivial: more than one diagram, a diagram with several changed parts, or any flow. Skip it only when the document is one small diagram whose single step would just repeat the title.
    
    Aim for three to seven steps.
    
    A walkthrough is the fastest read of a pull request. Each step is one change: something added, changed, removed or moved, in the order a reviewer needs it. A step is never a description of the diagram.
    
    What counts as a step: a behaviour change, an API change, an architecture change, a data-flow change, or an addition. Unchanged parts appear only where a step needs them to make sense. The headline change is step one. An overview of everything touched, if there is one, is the last step.
    
    ```json
    "walkthrough": {
      "steps": [
        {
          "id": "four-batch-calls",
          "heading": "Postmark now gets 500 emails per call",
          "body": "One call per batch, and Postmark answers with a result for each message.",
          "stage": { "kind": "flow", "flow": "send-pipeline" },
          "focus": { "kind": "selection", "messages": ["batch-post", "batch-results"] }
        },
        {
          "id": "blast-radius",
          "heading": "4 parts added, 2 removed, across 3 lanes",
          "body": "A 2,000-person broadcast used to make 2,000 calls to Postmark. It now makes 4.",
          "stage": { "kind": "view", "view": "overview" }
        }
      ]
    }
    ```
    
    Each step has:
    
    - `heading`: the thing and what happened to it, up to 48 characters, in sentence case. Build it from change words: added, removed, replaced, now, moved, split. If a heading could have been true before the pull request, it is not a change heading.
    - `body`: one line under the heading, up to 140 characters, on what the change means for behaviour: what happens now that did not before, or what stops happening, with the numbers when they matter. Not a restatement of the heading, and not a description of the code. A heading with no body reads as unfinished, so the body is required.
    - `stage`: which diagram to show. A document can have several diagrams: its views (the drill-down diagrams) and its flows (the sequence diagrams). `{ "kind": "view", "view": "overview" }` shows the view called `overview`. `{ "kind": "flow", "flow": "send-pipeline" }` shows the flow called `send-pipeline`. Leave `stage` out and the step uses the diagram the reader is already on. Open on the widest view with the focus left out, so the reader sees the whole thing before it narrows.
    - `focus`: what to zoom in on inside that diagram. `{ "kind": "all" }`, the default, means the whole diagram. A selection means "just these things": name any lanes, nodes, edges or flow steps (`messages`) by id, and the camera zooms to them while everything else dims. Focus the elements the step's change touched, so the veil lights the change. Point at two or three of them. A step that lights half the diagram has not said anything.
    
    Write every word for a smart twelve-year-old: short common words, one idea per line, active voice, things named as the diagram names them, numbers as digits. If a line needs a second read, rewrite it. Words like leverages, orchestrates, asynchronous pipeline and fan-out never belong in a step. This holds in whatever language the document is written in.
    
    The same three steps, written well and written badly. Heading first, then the body after the slash:
    
    | Write this | Not this |
    | -------- | -------- |
    | Route now queues the job instead of sending / The API call finishes at once. A worker sends the mail later. | Broadcast fan-out moves behind the queue / The API route now enqueues broadcast jobs for asynchronous batch processing instead of sending emails inline. |
    | Postmark now gets 500 emails per call / One call per batch instead of one call per person. | Batched delivery replaces single sends / The worker leverages the shared library to send emails in chunks of 500 via Postmark's batch endpoint. |
    | processBroadcast and sendSingleEmail removed / sendBroadcastBulk does their job for whole batches. | Single send functions are retired / sendBroadcastBulk replaces processBroadcast and sendSingleEmail to handle bulk deliveries in chunks. |
    
    Keep consecutive steps on the same stage together. Every change of stage flies the camera across the canvas, so a tour that alternates between two diagrams spends its time travelling.
    
    The validator checks:
    
    - Every id you name exists in the document. A flow step you name must belong to the flow the stage shows, because flow step ids are only unique inside their own flow.
    - `messages` needs a stage that shows a flow. Leave it out when the stage is an architecture view.
    - Step ids are unique within the walkthrough. Two steps minimum, twelve maximum.
    - A stored map never carries a walkthrough. A map describes the system; a walkthrough tells the story of one change.
    
    The field arrived with contract 0.1.1. A CLI older than 0.4.0 does not know it and rejects the whole document as an invented field, so validate with a current one.
    
    ## Sample traffic on a flow step
    
    A flow step can carry a `payload`: what travels on it. Only the canvas draws it, in the rail that opens when a reader clicks a step. Nothing in an SVG or a pull request comment changes. Write it when the document is going to a canvas (step 4, `canvas push`) and leave it out otherwise. Six payloads on the reference document add half its length again, so this is not a field to fill by default.
    
    On a canvas document, add it to a step that moves data: a request body, a job record, a query, a result. Leave it off a step that only signals, such as a trigger with nothing attached.
    
    ```json
    {
      "id": "batch-post",
      "from": "send-broadcast-bulk",
      "to": "postmark",
      "label": "POST /email/batch",
      "kind": "sync",
      "delta": "added",
      "repeat": 4,
      "payload": {
        "request": {
          "type": "EmailBatch[500]",
          "shape": "Email[]  // max 500\nEmail = { From: string; To: string; Subject: string; HtmlBody: string; MessageStream: \"broadcast\"; Metadata: { campaignId: string; batchId: string } }",
          "sample": [
            {
              "From": "news@example.com",
              "To": "ada@example.com",
              "Subject": "The batching issue, fixed",
              "HtmlBody": "<!doctype html><html><body>…",
              "MessageStream": "broadcast",
              "Metadata": { "campaignId": "cmp_0001", "batchId": "b_0001" }
            }
          ],
          "before": [
            {
              "From": "news@example.com",
              "To": "ada@example.com",
              "Subject": "The batching issue, fixed",
              "HtmlBody": "<!doctype html><html><body>…",
              "Metadata": { "campaignId": "cmp_0001" }
            }
          ],
          "source": { "path": "tests/fixtures/postmark-batch.json" }
        },
        "response": {
          "type": "BatchResult[500]",
          "shape": "SendResult[]  // one per Email, same order",
          "sample": [{ "ErrorCode": 0, "Message": "OK", "To": "ada@example.com", "MessageID": "b7fa5c1e-…" }]
        }
      }
    }
    ```
    
    A payload has a `request` side, a `response` side, or both. Each side has:
    
    - `type`: the name a reader of the code would recognise. Put the count in it when the step carries a collection: `EmailBatch[500]`, not `EmailBatch`. Write `{ "type": "void" }` for a side that carries nothing, such as the answer to a fire and forget call.
    - `shape`: the type signature as text, taken from the code's own types. Up to 2048 bytes.
    - `sample`: one exemplar instance after the change, written inline as JSON. It is a JSON value, not a JSON string: `"sample": [{ "To": "ada@example.com" }]`, never `"sample": "[{\"To\": ...}]"`. A string here is rejected. Every key once, one element in any array, long strings cut with an ellipsis. At most 8 levels deep and 4096 bytes once serialised. The parser refuses a sample over either cap rather than trimming it.
    - `before`: the same exemplar as it was before the change, when it differs. Same rules as `sample`, and it needs a `sample` to differ from.
    - `source`: the fixture or type the shape and sample came from, as a file reference. It becomes the permalink.
    
    Use placeholder values: `ada@example.com`, `cmp_0001`. Never copy a value from a fixture that could belong to a real person or unlock something, even in test data.
    
    Do not write `changedPaths`. The paths that differ between `before` and `sample` are worked out when the document is stored. A list you write is discarded.
    
    The field arrived with contract 0.2.0. A CLI built before it rejects the whole document as an invented field, so validate with a current one.
    
    ## What the validator will catch
    
    Read `references/graph-document.md` before writing. The four failures that account for nearly everything:
    
    | Code                         | What you did                                                         |
    | ---------------------------- | -------------------------------------------------------------------- |
    | `BROKEN_REFERENCE`           | an edge, a flow step, a view or a walkthrough step names an id you never declared |
    | `INVALID_DOCUMENT`           | an invented field; the schemas are strict, unknown keys are rejected |
    | `DUPLICATE_ID`               | two nodes, edges or views sharing an id                              |
    | `UNSUPPORTED_SCHEMA_VERSION` | `schemaVersion` is not the contract version installed                |
    
    Seven rules cannot be expressed in JSON Schema and are checked only by the parser, so structured output alone does not make a document valid: referential integrity, a line range that ends before it starts, a `self` message whose endpoints disagree, a patch whose two commits are the same, more views than a render manifest could describe, a walkthrough step focusing flow steps the diagram on its stage does not draw, and sample traffic past its depth or byte caps. Always validate.
    
    ## Fixing a map instead of writing one
    
    When someone says the diagram is wrong (a node is misnamed, a folder should not be on it, something sits in the wrong lane), do not edit the generated document. It is regenerated on every run. Write the correction into `.github/pr-lens.yml`, which is an overlay applied over fresh inference every time:
    
    ```yaml
    schemaVersion: 0.2.0
    map:
      rename:
        - match: functions/src/broadcast/sendBroadcastBulk.ts
          to: Broadcast sender
      exclude:
        - "**/*.test.ts"
      lane:
        - match: packages/broadcast-lib/**
          lane: functions
    ```
    
    `references/config.md` has the full format and the recipes. Validate it the same way: `npx @coldtea/pr-lens-cli@latest validate .github/pr-lens.yml`.
    
    A `match` beginning with `id:` addresses one node exactly; anything else is a path glob matched against a node's file paths. Prefer the glob, because it keeps holding when the next run names the node differently. A lane pin may name a lane the document never declared: the band is created, and takes the id for its label, so give it one a reader would want to see.
    
    `pr-lens render` says so when a correction matched nothing, which is how a config that has drifted, because the file it named moved or was deleted, becomes visible instead of quietly doing nothing.
    
    ## What ships with this skill
    
    Everything you need is beside this page. Nothing here asks you to install a package first.
    
    |                                 |                                                                                    |
    | ------------------------------- | ---------------------------------------------------------------------------------- |
    | `references/graph-document.md`  | the document, field by field: enums, limits, and where documents actually go wrong |
    | `references/config.md`          | `.github/pr-lens.yml`, the correction overlay, in full                             |
    | `references/example.graph.json` | one complete document that validates, to read and to copy the shape of             |
    
    The same document ships as `postmark-refactor.graph.json` in `@coldtea/pr-lens-schema`, and the JSON Schema the validator enforces is published at `https://unpkg.com/@coldtea/pr-lens-schema/json-schema/graph-doc.schema.json`. Neither is something you need to fetch to write a document.
    
  • tsconfig.json 148 B
    {
      "extends": "../../tsconfig.base.json",
      "compilerOptions": {
        "noEmit": true,
        "types": ["node"]
      },
      "include": ["scripts", "test"]
    }
    
  • vitest.config.ts 132 B
    import { defineConfig } from "vitest/config";
    
    export default defineConfig({
      test: {
        include: ["test/**/*.test.ts"],
      },
    });
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related