From ec7895c29e5037971cd07b36618987014b2d3df4 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sat, 10 Oct 2026 12:13:09 -0400 Subject: [PATCH] feat: review model changes since the last approval AI agents edit model files directly, so nothing showed what changed or whether the agent explained it. Serve now diffs against an approved baseline and cross-checks the agent's change notes. fix: keep modals above the global findings banner --- .claude/rules/wiki/cli.md | 2 +- .claude/rules/wiki/docs.md | 2 +- .claude/rules/wiki/flow-view.md | 2 +- .claude/rules/wiki/flows.md | 2 +- .claude/rules/wiki/frontend.md | 2 +- .claude/rules/wiki/parser.md | 7 +- .claude/rules/wiki/scripts.md | 2 +- .claude/rules/wiki/server.md | 2 +- .claude/rules/wiki/skill.md | 2 +- .claude/rules/wiki/validate.md | 2 +- README.md | 3 +- docs/design/model-change-review.md | 81 ++ docs/guides/change-review.md | 177 +++++ docs/guides/commands.md | 24 +- docs/guides/folder-format.md | 10 + docs/guides/getting-started.md | 3 +- docs/guides/modeling-skill.md | 8 + docs/spec/model-change-review.md | 382 ++++++++++ docs/wiki/cli.md | 35 +- docs/wiki/docs.md | 231 +++--- docs/wiki/feature-map.md | 1 + docs/wiki/flow-view.md | 53 +- docs/wiki/frontend.md | 134 +++- docs/wiki/index.md | 20 +- docs/wiki/parser.md | 65 +- docs/wiki/scan.md | 192 +++-- docs/wiki/scripts.md | 61 +- docs/wiki/server.md | 122 +-- docs/wiki/skill.md | 73 +- models/broken-demo/.gitignore | 1 + models/key-inherited/.gitignore | 1 + models/llm-memory-db-mssql/.gitignore | 1 + models/orm-hybrid/.gitignore | 1 + models/orm-pure/.gitignore | 1 + package.json | 2 +- scripts/clear-review-state.ts | 9 + skills/ignatius-modeling/SKILL.md | 2 + .../references/change-notes.md | 136 ++++ .../references/dfd-authoring.md | 3 +- .../references/discover-flow.md | 1 + .../references/model-flow.md | 3 +- .../references/reverse-engineering.md | 4 +- .../references/verification.md | 6 +- src/app/App.tsx | 97 ++- src/app/components/changes/ChangeBadge.tsx | 44 ++ src/app/components/changes/ChangesPanel.tsx | 231 ++++++ src/app/components/entity/ColumnsTable.tsx | 42 +- src/app/components/entity/EntityCard.tsx | 12 +- src/app/components/entity/EntityModal.tsx | 7 +- src/app/components/entity/GridCard.tsx | 5 + src/app/components/findings/FindingsPanel.tsx | 10 +- src/app/components/ui/JsonValue.tsx | 10 +- src/app/components/ui/Modal.tsx | 4 +- src/app/dom/theme-css-vars.ts | 19 + src/app/globals.d.ts | 2 + src/app/hooks/useModelData.ts | 78 +- src/app/logic/change-marks.ts | 106 +++ src/app/styles.css | 457 +++++++++++- src/app/views/dict/DictionaryView.tsx | 48 ++ src/app/views/flow/FlowsView.tsx | 31 +- src/app/views/graph/GraphView.tsx | 231 +++++- src/app/views/graph/change-glow.ts | 220 ++++++ src/app/views/graph/layout-store.ts | 12 + src/app/views/graph/organic-layout.ts | 6 +- src/app/views/graph/styles.ts | 52 +- src/cli/cli.ts | 32 + src/flow-view/FlowChrome.tsx | 8 +- src/flow-view/FlowDiagramSvg.tsx | 53 +- src/flow-view/FlowIndex.tsx | 27 +- src/flow-view/LevelMenu.tsx | 26 +- src/model/change-review.ts | 417 +++++++++++ src/model/change-store.ts | 126 ++++ src/model/wikilink.ts | 21 +- src/server/server.ts | 76 +- test/checks/test-change-review-dict-flow.ts | 338 +++++++++ test/checks/test-change-review-flow-index.ts | 281 +++++++ test/checks/test-change-review-graph.ts | 575 +++++++++++++++ test/checks/test-change-review-serve.ts | 325 ++++++++ test/checks/test-change-review.ts | 696 ++++++++++++++++++ test/checks/test-layout-store.ts | 21 + test/checks/test-modal-above-banner.ts | 83 +++ test/fixtures/broken-flows-model/.gitignore | 1 + test/fixtures/flows-leveling/.gitignore | 1 + test/fixtures/flows-model/.gitignore | 1 + test/fixtures/hub-dfd/.gitignore | 1 + test/fixtures/subtype-no-basetype/.gitignore | 1 + test/visual/screenshot-change-glow.ts | 192 +++++ 87 files changed, 6381 insertions(+), 516 deletions(-) create mode 100644 docs/design/model-change-review.md create mode 100644 docs/guides/change-review.md create mode 100644 docs/spec/model-change-review.md create mode 100644 models/broken-demo/.gitignore create mode 100644 models/key-inherited/.gitignore create mode 100644 models/llm-memory-db-mssql/.gitignore create mode 100644 models/orm-hybrid/.gitignore create mode 100644 models/orm-pure/.gitignore create mode 100644 scripts/clear-review-state.ts create mode 100644 skills/ignatius-modeling/references/change-notes.md create mode 100644 src/app/components/changes/ChangeBadge.tsx create mode 100644 src/app/components/changes/ChangesPanel.tsx create mode 100644 src/app/logic/change-marks.ts create mode 100644 src/app/views/graph/change-glow.ts create mode 100644 src/model/change-review.ts create mode 100644 src/model/change-store.ts create mode 100644 test/checks/test-change-review-dict-flow.ts create mode 100644 test/checks/test-change-review-flow-index.ts create mode 100644 test/checks/test-change-review-graph.ts create mode 100644 test/checks/test-change-review-serve.ts create mode 100644 test/checks/test-change-review.ts create mode 100644 test/checks/test-modal-above-banner.ts create mode 100644 test/fixtures/broken-flows-model/.gitignore create mode 100644 test/fixtures/flows-leveling/.gitignore create mode 100644 test/fixtures/flows-model/.gitignore create mode 100644 test/fixtures/hub-dfd/.gitignore create mode 100644 test/fixtures/subtype-no-basetype/.gitignore create mode 100644 test/visual/screenshot-change-glow.ts diff --git a/.claude/rules/wiki/cli.md b/.claude/rules/wiki/cli.md index 20c9153..146d94a 100644 --- a/.claude/rules/wiki/cli.md +++ b/.claude/rules/wiki/cli.md @@ -4,7 +4,7 @@ paths: - "src/cli/**" --- -Domain: cli. citty-based subcommand dispatch (serve/validate/export/index/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update with streamed download progress + checksum verification +Domain: cli. `ignatius` binary dispatch via citty (serve/validate/index/export/approve/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive `pickModel`; port fallback + browser open on serve; self-update with checksum verification Map: - docs/wiki/cli.md diff --git a/.claude/rules/wiki/docs.md b/.claude/rules/wiki/docs.md index 23d42b7..9667943 100644 --- a/.claude/rules/wiki/docs.md +++ b/.claude/rules/wiki/docs.md @@ -8,7 +8,7 @@ paths: - "docs/glossary.md" --- -Domain: docs. Design docs, user guides, research notes, and implementation-contract specs — 81 markdown files plus `docs/glossary.md` across `docs/design/`, `docs/guides/`, `docs/research/`, `docs/spec/` +Domain: docs. Design docs, user guides, research notes, and implementation-contract specs plus `docs/glossary.md` across `docs/design/`, `docs/guides/`, `docs/research/`, `docs/spec/`; model change review is in the `model-change-review` design/spec and the `change-review` guide Map: - docs/wiki/docs.md diff --git a/.claude/rules/wiki/flow-view.md b/.claude/rules/wiki/flow-view.md index 080bfba..a18f50e 100644 --- a/.claude/rules/wiki/flow-view.md +++ b/.claude/rules/wiki/flow-view.md @@ -4,7 +4,7 @@ paths: - "src/flow-view/**" --- -Domain: flow-view. ELK-driven DFD layout (5-band partitioning, orthogonal edge routing) with a stack-node model for per-process/connected views and three collapse levels (stores/clusters/groups); `flow-nav.ts` resolves exact diagram paths for the new flow index + breadcrumb level menus (`FlowIndex.tsx`/`LevelMenu.tsx`, replacing the old standalone nav card); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming and gates hover transitions through the frontend domain's hover-intent/animation-limit helpers +Domain: flow-view. ELK-driven DFD layout (band partitioning, orthogonal edge routing), stack-node model and collapse levels (stores/clusters/groups); SVG renderer with change-review glows; `flow-nav.ts` path resolution for the flow index and breadcrumb level menus, which show change marks (pill glow, pulsing or static `ChangeDot`, index change summary) Map: - docs/wiki/flow-view.md diff --git a/.claude/rules/wiki/flows.md b/.claude/rules/wiki/flows.md index 2a69bfb..5ccf225 100644 --- a/.claude/rules/wiki/flows.md +++ b/.claude/rules/wiki/flows.md @@ -4,7 +4,7 @@ paths: - "src/flows/**" --- -Domain: flows. SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`, now also reading a folder's own `description:` off `index.md`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (17 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model +Domain: flows. SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`, reading a folder's own `description:` off `index.md`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (`flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model Map: - docs/wiki/flows.md diff --git a/.claude/rules/wiki/frontend.md b/.claude/rules/wiki/frontend.md index faf7c0e..57cf5c2 100644 --- a/.claude/rules/wiki/frontend.md +++ b/.claude/rules/wiki/frontend.md @@ -4,7 +4,7 @@ paths: - "src/app/**" --- -Domain: frontend. React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; `logic/motion.ts` centralizes hover-intent debouncing and a per-view animation-element-limit; `flowview=`/`collapse=` hash params drive the flow view mode and collapse level; new `i` shortcut opens the flow index; `StackDialog`/`EdgeContractDialog` cover stack and edge-contract detail; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath +Domain: frontend. React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; change-review overlay (`ChangesPanel`, `ChangeBadge`/`ChangeDot`, `logic/change-marks.ts`) marks graph, Dictionary, flow nodes, flow index and level-menu rows, with removed items in warning red; modals stack above the global findings banner; `flowview=`/`collapse=` hash params; views own cy/SVG lifecycle Map: - docs/wiki/frontend.md diff --git a/.claude/rules/wiki/parser.md b/.claude/rules/wiki/parser.md index cbc7cbb..78b7cd7 100644 --- a/.claude/rules/wiki/parser.md +++ b/.claude/rules/wiki/parser.md @@ -4,9 +4,11 @@ paths: - "src/model/parse.ts" - "src/model/wikilink.ts" - "src/model/model-index.ts" + - "src/model/change-review.ts" + - "src/model/change-store.ts" --- -Domain: parser. `ignatius.yml` config loading → ParseResult: {model, globalErrors}; nodes, edges, cardinality + classification derivation; wiki-link inline rule + two-pass body rendering; `ModelMeta.flowView` (`flow_view:`/`adjacency_stacks`); `buildModelIndex` — 13 O(1) lookup maps built once per Model +Domain: parser. `ignatius.yml` config loading → ParseResult: {model, globalErrors}; nodes, edges, cardinality + classification derivation; wiki-link inline rule + two-pass body rendering (`linkStateNeutral`); `buildModelIndex` lookup maps; change review: `change-review.ts` diffs model + flows against the approved baseline kept by `change-store.ts` and cross-checks agent change notes Map: - docs/wiki/parser.md @@ -14,12 +16,15 @@ Contracts: - docs/spec/bidirectional-predicates.md - docs/spec/derive-classification.md - docs/spec/folder-model.md + - docs/spec/model-change-review.md - docs/spec/wiki-entity-links.md Designs: - docs/design/bidirectional-predicates.md - docs/design/folder-model.md + - docs/design/model-change-review.md - docs/design/wiki-entity-links.md Guides: + - docs/guides/change-review.md - docs/guides/folder-format.md - docs/guides/predicates.md diff --git a/.claude/rules/wiki/scripts.md b/.claude/rules/wiki/scripts.md index df5093e..5ec535d 100644 --- a/.claude/rules/wiki/scripts.md +++ b/.claude/rules/wiki/scripts.md @@ -4,7 +4,7 @@ paths: - "scripts/**" --- -Domain: scripts. Build helpers: stable-names.ts, convert-yaml-to-md.ts; perf/diagnostic tooling: probe.ts, screenshot.ts, gen-synthetic-model.ts, perf-harness.ts +Domain: scripts. Build helpers: stable-names.ts, convert-yaml-to-md.ts; test prelude clear-review-state.ts (removes `.ignatius/` review state from demo + fixture models); perf/diagnostic tooling: probe.ts, screenshot.ts, gen-synthetic-model.ts, perf-harness.ts Map: - docs/wiki/scripts.md diff --git a/.claude/rules/wiki/server.md b/.claude/rules/wiki/server.md index 08c92a0..d496e45 100644 --- a/.claude/rules/wiki/server.md +++ b/.claude/rules/wiki/server.md @@ -4,7 +4,7 @@ paths: - "src/server/**" --- -Domain: server. Bun.serve with `/api/model` + `/api/flow` (now includes `clusters`) + `/events` SSE + fs.watch live-reload; `/dict` and `/flow` redirect to unified SPA hash routes; `/flow-dict` redirects to `/#view=dict` +Domain: server. Bun.serve with `/api/model` (carries the change-review `diff`), `POST /api/approve`, `/api/flow` (returns `clusters`), `/events` SSE live-reload driven by fs.watch plus a `baseline.json` stat poll; `/dict`, `/flow`, `/flow-dict` redirect to SPA hash routes Map: - docs/wiki/server.md diff --git a/.claude/rules/wiki/skill.md b/.claude/rules/wiki/skill.md index f2816f6..b372a41 100644 --- a/.claude/rules/wiki/skill.md +++ b/.claude/rules/wiki/skill.md @@ -4,7 +4,7 @@ paths: - "skills/ignatius-modeling/**" --- -Domain: skill. Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate`; a flow's own `description:` is now authored to `flows//index.md` (Step F6a) +Domain: skill. Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate`; ends every edit session with a `.ignatius/changes/.md` change note (`references/change-notes.md`) and never approves its own edits Map: - docs/wiki/skill.md diff --git a/.claude/rules/wiki/validate.md b/.claude/rules/wiki/validate.md index 3d212aa..dcadb4c 100644 --- a/.claude/rules/wiki/validate.md +++ b/.claude/rules/wiki/validate.md @@ -4,7 +4,7 @@ paths: - "src/model/validate.ts" --- -Domain: validate. Pure model validator: 38 RuleIds across 8 prefixes (parse/config/entity/body/edge/cluster/index/flow), two severity tiers (A=warn, B=omit); `flow.*` now 17 ids including five `flow.cluster_*` DFD-store-cluster rules; `validateIndex` reuses `buildRouters` to detect router drift +Domain: validate. Pure model validator: RuleIds across parse/config/entity/body/edge/cluster/index/flow prefixes, two severity tiers (A=warn, B=omit); `flow.cluster_*` DFD-store-cluster rules; `validateIndex` reuses `buildRouters` to detect router drift Map: - docs/wiki/validate.md diff --git a/README.md b/README.md index 477be62..13edb48 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,8 @@ Edit any file in the folder and the graph reloads in the browser without a refre | Guide | What it covers | |---|---| | [Getting started](docs/guides/getting-started.md) | Install, build from source, and serve your first model | -| [Commands](docs/guides/commands.md) | `serve`, `export`, `validate`, `index`, and model discovery | +| [Commands](docs/guides/commands.md) | `serve`, `export`, `validate`, `index`, `approve`, and model discovery | +| [Change review](docs/guides/change-review.md) | What changed since the last approval, change notes, and `approve` | | [The folder format](docs/guides/folder-format.md) | `ignatius.yml`, entity files, columns, relationships, groups, generated routers | | [What gets derived](docs/guides/derivation.md) | Cardinality, classification, and subtype clusters | | [Bidirectional predicates](docs/guides/predicates.md) | Forward and reverse edge labels with hover-swap | diff --git a/docs/design/model-change-review.md b/docs/design/model-change-review.md new file mode 100644 index 0000000..afdb6ab --- /dev/null +++ b/docs/design/model-change-review.md @@ -0,0 +1,81 @@ +# Design: Model change review + + +## Problem + + +An AI agent edits a model by writing entity and flow markdown files directly. When the user opens `ignatius serve` afterwards, the graph renders the new state with nothing marking what the agent added, removed, or changed. The user has no way to review the edit visually before accepting it. + + +## Concepts + + +| Term | Meaning | +|------|---------| +| Baseline | The parsed model and flows at the last approval, stored as `.ignatius/baseline.json` next to `ignatius.yml`. | +| Change item | One semantic difference between the baseline and the current parse: an entity, column, relationship, body, or flow node that was added, removed, or changed. | +| Change note | A markdown file the agent writes to `.ignatius/changes/`. Frontmatter names the entities, columns, diagrams, and flow nodes it touched; the body explains why. | +| Approve | Moves the baseline to the current state and deletes the change notes. | + + +## Approach + + +Hybrid: ignatius computes the diff deterministically, and the agent's change notes explain it. Each source checks the other. + +```mermaid +flowchart TD + B[baseline.json] --> D[diffSnapshots] + C[current parse] --> D + D --> X[crossCheck] + N[changes/*.md] --> X + X --> R["/api/model diff"] + R --> G[Graph / Dictionary / Flow highlights] + R --> P[Changes panel] +``` + +Cross-check outcomes: + +| Diff | Note | Result | +|------|------|--------| +| changed | names it | highlighted, explanation shown | +| changed | silent | highlighted, flagged unexplained | +| unchanged | names it | flagged claimed but unchanged | + +The diff alone shows what changed but never why. Notes alone are only as accurate as the agent that wrote them: an entity the agent forgot to list would never highlight. The two together surface both failure modes during approval. + + +## Business rules + + +- The baseline is a snapshot ignatius writes, not a git ref, so review works for a model folder outside any repository. +- `.ignatius/` is review state, never committed. A `.gitignore` next to `ignatius.yml` lists it. The modeling skill writes that file for every model it generates; ignatius adds the line when it creates `.ignatius/` and the line is missing. +- The diff runs on the parsed model, not file text. Reordering YAML keys or reformatting produces no change item. +- A body-only edit is its own kind (`doc`), so prose edits do not read as structural changes. +- Column granularity is required: a note covers a column change only when it names the column. +- Serve creates the baseline from the current state when none exists, so a first run highlights nothing. The modeling skill runs `approve` before its first write to a model with no baseline, so an agent's edits to a never-served model still show. + + +## Lifecycle + + +```mermaid +stateDiagram-v2 + [*] --> Clean: serve creates baseline + [*] --> Clean: skill runs approve before first write + Clean --> Pending: agent edits files and writes notes + Pending --> Pending: more edits (live via SSE) + Pending --> Clean: ignatius approve / Approve button + Pending --> Claimed: user reverts files, note kept + Claimed --> Clean: user deletes or edits the note + Pending --> Clean: user reverts files (no note, or note deleted) +``` + + +## Rejected approaches + + +- **Git diff against HEAD.** Fails for model folders outside a repository, and conflates "committed" with "approved". +- **Change notes only.** Cheaper, but the highlight trusts the agent; an omission is invisible. +- **Diff only.** Shows what changed but carries no explanation for the approval decision. +- **Committing notes.** They exist for one review cycle; git history already records what landed. diff --git a/docs/guides/change-review.md b/docs/guides/change-review.md new file mode 100644 index 0000000..1c214c6 --- /dev/null +++ b/docs/guides/change-review.md @@ -0,0 +1,177 @@ +# Change review + + +`ignatius serve` shows what changed in a model since you last approved it. When an agent (or a person) edits entity or flow files, the app marks every added, changed, and removed entity, column, relationship, and flow node. A Changes panel lists each change next to the note that explains it. When the changes look right, `ignatius approve` accepts the current files as the new starting point and the highlights clear. + +```bash +$ ignatius serve models/billing +# ...an agent adds Invoice.line_total and writes .ignatius/changes/split-invoice-lines.md +# the open tab marks Invoice changed and line_total added; the Changes panel shows the note +$ ignatius approve models/billing +Approved 1 change(s), cleared 1 note(s). +``` + +Reach for it when a model is edited by someone else, most often the [modeling skill](modeling-skill.md), and you want to read the edit as a diff instead of re-reading every file. + + +## How it works + + +ignatius keeps a baseline: a snapshot of the parsed model and flows, written to `.ignatius/baseline.json` inside the model folder. On every load and every live reload it parses the current files, diffs them against the baseline, and checks the diff against the change notes in `.ignatius/changes/`. The diff is semantic: reordering YAML keys, columns, or key members produces no change. + +Once written, the baseline only moves when you approve or when it is unreadable, so every edit made between two approvals stays highlighted, however many sessions it took: + +```mermaid +sequenceDiagram + participant User + participant Agent + participant Folder as model folder + participant Serve as ignatius serve + User->>Serve: ignatius serve + Serve->>Folder: write .ignatius/baseline.json when absent + Agent->>Folder: edit entity and flow files + Agent->>Folder: write .ignatius/changes/slug.md + Folder-->>Serve: file watcher fires + Serve-->>User: highlights and Changes panel + User->>Serve: Approve, or ignatius approve + Serve->>Folder: rewrite baseline, delete notes + Serve-->>User: highlights clear +``` + +The first `serve` of a model writes the baseline from the files as they are, so a model opened for the first time shows no changes. An edit made before that first `serve` would land in the baseline and never show. To prevent that, run `ignatius approve` before editing a model that has no `.ignatius/baseline.json`: with nothing pending it accepts nothing and records the untouched files as the baseline. If `.ignatius/changes/` already holds notes, read them first, because `approve` deletes them. The [modeling skill](modeling-skill.md) runs `approve` before its first write when there is no valid baseline and no note, and asks you when notes exist without a baseline. + + +## What the app shows + + +Green marks an addition, amber a change. On the Graph and Flows views the mark is a slow pulsing glow; a change no note explains alternates its glow between its color and red. In the Dictionary it carries an `unexplained` label. With reduced motion turned on in your operating system, or on a view too large to animate, the glows hold steady and an unexplained one stays tinted red. + +| Where | Added | Changed | Removed | +|-------|-------|---------|---------| +| Graph | green glow around the entity or along the relationship | amber glow; a column-only or body-only change marks the entity | entity drawn as a dashed, faded ghost node with a muted glow and a red ✕ badge on its corner; relationship drawn as a dashed edge | +| Dictionary | `added` badge on the entity, green marker on a new column row | `changed` badge, amber marker on a changed column row | struck-through column rows with a red marker; removed entities, with a red `removed` badge, in a Removed section at the end | +| Flows | green glow behind the process, external, or store; on a new diagram, behind every node | amber glow | listed in the Changes panel only; the diagram does not draw them | +| Flow index | green glow on the row of a new process or diagram, and on every row inside a new diagram | amber glow on the row; the side pane names the changed fields | no row; listed in the Changes panel only | +| Level menus | pulsing green dot after the diagram's name | pulsing amber dot | no row; listed in the Changes panel only | +| Changes panel | green bar | amber bar | a solid red bar, a red ✕ before the name, and `removed` in red | + +In the flow index (**☰ Process Flows**, or `i`) and the breadcrumb level menus, a row whose diagram holds changes further down shows a steady dot after its label: red if any of them is unexplained, amber if any is a change or removal, green if all are additions. + +The Changes panel appears only in `serve`, and only when there is something to review. It lists, in order: + +1. Each note: its title, its body, and the changes it covers. +2. **Unexplained**: changes no note covers. +3. **Named but unchanged**: names in a note with no matching change. +4. **Note errors**: notes whose frontmatter did not parse. + +Clicking an entity item opens it in the graph, ghost nodes included. Clicking a flow item opens its diagram. The **Highlight changes** checkbox hides every marker and ghost without reloading, and the **Approve** button does what `ignatius approve` does. + + +## The .ignatius folder + + +``` +models/billing/ + ignatius.yml + .gitignore contains the line .ignatius/ + .ignatius/ + baseline.json the last approved parse + changes/ + split-invoice-lines.md +``` + +`.ignatius/` is local review state, never committed. Whenever ignatius writes the baseline (the first `serve`, or `approve`), it also makes sure the model's `.gitignore` lists `.ignatius/`: it creates the file when absent, appends the line when missing, and never adds it twice. ignatius never reads `.ignatius/` as model files. + + +## Change notes + + +A change note is a markdown file in `.ignatius/changes/`, one per edit session. The [modeling skill](modeling-skill.md) writes one at the end of every session that changes the model; you can write one by hand the same way. + +```markdown +--- +title: Split invoice lines out of Invoice +entities: + Invoice: [line_total] + InvoiceLine: [] +flows: + order-to-cash: [Issue-Invoice, invoice-events] +--- + +Billing needs one row per charged item so partial credits can target a line. +`InvoiceLine` inherits the invoice key; `Invoice.line_total` keeps the summed +amount the statement prints. +``` + +| Key | Value | +|-----|-------| +| `title` | Required. Shown as the note's heading in the panel. | +| `entities` | Optional. Entity id to a list of column names. | +| `flows` | Optional. Diagram id (its folder name under `flows/`) to a list of node ids: a process's file name, an external's name, or a store's name (`invoice-events` for `queue:invoice-events`). | + +The body is markdown, rendered in the panel. It should say why the change was made; the panel already shows what changed. + + +### What a note covers + + +| Change | Covered when the note | +|--------|-----------------------| +| Entity added, removed, or a field changed (`pk`, `ak`, `classification`, `group`, `description`, `examples`, `singleton`, subtypes) | names the entity | +| Column added, removed, or changed (`type`, `nullable`, `default`, `desc`) | names the entity and lists the column | +| Relationship added, removed, or changed | names the source or the target entity | +| Body text only | names the entity | +| Diagram added or removed | names the diagram | +| Process, external, or store added, removed, or changed | names the diagram and lists the node | + +The check runs both ways. A change no note covers is **unexplained**. A name in a note that matches no change is **named but unchanged**: an entity with no change, a column with no column change, a diagram with no flow change, or a node with no change of its own. Both show in the panel so a note that misdescribes the edit is caught before you approve it. + +Externals and stores are defined once, in `externals/.md` and `stores/.md`, and every diagram that references one draws it as a node of its own. An edit to that file changes the node in each of those diagrams, so the note lists it under every one. Adding a diagram's first reference to an external or store adds the node to that diagram; removing the last reference removes it. + +A relationship change is covered by either end, but name the child entity, the one holding the foreign key. Adding or removing an identifying relationship re-derives the child's `classification`: a foreign key inside the child's primary key turns an Independent entity into a Dependent one, or back. Only the child's name covers that entity change. + +When a subtype cluster gains or loses a member, name that member as well as the basetype. An entity listed in a basetype's `subtypes:` is classified as a Subtype, so adding or removing it re-derives the member's `classification`, and only the member's name covers that change. + +Flow changes are diffed on the diagrams you author. The derived context and Level 1 diagrams are never diffed, since no note could name them and their nodes repeat the leaves'. + + +## Approve + + +```bash +ignatius approve [path] [--model ] +``` + +`approve` resolves the model the same way the other commands do (see [Commands](commands.md#approve)). It writes the current parse as the new baseline, deletes every file in `.ignatius/changes/`, and prints what it accepted. The panel's **Approve** button does the same and refreshes every open tab. After either, the app shows no changes until the next edit. + +The button only answers requests from the app's own page on `localhost` or an IP address, so another site open in the browser cannot approve on your behalf. + +To reject a change, edit the files back. The diff empties on its own once the files match the baseline again. The note stays, though, and every name in it now shows as named but unchanged. Delete the note, or edit it down to the changes you kept. + + +## What it does not do + + +| Not covered | Instead | +|-------------|---------| +| Git history | The baseline is a file ignatius writes; it does not read commits or branches. | +| `export` and `validate` | Neither diffs. A static export has no highlights and no Changes panel. | +| Removed flow nodes on the diagram | They are listed in the Changes panel; the DFD canvas draws only what exists now. | +| Layout | Highlights never move nodes or change saved positions. Ghost nodes are placed near their old neighbours and are not saved. | +| Keeping notes | Approve deletes them. Copy anything worth keeping into the entity or flow body first. | + + +## When the review cannot be built + + +If ignatius cannot build the review, for example in a read-only model folder where it cannot write the baseline, `serve` still serves the model. `/api/model` returns `diff: null`, the app shows no highlights and no Changes panel, and `serve` prints the reason to stderr. At startup, when it cannot prepare the baseline: + +``` +ignatius: could not prepare the change-review baseline: +``` + +On each request for the model, when it cannot build the review: + +``` +ignatius: could not build the change review: +``` diff --git a/docs/guides/commands.md b/docs/guides/commands.md index b4b78a5..7fd3301 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -1,13 +1,15 @@ # Commands -ignatius has five subcommands. The three model commands read the same folder format. Two utility commands report and update the installed binary. +ignatius has seven subcommands. The five model commands read the same folder format. Two utility commands report and update the installed binary. | Subcommand | What it does | |---|---| | `serve` | Starts an interactive single-page app and watches the folder for changes | | `export` | Writes a self-contained model file (graph, dictionary, and flows) as one HTML file | | `validate` | Checks the model and reports findings without writing any output | +| `index` | Writes a navigable router file into every organizing folder | +| `approve` | Accepts the current model as the change-review baseline and deletes the change notes | | `version` | Prints the installed version | | `update` | Checks for a newer release and installs it | @@ -45,6 +47,8 @@ ignatius serve [path] [-p|--port ] [--model ] [-o|--open] While serving, a findings panel lists any schema problems and updates on every save. See [Validation and findings](validation.md). +`serve` also diffs the model against the last approved state and marks what changed in all three views, with a Changes panel that lists each change beside the note that explains it. See [Change review](change-review.md). + ## export @@ -94,6 +98,24 @@ It prints each finding to stderr in the same format as `export` and writes a one `--index` also recomputes every router digest and reports drift as `index.stale`, a Class B finding, so a model whose routers no longer match its files fails the gate. It writes nothing; the fix is `ignatius index`. A plain `validate` never hashes, so the check costs nothing unless asked for. +## approve + + +Accepts the current model as the baseline that `serve` diffs against, and deletes every file in `.ignatius/changes/`. + +```bash +ignatius approve [path] [--model ] +``` + +It resolves the model with the same discovery rules as the other model commands, then prints what it accepted and exits `0`: + +``` +Approved 3 change(s), cleared 1 note(s). +``` + +A model with no baseline yet gets one, and reports `0` changes. The Approve button in the `serve` Changes panel does the same thing. See [Change review](change-review.md). + + ## version diff --git a/docs/guides/folder-format.md b/docs/guides/folder-format.md index bbdeb66..9f8836e 100644 --- a/docs/guides/folder-format.md +++ b/docs/guides/folder-format.md @@ -23,6 +23,10 @@ models/ stores/ # optional — shared non-db store definitions for DFDs flows/ # optional — data flow diagrams notes/ # free-form; ignored by the parser + .gitignore # lists .ignatius/ + .ignatius/ # local change-review state; never committed + baseline.json + changes/ ``` Entities live under `data//.md`. The group name comes from the entity's `group:` frontmatter field — the subdirectory path under `data/` is just a convenience for organization; the parser uses the frontmatter value, not the folder name. `groups/` is optional: a model with no `groups/` directory parses with zero groups and no error. `externals/` and `stores/` are the global registries for DFD authoring (see [Process flows](flows.md)). @@ -271,6 +275,12 @@ An entity whose `group` references a name with no matching `groups/.md` fi In the flow view's groups collapse level, a group is also a stacking bucket: stores sharing a `group:` that a process touches (two or more) nest under one group row, containing that group's own cluster rows and loose tables. See [Labels, stacks, clusters, and groups](flows.md#labels-stacks-clusters-and-groups). +## The .ignatius folder + + +`ignatius serve` keeps its change-review state in `.ignatius/` at the model root: `baseline.json` is the model as last approved, and `changes/` holds one markdown change note per edit session. Neither is parsed as model files. The folder is local and never committed, so the model root carries a `.gitignore` with the line `.ignatius/`. ignatius creates the file or appends the line whenever it writes the baseline (the first `serve`, or `approve`). See [Change review](change-review.md) for the note format and `ignatius approve`. + + ## The flows folder diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 17b79e9..2fb62ca 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -89,5 +89,6 @@ If the path contains multiple model folders, ignatius lists them and prompts you - [The folder format](folder-format.md) — how to structure entity files, groups, and `ignatius.yml`. - [What gets derived](derivation.md) — cardinality, classification, and subtype clusters come from the structure, not from labels you set. - [Process flows](flows.md) — add data flow diagrams that show how processes move the data. -- [Commands](commands.md) — `serve`, `export`, `validate`, and `index` in full. +- [Commands](commands.md) — `serve`, `export`, `validate`, `index`, and `approve` in full. +- [Change review](change-review.md) — see what changed since you last approved the model, with the notes that explain it. - [Authoring with the modeling skill](modeling-skill.md) — let `/ignatius-modeling` write entity and flow files for you. diff --git a/docs/guides/modeling-skill.md b/docs/guides/modeling-skill.md index 2a4ce5e..4906158 100644 --- a/docs/guides/modeling-skill.md +++ b/docs/guides/modeling-skill.md @@ -71,3 +71,11 @@ You never set `classification` or `identifying` manually. The parser derives bot After writing each file the skill runs `ignatius validate ` and parses the lint findings from stderr. Findings are reported with fix hints, and you can ask the skill to revise and re-run (up to five attempts). A clean run with no findings confirms the file is valid. See [Validation and findings](validation.md) for the rules it checks against. When the model carries generated routers, the loop also runs `ignatius validate --index`, which reports them as `index.stale` after any authoring change, and then `ignatius index` to regenerate them. Every file the skill writes gets a one-line `description:`, since that field is what the routers show a reader deciding whether to open the file. + + +## Change notes + + +Every session that changes a model ends with one change note in `.ignatius/changes/`, naming the entities, columns, diagrams, and nodes it touched and saying why. Open the model in `ignatius serve` to read the edit as highlighted changes beside that note, then accept it with `ignatius approve`. A model the skill bootstraps gets a `.gitignore` that keeps `.ignatius/` out of version control. See [Change review](change-review.md). + +Before its first write to a model with no valid `.ignatius/baseline.json`, the skill runs `ignatius approve` to record the untouched files as the baseline. Its edits then show as changes even when you never opened the model in `serve`. If `.ignatius/changes/` already holds notes, it stops instead, because `approve` would delete notes you have not read. It asks you to approve them yourself or to restore the baseline. A session that bootstraps a model and keeps writing runs `approve` once after the bootstrap. The skill never runs `approve` after editing: accepting the edit is your step. diff --git a/docs/spec/model-change-review.md b/docs/spec/model-change-review.md new file mode 100644 index 0000000..68e1118 --- /dev/null +++ b/docs/spec/model-change-review.md @@ -0,0 +1,382 @@ +# Spec: Model change review + + +## Goal + + +`ignatius serve` highlights what changed in a model since the last approval. ignatius diffs the parsed model and flows against a stored baseline; agent-written change notes explain the changes; the two are cross-checked. The graph, Dictionary, and Flow views mark added, changed, and removed items, a Changes panel lists them with their notes, and `ignatius approve` (or the panel's Approve button) accepts the current state as the new baseline. + + +## Approach + + +Hybrid diff plus change notes, stored in a per-model `.ignatius/` folder, per `docs/design/model-change-review.md`. + + +## Non-goals + + +- No git integration. The baseline is a file ignatius writes. +- No diff in `ignatius export` or `validate`. Static mode renders no change highlights and no Changes panel. +- No ghost rendering of removed flow nodes on the DFD canvas. Removed flow nodes appear only in the Changes panel. +- No reject/revert command. Reverting is done by editing the files back and deleting or editing the note; the note's names otherwise stay listed as claimed but unchanged. +- No layout changes: highlights never move nodes and never write `layout-store` positions or the layout fingerprint. +- Change notes and the baseline are never parsed as model files and never committed. + + +## On-disk contract + + +``` +/ + ignatius.yml + .gitignore contains the line `.ignatius/` + .ignatius/ + baseline.json { "version": 1, "model": Model, "flows": FlowDiagram[] } + changes/ + .md one note per edit session +``` + +Change note format: + +```markdown +--- +title: Split invoice lines out of Invoice +entities: + Invoice: [line_total] # changed entity: the columns touched + InvoiceLine: [] # added or removed entity: columns optional +flows: + order-to-cash: [bill-customer] # diagram id: process/external/store ids touched +--- + +Why the change was made, written for the reviewer. +``` + +`entities` maps entity id to a column-name list. `flows` maps `FlowDiagram.id` to a list of node ids (`FlowProcess.id`, `FlowExternal.id`, `FlowStoreRef.name`). Both keys are optional. A note whose frontmatter fails to parse is reported as a note error in the review and is otherwise ignored. + + +## Coverage rules + + +A note **covers** a change item when: + +| Item scope | Covered by | +|------------|-----------| +| `entity` (added, removed, or any SC1 entity field changed) | note names the entity | +| `column` | note names the entity and lists the column | +| `relationship` | note names the source or the target entity | +| `doc` (body-only change) | note names the entity | +| `flow` diagram added/removed | note names the diagram | +| `flow` node added/removed/changed | note names the diagram and lists the node | + +A note **claim** is unchanged when it names an entity with no item for that entity, a column with no `column` item, a diagram with no `flow` item, or a node with no `flow` item for that node. + + +## Success criteria + + +- SC1 — `diffSnapshots(baseline, current)` returns one item per semantic difference: entity added/removed/changed (fields: `pk`, `ak`, `classification`, `group`, `description`, `examples`, `singleton`, `subtypes`; PK and AK compare as sets, `subtypes` is the basetype's subtype clusters with members compared as sets); column added/removed/changed (fields: `type`, `nullable`, `default`, `desc`); relationship added/removed/changed (fields: `identifying`, `on`, `predicate`, `cardinality`), keyed by source+target plus `on` when a pair has more than one edge; body-only change as a `doc` item; flow diagram added/removed and process/external/store added/removed/changed. A removed column item carries the baseline `ColumnDef` as `def`. Stores are keyed by `kind:name` and store items carry `storeKind`; flow endpoints compare by resolved `kind:name`, not raw text. Diagrams in `SYNTHETIC_DIAGRAM_IDS` (`__context__`, `__system__`) are derived from the leaf diagrams and produce no items. Diagrams are keyed by their ancestor path (`parent/child`), and each flow item carries both `diagram` (the `FlowDiagram.id`, which notes name) and `diagramPath`. Reordering YAML keys, columns, or key members produces no item. +- SC2 — `crossCheck(items, notes, baseline)` takes raw `parseChangeNote` results, marks every item `explained` (with the covering note files) or `unexplained`, lists every unchanged claim per the coverage rules, splits parse failures into `noteErrors`, and fills `removedNodes`/`removedEdges` from the baseline model. +- SC3 — `serve` writes `.ignatius/baseline.json` from the current parse when it is absent, at startup and on any `/api/model` request that finds it missing. Creating `.ignatius/` ensures `/.gitignore` contains a `.ignatius/` line: the file is created when absent, the line appended when missing, and an existing line never duplicated. +- SC4 — `/api/model` returns a `diff` field: `{ items, claims, notes, noteErrors, removedNodes, removedEdges }`. `notes` carries each note's file name, title, entities, flows, and rendered body HTML. `removedNodes`/`removedEdges` carry the baseline's `ModelNode`/`ModelEdge` for each removed entity/relationship. Existing payload keys are unchanged. When the review cannot be built (unwritable model folder, unreadable notes), `diff` is `null`, the error is logged, and the rest of the payload is served unchanged. A baseline that does not parse or lacks `version: 1`, an object `model`, and an array `flows` is treated as missing and rewritten. +- SC5 — Editing an entity file or a change note while serving triggers the existing `model-changed` SSE event, and the refetched `diff` reflects the edit. +- SC6 — `ignatius approve [path] [--model ]` writes the current parse as the baseline and deletes every file in `.ignatius/changes/`, then prints what it approved (item and note counts) and exits 0. It resolves the model with `pickModel` like the other commands. `POST /api/approve` in serve does the same and broadcasts `model-changed`. It answers 403 to a cross-origin request (`Sec-Fetch-Site` other than `same-origin`, or an `Origin` whose host differs from `Host`) and to a `Host` that is not `localhost` or an IP literal. After either, `/api/model` returns an empty `items` list. +- SC7 — Graph view: nodes carry `change` data (`added` / `changed` / `removed`) and an `unexplained` flag; edges carry `change` data. Marks are glows, never an `outline-*` or a node underlay: a node glow is a blurred shape drawn on a canvas behind cytoscape's canvases, sized from the node's rendered bounds so it holds at any zoom, with the same blur and spread as the flow glow (`GLOW_BLUR`, `GLOW_SPREAD`) scaled by zoom and clipped to the outside of the node, so no tint lands inside it; nodes whose glow falls outside the viewport are not drawn; the canvas takes no pointer events and is removed with the graph. An edge glow is a thin cytoscape underlay along the line, whose line also takes the color. Green marks added, amber changed. Removed entities render as ghost nodes (dashed border, reduced opacity, muted glow, and a red ✕ badge at the top-right corner drawn on a canvas above cytoscape at full opacity, dimmed only by the hover, lineage, and search tiers, scaled with zoom within a legible range) built from `removedNodes`, and removed relationships as dashed edges with a muted glow from `removedEdges`. A column-only or doc-only change marks its entity as changed. Glows pulse from one shared animation-frame loop (a 3.6s cycle shared with the flow glow, opacity never below 0.35) that scales each glow by its element's effective opacity, so hover, lineage, and search dimming dim it too; the loop stops when the overlay is off, there are no marks, or the graph is torn down. An unexplained item's glow alternates between its kind color and red on successive pulses. Under `prefers-reduced-motion: reduce` (re-read when the setting changes), or when the graph is over `ANIMATION_ELEMENT_LIMIT`, glows hold steady at the pulse midpoint, and an unexplained glow holds a blend leaning toward red. Colors read in both light and dark themes. +- SC8 — Dictionary view: an entity card with change items shows a change badge; column rows show added/changed markers; removed columns render as struck-through rows from the baseline. Removed column markers use `REMOVED_RED` and the removed badge text uses `REMOVED_TEXT` (SC10); the badge stays at full opacity while the rest of its removed card, frame included, is dimmed. Removed entities render in a "Removed since approval" section of the read lens, filtered by the Dictionary search; the browse lens shows a link to it. A body comparison ignores whether a wiki-link target exists, so adding or removing an entity never marks the bodies that link to it. +- SC9 — Flow view: process, external, and store nodes named by a `flow` item render with a blurred glow behind them on the diagram that owns them, green for added and amber for changed, with no stroke outline and `pointer-events: none`. On a newly added diagram, every node glows `added`, unexplained when the diagram's item is. The glow pulses through CSS keyframes on the same 3.6s cycle as the graph glow. An unexplained node stacks a red glow layer under its kind-color layer, and the two swap on successive pulses, matching the graph. Under `prefers-reduced-motion: reduce`, or when the diagram is over `ANIMATION_ELEMENT_LIMIT` (the SVG carries `data-motion="off"`), both layers hold steady and the red layer shows as a ring outside the kind color. + The flow index and the breadcrumb level menus mark their rows the same way. A row is marked by the item for its process in the parent diagram, by a diagram item for the diagram it opens, or by the item adding the diagram that holds it, so every row inside a new diagram is marked `added` with that item's unexplained status. A marked index row's pill glows with the same layers, colors, keyframes, and cycle as a flow node, drawn as CSS `box-shadow`. A marked level menu row instead shows a glowing dot after its label that pulses the same way. A row whose own diagram, or any diagram below it, holds other items shows a static dot after its label: red when any of them is unexplained, else amber when any is not an addition, else green. A marked level menu row shows only its pulsing dot. Removed processes and diagrams have no row; only the Changes panel lists them. Hovering or focusing a marked index row adds a one-line summary under the side pane's title (`Changed: label, outputs · unexplained`). Reduced motion holds row glows and pulsing dots steady; the overlay toggle and static mode (`diff` null) remove every row mark. +- SC10 — Changes panel (live mode only, hidden when the diff has no items, claims, notes, or note errors): lists each note (title, rendered body, the items it covers), then unexplained items, then unchanged claims, then note errors. Clicking an entity item navigates the graph to it (ghost nodes included); clicking a flow item opens its diagram. A removed item's row reads as a warning: a solid left bar and a red-circle ✕ before the label in `REMOVED_RED` (the ghost ✕ badge's red, `--change-removed`), and the word `removed` in `REMOVED_TEXT` (`--change-removed-text`: the same red in light mode, `#f87171` in dark, where `#dc2626` text falls under 4.5:1). The panel has an overlay toggle that removes every change class and ghost element without refetching, and an Approve button that calls `POST /api/approve`. It never overlaps the Findings panel. +- SC11 — The modeling skill instructs the agent to write one `.ignatius/changes/.md` note per edit session, with column-level `entities:` and node-level `flows:` frontmatter, and to create `/.gitignore` with `.ignatius/` for every model it generates. Before its first write to a model with no valid baseline, the skill runs `ignatius approve` when `.ignatius/changes/` is empty, and stops and asks the user when notes exist; a session that bootstraps a model runs `approve` after the bootstrap writes. `test-skill-no-repo-paths` stays green. +- SC12 — Docs: a guide page for change review, a `commands.md` row for `approve`, and the folder-format guide lists `.ignatius/` and the model `.gitignore`. +- SC13 — Every check in `test/checks/` that passed before this change still passes, the new checks pass, and `bunx tsc --noEmit` reports no new error kinds in files this change adds or modifies (the unresolved-cytoscape `Core`/namespace errors already present in the repo's checks are the one tolerated kind). + + +## Checkpoints + + +| # | Checkpoint | Files/areas | Verifies | +|---|------------|-------------|----------| +| CP1 | Pure diff and cross-check: snapshot type, `diffSnapshots`, `parseChangeNote`, `crossCheck` | `src/model/change-review.ts`, `test/checks/test-change-review.ts` | SC1, SC2 on in-memory fixtures, including a key-reorder no-op and every coverage row | +| CP2 | Storage, server, CLI: baseline read/write, `.gitignore` ensure, note loading, `approve`, `/api/model` `diff`, `POST /api/approve`, `approve` subcommand | `src/model/change-store.ts`, `src/server/server.ts`, `src/cli/cli.ts`, `test/checks/test-change-review-serve.ts` | SC3–SC6 against a temp copy of `models/key-inherited` | +| CP3 | Graph highlights and Changes panel: `useModelData` exposes `diff`, GraphView change data + ghost elements, styles, `ChangesPanel`, overlay toggle, approve button | `src/app/hooks/useModelData.ts`, `src/app/views/graph/GraphView.tsx`, `src/app/views/graph/styles.ts`, `src/app/components/changes/ChangesPanel.tsx`, `src/app/App.tsx`, `src/app/styles.css`, `test/checks/test-change-review-graph.ts` | SC7, SC10 in a Playwright check against a served temp model with an edit and a note | +| CP4 | Dictionary and Flow highlights | `src/app/views/dict/DictionaryView.tsx`, `src/app/components/entity/ColumnsTable.tsx`, `src/app/components/entity/{EntityCard,GridCard,EntityModal}.tsx`, `src/app/components/changes/ChangeBadge.tsx`, `src/app/logic/change-marks.ts`, `src/app/components/changes/ChangesPanel.tsx` (flow labels), `src/app/App.tsx`, `src/app/views/graph/GraphView.tsx` (shared marks), `src/app/hooks/useModelData.ts`, `src/model/change-review.ts` (`def`, `storeKind`), `test/checks/test-change-review.ts`, `test/checks/test-change-review-graph.ts`, `src/app/views/flow/FlowsView.tsx`, `src/flow-view/FlowDiagramSvg.tsx`, `src/flow-view/FlowChrome.tsx`, `src/flow-view/FlowIndex.tsx`, `src/flow-view/LevelMenu.tsx`, `src/app/dom/theme-css-vars.ts`, `src/app/styles.css`, `test/checks/test-change-review-dict-flow.ts`, `test/checks/test-change-review-flow-index.ts` | SC8, SC9 in Playwright checks | +| CP5 | Skill and docs | `skills/ignatius-modeling/SKILL.md`, `skills/ignatius-modeling/references/change-notes.md`, `skills/ignatius-modeling/references/model-flow.md`, `skills/ignatius-modeling/references/verification.md`, `docs/guides/change-review.md`, `docs/guides/commands.md`, `docs/guides/folder-format.md` | SC11, SC12 | + + +## Change tree + + +``` +A src/model/change-review.ts snapshot type, diffSnapshots, parseChangeNote, crossCheck +A src/model/change-store.ts .ignatius/ I/O: baseline, notes, gitignore, approve +M src/server/server.ts baseline on start, diff in /api/model, POST /api/approve +M src/cli/cli.ts approve subcommand +M src/app/hooks/useModelData.ts expose diff +M src/app/views/graph/GraphView.tsx change data on elements, ghost nodes/edges +M src/app/views/graph/styles.ts steady edge glows, dimmed-tier edge glows +A src/app/views/graph/change-glow.ts glow and ghost-badge canvases, pulse loop, glow colors +A src/app/components/changes/ChangesPanel.tsx notes, unexplained, claims, toggle, approve +M src/app/App.tsx panel mount, toggle state, navigation +M src/app/views/dict/DictionaryView.tsx entity change badge +M src/app/components/entity/ColumnsTable.tsx column markers, removed rows +A src/app/components/changes/ChangeBadge.tsx added/changed/removed + unexplained badge +M src/app/components/entity/EntityCard.tsx change badge +M src/app/components/entity/GridCard.tsx change badge +M src/app/components/entity/EntityModal.tsx change badge +A src/app/logic/change-marks.ts shared per-entity and per-flow-node marks, glow geometry +M src/app/views/flow/FlowsView.tsx pass flow change ids and the review to FlowChrome +M src/flow-view/FlowDiagramSvg.tsx node highlight +M src/flow-view/FlowChrome.tsx review to the index and level menus +M src/flow-view/FlowIndex.tsx row glows, ancestor dots, pane summary +M src/flow-view/LevelMenu.tsx pulsing and static dots +M src/app/styles.css panel and marker styles +M src/app/dom/theme-css-vars.ts CHANGE_COLORS, REMOVED_RED, REMOVED_TEXT single source +M src/app/components/findings/FindingsPanel.tsx shared side-panel chrome +M src/app/components/ui/Modal.tsx stacked backdrop click note for portaled dialogs +M src/app/components/ui/JsonValue.tsx dialog portaled to document.body, above the findings banner +M src/app/views/graph/layout-store.ts lastPosition read for ghost placement +M src/app/views/graph/organic-layout.ts gradeEdgeSpans takes an edge collection +M test/checks/test-layout-store.ts +A models/*/.gitignore, test/fixtures/*/.gitignore `.ignatius/` +M package.json test script clears in-place review state +A scripts/clear-review-state.ts removes models/*/.ignatius and test/fixtures/*/.ignatius +M src/model/wikilink.ts linkStateNeutral for body comparison +A test/checks/test-change-review.ts +A test/checks/test-change-review-serve.ts +A test/checks/test-change-review-graph.ts +A test/checks/test-change-review-dict-flow.ts +A test/checks/test-change-review-flow-index.ts +A test/checks/test-modal-above-banner.ts +A test/visual/screenshot-change-glow.ts glow captures per theme and motion setting +M skills/ignatius-modeling/SKILL.md core rule: change notes + model .gitignore +A skills/ignatius-modeling/references/change-notes.md +M skills/ignatius-modeling/references/model-flow.md +M skills/ignatius-modeling/references/verification.md +M skills/ignatius-modeling/references/dfd-authoring.md approve after bootstrap pointer +M skills/ignatius-modeling/references/discover-flow.md approve after bootstrap pointer +M skills/ignatius-modeling/references/reverse-engineering.md approve after bootstrap pointer +M docs/guides/getting-started.md +M docs/guides/modeling-skill.md +M README.md +M docs/wiki/feature-map.md +A docs/guides/change-review.md +M docs/guides/commands.md +M docs/guides/folder-format.md +``` + + +## Outline + + +``` +src/model/change-review.ts + ModelSnapshot — { version: 1, model, flows } + ChangeItem — tagged union by scope: entity, column, relationship, doc, flow + ChangeNote — file, title, entities map, flows map, bodyHtml + ChangeReview — items with status, claims, notes, noteErrors, removedNodes, removedEdges + diffSnapshots — baseline vs current → ChangeItem[] + parseChangeNote — note file text → ChangeNote or error + crossCheck — items + notes + baseline → ChangeReview +src/model/change-store.ts + readBaseline — load or null + writeBaseline — snapshot current parse, ensure .ignatius/ and .gitignore + ensureGitignore — create or append the .ignatius/ line + loadNotes — read .ignatius/changes/*.md + buildReview — parse current, load baseline (create if absent), diff, cross-check + approve — write baseline, delete notes, return counts +src/server/server.ts + serveCommand — create baseline at start; diff in /api/model; POST /api/approve; poll baseline.json mtime so a CLI approve refreshes tabs +src/model/wikilink.ts + linkStateNeutral — body HTML with wiki-link target existence erased, for comparison +scripts/clear-review-state.ts + clear — remove in-place review state from repo models and fixtures before checks +src/cli/cli.ts + approveCmd — resolve model, approve, print counts +src/app/components/changes/ChangesPanel.tsx + ChangesPanel — notes, unexplained, claims, note errors, overlay toggle, approve +src/app/views/graph/GraphView.tsx + element build — change data, ghost nodes and edges when overlay on +src/app/views/graph/styles.ts + change selectors — steady edge glow per kind, unexplained blend, ghost styling, dimmed-tier edge glow +src/app/views/graph/change-glow.ts + glowColors — kind, muted, and red glow colors per theme mode + createChangeGlow — glow canvas behind cytoscape, ghost-badge canvas above it, one animation-frame loop over marked elements + refresh — re-read marks; pulse only when motion is allowed + destroy — cancel the loop and remove both canvases +src/app/components/entity/ColumnsTable.tsx + column markers — added/changed markers, removed rows +src/app/logic/change-marks.ts + entityChanges — per-entity mark from review items + flowNodeMarks — per-node marks for one diagram path + flowRowChanges — a flow index or level menu row's mark, changed fields, and worst change inside + GLOW_BLUR, GLOW_SPREAD — glow blur and spread shared by graph and flow +src/app/components/changes/ChangeBadge.tsx + ChangeBadge — added/changed/removed label with unexplained marker + ChangeDot — static "contains changes" dot in the worst change's color, or a pulsing dot for a row's own mark +src/flow-view/FlowDiagramSvg.tsx + ChangeGlow — blurred kind-color layer, red layer when unexplained + flow-svg root — data-motion="off" over the animation limit +src/flow-view/FlowIndex.tsx + row marks — glow and dot per row + pane change summary — kind, changed fields, unexplained +src/flow-view/LevelMenu.tsx + row marks — one dot per entry: pulsing for its own mark, else static +src/flow-view/FlowChrome.tsx + changeReview prop — passed to the index and level menus +skills/ignatius-modeling/references/change-notes.md + when to write a note — per edit session + note format — frontmatter and body + model .gitignore — the .ignatius/ line +docs/guides/change-review.md + what it shows + lifecycle + note format + approve +``` + + +## Flows + + +**First serve** + +1. User runs `ignatius serve`. +2. Server finds no `.ignatius/baseline.json`, writes it from the current parse, and ensures `/.gitignore` lists `.ignatius/`. +3. `/api/model` returns `diff.items: []`; no highlights, no Changes panel. + +**Agent edit under review** + +1. Agent edits `data/billing/Invoice.md` (adds `line_total`) and writes `.ignatius/changes/split-invoice-lines.md`. +2. The watcher broadcasts `model-changed`; the client refetches `/api/model`. +3. Server diffs baseline vs current and cross-checks against the note. +4. Graph marks `Invoice` changed; Dictionary marks the `line_total` row added; the Changes panel shows the note with its covered item. + +**Unexplained and unchanged** + +1. Agent also edits `Customer.md` without naming it in a note, and names `Payment` though it did not change it. +2. Changes panel lists the `Customer` item under unexplained and the `Payment` claim under claimed but unchanged. + +**Approve** + +1. User runs `ignatius approve` or clicks Approve. +2. Baseline is rewritten from the current parse; `.ignatius/changes/*.md` are deleted. +3. Serve broadcasts `model-changed` (the Approve button directly; a CLI `approve` through a stat poll on `.ignatius/baseline.json`, started after serve's own startup write); highlights and the panel clear. + + +## Risks + + +- Baseline written by a newer ignatius with a changed `Model` shape: a diff of mismatched shapes produces noise. `version: 1` lets a future change detect and rebuild it. +- Deleting notes on approve removes the only copy of the agent's explanation. Accepted: notes are review state, and the user approves after reading them. +- Ghost nodes enter cytoscape layout. They must be excluded from saved positions and the layout fingerprint, or approving would shift the layout. + + +## Change log + + +### 2026-10-10 — A new diagram marks everything inside it + +**What changed:** SC9 marks every row inside a newly added diagram, and every node on its canvas, `added` with the diagram item's unexplained status. The Dictionary's removed-marker rules move from SC10 to SC8. The CP4 row and change tree list the flow index files and the modal fix. + +**Why:** The final audit found leaf rows in a new diagram unmarked while their decomposed siblings glowed, and a new diagram's canvas drew no glows. + +**Superseded:** only a row's own process item or the diagram it opens marked it, and a new diagram's canvas drew no glows. + +### 2026-10-10 — Removed items read as warnings + +**What changed:** SC10 gives removed rows in the Changes panel a solid red bar, a red ✕, and a red `removed`; the Dictionary's removed badge and column markers take the same red, which the ghost badge now reads from `REMOVED_RED` in `theme-css-vars.ts`. Text uses `REMOVED_TEXT`, lighter in dark mode for contrast. A removed card dims its frame and content but not its badge. + +**Why:** User asked for removals to read as warnings; the dashed grey bar read as inactive. + +**Superseded:** removed rows had a dashed grey bar, and the Dictionary removed badge was dashed and muted. + +### 2026-10-10 — Change marks in the flow index and level menus + +**What changed:** SC9 extends to the flow index and the breadcrumb level menus: a row named by a process or diagram item glows like a flow node (a level menu row as a pulsing dot), an ancestor of changes shows a static dot colored by the worst of them, and the index pane summarizes a marked row's change. The outline and change tree add `flowRowChanges`, `ChangeDot`, the three flow-view components, and the new check. + +**Why:** User found no change marks in the process hierarchy, which is how a reviewer finds a changed process in a deep tree. + +### 2026-10-10 — Pulsing glows replace outlines + +**What changed:** SC7 and SC9 draw change marks as pulsing glows: blurred shapes on a canvas behind cytoscape for graph nodes and a thin underlay for edges, driven by one shared loop, and a blurred SVG layer animated by CSS keyframes on flow diagrams. Both views share one blur radius and spread, kept tight to the node edge, and one 3.6s pulse cycle; the graph glow is clipped to the outside of its node. Ghost nodes gain a full-opacity red ✕ badge, since the translucent dashed ghost alone read too faintly. Unexplained items alternate their kind color with red. Reduced motion and the animation limit hold glows steady. The change tree and outline add `change-glow.ts` and the glow capture script. + +**Why:** User asked for pulsing glows instead of borders. Cytoscape has no blur style, so a node underlay drew a hard-edged frame; node glows moved to a canvas. + +**Superseded:** graph nodes carried a 3px `outline-*` in the change color with a red underlay when unexplained; flow nodes carried a stroked outline rect with a red underlay rect. + +### 2026-10-09 — Final audit: body comparison, CLI approve refresh, body currency + +**What changed:** SC8 records the Removed section and that body comparison ignores wiki-link target existence; SC11 records the skill's approve-before-first-write rule; the Approve flow states how a CLI approve refreshes open tabs; the revert non-goal states the note must go too; the change tree lists every delivered file. + +**Why:** The final audit reproduced `doc` items on untouched entities after deleting a linked entity, found CLI approve left open tabs highlighted, and found shipped behaviour missing from the body. + +**Correction:** SC13 said "no errors" in touched files; the gate the loop ran and the repo can meet is "no new error kinds", because the browser checks share the repo's pre-existing unresolved cytoscape typings. + +### 2026-10-09 — CP5 review: synthetic diagrams produce no items + +**What changed:** SC1 excludes the derived `__context__`/`__system__` diagrams from the flow diff. + +**Why:** CP5 review reproduced permanent unexplained items under synthetic ids that no note can name and the panel hides. + +### 2026-10-09 — CP4: item fields for Dictionary and Flow rendering + +**What changed:** SC1 adds `def` (baseline `ColumnDef`) on removed column items and `storeKind` on flow store items. Change tree, outline, and the CP4 row add `change-marks.ts`, `ChangeBadge.tsx`, and the entity card/modal files. + +**Why:** The Dictionary draws removed columns from the baseline definition, and the Flow view must not highlight a same-name store of another kind. + +### 2026-10-09 — CP2 review: failure isolation and approve origin + +**What changed:** SC4 adds `diff: null` when the review cannot be built and the baseline shape check; SC6 adds the approve origin and `Host` rules. + +**Why:** CP2 review reproduced a 500 on `/api/model` for a read-only model folder and for a wrong-shape baseline, and showed `POST /api/approve` reachable cross-origin and by DNS rebinding. + +### 2026-10-09 — CP1 review: keys and fields + +**What changed:** SC1 names the full entity field list (adds `singleton` and `subtypes`), keys stores by `kind:name`, compares flow endpoints by resolved `kind:name`, and keys diagrams by ancestor path with `diagramPath` on flow items. SC2 gives `crossCheck` its third `baseline` argument. + +**Why:** CP1 review found store shadowing (`db:x` vs `cache:x`), sub-DFD id collisions across parents, and subtype edits producing no item. + +### 2026-10-09 — Initial spec + +**What changed:** First version. + +**Why:** User asked for a visible "what changed since last approval" in the rendered model after AI edits. + + +## Implementation log + + +### follow-up — 2026-10-10 + +Follow-up rounds on the same PR, squashed into its commit: pulsing glows replace outlines in the graph and flow views, with a red ✕ badge on ghost nodes; change marks in the flow index and level menus; removed items read in warning red; a new diagram marks every row and node inside it. + +**Out-of-scope work performed during this build:** + +- Modals stack above the global findings banner (`Modal.tsx`, `JsonValue.tsx`, `test-modal-above-banner.ts`). No criterion asks for it; main's CI fails `test-graph-bundle-mode` on the current runner because the banner covers a modal's close button, so this PR could not pass CI without it. + +### shipped — 2026-10-09 + +Built across 5 checkpoints plus one post-audit iteration of /autopilot, squashed into one PR commit. Pre-squash commits (chronological): + +- `379b93c` — CP1 diff snapshots and cross-check change notes +- `4ca01eb` — CP2 baseline storage, `/api/model` diff, `POST /api/approve`, `ignatius approve` +- `291d400` — CP3 graph highlights, ghost nodes, Changes panel +- `7991a36` — CP4 Dictionary and Flow highlights +- `029e811` — CP5 skill change notes and guides; derived diagrams excluded from the flow diff +- `5ec4386` — feature-map row +- `805b385` — post-audit: link-state-neutral body diff, CLI approve refresh via stat poll, dual-FK labels + +**Out-of-scope work performed during this build:** + +- A committed `.gitignore` (`.ignatius/`) in every repo model and fixture, and `scripts/clear-review-state.ts` before checks, because checks serve repo models in place. +- `/api/flow` failure no longer prevents the entity model from loading at boot. + +**Unforeseens — surprises that emerged during implementation:** + +- Bun's recursive `fs.watch` on macOS replays folder creation late and often misses the baseline rename; the CLI approve refresh uses `fs.watchFile` polling instead. +- Rendered body HTML depends on wiki-link target existence; body comparison normalizes it. +- Derived `__context__`/`__system__` diagrams repeated leaf nodes under ids no note can name; they are skipped. + +**Deferred items still open:** + +- None from the review ledger. Pre-existing on base and untouched: a selection made from a panel row is lost on any SSE model refresh; `test-sse-live-reload.ts`, `test-cli-binary.ts`, and `test-large-model-nav.ts` fail or flake on base in this environment. diff --git a/docs/wiki/cli.md b/docs/wiki/cli.md index 28f6a17..59a3f95 100644 --- a/docs/wiki/cli.md +++ b/docs/wiki/cli.md @@ -8,7 +8,7 @@ tags: [cli, dispatch, model-resolution] ## What it does -[`src/cli/cli.ts`](../../src/cli/cli.ts) is the single entry point a user or CI job runs: the compiled `dist/ignatius` binary, or `bun src/cli/cli.ts` in a dev checkout. `cli.ts` registers nine subcommand definitions (`serve`/`server` share one, so `server` is an alias, not a tenth). Every other domain in this repo (server, parser, validate, flows, generators, router) is reached only through one of those nine — there is no other production caller of `serveCommand` or `generateApp`. `parseModels`, though, is also called directly by [`src/server/server.ts`](../../src/server/server.ts), and `buildRouters` also directly by `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts) (see Coupling). Two responsibilities exist outside dispatch: finding which directory on disk is "the model" when the user did not say, and keeping the installed binary current against GitHub Releases, streaming the download with a live progress line rather than buffering it. +[`src/cli/cli.ts`](../../src/cli/cli.ts) is the single entry point a user or CI job runs: the compiled `dist/ignatius` binary, or `bun src/cli/cli.ts` in a dev checkout. `cli.ts` registers the subcommand definitions (`serve`/`server` share one, so `server` is an alias). The CLI reaches every other domain in this repo (server, parser, validate, flows, generators, router, model change review) only through those subcommands. The one other caller of `serveCommand` is the dev-only `import.meta.main` block in [`src/server/server.ts`](../../src/server/server.ts); nothing else calls `generateApp`. `parseModels`, though, is also called directly by [`src/server/server.ts`](../../src/server/server.ts), and `buildRouters` also directly by `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts) (see Coupling). Two responsibilities exist outside dispatch: finding which directory on disk is "the model" when the user did not say, and keeping the installed binary current against GitHub Releases, streaming the download with a live progress line rather than buffering it. ## How it works @@ -18,11 +18,12 @@ tags: [cli, dispatch, model-resolution] | `validate` | `path`, `--model`, `--index` | `parseModels` → `validateModel`; folds in `parseFlows`/`validateFlows` when `/flows` exists; `--index` additionally calls `validateIndex` to recompute router digests and report drift, without writing anything | | `index` | `path`, `--model`, `--agents` | Full parse + validate + flow pipeline, then `buildRouters`/`writeRouters` writes `index.md` routers into every organizing folder; `--agents` also writes `AGENTS.md`/`SKILL.md` and, when the harness resolves to Claude, [`CLAUDE.md`](../../CLAUDE.md) | | `export` | `path`, `--out`/`-o` (required), `--theme` (`light`\|`dark`), `--model` | Same parse/validate/flow pipeline as `validate`, then `loadEmbeddedBundle()` + `generateApp()` writes one self-contained HTML file | +| `approve` | `path`, `--model` | Resolves the model dir via `pickModel`, then calls `approve(dir)` from [`src/model/change-store.ts`](../../src/model/change-store.ts): the current parse becomes the change-review baseline and every change note is deleted. Prints `Approved change(s), cleared note(s).` | | `dict`, `graph`, `flow` | none | Removal stubs: write `" was removed — use: ignatius export -o model.html"` to stderr, exit 1 | | `version` | none | Prints `VERSION` (baked from [`package.json`](../../package.json) at compile time) | | `update` | `--check`, `--yes`/`-y` | Delegates to `runUpdateCommand()`, exits with its returned code | -`validate`, `index`, and `export` all resolve the model dir through `pickModel` before doing anything else, then run `parseModels` → `validateModel`. `validate` and `export` call `parseFlows`/`validateFlows` only when `/flows` exists, and diverge from each other only at the last step; `index` calls the same two functions unconditionally, with no existence guard, so it diverges from `validate`/`export` mid-pipeline, not just at the end. +`serve`, `validate`, `index`, `export`, and `approve` all resolve the model dir through `pickModel` before doing anything else. `validate`, `index`, and `export` then run `parseModels` → `validateModel`. `validate` and `export` call `parseFlows`/`validateFlows` only when `/flows` exists, and diverge from each other only at the last step; `index` calls the same two functions unconditionally, with no existence guard, so it diverges from `validate`/`export` mid-pipeline, not just at the end. ### Model resolution @@ -41,7 +42,7 @@ flowchart TD I -->|no| C ``` -`resolveModel` ([`src/cli/discover.ts`](../../src/cli/discover.ts)) itself has no TTY dependency: it checks `base/ignatius.yml` first, then searches down (skipping `_`-prefixed dirs and `node_modules`, `.git`, `dist`, `tmp`, [`trash/`](../../trash), `.worktrees/`, [`.claude/`](../../.claude), treating any directory holding `ignatius.yml` as a leaf), then walks up if nothing was found below. `pickModel` ([`src/cli/resolve-model.ts`](../../src/cli/resolve-model.ts)) is the only file that imports `@clack/prompts`'s `select` specifically, so the TTY-gated `select` prompt can never fire inside a spawned, non-interactive process. +`resolveModel` ([`src/cli/discover.ts`](../../src/cli/discover.ts)) itself has no TTY dependency: it checks `base/ignatius.yml` first, then searches down (skipping `_`-prefixed dirs and `node_modules`, `.git`, `dist`, `tmp`, [`trash/`](../../trash), `.worktrees/`, [`.claude/`](../../.claude), treating any directory holding `ignatius.yml` as a leaf), then walks up if nothing was found below. `pickModel` ([`src/cli/resolve-model.ts`](../../src/cli/resolve-model.ts)) exits 2 on non-TTY stdin before reaching `select`; it is also the only file that imports `select`. ### Shared validate/export pipeline @@ -58,6 +59,8 @@ flowchart TD F -->|export| H[generateApp] ``` +With `--index`, `validate` calls `parseFlows` even when `/flows` is absent, because `validateIndex` needs a flow model to hash router targets. + `validate --index` and `index` both end up computing router digests via `buildRouters`, but only `index` calls `writeRouters` to persist them; `validate --index` reports `index.stale`/`index.orphaned` findings and writes nothing to disk. ### index pipeline @@ -78,7 +81,7 @@ Two other subsystems sit off this pipeline. `serveWithPortFallback` ([`src/cli/s ### Self-update decision path -`runUpdateCommand` exits 0 at every disqualifying condition; `checkForUpdate` and `downloadAndReplace` are the only two calls that can exit 1: +`runUpdateCommand` exits 0 at every disqualifying condition; only `checkForUpdate` and `downloadAndReplace` can exit 1: ```mermaid flowchart TD @@ -114,16 +117,15 @@ flowchart TD G -->|rename fails| F ``` -A genuine checksum mismatch aborts and deletes the staged file; an unreachable `checksums.txt` does not block the install, so verification is best-effort by design, not by accident. The staging file is named `.{basename(target)}.update-{pid}` next to `target` so the final `renameSync` is same-filesystem and atomic; overwriting the running binary this way is safe on Unix because the live process keeps its old inode until it exits. Each of those three throw paths (chunk-read failure, checksum mismatch, rename failure) unlinks the staging file first. The `chmodSync(tmp, 0o755)` call between checksum verification and rename sits outside any try/catch, though: if it throws (e.g. a read-only staging directory), the error propagates without unlinking `tmp`, leaving the staged file behind. +A genuine checksum mismatch aborts and deletes the staged file; an unreachable `checksums.txt` does not block the install, so verification is best-effort by design, not by accident. The staging file is named `.{basename(target)}.update-{pid}` next to `target` so the final `renameSync` is same-filesystem and atomic; overwriting the running binary this way is safe on Unix because the live process keeps its old inode until it exits. Each of the throw paths listed (chunk-read failure, checksum mismatch, rename failure) unlinks the staging file first. The `chmodSync(tmp, 0o755)` call between checksum verification and rename sits outside any try/catch, though: if it throws (e.g. a read-only staging directory), the error propagates without unlinking `tmp`, leaving the staged file behind. ### Progress renderer state -`downloadProgressRenderer` is a pure `\r`-rewriting status line that latches shut once the download completes, so a stray final tick can never reprint the 100% line: +Off a TTY, `downloadProgressRenderer` returns null and no state machine runs. On a TTY it is a pure `\r`-rewriting status line that latches shut once the download completes, so a stray final tick can never reprint the 100% line: ```mermaid stateDiagram-v2 [*] --> Streaming: isTTY - [*] --> [*]: off-TTY, returns null Streaming --> Streaming: received < total, or total unknown
writes \r-line, no newline Streaming --> Done: received >= total
writes \r-line + final newline Done --> Done: any later call
writes nothing @@ -135,9 +137,9 @@ stateDiagram-v2 | Path | Responsibility | |---|---| -| [`src/cli/cli.ts`](../../src/cli/cli.ts) | citty `defineCommand`/`runMain` entry point; registers nine subcommand definitions (`server` is an alias of `serve`) | +| [`src/cli/cli.ts`](../../src/cli/cli.ts) | citty `defineCommand`/`runMain` entry point; registers the subcommand definitions (`server` is an alias of `serve`); statically imports `approve` from [`src/model/change-store.ts`](../../src/model/change-store.ts) | | [`src/cli/discover.ts`](../../src/cli/discover.ts) | `resolveModel(base, opts)` — pure, TTY-agnostic model-root search; exports the `ModelCandidate`/`ResolveResult` types | -| [`src/cli/resolve-model.ts`](../../src/cli/resolve-model.ts) | `pickModel(base, modelKey)` — the shared resolution+prompt layer used by `serve`, `validate`, `index`, `export`; the only file that imports `@clack/prompts`'s `select` | +| [`src/cli/resolve-model.ts`](../../src/cli/resolve-model.ts) | `pickModel(base, modelKey)` — the shared resolution+prompt layer used by `serve`, `validate`, `index`, `export`, `approve`; the only file that imports `@clack/prompts`'s `select` | | [`src/cli/serve-port.ts`](../../src/cli/serve-port.ts) | `serveWithPortFallback`, `findAvailablePort`, `isAddrInUse` — port-conflict recovery for `serve` | | [`src/cli/open-browser.ts`](../../src/cli/open-browser.ts) | `browserOpenCommand(platform, url)` (pure) and `openBrowser()` (fire-and-forget `Bun.spawn`); dynamically imported by `cli.ts` only when `--open` is passed | | [`src/cli/version.ts`](../../src/cli/version.ts) | `VERSION`, a JSON import of [`package.json`](../../package.json) that Bun inlines at `bun build --compile` time | @@ -157,18 +159,19 @@ stateDiagram-v2 Other constraints observed in the source: -- `validate`, `export`, and `index` all read `RULES[ruleId].class` (`'B'` = error) to decide their exit code rather than each finding's own severity field, so the exit code and the rule registry cannot silently diverge; all three share the identical `allGlobalErrors.length > 0 || hasClassBFlowErrors` exit condition. +- `approve` is a static import in `cli.ts`, not a lazy one: [`src/server/server.ts`](../../src/server/server.ts) imports `change-store` statically too, and lazy-importing a module the server also imports statically makes the compiled binary throw `__promiseAll is not defined` at startup. +- `validate`, `export`, and `index` all read `RULES[ruleId].class` (`'B'` = error) to decide their exit code rather than each finding's own severity field, so the exit code and the rule registry cannot silently diverge; all three reach an equivalent exit condition (`validate` writes it as `errorCount > 0 || hasClassBFlowErrors`). - `dict`, `graph`, and `flow` stay registered as citty subcommands (rather than being removed outright) purely so they can print a redirect message to `export` — deleting them would surface citty's generic "unknown command" error instead. -- `@clack/prompts` is imported dynamically, only inside the three files that need a TTY prompt (`resolve-model.ts`, `serve-port.ts`, `update.ts`); importing it eagerly in `cli.ts` would risk its TTY-gated prompts firing inside a spawned, non-interactive process, such as a CI job invoking the compiled binary with no attached terminal. -- `update.ts`'s checksum verification is best-effort: a genuine sha256 mismatch against `checksums.txt` aborts the update, but an unreachable `checksums.txt` does not block it. -- On a permission-denied write to `target`, matched by regex against the error message (`EACCES`/`EPERM`/`EROFS`, or the words "permission"/"denied"), `runUpdateCommand` prints a `sudo ignatius update --yes` suggestion and a `curl … install.sh` reinstall fallback instead of the generic "update failed" message. -- The compiled binary or `bun src/cli/cli.ts` is the only production entry point; five [`test/checks/`](../../test/checks) files (`test-discover.ts`, `test-serve-port.ts`, `test-open-browser.ts`, `test-update-helpers.ts`, `test-update-progress.ts`) import individual [`src/cli/`](../../src/cli) modules directly for unit testing, so a signature change to any of those exports breaks a check even when `cli.ts`'s own dispatch logic hasn't changed. `test-update-progress.ts` covers `downloadProgressRenderer` only (off-TTY null, mid-stream no-newline, 100%-latch, unknown-total); the network and self-replace paths in `update.ts` are exercised manually against a real release, not by a check. +- `@clack/prompts` is imported dynamically, only inside the files that need a prompt (`resolve-model.ts`, `serve-port.ts`, `update.ts`). The prompts are lazy-loaded and TTY-guarded at each call site. +- `update.ts`'s checksum verification is best-effort: a genuine sha256 mismatch against `checksums.txt` aborts the update, but an unreachable `checksums.txt` does not block it, so the update installs an unverified binary. +- The compiled binary or `bun src/cli/cli.ts` is the only production entry point ([`src/server/server.ts`](../../src/server/server.ts) has a dev-only `import.meta.main` entry); [`test/checks/`](../../test/checks) files (`test-discover.ts`, `test-serve-port.ts`, `test-open-browser.ts`, `test-update-helpers.ts`, `test-update-progress.ts`) import individual [`src/cli/`](../../src/cli) modules directly for unit testing, so a signature change to any of those exports breaks a check even when `cli.ts`'s own dispatch logic hasn't changed. `test-update-progress.ts` covers `downloadProgressRenderer` only (off-TTY null, mid-stream no-newline, 100%-latch, unknown-total); the network and self-replace paths in `update.ts` are exercised manually against a real release, not by a check. ## Coupling - `cli.ts` calls into **server** (`serveCommand`, reached through `serve-port.ts`), **parser** (`parseModels` in [`src/model/parse.ts`](../../src/model/parse.ts)), **validate** (`validateModel`, `formatFindingsForStderr`, `RULES`, `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts)), **flows** (`parseFlows` in [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts), `validateFlows` in [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts)), and **generators** (`loadEmbeddedBundle` in [`src/generators/embedded-bundle.ts`](../../src/generators/embedded-bundle.ts), `generateApp` in [`src/generators/app.ts`](../../src/generators/app.ts)) — a signature change in any of those exports forces a change in `cli.ts`. -- `cli.ts`'s `index` subcommand additionally calls into **router**, the new domain added by this range: `buildRouters` and `writeRouters` ([`src/router/build.ts`](../../src/router/build.ts), [`src/router/write.ts`](../../src/router/write.ts)), and — only under `--agents` — `resolveHarness` ([`src/router/detect.ts`](../../src/router/detect.ts)) and `writeGuidance` ([`src/router/agents.ts`](../../src/router/agents.ts)). `validate --index` reaches the same router domain indirectly, through `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts), which dynamically imports `buildRouters` itself. -- `validate`, `index`, and `export` all read `RULES[ruleId].class` from **validate** to decide their exit code — renaming or restructuring the rule-class scheme in [`src/model/validate.ts`](../../src/model/validate.ts) breaks all three subcommands' exit-code logic. +- `cli.ts`'s `index` subcommand additionally calls into **router**: `buildRouters` and `writeRouters` ([`src/router/build.ts`](../../src/router/build.ts), [`src/router/write.ts`](../../src/router/write.ts)), and — only under `--agents` — `resolveHarness` ([`src/router/detect.ts`](../../src/router/detect.ts)) and `writeGuidance` ([`src/router/agents.ts`](../../src/router/agents.ts)). `validate --index` reaches the same router domain indirectly, through `validateIndex` in [`src/model/validate.ts`](../../src/model/validate.ts), which dynamically imports `buildRouters` itself. +- `validate`, `index`, and `export` all read `RULES[ruleId].class` from **validate** to decide their exit code — renaming or restructuring the rule-class scheme in [`src/model/validate.ts`](../../src/model/validate.ts) breaks those subcommands' exit-code logic. +- `cli.ts`'s `approve` subcommand calls `approve` in [`src/model/change-store.ts`](../../src/model/change-store.ts), the same module that `buildReview` and the server's change-review path use (**parser** and **server** domains); a change to the `{ items, notes }` return shape breaks the printed summary line. - `update.ts` talks only to GitHub Releases (`github.com/noormdev/ignatius`) and the local filesystem; it has no dependency on any other domain in this repo, and no other domain calls into it outside `cli.ts`'s `update` subcommand. - The **skill** domain ([`skills/ignatius-modeling/`](../../skills/ignatius-modeling)) drives this domain from outside dispatch: `references/verification.md` shells out to `ignatius validate`, `ignatius validate --index`, `ignatius index`, and `ignatius index --agents` as its own write-verification step, coupling the skill's authoring loop to this domain's stderr format and exit codes. diff --git a/docs/wiki/docs.md b/docs/wiki/docs.md index a14b6ae..1f01fd2 100644 --- a/docs/wiki/docs.md +++ b/docs/wiki/docs.md @@ -10,7 +10,7 @@ tags: [design, spec, guide] ## What it does -[`docs/`](..) (excluding the generated [`docs/wiki/`](.)) is ignatius's documentation corpus, split into four directories that each answer a different question about a feature: why it exists, what its contract is, how to drive it, and what background research shaped it. A session that needs one of those answers goes to the directory that owns it, rather than searching the whole tree. [`docs/design/`](../design) states why a feature exists and which approach was chosen over its alternatives. [`docs/spec/`](../spec) is the implementation contract derived from a design: checkpoints, success criteria, and (for four specs so far) a change-tree/outline/flows triad. [`docs/guides/`](../guides) teaches a user how to drive the built feature. [`docs/research/`](../research) records background investigation that fed a design's option table. None of these files execute; every other domain's code and tests point back at them by name for the "why is it built this way" and "what is the contract" questions code alone can't answer. The corpus totals 81 markdown files plus [`docs/glossary.md`](../glossary.md): 31 in [`docs/design/`](../design), 38 in [`docs/spec/`](../spec), 10 in [`docs/guides/`](../guides), 2 in [`docs/research/`](../research). +[`docs/`](..) (excluding the generated [`docs/wiki/`](.)) is ignatius's documentation corpus, split into directories that each answer a different question about a feature: why it exists, what its contract is, how to drive it, and what background research shaped it. A session that needs one of those answers goes to the directory that owns it, rather than searching the whole tree. [`docs/design/`](../design) states why a feature exists and which approach was chosen over its alternatives. [`docs/spec/`](../spec) is the implementation contract derived from a design: checkpoints, success criteria, and (for specs drafted under the current rule) a change-tree/outline/flows triad. [`docs/guides/`](../guides) teaches a user how to drive the built feature. [`docs/research/`](../research) records background investigation that fed a design's option table. None of these files execute; every other domain's code and tests point back at them by name for the "why is it built this way" and "what is the contract" questions code alone can't answer. [`docs/glossary.md`](../glossary.md) holds the shared vocabulary. [`README.md`](../../README.md) states the design/spec relationship directly: "Conceptual designs live in [`docs/design/`](../design); the implementation contracts derived from them live in [`docs/spec/`](../spec)." @@ -18,24 +18,27 @@ tags: [design, spec, guide] ## How it works -A feature's four surfaces are written by hand, in sequence, and kept in sync by a fifth file that is neither generated nor derived from any of them. +A feature's surfaces are written by hand, in sequence, and kept in sync by a separate file that is neither generated nor derived from any of them. ```mermaid -flowchart LR - A[design doc: the why] --> B[spec: the contract] - B --> C[guide: the how] - C --> D[skill reference: the authoring loop] - A -.tracked in.-> E[feature-map.md] +flowchart TD + %% source: docs/wiki/feature-map.md; docs/design, docs/spec, docs/guides + A["design doc: the why"] --> B["spec: the contract"] + B --> C["guide: the how"] + C --> D["skill reference: the authoring loop"] + A -.tracked in.-> E["feature-map.md"] B -.tracked in.-> E C -.tracked in.-> E D -.tracked in.-> E ``` -A design doc that never gets a spec (`markdown-driven-erd.md`) or a spec with no design doc (eight of them, see Where it lives) are both valid end states; `feature-map.md` records the actual per-feature surface set rather than assuming every feature has all four. +A design doc that never gets a spec (`markdown-driven-erd.md`) or a spec with no design doc (see Where it lives) are both valid end states; `feature-map.md` records the actual per-feature surface set rather than assuming every feature has all four. ### A spec's body describes only current truth; correction and rename are explicit states -[`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) alone carries four dated `## Change log` entries that append a **Superseded:** line rather than leaving the old text in place ("Reserved-name skip covers every scan, not just `data/`", "Correction: routers follow the folder tree, not declared groups", "Audit corrections", "Region boundaries are position-based"); a fifth entry, "Correction: `build.ts` does I/O", amends the body but carries no **Superseded:** line. [`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) and [`docs/design/noorm-modeling-skill.md`](../design/noorm-modeling-skill.md) show the renamed-file end state: both are 12-line stubs whose body is one sentence pointing at `ignatius-modeling-skill.md`, kept only so a rename-era grep still finds them. +[`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) carries dated `## Change log` entries that append a **Superseded:** line rather than leaving the old text in place ("Reserved-name skip covers every scan, not just `data/`", "Correction: routers follow the folder tree, not declared groups", "Audit corrections", "Region boundaries are position-based"); another entry, "Correction: `build.ts` does I/O", amends the body but carries no **Superseded:** line. [`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) and [`docs/design/noorm-modeling-skill.md`](../design/noorm-modeling-skill.md) show the renamed-file end state: both are stubs whose body is one sentence pointing at `ignatius-modeling-skill.md`, kept only so a rename-era grep still finds them. + +A spec body is only ever Current; amendments rewrite the body and append a log entry, and a rename leaves a stub. ```mermaid stateDiagram-v2 @@ -48,122 +51,101 @@ stateDiagram-v2 ### The change-tree / outline / flows triad is opt-in by spec age, not by feature size -[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md), [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md), [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md), and [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) are the only four of 38 specs carrying `## Change tree`, `## Outline`, and `## Flows` sections; the other 34 predate the rule that requires them and are not backfilled by an unrelated amendment. 29 of 38 specs also carry a `## Implementation log` (narrative build history: checkpoints landed, out-of-scope work performed, unforeseens, deferred items) — a section distinct from `## Change log`, which records contract amendments, not build narrative. +[`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md), [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md), [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md), [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md), and [`docs/spec/model-change-review.md`](../spec/model-change-review.md) are the specs carrying `## Change tree`, `## Outline`, and `## Flows` sections; the older specs predate the rule that requires them and are not backfilled by an unrelated amendment. Most specs also carry a `## Implementation log` (narrative build history: checkpoints landed, out-of-scope work performed, unforeseens, deferred items) — a section distinct from `## Change log`, which records contract amendments, not build narrative. ## Where it lives -### [`docs/design/`](../design) — conceptual/approach docs (31 files) - -| Path | Lines | Covers | -|------|-------|--------| -| [`docs/design/model-index-routing.md`](../design/model-index-routing.md) | 476 | Per-folder generated routers (`index.md`), rolled-up SHA digests, `` managed regions, `index_file:`/`harness:` config, in-folder `AGENTS.md`/[`CLAUDE.md`](../../CLAUDE.md)/`SKILL.md` agent guidance | -| [`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) | 333 | Canonical source for the markdown-driven entity file format; no [`docs/spec/`](../spec) counterpart | -| [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) | 308 | Per-process vs. connected DFD views, prose `label:` on any flow entry, `clusters/` author-cluster files, the `cluster:` token, subtype-family and group collapse levels, adjacency stacking, the stack/contract/subtype/group dialog rules, the stacked-paper "more inside" marker construction, row-local store-kind identity and coloring inside a mixed stack | -| [`docs/design/process-flows.md`](../design/process-flows.md) | 218 | SSADM DFD subsystem: processes, externals, stores, sub-DFDs | -| [`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) | 205 | Schema lint + error UX | -| [`docs/design/noorm-flow-discovery.md`](../design/noorm-flow-discovery.md) | 179 | `ignatius-modeling` skill's `flow` and `discover` Q&A modes | -| [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) | 175 | Key-edge connected-component lineage (FK ⊆ child PK), associative-entity traversal barriers | -| [`docs/design/ignatius-modeling-skill.md`](../design/ignatius-modeling-skill.md) | 160 | The `ignatius-modeling` skill itself | -| [`docs/design/branding.md`](../design/branding.md) | 160 | Branding system | -| [`docs/design/unified-app.md`](../design/unified-app.md) | 152 | Unified SPA collapse (Graph/Dictionary/Flows in one app) | -| [`docs/design/viewer-fab-ux.md`](../design/viewer-fab-ux.md) | 144 | Floating action button UX | -| [`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) | 142 | `src/App.tsx` → [`src/app/`](../../src/app) decomposition | -| [`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md) | 135 | CLI modes and the static output approach | -| [`docs/design/example-instance-tables.md`](../design/example-instance-tables.md) | 135 | Example/sample-row instance tables | -| [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md) | 133 | 6-fix viewer-ux-polish batch | -| [`docs/design/folder-model.md`](../design/folder-model.md) | 104 | Folder-model migration (`_*`-prefix vs hoisted top-level folders) | -| [`docs/design/keyboard-nav-shortcuts.md`](../design/keyboard-nav-shortcuts.md) | 110 | Single-key keyboard navigation shortcuts | -| [`docs/design/ignatius-project-config.md`](../design/ignatius-project-config.md) | 107 | `ignatius.yml` config + model discovery | -| [`docs/design/graph-position-persistence.md`](../design/graph-position-persistence.md) | 118 | Graph node position persistence | -| [`docs/design/dict-navigation.md`](../design/dict-navigation.md) | 100 | Data-dictionary side navigation | -| [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) | 100 | DFD edge-hover data reveal | -| [`docs/design/large-model-nav.md`](../design/large-model-nav.md) | 102 | Hover-intent settle-then-apply dimming, the `ANIMATION_ELEMENT_LIMIT` animation cutoff, the SSADM flow index, breadcrumb level menus, and where a flow folder's `description:` lives | -| [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) | 93 | DFD viewer overhaul: Yourdon/SSADM leveling, ELK layout, 5-band partitioning | -| [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) | 84 | Search on Graph and Flows views | -| [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) | 75 | Arbitrary DFD nesting depth fix | -| [`docs/design/bidirectional-predicates.md`](../design/bidirectional-predicates.md) | 67 | Bidirectional predicate feature | -| [`docs/design/help-overlay.md`](../design/help-overlay.md) | 62 | View-aware help overlay | -| [`docs/design/dd-spotlight-grid.md`](../design/dd-spotlight-grid.md) | 60 | DD browse-lens spotlight grid; off-screen connection chips scroll to the target, then flash it only once the scroll settles | -| [`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) | 59 | Wiki-style `[[Entity]]` body links | -| [`docs/design/src-root-organization.md`](../design/src-root-organization.md) | 49 | [`src/`](../../src) top-level subdirectory split | -| [`docs/design/noorm-modeling-skill.md`](../design/noorm-modeling-skill.md) | 12 | Rename stub; points to `ignatius-modeling-skill.md` | - -### [`docs/spec/`](../spec) — implementation contracts (38 files) - -| Path | Lines | Covers | -|------|-------|--------| -| [`docs/spec/process-flows.md`](../spec/process-flows.md) | 690 | Largest spec; SSADM DFD: parse, 11 `flow.*` rules, viewer, sub-DFD drill-down, `db:` store dialog | -| [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) | 372 | `buildInheritedConnections` key-edge connected-component algorithm, DG/DD lineage reveal | -| [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | 411 | CP1–CP8: `label:` on every flow entry + contract dialog, `flow-clusters.ts` registry + `cluster:` token expansion, five `flow.cluster_*` rules, per-process/connected view stacking, collapse levels, row-local store-kind identity and kind-specific numbered caps inside a mixed stack, `StackDialog`/`EdgeContractDialog`; five `## Change log` entries plus an `## Implementation log` recording the 9-iteration shipped build and one open followup | -| [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | 276 | Router build/write, fingerprint roll-up, `index_file`/`harness` config, four `config.index_file_*`/`index.*` rules, `--agents` guidance files, a flow/sub-DFD folder row's description-folded digest | -| [`docs/spec/app-tsx-decomposition.md`](../spec/app-tsx-decomposition.md) | 246 | `App.tsx` decomposition | -| [`docs/spec/dd-spotlight-grid.md`](../spec/dd-spotlight-grid.md) | 239 | DD browse-lens spotlight grid | -| [`docs/spec/dfd-polish-round3.md`](../spec/dfd-polish-round3.md) | 238 | CP18–23 | -| [`docs/spec/render-perf-indexing.md`](../spec/render-perf-indexing.md) | 231 | Preset-layout cache-skip, ELK cost scaling, `buildModelIndex` | -| [`docs/spec/unified-app.md`](../spec/unified-app.md) | 216 | Unified SPA | -| [`docs/spec/ignatius-modeling-skill.md`](../spec/ignatius-modeling-skill.md) | 211 | `ignatius-modeling` skill contract | -| [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) | 207 | Graph/Flows search (SC1–SC12); SC12 no longer names a diagram nav card, since Flows has none | -| [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) | 217 | `createHoverIntent`/`HOVER_INTENT_MS`/`ANIMATION_ELEMENT_LIMIT` in `motion.ts`, flow folder `description:` through parse/leveling/router, `flow-nav.ts` navigation model, breadcrumb `LevelMenu`, the `FlowIndex` hierarchy chart, the `i` shortcut; carries a `## Change tree`/`## Outline`/`## Flows` triad | -| [`docs/spec/unified-app-polish.md`](../spec/unified-app-polish.md) | 194 | CP1–CP13 unified-app-polish batch | -| [`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) | 196 | `resolveShortcut`, `useKeyboardShortcuts`, the flow-only `i` binding | -| [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) | 180 | viewer-ux-polish batch | -| [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) | 170 | Example/sample-row instance tables | -| [`docs/spec/dfd-polish-round2.md`](../spec/dfd-polish-round2.md) | 169 | CP14–17 | -| [`docs/spec/dfd-polish-round4.md`](../spec/dfd-polish-round4.md) | 159 | CP24–26 | -| [`docs/spec/bidirectional-predicates.md`](../spec/bidirectional-predicates.md) | 157 | Bidirectional predicates | -| [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | 163 | DFD viewer overhaul; success criteria C1–C18 | -| [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) | 144 | CLI output modes and theme system | -| [`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) | 141 | Schema lint + error UX | -| [`docs/spec/folder-model.md`](../spec/folder-model.md) | 117 | Folder-model migration | -| [`docs/spec/ignatius-project-config.md`](../spec/ignatius-project-config.md) | 108 | `ignatius.yml` config loading + model discovery | -| [`docs/spec/graph-position-persistence.md`](../spec/graph-position-persistence.md) | 106 | Graph node position persistence | -| [`docs/spec/branding.md`](../spec/branding.md) | 102 | Branding | -| [`docs/spec/viewer-fab-ux.md`](../spec/viewer-fab-ux.md) | 101 | FAB UX | -| [`docs/spec/dict-navigation.md`](../spec/dict-navigation.md) | 90 | Dict side nav | -| [`docs/spec/dict-polish.md`](../spec/dict-polish.md) | 87 | Dict visual polish; no design-doc counterpart | -| [`docs/spec/noorm-flow-discovery.md`](../spec/noorm-flow-discovery.md) | 83 | `flow`/`discover` skill modes; skill-markdown-only | -| [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) | 83 | DFD edge-hover data reveal | -| [`docs/spec/src-root-organization.md`](../spec/src-root-organization.md) | 82 | [`src/`](../../src) directory split | -| [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | 80 | `downloadProgressRenderer` + a streamed, incrementally-hashed `downloadAndReplace` in [`src/cli/update.ts`](../../src/cli/update.ts); no design-doc counterpart | -| [`docs/spec/wiki-entity-links.md`](../spec/wiki-entity-links.md) | 79 | Wiki-entity links | -| [`docs/spec/derive-classification.md`](../spec/derive-classification.md) | 72 | 5-rule classification derivation; no design-doc counterpart | -| [`docs/spec/help-overlay.md`](../spec/help-overlay.md) | 70 | Help overlay; Flows body now covers the flow index and breadcrumb level switching | -| [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) | 69 | DFD nesting-depth fix | -| [`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) | 12 | Rename stub; points to `ignatius-modeling-skill.md` | - -Eight specs ship without a design-doc counterpart: `dict-polish.md`, `derive-classification.md`, `render-perf-indexing.md`, `unified-app-polish.md`, `dfd-polish-round2.md`, `dfd-polish-round3.md`, `dfd-polish-round4.md`, `update-download-progress.md`. Exactly one design doc ships without a spec: `markdown-driven-erd.md`. - -### [`docs/guides/`](../guides) — user-facing how-to (10 files) - -All ten are linked from [`README.md`](../../README.md)'s docs table. Six were updated for model-index-routing (marked below). - -| Path | Lines | Covers | -|------|-------|--------| -| [`docs/guides/folder-format.md`](../guides/folder-format.md) | 277 | ★ `ignatius.yml`, the six top-level folders including `clusters/`, entity/column/relationship authoring, `index_file:`/`harness:` config, generated routers, `description:` frontmatter, a flow folder's `description:` folding into its router row's hash | -| [`docs/guides/commands.md`](../guides/commands.md) | 176 | ★ The CLI subcommands including `index`/`index --agents`, `validate --index`, and the full keyboard-shortcut table including `i` (open or close the flow index) | -| [`docs/guides/flows.md`](../guides/flows.md) | 309 | ★ DFDs: processes, externals, stores, sub-DFDs, `description:` on process/external/store/flow folder, plus labels/stacks/clusters/groups — the contract dialog, the closed endpoint-prefix set and its one `cluster:` exception, the per-process vs. connected view split, the stores/clusters/groups collapse level, adjacency stacking and its `ignatius.yml` switch, the Flows FAB controls, the "☰ Process Flows" flow index and breadcrumb ▾ level menus, and the 300 ms hover-settle / 150-element animation cutoff | -| [`docs/guides/validation.md`](../guides/validation.md) | 142 | ★ The linter, severity tiers, the Config-rules/Index-rules tables (`config.index_file_*`, `index.stale`, `index.orphaned`, `index.unreadable_target`), and the five `flow.cluster_*` rules | -| [`docs/guides/getting-started.md`](../guides/getting-started.md) | 93 | ★ Install, build from source, serve the first model; command list now names `index` | -| [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) | 73 | ★ The `/ignatius-modeling` skill's Q&A modes; the `flow` mode's step list now names the cluster-membership decision and the flow label alongside the `db:`-or-`kind:` store decision; verification loop now runs `ignatius validate --index` | -| [`docs/guides/derivation.md`](../guides/derivation.md) | 45 | What gets derived (cardinality, classification, subtype clusters) vs authored by hand | -| [`docs/guides/predicates.md`](../guides/predicates.md) | 83 | Bidirectional relationship-edge label authoring | -| [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) | 83 | `theme`/`branding` blocks in `ignatius.yml` | -| [`docs/guides/building-from-source.md`](../guides/building-from-source.md) | 50 | Bun build stages, project layout, tests | - -### [`docs/research/`](../research) (2 files) - -| Path | Lines | Covers | -|------|-------|--------| -| [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) | 129 | DFD layout engines and Yourdon leveling; primary source for `dfd-overhaul`'s ELK algorithm choice | -| [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) | 118 | SSADM DFD rules | - -### [`docs/glossary.md`](../glossary.md) (67 lines) - -Canonical vocabulary table: DG (Data Graph), DD (Data Dictionary), DFD (Data Flow Diagram), DE (Data Entity), DS (Data Store), EE (External Entity), Process, Data Flow, plus the DS ⊃ DE relationship note, the `kind:` store taxonomy (`db`/`cache`/`queue`/`file`/`doc`/`manual`/`other`), and a "Flow view: stacks, clusters, and groups" table (flow label, cluster, group, stack, collapse level, per-process view, connected view, adjacency stack). - -[`docs/wiki/`](.) also lives under [`docs/`](..) as the generated signals wiki; it is separate, self-referential infrastructure, out of scope for this domain. +The guides are linked from [`README.md`](../../README.md)'s docs table. `markdown-driven-erd.md` has no spec; `dict-polish.md`, `derive-classification.md`, `render-perf-indexing.md`, `unified-app-polish.md`, `dfd-polish-round2.md`, `dfd-polish-round3.md`, `dfd-polish-round4.md`, and `update-download-progress.md` have no design doc. [`docs/wiki/`](.) also lives under [`docs/`](..) as the generated signals wiki; it is separate infrastructure, out of scope for this domain. + +| Directory | Path | Covers | +|---|---|---| +| [`docs/design/`](../design) | [`docs/design/model-change-review.md`](../design/model-change-review.md) | Why `serve` diffs the model against a stored baseline: the baseline/change item/change note/approve vocabulary, the hybrid diff-plus-notes cross-check and its cross-check outcomes, and the business rules (the baseline is a file ignatius writes, `.ignatius/` is never committed, body-only edits are their own kind, column-level coverage) | +| [`docs/design/`](../design) | [`docs/design/model-index-routing.md`](../design/model-index-routing.md) | Per-folder generated routers (`index.md`), rolled-up SHA digests, `` managed regions, `index_file:`/`harness:` config, in-folder `AGENTS.md`/[`CLAUDE.md`](../../CLAUDE.md)/`SKILL.md` agent guidance | +| [`docs/design/`](../design) | [`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) | Canonical source for the markdown-driven entity file format; no [`docs/spec/`](../spec) counterpart | +| [`docs/design/`](../design) | [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) | Per-process vs. connected DFD views, prose `label:` on any flow entry, `clusters/` author-cluster files, the `cluster:` token, subtype-family and group collapse levels, adjacency stacking, the stack/contract/subtype/group dialog rules, the stacked-paper "more inside" marker construction, row-local store-kind identity and coloring inside a mixed stack | +| [`docs/design/`](../design) | [`docs/design/process-flows.md`](../design/process-flows.md) | SSADM DFD subsystem: processes, externals, stores, sub-DFDs | +| [`docs/design/`](../design) | [`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) | Schema lint + error UX | +| [`docs/design/`](../design) | [`docs/design/noorm-flow-discovery.md`](../design/noorm-flow-discovery.md) | `ignatius-modeling` skill's `flow` and `discover` Q&A modes | +| [`docs/design/`](../design) | [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) | Key-edge connected-component lineage (FK ⊆ child PK), associative-entity traversal barriers | +| [`docs/design/`](../design) | [`docs/design/ignatius-modeling-skill.md`](../design/ignatius-modeling-skill.md) | The `ignatius-modeling` skill itself | +| [`docs/design/`](../design) | [`docs/design/branding.md`](../design/branding.md) | Branding system | +| [`docs/design/`](../design) | [`docs/design/unified-app.md`](../design/unified-app.md) | Unified SPA collapse (Graph/Dictionary/Flows in one app) | +| [`docs/design/`](../design) | [`docs/design/viewer-fab-ux.md`](../design/viewer-fab-ux.md) | Floating action button UX | +| [`docs/design/`](../design) | [`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) | `src/App.tsx` → [`src/app/`](../../src/app) decomposition | +| [`docs/design/`](../design) | [`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md) | CLI modes and the static output approach | +| [`docs/design/`](../design) | [`docs/design/example-instance-tables.md`](../design/example-instance-tables.md) | Example/sample-row instance tables | +| [`docs/design/`](../design) | [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md) | viewer-ux-polish batch | +| [`docs/design/`](../design) | [`docs/design/folder-model.md`](../design/folder-model.md) | Folder-model migration (`_*`-prefix vs hoisted top-level folders) | +| [`docs/design/`](../design) | [`docs/design/keyboard-nav-shortcuts.md`](../design/keyboard-nav-shortcuts.md) | Single-key keyboard navigation shortcuts | +| [`docs/design/`](../design) | [`docs/design/ignatius-project-config.md`](../design/ignatius-project-config.md) | `ignatius.yml` config + model discovery | +| [`docs/design/`](../design) | [`docs/design/graph-position-persistence.md`](../design/graph-position-persistence.md) | Graph node position persistence | +| [`docs/design/`](../design) | [`docs/design/dict-navigation.md`](../design/dict-navigation.md) | Data-dictionary side navigation | +| [`docs/design/`](../design) | [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) | DFD edge-hover data reveal | +| [`docs/design/`](../design) | [`docs/design/large-model-nav.md`](../design/large-model-nav.md) | Hover-intent settle-then-apply dimming, the `ANIMATION_ELEMENT_LIMIT` animation cutoff, the SSADM flow index, breadcrumb level menus, and where a flow folder's `description:` lives | +| [`docs/design/`](../design) | [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) | DFD viewer overhaul: Yourdon/SSADM leveling, ELK layout, 5-band partitioning | +| [`docs/design/`](../design) | [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) | Search on Graph and Flows views | +| [`docs/design/`](../design) | [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) | Arbitrary DFD nesting depth fix | +| [`docs/design/`](../design) | [`docs/design/bidirectional-predicates.md`](../design/bidirectional-predicates.md) | Bidirectional predicate feature | +| [`docs/design/`](../design) | [`docs/design/help-overlay.md`](../design/help-overlay.md) | View-aware help overlay | +| [`docs/design/`](../design) | [`docs/design/dd-spotlight-grid.md`](../design/dd-spotlight-grid.md) | DD browse-lens spotlight grid; off-screen connection chips scroll to the target, then flash it only once the scroll settles | +| [`docs/design/`](../design) | [`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) | Wiki-style `[[Entity]]` body links | +| [`docs/design/`](../design) | [`docs/design/src-root-organization.md`](../design/src-root-organization.md) | [`src/`](../../src) top-level subdirectory split | +| [`docs/design/`](../design) | [`docs/design/noorm-modeling-skill.md`](../design/noorm-modeling-skill.md) | Rename stub; points to `ignatius-modeling-skill.md` | +| [`docs/spec/`](../spec) | [`docs/spec/model-change-review.md`](../spec/model-change-review.md) | Change review contract: non-goals (no VCS integration, no diff in `export`/`validate`, no reject command, no layout changes), the `.ignatius/` on-disk layout (`baseline.json`, `changes/.md`), coverage rules, success criteria, checkpoints CP1-CP5 (pure diff in [`src/model/change-review.ts`](../../src/model/change-review.ts), storage/server/CLI, graph highlights and Changes panel, Dictionary and Flow highlights, skill and docs); carries the change-tree/outline/flows triad, a `## Change log`, and an `## Implementation log` | +| [`docs/spec/`](../spec) | [`docs/spec/process-flows.md`](../spec/process-flows.md) | Largest spec; SSADM DFD: parse, the `flow.*` rules, viewer, sub-DFD drill-down, `db:` store dialog | +| [`docs/spec/`](../spec) | [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) | `buildInheritedConnections` key-edge connected-component algorithm, DG/DD lineage reveal | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | CP1–CP8: `label:` on every flow entry + contract dialog, `flow-clusters.ts` registry + `cluster:` token expansion, the `flow.cluster_*` rules, per-process/connected view stacking, collapse levels, row-local store-kind identity and kind-specific numbered caps inside a mixed stack, `StackDialog`/`EdgeContractDialog`; `## Change log` entries plus an `## Implementation log` recording the shipped build and an open followup | +| [`docs/spec/`](../spec) | [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | Router build/write, fingerprint roll-up, `index_file`/`harness` config, the `config.index_file_*`/`index.*` rules, `--agents` guidance files, a flow/sub-DFD folder row's description-folded digest | +| [`docs/spec/`](../spec) | [`docs/spec/app-tsx-decomposition.md`](../spec/app-tsx-decomposition.md) | `App.tsx` decomposition | +| [`docs/spec/`](../spec) | [`docs/spec/dd-spotlight-grid.md`](../spec/dd-spotlight-grid.md) | DD browse-lens spotlight grid | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-polish-round3.md`](../spec/dfd-polish-round3.md) | CP18–23 | +| [`docs/spec/`](../spec) | [`docs/spec/render-perf-indexing.md`](../spec/render-perf-indexing.md) | Preset-layout cache-skip, ELK cost scaling, `buildModelIndex` | +| [`docs/spec/`](../spec) | [`docs/spec/unified-app.md`](../spec/unified-app.md) | Unified SPA | +| [`docs/spec/`](../spec) | [`docs/spec/ignatius-modeling-skill.md`](../spec/ignatius-modeling-skill.md) | `ignatius-modeling` skill contract | +| [`docs/spec/`](../spec) | [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) | Graph/Flows search (SC1–SC12); SC12 names no diagram nav card, since Flows has none | +| [`docs/spec/`](../spec) | [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) | `createHoverIntent`/`HOVER_INTENT_MS`/`ANIMATION_ELEMENT_LIMIT` in `motion.ts`, flow folder `description:` through parse/leveling/router, `flow-nav.ts` navigation model, breadcrumb `LevelMenu`, the `FlowIndex` hierarchy chart, the `i` shortcut; carries a `## Change tree`/`## Outline`/`## Flows` triad | +| [`docs/spec/`](../spec) | [`docs/spec/unified-app-polish.md`](../spec/unified-app-polish.md) | CP1–CP13 unified-app-polish batch | +| [`docs/spec/`](../spec) | [`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) | `resolveShortcut`, `useKeyboardShortcuts`, the flow-only `i` binding | +| [`docs/spec/`](../spec) | [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) | viewer-ux-polish batch | +| [`docs/spec/`](../spec) | [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) | Example/sample-row instance tables | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-polish-round2.md`](../spec/dfd-polish-round2.md) | CP14–17 | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-polish-round4.md`](../spec/dfd-polish-round4.md) | CP24–26 | +| [`docs/spec/`](../spec) | [`docs/spec/bidirectional-predicates.md`](../spec/bidirectional-predicates.md) | Bidirectional predicates | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | DFD viewer overhaul; success criteria C1–C18 | +| [`docs/spec/`](../spec) | [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) | CLI output modes and theme system | +| [`docs/spec/`](../spec) | [`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) | Schema lint + error UX | +| [`docs/spec/`](../spec) | [`docs/spec/folder-model.md`](../spec/folder-model.md) | Folder-model migration | +| [`docs/spec/`](../spec) | [`docs/spec/ignatius-project-config.md`](../spec/ignatius-project-config.md) | `ignatius.yml` config loading + model discovery | +| [`docs/spec/`](../spec) | [`docs/spec/graph-position-persistence.md`](../spec/graph-position-persistence.md) | Graph node position persistence | +| [`docs/spec/`](../spec) | [`docs/spec/branding.md`](../spec/branding.md) | Branding | +| [`docs/spec/`](../spec) | [`docs/spec/viewer-fab-ux.md`](../spec/viewer-fab-ux.md) | FAB UX | +| [`docs/spec/`](../spec) | [`docs/spec/dict-navigation.md`](../spec/dict-navigation.md) | Dict side nav | +| [`docs/spec/`](../spec) | [`docs/spec/dict-polish.md`](../spec/dict-polish.md) | Dict visual polish; no design-doc counterpart | +| [`docs/spec/`](../spec) | [`docs/spec/noorm-flow-discovery.md`](../spec/noorm-flow-discovery.md) | `flow`/`discover` skill modes; skill-markdown-only | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) | DFD edge-hover data reveal | +| [`docs/spec/`](../spec) | [`docs/spec/src-root-organization.md`](../spec/src-root-organization.md) | [`src/`](../../src) directory split | +| [`docs/spec/`](../spec) | [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | `downloadProgressRenderer` + a streamed, incrementally-hashed `downloadAndReplace` in [`src/cli/update.ts`](../../src/cli/update.ts); no design-doc counterpart | +| [`docs/spec/`](../spec) | [`docs/spec/wiki-entity-links.md`](../spec/wiki-entity-links.md) | Wiki-entity links | +| [`docs/spec/`](../spec) | [`docs/spec/derive-classification.md`](../spec/derive-classification.md) | rule-order classification derivation; no design-doc counterpart | +| [`docs/spec/`](../spec) | [`docs/spec/help-overlay.md`](../spec/help-overlay.md) | Help overlay; Flows body covers the flow index and breadcrumb level switching | +| [`docs/spec/`](../spec) | [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) | DFD nesting-depth fix | +| [`docs/spec/`](../spec) | [`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) | Rename stub; points to `ignatius-modeling-skill.md` | +| [`docs/guides/`](../guides) | [`docs/guides/change-review.md`](../guides/change-review.md) | What `ignatius serve` marks since the last approval, how the diff is cross-checked against change notes, the Changes panel, the `.ignatius/` folder, the note format and what a note covers, `ignatius approve`, what review does not do, and when the review cannot be built | +| [`docs/guides/`](../guides) | [`docs/guides/folder-format.md`](../guides/folder-format.md) | `ignatius.yml`, the top-level folders including `clusters/`, entity/column/relationship authoring, `index_file:`/`harness:` config, generated routers, `description:` frontmatter, a flow folder's `description:` folding into its router row's hash, and the `.ignatius/` folder (`baseline.json`, `changes/`) with its [`.gitignore`](../../.gitignore) line | +| [`docs/guides/`](../guides) | [`docs/guides/commands.md`](../guides/commands.md) | The CLI subcommands including `index`/`index --agents`, `approve`, `validate --index`, and the full keyboard-shortcut table including `i` (open or close the flow index) | +| [`docs/guides/`](../guides) | [`docs/guides/flows.md`](../guides/flows.md) | DFDs: processes, externals, stores, sub-DFDs, `description:` on process/external/store/flow folder, plus labels/stacks/clusters/groups — the contract dialog, the closed endpoint-prefix set and its one `cluster:` exception, the per-process vs. connected view split, the stores/clusters/groups collapse level, adjacency stacking and its `ignatius.yml` switch, the Flows FAB controls, the "☰ Process Flows" flow index and breadcrumb ▾ level menus, and the 300 ms hover-settle / 150-element animation cutoff | +| [`docs/guides/`](../guides) | [`docs/guides/validation.md`](../guides/validation.md) | The linter, severity tiers, the Config-rules/Index-rules tables (`config.index_file_*`, `index.stale`, `index.orphaned`, `index.unreadable_target`), and the `flow.cluster_*` rules | +| [`docs/guides/`](../guides) | [`docs/guides/getting-started.md`](../guides/getting-started.md) | Install, build from source, serve the first model; command list names `index` and `approve`, with a link to the change-review guide | +| [`docs/guides/`](../guides) | [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) | The `/ignatius-modeling` skill's Q&A modes; the `flow` mode's step list names the cluster-membership decision and the flow label alongside the `db:`-or-`kind:` store decision; verification loop runs `ignatius validate --index`; a `## Change notes` section describes the note every session ends with, the baseline `approve` before the first write, and that the skill never approves after editing | +| [`docs/guides/`](../guides) | [`docs/guides/derivation.md`](../guides/derivation.md) | What gets derived (cardinality, classification, subtype clusters) vs authored by hand | +| [`docs/guides/`](../guides) | [`docs/guides/predicates.md`](../guides/predicates.md) | Bidirectional relationship-edge label authoring | +| [`docs/guides/`](../guides) | [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) | `theme`/`branding` blocks in `ignatius.yml` | +| [`docs/guides/`](../guides) | [`docs/guides/building-from-source.md`](../guides/building-from-source.md) | Bun build stages, project layout, tests | +| [`docs/research/`](../research) | [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) | DFD layout engines and Yourdon leveling; primary source for `dfd-overhaul`'s ELK algorithm choice | +| [`docs/research/`](../research) | [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) | SSADM DFD rules | +| [`docs/glossary.md`](../glossary.md) | [`docs/glossary.md`](../glossary.md) | Canonical vocabulary table: DG (Data Graph), DD (Data Dictionary), DFD (Data Flow Diagram), DE (Data Entity), DS (Data Store), EE (External Entity), Process, Data Flow, plus the DS ⊃ DE relationship note, the `kind:` store taxonomy (`db`/`cache`/`queue`/`file`/`doc`/`manual`/`other`), and a "Flow view: stacks, clusters, and groups" table (flow label, cluster, group, stack, collapse level, per-process view, connected view, adjacency stack) | ## Constraints @@ -172,7 +154,7 @@ Canonical vocabulary table: DG (Data Graph), DD (Data Dictionary), DFD (Data Flo | Constraint | Detail | |------------|--------| | Spec body is forward-only | `docs/spec/.md` must describe only the current decision; superseded content moves to a dated `## Change log` entry with a **Superseded:** line. Leaving old text in the body instead means a subagent implementing from the spec reads a contradicted or stale contract as current truth | -| Change-tree/outline/flows apply forward only | The three required sections apply to specs drafted after the rule shipped; only 4 of 38 specs (`graph-flow-search.md`, `model-index-routing.md`, `dfd-store-clusters.md`, `large-model-nav.md`) carry them. Backfilling them onto a pre-existing spec via an unrelated amendment would bundle an unrelated structural change into that amendment's `## Change log` entry, misstating what the amendment actually changed | +| Change-tree/outline/flows apply forward only | The three required sections apply to specs drafted after the rule shipped; only `graph-flow-search.md`, `model-index-routing.md`, `dfd-store-clusters.md`, `large-model-nav.md`, and `model-change-review.md` carry them. Backfilling them onto a pre-existing spec via an unrelated amendment would bundle an unrelated structural change into that amendment's `## Change log` entry, misstating what the amendment actually changed | | The `cluster:` prefix is the one exception to the closed endpoint-prefix set | [`docs/guides/flows.md`](../guides/flows.md)'s Endpoints section states the `db:`/`ext:`/`proc:`/`cache:`/`queue:`/`file:`/`doc:`/`manual:`/`other:` prefix set is closed, then names `cluster:` as intercepted and expanded before endpoint parsing runs; [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md)'s Checkpoints table (CP8's Verifies cell) requires every closed-prefix-set passage across the guides and the `ignatius-modeling` skill references to name this one exception. A guide or skill passage that repeats the closed-set list without this exception either teaches an author that `cluster:` is unsupported, or contradicts the other doc stating the same closed set | @@ -181,14 +163,15 @@ Canonical vocabulary table: DG (Data Graph), DD (Data Dictionary), DFD (Data Flo | Docs surface | Coupled domain | Coupling | |---|---|---| -| [`docs/design/model-index-routing.md`](../design/model-index-routing.md) + [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | **router** (new) | The pair is the sole source for [`src/router/`](../../src/router) (`region.ts`, `fingerprint.ts`, `build.ts`, `write.ts`, `detect.ts`, `agents.ts`); the spec's Change tree also names edits to **parser** ([`src/model/parse.ts`](../../src/model/parse.ts)), **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)), **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)), **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)), and **skill** (`skills/ignatius-modeling/**`) | +| [`docs/design/model-index-routing.md`](../design/model-index-routing.md) + [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) | **router** | The pair is the sole source for [`src/router/`](../../src/router) (`region.ts`, `fingerprint.ts`, `build.ts`, `write.ts`, `detect.ts`, `agents.ts`); the spec's Change tree also names edits to **parser** ([`src/model/parse.ts`](../../src/model/parse.ts)), **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)), **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)), **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)), and **skill** (`skills/ignatius-modeling/**`) | | [`docs/design/large-model-nav.md`](../design/large-model-nav.md) + [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) | **flow-view**, **frontend**, **flows**, **router** | The pair's Change tree adds [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) (`resolveDiagramPath`, `levelEntries`, `buildFlowIndex`), [`src/flow-view/FlowIndex.tsx`](../../src/flow-view/FlowIndex.tsx), and [`src/flow-view/LevelMenu.tsx`](../../src/flow-view/LevelMenu.tsx), plus edits to [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (split crumbs, index button) and `FlowDiagramSvg.tsx` (hover intent, memoized flow data) for **flow-view**; new [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) (`createHoverIntent`, `HOVER_INTENT_MS`, `ANIMATION_ELEMENT_LIMIT`, `scrollBehaviorWithin`) plus edits to `GraphView.tsx`, `DictionaryView.tsx`, and `App.tsx` for **frontend**; `description:` frontmatter reads in [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (`FlowDiagram.description`) and `flow-derive-levels.ts` (copied onto the L1 process) for **flows**; and a folder-row description plus its hash fold in [`src/router/build.ts`](../../src/router/build.ts) (`buildFlowFolder`) for **router** | -| [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | **cli** | No design-doc counterpart; both checkpoints target [`src/cli/update.ts`](../../src/cli/update.ts) alone — `downloadProgressRenderer` (Checkpoint 1) and a streamed, incrementally-hashed `downloadAndReplace` (Checkpoint 2) | -| [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | **flow-view**, **frontend** | Success criteria C4/C16/C17 cited by name in [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts); all six (C4/C5/C13/C15/C16/C17) checked directly by [`test/checks/test-cp4b-elk-edge-routing.ts`](../../test/checks/test-cp4b-elk-edge-routing.ts) and siblings | +| [`docs/spec/update-download-progress.md`](../spec/update-download-progress.md) | **cli** | No design-doc counterpart; the checkpoints target [`src/cli/update.ts`](../../src/cli/update.ts) alone — `downloadProgressRenderer` (Checkpoint 1) and a streamed, incrementally-hashed `downloadAndReplace` (Checkpoint 2) | +| [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) | **flow-view**, **frontend** | Success criteria C4/C16/C17 cited by name in [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts); C4/C5/C13/C15/C16/C17 checked directly by [`test/checks/test-cp4b-elk-edge-routing.ts`](../../test/checks/test-cp4b-elk-edge-routing.ts) and siblings | | [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) | **frontend** | SC5 cited by name in [`src/app/logic/search.ts`](../../src/app/logic/search.ts); CP1 checked by [`test/checks/test-viewer-search.ts`](../../test/checks/test-viewer-search.ts) | | [`docs/spec/derive-classification.md`](../spec/derive-classification.md) | **parser**, **validate** | Cited by name in [`test/checks/test-validate-entity.ts`](../../test/checks/test-validate-entity.ts) for classification-derivation rules | | [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) | **skill** | Names [`skills/ignatius-modeling/references/entity-flow.md`](../../skills/ignatius-modeling/references/entity-flow.md) and directs it to add Step E7b — Examples, between E7 (Columns) and E8 (Reference table) | | [`docs/spec/process-flows.md`](../spec/process-flows.md) | **skill** | Its `flow.*` frontmatter/token grammar is matched by [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) | | [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) | **theme**, **skill** | Its worked example is pointed to by [`skills/ignatius-modeling/references/model-flow.md`](../../skills/ignatius-modeling/references/model-flow.md) | | [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) + [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) | **flows**, **flow-view**, **frontend**, **skill** | The pair's Change tree names a new [`src/flows/flow-clusters.ts`](../../src/flows/flow-clusters.ts) plus edits to `flow-parse.ts`/`flow-validate.ts` (**flows**); `flow-layout.ts`/`elk-flow-layout.ts`/`FlowDiagramSvg.tsx` for per-process/connected-view stacking, collapse levels, and row-local store-kind coloring (**flow-view**); `App.tsx`/`FlowsView.tsx` and new `StackDialog.tsx`/`EdgeContractDialog.tsx` for the view/collapse toggles and dialogs (**frontend**); and `dfd-authoring.md`/`flow-templates.md`/`discover-flow.md`/`verification.md` for the new authoring and validation surface (**skill**) — [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) documents that same skill-side coupling from the user's side, naming the cluster-membership decision and the flow label as steps in the `flow` mode's Q&A | +| [`docs/design/model-change-review.md`](../design/model-change-review.md) + [`docs/spec/model-change-review.md`](../spec/model-change-review.md) | **cli**, **server**, **parser**, **frontend**, **flow-view**, **skill**, **scripts** | The pair's Change tree and checkpoints name [`src/model/change-review.ts`](../../src/model/change-review.ts) and [`src/model/change-store.ts`](../../src/model/change-store.ts) (diff, cross-check, baseline and note storage; owned by **parser** along with [`src/model/wikilink.ts`](../../src/model/wikilink.ts)), [`src/server/server.ts`](../../src/server/server.ts) (`/api/model` `diff`, `POST /api/approve`), and [`src/cli/cli.ts`](../../src/cli/cli.ts) (`approve`) for **cli**/**server**/**parser**; [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts), [`src/app/components/changes/`](../../src/app/components/changes), and the graph, Dictionary, and Flows views for **frontend**; the `changeMarks` prop in `FlowDiagramSvg.tsx` for **flow-view**; [`skills/ignatius-modeling/references/change-notes.md`](../../skills/ignatius-modeling/references/change-notes.md) plus edits to `SKILL.md`, `model-flow.md`, `verification.md`, `dfd-authoring.md`, `discover-flow.md`, and `reverse-engineering.md` for **skill**; and [`scripts/clear-review-state.ts`](../../scripts/clear-review-state.ts) (wired into [`package.json`](../../package.json)'s [`test`](../../test)) for **scripts**. The skill's change-note format must stay in step with the spec's note format and coverage rules | | [`docs/wiki/feature-map.md`](feature-map.md) | all domains | Hand-authored feature-to-doc-to-skill cross-reference table; not generated by this signals pipeline, maintained separately | diff --git a/docs/wiki/feature-map.md b/docs/wiki/feature-map.md index 78f35d9..35bbba8 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -22,6 +22,7 @@ Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`do | Alternate keys (AK): cardinality derivation, dict/graph key-cell marker, `ak_unknown_column` validation | derive-classification (cardinality) | derive-classification, schema-lint-and-error-ux | derivation | entity-flow E4 | | CLI subcommands (`serve` SPA + `export` unified static + `validate`) — `dict`/`graph`/`flow` removed | cli-and-outputs, unified-app | cli-and-outputs, unified-app | commands, building-from-source, getting-started | verification (runs `ignatius validate`) | | CLI version + self-update (`version`/`--version`, `update`) | — | update-download-progress | commands, getting-started | — | +| Model change review (`.ignatius/` baseline + change notes; graph/Dictionary/Flow highlights; Changes panel; `approve` + `POST /api/approve`) | model-change-review | model-change-review | change-review, commands, folder-format, modeling-skill | SKILL core rules, change-notes, model-flow M8, verification, dfd-authoring F0, discover-flow, reverse-engineering R0 | | Project config + model discovery (`ignatius.yml`) | ignatius-project-config | ignatius-project-config | getting-started, folder-format | entity-flow E0, model-flow M1–M8, templates | | Themes | cli-and-outputs | cli-and-outputs | themes-and-branding | model-flow M4 | | Branding | branding | branding | themes-and-branding | model-flow M5 | diff --git a/docs/wiki/flow-view.md b/docs/wiki/flow-view.md index 5c974ac..915221d 100644 --- a/docs/wiki/flow-view.md +++ b/docs/wiki/flow-view.md @@ -10,9 +10,9 @@ tags: [flow-view, flows, frontend] [`src/flow-view/`](../../src/flow-view) turns a parsed `FlowDiagram` into on-screen positions, routed edges, a rendered SVG, and the chrome a user drills through it with. Without this domain the app has no DFD canvas: `flows` has parsed data with nowhere to draw, and the frontend's Flows tab is empty. Layout runs two ways — an async elkjs pass with 5-band partitioning and orthogonal edge routing, or a synchronous hand-rolled banded fallback used while ELK resolves or after it fails — and both feed the same custom SVG renderer, which also owns pan/zoom/drag, the minimap, breadcrumbs, edge-hover tooltips, and search-driven dimming. -On a model with dozens of flows, drilling down one level at a time is the only way in unless the diagram exposes shortcuts: `flow-nav.ts` turns the leveled DFD tree into breadcrumb sibling menus and a searchable process-hierarchy index, so a user can jump sideways or straight to any process without re-walking the whole path. It also resolves `dfd=` deep links, which carry a path reference rather than a bare id, so a reload or a link lands on the exact sub-DFD even when two unrelated flows share a process file name. The same large-model concern drives the hover behavior: `FlowDiagramSvg` now settles a hover through a shared intent delay before dimming anything, and drops fade transitions entirely once a diagram's element count passes the shared animation cutoff, so a pointer crossing a big diagram no longer repaints once per element it passes over. +On a model with dozens of flows, drilling down one level at a time is the only way in unless the diagram exposes shortcuts: `flow-nav.ts` turns the leveled DFD tree into breadcrumb sibling menus and a searchable process-hierarchy index, so a user can jump sideways or straight to any process without re-walking the whole path. It also resolves `dfd=` deep links, which carry a path reference rather than a bare id, so a reload or a link lands on the exact sub-DFD even when two unrelated flows share a process file name. The same large-model concern drives the hover behavior: `FlowDiagramSvg` settles a hover through a shared intent delay before dimming anything, and drops fade transitions entirely once a diagram's element count passes the shared animation cutoff, so a pointer crossing a big diagram no longer repaints once per element it passes over. -A store is not always drawn one-to-one with an entity. When a process touches two or more stores in one direction, or (in the connected view) two or more stores share a cluster tag, author cluster, subtype family, group, or read/write/kind signature, they collapse into one **stack node** — a single box whose rows can represent individual stores or nested groupings, at a collapse level the caller controls. Two view modes decide how this grouping runs: **per-process** (the default) stacks each process's own reads and writes; **connected** keeps one node per store and only groups where clusters/subtypes/groups/adjacency say to. Both are global settings owned by the frontend shell, not this domain. +A store is not always drawn one-to-one with an entity. When a process touches two or more stores in one direction, or (in the connected view) two or more stores share a cluster tag, author cluster, subtype family, group, or read/write/kind signature, they collapse into one **stack node** — a single box whose rows can represent individual stores or nested groupings, at a collapse level the caller controls. View modes decide how this grouping runs: **per-process** (the default) stacks each process's own reads and writes; **connected** keeps one node per store and only groups where clusters/subtypes/groups/adjacency say to. Both are global settings owned by the frontend shell, not this domain. ## How it works @@ -45,7 +45,7 @@ flowchart TD ### View routing in buildFlowData -`buildFlowData(diagram, opts)` is the single entry point for node/edge construction; `opts.view` picks one of three independent builders. +`buildFlowData(diagram, opts)` is the single entry point for node/edge construction; `opts.view` picks an independent builder. ```mermaid flowchart TD @@ -55,11 +55,11 @@ flowchart TD B -->|"'connected'"| E["buildConnectedViewGrouping: cluster/subtype/group/adjacency passes, per-store otherwise"] ``` -`computeElkLayout` takes the same `opts` (`ComputeElkLayoutOpts extends BuildFlowDataOpts`) so ELK's node/edge id set always matches what `FlowDiagramSvg` builds against. `FlowDiagramSvg` now calls `buildFlowData` inside a `useMemo` keyed on `[diagram, flowDataOpts]`, since every hover and drag re-renders the component and rebuilding the whole node/edge model on each of those made hovering a large DFD stall. +`computeElkLayout` takes the same `opts` (`ComputeElkLayoutOpts extends BuildFlowDataOpts`) so ELK's node/edge id set always matches what `FlowDiagramSvg` builds against. `FlowDiagramSvg` calls `buildFlowData` inside a `useMemo` keyed on `[diagram, flowDataOpts]`, since every hover and drag re-renders the component and rebuilding the whole node/edge model on each of those made hovering a large DFD stall. ### Connected-view grouping order -Within the connected view, five passes run in a fixed order; each pass claims members from what the previous pass left, per process. +Within the connected view, the passes run in a fixed order; each pass claims members from what the previous pass left, per process. ```mermaid flowchart TD @@ -141,6 +141,28 @@ flowchart TD `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the rendered diagram's own bare id, never the path reference — it stays a stable test hook independent of how deep the diagram sits in the tree. +### Change-review marks + +**A node's change mark comes from the `changeMarks` map keyed by the same base token search uses; a stack has no token, so its members decide.** + +```mermaid +flowchart TD + A["changeMarkOf(node)"] --> B{"changeMarks set?"} + B -->|no| C["no mark"] + B -->|yes| D{"node.nodeType
is 'stack'?"} + D -->|no| E["changeMarks.get(
baseToken(node.id))"] + D -->|yes| F["look up
baseToken(m.storeId)
for each member"] + F --> G{"any member marked?"} + G -->|no| C + G -->|yes| H["mark from members
(rule in prose below)"] + E --> I["ChangeGlow drawn
behind the node"] + H --> I +``` + +A stack's mark is `added` if every marked member is added, else `changed`; it is unexplained if any marked member is. + +`FlowDiagramSvg` takes an optional `changeMarks?: ReadonlyMap | null` prop (`ChangeMark` is the type from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts)). For a marked node the `` gets `data-change` (`added` or `changed`) and, when no note covers it, `data-unexplained="true"`, and `ChangeGlow` renders before the node body inside the same group: a `g.flow-glow` (`pointerEvents="none"`) holding a `flow-glow-kind` layer padded `GLOW_SPREAD` world px around `nodeBounds`, plus a `flow-glow-unexplained` layer at `UNEXPLAINED_GLOW_PAD` (`GLOW_SPREAD + 2`) when `mark.unexplained`. Both layers are blurred by an SVG `feGaussianBlur` filter at `stdDeviation={GLOW_BLUR}`; `GLOW_BLUR` and `GLOW_SPREAD` come from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts) so the graph and flow glows match. Colors and the pulse (`flow-glow-pulse` and `flow-glow-swap` keyframes) come from the `flow-glow*` classes in the frontend's stylesheet, not from this domain. Process, external, store, and stack nodes all take the same glow. The glow never moves a node or changes its size. + ### Hover settle path Dimming and the edge tooltip apply only after the pointer rests on one target; a pass across many elements repaints once instead of once per element crossed. @@ -159,20 +181,20 @@ sequenceDiagram Svg->>Svg: recompute nodeOpacity/edgeOpacity, dim non-connected elements ``` -`FlowDiagramSvg` never times the delay itself: `hoverIntent.set(key)` on pointer-enter/leave and `hoverIntent.applyNow(null)` on opening the contract dialog are the only calls it makes; `createHoverIntent` and `HOVER_INTENT_MS` live in the frontend domain's [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) and are shared verbatim with the Graph and Dictionary views. The same module's `animationsAllowed(nodes.length + edges.length)` gates the `animate` flag threaded into `EdgePath`/`EdgeChip` and every node's hover style: past the shared element-count cutoff, opacity changes apply with no `transition`, matching the other two views' large-model behavior. +`FlowDiagramSvg` never times the delay itself: `hoverIntent.set(key)` on pointer-enter/leave and `hoverIntent.applyNow(null)` on opening the contract dialog are the only calls it makes; `createHoverIntent` and `HOVER_INTENT_MS` live in the frontend domain's [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) and are shared verbatim with the Graph and Dictionary views. The same module's `animationsAllowed(nodes.length + edges.length)` gates the `animate` flag threaded into `EdgePath`/`EdgeChip` and every node's hover style: past the shared element-count cutoff, opacity changes apply with no `transition`, matching the Graph and Dictionary views' large-model behavior. ## Where it lives | Path | Exports | Role | |---|---|---| -| [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) (382L) | `computeElkLayout`, `buildElkGraph`, `nodeSize`, `bandOf`, `isDbEdge`, `SHORT_LABEL_MAX`, `isInlineLabel`, `terminateQuietly`, `ElkLayoutResult`, `ComputeElkLayoutOpts` | Async ELK layout: 5-band partitioning (source-ext=0, input-store=1, process-row=2, output-store=3, sink-ext=4), `ORTHOGONAL` edge routing. Per-process view sets `elk.separateConnectedComponents: 'false'` so disconnected stacks still obey band ordering. No Bun/Node-only APIs at module top level — browser-safe. Reads no `FlowDiagram.description`; layout is unaffected by it. | -| [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) (1800L) | `buildFlowData`, `computeFlowLayout`, `assignStoreNumbers`, `normalizeEdgeData`, `resolveChipLines`, `estProcessLineWidth`, `processNodeSize`, `stackNodeSize`, `stackRowLayout`, `stackRowBodyText`, `storeBodyWidth`, `measureText`, `layoutKeyForView`, `PROC_MIN_W`, `PROC_MIN_H`, `PROC_TEXT_LEFT`, `PROC_TEXT_RIGHT_PAD`, `PROC_LINE_H`, `PROC_TEXT_PAD_Y`, `STORE_ROW_H`, `STORE_CAP_W`, `STORE_STROKE_W`, `CHIP_TRUNCATE_MAX`, `STACK_ROW_PEEK_GAP`, `STACK_ROW_PEEK_RESERVE`, plus the `NodePos`/`StackMember`/`StackRow`/`ProcessNodeData`/`ExternalNodeData`/`StoreNodeData`/`StackNodeData`/`FlowNodeData`/`FlowElementData`/`BuildFlowDataOpts`/`FlowRenderData`/`StoreSplitMap` types | Renderer-agnostic layout: node/edge construction for all three views, the store split (read/write) map, external routing (max two aggregated copies per external), and the synchronous banded fallback (`computeFlowLayout`). `buildPerProcessStores`, `buildConnectedViewGrouping`, `buildStackRows`, and `resolveStackEdgeLabel` are internal, not exported. Reads no `FlowDiagram.description`. | -| [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) (185L) | `resolveDiagramPath`, `defaultDiagramPath`, `diagramRef`, `findDiagramByRef`, `levelEntries`, `buildFlowIndex`, `FlowLevelEntry`, `FlowIndexNode` | Pure, browser-safe: `resolveDiagramPath` walks an exact id path from the roots for breadcrumb/level-menu/index selection; `diagramRef`/`findDiagramByRef` build and resolve a `dfd=` path reference by trailing-id match; `defaultDiagramPath` picks the System overview (or the first root when unleveled) for a diagram-less load; `levelEntries`/`buildFlowIndex` turn the leveled `FlowDiagram` tree into breadcrumb sibling lists and the full process-hierarchy index, the latter dropping the derived Context/System rows. Sole consumer of `FlowDiagram.description`/`FlowProcess.description` in this domain. Imports `CONTEXT_DIAGRAM_ID`/`SYNTHETIC_DIAGRAM_IDS`/`SYSTEM_PROCESS_ID` from `src/flows/flow-derive-levels` and `compareDottedProcesses` from `src/app/logic/search` for process ordering. | -| [`src/flow-view/FlowIndex.tsx`](../../src/flow-view/FlowIndex.tsx) (120L) | `FlowIndex` | The process-hierarchy tree overlay opened from the breadcrumb's index chip or the `i` key: a scrollable tree (`buildFlowIndex` output) beside a preview pane that shows the hovered row's number/description/diagram-opens hint, else the focused row's, else the current diagram's, else the model's own name and description. | -| [`src/flow-view/LevelMenu.tsx`](../../src/flow-view/LevelMenu.tsx) (123L) | `LevelMenu` | The dropdown a breadcrumb's ▾ opens: `levelEntries` rendered as a listbox with number/label/process-count/description per row, a filter input above `FILTER_MIN_ENTRIES` (8) entries, arrow-key/Enter selection, and outside-pointerdown/Escape close. | -| [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) (2234L) | `FlowDiagramSvg`, `DARK_PALETTE`/`LIGHT_PALETTE` (`FlowPalette`), `ElkPositionMap`, `MinimapData`, `FlowDiagramSvgProps`, `nodeBounds`, `sizingInfo`, `suppressDuplicateChips`, `deoverlapChips`, `chipAnchor`, `elkChannelChip`, `computeEdgeAnchors`, `chipDims`, `boxesOverlap`, `FlowNode`, `FlowEdge`, `StackSizeInfo`, `EdgeAnchors`, `Box`, `Pt` | The SVG renderer: process/external/store/stack node drawing, pan/zoom/drag, edge-hover tooltip, chip placement and dedup, search-driven dimming (`searchTokens`, `baseToken`). Consumes `elkPositions`/`elkEdgeRoutes` from `computeElkLayout` and `flowDataOpts` passed straight through to `buildFlowData` (now memoized) — the two must agree on `opts` or their node/edge id sets diverge. Routes every hover through `createHoverIntent` and gates transitions through `animationsAllowed`, both from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts). | -| [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) (452L) | `FlowChrome`, `FlowChromeHandle`, `FlowChromeProps`, `BreadcrumbEntry` | Floating chrome around the SVG: breadcrumb chips, each non-leaf crumb's ▾ opening a `LevelMenu` of that level's diagrams (`levelEntries`), the "Process Flows" chip opening a `FlowIndex` overlay, and the bottom-left minimap (`FlowMinimap`, driven by an imperative `FlowChromeHandle` ref: `setStack`, `setDiagrams`, `setMinimap`, `setMinimapPanTo`, `toggleIndex`). A house (Home) button between the "Process Flows" chip and the first crumb opens the System overview (`defaultDiagramPath`) from any depth and is marked current (`aria-current="page"`) while the overview is on screen; on a derived diagram the "← Back" button is hidden. A `breadcrumbRef` + `ResizeObserver` writes the breadcrumb row's measured bottom edge into `--flow-search-bar-top` on `document.documentElement`. | -| [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) (74L) | `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale`, `Size`, `Box` | Pure zoom/fit math, no DOM/React/Bun imports. Implements the native-1:1 zoom model: 100% means one diagram world-unit renders as one CSS pixel, not "fits the container." | +| [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) | `computeElkLayout`, `buildElkGraph`, `nodeSize`, `bandOf`, `isDbEdge`, `SHORT_LABEL_MAX`, `isInlineLabel`, `terminateQuietly`, `ElkLayoutResult`, `ComputeElkLayoutOpts` | Async ELK layout: 5-band partitioning (source-ext=0, input-store=1, process-row=2, output-store=3, sink-ext=4), `ORTHOGONAL` edge routing. Per-process view sets `elk.separateConnectedComponents: 'false'` so disconnected stacks still obey band ordering. No Bun/Node-only APIs at module top level — browser-safe. Reads no `FlowDiagram.description`; layout is unaffected by it. | +| [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) | `buildFlowData`, `computeFlowLayout`, `assignStoreNumbers`, `normalizeEdgeData`, `resolveChipLines`, `estProcessLineWidth`, `processNodeSize`, `stackNodeSize`, `stackRowLayout`, `stackRowBodyText`, `storeBodyWidth`, `measureText`, `layoutKeyForView`, `PROC_MIN_W`, `PROC_MIN_H`, `PROC_TEXT_LEFT`, `PROC_TEXT_RIGHT_PAD`, `PROC_LINE_H`, `PROC_TEXT_PAD_Y`, `STORE_ROW_H`, `STORE_CAP_W`, `STORE_STROKE_W`, `CHIP_TRUNCATE_MAX`, `STACK_ROW_PEEK_GAP`, `STACK_ROW_PEEK_RESERVE`, plus the `NodePos`/`StackMember`/`StackRow`/`ProcessNodeData`/`ExternalNodeData`/`StoreNodeData`/`StackNodeData`/`FlowNodeData`/`FlowElementData`/`BuildFlowDataOpts`/`FlowRenderData`/`StoreSplitMap` types | Renderer-agnostic layout: node/edge construction for every view, the store split (read/write) map, external routing (max two aggregated copies per external), and the synchronous banded fallback (`computeFlowLayout`). `buildPerProcessStores`, `buildConnectedViewGrouping`, `buildStackRows`, and `resolveStackEdgeLabel` are internal, not exported. Reads no `FlowDiagram.description`. | +| [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) | `resolveDiagramPath`, `defaultDiagramPath`, `diagramRef`, `findDiagramByRef`, `levelEntries`, `buildFlowIndex`, `FlowLevelEntry`, `FlowIndexNode` | Pure, browser-safe: `resolveDiagramPath` walks an exact id path from the roots for breadcrumb/level-menu/index selection; `diagramRef`/`findDiagramByRef` build and resolve a `dfd=` path reference by trailing-id match; `defaultDiagramPath` picks the System overview (or the first root when unleveled) for a diagram-less load; `levelEntries`/`buildFlowIndex` turn the leveled `FlowDiagram` tree into breadcrumb sibling lists and the full process-hierarchy index, the latter dropping the derived Context/System rows. Sole consumer of `FlowDiagram.description`/`FlowProcess.description` in this domain. Imports `CONTEXT_DIAGRAM_ID`/`SYNTHETIC_DIAGRAM_IDS`/`SYSTEM_PROCESS_ID` from `src/flows/flow-derive-levels` and `compareDottedProcesses` from `src/app/logic/search` for process ordering. | +| [`src/flow-view/FlowIndex.tsx`](../../src/flow-view/FlowIndex.tsx) | `FlowIndex` | The process-hierarchy tree overlay opened from the breadcrumb's index chip or the `i` key: a scrollable tree (`buildFlowIndex` output) beside a preview pane that shows the hovered row's number/description/diagram-opens hint, else the focused row's, else the current diagram's, else the model's own name and description. With a `changeReview`, each row takes `flowRowChanges(review, node.key, node.opensOwnDiagram)`: the pill gets `data-change` (`added` or `changed`) and `data-unexplained="true"`, which the stylesheet turns into a box-shadow glow, and a trailing `ChangeDot` shows the worst change inside the row's diagram. The pane adds a `flow-index-change` line (`Added` or `Changed`, then the touched fields, then `unexplained`) for the previewed row. | +| [`src/flow-view/LevelMenu.tsx`](../../src/flow-view/LevelMenu.tsx) | `LevelMenu` | The dropdown a breadcrumb's ▾ opens: `levelEntries` rendered as a listbox with number/label/process-count/description per row, a filter input above `FILTER_MIN_ENTRIES` (8) entries, arrow-key/Enter selection, and outside-pointerdown/Escape close. Takes `parentIds` (the id path of the diagram its entries decompose) and `changeReview`; each row's `flowRowChanges` key is `parentIds` joined to `entry.diagramId`, and its label ends in a `ChangeDot`. | +| [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) | `FlowDiagramSvg`, `DARK_PALETTE`/`LIGHT_PALETTE` (`FlowPalette`), `ElkPositionMap`, `MinimapData`, `FlowDiagramSvgProps` (including `changeMarks`), `nodeBounds`, `sizingInfo`, `suppressDuplicateChips`, `deoverlapChips`, `chipAnchor`, `elkChannelChip`, `computeEdgeAnchors`, `chipDims`, `boxesOverlap`, `FlowNode`, `FlowEdge`, `StackSizeInfo`, `EdgeAnchors`, `Box`, `Pt` | The SVG renderer: process/external/store/stack node drawing, pan/zoom/drag, edge-hover tooltip, chip placement and dedup, search-driven dimming (`searchTokens`, `baseToken`). Consumes `elkPositions`/`elkEdgeRoutes` from `computeElkLayout` and `flowDataOpts` passed straight through to `buildFlowData` (memoized) — the two must agree on `opts` or their node/edge id sets diverge. Draws a `ChangeGlow` and `data-change`/`data-unexplained` attributes on every node whose base token (or, for a stack, any member's `storeId` token) is in the optional `changeMarks` prop. Routes every hover through `createHoverIntent` and gates transitions through `animationsAllowed`, both from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts). | +| [`src/flow-view/FlowChrome.tsx`](../../src/flow-view/FlowChrome.tsx) | `FlowChrome`, `FlowChromeHandle`, `FlowChromeProps`, `BreadcrumbEntry` | Floating chrome around the SVG: breadcrumb chips, each non-leaf crumb's ▾ opening a `LevelMenu` of that level's diagrams (`levelEntries`), the "Process Flows" chip opening a `FlowIndex` overlay, and the bottom-left minimap (`FlowMinimap`, driven by an imperative `FlowChromeHandle` ref: `setStack`, `setDiagrams`, `setMinimap`, `setMinimapPanTo`, `toggleIndex`). It takes a `changeReview` prop and passes it, with `stackIds.slice(0, i)` as `parentIds` for each crumb's menu, to `LevelMenu` and `FlowIndex`. A house (Home) button between the "Process Flows" chip and the first crumb opens the System overview (`defaultDiagramPath`) from any depth and is marked current (`aria-current="page"`) while the overview is on screen; on a derived diagram the "← Back" button is hidden. A `breadcrumbRef` + `ResizeObserver` writes the breadcrumb row's measured bottom edge into `--flow-search-bar-top` on `document.documentElement`. | +| [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) | `computeFitScale`, `screenScaleToPercent`, `percentToScreenScale`, `Size`, `Box` | Pure zoom/fit math, no DOM/React/Bun imports. Implements the native-1:1 zoom model: 100% means one diagram world-unit renders as one CSS pixel, not "fits the container." | ## Constraints @@ -189,12 +211,13 @@ sequenceDiagram - `resolveDiagramPath` addresses a diagram strictly by exact id path from a root — used only where the caller already knows the full path (breadcrumb picks, level-menu picks, flow-index rows) — because sub-DFD ids are process file names and two unrelated flows can share one. A `dfd=` reference is different: it names a diagram by its trailing ids only, so `findDiagramByRef` can match it against any path in the tree, oldest bare-id links included. - A breadcrumb crumb gets a ▾ only when `levelEntries` for its level returns more than one entry (`hasMenu = siblings.length > 1`); since `FlowChrome`'s `stack.map` never renders a crumb for a derived (Context/System) entry at all, this single-entry case only ever applies to a non-derived crumb, e.g. a single-root unleveled tree or any level with exactly one sibling diagram. - `animationsAllowed`/`ANIMATION_ELEMENT_LIMIT` and `createHoverIntent`/`HOVER_INTENT_MS` are owned by [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) (frontend domain), not redeclared here — `FlowDiagramSvg` only calls them, so the cutoff and delay stay identical across Graph, Dictionary, and Flows. -- The `data-ignatius` attributes emitted by `FlowChrome`/`FlowIndex`/`LevelMenu` (`flow-crumb`, `flow-crumb-menu-button`, `flow-index`, `flow-index-row`, `flow-index-pane`, `flow-level-menu`, `flow-level-menu-item`) are the selectors [`test/checks/test-large-model-nav.ts`](../../test/checks/test-large-model-nav.ts) drives via Playwright; renaming one without updating that test breaks it silently at the DOM level, not at compile time. +- The `data-ignatius` attributes emitted by `FlowChrome`/`FlowIndex`/`LevelMenu` (`flow-crumb`, `flow-crumb-menu-button`, `flow-index`, `flow-index-row`, `flow-index-pane`, `flow-level-menu`, `flow-level-menu-item`) are the selectors [`test/checks/test-large-model-nav.ts`](../../test/checks/test-large-model-nav.ts) drives via Playwright; renaming one without updating that test breaks it silently at the DOM level, not at compile time. The change-mark selectors `flow-change-dot` (emitted by `ChangeDot` in [`src/app/components/changes/ChangeBadge.tsx`](../../src/app/components/changes/ChangeBadge.tsx)) and `flow-index-change` are driven only by [`test/checks/test-change-review-flow-index.ts`](../../test/checks/test-change-review-flow-index.ts). ## Coupling - **flows** ([`src/flows/`](../../src/flows)): every layout/render entry point takes a parsed `FlowDiagram` as input (`import type { FlowDiagram, FlowEdge, FlowStoreRef } from '../flows/flow-parse'` in `flow-layout.ts` and `elk-flow-layout.ts`) — type-only, no runtime dependency. `flow-nav.ts` additionally imports `CONTEXT_DIAGRAM_ID`/`SYNTHETIC_DIAGRAM_IDS`/`SYSTEM_PROCESS_ID` from `src/flows/flow-derive-levels` and reads `FlowDiagram.description`/`FlowProcess.description`, which [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) populates from a DFD folder's `index.md` frontmatter (`readDiagramDescription`) and a process file's own `description:`. `FlowChrome.tsx` also imports `SYNTHETIC_DIAGRAM_IDS` directly, to tell whether the on-screen diagram is a derived level. flow-view never parses, validates, or levels diagrams itself; a shape change to `FlowDiagram`/`FlowStoreRef`/edge endpoint kinds or to the description/id fields forces a review of every flow-view file that imports them. - **frontend** ([`src/app/`](../../src/app)): [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) is the sole orchestrator — it owns `flowViewParams` (`{ view, collapseLevel }`, default `{ view: 'per-process', collapseLevel: 'clusters' }`), calls `computeElkLayout(diagram, flowDataOpts)`, catches ELK failures so the renderer falls back to the banded layout, calls `layoutKeyForView`, and passes `modelName`/`modelDescription` into `FlowChrome` for the flow index's title and root/whole-system descriptions. It also owns diagram selection: `selectDiagramById` (popstate and flow search, via `findDiagramByRef`) and `selectDiagramPath` (breadcrumb/level-menu/index picks, via `resolveDiagramPath`) both call a shared `showPath`, which rebuilds the breadcrumb stack with `stackFor` and writes the new `diagramRef` out through `onDiagramChange`; the initial stack seeds from `findDiagramByRef(allDiagrams, startDiagramId)` falling back to `defaultDiagramPath`. [`src/app/hash-router.ts`](../../src/app/hash-router.ts) owns the `FlowViewMode`/`FlowCollapseLevel` types, the `flowview=`/`collapse=` URL params, and the `dfd=` param, whose value it keeps as a path reference with `/` left unencoded (`serializeHash` special-cases it rather than letting `URLSearchParams` percent-encode the separator). [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts) is the source of `createHoverIntent`/`HOVER_INTENT_MS` and `animationsAllowed`/`ANIMATION_ELEMENT_LIMIT` that `FlowDiagramSvg` consumes for hover settle and the animation cutoff — shared verbatim with the Graph and Dictionary views; its internals belong to the frontend domain page, not here. [`src/app/logic/search.ts`](../../src/app/logic/search.ts) supplies `compareDottedProcesses` (used by `flow-nav.ts` for level ordering) and `searchFlowDiagrams`, whose base tokens `FlowDiagramSvg.tsx`'s `baseToken` strips to match against. [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) imports `DARK_PALETTE`/`LIGHT_PALETTE` from `FlowDiagramSvg.tsx` directly. `FlowDiagramSvg.tsx` imports `PositionMap` from [`src/app/views/graph/layout-store`](../../src/app/views/graph/layout-store.ts) for drag persistence. `StackDialog` and the edge contract dialog live in [`src/app/components/flow-node/`](../../src/app/components/flow-node), outside this domain — `FlowDiagramSvg`'s `onOpenStack`/`onOpenContract` callbacks are the only link, and opening the contract dialog calls `hoverIntent.applyNow(null)` to drop a stale tooltip; opening a stack does not. +- **change review** (frontend and docs domains): [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts) defines `ChangeMark` and `flowNodeMarks(review, diagram, diagramPath)`, which builds the base-token map and marks every node of a newly added diagram with that diagram's item; [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) passes it as `changeMarks`. A change to `ChangeMark`'s fields, or to how the map's keys are tokenized, must match `baseToken` in `FlowDiagramSvg.tsx`. `flowRowChanges(review, rowKey, opensOwnDiagram)` and `ChangeSeverity` feed the index and level-menu rows: a row's `mark` comes from an item naming its process or its own diagram, or from the item adding the diagram that holds it, and `inside` is the worst severity (`added` < `changed` < `unexplained`) of any other item at or below that diagram. `FlowsView` passes `changeReview` to `FlowChrome`. `ChangeDot` (from [`src/app/components/changes/ChangeBadge.tsx`](../../src/app/components/changes/ChangeBadge.tsx)) pulses when given the row's own `mark` and is a static severity dot when given only `inside`; [`test/checks/test-change-review-flow-index.ts`](../../test/checks/test-change-review-flow-index.ts) covers both surfaces. [`docs/spec/model-change-review.md`](../spec/model-change-review.md) / [`docs/design/model-change-review.md`](../design/model-change-review.md) is the source; removed flow nodes are not drawn on the canvas (a non-goal in the spec) and appear only in the Changes panel. - **theme** ([`src/theme/`](../../src/theme)): `flow-layout.ts` and `FlowDiagramSvg.tsx` import `FlowKindKey`/`FlowKindEntry` from `src/theme/theme-defaults` for kind-colored store/external fills. - **docs**: [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) / [`docs/design/large-model-nav.md`](../design/large-model-nav.md) is the source of the breadcrumb level menus, the flow index, the `dfd=` path-reference format, the hover-intent delay, and the animation cutoff. [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md) / [`docs/design/dfd-store-clusters.md`](../design/dfd-store-clusters.md) is the source of the stack-node model, the per-process/connected views, the collapse level, and the mixed-label chip rule. [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) / [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) is the source of the 5-band ELK layout contract (cited by name in `elk-flow-layout.ts`'s `bandOf`). [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) is the evidence base behind the ELK pipeline. [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) / [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) is the source of the edge-hover tooltip (`dataLines`, `tooltipLines`). [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) is the source of the native-1:1 zoom model in `zoom-scale.ts`. [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) / [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) is the source of the `searchTokens` dimming feature. - **Tests**: [`test/checks/test-flow-nav.ts`](../../test/checks/test-flow-nav.ts) exercises `flow-nav.ts` directly (path resolution, `diagramRef`/`findDiagramByRef`, level entries, index construction, id-collision handling). [`test/checks/test-large-model-nav.ts`](../../test/checks/test-large-model-nav.ts) drives a served fixture through Playwright to check the breadcrumb menus, the flow index, the hover-intent delay, and the animation cutoff end to end. [`test/checks/test-hash-router.ts`](../../test/checks/test-hash-router.ts) covers the `dfd=` path round-trip through `hash-router.ts`. [`test/checks/test-flow-diagram-description.ts`](../../test/checks/test-flow-diagram-description.ts) covers `FlowDiagram.description` at its source in the flows domain ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)), not in this domain. diff --git a/docs/wiki/frontend.md b/docs/wiki/frontend.md index ef85bc6..ed66826 100644 --- a/docs/wiki/frontend.md +++ b/docs/wiki/frontend.md @@ -1,6 +1,6 @@ --- type: Domain -description: React 19 unified SPA (graph / dictionary / flow) under src/app — shell, views, components, pure logic, hooks, and DOM helpers +description: React 19 unified SPA (graph / dictionary / flow) under src/app — shell, views, components, pure logic, hooks, DOM helpers, and the change-review overlay tags: [frontend, react, dfd] --- @@ -12,7 +12,9 @@ The unified SPA collapses three surfaces — graph (ERD), dict (data dictionary) On a large model all three surfaces share one hover-settle and animation-cutoff discipline ([`src/app/logic/motion.ts`](../../src/app/logic/motion.ts), [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md)): a hover focus applies only once the pointer has rested on a target for 300 ms, and a view whose rendered element count passes 150 drops its transitions and scrolls straight to the end state. -[`src/app/App.tsx`](../../src/app/App.tsx) (862 lines) is the entry point. It was decomposed from the original `src/App.tsx` monolith, 5514 lines per `docs/design/app-tsx-decomposition.md:5`, into a layered [`src/app/`](../../src/app) tree: shell → views → components → ui → logic/dom, downward only. [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. +[`src/app/App.tsx`](../../src/app/App.tsx) is the entry point. It was decomposed from the original `src/App.tsx` monolith, into a layered [`src/app/`](../../src/app) tree: shell → views → components → ui → logic/dom, downward only. [`src/app/main.tsx`](../../src/app/main.tsx) is the React entry point. + +When an agent edits the model files, the SPA shows what changed since the last approval instead of presenting the new state as if it had always been there. In live mode `/api/model` carries a `ChangeReview` (`diff`); a Changes panel lists it and approves it, and every surface marks the same items: pulsing glows and badges on graph entities with dashed ghosts for removals, badges and per-column row marks in the Dictionary and entity modal, and marked nodes in the flow diagram. Flow index rows glow, level-menu rows carry a pulsing dot, and a row with changes below it carries a static dot. Removed items read in warning red in the Changes panel, the Dictionary, and the ghost node's ✕ badge. In static mode `diff` is `null` and nothing is marked. ## How it works @@ -94,13 +96,89 @@ On load, `App.tsx` resolves each setting hash first, then `localStorage`, then a A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independent of a fresh FAB toggle: a history entry pushed earlier (e.g. opening an entity) snapshotted whatever `flowview`/`collapse` was current at push time, since the FAB toggle only `replaceState`s the top entry rather than pushing a new one. `useHashRoute`'s `popstate` handler fires `onRestoreFlowView`/`onRestoreCollapseLevel` unconditionally whenever the reconciled hash carries either param, mirroring how it already restores `dfd=`. +### Change review: one payload, read by every surface + +The review is fetched with the model, held in `useModelData`, and passed down as one `ChangeReview`, so the panel and every surface read the same items. The panel's "Highlight changes" checkbox swaps the shared value for `null` and switches all markers off together. + +```mermaid +flowchart TD + %% source: docs/design/model-change-review.md; src/app/hooks/useModelData.ts, src/app/App.tsx, src/app/logic/change-marks.ts, src/app/views/graph/GraphView.tsx + api["/api/model: diff"] --> hook["useModelData:
diff, refreshDiff"] + hook --> panel["ChangesPanel
(always gets diff)"] + hook --> app["App.tsx:
shownChanges"] + app --> gv["GraphView:
changeReview"] + app --> fv["FlowsView:
changeReview"] + app --> em["entityChanges(review)"] + gv --> em + em --> dv["DictionaryView,
EntityModal"] + fv --> fm["flowNodeMarks(review,
diagram, diagramPath)"] +``` + +`App.tsx` computes `shownChanges = changeOverlay ? diff : null` and `shownEntityChanges = entityChanges(shownChanges)`. `ChangesPanel` receives `diff` itself, never `shownChanges`, so unchecking the overlay hides markers without emptying the list. `entityChanges` ([`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts)) folds entity, column, and doc items into one `EntityChanges` per entity (`mark`, per-column `columns`, and `removedColumns` carrying the baseline `ColumnDef`); a column-only or doc-only change marks its entity `changed`. `addMark` lets `added`/`removed` outrank `changed` and keeps `unexplained` once set. `flowNodeMarks` keys a diagram's node items by the flow renderer's base token (`proc:`, `ext:`, `:`), and gives every node of a newly added diagram that diagram's mark. + +### Graph ghosts are drawn only after a layout settles + +Removed entities and relationships do not exist in the current model, so `GraphView` adds them as cytoscape elements classed `change-ghost` after the layout has placed everything else, and removes them before the next layout starts. They are never laid out and never saved to `layout-store`. + +```mermaid +stateDiagram-v2 + %% source: src/app/views/graph/GraphView.tsx + [*] --> Laying + Laying --> Settled: settleLayout(),
applyChangeOverlay() + Settled --> Settled: changeReview changes,
applyChangeOverlay() + Settled --> Laying: unsettleLayout(),
cy.remove(.change-ghost) + Laying --> Laying: panelNavigate(id),
parked in
pendingPanelTarget + Settled --> Settled: panelNavigate(id),
runs at once +``` + +`applyChangeOverlay` returns early when the layout has not settled or when `changeReviewRef.current` equals `appliedReview` (re-adding ghosts drops their selection); `settleLayout` resets `appliedReview` so each finished layout re-applies. It writes `change` and `unexplained` onto element data, which `buildStyles` selects on (`node[change = "added"]` and so on): recoloured edges with a thin glow underlay, a dashed border and 0.45 opacity for removed ghosts, and an `unexplained` color mix. Node glows are not stylesheet rules: `createChangeGlow(cy, container, colors)` in `views/graph/change-glow.ts` draws blurred node shapes on a canvas behind cytoscape, clipped out of the node itself, culled to the viewport, and pulsed by one rAF loop on a 3.6s cycle (`GLOW_PERIOD_MS`); a `matchMedia('(prefers-reduced-motion: reduce)')` listener holds it at `GLOW_STEADY`. Ghosts get a muted glow plus a full-opacity red ✕ badge drawn on a canvas above cytoscape. `destroy()` removes both canvases and the listener. `ghostStart` places a ghost at the newest position `layoutStore.lastPosition` holds for it, else at the centroid of its present neighbours, else in a row below the graph; `moveToClearSpot` then searches rings outward for a spot clear of other nodes. `gradeSpans` passes `cy.edges()` minus the ghosts to `gradeEdgeSpans`, so ghost edges do not skew the span percentiles. A `panelNavigate` that arrives mid-layout is held in `pendingPanelTarget` and consumed on settle, since the layout's closing fit would otherwise undo the centering. + +### A Changes-panel click may need a view switch first + +`graphViewRef` and `flowsViewRef` work only once their view is the mounted-active one, so `App.tsx` parks the target and an effect on `[view, changeOverlay]` consumes it after the switch. + +### Changes-panel click, entity path + +Entity, column, relationship, and doc items go through `handleChangeEntity`, which turns the overlay on first for a removed entity and parks the target when the graph is not the active view: + +```mermaid +flowchart TD + %% source: src/app/App.tsx + Item["ChangesPanel
item click"] --> he["handleChangeEntity(id)"] + he --> ghost{"removed entity,
overlay off?"} + ghost -->|yes| on["setChangeOverlay(true)"] + ghost -->|no| ingraph{"view is graph?"} + on --> parkE["pendingChangeEntityRef,
setView('graph')"] + ingraph -->|yes| pn["graphViewRef.
panelNavigate"] + ingraph -->|no| parkE + parkE --> eff["effect on view,
changeOverlay"] + eff --> pn +``` + +### Changes-panel click, diagram path + +A `flow` item goes through `handleChangeDiagram` and parks only when the view is not already the flow view: + +```mermaid +flowchart TD + %% source: src/app/App.tsx + Item["ChangesPanel
item click"] --> hd["handleChangeDiagram
(diagramPath)"] + hd --> inflow{"view is flow?"} + inflow -->|yes| sel["flowsViewRef.
selectDiagramById"] + inflow -->|no| parkD["pendingChangeDiagramRef,
setView('flow')"] + parkD --> eff["effect on view,
changeOverlay"] + eff --> sel +``` + +A removed entity exists only as a ghost, so clicking it with the overlay off turns the overlay on first; the effect then runs after `GraphView` has re-added the ghosts. A relationship item navigates to its `source` entity. A removed diagram (a `flow` item with no `node` and kind `removed`) renders as plain text, not a button, since there is nothing to open. + ## Where it lives ### Shell | Path | Role | |---|---| -| [`src/app/App.tsx`](../../src/app/App.tsx) | State, view-switch, modal hosting, composition. Owns `openEntityById` (with `fromFlow` flag), `modelIndex`/`modelIndexRef` useMemo/ref pair, `appErrorsByEntityId` Map, `appAllFlowNodeIds`, `entityUsageIndex` useMemo, and `pendingScrollProcessIdRef` for dict process-scroll (its `scrollIntoView` call uses `scrollBehaviorWithin(el)` from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts), not a hardcoded `'smooth'`). `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` are shared between the FAB button and the keyboard shortcut `l`. `showHelp` boolean state wires the `?` top-bar button (`.help-toggle`) and `onHelp` passed to `useKeyboardShortcuts`; renders `HelpModal` when true. Wires `onFlowIndex: () => flowsViewRef.current?.toggleIndex()` for the `i` shortcut (flow view only). Owns `flowView`/`collapseLevel` state (seeded hash → localStorage → default `per-process`/`clusters`), `handleToggleFlowView`/`handleCycleCollapseLevel`, and `writeFlowViewHash` (a `history.replaceState` write beside the existing hash params, mirroring how `useHashRoute` writes `view`); both are threaded as props into `FlowsView` and `FabMenu`. **Keyboard pan:** `handleKeyboardPan(dx, dy)` routes the resolver's `{type:'pan'}` action to the active canvas — `graphViewRef.current?.panBy(dx, dy)` on graph, `flowsViewRef.current?.panBy(dx, dy)` on flow, no-op on dict. Interacts with `GraphView`, `DictionaryView`, and `FlowsView` exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). | +| [`src/app/App.tsx`](../../src/app/App.tsx) | State, view-switch, modal hosting, composition. Owns `openEntityById` (with `fromFlow` flag), `modelIndex`/`modelIndexRef` useMemo/ref pair, `appErrorsByEntityId` Map, `appAllFlowNodeIds`, `entityUsageIndex` useMemo, and `pendingScrollProcessIdRef` for dict process-scroll (its `scrollIntoView` call uses `scrollBehaviorWithin(el)` from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts), not a hardcoded `'smooth'`). `dictViewRef` (`DictionaryViewHandle`) and `handleToggleLayoutMode` are shared between the FAB button and the keyboard shortcut `l`. `showHelp` boolean state wires the `?` top-bar button (`.help-toggle`) and `onHelp` passed to `useKeyboardShortcuts`; renders `HelpModal` when true. Wires `onFlowIndex: () => flowsViewRef.current?.toggleIndex()` for the `i` shortcut (flow view only). Owns `flowView`/`collapseLevel` state (seeded hash → localStorage → default `per-process`/`clusters`), `handleToggleFlowView`/`handleCycleCollapseLevel`, and `writeFlowViewHash` (a `history.replaceState` write beside the existing hash params, mirroring how `useHashRoute` writes `view`); both are threaded as props into `FlowsView` and `FabMenu`. **Keyboard pan:** `handleKeyboardPan(dx, dy)` routes the resolver's `{type:'pan'}` action to the active canvas — `graphViewRef.current?.panBy(dx, dy)` on graph, `flowsViewRef.current?.panBy(dx, dy)` on flow, no-op on dict. Interacts with `GraphView`, `DictionaryView`, and `FlowsView` exclusively through typed imperative handles (`GraphViewHandle`, `DictionaryViewHandle`, `FlowsViewHandle`). **Change review:** destructures `diff`/`refreshDiff` from `useModelData`; owns `changeOverlay` (default on) and `changesCollapsed`; derives `shownChanges`/`shownEntityChanges`; passes `changeReview={shownChanges}` to `GraphView`, `DictionaryView`, and `FlowsView`, and `changes={shownEntityChanges?.get(selected.id)}` to `EntityModal`; hosts the parked-target refs and `handleChangeEntity`/`handleChangeDiagram` (see How it works). `ChangesPanel` and `FindingsPanel` render inside one `.side-panel-stack` div, the Changes panel only when `diff` is non-null. | | [`src/app/App.tsx`](../../src/app/App.tsx) (cross-view search) | The shell owns per-surface search state that survives view switches. Graph search: `graphSearchTerm`/`graphSearchIncludeBody` plus `graphSearchCursorRef` (Enter-to-cycle cursor, reset on term/toggle change); `graphSearchMatches` is `null` when inactive or a `Set` (possibly empty) when active — `entityMatches` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts) runs over `model.nodes`. Flow search: `flowSearchTerm`/`flowSearchIncludeBody`; `flowSearchResults` calls `searchFlowDiagrams`; `flowSearchTokens` is threaded into `FlowsView`'s `searchTokens` prop. Neither graph nor flow search state touches the model, layout fingerprint, layout-store, or URL hash. `bannerRef` measures the global-error banner's rendered height via `ResizeObserver` and writes it into the `--search-bar-top` CSS custom property so `SearchBar` sits below the banner. | | [`src/app/App.tsx`](../../src/app/App.tsx) (branding gutter, fix 2026-08-01) | The branding block is `position: fixed`, out of document flow, so no stylesheet can know how much room to leave for it. A `brandingRef`-measuring `useEffect` publishes the block's measured width as the `--branding-gutter` CSS custom property (via `ResizeObserver`, re-published on `[branding, logoSrc]` change since a theme swap can change logo width) so the Dictionary view's full-bleed search bar can indent past it below ~1920px viewport width, where the two previously shared a row and the z-50 branding block painted over the z-30 search input. [`src/app/styles.css`](../../src/app/styles.css)'s `.dict-search-bar-inner` computes `padding-left: max(2rem, calc(16px + var(--branding-gutter, 0px) + 12px - var(--dict-bar-left-slack)))`. | | [`src/app/main.tsx`](../../src/app/main.tsx) | React root mount, reads `window.__MODEL__`/`__THEME_MODE__` globals in static mode. | @@ -112,7 +190,7 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | Path | Role | |---|---| -| [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) | Exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode reads `window.__MODEL__`/`__FLOW_MODEL__`/`__FLOW_CLUSTERS__` once on mount. Live mode boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings, flowDiagrams, flowClusters, flowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. `applyFlowPayload` mirrors a non-empty `/api/flow` `clusters` field into both React state (`flowClusters`, returned by the hook) and the `window.__FLOW_CLUSTERS__` global; `App.tsx` does not destructure `flowClusters` from the hook's return, so the live consumer of the cluster registry is `FlowsView`'s `initFlowGraphCore`, which reads `window.__FLOW_CLUSTERS__` directly — the same global-read pattern `__FLOW_MODEL__` uses for the imperative (non-React-tree) renderer. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set `true` in its cleanup; every `setState` inside the boot and SSE-handler promise chains is gated on the flag to survive React StrictMode's dev-mode mount→cleanup→mount double-invoke. | +| [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) | Exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode reads `window.__MODEL__`/`__FLOW_MODEL__`/`__FLOW_CLUSTERS__` once on mount. Live mode boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings, flowDiagrams, flowClusters, flowFindings, diff, refreshDiff, layoutKeyRef, bannerDismissed, setBannerDismissed }`. `diff: ChangeReview | null` comes from the `/api/model` payload's `diff` field and stays `null` in static mode or when the server could not build a review; `refreshDiff()` refetches `/api/model` and replaces `diff` only. `ChangeReview` is imported type-only, because [`src/model/change-review.ts`](../../src/model/change-review.ts) imports `flow-markdown` at runtime and that pulls shiki into the bundle. `doModelFetch`/`doFlowFetch` are module-level and throw on a non-OK response; the boot and SSE refetches wrap the flow fetch in `fetchFlowOrNull`, so a failed `/api/flow` logs and leaves the model update applied instead of discarding both. `applyFlowPayload` mirrors a non-empty `/api/flow` `clusters` field into both React state (`flowClusters`, returned by the hook) and the `window.__FLOW_CLUSTERS__` global; `App.tsx` does not destructure `flowClusters` from the hook's return, so the live consumer of the cluster registry is `FlowsView`'s `initFlowGraphCore`, which reads `window.__FLOW_CLUSTERS__` directly — the same global-read pattern `__FLOW_MODEL__` uses for the imperative (non-React-tree) renderer. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set `true` in its cleanup; every `setState` inside the boot and SSE-handler promise chains is gated on the flag to survive React StrictMode's dev-mode mount→cleanup→mount double-invoke. | | [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) | Exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. `entity=` in the hash is the single source of truth for the modal stack: `openEntity(id)` pushes history (deduped when the hash already carries the same entity), `closeEntity()` replaces it. `popstate` invokes `onEntityChange(id \| null)` to reconcile the shell without pushing another history entry; it also invokes `onRestoreFlowView(view)`/`onRestoreCollapseLevel(level)` whenever the reconciled hash carries a `flowview=`/`collapse=` value, unconditional on whether it differs from the shell's live state (mirroring how `dfd=` restoration works). Returns `{ view, setView, openEntity, closeEntity }`. | | [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) | Exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage; calls `applyThemeCssVars` on change (also on `model` identity change). Returns `{ themeMode, toggleTheme }`. | | [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) | Registers exactly ONE global `keydown` listener for the unified SPA shortcuts (g/d/f/l/b/i/?//`/Cmd-Ctrl-k/arrows). `onFlowIndex` (`i`, flow view only) is one of the config callbacks alongside `onView`/`onToggleLayout`/`onToggleLens`/etc. Stale-closure hazard avoided via a `configRef` updated each render. Editable guard returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported only by `App.tsx`. | @@ -125,6 +203,7 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | [`src/app/logic/doc-resolver.ts`](../../src/app/logic/doc-resolver.ts) | Exports `buildFlowDocResolver(diagrams, getEntityModel)` and `splitDocToken(token)`. `FlowDocResult` discriminated union: `entity`/`node`/`doc`. Keyed by stable id/slug so `title:` overrides don't break `[[wiki-link]]` resolution. | | [`src/app/logic/flow-node-ids.ts`](../../src/app/logic/flow-node-ids.ts) | Exports `buildAllFlowNodeIds(diagrams, entityModel?)`. Returns `ReadonlySet` merging all process/external/non-db-store ids with ERD entity ids. | | [`src/app/logic/color.ts`](../../src/app/logic/color.ts) | Exports `hexToRgba` and `blendHex`. | +| [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts) | Pure reduction of a `ChangeReview` to render marks. Exports `ChangeMark` (`{ change: ChangeKind; unexplained: boolean }`), `EntityChanges`, `addMark(marks, id, change, unexplained)`, `entityChanges(review)` (per-entity map; skips `relationship` and `flow` items), and `flowNodeMarks(review, diagram, diagramPath)` (per-diagram map keyed by base token), `ChangeSeverity` (`added` | `changed` | `unexplained`), and `flowRowChanges(review, rowKey, opensOwnDiagram)` (a flow index or level-menu row's own mark, its changed fields, and the worst change inside the diagram it opens; read by the flow-view domain's `FlowIndex.tsx` and `LevelMenu.tsx`). `GraphView` also uses `addMark` for relationship marks, keyed `-`. | | [`src/app/logic/search.ts`](../../src/app/logic/search.ts) | Search-matcher logic for all three surfaces. Dictionary matchers (`nodeMatchesSearch`, `processMatchesSearch`, `externalMatchesSearch`, `storeMatchesSearch`) always match id/label/columns/body, no opt-in flag. Graph/Flows matchers are a deliberately different, title-first UX: `entityMatches(node, term, includeBody)` matches only `node.id` by default, body only when `includeBody` is true; `flowProcessMatches`, `flowExternalMatches`, `flowStoreMatches`, `flowDiagramMatches` follow the same pattern. Exports `FlowSearchResultKind`, `FlowSearchResult`, and `searchFlowDiagrams(diagrams, term, includeBody)`, which recursively walks every diagram and its `subDfds` (parent before children); diagrams in `SYNTHETIC_DIAGRAM_IDS` (from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts)) are excluded from results but still walked so their leaf `subDfds` are reached. | | [`src/app/logic/finding-rows.ts`](../../src/app/logic/finding-rows.ts) | Finding-row formatting logic extracted from `FindingsPanel`. | | [`src/app/logic/relationship-key.ts`](../../src/app/logic/relationship-key.ts) | Exports `relationshipRowKey(edge: ModelEdge): string`. Stable, collision-free React key for relationship rows; encodes source, target, and sorted `on` FK pairs to handle dual-FK tables. | @@ -139,13 +218,13 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | Path | Role | |---|---| | [`src/app/dom/body-links.ts`](../../src/app/dom/body-links.ts) | Exports `resolveBodyClick(e, scrollFn)` (shared body-click handler for entity/process/external/store DD body divs — intercepts `a[data-entity]` and `.entity-link--missing` spans) and `upgradeMissingLinksInContainer(container, knownIds)` (rewrites `.entity-link--missing` spans to live `` anchors once the target id is known). | -| [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts) | Exports `applyThemeCssVars(theme, mode)` and mode-aware color constants including `SPOTLIGHT_LINE_INHERITED`. Sets all CSS custom properties on `document.documentElement`, including spotlight vars `--spotlight-line-out`, `--spotlight-line-in`, `--spotlight-line-flow`, `--spotlight-line-inherited`. Called by `useThemeMode`. | +| [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts) | Exports `applyThemeCssVars(theme, mode)` and mode-aware color constants including `SPOTLIGHT_LINE_INHERITED`. Sets all CSS custom properties on `document.documentElement`, including spotlight vars `--spotlight-line-out`, `--spotlight-line-in`, `--spotlight-line-flow`, `--spotlight-line-inherited`. Called by `useThemeMode`. Also exports `CHANGE_COLORS` (per-mode `added`/`changed`/`unexplained` hex values, read by `buildStyles`) and sets `--change-added`, `--change-changed`, `--change-unexplained`. Also exports `REMOVED_RED` (`--change-removed`, both modes) and `REMOVED_TEXT` (`--change-removed-text`, lightened in dark mode for text contrast). | ### UI components (`components/ui/`) | Path | Role | |---|---| -| `Modal.tsx` | Shared modal primitive (title + onClose + children + optional `headerExtra`). | +| `Modal.tsx` | Shared modal primitive (title + onClose + children + optional `headerExtra`). `.modal-backdrop` sits at z-index 210 and the stacked variant at 220, above the global findings banner. `JsonValue.tsx` portals its expanded dialog to `document.body`, because the dictionary view is its own stacking context and would trap the dialog under the banner (covered by [`test/checks/test-modal-above-banner.ts`](../../test/checks/test-modal-above-banner.ts)). | | `ZoomControl.tsx` | View-agnostic zoom readout. Props: `percent`, `onZoomIn`, `onZoomOut`, `onSetPercent(pct)`, `onReset`. Clicking the readout opens an inline commit-on-Enter/blur text input. | | `FabMenu.tsx` | Per-view FAB menus, items view-gated. `kbd-hint` badges surface keyboard shortcuts inline. Flow-specific items (no `kbd-hint`): a per-process/connected toggle button labelled by the *current* mode's opposite (`"Connected view"` while `flowView === 'per-process'`, else `"Per-process view"`), and a collapse-level cycle button labelled by the *target* level via `collapseActionLabel` (`"Collapse to clusters"`, `"Collapse to groups"`, `"Expand to stores"`). | | `HelpModal.tsx` | View-aware orientation overlay built on `Modal`. Three branches (graph/dict/flow), each documenting that surface's search behavior and closing with a `shortcutRows(view)`-driven keyboard section. The flow branch's `FLOW_EXPLORE` rows cover the flow index ("Click Process Flows (or press I)…") and breadcrumb level switching ("A ▾ on a breadcrumb lists the other diagrams at that level…"); `shortcutRows('flow')` adds an `I` row ("Open or close the flow index"). | @@ -161,13 +240,13 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | Path | Role | |---|---| -| `EntityModal.tsx` | Exports `SelectedEntityModal`. | -| `EntityCard.tsx` | Exports `DictEntitySection`. | +| `EntityModal.tsx` | Exports `SelectedEntityModal`. Takes optional `changes?: EntityChanges`, renders a `ChangeBadge`, and forwards `changes` to `ColumnsTable`. | +| `EntityCard.tsx` | Exports `DictEntitySection`. Takes optional `changes?: EntityChanges`: adds a `ChangeBadge` to the header, the `dict-entity-section--removed` class when the entity is removed, and forwards `changes` to `ColumnsTable`. | | `ClassificationBadge.tsx` | Exports `DictClassificationBadge`. | -| `ColumnsTable.tsx` | Columns table, `variant='modal'\|'dict'`. | +| `ColumnsTable.tsx` | Columns table, `variant='modal'\|'dict'`. With `changes`, tags added/changed rows (`change-row--`, `change-tag`) and appends each `removedColumns` entry as a row built from the baseline `ColumnDef`. A table with no current columns still renders when it has removed ones. | | `ChildrenTable.tsx` | Children/subtype table, `variant='modal'\|'dict'`; uses `relationshipRowKey`. | | `ExamplesAccordion.tsx` | Examples accordion, `variant='modal'\|'dict'`. | -| `GridCard.tsx` | Exports `GridCard`, compact entity card for the DD browse lens. | +| `GridCard.tsx` | Exports `GridCard`, compact entity card for the DD browse lens. Optional `change?: ChangeMark` renders a `ChangeBadge` beside the classification badge. | | `FlowNodeGridCard.tsx` | Exports `ProcessGridCard`, `ExternalGridCard`, `StoreGridCard`. | | `SpotlightOverlay.tsx` | The largest single component in the domain. Exports `SpotlightOverlay`. Position:fixed SVG + chips container over the DD browse grid. FK connections draw SOLID bezier paths (direction-coded stroke, arrowhead placement, predicate + cardinality pills). Flow connections draw DASHED paths (`--spotlight-line-flow`). Inherited (lineage) connections draw DOTTED paths in `--spotlight-line-inherited`, computed via `computeInheritedLines` from `buildInheritedConnections`'s output, with a provenance pill ("shared key" for `INHERITED_IDENTITY`, else "via ``"). Off-screen connections render as clickable directional chips instead of a line. Anchors re-measured every rAF-throttled frame via `ResizeObserver` on the grid container, `window resize`, and scroll on `.dict-view`. Calls `separateSpotlightLines` so overlapping edge bundles render as offset parallel paths. Scrolling a pinned card into view calls `scrollBehaviorWithin(targetCard)` (falling back to `'auto'` under `prefers-reduced-motion`) instead of a hardcoded `'smooth'`, so the scroll jumps inside a `data-motion="off"` Dictionary. | @@ -193,32 +272,42 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen | `StackDialog.tsx` | A stack node's row breakdown at the current collapse level, plus grouping-source-specific extra content (see How it works). Props: `node: StackNodeData`, `feederProcessLabels` (dotted-number + label of every process whose edge feeds the stack, shown as a header badge), `processLabelById` (for an adjacency stack's reader/writer links), `clusters: FlowCluster[]`, `groups: Record`, `onClose`, `onOpenEntity`. | | `EdgeContractDialog.tsx` | The CONTRACT dialog for a data-carrying flow edge (see How it works). Props: `edges: FlowEdge[]`, `sourceLabel`/`targetLabel` (the same text the hover tooltip header shows), `entityModel?: Model`, `onClose`, `onOpenEntity`. | +### Changes (`components/changes/`) + +| Path | Role | +|---|---| +| [`src/app/components/changes/ChangesPanel.tsx`](../../src/app/components/changes/ChangesPanel.tsx) | Exports `ChangesPanel` and `itemLabel(item, diagrams)`. Renders nothing when items, claims, notes, and note errors are all empty. Sections: the change notes (each with the items that cite it), `Unexplained`, `Named but unchanged` (claims), and `Note errors`. Header counts `items + claims + noteErrors` as "to review". Carries the "Highlight changes" checkbox (`onOverlayChange`) and an Approve button that POSTs `/api/approve`, then calls `onRefresh` after `APPROVE_REFRESH_MS` (5 s) unless a new `review` prop arrives first (the SSE refresh clears "Approving…" by changing `review`). Flow item labels resolve diagram titles and node labels from `flowDiagrams`, falling back to ids for what the current flows no longer hold. A `removed` row renders a solid red bar, a ✕ (`changes-panel-removed-icon`), and a red "removed" label (`changes-panel-removed`). | +| [`src/app/components/changes/ChangeBadge.tsx`](../../src/app/components/changes/ChangeBadge.tsx) | Exports `ChangeBadge({ mark })`: a `change-badge--` pill, plus `change-badge--unexplained` and the text "unexplained" when the mark is. Renders nothing for an undefined mark. A removed Dictionary badge keeps full opacity on its dimmed card. Also exports `ChangeDot({ severity, mark })`: a static `change-dot--` dot for the worst change inside a row's diagram, or a pulsing `change-dot--pulse` dot in the row's own kind color. | + +Change-review checks: [`test/checks/test-change-review-graph.ts`](../../test/checks/test-change-review-graph.ts) covers graph glows, ghosts, and the Changes panel; [`test/checks/test-change-review-dict-flow.ts`](../../test/checks/test-change-review-dict-flow.ts) covers Dictionary marks and flow node glows; [`test/checks/test-change-review-flow-index.ts`](../../test/checks/test-change-review-flow-index.ts) covers flow index and level-menu marks. + ### Findings | Path | Role | |---|---| -| `components/findings/FindingsPanel.tsx` | `` renders only when `totalFindings > 0`; collapses to a badge; present across all three views. | +| `components/findings/FindingsPanel.tsx` | `` renders only when `totalFindings > 0`; collapses to a badge; present across every view. Shares the `.side-panel`, `.side-panel-header`, `.side-panel-collapse`, and `.side-panel-badge` classes with `ChangesPanel`. | ### Views | Path | Role | |---|---| -| `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate`, `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Hover settles before it repaints:** `cy.on('mouseover'/'mouseout', 'node')` feed `graphHover` (a `createHoverIntent` from `logic/motion.ts`) instead of restyling directly; `showHover(nodeId)` is the `apply` callback — it restores the leaving node's forward predicates, then, for a settled non-null target, shows its reverse predicates plus (`shiftHeld`) either lineage via `enterLineageHover` or a plain `applyFocusTiers` fade, falling back to the selected node's tiers or a full clear on `null`. `shiftHeld` (a plain `let` local to the cy-init effect, updated by the `mouseover` handler's event and by document-level `onShiftKeyDown`/`onShiftKeyUp` listeners) and `hoveredNodeIdRef` (a ref) track live Shift and pointer state so `onShiftKeyDown` can call `graphHover.applyNow(hoveredId)` at once when Shift is pressed over a still-waiting hover. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. | -| `views/graph/organic-layout.ts` | Organic (fCoSE-based) layout engine built on `cytoscape-fcose`. `ORGANIC_FALLBACK_THRESHOLD = 500` entities before falling back to layered. `buildScratchCore` runs the multi-seed layout search on a headless mirror core so only winning positions touch the live core; `groupRegions: true` (entity count ≥ 150) wraps each color family in an invisible compound parent so fCoSE decomposes large models into per-family sub-layouts. `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline (expand, fan subtype clusters, dock leaves/isolates, deoverlap). `gradeEdgeSpans(cy)` grades every edge's `span` (`'near'\|'mid'\|'far'`) by percentile within the layout's own length distribution, for `styles.ts`'s length-graded de-emphasis. | +| `views/graph/GraphView.tsx` | Exports `GraphView` (forwardRef) and `GraphViewHandle`/`LayoutMode` types. Owns the full Cytoscape lifecycle, navigator lifecycle, zoom adapter, hash wiring, preset-layout cache-skip, ELK cost scaling. `GraphViewHandle` exposes `navigateToEntity`, `panelNavigate` (findings and changes panel rows; held until a running layout settles), `resetLayout`, `applyLayoutMode`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `retheme`. `wheelSensitivity: 0.2`. **Change overlay:** takes `changeReview: ChangeReview | null`, mirrored into `changeReviewRef`; an effect on `[changeReview, isActive, cyReady]` calls `applyChangeOverlayRef` (see How it works). `nodeData`/`edgeData`/`graphEdgeId` are shared by the initial element build and the ghost builder. **Hover settles before it repaints:** `cy.on('mouseover'/'mouseout', 'node')` feed `graphHover` (a `createHoverIntent` from `logic/motion.ts`) instead of restyling directly; `showHover(nodeId)` is the `apply` callback — it restores the leaving node's forward predicates, then, for a settled non-null target, shows its reverse predicates plus (`shiftHeld`) either lineage via `enterLineageHover` or a plain `applyFocusTiers` fade, falling back to the selected node's tiers or a full clear on `null`. `shiftHeld` (a plain `let` local to the cy-init effect, updated by the `mouseover` handler's event and by document-level `onShiftKeyDown`/`onShiftKeyUp` listeners) and `hoveredNodeIdRef` (a ref) track live Shift and pointer state so `onShiftKeyDown` can call `graphHover.applyNow(hoveredId)` at once when Shift is pressed over a still-waiting hover. **3-tier focus opacity:** direct (full opacity), inherited/ancestral (`.inherited-dim`, 0.5), unrelated (`.faded`, 0.2). **Cross-view search:** accepts `searchMatches: ReadonlySet \| null`; `applySearchClasses` applies dedicated `.search-match`/`.search-dim` classes, kept separate from the hover-tier and lineage classes so search dimming survives hover/lineage/tap/relayout. | +| `views/graph/organic-layout.ts` | Organic (fCoSE-based) layout engine built on `cytoscape-fcose`. `ORGANIC_FALLBACK_THRESHOLD = 500` entities before falling back to layered. `buildScratchCore` runs the multi-seed layout search on a headless mirror core so only winning positions touch the live core; `groupRegions: true` (entity count ≥ 150) wraps each color family in an invisible compound parent so fCoSE decomposes large models into per-family sub-layouts. `arrangeOrganic(cy, iters)` is the post-settle local-polish pipeline (expand, fan subtype clusters, dock leaves/isolates, deoverlap). `gradeEdgeSpans(edges: cytoscape.EdgeCollection)` grades the given edges' `span` (`'near'\|'mid'\|'far'`) by percentile within that collection's own length distribution (`GraphView` passes every edge except ghosts), for `styles.ts`'s length-graded de-emphasis. | | `views/graph/navigator.ts` | `mountNavigator`/`teardownNavigator`/`NavigatorInstance`, cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` before `nav.destroy()` to avoid a resize-listener leak that fires on a destroyed core. | -| `views/graph/styles.ts` | `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. `.faded` (0.2), `.inherited-dim` (0.5), `edge.inherited` dotted at 0.5 opacity. Length-graded de-emphasis on `edge[span]`. Graph search: `SEARCH_MATCH_BORDER` gold/yellow border on `.search-match`, `.search-dim` at 0.2 opacity, both pushed last in the stylesheet array to win the cascade. | +| `views/graph/change-glow.ts` | `createChangeGlow(cy, container, colors)` → `ChangeGlow` with `destroy()`: canvas behind cytoscape for pulsing blurred node glows (clipped out of the node, viewport-culled, one rAF loop, `GLOW_PERIOD_MS` 3600, reduced-motion `matchMedia` listener) and canvas above it for the ghost ✕ badge. Exports `glowColors`, `GLOW_STEADY`, `EDGE_GLOW_SCALE`, `UNEXPLAINED_STEADY_MIX`, `TIER_OPACITY` for `styles.ts`. | +| `views/graph/styles.ts` | `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. `.faded` (0.2), `.inherited-dim` (0.5), `edge.inherited` dotted at 0.5 opacity. Length-graded de-emphasis on `edge[span]`. Graph search: `SEARCH_MATCH_BORDER` gold/yellow border on `.search-match`, `.search-dim` at 0.2 opacity, both pushed last in the stylesheet array to win the cascade. Change review: `node[change=...]`/`edge[change=...]` rules use `glowColors(theme, mode)` from `change-glow.ts`, edge glow underlays at `GLOW_STEADY * EDGE_GLOW_SCALE` (node glows live in `change-glow.ts`), the removed-ghost dashed border, and are pushed before the focus and search classes so those still dim changed elements; `[unexplained = "true"]` blends the underlay toward the unexplained color (`UNEXPLAINED_STEADY_MIX`). | | `views/graph/markers.ts` | Exports `drawWarningBadges(cy, svg, entityIds: Set)`, `createMarkerOverlay(container)`, `updateMarkers(cy, svg, theme, mode?)`. | | `views/graph/wrap-label.ts` | `wrapEntityLabel`: underscores → spaces; names longer than ~13 chars break at PascalCase/acronym/digit boundaries. | -| `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). | -| `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); }`. Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search, `beforeprint`/`afterprint` print handling, DD sidebar process nesting. **Browse lens** (`'read'\|'browse'`, persisted to localStorage): entity groups + Processes/External Entities/Data Stores sections; spotlight state `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId`. A card's hover goes through `cardHover`, a `createHoverIntent` instance whose `apply` callback sets `hoverId` and, when the settled card is a spotlit-but-not-active card, `labelHoverCardId`; switching lens calls `cardHover.applyNow(null)` to clear at once. The root `.dict-view` gets `data-motion="off"` when `animationsAllowed(totalVisible + totalFlowVisible)` is `false` (see How it works), and every `scrollIntoView` call in the file (`scrollToEntity`, `scrollToSection`, `scrollToMissing`, `scrollToProcess`) uses `scrollBehaviorWithin(el)` instead of a hardcoded `'smooth'`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. | -| `views/flow/FlowsView.tsx` | Exports `FlowsView` (forwardRef), `FlowsViewHandle`, `FlowSurface` (the stateful dialog-hosting wrapper around `FlowDiagramSvg`, see How it works), `FlowChromeCallbacks`, and `initFlowGraphCore` (the imperative renderer lifecycle both static and live modes call). `FlowsViewProps` carries `flowView: FlowViewMode` and `collapseLevel: FlowCollapseLevel`, both owned by the shell; a change to either is an effect dependency of the renderer-mount effect, so toggling either re-mounts `initFlowGraphCore` and every diagram in the session picks up the new opts. `renderDiagram` calls `computeElkLayout(diagram, flowDataOpts)` from [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and passes `elkPositions`/`elkEdgeRoutes` into `FlowDiagramSvg`; falls back to a banded `computeFlowLayout` only on ELK failure. `FlowsViewHandle` exposes `selectDiagramById(ref)` (resolves a `dfd=`-style diagram reference — a bare id or a path such as `invoicing/Submit-PCI` — via `findDiagramByRef` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts)), `toggleIndex()` (delegates to `flowChromeRef.current?.toggleIndex()`, the `i`-key entry point), `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. `initFlowGraphCore` exposes both `selectDiagramById(ref)` and `selectDiagramPath(ids)` (walks `resolveDiagramPath`, exact — so two flows sharing a process file name resolve to the right one); both funnel into an internal `showPath(path)` that replaces the breadcrumb stack via `stackFor(path)` (each step's label is the owning process's dotted number + label, or the diagram's own title at the root) and renders the path's last diagram (see How it works). On mount, the starting diagram resolves the seeded `dfd=` reference the same way, falling back to `defaultDiagramPath(allDiagrams)` (flow-view domain — opens the System overview) when it is absent or unresolvable. `onRegisterHandlers(drillUp, selectDiagram, selectPath)` hands all three selectors up to `FlowChrome` (flow-view domain) for the breadcrumb level menus and the flow index. `FlowChrome` receives `modelName`/`modelDescription` from `getEntityModel()?._meta` for the index's whole-system description fallback. `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the bare `diagram.id` on every render; `onActiveDiagramChange` fires separately with the path reference (`diagramRef(stack...)`) that `App.tsx` writes into the `dfd=` hash. **`disposed` teardown guard:** `initFlowGraphCore` declares `let disposed = false`; the async `renderDiagram` checks it before and after every `await computeElkLayout(...)` to guard against an orphaned continuation resuming after React StrictMode's dev-mode mount→cleanup→mount. | +| `views/graph/layout-store.ts` | Exports `PositionMap`, `StorageLike`, `LayoutStoreHandle`, `createLayoutStore(storage?, now?)`. Single localStorage key `ignatius-layout-positions`; newest-10 pruning on save. `PositionMap` is also imported directly by [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). `LayoutStoreHandle.lastPosition(nodeId, exceptKey, neighbourIds)` returns the newest saved position of `nodeId` from any entry other than `exceptKey` that also saved at least one of `neighbourIds`; the store is shared by every model, and the neighbour test keeps same-named nodes of other models out. Only `GraphView`'s ghost placement calls it. | +| `views/dict/DictionaryView.tsx` | `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(); focusSearch(); }`. Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search, `beforeprint`/`afterprint` print handling, DD sidebar process nesting. **Browse lens** (`'read'\|'browse'`, persisted to localStorage): entity groups + Processes/External Entities/Data Stores sections; spotlight state `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId`. A card's hover goes through `cardHover`, a `createHoverIntent` instance whose `apply` callback sets `hoverId` and, when the settled card is a spotlit-but-not-active card, `labelHoverCardId`; switching lens calls `cardHover.applyNow(null)` to clear at once. The root `.dict-view` gets `data-motion="off"` when `animationsAllowed(totalVisible + totalFlowVisible)` is `false` (see How it works), and every `scrollIntoView` call in the file (`scrollToEntity`, `scrollToSection`, `scrollToMissing`, `scrollToProcess`) uses `scrollBehaviorWithin(el)` instead of a hardcoded `'smooth'`. FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`; inherited (lineage) connections from `buildInheritedConnections`, gated behind a document-level `shiftHeld` boolean. **Change review:** props `changeReview` and `entityChanges`; passes `change={entityChanges?.get(n.id)?.mark}` to each `GridCard` and `changes=` to each `EntityCard`. `removedNodes` (from `changeReview.removedNodes`, filtered by the active search) feeds a "N removed since approval" link in the browse lens, whose `showRemoved` switches to the read lens inside `flushSync` and scrolls to `#dict-removed`, and a read-lens "Removed since approval" section of `EntityCard`s built from `changeReview.removedEdges`. | +| `views/flow/FlowsView.tsx` | Exports `FlowsView` (forwardRef), `FlowsViewHandle`, `FlowSurface` (the stateful dialog-hosting wrapper around `FlowDiagramSvg`, see How it works), `FlowChromeCallbacks`, and `initFlowGraphCore` (the imperative renderer lifecycle both static and live modes call). `FlowsViewProps` carries `flowView: FlowViewMode` and `collapseLevel: FlowCollapseLevel`, both owned by the shell; a change to either is an effect dependency of the renderer-mount effect, so toggling either re-mounts `initFlowGraphCore` and every diagram in the session picks up the new opts. `renderDiagram` calls `computeElkLayout(diagram, flowDataOpts)` from [`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts) and passes `elkPositions`/`elkEdgeRoutes` into `FlowDiagramSvg`; falls back to a banded `computeFlowLayout` only on ELK failure. `FlowsViewHandle` exposes `selectDiagramById(ref)` (resolves a `dfd=`-style diagram reference — a bare id or a path such as `invoicing/Submit-PCI` — via `findDiagramByRef` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts)), `toggleIndex()` (delegates to `flowChromeRef.current?.toggleIndex()`, the `i`-key entry point), `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `panBy`, `openFlowToken`. `initFlowGraphCore` exposes both `selectDiagramById(ref)` and `selectDiagramPath(ids)` (walks `resolveDiagramPath`, exact — so two flows sharing a process file name resolve to the right one); both funnel into an internal `showPath(path)` that replaces the breadcrumb stack via `stackFor(path)` (each step's label is the owning process's dotted number + label, or the diagram's own title at the root) and renders the path's last diagram (see How it works). On mount, the starting diagram resolves the seeded `dfd=` reference the same way, falling back to `defaultDiagramPath(allDiagrams)` (flow-view domain — opens the System overview) when it is absent or unresolvable. **Change review:** `FlowsViewProps.changeReview` seeds `initFlowGraphCore`'s `initialChangeReview` parameter; later changes arrive through `FlowChromeCallbacks.onRegisterChangeReview` (the same register-a-setter pattern as `onRegisterSearchTokens`), so a toggle re-renders the current diagram without re-mounting. Each render computes `diagramPath` from every id in the breadcrumb stack (synthetic levels included, matching how change items key diagrams) and passes `changeMarks: flowNodeMarks(review, diagram, diagramPath)` to `FlowDiagramSvg`. `onRegisterHandlers(drillUp, selectDiagram, selectPath)` hands all three selectors up to `FlowChrome` (flow-view domain) for the breadcrumb level menus and the flow index. `FlowChrome` receives `modelName`/`modelDescription` from `getEntityModel()?._meta` for the index's whole-system description fallback. `window.__IGNATIUS_ACTIVE_FLOW_DFD__` is set to the bare `diagram.id` on every render; `onActiveDiagramChange` fires separately with the path reference (`diagramRef(stack...)`) that `App.tsx` writes into the `dfd=` hash. **`disposed` teardown guard:** `initFlowGraphCore` declares `let disposed = false`; the async `renderDiagram` checks it before and after every `await computeElkLayout(...)` to guard against an orphaned continuation resuming after React StrictMode's dev-mode mount→cleanup→mount. | | `views/flow/LegendModal.tsx` | `LegendModal` component; imports `DARK_PALETTE`/`LIGHT_PALETTE`/`FlowPalette` from [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx). | ### Other | Path | Role | |---|---| -| [`src/app/styles.css`](../../src/app/styles.css) (3374L) | Full SPA stylesheet. `@media print` block, `::highlight(dd-search-highlight)`, `.dict-process-direction` badges, `.flow-minimap-wrapper` (now fixed at `left: 16px` — the DFD nav card it used to step aside for is gone), `.zoom-control`, `.kbd-hint`, `.flow-edge-tooltip`, `.help-toggle`/`.help-modal`/`.help-section*` (help overlay), `.stack-dialog-row-toggle` (the `
` disclosure marker for `StackDialog` rows), `[data-motion="off"]` (drops `.dict-grid-card--dim`/`--spotlit` transitions), the flow-view domain's `.flow-crumbs*`/`.flow-crumb*` (breadcrumbs with a ▾ level-menu trigger), `.flow-level-menu*` (a crumb's level dropdown), and `.flow-index*` (the process-hierarchy chart + description pane) — stacked crumbs (z-46) above the flow search bar (45) so a level menu drops over it, the index (56) above the minimap (50) and zoom control (55), all under the FAB (60) — DD chrome (`.dict-view`, `.dict-search-bar`, `.dict-browse-lens`, `.dict-grid-card`, `.spotlight-overlay`, `.spotlight-line*`), the shared Graph/Flows `.viewer-search-bar`/`.viewer-search-results` chrome, and the `.dict-search-bar-inner` branding-gutter padding rule. | +| [`src/app/styles.css`](../../src/app/styles.css) | Full SPA stylesheet. Change-review rules: `.side-panel*` and `.side-panel-stack` (shared panel chrome), `.changes-panel*`, `.change-badge*`, `.change-row*`, `.change-tag`, `.dict-removed-link`, `.dict-removed-section`, `.change-dot*`, and the box-shadow glow on `.flow-index__pill` and `.change-dot--pulse` rows. `@media print` block, `::highlight(dd-search-highlight)`, `.dict-process-direction` badges, `.flow-minimap-wrapper` (now fixed at `left: 16px` — the DFD nav card it used to step aside for is gone), `.zoom-control`, `.kbd-hint`, `.flow-edge-tooltip`, `.help-toggle`/`.help-modal`/`.help-section*` (help overlay), `.stack-dialog-row-toggle` (the `
` disclosure marker for `StackDialog` rows), `[data-motion="off"]` (drops `.dict-grid-card--dim`/`--spotlit` transitions), the flow-view domain's `.flow-crumbs*`/`.flow-crumb*` (breadcrumbs with a ▾ level-menu trigger), `.flow-level-menu*` (a crumb's level dropdown), and `.flow-index*` (the process-hierarchy chart + description pane) — stacked crumbs (z-46) above the flow search bar (45) so a level menu drops over it, the index (56) above the minimap (50) and zoom control (55), all under the FAB (60) — DD chrome (`.dict-view`, `.dict-search-bar`, `.dict-browse-lens`, `.dict-grid-card`, `.spotlight-overlay`, `.spotlight-line*`), the shared Graph/Flows `.viewer-search-bar`/`.viewer-search-results` chrome, and the `.dict-search-bar-inner` branding-gutter padding rule. | | [`src/app/hash-router.ts`](../../src/app/hash-router.ts) (fromFlow flag) | `openEntityById(id, fromFlow = false)` — when `true`, the modal's FK links, body `[[wiki-links]]`, and process-usage links stay in-place over the Flows view instead of switching to graph/dict. | | [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts) (kind-colored stores/externals) | `FlowsView` calls `resolveFlowKindPalette(themeMode, themeConfig?.flowKinds)` and passes the palette into `FlowDiagramSvg`. `KindMarker` reads `FLOW_STORE_KIND_SYMBOLS` from the same module for its non-`proc`/`ext` glyph. | | [`src/model/model-index.ts`](../../src/model/model-index.ts) (ModelIndex wiring) | `buildModelIndex(model)` is called once per Model in `App.tsx` via `useMemo`; `modelIndexRef` mirrors the live value for cy-init closures. | @@ -231,6 +320,11 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen - **`EdgeContractDialog` only resolves columns for a `db:`-kind store endpoint.** Any other endpoint kind falls back to a one-column table of the raw `data` lines with no type/group resolution, because non-`db` endpoints have no entity-model backing and are never stacked. - **`useModelData`'s `flowClusters` return value is not read by `App.tsx`.** The live consumer of the cluster registry is `FlowsView`'s `initFlowGraphCore`, which reads `window.__FLOW_CLUSTERS__` directly rather than through a prop. - **`IoTable`'s rich-link props are opt-in.** Without `onOpenEntity`/`onOpenToken`/`canOpenToken`, a `db:` cell falls back to `onScrollToEntity` (dict scroll-to-anchor) and a non-`db` cell renders as plain text — only `FlowNodeModal` passes the rich-link props. +- **Ghost elements exist only while a layout is settled.** `GraphView` removes every `.change-ghost` element before a layout starts and re-adds them on settle; anything iterating `cy.nodes()` or `cy.edges()` for layout, saving, or span grading must exclude the class (`gradeSpans` and the drag-release save (`free` handler) do). Including ghosts saves positions for entities that are not in the model and skews edge-length percentiles. +- **`ChangesPanel` is fed `diff`, the markers are fed `shownChanges`.** Passing `shownChanges` to the panel would empty it when "Highlight changes" is unchecked, and passing `diff` to a surface would ignore the checkbox. +- **`diff` is `null` in static mode.** `ChangesPanel` is not rendered and every `changeReview`/`changes`/`change` prop is `null` or `undefined`, so a surface must treat an absent review as no markers rather than as an empty review. +- **`ChangeReview` is imported type-only in the frontend.** A runtime import of [`src/model/change-review.ts`](../../src/model/change-review.ts) pulls `flow-markdown` and shiki into the browser bundle. +- **A relationship change marks a graph edge only.** `entityChanges` skips `relationship` items, so the Dictionary and entity modal show no relationship marker; `GraphView` builds edge marks itself, keyed `-`. - **Layer rule is downward-only by discipline, not build enforcement.** Shell (`App.tsx`) → views (`views/*/`) → components (`components/*/`) → ui (`components/ui/`) → logic/dom (`logic/`, `dom/`); a lower layer importing from a higher one (or `logic/`/`dom/` importing React/DOM) reintroduces the tangled coupling the `src/App.tsx` decomposition ([`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md)) removed. - **Pure logic modules stay DOM/React-free by discipline.** `logic/motion.ts`, `logic/spotlight.ts`, `logic/spotlight-inherited.ts`, `logic/spotlight-lines.ts`, `logic/shortcuts.ts`, `logic/search.ts`, `logic/flow-spotlight.ts`, `logic/doc-resolver.ts`, `logic/finding-rows.ts`, `logic/relationship-key.ts`, `logic/color.ts`, `logic/flow-node-ids.ts`, and `components/process/IoTable.tsx`'s `resolveIoRowCells` are browser-safe and independently unit-testable; importing React or a DOM API into any of them breaks that testability and the reuse across `App.tsx`/view components (and, for `motion.ts`, the flow-view domain) it enables. - **Every hover site in this domain funnels through `createHoverIntent`.** `GraphView`'s node fade and `DictionaryView`'s card spotlight each own one `createHoverIntent` instance; restyling directly from a `mouseover`/`mouseenter` handler instead of through the instance's `apply` callback reintroduces the per-element repaint-on-sweep the hover-intent design ([`docs/design/large-model-nav.md`](../design/large-model-nav.md)) removed. @@ -246,6 +340,9 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen - **model** ([`src/model/parse.ts`](../../src/model/parse.ts), `model-index.ts`, `validate.ts`) — `App.tsx` and most views/components import `Model`, `ModelNode`, `ModelEdge`, `ModelIndex`, `buildModelIndex`, `GroupConfig`, and validation `RULES`/`EntityError` directly. `EdgeContractDialog` and `StackDialog` both import `GroupConfig`/`Model` for column-type and group-label resolution. `FlowsView` reads `Model._meta` (`name`/`desc`) and passes it into `FlowChrome` as the flow index's whole-system description fallback. A change to the `Model` or `ModelIndex` shape forces changes throughout [`src/app/`](../../src/app). - **flows** ([`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts), `flow-parse.ts`, `flow-validate.ts`, `flow-clusters.ts`) — `DictionaryView` and `FlowsView` import `SYNTHETIC_DIAGRAM_IDS` and flow-diagram types; `logic/flow-node-ids.ts` and `logic/search.ts` build on flow diagram shapes; `useModelData` types its `FlowApiPayload.clusters` as `FlowCluster[]` from `flow-clusters.ts`; `StackDialog` imports `FlowCluster` for its author-cluster body lookup. `FlowDiagram.description` (from a flow folder's `index.md` frontmatter) flows through to the flow-view domain's `FlowIndex`/`LevelMenu`, not through [`src/app/`](../../src/app). A change to flow diagram structure, leveling, or the cluster registry shape forces changes here. +- **parser** ([`src/model/change-review.ts`](../../src/model/change-review.ts)) — the types `ChangeReview`, `ChangeItem`, `ChangeKind`, `ChangeClaim`, and `ReviewedItem` define every prop the markers and the panel take. A change to those types or to how items key diagrams (`diagramPath`) forces changes in `logic/change-marks.ts`, `GraphView`, `FlowsView`, and `ChangesPanel`. +- **server** (`/api/model`'s `diff` and `/api/approve`) — `useModelData` reads `diff` from the `/api/model` payload and `ChangesPanel`'s Approve button POSTs `/api/approve`. A change to the `diff` field's shape or the approve contract forces changes in `useModelData` and `ChangesPanel`. +- **flow-view change marks** — the flow-view domain's `FlowDiagramSvg` takes the `changeMarks` map `FlowsView` builds (it imports `ChangeMark` from [`src/app/logic/change-marks.ts`](../../src/app/logic/change-marks.ts)). - **flow-view** ([`src/flow-view/`](../../src/flow-view) — separate domain, ELK/SVG rendering, breadcrumbs, and the flow index) — `FlowsView.tsx`, `LegendModal.tsx`, `StackDialog.tsx`, and `EdgeContractDialog.tsx` import from it: `FlowDiagramSvg`, `FlowChrome`, `computeElkLayout`, `screenScaleToPercent`/`percentToScreenScale`, the `DARK_PALETTE`/`LIGHT_PALETTE` constants, `normalizeEdgeData`, and the `StackNodeData`/`StackRow`/`StackMember` types that back the stack dialog. `FlowsView` also imports `resolveDiagramPath` from [`src/flow-view/flow-nav.ts`](../../src/flow-view/flow-nav.ts) for its `selectDiagramPath` handler, and calls `flowChromeRef.current?.toggleIndex()` (the `FlowChrome` imperative handle) to open/close the flow index on `i`. Coupling runs both ways: [`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx) imports the `PositionMap` type from [`src/app/views/graph/layout-store.ts`](../../src/app/views/graph/layout-store.ts) and, for its own hover-settle and animation-cutoff behavior, `createHoverIntent`/`animationsAllowed` from [`src/app/logic/motion.ts`](../../src/app/logic/motion.ts). A change to `FlowDiagramSvg`'s prop contract, `FlowChrome`'s `onSelectPath`/`toggleIndex` contract, `StackNodeData`'s shape, `motion.ts`'s exports, or ELK layout output forces a change in `FlowsView.tsx` and/or the two dialog components. - **theme** ([`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)) — `App.tsx`, `dom/theme-css-vars.ts`, `hooks/useThemeMode.ts`, `views/graph/styles.ts`, `views/graph/markers.ts`, `views/flow/LegendModal.tsx`, `views/flow/FlowsView.tsx`, `views/dict/DictionaryView.tsx`, `components/process/KindMarker.tsx`, and `components/entity/FlowNodeGridCard.tsx` all consume `semanticColors`/`resolveFlowKindPalette`/`FLOW_STORE_KIND_SYMBOLS`/theme config types. A theme-shape change ripples widely through this domain. - **server** ([`src/server/`](../../src/server)) — [`src/server/server.ts`](../../src/server/server.ts) imports [`src/app/index.html`](../../src/app/index.html) directly as its `Bun.serve()` HTML route; the frontend build/dev flow is driven by the server domain, not a separate bundler step. `/api/flow`'s response shape (including its `clusters` field) is the contract `useModelData`'s `FlowApiPayload` type encodes. @@ -253,6 +350,7 @@ A `flowview=`/`collapse=` value can also arrive via **Back/Forward**, independen ### Related design docs +- [`docs/design/model-change-review.md`](../design/model-change-review.md) — the baseline, change notes, cross-check, and approve flow behind the Changes panel and the overlay. - [`docs/design/large-model-nav.md`](../design/large-model-nav.md) and [`docs/spec/large-model-nav.md`](../spec/large-model-nav.md) — hover-settle timing and the animation cutoff (`logic/motion.ts`), and the flow index/breadcrumb-level-menu navigation model. - [`docs/design/unified-app.md`](../design/unified-app.md) — collapsing graph/dict/flow into one React surface. - [`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) — the original `App.tsx` monolith breakup into [`src/app/`](../../src/app). diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 487ec26..a2c6bb2 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -70,18 +70,18 @@ No linter or formatter configured in package.json. | Domain | Repo paths | One-liner | Detail | |--------|------------|-----------|--------| -| cli | [`src/cli/`](../../src/cli) | citty-based subcommand dispatch (serve/validate/export/index/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update with streamed download progress + checksum verification | [`docs/wiki/cli.md`](cli.md) | -| server | [`src/server/server.ts`](../../src/server/server.ts) | Bun.serve with `/api/model` + `/api/flow` (returns `clusters`) + `/events` SSE + fs.watch live-reload; `/dict` and `/flow` redirect to unified SPA hash routes; `/flow-dict` redirects to `/#view=dict` | [`docs/wiki/server.md`](server.md) | -| parser | [`src/model/parse.ts`](../../src/model/parse.ts), [`src/model/wikilink.ts`](../../src/model/wikilink.ts), [`src/model/model-index.ts`](../../src/model/model-index.ts) | `ignatius.yml` config loading → ParseResult: {model, globalErrors}; nodes, edges, cardinality + classification derivation; wiki-link inline rule + two-pass body rendering; `ModelMeta.flowView` (`flow_view:`/`adjacency_stacks`); `buildModelIndex` — 13 O(1) lookup maps built once per Model | [`docs/wiki/parser.md`](parser.md) | -| validate | [`src/model/validate.ts`](../../src/model/validate.ts) | Pure model validator: 38 RuleIds across 8 prefixes (parse/config/entity/body/edge/cluster/index/flow), two severity tiers (A=warn, B=omit); `flow.*` is 17 ids including five `flow.cluster_*` DFD-store-cluster rules; `validateIndex` reuses `buildRouters` to detect router drift | [`docs/wiki/validate.md`](validate.md) | -| flows | [`src/flows/`](../../src/flows) | SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`, now also reading a folder's own `description:` off `index.md`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (17 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model | [`docs/wiki/flows.md`](flows.md) | -| flow-view | [`src/flow-view/`](../../src/flow-view) | ELK-driven DFD layout (5-band partitioning, orthogonal edge routing) with a stack-node model for per-process/connected views and three collapse levels (stores/clusters/groups); `flow-nav.ts` resolves exact diagram paths for the new flow index + breadcrumb level menus (`FlowIndex.tsx`/`LevelMenu.tsx`, replacing the old standalone nav card); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming and gates hover transitions through the frontend domain's hover-intent/animation-limit helpers | [`docs/wiki/flow-view.md`](flow-view.md) | -| frontend | [`src/app/`](../../src/app) | React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; `logic/motion.ts` centralizes hover-intent debouncing and a per-view animation-element-limit; `flowview=`/`collapse=` hash params drive the flow view mode and collapse level; new `i` shortcut opens the flow index; `StackDialog`/`EdgeContractDialog` cover stack and edge-contract detail; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath | [`docs/wiki/frontend.md`](frontend.md) | +| cli | [`src/cli/`](../../src/cli) | `ignatius` binary dispatch via citty (serve/validate/index/export/approve/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive `pickModel`; port fallback + browser open on serve; self-update with checksum verification | [`docs/wiki/cli.md`](cli.md) | +| server | [`src/server/server.ts`](../../src/server/server.ts) | Bun.serve with `/api/model` (carries the change-review `diff`), `POST /api/approve`, `/api/flow` (returns `clusters`), `/events` SSE live-reload driven by fs.watch plus a `baseline.json` stat poll; `/dict`, `/flow`, `/flow-dict` redirect to SPA hash routes | [`docs/wiki/server.md`](server.md) | +| parser | [`src/model/parse.ts`](../../src/model/parse.ts), [`src/model/wikilink.ts`](../../src/model/wikilink.ts), [`src/model/model-index.ts`](../../src/model/model-index.ts) | `ignatius.yml` config loading → ParseResult: {model, globalErrors}; nodes, edges, cardinality + classification derivation; wiki-link inline rule + two-pass body rendering (`linkStateNeutral`); `buildModelIndex` lookup maps; change review: `change-review.ts` diffs model + flows against the approved baseline kept by `change-store.ts` and cross-checks agent change notes | [`docs/wiki/parser.md`](parser.md) | +| validate | [`src/model/validate.ts`](../../src/model/validate.ts) | Pure model validator: RuleIds across parse/config/entity/body/edge/cluster/index/flow prefixes, two severity tiers (A=warn, B=omit); `flow.cluster_*` DFD-store-cluster rules; `validateIndex` reuses `buildRouters` to detect router drift | [`docs/wiki/validate.md`](validate.md) | +| flows | [`src/flows/`](../../src/flows) | SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`, reading a folder's own `description:` off `index.md`), `flow-clusters.ts` `cluster:` token expansion from `clusters/.md` author files, `validateFlows` (`flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model | [`docs/wiki/flows.md`](flows.md) | +| flow-view | [`src/flow-view/`](../../src/flow-view) | ELK-driven DFD layout (band partitioning, orthogonal edge routing), stack-node model and collapse levels (stores/clusters/groups); SVG renderer with change-review glows; `flow-nav.ts` path resolution for the flow index and breadcrumb level menus, which show change marks (pill glow, pulsing or static `ChangeDot`, index change summary) | [`docs/wiki/flow-view.md`](flow-view.md) | +| frontend | [`src/app/`](../../src/app) | React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; change-review overlay (`ChangesPanel`, `ChangeBadge`/`ChangeDot`, `logic/change-marks.ts`) marks graph, Dictionary, flow nodes, flow index and level-menu rows, with removed items in warning red; modals stack above the global findings banner; `flowview=`/`collapse=` hash params; views own cy/SVG lifecycle | [`docs/wiki/frontend.md`](frontend.md) | | generators | [`src/generators/`](../../src/generators) | Unified static HTML export via `generateApp` (single file — graph + dict + flows), embedding `window.__FLOW_CLUSTERS__`; sole static generator | [`docs/wiki/generators.md`](generators.md) | | theme | [`src/theme/`](../../src/theme) | ThemeConfig + Branding types, default palettes, flow-kind colors + per-kind store-cap symbols (`FLOW_STORE_KIND_SYMBOLS`), dark/light merging | [`docs/wiki/theme.md`](theme.md) | -| skill | [`skills/ignatius-modeling/`](../../skills/ignatius-modeling) | Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate`; a flow's own `description:` is now authored to `flows//index.md` (Step F6a) | [`docs/wiki/skill.md`](skill.md) | -| docs | [`docs/`](..) (excluding [`docs/wiki/`](.)) | Design docs, user guides, research notes, and implementation-contract specs — 81 markdown files plus [`docs/glossary.md`](../glossary.md) across [`docs/design/`](../design), [`docs/guides/`](../guides), [`docs/research/`](../research), [`docs/spec/`](../spec) | [`docs/wiki/docs.md`](docs.md) | -| scripts | [`scripts/`](../../scripts) | Build helpers: stable-names.ts, convert-yaml-to-md.ts; perf/diagnostic tooling: probe.ts, screenshot.ts, gen-synthetic-model.ts, perf-harness.ts | [`docs/wiki/scripts.md`](scripts.md) | +| skill | [`skills/ignatius-modeling/`](../../skills/ignatius-modeling) | Project-scoped Claude Code skill: Q&A-driven entity/model/DFD authoring, convention-aware, writes files + verifies with `ignatius validate`; ends every edit session with a `.ignatius/changes/.md` change note (`references/change-notes.md`) and never approves its own edits | [`docs/wiki/skill.md`](skill.md) | +| docs | [`docs/`](..) (excluding [`docs/wiki/`](.)) | Design docs, user guides, research notes, and implementation-contract specs plus [`docs/glossary.md`](../glossary.md) across [`docs/design/`](../design), [`docs/guides/`](../guides), [`docs/research/`](../research), [`docs/spec/`](../spec); model change review is in the `model-change-review` design/spec and the `change-review` guide | [`docs/wiki/docs.md`](docs.md) | +| scripts | [`scripts/`](../../scripts) | Build helpers: stable-names.ts, convert-yaml-to-md.ts; test prelude clear-review-state.ts (removes `.ignatius/` review state from demo + fixture models); perf/diagnostic tooling: probe.ts, screenshot.ts, gen-synthetic-model.ts, perf-harness.ts | [`docs/wiki/scripts.md`](scripts.md) | | router | [`src/router/`](../../src/router) | Generates per-folder `index.md` routers with rolled-up SHA-256 digests, in-folder agent guidance (`AGENTS.md`, [`CLAUDE.md`](../../CLAUDE.md) shim, `SKILL.md`), and a position-based `` region parser; a flow folder's own `description:` now folds into its parent row's digest; backs `ignatius index` and `validate --index` | [`docs/wiki/router.md`](router.md) | ## Cross-cutting diff --git a/docs/wiki/parser.md b/docs/wiki/parser.md index 1b76b77..f14ed53 100644 --- a/docs/wiki/parser.md +++ b/docs/wiki/parser.md @@ -1,7 +1,7 @@ --- type: Domain -description: Parses an ignatius model root into a typed Model, derives classification/cardinality by structure, and builds O(1) lookup indices. -tags: [model, markdown, derivation] +description: Parses an ignatius model root into a typed Model, derives classification/cardinality by structure, builds lookup indices, and diffs the model against an approved baseline for change review. +tags: [model, markdown, derivation, change-review] --- # parser @@ -14,6 +14,8 @@ tags: [model, markdown, derivation] Every consumer in the repo (`cli`, `server`, `validate`, `frontend`, `flows`, `generators`) sits downstream of this one function. +The same directory also holds the change-review files. [`src/model/change-store.ts`](../../src/model/change-store.ts) keeps a snapshot of the parsed model and flows at the last approval (the baseline) in `/.ignatius/baseline.json`. [`src/model/change-review.ts`](../../src/model/change-review.ts) diffs the current parse against that baseline and checks the difference against hand-written change notes in `.ignatius/changes/*.md`. A reviewer sees what changed from the diff and why from the notes, and each source exposes what the other omitted: an unexplained change, or a note that names something that did not change. Without the baseline, "changed since approved" has no reference point, and a version-control diff cannot supply one for a model folder outside a repository. + ## How it works **`parseModels` runs its derivation stages in a fixed order; classification and body rendering share one stage because neither depends on the other.** @@ -46,6 +48,50 @@ flowchart LR `wikiLinkPlugin` ([`src/model/wikilink.ts`](../../src/model/wikilink.ts)) is a markdown-it inline rule registered before the `link` rule, so `[[…]]` is recognized ahead of standard link syntax. It never raises an error itself: an unresolved target renders as a non-navigating span, and is reported separately by `validate.ts`'s `body.unknown_link` rule. +### Baseline lifecycle + +**The baseline file is created from the current parse when absent or unreadable, and afterward only `approve`, or a rebuild after an unreadable file, rewrites it.** + +```mermaid +stateDiagram-v2 + %% source: docs/design/model-change-review.md (Lifecycle), src/model/change-store.ts + [*] --> NoBaseline + NoBaseline --> Stored: ensureBaseline writes current snapshot + NoBaseline --> Stored: approve writes current snapshot + Stored --> NoBaseline: readBaseline finds unparseable or wrong-shape file + Stored --> Stored: approve writes current snapshot, deletes notes +``` + +`readBaseline` returns `null` for a missing file, invalid JSON, or a value that fails `isSnapshot` (`version === 1`, an object `model`, an array `flows`); `ensureBaseline` then rebuilds it. `writeBaseline` creates `.ignatius/`, calls `ensureGitignore`, and writes through a temp file plus `rename`. `ensureGitignore` appends `.ignatius/` to `/.gitignore` unless a line already matches. Concurrent `ensureBaseline` calls for one model directory share a single in-flight promise (`pendingBaselines`), so the server's startup call and the first `/api/model` request do not both append to that ignore file. + +### Diff and cross-check + +**`buildReview` parses once, diffs against the baseline, then lets the notes cover or contradict each item.** + +```mermaid +flowchart TD + %% source: docs/design/model-change-review.md (Approach), src/model/change-store.ts, src/model/change-review.ts + Cur["currentSnapshot()"] --> Base["ensureBaseline()"] + Base --> Diff["diffSnapshots()"] + Cur --> Diff + Notes["loadNotes →
parseChangeNote"] --> Cross["crossCheck()"] + Diff --> Cross + Cross --> Review["ChangeReview"] +``` + +`diffSnapshots` compares entities, columns, relationships, and flow diagrams with keyed maps and returns `ChangeItem`s with scope `entity`, `column`, `relationship`, `doc`, or `flow` and kind `added`, `removed`, or `changed`. It compares derived fields with object keys sorted (`canonical`) and set-like lists sorted, so YAML key order or list order never reads as a change. A body-only edit yields a `doc` item, compared through `linkStateNeutral`. Derived context and L1 flow diagrams (`SYNTHETIC_DIAGRAM_IDS`) are skipped; sub-DFDs are keyed by `diagramPath`. + +`crossCheck` marks each item `explained` (with the covering note files) or `unexplained`, and emits a `ChangeClaim` for each entity, column, diagram, or node a note names that has no matching item, unless the entity or diagram itself was added or removed. Coverage is by scope: + +| Item scope | Covered when a note lists | +|---|---| +| `entity`, `doc` | the entity under `entities:` | +| `column` | the entity with that column name under `entities:` | +| `relationship` | the source or the target entity under `entities:` | +| `flow` | the diagram id under `flows:`, with the node name when the item has one | + +A note with bad frontmatter becomes a `ChangeNoteError` value and the review continues. `removedNodes` and `removedEdges` come from the baseline model, since the current model no longer holds them. + ### Classification derivation **Classification is derived by a fixed rule order, first match wins; the frontmatter `classification:` field is only a fallback signal for the first rule.** @@ -93,8 +139,10 @@ A dangling edge (unknown target, or an FK column absent from the child's PK) is | Path | Exports | Role | |------|---------|------| | [`src/model/parse.ts`](../../src/model/parse.ts) | `parseModels(dir): Promise`, `normalizePredicate()`, `ModelNode`, `ModelEdge`, `Model`, `ParseResult`, `ModelMeta`, `Predicate`, `ColumnDef`, `SubtypeCluster`, `GroupConfig`, `Cardinality`, `ThemeConfig`, `Branding`, `HarnessMode` | Reads `ignatius.yml` (`_meta`, `theme:`, `branding:`) and `data/**/*.md` + `groups/*.md`; owns all derivation logic and the shared `MarkdownIt` instance | -| [`src/model/wikilink.ts`](../../src/model/wikilink.ts) | `WikiLinkEnv`, `splitWikiTarget()`, `wikiLinkPlugin(md)` | markdown-it inline rule for `[[Target]]` / `[[Target\|label]]`; registered onto `parse.ts`'s `md` via `md.use(wikiLinkPlugin)`. Types markdown-it's state/instance via minimal local interfaces (`InlineToken`, `InlineState`, `MarkdownItLike`) rather than casting to `any`, because markdown-it 14 ships no types in this repo. | +| [`src/model/wikilink.ts`](../../src/model/wikilink.ts) | `WikiLinkEnv`, `splitWikiTarget()`, `wikiLinkPlugin(md)`, `linkStateNeutral()` | markdown-it inline rule for `[[Target]]` / `[[Target\|label]]`; `linkStateNeutral(html)` rewrites missing-link spans as live anchors so two renders of one body compare equal whichever targets exist; registered onto `parse.ts`'s `md` via `md.use(wikiLinkPlugin)`. Types markdown-it's state/instance via minimal local interfaces (`InlineToken`, `InlineState`, `MarkdownItLike`) rather than casting to `any`, because markdown-it 14 ships no types in this repo. | | [`src/model/model-index.ts`](../../src/model/model-index.ts) | `buildModelIndex(model): ModelIndex`, `endpointKey(source, target)`, `ModelIndex` | Pure, no Bun/Node/DOM imports; browser-safe. Builds `nodeById`, `edgesBySource`/`edgesByTarget`/`edgeByEndpointPair`, `pkByNode`/`columnsByNode`, `akColumnsByNode`/`fkColumnsByNode`, subtype-cluster maps, `nodesByGroup` | +| [`src/model/change-review.ts`](../../src/model/change-review.ts) | `diffSnapshots()`, `parseChangeNote()`, `crossCheck()`, `ModelSnapshot`, `ChangeKind`, `FlowNodeKind`, `ChangeItem`, `ChangeNote`, `ChangeNoteError`, `ReviewedItem`, `ChangeClaim`, `ChangeReview` | Diff of two `ModelSnapshot`s and the note cross-check. Imports `md` and `parseFrontmatter` from [`src/flows/flow-markdown.ts`](../../src/flows/flow-markdown.ts) at runtime | +| [`src/model/change-store.ts`](../../src/model/change-store.ts) | `baselinePath()`, `ensureBaseline()`, `buildReview()`, `approve()`, `ensureGitignore()` | Filesystem side of change review: reads and writes `.ignatius/baseline.json`, reads `.ignatius/changes/*.md`, calls `parseModels` and `parseFlows` for the current snapshot | `ModelMeta.indexFile` and `ModelMeta.harness` are populated from `ignatius.yml`'s top-level `index_file:` and `harness:` keys (`harness` is validated against `'auto' | 'claude' | 'agents' | 'both'` and dropped if it does not match). `ModelMeta.flowRules` and `ModelMeta.flowView` are populated the same way from the top-level `flow_rules:` and `flow_view:` blocks (parse.ts:204-221): `flowRules.process_to_process` is copied from the YAML `process_to_process` boolean, and `flowView.adjacencyStacks` is copied from the YAML `adjacency_stacks` boolean nested under `flow_view:`; either field is omitted from its object when the source YAML key is absent or not a boolean, and `_meta` itself stays `undefined` only when every one of `name`/`version`/`description`/`updated`/`flowRules`/`flowView`/`indexFile`/`harness` is absent. `ModelNode.sourcePath` is set for every parsed entity to its path relative to the model root (e.g. `data/catalog/Product.md`) and is optional only for hand-built test fixtures. Both entity and group frontmatter accept a top-level `description:` string, copied onto `ModelNode.description` / `GroupConfig.description` verbatim (distinct from `GroupConfig.desc`, which is the group's body rendered to HTML). @@ -105,13 +153,18 @@ A dangling edge (unknown target, or an FK column absent from the child's PK) is - `subtypeMemberToCluster` is first-wins for a member appearing in multiple clusters; a caller reading only `subtypeMemberToCluster` for a multi-cluster member silently gets one cluster and misses the rest. `clustersByMemberId` is the array form that captures all of them. - `identifying` and `cardinality` are always derived from structure on every parse; a caller that reads frontmatter `classification` or `relationships[].identifying` directly instead of the derived `ModelNode.classification` / `ModelEdge.identifying` gets values that go stale the moment the underlying data changes, since only the derived fields are recomputed on the next parse. - `index_file:` must be a bare filename ending in `.md` with no `/`, `\`, or `..` segments; a violation is a `config.index_file_ext` / `config.index_file_path` global error rather than a thrown exception, but the invalid value is still written into `_meta.indexFile` and used as `indexFileName` (parse.ts:222-237, :264) — parsing does not fall back to `index.md`, so the reserved-file skip silently never matches any real file. +- Adding a `Map`/`Set` field to `Model` or `FlowDiagram` makes it serialize to `{}` in `baseline.json`, so the field is never diffed and a change to it reports nothing. A baseline whose `version` is not `1` is treated as absent and silently replaced on the next `ensureBaseline`. +- `approve` deletes every file in `.ignatius/changes/`, not only `.md` notes, and rewrites the baseline; the returned `notes` count includes only `.md` files, and `items` is `0` when no baseline existed. +- `change-review.ts` imports `flow-markdown` at runtime, which pulls shiki. Browser code imports its types only (`import type`); a value import would put shiki in the frontend bundle. +- Relationship items key on source plus target. When a source+target pair has more than one edge on either side, the key also includes the canonical FK mapping (`edge.on`) so dual-FK and self-referential edges do not overwrite each other. ## Coupling - `validate` — two-way type coupling: `parse.ts` imports `GlobalError` from [`src/model/validate.ts`](../../src/model/validate.ts), and `validate.ts` imports `Model`/`ModelNode`/`ModelEdge`/`SubtypeCluster` from `parse.ts`. `model-index.ts` mirrors `validate.ts`'s `checkAlternateKeys` (AK column union) and `checkEdgeDanglingFkColumn` (FK column derivation from `edge.on` keys) logic; a change to either derivation must be kept in sync in both files. -- `flows` — [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) imports `Model` from `parse.ts` only for `entityModel` parameters (node/column lookups); flow-rule silencing comes through a separate `config: FlowRulesConfig` parameter to `validateFlows(flowModel, entityModel, config)` (flow-validate.ts:822-824), checked directly (e.g. `if (config.process_to_process === false) return [];` at flow-validate.ts:382) — `flow-validate.ts` never reads `_meta` itself. [`src/flows/flow-markdown.ts`](../../src/flows/flow-markdown.ts) imports `wikiLinkPlugin` from `wikilink.ts` directly into its own `MarkdownIt` instance (separate from the one in `parse.ts`); [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) imports that instance's `md` export rather than registering its own. Both instances pass the same `highlight` callback, so entity and flow bodies highlight identically. -- [`src/model/markdown-highlight.ts`](../../src/model/markdown-highlight.ts) — shiki with six precompiled grammars (json, sql, javascript, typescript, python, bash) behind `createJavaScriptRawEngine()`, exported as `highlightCodeFence(code, lang)` and wired into both `MarkdownIt` constructors as the `highlight` option. Server-side only: app code imports `parse.ts` for types alone, so neither markdown-it nor these grammars reach the browser bundle. -- `frontend` ([`src/app/`](../../src/app)) — modules under [`src/app/logic/`](../../src/app/logic), [`src/app/hooks/`](../../src/app/hooks), [`src/app/components/entity/`](../../src/app/components/entity), and view files import `Model`, `ModelNode`, `ModelEdge`, `Predicate`, `ThemeConfig`, or `SubtypeCluster` as types from `parse.ts`; `App.tsx`, `spotlight.ts`, `spotlight-inherited.ts`, `DictionaryView.tsx`, and `GraphView.tsx` import `ModelIndex` from `model-index.ts`. `GroupConfig` is imported from `parse.ts` by `App.tsx`, `FabMenu.tsx`, and [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts). [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) reads `resolvedEntityModel?._meta?.flowView?.adjacencyStacks` and passes it through as the `adjacencyStacks` layout option. +- `flows` — [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) imports `Model` from `parse.ts` only for `entityModel` parameters (node/column lookups); flow-rule silencing comes through a separate `config: FlowRulesConfig` parameter to `validateFlows(flowModel, entityModel, config)` (flow-validate.ts:822-824), checked directly (e.g. `if (config.process_to_process === false) return [];` at flow-validate.ts:382) — `flow-validate.ts` never reads `_meta` itself. [`src/flows/flow-markdown.ts`](../../src/flows/flow-markdown.ts) imports `wikiLinkPlugin` from `wikilink.ts` directly into its own `MarkdownIt` instance (separate from the one in `parse.ts`); [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) imports that instance's `md` export rather than registering its own. Both instances pass the same `highlight` callback, so entity and flow bodies highlight identically. [`src/model/change-review.ts`](../../src/model/change-review.ts) imports types from `flow-parse.ts`, `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts), and `md`/`parseFrontmatter`/`isRecord` from `flow-markdown.ts`; `change-store.ts` calls `parseFlows` for the current snapshot. +- [`src/model/markdown-highlight.ts`](../../src/model/markdown-highlight.ts) — shiki with precompiled grammars for json, sql, javascript, typescript, python, and bash behind `createJavaScriptRawEngine()`, exported as `highlightCodeFence(code, lang)` and wired into both `MarkdownIt` constructors as the `highlight` option. Server-side only: app code imports `parse.ts` for types alone, so neither markdown-it nor these grammars reach the browser bundle. +- `frontend` ([`src/app/`](../../src/app)) — modules under [`src/app/logic/`](../../src/app/logic), [`src/app/hooks/`](../../src/app/hooks), [`src/app/components/entity/`](../../src/app/components/entity), and view files import `Model`, `ModelNode`, `ModelEdge`, `Predicate`, `ThemeConfig`, or `SubtypeCluster` as types from `parse.ts`; `App.tsx`, `spotlight.ts`, `spotlight-inherited.ts`, `DictionaryView.tsx`, and `GraphView.tsx` import `ModelIndex` from `model-index.ts`. `GroupConfig` is imported from `parse.ts` by `App.tsx`, `FabMenu.tsx`, and [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts). [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) reads `resolvedEntityModel?._meta?.flowView?.adjacencyStacks` and passes it through as the `adjacencyStacks` layout option. `ChangesPanel.tsx`, `change-marks.ts`, `useModelData.ts`, `GraphView.tsx`, `DictionaryView.tsx`, and `FlowsView.tsx` import `ChangeReview`-family types from `change-review.ts`. - `server` ([`src/server/server.ts`](../../src/server/server.ts)) and `cli` ([`src/cli/cli.ts`](../../src/cli/cli.ts)) both call `parseModels()` directly to produce the `Model` they serve or output, and both read `model._meta?.flowRules` themselves (`server.ts:93`; `cli.ts:135, 217, 329`) to build the `FlowRulesConfig` they pass into `flow-validate.ts`'s `validateFlows`. - `generators` ([`src/generators/app.ts`](../../src/generators/app.ts)) imports the `Model` type from `parse.ts`. +- `server` and `cli` call into change review: `server.ts` calls `buildReview(modelsDir, model)` inside `/api/model` (a failure logs and returns `diff: null`), `ensureBaseline(modelsDir)` at startup, and `approve(modelsDir)` from the same-origin `POST /api/approve`; `cli.ts` imports `approve` from `change-store.ts` for its approve command. Design: [`docs/design/model-change-review.md`](../design/model-change-review.md). - Changing the `Model`, `ModelNode`, `ModelEdge`, or `ModelIndex` shapes forces updates across all of the above; changing `ignatius.yml` top-level key handling in `parseModels` forces updates to `theme` and `branding` default-merge logic in [`src/theme/`](../../src/theme). diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 5710610..d8a4eac 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -3,22 +3,23 @@ ## Tree ``` -├── .claude/ (2) +├── .claude/ (3) │ ├── rules/ (1) │ │ └── wiki/ (13) -│ │ ├── cli.md (61979a6, 17L, 731ch, 733B) -│ │ ├── docs.md (12b4631, 18L, 652ch, 656B) -│ │ ├── flow-view.md (66fa416, 27L, 1363ch, 1365B) -│ │ ├── flows.md (87c71f4, 31L, 1241ch, 1243B) -│ │ ├── frontend.md (9f02e64, 28L, 1333ch, 1335B) +│ │ ├── cli.md (870207d, 17L, 712ch, 714B) +│ │ ├── docs.md (d1e6a95, 18L, 727ch, 729B) +│ │ ├── flow-view.md (9f1085d, 27L, 1147ch, 1149B) +│ │ ├── flows.md (c05b7aa, 31L, 1229ch, 1231B) +│ │ ├── frontend.md (ee1ee27, 28L, 1278ch, 1280B) │ │ ├── generators.md (c54e110, 15L, 576ch, 580B) -│ │ ├── parser.md (cb759ae, 26L, 1044ch, 1050B) +│ │ ├── parser.md (29c63c2, 31L, 1299ch, 1303B) │ │ ├── router.md (308c8a6, 18L, 819ch, 821B) -│ │ ├── scripts.md (27ca70a, 14L, 533ch, 535B) -│ │ ├── server.md (a99cf7f, 12L, 546ch, 548B) -│ │ ├── skill.md (471bfad, 23L, 890ch, 892B) +│ │ ├── scripts.md (4c452dd, 14L, 632ch, 634B) +│ │ ├── server.md (335a890, 12L, 598ch, 600B) +│ │ ├── skill.md (b1ceba2, 23L, 945ch, 947B) │ │ ├── theme.md (612e1c3, 18L, 601ch, 603B) -│ │ └── validate.md (f5bffbd, 20L, 887ch, 889B) +│ │ └── validate.md (ed5075e, 20L, 845ch, 847B) +│ ├── .gitignore (9ebf88f, 2L, 74ch, 74B) │ └── atomic.toml (da6b99f, 10L, 128ch, 128B) ├── .cursor/ (1) │ └── rules/ (1) @@ -30,7 +31,7 @@ ├── assets/ (1) │ └── noorm-logo.svg (8d46c28, 6L, 2529ch, 2529B) ├── docs/ (5) -│ ├── design/ (31) +│ ├── design/ (32) │ │ ├── app-tsx-decomposition.md (6620907, 142L, 9400ch, 9870B) │ │ ├── bidirectional-predicates.md (5e872cc, 67L, 3485ch, 3522B) │ │ ├── branding.md (6025a97, 160L, 7673ch, 7956B) @@ -50,8 +51,9 @@ │ │ ├── ignatius-project-config.md (854740b, 107L, 6806ch, 6838B) │ │ ├── key-inheritance-lineage.md (63dd0fb, 175L, 10759ch, 10911B) │ │ ├── keyboard-nav-shortcuts.md (37b922e, 110L, 5283ch, 5301B) -│ │ ├── large-model-nav.md (98924fe, 102L, 8343ch, 8367B) +│ │ ├── large-model-nav.md (5c23c3a, 102L, 8339ch, 8365B) │ │ ├── markdown-driven-erd.md (2a28ec9, 333L, 12635ch, 12763B) +│ │ ├── model-change-review.md (b5093b5, 81L, 3675ch, 3675B) │ │ ├── model-index-routing.md (5775685, 476L, 23244ch, 23505B) │ │ ├── noorm-flow-discovery.md (2062784, 179L, 14151ch, 14249B) │ │ ├── noorm-modeling-skill.md (9d8e831, 12L, 415ch, 419B) @@ -62,21 +64,22 @@ │ │ ├── viewer-fab-ux.md (74eb82e, 144L, 6791ch, 6826B) │ │ ├── viewer-ux-polish.md (d4281dd, 133L, 5687ch, 5728B) │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) -│ ├── guides/ (10) +│ ├── guides/ (11) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (eb36c04, 176L, 11690ch, 11740B) +│ │ ├── change-review.md (9ec5c9c, 177L, 11135ch, 11141B) +│ │ ├── commands.md (9bf8819, 198L, 12622ch, 12672B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) -│ │ ├── flows.md (2780567, 309L, 21825ch, 21885B) -│ │ ├── folder-format.md (afdd7c2, 277L, 13132ch, 13175B) -│ │ ├── getting-started.md (85d1b13, 93L, 3536ch, 3550B) -│ │ ├── modeling-skill.md (1d1e795, 73L, 4187ch, 4203B) +│ │ ├── flows.md (f13e841, 309L, 21883ch, 21943B) +│ │ ├── folder-format.md (b23bd50, 287L, 13831ch, 13874B) +│ │ ├── getting-started.md (69642d6, 94L, 3669ch, 3685B) +│ │ ├── modeling-skill.md (fb13671, 81L, 5232ch, 5248B) │ │ ├── predicates.md (502da1e, 83L, 3790ch, 3790B) │ │ ├── themes-and-branding.md (2290a7b, 83L, 2823ch, 2827B) │ │ └── validation.md (407bd9c, 142L, 7952ch, 7968B) │ ├── research/ (2) │ │ ├── dfd-layout-and-leveling.md (091455c, 129L, 14807ch, 14993B) │ │ └── ssadm-dfd-rules.md (7d83a02, 118L, 8356ch, 8460B) -│ ├── spec/ (38) +│ ├── spec/ (39) │ │ ├── app-tsx-decomposition.md (430d88c, 246L, 21478ch, 22248B) │ │ ├── bidirectional-predicates.md (e6355c0, 157L, 7664ch, 7712B) │ │ ├── branding.md (bd50adc, 102L, 8976ch, 9031B) @@ -101,7 +104,8 @@ │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) │ │ ├── keyboard-nav-shortcuts.md (0f4c346, 196L, 17379ch, 17540B) -│ │ ├── large-model-nav.md (f5b308c, 217L, 14042ch, 14270B) +│ │ ├── large-model-nav.md (e6ebf7a, 217L, 14168ch, 14396B) +│ │ ├── model-change-review.md (59422d3, 382L, 32128ch, 32295B) │ │ ├── model-index-routing.md (339970f, 276L, 30929ch, 31183B) │ │ ├── noorm-flow-discovery.md (62f4c01, 83L, 10572ch, 10684B) │ │ ├── noorm-modeling-skill.md (b2a16d4, 12L, 412ch, 418B) @@ -117,7 +121,7 @@ │ │ └── wiki-entity-links.md (569742d, 79L, 4329ch, 4377B) │ └── glossary.md (7461687, 67L, 4385ch, 4413B) ├── models/ (6) -│ ├── broken-demo/ (3) +│ ├── broken-demo/ (4) │ │ ├── data/ (12) │ │ │ ├── Admin.md (e2201cd, 15L, 250ch, 252B) │ │ │ ├── Customer.md (4b588f9, 25L, 740ch, 742B) @@ -133,8 +137,9 @@ │ │ │ └── no-entity-id.md (8a03d50, 10L, 242ch, 242B) │ │ ├── groups/ (1) │ │ │ └── core.md (f94ead2, 7L, 91ch, 91B) +│ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ └── ignatius.yml (a075bab, 23L, 618ch, 620B) -│ ├── key-inherited/ (8) +│ ├── key-inherited/ (9) │ │ ├── clusters/ (1) │ │ │ └── settlement.md (1c35e6d, 10L, 284ch, 284B) │ │ ├── data/ (5) @@ -194,9 +199,10 @@ │ │ ├── stores/ (2) │ │ │ ├── gateway-log.md (89700cd, 12L, 460ch, 462B) │ │ │ └── index.md (9b481ad, 13L, 358ch, 360B) +│ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ ├── ignatius.yml (3c11369, 20L, 463ch, 465B) │ │ └── index.md (539042c, 11L, 748ch, 748B) -│ ├── llm-memory-db-mssql/ (11) +│ ├── llm-memory-db-mssql/ (12) │ │ ├── clusters/ (1) │ │ │ └── tag-junctions.md (501db59, 12L, 328ch, 328B) │ │ ├── data/ (9) @@ -311,12 +317,13 @@ │ │ │ └── tagging.md (8f3ee0c, 9L, 320ch, 320B) │ │ ├── stores/ (1) │ │ │ └── index.md (c4dac7e, 12L, 307ch, 309B) +│ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ ├── AGENTS.md (93b4695, 26L, 1265ch, 1267B) │ │ ├── CLAUDE.md (645220e, 8L, 162ch, 162B) │ │ ├── SKILL.md (dd56c86, 15L, 417ch, 417B) │ │ ├── ignatius.yml (9b85962, 13L, 746ch, 752B) │ │ └── index.md (fd69074, 13L, 1039ch, 1041B) -│ ├── orm-hybrid/ (7) +│ ├── orm-hybrid/ (8) │ │ ├── data/ (5) │ │ │ ├── catalog/ (3) │ │ │ │ ├── Product.md (4a42016, 46L, 1223ch, 1227B) @@ -363,9 +370,10 @@ │ │ │ └── transactional.md (0425821, 8L, 204ch, 204B) │ │ ├── stores/ (1) │ │ │ └── index.md (dae4e31, 12L, 296ch, 298B) +│ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ ├── ignatius.yml (d5d7c97, 20L, 462ch, 464B) │ │ └── index.md (286396d, 11L, 750ch, 750B) -│ ├── orm-pure/ (7) +│ ├── orm-pure/ (8) │ │ ├── data/ (5) │ │ │ ├── catalog/ (3) │ │ │ │ ├── Product.md (4a42016, 46L, 1223ch, 1227B) @@ -412,6 +420,7 @@ │ │ │ └── transactional.md (0425821, 8L, 204ch, 204B) │ │ ├── stores/ (1) │ │ │ └── index.md (a82c903, 12L, 294ch, 296B) +│ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ ├── ignatius.yml (b69f627, 20L, 450ch, 452B) │ │ └── index.md (563b1c0, 11L, 750ch, 750B) │ └── tmp/ (5) @@ -420,7 +429,8 @@ │ ├── detail2.png (c510448, 85L, 26356ch, 27358B) │ ├── detail3.png (6b7cd20, 4L, 1501ch, 1525B) │ └── zoomed.png (669cf22, 401L, 120636ch, 124830B) -├── scripts/ (6) +├── scripts/ (7) +│ ├── clear-review-state.ts (89b984d, 9L, 459ch, 459B) │ ├── convert-yaml-to-md.ts (09465eb, 270L, 9508ch, 9516B) │ ├── gen-synthetic-model.ts (3e960ce, 428L, 15552ch, 16414B) │ ├── perf-harness.ts (7ea0ac8, 215L, 7526ch, 8872B) @@ -429,35 +439,39 @@ │ └── stable-names.ts (a69a3aa, 46L, 2038ch, 2040B) ├── skills/ (1) │ └── ignatius-modeling/ (2) -│ ├── references/ (10) +│ ├── references/ (11) +│ │ ├── change-notes.md (bdd5069, 136L, 7806ch, 7806B) │ │ ├── conventions.md (d42e9e0, 64L, 3000ch, 3024B) -│ │ ├── dfd-authoring.md (deb7fb3, 347L, 21209ch, 21363B) -│ │ ├── discover-flow.md (87b6eb6, 193L, 11572ch, 11672B) +│ │ ├── dfd-authoring.md (4332c06, 348L, 21297ch, 21451B) +│ │ ├── discover-flow.md (091daf5, 194L, 11670ch, 11770B) │ │ ├── entity-flow.md (9c72852, 247L, 16390ch, 16546B) │ │ ├── flow-templates.md (a55c8d3, 316L, 11389ch, 11419B) │ │ ├── interviewing.md (7e93bc1, 13L, 1853ch, 1859B) -│ │ ├── model-flow.md (260b6af, 86L, 3787ch, 3826B) -│ │ ├── reverse-engineering.md (f6c9e69, 129L, 8403ch, 8471B) +│ │ ├── model-flow.md (596d448, 87L, 4148ch, 4187B) +│ │ ├── reverse-engineering.md (cf54889, 131L, 8542ch, 8610B) │ │ ├── templates.md (329dfd3, 443L, 12207ch, 12273B) -│ │ └── verification.md (e531840, 104L, 11562ch, 11624B) -│ └── SKILL.md (a8df1c8, 50L, 6254ch, 6304B) +│ │ └── verification.md (ef661c0, 108L, 12157ch, 12219B) +│ └── SKILL.md (161d48e, 52L, 7336ch, 7388B) ├── spec/ (1) │ └── spec.md (478f9ed, 464L, 20340ch, 20470B) [generated] ├── src/ (10) │ ├── app/ (11) -│ │ ├── components/ (6) +│ │ ├── components/ (7) +│ │ │ ├── changes/ (2) +│ │ │ │ ├── ChangeBadge.tsx (15fc2f1, 44L, 1563ch, 1564B) +│ │ │ │ └── ChangesPanel.tsx (38962e7, 231L, 9055ch, 9077B) │ │ │ ├── entity/ (9) │ │ │ │ ├── ChildrenTable.tsx (56b4460, 115L, 4123ch, 4135B) │ │ │ │ ├── ClassificationBadge.tsx (e138348, 25L, 627ch, 627B) -│ │ │ │ ├── ColumnsTable.tsx (0a310ba, 159L, 5090ch, 5110B) -│ │ │ │ ├── EntityCard.tsx (5acf848, 139L, 5084ch, 5091B) -│ │ │ │ ├── EntityModal.tsx (def2c1d, 129L, 5085ch, 5095B) +│ │ │ │ ├── ColumnsTable.tsx (ac89fa9, 191L, 6587ch, 6609B) +│ │ │ │ ├── EntityCard.tsx (5556612, 147L, 5411ch, 5418B) +│ │ │ │ ├── EntityModal.tsx (4182a0f, 134L, 5307ch, 5317B) │ │ │ │ ├── ExamplesAccordion.tsx (1bd5324, 70L, 2219ch, 2220B) │ │ │ │ ├── FlowNodeGridCard.tsx (571a1a5, 209L, 6180ch, 6572B) -│ │ │ │ ├── GridCard.tsx (084e391, 87L, 2667ch, 2675B) +│ │ │ │ ├── GridCard.tsx (370d71e, 92L, 2852ch, 2860B) │ │ │ │ └── SpotlightOverlay.tsx (3ef1248, 1187L, 46601ch, 46692B) │ │ │ ├── findings/ (1) -│ │ │ │ └── FindingsPanel.tsx (a31dd6a, 93L, 3285ch, 3291B) +│ │ │ │ └── FindingsPanel.tsx (3b0b632, 93L, 3358ch, 3364B) │ │ │ ├── flow/ (1) │ │ │ │ └── FlowSearchResults.tsx (bfb86ae, 73L, 2726ch, 2734B) │ │ │ ├── flow-node/ (6) @@ -477,20 +491,21 @@ │ │ │ └── ui/ (7) │ │ │ ├── ExampleCell.tsx (2422242, 26L, 885ch, 887B) │ │ │ ├── FabMenu.tsx (61407e9, 244L, 7778ch, 7794B) -│ │ │ ├── HelpModal.tsx (5c41743, 141L, 7533ch, 7597B) -│ │ │ ├── JsonValue.tsx (4c89f39, 54L, 1945ch, 1951B) -│ │ │ ├── Modal.tsx (c55b148, 73L, 2608ch, 2611B) +│ │ │ ├── HelpModal.tsx (adf03d1, 141L, 7534ch, 7598B) +│ │ │ ├── JsonValue.tsx (97fa34e, 56L, 2045ch, 2051B) +│ │ │ ├── Modal.tsx (1dc7a00, 73L, 2627ch, 2630B) │ │ │ ├── SearchBar.tsx (b187f11, 147L, 5239ch, 5253B) │ │ │ └── ZoomControl.tsx (486130d, 69L, 2346ch, 2358B) │ │ ├── dom/ (2) │ │ │ ├── body-links.ts (d795f15, 70L, 2869ch, 2875B) -│ │ │ └── theme-css-vars.ts (61ca750, 114L, 6255ch, 6267B) +│ │ │ └── theme-css-vars.ts (c3eeeed, 133L, 7298ch, 7312B) │ │ ├── hooks/ (4) │ │ │ ├── useHashRoute.ts (267fba1, 140L, 6274ch, 6286B) │ │ │ ├── useKeyboardShortcuts.ts (8d11972, 105L, 4213ch, 4235B) -│ │ │ ├── useModelData.ts (fde38cb, 183L, 6730ch, 6862B) +│ │ │ ├── useModelData.ts (77d7e41, 211L, 7792ch, 7924B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) -│ │ ├── logic/ (14) +│ │ ├── logic/ (15) +│ │ │ ├── change-marks.ts (09d1270, 106L, 5426ch, 5426B) │ │ │ ├── color.ts (8006242, 39L, 1796ch, 1796B) │ │ │ ├── doc-resolver.ts (075dcf2, 125L, 5950ch, 5970B) │ │ │ ├── finding-rows.ts (831abb1, 48L, 1695ch, 1695B) @@ -507,26 +522,27 @@ │ │ │ └── spotlight.ts (d4aec20, 101L, 3534ch, 3540B) │ │ ├── views/ (3) │ │ │ ├── dict/ (1) -│ │ │ │ └── DictionaryView.tsx (db60923, 1457L, 64234ch, 64536B) +│ │ │ │ └── DictionaryView.tsx (0164329, 1505L, 66366ch, 66668B) │ │ │ ├── flow/ (2) -│ │ │ │ ├── FlowsView.tsx (4c2e3be, 963L, 45460ch, 45597B) +│ │ │ │ ├── FlowsView.tsx (cd515b8, 992L, 46856ch, 46993B) │ │ │ │ └── LegendModal.tsx (7894d56, 204L, 11306ch, 11324B) -│ │ │ └── graph/ (7) -│ │ │ ├── GraphView.tsx (1baea66, 1456L, 65993ch, 66772B) -│ │ │ ├── layout-store.ts (8a12833, 108L, 3606ch, 3618B) +│ │ │ └── graph/ (8) +│ │ │ ├── GraphView.tsx (18905b7, 1625L, 73736ch, 74519B) +│ │ │ ├── change-glow.ts (c9acac7, 220L, 8856ch, 8856B) +│ │ │ ├── layout-store.ts (2d8f9b9, 120L, 4388ch, 4400B) │ │ │ ├── markers.ts (8097d7a, 202L, 7434ch, 7448B) │ │ │ ├── navigator.ts (72cc64f, 53L, 2711ch, 2729B) -│ │ │ ├── organic-layout.ts (8512e49, 648L, 31865ch, 32203B) -│ │ │ ├── styles.ts (ea7f166, 243L, 8584ch, 8620B) +│ │ │ ├── organic-layout.ts (0dba862, 648L, 31858ch, 32196B) +│ │ │ ├── styles.ts (54c4157, 289L, 10454ch, 10490B) │ │ │ └── wrap-label.ts (ce0e43d, 42L, 1976ch, 1986B) -│ │ ├── App.tsx (943db1a, 862L, 39653ch, 39826B) -│ │ ├── globals.d.ts (7e966b0, 51L, 2854ch, 2860B) +│ │ ├── App.tsx (771ec8e, 929L, 42320ch, 42493B) +│ │ ├── globals.d.ts (2f77aca, 53L, 3038ch, 3044B) │ │ ├── hash-router.ts (55e9af6, 136L, 4723ch, 4737B) │ │ ├── index.html (e560504, 14L, 396ch, 396B) │ │ ├── main.tsx (881fa67, 12L, 293ch, 293B) -│ │ └── styles.css (89afed4, 3374L, 78364ch, 79506B) +│ │ └── styles.css (b25b37d, 3814L, 88043ch, 89189B) │ ├── cli/ (7) -│ │ ├── cli.ts (d7d3e48, 435L, 16195ch, 19355B) +│ │ ├── cli.ts (2b57167, 467L, 17315ch, 20787B) │ │ ├── discover.ts (2a355d6, 176L, 5910ch, 5940B) │ │ ├── open-browser.ts (b9b6fa8, 33L, 1206ch, 1210B) │ │ ├── resolve-model.ts (0c23743, 70L, 2276ch, 2294B) @@ -534,10 +550,10 @@ │ │ ├── update.ts (76b4443, 298L, 10708ch, 10964B) │ │ └── version.ts (8ca92d2, 6L, 312ch, 314B) │ ├── flow-view/ (8) -│ │ ├── FlowChrome.tsx (a999d37, 431L, 16623ch, 17058B) -│ │ ├── FlowDiagramSvg.tsx (c48f7f2, 2234L, 94988ch, 97072B) -│ │ ├── FlowIndex.tsx (ad93d48, 120L, 5188ch, 5192B) -│ │ ├── LevelMenu.tsx (db3095c, 123L, 4786ch, 4797B) +│ │ ├── FlowChrome.tsx (5cdcfff, 458L, 17929ch, 18364B) +│ │ ├── FlowDiagramSvg.tsx (d7b4866, 2283L, 97258ch, 99342B) +│ │ ├── FlowIndex.tsx (0b39695, 145L, 6605ch, 6610B) +│ │ ├── LevelMenu.tsx (5e0e1bf, 143L, 5828ch, 5839B) │ │ ├── elk-flow-layout.ts (17fe2ce, 382L, 16716ch, 17539B) │ │ ├── flow-layout.ts (bd166cc, 1800L, 77834ch, 78850B) │ │ ├── flow-nav.ts (016aa1f, 185L, 6746ch, 6748B) @@ -554,13 +570,15 @@ │ ├── generators/ (2) │ │ ├── app.ts (551984e, 145L, 6469ch, 6501B) │ │ └── embedded-bundle.ts (5de9575, 119L, 4895ch, 4905B) -│ ├── model/ (6) +│ ├── model/ (8) +│ │ ├── change-review.ts (e975e0f, 417L, 16270ch, 16274B) +│ │ ├── change-store.ts (2656432, 126L, 5298ch, 5298B) │ │ ├── layout-fingerprint.ts (e1eb9bc, 34L, 1235ch, 1237B) │ │ ├── markdown-highlight.ts (6eb6df2, 54L, 2342ch, 2348B) │ │ ├── model-index.ts (9fb3487, 222L, 8166ch, 8210B) │ │ ├── parse.ts (b9790c6, 523L, 20641ch, 20671B) │ │ ├── validate.ts (d58ac5a, 797L, 33082ch, 33127B) -│ │ └── wikilink.ts (b6c297b, 98L, 3416ch, 3426B) +│ │ └── wikilink.ts (13b357e, 113L, 4086ch, 4096B) │ ├── router/ (6) │ │ ├── agents.ts (3da0e86, 191L, 9053ch, 9061B) │ │ ├── build.ts (813b48c, 365L, 13368ch, 13397B) @@ -569,7 +587,7 @@ │ │ ├── region.ts (d7894db, 134L, 4955ch, 4959B) │ │ └── write.ts (74540d8, 48L, 2005ch, 2007B) │ ├── server/ (1) -│ │ └── server.ts (41860fc, 182L, 7293ch, 7303B) +│ │ └── server.ts (ca8807f, 242L, 9957ch, 9967B) │ ├── theme/ (2) │ │ ├── branding-defaults.ts (9e1300e, 134L, 4495ch, 4497B) │ │ └── theme-defaults.ts (195145b, 199L, 6934ch, 7088B) @@ -579,13 +597,18 @@ │ ├── cytoscape-navigator.d.ts (10d3feb, 51L, 1802ch, 1806B) │ └── file-imports.d.ts (c8fc2ce, 46L, 1951ch, 1953B) ├── test/ (5) -│ ├── checks/ (108) +│ ├── checks/ (114) │ │ ├── test-api-model.ts (7cb8b51, 97L, 3807ch, 3809B) │ │ ├── test-app-gen-zero-diagrams.ts (5bb5382, 131L, 4343ch, 4599B) │ │ ├── test-app-title.ts (53472fb, 144L, 5253ch, 5631B) │ │ ├── test-asset-route.ts (61fbc88, 61L, 2317ch, 2317B) │ │ ├── test-branding-parse.ts (a8701dc, 148L, 7280ch, 7284B) │ │ ├── test-branding-zero-network.ts (a383f56, 205L, 8157ch, 8653B) +│ │ ├── test-change-review-dict-flow.ts (8e3adee, 338L, 20411ch, 20417B) +│ │ ├── test-change-review-flow-index.ts (f887d90, 281L, 15041ch, 15044B) +│ │ ├── test-change-review-graph.ts (95ff4a1, 575L, 33786ch, 33810B) +│ │ ├── test-change-review-serve.ts (94efe3b, 325L, 16065ch, 16085B) +│ │ ├── test-change-review.ts (7c37962, 696L, 28035ch, 28043B) │ │ ├── test-cli-binary.ts (40de80b, 229L, 8269ch, 11087B) │ │ ├── test-cli-discovery.ts (fa96c0a, 125L, 4728ch, 6610B) │ │ ├── test-cli-stderr.ts (df4019b, 165L, 6932ch, 6946B) @@ -643,11 +666,12 @@ │ │ ├── test-inherited-edges-no-leak.ts (0c8a942, 120L, 5250ch, 5348B) │ │ ├── test-json-value.ts (f48fbe3, 126L, 4802ch, 4830B) │ │ ├── test-keyboard-shortcuts.ts (28cb588, 297L, 12141ch, 12172B) -│ │ ├── test-large-model-nav.ts (9fc6570, 251L, 14042ch, 14532B) +│ │ ├── test-large-model-nav.ts (186eb58, 257L, 14414ch, 14904B) │ │ ├── test-layout-fingerprint.ts (92d8eea, 255L, 8386ch, 8464B) │ │ ├── test-layout-key-injection.ts (1ff0306, 104L, 4296ch, 4300B) -│ │ ├── test-layout-store.ts (9c4a003, 157L, 6200ch, 6236B) +│ │ ├── test-layout-store.ts (9b1b47f, 178L, 7547ch, 7589B) │ │ ├── test-leveling.ts (1c44ed4, 216L, 8107ch, 8161B) +│ │ ├── test-modal-above-banner.ts (8acb3b8, 83L, 4526ch, 4528B) │ │ ├── test-modal-history.ts (3b62815, 187L, 7092ch, 7173B) │ │ ├── test-mode-flag.ts (52f3812, 136L, 5187ch, 5195B) │ │ ├── test-model-index.ts (f6e304f, 409L, 17447ch, 17469B) @@ -695,7 +719,7 @@ │ │ │ │ └── Shopper.md (e0dbed2, 5L, 51ch, 51B) │ │ │ └── flows/ (1) │ │ │ └── checkout/ (6 files, 1 dir) -│ │ ├── broken-flows-model/ (4) +│ │ ├── broken-flows-model/ (5) │ │ │ ├── clusters/ (2) │ │ │ │ ├── role-grants-invalid.md (d218629, 9L, 232ch, 232B) │ │ │ │ └── role-grants.md (c05d476, 8L, 117ch, 117B) @@ -703,6 +727,7 @@ │ │ │ │ └── Buyer.md (8f902f6, 5L, 41ch, 41B) │ │ │ ├── flows/ (1) │ │ │ │ └── checkout/ (2 files, 0 dirs) +│ │ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ │ └── ignatius.yml (273fead, 3L, 126ch, 126B) │ │ ├── flows/ (3) │ │ │ ├── externals/ (2) @@ -712,7 +737,7 @@ │ │ │ │ └── clean/ (1 file, 1 dir) │ │ │ └── stores/ (1) │ │ │ └── Sessions.md (de13da2, 5L, 111ch, 111B) -│ │ ├── flows-leveling/ (5) +│ │ ├── flows-leveling/ (6) │ │ │ ├── data/ (1) │ │ │ │ └── Party.md (928d654, 12L, 241ch, 243B) │ │ │ ├── externals/ (1) @@ -721,8 +746,9 @@ │ │ │ │ └── auth/ (1 file, 1 dir) │ │ │ ├── groups/ (1) │ │ │ │ └── auth.md (6c5ffdc, 6L, 86ch, 86B) +│ │ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ │ └── ignatius.yml (6f928bd, 3L, 140ch, 140B) -│ │ ├── flows-model/ (5) +│ │ ├── flows-model/ (6) │ │ │ ├── data/ (1) │ │ │ │ └── Customer.md (e0a0aeb, 11L, 120ch, 120B) │ │ │ ├── externals/ (1) @@ -731,8 +757,9 @@ │ │ │ │ └── checkout/ (1 file, 0 dirs) │ │ │ ├── groups/ (1) │ │ │ │ └── core.md (7d07a8c, 4L, 37ch, 37B) +│ │ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ │ └── ignatius.yml (bc4d92f, 3L, 125ch, 125B) -│ │ ├── hub-dfd/ (6) +│ │ ├── hub-dfd/ (7) │ │ │ ├── clusters/ (1) │ │ │ │ └── grants.md (4a27c8c, 10L, 225ch, 225B) │ │ │ ├── data/ (17) @@ -761,8 +788,9 @@ │ │ │ │ ├── grants.md (fc6778a, 6L, 71ch, 71B) │ │ │ │ ├── other.md (926a293, 6L, 83ch, 83B) │ │ │ │ └── records.md (463bb45, 6L, 68ch, 68B) +│ │ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ │ └── ignatius.yml (e66f938, 5L, 243ch, 245B) -│ │ ├── subtype-no-basetype/ (4) +│ │ ├── subtype-no-basetype/ (5) │ │ │ ├── data/ (3) │ │ │ │ ├── Base.md (05f387c, 16L, 197ch, 197B) │ │ │ │ ├── SubA.md (8f381b9, 10L, 72ch, 72B) @@ -771,6 +799,7 @@ │ │ │ │ └── Downstream.md (5c5ea84, 5L, 111ch, 111B) │ │ │ ├── flows/ (1) │ │ │ │ └── diagram/ (1 file, 0 dirs) +│ │ │ ├── .gitignore (72e0a76, 1L, 11ch, 11B) │ │ │ └── ignatius.yml (2c6dd17, 3L, 168ch, 168B) │ │ ├── merge-tag-before-clusters.md (4a7c5bc, 99L, 3823ch, 3833B) │ │ ├── sample_model.yaml (72d3fa6, 532L, 17934ch, 17962B) @@ -779,8 +808,9 @@ │ ├── notes/ (2) │ │ ├── another-idea.md (1e542c5, 384L, 16674ch, 17352B) │ │ └── branding-screenshots.md (027cad8, 33L, 2524ch, 2537B) -│ ├── visual/ (67) +│ ├── visual/ (68) │ │ ├── screenshot-branding.ts (702ea86, 65L, 2336ch, 2338B) +│ │ ├── screenshot-change-glow.ts (6598d2d, 192L, 9336ch, 9336B) │ │ ├── screenshot-chrome-unification.ts (a7d7479, 326L, 13382ch, 14842B) │ │ ├── screenshot-cp4-live-dict.ts (f0f85bc, 34L, 1043ch, 1043B) │ │ ├── screenshot-dict-examples.ts (5feecf3, 105L, 3919ch, 3943B) @@ -870,31 +900,31 @@ │ └── App.tsx (8ff50d4, 78L, 2470ch, 2474B) ├── .gitignore (6d3972b, 53L, 809ch, 811B) ├── .signalsignore (50ea1be, 28L, 1036ch, 1050B) -├── CHANGELOG.md (d299576, 312L, 22075ch, 22081B) +├── CHANGELOG.md (07eb62e, 319L, 22322ch, 22328B) ├── CLAUDE.md (2b92f41, 51L, 2352ch, 2364B) ├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) ├── NOTICE (3ae39c7, 2L, 38ch, 38B) -├── README.md (6a15a77, 82L, 4583ch, 4593B) +├── README.md (d47597f, 83L, 4712ch, 4722B) ├── bun-env.d.ts (4404f36, 16L, 284ch, 284B) ├── bun.lock (c190846, 202L, 18164ch, 18164B) ├── bunfig.toml (3ab75b0, 2L, 35ch, 35B) ├── install.sh (64a6757, 116L, 3578ch, 3594B) -├── package.json (aabfdd9, 52L, 1931ch, 1933B) +├── package.json (fa14139, 52L, 1968ch, 1970B) ├── release-please-config.json (9f191fe, 15L, 416ch, 416B) -├── release-please-manifest.json (8fb3c0b, 3L, 20ch, 20B) +├── release-please-manifest.json (6813989, 3L, 20ch, 20B) └── tsconfig.json (1d9427f, 37L, 870ch, 870B) ``` ## Manifests -- package.json: name=ignatius, version=0.20.2, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] +- package.json: name=ignatius, version=0.21.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] ## Languages -- TypeScript: 70979 LOC (70%), 310 files (41%) -- Markdown: 25062 LOC (24%), 417 files (55%) -- CSS: 3561 LOC (3%), 2 files (0%) +- TypeScript: 75349 LOC (70%), 324 files (41%) +- Markdown: 25903 LOC (24%), 421 files (54%) +- CSS: 4001 LOC (3%), 2 files (0%) - YAML: 1348 LOC (1%), 16 files (2%) - Shell: 116 LOC (0%), 1 file (0%) - JSON: 107 LOC (0%), 4 files (0%) diff --git a/docs/wiki/scripts.md b/docs/wiki/scripts.md index accddbe..5cd9c22 100644 --- a/docs/wiki/scripts.md +++ b/docs/wiki/scripts.md @@ -1,42 +1,63 @@ --- type: Domain -description: Standalone Bun CLI scripts for build post-processing, model migration/generation, and manual visual/perf diagnostics. +description: Standalone Bun CLI scripts for build post-processing, test-run review-state reset, model migration/generation, and manual visual/perf diagnostics. +tags: [scripts, build, tests] --- # scripts ## What it does -[`scripts/`](../../scripts) holds standalone Bun scripts run directly with `bun scripts/.ts`, not imported by application code. They fall into three groups: one required build post-processing step (`stable-names.ts`), one one-time data-migration script (`convert-yaml-to-md.ts`), and four dev-time tools for synthetic model generation, performance measurement, and manual visual/diagnostic inspection (`gen-synthetic-model.ts`, `perf-harness.ts`, `probe.ts`, `screenshot.ts`). +[`scripts/`](../../scripts) holds standalone Bun scripts run directly with `bun scripts/.ts`, not imported by application code. They fall into these groups: a required build post-processing step (`stable-names.ts`), a test-run reset step (`clear-review-state.ts`), a one-time data-migration script (`convert-yaml-to-md.ts`), and dev-time tools for synthetic model generation, performance measurement, and manual visual/diagnostic inspection (`gen-synthetic-model.ts`, `perf-harness.ts`, `probe.ts`, `screenshot.ts`). Without `clear-review-state.ts`, a check that serves a repo model leaves a change-review baseline behind, and later checks see a Changes panel they did not expect. -## Artifacts +## How it works -No user-facing Claude Code skill/command artifacts in this domain. +Scripts are plain top-level-await Bun scripts, not wrapped in a `main()` function or a CLI framework (`citty`, used by the cli domain, is not used here). CLI args are read ad hoc via `process.argv.indexOf(flag)` helpers: `argValue` is duplicated identically in `gen-synthetic-model.ts` and `perf-harness.ts`, while `argFlag` exists only in `perf-harness.ts`. -## CLI code +The diagnostic scripts split by driver: `probe.ts` and `screenshot.ts` use the `webview-bun` devDependency to drive a native webview window, and `perf-harness.ts` is the only script that uses the `playwright` devDependency. -- [`scripts/stable-names.ts`](../../scripts/stable-names.ts) — post-build step that copies the content-hashed `index-*.js`/`index-*.css` files in `dist/static/` to stable names `index.js`/`index.css`. Reads `dist/static/index.html` to find the authoritative hashed filenames via regex on the `src=`/`href=` attributes; falls back to a `Bun.Glob('index-*.js'/'index-*.css')` scan only if the HTML match fails. Exits with code 1 and an error message if either the JS or the CSS file can't be found — the two are checked independently, so it exits on the first missing one rather than waiting for both to be absent. Required before `bun build --compile` because compiled binaries need import paths known at compile time. -- [`scripts/convert-yaml-to-md.ts`](../../scripts/convert-yaml-to-md.ts) (257 lines) — one-time migration script that reads a hardcoded input `tmp/sample_model.yaml`, parses it with the `yaml` package, and writes one markdown file per entity into `models//.md`. Derives an IDEF1X classification per entity (`Subtype`, `Associative`, `Dependent`, `Classifier`, or `Independent`) from subtype membership, identifying-relationship count, and column shape; derives a `group` (`reference`, `transactional`, `catalog`, `identity`) from the entity's declared `groups` or by walking up identifying parents. Emits YAML frontmatter (`entity`, `classification`, `group`, `pk`, `columns`, optional `ak`/`subtypes`/`relationships`) plus a markdown body with `## Subtypes`, `## Values`, and `## Constraints` sections built from the source YAML. -- [`scripts/probe.ts`](../../scripts/probe.ts) (95 lines) — ad-hoc diagnostic script. Opens a native `webview-bun` window against a URL (default `http://localhost:3777`), waits for a `.react-flow__node` element to appear, then serializes visible node positions (parsed from each element's CSS `transform`) and edge count into a JSON report printed to stdout. Has a 60-second safety-quit timer and a 30-second wait-for-node timeout. Exits 0 only if a `report` payload was produced. -- [`scripts/screenshot.ts`](../../scripts/screenshot.ts) (82 lines) — snapshot helper that opens a native `webview-bun` window against a URL (default `http://localhost:3777`), waits up to 8 seconds for `.card svg` to appear, then serializes every `` inside a `.card` on the page (wrapped with an XML declaration and a dark background ``) and writes each to a file under an output directory (default `/tmp/ignatius-shots`, second CLI arg). Filenames are sanitized to `[a-z0-9._-]` and prefixed with a zero-padded index. Uses `writeFileSync` synchronously inside the `webview.bind` callback specifically to avoid an IPC deadlock in `webview-bun` that affects `await`ed writes from a bind handler. Exits 0 only if at least one file was written. -- [`scripts/gen-synthetic-model.ts`](../../scripts/gen-synthetic-model.ts) (428 lines) — synthetic IDEF1X model generator for perf testing. CLI: `bun scripts/gen-synthetic-model.ts [--n 300] [--out tmp/my-model]`. Writes a complete model root: `ignatius.yml` (with a hardcoded dark/light theme), one `groups/.md` file per group across 6 fixed groups (`core`, `catalog`, `sales`, `finance`, `ops`, `reporting`, distributed via fixed weights `[0.18, 0.20, 0.22, 0.18, 0.16, 0.06]`), and one entity markdown file per generated entity under `data//`. Always includes 3 hardcoded subtype clusters (`Party` → `Person`/`Organization`, `Product` → `PhysicalProduct`/`DigitalProduct`, `Channel` → `WebChannel`/`PartnerChannel`). Non-cluster entities get 1–3 FK relationships wired via a hub-and-peer strategy targeting ~1.4–1.8× edges per node while avoiding cycles. Default output directory: `tmp/synthetic-model-/`. -- [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts) (215 lines) — Playwright-based render latency measurement harness. CLI: `bun scripts/perf-harness.ts [--model ] [--n ] [--port ] [--runs ] [--mode organic|hierarchical]`. Without `--model`, first spawns `bun scripts/gen-synthetic-model.ts --n --out tmp/synthetic-model-` as a subprocess. Measures `parseModels()` timing directly via a dynamic import of `../src/model/parse`, spawns `bun src/cli/cli.ts serve --port ` as a subprocess server, fetches `/api/model` for payload byte size, then drives a headless Chromium page via `playwright` to measure `time-to-layoutstop` (navigation start until `window.__IGNATIUS_PERF__` is stamped) and `time-to-interactive` (layoutstop + a 200ms settle). Clears the `ignatius-layout-positions` `localStorage` key before each measured run so ELK always runs cold; `--mode` optionally forces `ignatius-layout-mode` in `localStorage` before navigation. Uses a 10-minute `waitForFunction` timeout to accommodate large (300+ node) graphs. Prints a fixed-width ASCII report table to stdout. Not run by `bun run test` or CI. +Only two scripts are wired into [`package.json`](../../package.json): `stable-names.ts` through `build:stable-names`, and `clear-review-state.ts` as the first command of [`test`](../../test). -## Docs +```mermaid +flowchart TD + %% source: package.json scripts, scripts/stable-names.ts, scripts/clear-review-state.ts + B["build:bundle"] --> S["build:stable-names
stable-names.ts"] + S --> C["bun build --compile"] + T["bun run test"] --> R["clear-review-state.ts"] + R --> L["test/checks/*.ts loop"] +``` -- [`docs/guides/building-from-source.md`](../guides/building-from-source.md) — documents the 3-stage `build:cli` sequence (`build:bundle` → `build:stable-names` → compile), explicitly naming [`scripts/stable-names.ts`](../../scripts/stable-names.ts)'s role as the file-renaming stage. +`build:cli` runs the bundle, stable-names, and compile stages in order, because the compile needs import paths known at compile time. [`test`](../../test) clears review state before the check loop so a stale `.ignatius/baseline.json` cannot change what a check sees. + +## Where it lives + +| Path | Role | +|---|---| +| [`scripts/stable-names.ts`](../../scripts/stable-names.ts) | post-build step that copies the content-hashed `index-*.js`/`index-*.css` files in `dist/static/` to stable names `index.js`/`index.css`. Reads `dist/static/index.html` to find the authoritative hashed filenames via regex on the `src=`/`href=` attributes; falls back to a `Bun.Glob('index-*.js'/'index-*.css')` scan only if the HTML match fails. Exits with code 1 and an error message if either the JS or the CSS file can't be found — the two are checked independently, so it exits on the first missing one rather than waiting for both to be absent. Required before `bun build --compile` because compiled binaries need import paths known at compile time. | +| [`scripts/clear-review-state.ts`](../../scripts/clear-review-state.ts) | test-run reset. For each directory under [`models/`](../../models) and [`test/fixtures/`](../../test/fixtures), removes its `.ignatius/` folder recursively (`rmSync` with `force: true`, so a missing folder is fine). `ignatius serve` writes `.ignatius/baseline.json` into the model it serves (see [`docs/guides/change-review.md`](../guides/change-review.md)), and checks serve repo models in place; a stale baseline would make a later check's served model render change highlights and a Changes panel. Takes no arguments. Reads [`models/`](../../models) and [`test/fixtures/`](../../test/fixtures) with `readdirSync` and does not catch a missing parent directory, so both must exist. | +| [`scripts/convert-yaml-to-md.ts`](../../scripts/convert-yaml-to-md.ts) | one-time migration script that reads a hardcoded input `tmp/sample_model.yaml`, parses it with the `yaml` package, and writes one markdown file per entity into `models//.md`. Derives an IDEF1X classification per entity (`Subtype`, `Associative`, `Dependent`, `Classifier`, or `Independent`) from subtype membership, identifying-relationship count, and column shape; derives a `group` (`reference`, `transactional`, `catalog`, `identity`) from the entity's declared `groups` or by walking up identifying parents. Emits YAML frontmatter (`entity`, `classification`, `group`, `pk`, `columns`, optional `ak`/`subtypes`/`relationships`) plus a markdown body with `## Subtypes`, `## Values`, and `## Constraints` sections built from the source YAML. | +| [`scripts/probe.ts`](../../scripts/probe.ts) | ad-hoc diagnostic script. Opens a native `webview-bun` window against a URL (default `http://localhost:3777`), waits for a `.react-flow__node` element to appear, then serializes visible node positions (parsed from each element's CSS `transform`) and edge count into a JSON report printed to stdout. Has a 60-second safety-quit timer and a 30-second wait-for-node timeout. Exits 0 only if a `report` payload was produced. | +| [`scripts/screenshot.ts`](../../scripts/screenshot.ts) | snapshot helper that opens a native `webview-bun` window against a URL (default `http://localhost:3777`), waits up to 8 seconds for `.card svg` to appear, then serializes every `` inside a `.card` on the page (wrapped with an XML declaration and a dark background ``) and writes each to a file under an output directory (default `/tmp/ignatius-shots`, second CLI arg). Filenames are sanitized to `[a-z0-9._-]` and prefixed with a zero-padded index. Uses `writeFileSync` synchronously inside the `webview.bind` callback specifically to avoid an IPC deadlock in `webview-bun` that affects `await`ed writes from a bind handler. Exits 0 only if at least one file was written. | +| [`scripts/gen-synthetic-model.ts`](../../scripts/gen-synthetic-model.ts) | synthetic IDEF1X model generator for perf testing. CLI: `bun scripts/gen-synthetic-model.ts [--n 300] [--out tmp/my-model]`. Writes a complete model root: `ignatius.yml` (with a hardcoded dark/light theme), one `groups/.md` file per group across fixed groups (`core`, `catalog`, `sales`, `finance`, `ops`, `reporting`, distributed via fixed weights `[0.18, 0.20, 0.22, 0.18, 0.16, 0.06]`), and one entity markdown file per generated entity under `data//`. Always includes hardcoded subtype clusters (`Party` → `Person`/`Organization`, `Product` → `PhysicalProduct`/`DigitalProduct`, `Channel` → `WebChannel`/`PartnerChannel`). Non-cluster entities get 1–3 FK relationships wired via a hub-and-peer strategy targeting ~1.4–1.8× edges per node while avoiding cycles. Default output directory: `tmp/synthetic-model-/`. | +| [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts) | Playwright-based render latency measurement harness. CLI: `bun scripts/perf-harness.ts [--model ] [--n ] [--port ] [--runs ] [--mode organic\|hierarchical]`. Without `--model`, first spawns `bun scripts/gen-synthetic-model.ts --n --out tmp/synthetic-model-` as a subprocess. Measures `parseModels()` timing directly via a dynamic import of `../src/model/parse`, spawns `bun src/cli/cli.ts serve --port ` as a subprocess server, fetches `/api/model` for payload byte size, then drives a headless Chromium page via `playwright` to measure `time-to-layoutstop` (navigation start until `window.__IGNATIUS_PERF__` is stamped) and `time-to-interactive` (layoutstop + a 200ms settle). Clears the `ignatius-layout-positions` `localStorage` key before each measured run so ELK always runs cold; `--mode` optionally forces `ignatius-layout-mode` in `localStorage` before navigation. Uses a 10-minute `waitForFunction` timeout to accommodate large (300+ node) graphs. Prints a fixed-width ASCII report table to stdout. Not run by `bun run test` or CI. | +| [`docs/guides/building-from-source.md`](../guides/building-from-source.md) | Documents the `build:cli` sequence (`build:bundle`, `build:stable-names`, compile), naming `stable-names.ts` as the file-renaming stage | + +## Constraints + +| Constraint | Detail | +|---|---| +| `clear-review-state.ts` has no existence guard | It reads [`models/`](../../models) and [`test/fixtures/`](../../test/fixtures) with `readdirSync` and does not catch a missing parent, so `bun run test` fails if either directory is missing | +| `probe.ts` selectors target React Flow | It waits for `.react-flow__node` and reads `.react-flow__edge`, but the frontend graph is Cytoscape.js (`cytoscape`/`cytoscape-navigator` in [`src/app/`](../../src/app)), which renders no such DOM elements, so the selectors do not match the current graph | +| `screenshot.ts` writes synchronously | The `webview.bind` handler uses `writeFileSync` instead of awaited Bun file writes, to avoid an IPC deadlock in `webview-bun` | +| `stable-names.ts` is required before compile | Skipping it leaves hashed `index-*` filenames that the compiled binary cannot resolve | +| `perf-harness.ts` is not part of `bun run test` or CI | It hangs until its 10-minute timeout if the `window.__IGNATIUS_PERF__` global disappears | ## Coupling - [`scripts/stable-names.ts`](../../scripts/stable-names.ts) is wired into [`package.json`](../../package.json)'s `build:stable-names` script, which `build:cli` depends on; it is also invoked directly as a CI step in [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) and [`.github/workflows/release-please.yml`](../../.github/workflows/release-please.yml). It reads `dist/static/index.html`, so any change to how the frontend/build pipeline ([`src/app/`](../../src/app)) names or references its hashed bundle entrypoints in the emitted HTML can break its regex match. +- [`scripts/clear-review-state.ts`](../../scripts/clear-review-state.ts) is the first command in [`package.json`](../../package.json)'s [`test`](../../test) script (`bun scripts/clear-review-state.ts && for f in test/checks/*.ts; ...`), so it runs once before the check loop and a failure stops the run. It deletes the `.ignatius/` state that [`src/model/change-store.ts`](../../src/model/change-store.ts) (owned by the parser domain, called by the server and cli domains) writes into a served model; the `models/*/.gitignore` files listing `.ignatius/` keep that state out of git. A rename of the `.ignatius/` folder in the change-review feature ([`docs/spec/model-change-review.md`](../spec/model-change-review.md)) requires the same rename here. - [`scripts/gen-synthetic-model.ts`](../../scripts/gen-synthetic-model.ts) is invoked as a subprocess by [`test/checks/test-synthetic-model.ts`](../../test/checks/test-synthetic-model.ts) (run by `bun run test` and CI), by two [`test/visual/`](../../test/visual) scripts (`test-cp5b-edge-paint-on-load.ts`, `test-cp2-preset-layout.ts`), and by [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts). Its emitted entity/group markdown frontmatter schema (`entity`, `group`, `pk`, `columns`, `relationships`, `subtypes`) must stay parseable by the parser domain's `parseModels()` ([`src/model/parse.ts`](../../src/model/parse.ts)) — `test-synthetic-model.ts` asserts exactly that. - [`scripts/convert-yaml-to-md.ts`](../../scripts/convert-yaml-to-md.ts) writes into `models//.md` using the same entity frontmatter shape the parser domain expects, and its role as the old-format migration path is referenced from the skill domain's design/spec docs ([`docs/design/ignatius-modeling-skill.md`](../design/ignatius-modeling-skill.md), [`docs/spec/ignatius-modeling-skill.md`](../spec/ignatius-modeling-skill.md)) as the tool that already covers YAML→markdown migration. - [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts) imports `parseModels` directly from the parser domain ([`src/model/parse.ts`](../../src/model/parse.ts)), spawns the cli domain's `src/cli/cli.ts serve` subcommand, and reads the `window.__IGNATIUS_PERF__` global stamped by the frontend/app domain in [`src/app/views/graph/GraphView.tsx`](../../src/app/views/graph/GraphView.tsx) — a rename or removal of that global breaks the harness silently (it would just hang until the 10-minute timeout). - [`scripts/probe.ts`](../../scripts/probe.ts) and [`scripts/screenshot.ts`](../../scripts/screenshot.ts) both target a running dev server (default `http://localhost:3777`, matching the `dev`/`dev:cli` [`package.json`](../../package.json) scripts) and depend on specific DOM structure in the frontend/app domain to find their targets. -- No [`package.json`](../../package.json) script wires up `probe.ts`, `screenshot.ts`, `gen-synthetic-model.ts`, `perf-harness.ts`, or `convert-yaml-to-md.ts` directly — only `build:stable-names` → `stable-names.ts` is a first-class [`package.json`](../../package.json) script; the rest are invoked ad hoc via `bun scripts/.ts` per each script's own usage comment, or as subprocesses from test files. - -## Conventions worth knowing - -- Scripts here are plain top-level-await Bun scripts, not wrapped in a `main()` function or a CLI framework (`citty`, used by the actual CLI domain, is not used here) — CLI args are read ad hoc via `process.argv.indexOf(flag)` helpers — `argValue` is duplicated identically in `gen-synthetic-model.ts` and `perf-harness.ts`, while `argFlag` exists only in `perf-harness.ts`. -- `probe.ts` and `screenshot.ts` both use the `webview-bun` devDependency to drive a native webview window rather than Playwright — `perf-harness.ts` is the only script in this domain that uses the `playwright` devDependency. `probe.ts`'s DOM selectors (`.react-flow__node`, `.react-flow__edge`) target React Flow class names, but the frontend/app domain's graph is Cytoscape.js (confirmed by `cytoscape`/`cytoscape-navigator` imports and CSS in [`src/app/`](../../src/app)), which does not render nodes as individually classed `.react-flow__node` DOM elements — this script's selectors do not match the current graph implementation. -- [`scripts/screenshot.ts`](../../scripts/screenshot.ts)'s bind handler uses synchronous `writeFileSync` rather than `await`ed Bun file writes, with an inline comment explaining this sidesteps an IPC deadlock specific to `webview-bun`. +- No [`package.json`](../../package.json) script wires up `probe.ts`, `screenshot.ts`, `gen-synthetic-model.ts`, `perf-harness.ts`, or `convert-yaml-to-md.ts` directly — only `build:stable-names` → `stable-names.ts` and the [`test`](../../test) script's `clear-review-state.ts` prefix are wired into [`package.json`](../../package.json); the rest are invoked ad hoc via `bun scripts/.ts` per each script's own usage comment, or as subprocesses from test files. diff --git a/docs/wiki/server.md b/docs/wiki/server.md index 4b09209..40f51fa 100644 --- a/docs/wiki/server.md +++ b/docs/wiki/server.md @@ -1,46 +1,72 @@ --- type: Domain -description: Bun.serve() HTTP server exposing the model/flow JSON API, SSE live-reload, and the SPA shell behind `ignatius serve`. -tags: [server, bun-serve, sse] +description: Bun.serve() HTTP server exposing the model/flow JSON API, the change-review approve endpoint, SSE live-reload, and the SPA shell behind `ignatius serve`. +tags: [server, bun-serve, sse, change-review] --- # server ## What it does -Without [`src/server/server.ts`](../../src/server/server.ts) there is no way to view a model or a flow diagram in a browser, and no live-reload when a model file changes on disk: it is the process a running `ignatius serve` invocation actually is. The file is a single-file domain exporting `serveCommand(modelsDir, opts)`, which starts a `Bun.serve()` instance that serves the ignatius SPA shell and its JSON/SSE API for one model directory. +Without [`src/server/server.ts`](../../src/server/server.ts) there is no way to view a model or a flow diagram in a browser, no live-reload when a model file changes on disk, and no way to review what an agent changed in a model and approve it: it is the process a running `ignatius serve` invocation actually is. The file is a single-file domain exporting `serveCommand(modelsDir, opts)`, which starts a `Bun.serve()` instance that serves the ignatius SPA shell and its JSON/SSE API for one model directory. ## How it works -`serveCommand` registers every route on `Bun.serve`'s native `routes` map (no router library), wires an `fs.watch` on `modelsDir`, and returns a `ServeHandle = { server, stop }`. Two request paths carry the domain's real logic: the `/api/flow` branch, and the SSE broadcast loop that drives hot reload. +`serveCommand` registers every route on `Bun.serve`'s native `routes` map (no router library), wires an `fs.watch` on `modelsDir` plus a stat poll on the change-review baseline file, and returns a `ServeHandle = { server, stop }`. The domain's real logic sits in the `/api/flow` branch, the SSE broadcast loop that drives hot reload, and the review/approve cycle built on `/api/model` and `/api/approve`. ### `serveCommand`'s route table | Route | Behavior | |---|---| -| `GET /` | [`src/app/index.html`](../../src/app/index.html) bound directly as a Bun HTML import (the SPA shell) | -| `GET /dict` | 302 redirect to `/#view=dict` | -| `GET /flow` | 302 redirect to `/#view=flow` | -| `GET /flow-dict` | 302 redirect to `/#view=dict` (comment marks this CP5: the process dictionary fused into the SPA Dictionary view) | -| `GET /api/model` | `parseModels(modelsDir)` then `validateModel(model)` then `layoutFingerprint(model)`; returns `{ model, parseGlobalErrors, validation, layoutKey }` | -| `GET /api/flow` | see the branch diagram below; returns `{ diagrams, entityModel, validation, flowLayoutKeys, clusters }` in both branches | -| `GET /api/asset?path=` | resolves `path` under `modelsDir`; 400 on an absolute path or a `normalize(path)` that starts with `..`; 404 if the resolved file doesn't exist; otherwise streams the `Bun.file` | -| `GET /events` | SSE stream; disables Bun's idle timeout, tracks its controller in `sseClients` | +| `/` | [`src/app/index.html`](../../src/app/index.html) bound directly as a Bun HTML import (the SPA shell) | +| `/dict` | 302 redirect to `/#view=dict` | +| `/flow` | 302 redirect to `/#view=flow` | +| `/flow-dict` | 302 redirect to `/#view=dict` (the process dictionary is fused into the SPA Dictionary view) | +| `/api/model` | `parseModels(modelsDir)` then `validateModel(model)` then `layoutFingerprint(model)` then `buildReview(modelsDir, model)`; returns `{ model, parseGlobalErrors, validation, layoutKey, diff }`. `diff` is the change review, or `null` when `buildReview` rejects (the error is logged with `console.error`). | +| `POST /api/approve` | any other method gets 405 with `Allow: POST`; a request failing `isSameOrigin` or `isLocalHost` gets 403; otherwise `approve(modelsDir)`, then `broadcast('model-changed')`, then returns `{ items, notes }` | +| `/api/flow` | see the branch diagram below; returns `{ diagrams, entityModel, validation, flowLayoutKeys, clusters }` in both branches | +| `/api/asset?path=` | resolves `path` under `modelsDir`; 400 when `path` is missing, is absolute, or has a `normalize(path)` that starts with `..`; 404 if the resolved file doesn't exist; otherwise streams the `Bun.file` | +| `/events` | SSE stream; disables Bun's idle timeout, tracks its controller in `sseClients` | + +Function handlers answer every HTTP method; only `/api/approve` checks `req.method`. ### The `/api/flow` empty-state and populated branches converge on one response shape ```mermaid flowchart TD - A["GET /api/flow"] --> B{"existsSync(modelsDir/flows)"} - B -->|no| C["parseModels(modelsDir)"] - C --> D["Response.json: diagrams=[], entityModel, validation=empty, flowLayoutKeys={}, clusters=[]"] - B -->|yes| E["parseModels + parseFlows(modelsDir)"] - E --> F["validateFlows(flowModel, model, flowRules)"] - F --> G["buildFlowLayoutKeys(flowModel)"] - G --> H["Response.json: diagrams=flowModel.diagrams, entityModel=model, validation, flowLayoutKeys, clusters=flowModel.clusters"] + A["GET /api/flow"] --> B{"existsSync
(modelsDir/flows)"} + B -->|no| C["parseModels
(modelsDir)"] + C --> D["Response.json
empty-state payload"] + B -->|yes| E["parseModels +
parseFlows(modelsDir)"] + E --> F["validateFlows(flowModel,
model, flowRules)"] + F --> G["buildFlowLayoutKeys
(flowModel)"] + G --> H["Response.json
populated payload"] ``` -Both branches return the same five top-level keys (`diagrams`, `entityModel`, `validation`, `flowLayoutKeys`, `clusters`); the empty-state branch parses the model solely to populate `entityModel`, and returns `diagrams: []` and `clusters: []` rather than omitting either key. A source comment on the guard states the reason: the empty state must not be a different shape from the populated one, so a caller reading `entityModel`/`clusters` gets the same key set from either branch. +Both branches return `diagrams`, `entityModel`, `validation`, `flowLayoutKeys`, and `clusters`. The empty-state branch parses the model solely to populate `entityModel`, and returns `diagrams: []`, `clusters: []`, `flowLayoutKeys: {}`, and an empty `validation` rather than omitting any key. A source comment on the guard names `entityModel` as the key whose absence would break a consumer. + +### Approve resets the review baseline and every open tab refetches + +The change review compares the current parse against a stored baseline (`baselinePath(modelsDir)`, `baseline.json` under the ignatius directory in `modelsDir`). `/api/model` carries that comparison as `diff`; `/api/approve` accepts the current parse as the new baseline and deletes the change notes, as implemented in [`src/model/change-store.ts`](../../src/model/change-store.ts). The design is in [`docs/design/model-change-review.md`](../design/model-change-review.md). + +```mermaid +sequenceDiagram + %% source: docs/design/model-change-review.md; src/server/server.ts, src/model/change-store.ts + participant UI as SPA + participant Srv as /api/approve + participant Store as change-store + participant Cl as sseClients + + UI->>Srv: POST (same-origin, local Host) + Srv->>Srv: isSameOrigin, isLocalHost + Srv->>Store: approve(modelsDir) + Store-->>Srv: items, notes + Srv->>Cl: broadcast("model-changed") + Srv-->>UI: JSON items, notes + Cl-->>UI: model-changed, UI refetches /api/model +``` + +The approve handler calls `broadcast('model-changed')` itself, so every connected tab refetches `/api/model`. ### fs.watch coalesces bursty file events into one SSE broadcast per debounce window @@ -55,48 +81,56 @@ sequenceDiagram alt not .md/.yaml/.yml Srv-->>FS: ignore else watched extension - Srv->>Srv: clearTimeout(debounceTimer), setTimeout 200ms + Srv->>Srv: scheduleModelChanged(): clearTimeout(debounceTimer), setTimeout 200ms Note over Srv: an event inside the 200ms window restarts the timer Srv->>Cl: broadcast("model-changed") on timer fire - loop each controller in sseClients - Cl-->>Cl: enqueue SSE frame, delete controller on throw - end end - Cl-->>Srv: client disconnects (tab closed, EventSource.close()) - Srv->>Srv: stream cancel() deletes controller from sseClients ``` -`broadcast(event, data)` encodes an `event: ...\ndata: ...\n\n` SSE frame and enqueues it to every controller in the per-instance `sseClients` set. The `/events` stream's `cancel()` callback is the primary cleanup path: it fires on a normal disconnect (tab closed, `EventSource.close()`) and deletes the controller from `sseClients`. A controller whose `enqueue` throws (a write to an already-broken pipe) is a secondary path, deleted from the set during that same `broadcast` iteration. `/events` calls `server.timeout(req, 0)` so Bun's default idle timeout never kills the long-lived stream, and writes a `: connected\n\n` comment line on open so the client sees an immediate response. +The baseline file is watched separately. `ensureBaseline(modelsDir)` runs once after `Bun.serve` starts; when it settles (success or logged failure) and `stop()` has not run, `watchFile(baselineFile, { interval: 500 }, onBaselineStat)` starts. A changed `mtimeMs` calls the same `scheduleModelChanged()`, so a CLI `approve` that clears no notes (and so touches only the baseline) still reaches open tabs. The recursive `fs.watch` does not cover this: it ignores non-model extensions, and matching `baseline.json` there proved unreliable on macOS (source comment at the `watchFile` call). + +### SSE clients leave `sseClients` on `cancel()` or on a failed `enqueue` + +Each `/events` stream adds its controller to `sseClients`; one of two callbacks removes it. + +```mermaid +flowchart TD + A["/events start(c)"] -->|"sseClients.add(ctrl)"| S[("sseClients")] + B["broadcast(event, data)"] -->|"ctrl.enqueue(chunk)"| S + B -->|"enqueue throws"| X["sseClients.delete(ctrl)"] + C["stream cancel()"] --> X +``` + +`broadcast(event, data)` encodes an `event: ...\ndata: ...\n\n` SSE frame and enqueues it to every controller in the per-instance `sseClients` set. The stream's `cancel()` callback is the primary cleanup path: it fires on a normal disconnect (tab closed, `EventSource.close()`). A controller whose `enqueue` throws (controller already closed) is deleted from the set during that same `broadcast` iteration. `/events` calls `server.timeout(req, 0)` so Bun's default idle timeout never kills the long-lived stream, and writes a `: connected\n\n` comment line on open so the client sees an immediate response. ## Where it lives | Path | What | |---|---| -| [`src/server/server.ts`](../../src/server/server.ts) | The entire domain: `serveCommand`, `ServeHandle`, route table, SSE broadcast, `fs.watch` debounce, `import.meta.main` entry point. No subdirectory under [`src/server/`](../../src/server). | -| `test/checks/*.ts` files that import `serveCommand` (17 as of this writing, see `grep -rln serveCommand test/checks/*.ts`) | Integration checks that boot a real server to exercise it; there is no [`src/server/`](../../src/server) test directory. | +| [`src/server/server.ts`](../../src/server/server.ts) | The entire domain: `serveCommand`, `ServeHandle`, route table, `isSameOrigin` and `isLocalHost` guards, SSE broadcast, `fs.watch` debounce, baseline stat poll, `import.meta.main` entry point. No subdirectory under [`src/server/`](../../src/server). | +| `test/checks/*.ts` files that import `serveCommand` (see `grep -rln serveCommand test/checks/*.ts`) | Integration checks that boot a real server to exercise it; there is no [`src/server/`](../../src/server) test directory. | ## Constraints | Constraint | Detail | |---|---| -| `/api/flow` response shape is fixed across both branches | The empty-state branch (no `flows/` directory) still parses the model and returns `entityModel` and `clusters: []`; a caller reading `entityModel`/`clusters` gets the same key set from either branch. | -| `/api/asset` path guard runs before `resolve()` | Rejects `isAbsolute(rawPath)` and a `normalize(rawPath)` starting with `..` with a 400, before the path is ever resolved against `modelsDir`. Reordering the check after `resolve()` would let a path-traversal read resolve outside `modelsDir` before the `..`/absolute check ever runs. | +| `/api/flow` response shape is fixed across both branches | The empty-state branch (no `flows/` directory) still parses the model and returns `entityModel` and `clusters: []`; a consumer reading `entityModel` would break only on models with no `flows/` if the empty state omitted it. | +| `/api/approve` is a state-changing POST guarded by two header checks | The server listens on every interface and a bodyless POST is a CORS simple request. `isSameOrigin` rejects a `sec-fetch-site` other than `same-origin` and an `origin` whose host differs from `Host`; `isLocalHost` accepts only the hostnames `localhost`, a dotted IPv4 literal, or a bracketed IPv6 literal, because a DNS-rebound page reaches the server under its own domain name where every request looks same-origin. A client that sends neither `Origin` nor `Sec-Fetch-Site` (curl) passes `isSameOrigin`. | +| `diff` degrades to `null` instead of failing `/api/model` | A `buildReview` error is logged and the rest of the payload is still returned, so a consumer must treat `diff: null` as "no review available", not "no changes". | +| Baseline polling starts after `ensureBaseline` settles | The poll is armed after `ensureBaseline` settles because, armed earlier, the file's creation by the first baseline write (`ensureBaseline` at startup, or `buildReview`) would fire a spurious `model-changed`. `stop()` sets `stopped` and calls `unwatchFile` so a late settle does not re-arm the poll. | +| `/api/asset` path guard runs before the file is read | Rejects `isAbsolute(rawPath)` and a `normalize(rawPath)` starting with `..` with a 400 before `Bun.file(...)` is touched. Dropping the check, or running it after `Bun.file(...)` reads, serves files outside `modelsDir`; `resolve()` itself reads nothing. | | `/events` disables the default idle timeout | `server.timeout(req, 0)` on the SSE route only; every other route keeps Bun's default. Without the override, Bun's default idle timeout would silently close the long-lived SSE stream, killing live-reload for any tab left open past the timeout. | | Watched extensions are `.md`, `.yaml`, `.yml` | `.yml` matters as much as `.yaml`: a comment in the source notes the model marker is `ignatius.yml`, so without watching `.yml` an edit to name, theme, branding, or `flow_rules` would never trigger a reload. | | `import.meta.main` gates the direct-invocation entry point, not a path comparison | A comment explains why: in a compiled Bun binary every bundled module shares the same `$bunfs` path, so comparing `import.meta.path === Bun.main` would always be true. | -| `development: { hmr: true, console: true }` is passed to `Bun.serve` unconditionally | Not gated on an environment check, so a compiled/production binary still ships HMR and dev console output. Passing this object also enables Bun's contextual-error page (stack traces in the response) regardless of which keys it sets, since Bun gates that page on the field's truthiness alone — Bun's own docs warn this "shouldn't be used in production or you will risk leaking sensitive information." | -| `sseClients` removal relies on `cancel()` firing or `enqueue` throwing | A TCP-level drop with no FIN/RST (neither triggers `cancel()` nor makes the next `enqueue()` throw) leaves a dead controller in `sseClients` for the life of the server instance; every other disconnect path is covered by one of the two removal callbacks. | +| `development: { hmr: true, console: true }` is passed to `Bun.serve` unconditionally | Not gated on an environment check, so even under `NODE_ENV=production` a compiled binary still gets HMR and dev console output. Passing this object also enables Bun's contextual-error page (stack traces in the response) regardless of which keys it sets, since Bun gates that page on the field's truthiness alone. | +| `sseClients` removal relies on `cancel()` firing or `enqueue` throwing | A controller that neither cancels nor throws (a TCP-level drop with no FIN/RST) stays in the set until `stop()` clears it. | ## Coupling -| Dependency | Direction | Coupling | -|---|---|---| -| [`src/model/parse.ts`](../../src/model/parse.ts) (`parseModels`), [`src/model/validate.ts`](../../src/model/validate.ts) (`validateModel`) | imports | Used by `/api/model` and both `/api/flow` branches; a shape change to `Model`, `ParseResult`, or `ValidationResult` forces a matching change in the JSON contracts here and in any frontend code consuming them. | -| [`src/model/layout-fingerprint.ts`](../../src/model/layout-fingerprint.ts) (`layoutFingerprint`) | imports | Feeds the `layoutKey` field of `/api/model`. | -| [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (`parseFlows`), [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) (`validateFlows`), [`src/flows/flow-fingerprint.ts`](../../src/flows/flow-fingerprint.ts) (`buildFlowLayoutKeys`) | imports | Drive `/api/flow`'s populated branch. `FlowModel.clusters` (from [`src/flows/flow-clusters.ts`](../../src/flows/flow-clusters.ts)) now travels through this endpoint as the response's `clusters` field; a change to `FlowModel`'s shape forces a matching change here. | -| `model._meta?.flowRules` | reads | Populated from `flow_rules:` in `ignatius.yml` by the parser; passed into `validateFlows` as its config argument. | -| [`src/app/index.html`](../../src/app/index.html) | imports | Bound directly to the `/` route as a Bun HTML import; a frontend build change is picked up through Bun's bundling of that import, with no separate wiring in this file. | -| [`src/cli/serve-port.ts`](../../src/cli/serve-port.ts) (`serveWithPortFallback`) | depends on `serveCommand` | Wraps `serveCommand` in an EADDRINUSE retry/prompt loop for the `serve` CLI command; a change to how `serveCommand` throws on a bound port would break that fallback logic. | -| [`scripts/perf-harness.ts`](../../scripts/perf-harness.ts) | spawns the CLI | Runs `bun src/cli/cli.ts serve --port ` as a subprocess to benchmark a running server; coupled through the CLI command, not a direct import of this file. | -| `test/checks/*.ts` | imports `serveCommand` | Boots a real server for integration checks (see the table above). | - +- **parser** ([`src/model/parse.ts`](../../src/model/parse.ts) `parseModels`, [`src/model/layout-fingerprint.ts`](../../src/model/layout-fingerprint.ts) `layoutFingerprint`, [`src/model/change-store.ts`](../../src/model/change-store.ts) `approve`, `baselinePath`, `buildReview`, `ensureBaseline`) is imported for `/api/model`, `/api/approve`, and both `/api/flow` branches. A shape change to `Model` or `ParseResult` forces a matching change in the JSON contracts here and in the frontend that consumes them. `change-store.ts` feeds `diff`, the `/api/approve` body, and the baseline file the stat poll watches; a change to the baseline location or the `{ items, notes }` return shape breaks the poll and the response. +- **validate** ([`src/model/validate.ts`](../../src/model/validate.ts) `validateModel`) feeds `validation` on `/api/model`; a change to `ValidationResult` forces a matching change in the JSON contract. +- **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) `parseFlows`, [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) `validateFlows`, [`src/flows/flow-fingerprint.ts`](../../src/flows/flow-fingerprint.ts) `buildFlowLayoutKeys`) drives `/api/flow`'s populated branch. `FlowModel.clusters` (from [`src/flows/flow-clusters.ts`](../../src/flows/flow-clusters.ts)) travels through the endpoint as `clusters`; a change to `FlowModel`'s shape forces a matching change here. `model._meta?.flowRules`, populated from `flow_rules:` in `ignatius.yml` by the parser, is passed into `validateFlows` as its config argument. +- **frontend** ([`src/app/index.html`](../../src/app/index.html)) is bound directly to the `/` route as a Bun HTML import; a frontend build change is picked up through Bun's bundling of that import, with no separate wiring in this file. +- **cli** ([`src/cli/serve-port.ts`](../../src/cli/serve-port.ts) `serveWithPortFallback`) wraps `serveCommand` in an EADDRINUSE retry/prompt loop for the `serve` command; a change to how `serveCommand` throws on a bound port would break that fallback. +- **scripts** ([`scripts/perf-harness.ts`](../../scripts/perf-harness.ts)) runs `bun src/cli/cli.ts serve --port ` as a subprocess to benchmark a running server; it is coupled through the CLI command, not a direct import. +- Tests: `test/checks/*.ts` import `serveCommand` and boot a real server for integration checks (see the table above). diff --git a/docs/wiki/skill.md b/docs/wiki/skill.md index 6f8ab67..1e17882 100644 --- a/docs/wiki/skill.md +++ b/docs/wiki/skill.md @@ -14,16 +14,14 @@ It never invents structure: PK shape, classification, and cardinality are always ## How it works -`SKILL.md`'s frontmatter declares `allowed-tools: Read Write Edit Bash Glob AskUserQuestion` and reads `$ARGUMENTS` to pick one of four modes, each delegating to its own reference file. +`SKILL.md`'s frontmatter declares `allowed-tools: Read Write Edit Bash Glob AskUserQuestion` and reads `$ARGUMENTS` to pick a mode, each delegating to its own reference file. **The mode's argument selects which reference file drives the conversation, and `discover` forks again on where the evidence comes from.** ```mermaid -flowchart LR +flowchart TD A["$ARGUMENTS"] --> B{mode} - B -->|entity| C["entity-flow.md"] - B -->|model| D["model-flow.md"] - B -->|flow| E["dfd-authoring.md"] + B -->|"entity, model, or flow"| C["entity-flow.md,
model-flow.md, or
dfd-authoring.md"] B -->|discover| F["discover-flow.md"] F -->|real system to read| G["reverse-engineering.md"] B -->|empty/unrecognized| H["ask which mode"] @@ -32,24 +30,24 @@ flowchart LR - `entity` — add one entity file (`references/entity-flow.md`, steps E0-E10). - `model` — bootstrap a new model skeleton (`references/model-flow.md`, steps M1-M8). - `flow` — author a DFD for a user who already knows their processes (`references/dfd-authoring.md`, steps F0-F9, with lettered sub-steps F4a and F6a). -- `discover` — a five-gate Socratic interview that generates both entities and flows (`references/discover-flow.md`); it routes to `references/reverse-engineering.md` (phases R0-R4) when a live database, codebase, schema, or API exists to read instead of a user description. +- `discover` — a Socratic interview that generates both entities and flows (`references/discover-flow.md`); it routes to `references/reverse-engineering.md` (phases R0-R4) when a live database, codebase, schema, or API exists to read instead of a user description. -Ten core rules in `SKILL.md` apply across all four modes: derive-never-ask (classification and per-edge `identifying` come from key shape, never a question), convention-is-derived (an entity's PK shape *is* its key-inherited-vs-orm-oriented style), adapt-to-the-user's-conventions (never invent naming), existence-rules-survive-the-key-style (a mandatory parent is asserted by key placement or by a documented `nullable: false` rule), subtype-independence (Subtype classification comes from cluster membership, never a direct ask), predicates-carry-business-meaning (push past "has many" to a domain verb), examples-always (every entity gets 2-3 `examples:` rows, every process gets `in`/`out` examples, generated by the skill and never skipped), `description:`-always (every entity, group, process, external, and store gets a one-line `description:` frontmatter field, plus every top-level flow folder's `flows//index.md`, generated by the skill, never skipped, and never named after the model's reserved `index_file` basename), capture-the-business-story (rules, constraints, and lifecycle go in the body with their source), and labels-and-clusters-always (every flow entry carries a prose `label:`, and stores a process treats as one thing are declared in `clusters/.md` and referenced with one `cluster:` entry; `dfd-authoring.md` Steps F4a and F5). +The core rules in `SKILL.md` apply across all modes: derive-never-ask (classification and per-edge `identifying` come from key shape, never a question), convention-is-derived (an entity's PK shape *is* its key-inherited-vs-orm-oriented style), adapt-to-the-user's-conventions (never invent naming), existence-rules-survive-the-key-style (a mandatory parent is asserted by key placement or by a documented `nullable: false` rule), subtype-independence (Subtype classification comes from cluster membership, never a direct ask), predicates-carry-business-meaning (push past "has many" to a domain verb), examples-always (every entity gets 2-3 `examples:` rows, every process gets `in`/`out` examples, generated by the skill and never skipped), `description:`-always (every entity, group, process, external, and store gets a one-line `description:` frontmatter field, plus every top-level flow folder's `flows//index.md`, generated by the skill, never skipped, and never named after the model's reserved `index_file` basename), capture-the-business-story (rules, constraints, and lifecycle go in the body with their source), labels-and-clusters-always (every flow entry carries a prose `label:`, and stores a process treats as one thing are declared in `clusters/.md` and referenced with one `cluster:` entry; `dfd-authoring.md` Steps F4a and F5), and one-change-note-per-edit-session (every edit session (not a `model` bootstrap) ends by writing `.ignatius/changes/.md`; see Change notes below). `references/interviewing.md` sets the conversational discipline every mode follows: one question at a time, explain the why, write files as answers land rather than only proposing YAML, infer from existing files before asking, and reflect on validator findings rather than blindly regenerating. -`discover` mode leads with verbs (find the business's processes), derives the nouns each verb requires, and writes nouns before verbs since a flow's `db:` `data:` fields point at entity columns that must already exist. Every candidate is run through five gates (Identify, Decide, Justify, Derive, Ground) translated into plain business questions; their underlying formal-logic names (excluded middle, law of identity, non-contradiction, sufficient reason, four causes, three-valued logic, falsifiable, syllogism, a priori, ontology) are banned from ever reaching the user. Gate 4, besides deriving the nouns a verb needs, also takes the user's own phrase for each piece of data as the flow entry's `label:`, and asks whether two or more same-direction stores are "one thing" worth a `clusters/.md` file. Gate 5's real instances seed both the entity's `examples:` (Step E7b) and the flow's `examples:` (Step F6), so one set of concrete values ends up in both places. `reverse-engineering.md` extracts the same shapes from a live system instead: a process's `SELECT`/`INSERT` targets become column-level flows, the DTO or parameter name in the code becomes the `label:`, and tables one transaction always writes together become a cluster. +`discover` mode leads with verbs (find the business's processes), derives the nouns each verb requires, and writes nouns before verbs since a flow's `db:` `data:` fields point at entity columns that must already exist. Every candidate is run through the gates (Identify, Decide, Justify, Derive, Ground) translated into plain business questions; their underlying formal-logic names (excluded middle, law of identity, non-contradiction, sufficient reason, four causes, three-valued logic, falsifiable, syllogism, a priori, ontology) are banned from ever reaching the user. Gate 4, besides deriving the nouns a verb needs, also takes the user's own phrase for each piece of data as the flow entry's `label:`, and asks whether two or more same-direction stores are "one thing" worth a `clusters/.md` file. Gate 5's real instances seed both the entity's `examples:` (Step E7b) and the flow's `examples:` (Step F6), so one set of concrete values ends up in both places. `reverse-engineering.md` extracts the same shapes from a live system instead: a process's `SELECT`/`INSERT` targets become column-level flows, the DTO or parameter name in the code becomes the `label:`, and tables one transaction always writes together become a cluster. ### Grouping and labeling flow stores -**Step F4a routes two or more same-direction stores on one process to one of three shapes, and only the middle one touches `clusters/`.** +**Step F4a routes two or more same-direction stores on one process to a shape, and only the middle one touches `clusters/`.** ```mermaid flowchart TD - A["F4 named 2+ db: stores, one direction, one process"] --> B{"one thing to the reader, or separate?"} - B -->|"basetype + subtypes"| C["subtype family — no clusters/ file; grouped from the basetype's subtypes: frontmatter"] - B -->|"business names the set"| D["clusters/<slug>.md + one cluster:<slug> entry"] - B -->|"no shared meaning"| E["separate db: entries, each with its own label:"] + A["F4: 2+ db: stores,
one direction,
one process"] --> B{"one thing to the
reader, or separate?"} + B -->|"basetype + subtypes"| C["subtype family:
no clusters/ file,
grouped by subtypes:"] + B -->|"business names the set"| D["clusters/<slug>.md
+ one cluster: entry"] + B -->|"no shared meaning"| E["separate db: entries,
each with own label:"] ``` A cluster file needs two or more members, all `db:` entities, each entity in at most one cluster (`flow.cluster_overlap`), every listed member existing (`flow.cluster_entity_unknown`); referencing it wrong fires `flow.unknown_cluster` (bad slug), `flow.cluster_member_unknown` (a `data:` map key not in the cluster's `entities:`), or `flow.cluster_no_members` (an empty `data:` map). Every `inputs:`/`outputs:` entry, `db:`, `ext:`, `kind:`, and `cluster:` alike, also carries a prose `label:` beside its `data:` (Step F5): two to four words in the user's own phrasing, no commas unless the chip should break onto a second line, identical wording for stores in one stack that carry the same thing. `label:` is never checked by `ignatius validate` — a missing one only shows up as a raw column-list chip on the diagram, caught by the flow self-check, not the linter. @@ -60,36 +58,56 @@ A cluster file needs two or more members, all `db:` entities, each entity in at ```mermaid flowchart TD - W["write file(s)"] --> V["ignatius validate model-root"] + W["write file(s)"] --> V["ignatius validate
model-root"] V --> P{"any findings?"} - P -->|yes| R["reflect: map ruleId to Q&A step"] - R --> Q["re-ask only that step, prefilled"] + P -->|yes| R["reflect: map ruleId
to Q&A step"] + R --> Q["re-ask only that step,
prefilled"] Q --> W - P -->|no| S["self-check: business context, predicates, examples"] - S --> Done["report: 0 findings, story captured"] + P -->|no| S["self-check: business
context, predicates,
examples"] + S --> Done["report: 0 findings,
story captured"] ``` -`references/verification.md` parses each stderr line (` `) against a hardcoded rule-reference table covering every `entity.*`/`parse.*`/`body.*`/`edge.*`/`cluster.*`/`config.*`/`index.*` ruleId, and a second table for `flow.*` rules that appear once a model has a `flows/` directory, including the five `flow.cluster_*` rules. The loop caps at 5 attempts; Class A warnings keep exit 0 but are still tracked to zero, not treated as done. A clean `validate` exit is necessary but not sufficient: the final self-check re-reads the entity body for captured business rules, checks that each predicate reads as a true sentence in both directions, and re-checks every `examples:` row key against `pk ∪ columns` by hand, because `entity.example_unknown_column` is live-server-only and `ignatius validate` never prints it. +`references/verification.md` parses each stderr line (` `) against a hardcoded rule-reference table covering every `entity.*`/`parse.*`/`body.*`/`edge.*`/`cluster.*`/`config.*`/`index.*` ruleId, and a second table for `flow.*` rules that appear once a model has a `flows/` directory, including the `flow.cluster_*` rules. The loop caps at 5 attempts; Class A warnings keep exit 0 but are still tracked to zero, not treated as done. A clean `validate` exit is necessary but not sufficient: the final self-check re-reads the entity body for captured business rules, checks that each predicate reads as a true sentence in both directions, and re-checks every `examples:` row key against `pk ∪ columns` by hand, because `entity.example_unknown_column` is live-server-only and `ignatius validate` never prints it. -When the write included flow files, the flow self-check adds three items validate cannot see on its own: every entry carries a `label:`, an ungrouped set of stores that should be a cluster but isn't declared as one, and a legibility count — the rows the default per-process view will draw, one per cluster or subtype family with two or more touched members plus one per loose store. More than about five in one direction flags a set still unnamed or a process that needs decomposing (Step F8); the check finishes by opening `ignatius serve ` at `#view=flow&dfd=` and confirming each process shows one labelled read stack and one labelled write stack. +When the write included flow files, the flow self-check adds items validate cannot see: every entry carries a `label:`, an ungrouped set of stores that should be a cluster but isn't declared as one, and a legibility count — the rows the default per-process view will draw, one per cluster or subtype family with two or more touched members plus one per loose store. More than about five in one direction flags a set still unnamed or a process that needs decomposing (Step F8); the check finishes by opening `ignatius serve ` at `#view=flow&dfd=` and confirming each process shows one labelled read stack and one labelled write stack. A second, separate gate covers router staleness: `ignatius validate --index ` recomputes each generated router's digest against current files and reports drift as `index.stale`, run only on a model that has been indexed at least once (`ignatius index` has run before); fixing it means running `ignatius index` again, then re-running `--index` to confirm clean. +### Change notes + +**A session that edits an existing model first makes sure a baseline exists, then ends with one change note; the skill never approves its own edits.** + +```mermaid +flowchart TD + A["first write to an
existing model"] --> B{"valid .ignatius/
baseline.json?"} + B -->|yes| E["edit files"] + B -->|no| C{"files in
.ignatius/changes/?"} + C -->|no| D["ignatius approve
model-root records
untouched files"] + D --> E + C -->|yes| S["stop: user approves,
or restores
baseline.json"] + E --> V["verification loop
is clean"] + V --> N["write .ignatius/
changes/slug.md"] + N --> U["user reviews in
ignatius serve, then
runs ignatius approve"] +``` + +`references/change-notes.md` defines the note: frontmatter `title:` (required), `entities:` (entity id to changed columns) and `flows:` (diagram id to changed node ids), both optional and `[]` when only the entity or diagram itself changed; the body tells the reviewer why. A valid baseline is JSON with `version: 1`, a `model` object, and a `flows` array. The note must name exactly what the session changed: a change no note covers shows as unexplained in the Changes panel, and a name with no matching change shows as named but unchanged. A relationship change names the child entity (the one holding the foreign key), and a subtype member added or removed is named itself, because each re-derives that entity's `classification`. A `model` bootstrap writes no note; a session that bootstraps and keeps writing (`discover`, or `flow` with no model yet) runs `ignatius approve` once after the bootstrap files exist, so its note covers only what came after. `references/verification.md` runs the note as the last step after the self-checks, and its clean report ends with "change note written". Every model the skill creates gets a [`.gitignore`](../../.gitignore) next to `ignatius.yml` holding `.ignatius/` (`references/model-flow.md` writes it in the bootstrap). + ## Where it lives | Path | Covers | |------|--------| -| [`skills/ignatius-modeling/SKILL.md`](../../skills/ignatius-modeling/SKILL.md) | Entry point: frontmatter, mode dispatch, the ten core rules, the reference-file index | +| [`skills/ignatius-modeling/SKILL.md`](../../skills/ignatius-modeling/SKILL.md) | Entry point: frontmatter, mode dispatch, the core rules (including the change-note rule), the reference-file index | | [`skills/ignatius-modeling/references/interviewing.md`](../../skills/ignatius-modeling/references/interviewing.md) | Q&A conduct rules applied to every mode | | [`skills/ignatius-modeling/references/entity-flow.md`](../../skills/ignatius-modeling/references/entity-flow.md) | `entity` mode, steps E0-E10 | -| [`skills/ignatius-modeling/references/model-flow.md`](../../skills/ignatius-modeling/references/model-flow.md) | `model` mode, steps M1-M8 | +| [`skills/ignatius-modeling/references/model-flow.md`](../../skills/ignatius-modeling/references/model-flow.md) | `model` mode, steps M1-M8; the bootstrap also writes the model [`.gitignore`](../../.gitignore) containing `.ignatius/` | | [`skills/ignatius-modeling/references/dfd-authoring.md`](../../skills/ignatius-modeling/references/dfd-authoring.md) | `flow` mode, steps F0-F9: the `db:`/`kind:` store fork (F4), the store-grouping step (F4a), the label step (F5), the descriptions step (F6a, including the flow folder's own `flows//index.md` description), sub-DFD decomposition (F8) | | [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) | Flow folder `index.md`, process, external, non-`db` store, and cluster file templates and worked examples | -| [`skills/ignatius-modeling/references/discover-flow.md`](../../skills/ignatius-modeling/references/discover-flow.md) | `discover` mode: the five gates, verbs-first shape, banned-term list | +| [`skills/ignatius-modeling/references/discover-flow.md`](../../skills/ignatius-modeling/references/discover-flow.md) | `discover` mode: the gates, verbs-first shape, banned-term list | | [`skills/ignatius-modeling/references/reverse-engineering.md`](../../skills/ignatius-modeling/references/reverse-engineering.md) | Extracting entities + flows from a live system, phases R0-R4 | | [`skills/ignatius-modeling/references/conventions.md`](../../skills/ignatius-modeling/references/conventions.md) | Reserved `index_file` basename, column types, classification/cardinality derivation tables | | [`skills/ignatius-modeling/references/templates.md`](../../skills/ignatius-modeling/references/templates.md) | Entity, group, and `ignatius.yml` templates | -| [`skills/ignatius-modeling/references/verification.md`](../../skills/ignatius-modeling/references/verification.md) | The `ignatius validate` loop, both rule-reference tables, self-check steps | +| [`skills/ignatius-modeling/references/verification.md`](../../skills/ignatius-modeling/references/verification.md) | The `ignatius validate` loop, the rule-reference tables, self-check steps, and the closing change-note step | +| [`skills/ignatius-modeling/references/change-notes.md`](../../skills/ignatius-modeling/references/change-notes.md) | When to write a change note, the baseline check before the first write, the note format and name table, the coverage table, the Changes-panel flags, and the model [`.gitignore`](../../.gitignore) | ## Constraints @@ -102,13 +120,18 @@ A second, separate gate covers router staleness: `ignatius validate --index ` entry's `data:` map keys a member the cluster file's `entities:` doesn't list, is empty, or the slug names no `clusters/.md` file | `flow.cluster_member_unknown` / `flow.cluster_no_members` / `flow.unknown_cluster` respectively (Step F4a); the first and third are Class B (block a clean exit), the second is Class A | | An `inputs:`/`outputs:` entry carries no `label:` | Not a validator finding at all — the diagram falls back to the raw column-list chip; only the flow self-check in `references/verification.md` catches it (Step F5) | +| `.ignatius/baseline.json` is absent or invalid and `.ignatius/changes/` holds files | The skill stops before writing: `ignatius approve` would delete notes the user has not read. The user approves after reading them or restores the baseline | +| The skill edits files, then runs `ignatius approve` | Never done: approving after editing accepts the skill's own edits, which is the user's act, and the Changes panel would show nothing to review | +| A relationship change is noted under the parent only, or a subtype cluster change under the basetype only | The child entity (relationship) or the member (subtype) shows as unexplained, since the change re-derives its `classification` | +| The skill edits or deletes `baseline.json`, or another session's note, or writes other files into `.ignatius/` | Forbidden by `references/change-notes.md` "What not to touch" | | Discover mode's internal gate names (excluded middle, law of identity, etc.) | Banned from ever reaching the user; a leaked formal-logic term means the question needs rewriting as plain business English | ## Coupling - `references/conventions.md`'s classification and cardinality derivation tables restate the parser's own key-shape derivation logic (parser domain); if that logic changes, these tables and `entity-flow.md` Step E3 go stale and the skill starts teaching wrong rules. - `references/verification.md`'s rule-reference table hardcodes every `ruleId`, severity, and Class (A/B) `ignatius validate` can emit, the exact stderr line format, and the `index.*`/`config.index_file_*` rows `validate --index` adds (validate domain); a new or renamed lint rule, or a CLI output-format change (CLI domain), requires a matching edit here or the fix-hint lookup and the parsing loop both break. -- The skill writes exclusively into the six-folder model-root layout (`data//`, `groups/`, `flows/`, `externals/`, `stores/`, `clusters/`, plus `ignatius.yml`) that [`docs/spec/folder-model.md`](../spec/folder-model.md), [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md), and the parser own; a folder-layout change forces another skill-wide reference rewrite, as it already has twice. +- The skill writes exclusively into the model-root layout (`data//`, `groups/`, `flows/`, `externals/`, `stores/`, `clusters/`, plus `ignatius.yml`) that [`docs/spec/folder-model.md`](../spec/folder-model.md), [`docs/spec/dfd-store-clusters.md`](../spec/dfd-store-clusters.md), and the parser own; a folder-layout change forces another skill-wide reference rewrite. - `references/model-flow.md` Step M4's theme key/default table (`background`, `surface`, `border`, `text`, `textMuted`, `edgeIdentifying`, `edgeReferential`, plus `spacing.nodeSep`) is copied verbatim from [`src/theme/theme-defaults.ts`](../../src/theme/theme-defaults.ts)'s `defaultTheme` (theme domain); a change to those defaults must be mirrored here. - The skill has no independent verification logic of its own: every write is checked exclusively by shelling out to `ignatius validate`, which runs the same `validateModel` logic ([`src/model/validate.ts`](../../src/model/validate.ts)) the test suite exercises directly (CLI/validate domains). `references/verification.md` flags one exception the CLI cannot catch (`entity.example_unknown_column`, live-server-only), coupling the skill's own self-check step to the frontend/server domain's in-app rule surface. - `references/dfd-authoring.md` and `references/flow-templates.md` share the DFD frontmatter shape (`inputs:`/`outputs:`/`examples:` endpoint tokens `db:`/`ext:`/`:`/`cluster:`, the per-entry `label:`, and the `cluster:` entry's entity-to-columns `data:` map) with the flows domain's own parser; a token-set or frontmatter-key change there must be mirrored in both skill files. +- `references/change-notes.md` restates the change-review contract owned by [`docs/spec/model-change-review.md`](../spec/model-change-review.md) (docs domain): the `.ignatius/` layout, the note frontmatter, the coverage rules, and the `ignatius approve` semantics (CLI and server domains). A change to the note format, the coverage table, or the baseline's JSON shape there must be mirrored in this reference; the user-facing side is [`docs/guides/change-review.md`](../guides/change-review.md) and the Change notes section of [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md). diff --git a/models/broken-demo/.gitignore b/models/broken-demo/.gitignore new file mode 100644 index 0000000..a6857b0 --- /dev/null +++ b/models/broken-demo/.gitignore @@ -0,0 +1 @@ +.ignatius/ diff --git a/models/key-inherited/.gitignore b/models/key-inherited/.gitignore new file mode 100644 index 0000000..a6857b0 --- /dev/null +++ b/models/key-inherited/.gitignore @@ -0,0 +1 @@ +.ignatius/ diff --git a/models/llm-memory-db-mssql/.gitignore b/models/llm-memory-db-mssql/.gitignore new file mode 100644 index 0000000..a6857b0 --- /dev/null +++ b/models/llm-memory-db-mssql/.gitignore @@ -0,0 +1 @@ +.ignatius/ diff --git a/models/orm-hybrid/.gitignore b/models/orm-hybrid/.gitignore new file mode 100644 index 0000000..a6857b0 --- /dev/null +++ b/models/orm-hybrid/.gitignore @@ -0,0 +1 @@ +.ignatius/ diff --git a/models/orm-pure/.gitignore b/models/orm-pure/.gitignore new file mode 100644 index 0000000..a6857b0 --- /dev/null +++ b/models/orm-pure/.gitignore @@ -0,0 +1 @@ +.ignatius/ diff --git a/package.json b/package.json index 3b57159..7173f06 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "build:bundle": "bun build src/app/index.html --outdir=dist/static --minify --target=browser --define:process.env.NODE_ENV='\"production\"'", "build:stable-names": "bun scripts/stable-names.ts", "build:cli": "bun run build:bundle && bun run build:stable-names && bun build --compile src/cli/cli.ts --outfile dist/ignatius", - "test": "for f in test/checks/*.ts; do echo \"=== $f ===\" && bun \"$f\" || exit 1; done", + "test": "bun scripts/clear-review-state.ts && for f in test/checks/*.ts; do echo \"=== $f ===\" && bun \"$f\" || exit 1; done", "typecheck": "bunx tsc --noEmit" }, "dependencies": { diff --git a/scripts/clear-review-state.ts b/scripts/clear-review-state.ts new file mode 100644 index 0000000..ffb17dc --- /dev/null +++ b/scripts/clear-review-state.ts @@ -0,0 +1,9 @@ +// Checks serve repo models in place, and serve leaves a baseline behind; a stale one would show later checks a Changes panel. +import { readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +for (const parent of ['models', 'test/fixtures']) { + for (const model of readdirSync(parent, { withFileTypes: true }).filter(entry => entry.isDirectory())) { + rmSync(join(parent, model.name, '.ignatius'), { recursive: true, force: true }); + } +} diff --git a/skills/ignatius-modeling/SKILL.md b/skills/ignatius-modeling/SKILL.md index 1f0c0ef..42839fd 100644 --- a/skills/ignatius-modeling/SKILL.md +++ b/skills/ignatius-modeling/SKILL.md @@ -33,6 +33,7 @@ After writing any file, always run the verification loop in `references/verifica - `description:` always, in every mode: every entity, group, process, external, and store carries a one-line `description:` in frontmatter, and so does every top-level flow folder, in the frontmatter of `flows//index.md` — never skipped, never offered as optional. It is not documentation; it is the payload of the generated router tables and of the app's flow index and breadcrumb menus, the line a reader uses to decide whether to open the file or the diagram. Generate it yourself from real business context, show it, and let the user adjust. Never name a file `index.md` (or whatever `index_file` resolves to in `ignatius.yml`) for an entity — that name is reserved for the generated router, whose frontmatter is the one place a flow folder describes itself. - Capture the business story, not just the schema: business rules, constraints, lifecycle, and the *why* behind structural complexity go in the body with their source and justification. Treat an offhand "billing won't allow payments under $5" as a documentable rule, not chatter. - Labels and clusters always, in every mode that writes flows: every `inputs:`/`outputs:` entry carries a short prose `label:` beside its `data:` (the diagram shows the label; the column list stays the validated contract), and two or more stores a process reads or writes as one thing are declared once in `clusters/.md` and referenced with one `cluster:` entry. This is what keeps a diagram readable at the size real models reach; neither is optional or an afterthought. Steps F4a and F5 in `references/dfd-authoring.md`. +- Every edit session ends with one change note: in any mode that writes or changes model files, finish by writing one `.ignatius/changes/.md` next to `ignatius.yml`. Its frontmatter names each changed entity with its changed columns (`entities:`) and each changed diagram with its changed nodes (`flows:`); its body tells the reviewer why. The user reviews it in `ignatius serve` and accepts it with `ignatius approve`. Before the first write to an existing model with no valid `/.ignatius/baseline.json`, run `ignatius approve ` to record the untouched starting point, but only when `.ignatius/changes/` holds no files; if it holds notes, stop and ask the user to approve after reading them or to restore the baseline. Never run it after editing. Every model the skill creates gets a `/.gitignore` containing `.ignatius/`. Format, coverage, and the bootstrap exception: `references/change-notes.md`. ## Reference files @@ -46,5 +47,6 @@ Load only the file for the step you are on: - `references/discover-flow.md` — the Socratic five-gate method (generates entities + flows). - `references/reverse-engineering.md` — extract entities + flows from an existing system (IDEF1X spirit). - `references/verification.md` — the `ignatius validate` loop and the rule reference tables. +- `references/change-notes.md` — the baseline step before the first write, the change note every edit session ends with, and the model `.gitignore`. - `references/templates.md` — entity, group, and `ignatius.yml` templates. - `references/conventions.md` — column types and the classification/cardinality derivation tables. diff --git a/skills/ignatius-modeling/references/change-notes.md b/skills/ignatius-modeling/references/change-notes.md new file mode 100644 index 0000000..6bce733 --- /dev/null +++ b/skills/ignatius-modeling/references/change-notes.md @@ -0,0 +1,136 @@ +## Change notes + +`ignatius serve` diffs the model against a baseline the user last approved and marks every added, changed, and removed entity, column, relationship, and flow node. A change note tells the reviewer why those changes happened. The user reads the notes in the Changes panel, then accepts the whole state with `ignatius approve` (or the panel's Approve button), which rewrites the baseline and deletes the notes. + +### When to write one + +- One note per edit session: any `entity`, `flow`, or `discover` run that wrote or changed model files. One note covers every file the session touched. +- Write it last, after the verification loop is clean, so it describes the final files. +- If the user asks for more edits in the same session, update this session's note rather than adding a second. +- A `model` bootstrap writes no note: it creates the model from nothing, so there is nothing to diff against. +- A session that bootstraps and then keeps writing (`discover`, or `flow` with no model yet) runs `ignatius approve ` after the bootstrap files are written and before its first entity or flow write. Its note then covers only what came after the bootstrap. + +### Before the first write + +Check `/.ignatius/` before the session's first write to an existing model: + +| `baseline.json` | `changes/` | Do | +|-----------------|------------|----| +| present and valid (JSON with `version: 1`, a `model` object, and a `flows` array) | any | nothing; edit as usual | +| absent, or not valid | empty or absent | run `ignatius approve `, then edit | +| absent, or not valid | holds files | stop; do not write | + +With no baseline and no notes, nothing is pending, so `approve` accepts nothing; it records the untouched files as the starting point the diff measures from. + +When notes exist without a baseline, `approve` would delete notes the user has not reviewed. Tell the user, and ask them either to read the notes and run `ignatius approve` themselves, or to restore `baseline.json`. Continue once one of them is done. + +Never run `ignatius approve` after editing: that accepts your edits, which is the user's act. + +**Why:** without a baseline, the first `serve` takes the edited files as the starting point, so the session's changes never show for review. + +### What not to touch + +- Never edit or delete `.ignatius/baseline.json`. +- Never delete or rewrite a note from an earlier session. `ignatius approve` deletes notes; that is the user's act. +- Never write anything else into `.ignatius/`. + +### Where it goes + +`/.ignatius/changes/.md`, where `` is the folder holding `ignatius.yml`. The slug is a short kebab-case summary (`split-invoice-lines.md`). If that file already exists, pick another slug. + +### Format + +```markdown +--- +title: +entities: + : [, ] +flows: + : [, ] +--- + + +``` + +`title` is required. `entities` and `flows` are both optional; omit the one the session did not touch. An entity or diagram whose list would be empty takes `[]`. Frontmatter that fails to parse turns the note into a note error: the panel shows the error and the note covers nothing. + +Use the names the files already use: + +| Thing | Name to write | +|-------|---------------| +| Entity | its `entity:` id | +| Column | the key under `columns:` (or the `pk` member) | +| Diagram | its folder name under `flows/`; a sub-DFD's id is the owning process's id (its folder name) | +| Process | its file name without `.md` | +| External | the name after `ext:` (the `externals/.md` file name) | +| Store | the name after the prefix: the entity id for `db:Invoice`, the slug for `queue:invoice-events` | + +### What a note must list + +A note covers a change when it names it as below. Name everything the session changed, and only that. + +| Change | Covered when the note | +|--------|-----------------------| +| Entity added or removed | names the entity (columns optional) | +| Entity field changed (`pk`, `ak`, `classification`, `group`, `description`, `examples`, `singleton`, subtypes) | names the entity | +| Column added, removed, or changed (`type`, `nullable`, `default`, `desc`) | names the entity and lists the column | +| Relationship added, removed, or changed | names the source or the target entity | +| Body text only | names the entity | +| Diagram added or removed | names the diagram | +| Process, external, or store added, removed, or changed | names the diagram and lists the node | + +A rename is a removal plus an addition: list both names. + +Externals and stores live once in `externals/.md` and `stores/.md`, but each diagram that references one shows it as its own node. Editing that file changes the node in every such diagram, so list the node under each of them. Adding a diagram's first reference to an external or store adds the node there; removing its last reference removes it. + +When a relationship changes, name the child entity (the one holding the foreign key) even if the note already names the parent. + +**Why:** a relationship change can re-derive the child's `classification`: adding a relationship whose foreign key is in the child's primary key turns an Independent entity into a Dependent one. That is a change to the child entity, and only the child's name covers it. + +When a subtype cluster gains or loses a member, name that member even if the note already names the basetype. + +**Why:** an entity listed in any basetype's `subtypes:` is classified as a Subtype, so adding or removing it re-derives its `classification`. That is a change to the member entity, and only the member's name covers it. + +### What the user sees when it is wrong + +| Flag in the Changes panel | Cause | Fix | +|---------------------------|-------|-----| +| Unexplained | a change no note covers | add the entity, column, diagram, or node to the note | +| Named but unchanged | a name in the note with no matching change | remove it, or make the change it promised | +| Note errors | frontmatter that does not parse, a missing `title`, or an `entities`/`flows` value that is not a list of names | fix the frontmatter | + +Before finishing, check the note against the coverage table above: every change the session made is named, and every name matches a change. When `ignatius serve` is running, `curl -s localhost:/api/model | jq .diff` returns the review: `items[].status` is `unexplained` for an uncovered change, `claims` lists names with no matching change, and `noteErrors` lists notes that failed to parse. + +### The body + +Write for the reviewer deciding whether to approve: the business reason, where the rule came from (who said it, which document), alternatives you rejected, and anything they should check. Do not list the files or columns; the panel already shows them. + +### Example + +The session added `line_total` to `Invoice`, created `InvoiceLine`, and changed the `Issue-Invoice` process to publish to a new `queue:invoice-events` store. + +```markdown +--- +title: Split invoice lines out of Invoice +entities: + Invoice: [line_total] + InvoiceLine: [] +flows: + order-to-cash: [Issue-Invoice, invoice-events] +--- + +Billing needs one row per charged item so partial credits can target a line +(the finance lead, in this session). `InvoiceLine` inherits the invoice key; +`Invoice.line_total` keeps the summed amount the statement prints. Issue-Invoice +now publishes to `invoice-events` so the ledger can post lines without polling. +``` + +### The model `.gitignore` + +`.ignatius/` is local review state and is never committed. Every model folder carries a `.gitignore` next to `ignatius.yml` with this line: + +``` +.ignatius/ +``` + +Create the file when it is absent; append the line when the file exists without it; never add it twice. diff --git a/skills/ignatius-modeling/references/dfd-authoring.md b/skills/ignatius-modeling/references/dfd-authoring.md index 2cbf48a..84b734c 100644 --- a/skills/ignatius-modeling/references/dfd-authoring.md +++ b/skills/ignatius-modeling/references/dfd-authoring.md @@ -66,7 +66,8 @@ If no model root is evident from context, ask: > "What is the path to your model root (directory containing `ignatius.yml`)?" If no `ignatius.yml` exists anywhere, run `model` mode first (`references/model-flow.md`) to -bootstrap one — flows live inside a model. +bootstrap one — flows live inside a model. Then run `ignatius approve` before the first flow +write (`references/change-notes.md`). Then read the existing entities (the `data//*.md` files) and any existing `flows/`. You need the entity ids and their columns so `db:` stores and flow data-labels resolve against real diff --git a/skills/ignatius-modeling/references/discover-flow.md b/skills/ignatius-modeling/references/discover-flow.md index 4c01901..4b9ac63 100644 --- a/skills/ignatius-modeling/references/discover-flow.md +++ b/skills/ignatius-modeling/references/discover-flow.md @@ -20,6 +20,7 @@ existing system — a database or code — I can read, or are we working from ho **If no model exists yet** (no `ignatius.yml` anywhere), run `model` mode first (`references/model-flow.md`) to bootstrap the skeleton — discovery writes into a model. +Then run `ignatius approve` before the first entity or flow write (`references/change-notes.md`). Greenfield is the *normal* case for this mode, not an edge case. ### How you talk (read this first) diff --git a/skills/ignatius-modeling/references/model-flow.md b/skills/ignatius-modeling/references/model-flow.md index 092d375..8a6d600 100644 --- a/skills/ignatius-modeling/references/model-flow.md +++ b/skills/ignatius-modeling/references/model-flow.md @@ -79,8 +79,9 @@ Write: 2. `groups/.md` for each group 3. `data//` directory for each group (entities live under `data/`) 4. Entity file if requested in M7 +5. `.gitignore` next to `ignatius.yml`, containing the line `.ignatius/` (the local change-review state `ignatius serve` writes; see `references/change-notes.md`) -Then run the verification loop in `references/verification.md`. +Then run the verification loop in `references/verification.md`. A bootstrap creates the model from nothing, so it writes no change note. If the session goes on to write entities or flows, run `ignatius approve ` first (`references/change-notes.md`). --- diff --git a/skills/ignatius-modeling/references/reverse-engineering.md b/skills/ignatius-modeling/references/reverse-engineering.md index 0845165..9a84a6e 100644 --- a/skills/ignatius-modeling/references/reverse-engineering.md +++ b/skills/ignatius-modeling/references/reverse-engineering.md @@ -26,7 +26,9 @@ does, and you do not guess. Two rules govern the whole exercise: ### Phase R0 — Inventory the sources Establish what you can read, and locate the model root (or run `model` mode first if none -exists). Ask what is available and prefer the most authoritative source: +exists; after that bootstrap, run `ignatius approve` before the first entity write, as +`references/change-notes.md` "When to write one" describes). Ask what is available and prefer +the most authoritative source: | Source | Yields | How to read it | |--------|--------|----------------| diff --git a/skills/ignatius-modeling/references/verification.md b/skills/ignatius-modeling/references/verification.md index 398c54d..936e31b 100644 --- a/skills/ignatius-modeling/references/verification.md +++ b/skills/ignatius-modeling/references/verification.md @@ -98,7 +98,11 @@ The linter validates structure; it cannot see whether the business story was cap 6. Cluster hygiene: every `clusters/*.md` has two or more members that all exist, no entity sits in two files, and every set of stores a process treats as one thing is either a cluster file referenced with the `cluster:` token or a subtype family (Step F4a). The five `flow.cluster_*` rules catch the file errors; only you can catch the set nobody named. 7. Legibility, per process and direction: count the rows the default view will draw (one per cluster or subtype family with two or more touched members, one per loose store). More than about five is a missing cluster or a process to decompose (Step F8). Then look: `ignatius serve `, open `#view=flow&dfd=` (the per-process view is the default), and confirm each process shows one read stack above and one write stack below with a label on every chip. Capture a screenshot when you have a tool that can; otherwise ask the user to look and say what they see. -Only when structure is clean **and** these hold: report "Verified clean — 0 findings, business context captured, sample rows consistent, every flow labelled and stacked legibly." +**Change note (runs last, after the checks above hold):** + +Write this session's `.ignatius/changes/.md`, or update it if the session already wrote one, following `references/change-notes.md`. List every entity, column, diagram, and node the session changed, and nothing else. Skip it when the session only bootstrapped a model. Then tell the user to review the changes in `ignatius serve` (the Changes panel lists the note, anything unexplained, and anything named but unchanged) and to accept them with `ignatius approve ` or the panel's Approve button. + +Only when structure is clean **and** these hold: report "Verified clean — 0 findings, business context captured, sample rows consistent, every flow labelled and stacked legibly, change note written." --- diff --git a/src/app/App.tsx b/src/app/App.tsx index d9bb05c..868a34f 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -24,6 +24,8 @@ import type { SearchBarHandle } from './components/ui/SearchBar'; import { FlowSearchResults } from './components/flow/FlowSearchResults'; import { EntityModal } from './components/entity/EntityModal'; import { FindingsPanel } from './components/findings/FindingsPanel'; +import { ChangesPanel } from './components/changes/ChangesPanel'; +import { entityChanges } from './logic/change-marks'; import { LegendModal } from './views/flow/LegendModal'; import { FlowsView } from './views/flow/FlowsView'; import type { FlowsViewHandle } from './views/flow/FlowsView'; @@ -57,6 +59,8 @@ export function App() { findings, flowDiagrams, flowFindings, + diff, + refreshDiff, layoutKeyRef, bannerDismissed, setBannerDismissed, @@ -119,6 +123,14 @@ export function App() { // Lifted from GraphView via onCyReadyChange — shell gates ZoomControl/minimap rendering. const [cyReady, setCyReady] = useState(false); const [panelCollapsed, setPanelCollapsed] = useState(false); + const [changesCollapsed, setChangesCollapsed] = useState(false); + const [changeOverlay, setChangeOverlay] = useState(true); + const shownChanges = changeOverlay ? diff : null; + const shownEntityChanges = useMemo(() => (shownChanges ? entityChanges(shownChanges) : null), [shownChanges]); + // The graph and flow handles only work once their view has mounted, so a + // Changes-panel click from another view parks its target until the switch. + const pendingChangeEntityRef = useRef(null); + const pendingChangeDiagramRef = useRef(null); // Dictionary state — keep-mounted so search text + scroll survive view switches. const [dictSearchText, setDictSearchText] = useState(''); const [dictNavOpen, setDictNavOpen] = useState(false); @@ -244,6 +256,40 @@ export function App() { }); }, [view]); + useEffect(() => { + const entity = pendingChangeEntityRef.current; + if (view === 'graph' && entity !== null) { + pendingChangeEntityRef.current = null; + graphViewRef.current?.panelNavigate(entity); + } + const diagram = pendingChangeDiagramRef.current; + if (view === 'flow' && diagram !== null) { + pendingChangeDiagramRef.current = null; + flowsViewRef.current?.selectDiagramById(diagram); + } + }, [view, changeOverlay]); + + function handleChangeEntity(id: string) { + const ghostHidden = !changeOverlay && (diff?.removedNodes.some(node => node.id === id) ?? false); + if (view === 'graph' && !ghostHidden) { + graphViewRef.current?.panelNavigate(id); + return; + } + // The pending-target effect runs after GraphView has re-added the ghosts. + if (ghostHidden) setChangeOverlay(true); + pendingChangeEntityRef.current = id; + setView('graph'); + } + + function handleChangeDiagram(diagramPath: string) { + if (view === 'flow') { + flowsViewRef.current?.selectDiagramById(diagramPath); + return; + } + pendingChangeDiagramRef.current = diagramPath; + setView('flow'); + } + // Re-apply Cytoscape styles when theme mode changes — delegate to GraphView handle // which owns cy, svg, and marker refs. Guard on view so it only fires for graph. useEffect(() => { @@ -594,6 +640,7 @@ export function App() { minimapOpen={minimapOpen} initialLayoutMode={layoutMode} searchMatches={graphSearchMatches} + changeReview={shownChanges} onCyReadyChange={setCyReady} onZoomPercentChange={setZoomPercent} onLayoutModeChange={setLayoutMode} @@ -639,6 +686,8 @@ export function App() { onToggleNav={() => setDictNavOpen(prev => !prev)} onOpenEntity={id => openEntityById(id)} themeMode={themeMode} + changeReview={shownChanges} + entityChanges={shownEntityChanges} /> ) : view === 'dict' ? ( @@ -662,6 +711,7 @@ export function App() { onOpenEntity={(id, fromFlow) => openEntityByIdRef.current(id, fromFlow)} onZoomPercentChange={setFlowZoomPercent} searchTokens={flowSearchTokens} + changeReview={shownChanges} flowView={flowView} collapseLevel={collapseLevel} /> @@ -787,6 +837,7 @@ export function App() { nodeById={modelIndex?.nodeById} nodeIdSet={modelIndex?.nodeIdSet} entityErrors={appErrorsByEntityId.get(selected.id) ?? []} + changes={shownEntityChanges?.get(selected.id)} onClose={() => { setShowEntityModal(false); setEntityModalOpenedFromFlow(false); closeEntity(); }} onNavigate={(id) => { if (entityModalOpenedFromFlow) { @@ -842,21 +893,37 @@ export function App() { )} )} - setPanelCollapsed(true)} - onExpand={() => setPanelCollapsed(false)} - onNavigate={(id) => graphViewRef.current?.panelNavigate(id)} - /> +
+ {diff && ( + setChangesCollapsed(true)} + onExpand={() => setChangesCollapsed(false)} + onNavigateEntity={handleChangeEntity} + onOpenDiagram={handleChangeDiagram} + onRefresh={refreshDiff} + /> + )} + setPanelCollapsed(true)} + onExpand={() => setPanelCollapsed(false)} + onNavigate={(id) => graphViewRef.current?.panelNavigate(id)} + /> +
); } diff --git a/src/app/components/changes/ChangeBadge.tsx b/src/app/components/changes/ChangeBadge.tsx new file mode 100644 index 0000000..d374f66 --- /dev/null +++ b/src/app/components/changes/ChangeBadge.tsx @@ -0,0 +1,44 @@ +import type { ChangeMark, ChangeSeverity } from '../../logic/change-marks'; + +export function ChangeBadge({ mark }: { mark: ChangeMark | undefined }) { + if (!mark) return null; + return ( + + {mark.change}{mark.unexplained ? ' · unexplained' : ''} + + ); +} + +/** A static dot colored by the worst change inside a flow index or level menu row's diagram, + * or, given the row's own `mark`, a dot that pulses in its kind color. */ +export function ChangeDot({ severity, mark = null }: { severity: ChangeSeverity | null; mark?: ChangeMark | null }) { + if (mark) { + const label = `${mark.change === 'added' ? 'Added' : 'Changed'}${mark.unexplained ? ', not covered by a change note' : ''}`; + return ( + + ); + } + if (!severity) return null; + const label = severity === 'unexplained' ? 'Contains changes no note covers' : 'Contains changes'; + return ( + + ); +} diff --git a/src/app/components/changes/ChangesPanel.tsx b/src/app/components/changes/ChangesPanel.tsx new file mode 100644 index 0000000..05d2db3 --- /dev/null +++ b/src/app/components/changes/ChangesPanel.tsx @@ -0,0 +1,231 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChangeClaim, ChangeItem, ChangeReview, ReviewedItem } from '../../../model/change-review'; +import type { FlowDiagram } from '../../../flows/flow-parse'; +import { SYNTHETIC_DIAGRAM_IDS } from '../../../flows/flow-derive-levels'; + +// The SSE refresh normally clears "Approving…"; this covers a missed event. +const APPROVE_REFRESH_MS = 5_000; + +function fieldList(fields: string[] | undefined): string { + return fields && fields.length > 0 ? ` (${fields.join(', ')})` : ''; +} + +type FlowItem = Extract; + +/** Diagram titles and the node's display label, falling back to ids for what the current flows no longer hold. */ +function flowNames(item: FlowItem, diagrams: FlowDiagram[] | null): { diagram: string; node?: string } { + const titles: string[] = []; + let level = diagrams ?? []; + let found: FlowDiagram | undefined; + for (const id of item.diagramPath.split('/')) { + found = level.find(d => d.id === id); + if (!SYNTHETIC_DIAGRAM_IDS.has(id)) titles.push(found?.title ?? id); + level = found?.subDfds ?? []; + } + const diagram = titles.join(' / '); + const node = item.node; + if (node === undefined) return { diagram }; + const label = item.nodeKind === 'process' ? found?.processes.find(p => p.id === node)?.label + : item.nodeKind === 'external' ? found?.externals.find(e => e.id === node)?.label + : found?.storeRefs.find(s => s.name === node && s.kind === item.storeKind)?.displayName; + return { diagram, node: label ?? node }; +} + +/** The label before the change kind, so a row can style the kind on its own. */ +function labelSubject(item: ChangeItem, diagrams: FlowDiagram[] | null): string { + switch (item.scope) { + case 'entity': + return `${item.entity} — entity`; + case 'column': + return `${item.entity}.${item.column} — column`; + case 'relationship': + return `${item.source} → ${item.target} (${Object.keys(item.on).join(', ')}) — relationship`; + case 'doc': + return `${item.entity} — body`; + case 'flow': { + const names = flowNames(item, diagrams); + return names.node === undefined + ? `${names.diagram} — diagram` + : `${names.diagram} / ${names.node} — ${item.nodeKind ?? 'node'}`; + } + } +} + +export function itemLabel(item: ChangeItem, diagrams: FlowDiagram[] | null): string { + return `${labelSubject(item, diagrams)} ${item.kind}${fieldList('fields' in item ? item.fields : undefined)}`; +} + +function claimLabel(claim: ChangeClaim): string { + const target = 'entity' in claim + ? (claim.column === undefined ? claim.entity : `${claim.entity}.${claim.column}`) + : (claim.node === undefined ? claim.diagram : `${claim.diagram} / ${claim.node}`); + return `${target} — named in ${claim.note}, unchanged`; +} + +/** The change review as notes, unexplained items, unchanged claims, and note errors; renders nothing when empty. */ +export function ChangesPanel({ + review, + flowDiagrams, + overlay, + onOverlayChange, + collapsed, + onCollapse, + onExpand, + onNavigateEntity, + onOpenDiagram, + onRefresh, +}: { + review: ChangeReview; + flowDiagrams: FlowDiagram[] | null; + overlay: boolean; + onOverlayChange: (on: boolean) => void; + collapsed: boolean; + onCollapse: () => void; + onExpand: () => void; + onNavigateEntity: (entityId: string) => void; + /** Takes a `diagramPath`, which `FlowsView.selectDiagramById` resolves. */ + onOpenDiagram: (diagramPath: string) => void; + onRefresh: () => Promise; +}) { + const [approving, setApproving] = useState(false); + const [approveError, setApproveError] = useState(null); + const refreshTimer = useRef | undefined>(undefined); + + useEffect(() => { + clearTimeout(refreshTimer.current); + setApproving(false); + setApproveError(null); + }, [review]); + + useEffect(() => () => clearTimeout(refreshTimer.current), []); + + const { items, claims, notes, noteErrors } = review; + if (items.length + claims.length + notes.length + noteErrors.length === 0) return null; + + async function approve() { + setApproving(true); + setApproveError(null); + try { + const res = await fetch('/api/approve', { method: 'POST' }); + if (!res.ok) { + setApproving(false); + setApproveError(`Approve failed: ${res.status} ${res.statusText}`); + return; + } + refreshTimer.current = setTimeout(() => { + onRefresh() + .catch(err => setApproveError(`Refresh failed: ${err instanceof Error ? err.message : String(err)}`)) + .finally(() => setApproving(false)); + }, APPROVE_REFRESH_MS); + } catch (err) { + setApproving(false); + setApproveError(`Approve failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + function openItem(item: ReviewedItem) { + if (item.scope === 'flow') onOpenDiagram(item.diagramPath); + else onNavigateEntity(item.scope === 'relationship' ? item.source : item.entity); + } + + function itemRow(item: ReviewedItem) { + let key = itemLabel(item, null); + if (item.scope === 'flow') key = [item.diagramPath, item.nodeKind, item.storeKind, item.node, item.kind].join('|'); + else if (item.scope === 'relationship') key = `${key}|${JSON.stringify(item.on)}`; + const className = `changes-panel-item changes-panel-item--${item.kind}`; + // A removed diagram has nowhere to open. + const openable = !(item.scope === 'flow' && item.node === undefined && item.kind === 'removed'); + const content = item.kind === 'removed' + ? ( + <> +