From 85a6a598dc2ab6453cfe9148e80ce6ebeb7ba87d Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Sun, 6 Sep 2026 02:49:37 -0400 Subject: [PATCH] feat: add model index routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every organizing folder of a model root gets a generated index.md router: a table of the folder's children with name, kind, description, and a relative link, so a reader descends root → group → entity through small tables instead of globbing the tree. Per-file hashes roll up into folder digests and a root digest; `validate --index` reports drift as a Class B finding. `index --agents` adds an in-folder AGENTS.md, a CLAUDE.md shim, and SKILL.md so the folder orients any agent that opens it, written only inside the model root. Routers mirror the filesystem, not declared groups: `group:` is declarative and a model may keep entities flat, so paths come from the parser's discovered sourcePath. The generator owns only the bytes inside its own tags, so hand-authored blocks and an existing CLAUDE.md survive regeneration. A tag is a region boundary only at column 0 ending its line; anywhere else it is text, since a delimiter can be accidental and a position cannot. SKILL.md frontmatter is the one exception to region-only writes, because the harness needs it to discover the skill. `description:` is a new top-level field on all five file kinds and is the router's payload. The reserved index_file name is skipped by every scan. models/llm-memory-db-mssql is the fully realized exemplar. Claude-Session: https://claude.ai/code/session_019fHbm44mjjSyPgGaFh81T9 --- .claude/rules/wiki/cli.md | 16 + .claude/rules/wiki/docs.md | 18 + .claude/rules/wiki/flow-view.md | 23 + .claude/rules/wiki/flows.md | 27 + .claude/rules/wiki/frontend.md | 23 + .claude/rules/wiki/generators.md | 15 + .claude/rules/wiki/parser.md | 26 + .claude/rules/wiki/router.md | 16 + .claude/rules/wiki/scripts.md | 14 + .claude/rules/wiki/server.md | 12 + .claude/rules/wiki/skill.md | 23 + .claude/rules/wiki/theme.md | 18 + .claude/rules/wiki/validate.md | 16 + README.md | 4 +- docs/design/model-index-routing.md | 476 ++++++++++++ docs/guides/commands.md | 22 +- docs/guides/flows.md | 6 +- docs/guides/folder-format.md | 53 ++ docs/guides/getting-started.md | 2 +- docs/guides/modeling-skill.md | 2 + docs/guides/validation.md | 24 + docs/spec/model-index-routing.md | 270 +++++++ docs/wiki/CLAUDE.md | 3 +- docs/wiki/cli.md | 127 +++- docs/wiki/docs.md | 272 ++++--- docs/wiki/feature-map.md | 17 +- docs/wiki/flows.md | 124 +++- docs/wiki/index.md | 27 +- docs/wiki/parser.md | 128 +++- docs/wiki/router.md | 101 +++ docs/wiki/scan.md | 299 +++++--- docs/wiki/skill.md | 121 +-- docs/wiki/validate.md | 113 ++- models/key-inherited/data/catalog/index.md | 14 + models/key-inherited/data/identity/index.md | 20 + models/key-inherited/data/index.md | 16 + models/key-inherited/data/reference/index.md | 15 + .../key-inherited/data/transactional/index.md | 23 + models/key-inherited/externals/index.md | 13 + models/key-inherited/flows/index.md | 14 + .../order-to-cash/Create-Sales-Order/index.md | 14 + .../flows/order-to-cash/index.md | 16 + models/key-inherited/flows/refund/index.md | 13 + models/key-inherited/groups/index.md | 16 + models/key-inherited/index.md | 11 + models/key-inherited/stores/index.md | 13 + models/llm-memory-db-mssql/AGENTS.md | 26 + models/llm-memory-db-mssql/CLAUDE.md | 8 + models/llm-memory-db-mssql/SKILL.md | 15 + .../data/artifact/Artifact.md | 1 + .../data/artifact/Milestone_Artifact.md | 1 + .../data/artifact/Task_Artifact.md | 1 + .../data/artifact/index.md | 15 + .../data/audit/Artifact_StateTransition.md | 1 + .../data/audit/Memory_StateTransition.md | 1 + .../data/audit/Milestone_StateTransition.md | 1 + .../data/audit/Note_StateTransition.md | 1 + .../data/audit/StateTransition.md | 1 + .../data/audit/Task_StateTransition.md | 1 + .../llm-memory-db-mssql/data/audit/index.md | 18 + .../data/identity/Agent.md | 1 + .../data/identity/Project.md | 1 + .../data/identity/index.md | 14 + models/llm-memory-db-mssql/data/index.md | 20 + .../data/knowledge/Milestone_Note.md | 1 + .../data/knowledge/Note.md | 1 + .../data/knowledge/Project_Note.md | 1 + .../data/knowledge/Task_Note.md | 1 + .../data/knowledge/index.md | 16 + .../llm-memory-db-mssql/data/memory/Memory.md | 1 + .../data/memory/Project_Memory.md | 1 + .../data/memory/Related_Memory.md | 1 + .../llm-memory-db-mssql/data/memory/index.md | 15 + .../data/planning/Milestone.md | 1 + .../data/planning/Project_Milestone.md | 1 + .../llm-memory-db-mssql/data/planning/Task.md | 1 + .../data/planning/Task_Dependency.md | 1 + .../data/planning/index.md | 16 + .../data/reference/DependencyVerb.md | 1 + .../data/reference/MemoryCategory.md | 1 + .../data/reference/MemoryDomain.md | 1 + .../data/reference/MemoryRelationVerb.md | 1 + .../data/reference/NoteType.md | 1 + .../data/reference/RelevanceStatus.md | 1 + .../data/reference/RelevanceStatus_Allowed.md | 1 + .../data/reference/StateTransitionType.md | 1 + .../data/reference/TrackingStatus.md | 1 + .../data/reference/TrackingStatus_Allowed.md | 1 + .../data/reference/index.md | 22 + .../data/tagging/Artifact_Tag.md | 1 + .../data/tagging/Memory_Tag.md | 1 + .../data/tagging/Milestone_Tag.md | 1 + .../data/tagging/Project_Tag.md | 1 + .../llm-memory-db-mssql/data/tagging/Tag.md | 1 + .../data/tagging/Task_Tag.md | 1 + .../llm-memory-db-mssql/data/tagging/index.md | 18 + .../externals/LLM-Agent.md | 1 + models/llm-memory-db-mssql/externals/index.md | 13 + .../flows/agent-project-setup/Create-Agent.md | 1 + .../agent-project-setup/Create-Project.md | 1 + .../flows/agent-project-setup/Delete-Agent.md | 1 + .../agent-project-setup/Update-Project.md | 1 + .../flows/agent-project-setup/index.md | 16 + .../Attach-Artifact-To-Milestone.md | 1 + .../Attach-Artifact-To-Task.md | 1 + .../artifact-management/Create-Artifact.md | 1 + .../Set-Artifact-Relevance.md | 1 + .../flows/artifact-management/index.md | 16 + models/llm-memory-db-mssql/flows/index.md | 18 + .../Attach-Memory-to-Project.md | 1 + .../memory-lifecycle/Consolidate-Memory.md | 1 + .../flows/memory-lifecycle/Create-Memory.md | 1 + .../Filter-Memories-by-Tags.md | 1 + .../flows/memory-lifecycle/Relate-Memories.md | 1 + .../memory-lifecycle/Set-Memory-Relevance.md | 1 + .../flows/memory-lifecycle/index.md | 18 + .../note-capture/Create-Milestone-Note.md | 1 + .../flows/note-capture/Create-Project-Note.md | 1 + .../flows/note-capture/Create-Task-Note.md | 1 + .../flows/note-capture/Set-Note-Relevance.md | 1 + .../flows/note-capture/index.md | 16 + .../Attach-Tag-to-Memory.md | 1 + .../Bulk-Attach-Tag-to-Memories.md | 1 + .../flows/tag-administration/Create-Tag.md | 1 + .../flows/tag-administration/Merge-Tag.md | 1 + .../flows/tag-administration/index.md | 16 + .../work-planning/Add-Task-Dependency.md | 1 + .../flows/work-planning/Close-Milestone.md | 1 + .../flows/work-planning/Create-Milestone.md | 1 + .../flows/work-planning/Create-Task.md | 1 + .../flows/work-planning/Set-Task-Tracking.md | 1 + .../flows/work-planning/index.md | 17 + models/llm-memory-db-mssql/groups/artifact.md | 1 + models/llm-memory-db-mssql/groups/audit.md | 1 + models/llm-memory-db-mssql/groups/identity.md | 1 + models/llm-memory-db-mssql/groups/index.md | 20 + .../llm-memory-db-mssql/groups/knowledge.md | 1 + models/llm-memory-db-mssql/groups/memory.md | 1 + models/llm-memory-db-mssql/groups/planning.md | 1 + .../llm-memory-db-mssql/groups/reference.md | 1 + models/llm-memory-db-mssql/groups/tagging.md | 1 + models/llm-memory-db-mssql/index.md | 13 + models/llm-memory-db-mssql/stores/index.md | 12 + models/orm-hybrid/data/catalog/index.md | 14 + models/orm-hybrid/data/identity/index.md | 20 + models/orm-hybrid/data/index.md | 16 + models/orm-hybrid/data/reference/index.md | 15 + models/orm-hybrid/data/transactional/index.md | 23 + models/orm-hybrid/externals/index.md | 12 + models/orm-hybrid/flows/index.md | 12 + models/orm-hybrid/groups/index.md | 16 + models/orm-hybrid/index.md | 11 + models/orm-hybrid/stores/index.md | 12 + models/orm-pure/data/catalog/index.md | 14 + models/orm-pure/data/identity/index.md | 20 + models/orm-pure/data/index.md | 16 + models/orm-pure/data/reference/index.md | 15 + models/orm-pure/data/transactional/index.md | 23 + models/orm-pure/externals/index.md | 12 + models/orm-pure/flows/index.md | 12 + models/orm-pure/groups/index.md | 16 + models/orm-pure/index.md | 11 + models/orm-pure/stores/index.md | 12 + skills/ignatius-modeling/SKILL.md | 1 + .../references/conventions.md | 4 + .../references/dfd-authoring.md | 4 + .../references/entity-flow.md | 6 + .../references/flow-templates.md | 3 + .../ignatius-modeling/references/templates.md | 2 + .../references/verification.md | 19 + src/cli/cli.ts | 103 ++- src/flows/flow-parse.ts | 66 +- src/model/parse.ts | 81 +- src/model/validate.ts | 127 ++++ src/router/agents.ts | 191 +++++ src/router/build.ts | 359 +++++++++ src/router/detect.ts | 33 + src/router/fingerprint.ts | 33 + src/router/region.ts | 134 ++++ src/router/write.ts | 48 ++ test/checks/test-description-field.ts | 156 ++++ test/checks/test-index-config.ts | 201 +++++ test/checks/test-router-agents.ts | 267 +++++++ test/checks/test-router-fingerprint.ts | 81 ++ test/checks/test-router-index.ts | 424 +++++++++++ test/checks/test-router-region.ts | 689 ++++++++++++++++++ test/checks/test-validate-index.ts | 293 ++++++++ 187 files changed, 6285 insertions(+), 430 deletions(-) create mode 100644 .claude/rules/wiki/cli.md create mode 100644 .claude/rules/wiki/docs.md create mode 100644 .claude/rules/wiki/flow-view.md create mode 100644 .claude/rules/wiki/flows.md create mode 100644 .claude/rules/wiki/frontend.md create mode 100644 .claude/rules/wiki/generators.md create mode 100644 .claude/rules/wiki/parser.md create mode 100644 .claude/rules/wiki/router.md create mode 100644 .claude/rules/wiki/scripts.md create mode 100644 .claude/rules/wiki/server.md create mode 100644 .claude/rules/wiki/skill.md create mode 100644 .claude/rules/wiki/theme.md create mode 100644 .claude/rules/wiki/validate.md create mode 100644 docs/design/model-index-routing.md create mode 100644 docs/spec/model-index-routing.md create mode 100644 docs/wiki/router.md create mode 100644 models/key-inherited/data/catalog/index.md create mode 100644 models/key-inherited/data/identity/index.md create mode 100644 models/key-inherited/data/index.md create mode 100644 models/key-inherited/data/reference/index.md create mode 100644 models/key-inherited/data/transactional/index.md create mode 100644 models/key-inherited/externals/index.md create mode 100644 models/key-inherited/flows/index.md create mode 100644 models/key-inherited/flows/order-to-cash/Create-Sales-Order/index.md create mode 100644 models/key-inherited/flows/order-to-cash/index.md create mode 100644 models/key-inherited/flows/refund/index.md create mode 100644 models/key-inherited/groups/index.md create mode 100644 models/key-inherited/index.md create mode 100644 models/key-inherited/stores/index.md create mode 100644 models/llm-memory-db-mssql/AGENTS.md create mode 100644 models/llm-memory-db-mssql/CLAUDE.md create mode 100644 models/llm-memory-db-mssql/SKILL.md create mode 100644 models/llm-memory-db-mssql/data/artifact/index.md create mode 100644 models/llm-memory-db-mssql/data/audit/index.md create mode 100644 models/llm-memory-db-mssql/data/identity/index.md create mode 100644 models/llm-memory-db-mssql/data/index.md create mode 100644 models/llm-memory-db-mssql/data/knowledge/index.md create mode 100644 models/llm-memory-db-mssql/data/memory/index.md create mode 100644 models/llm-memory-db-mssql/data/planning/index.md create mode 100644 models/llm-memory-db-mssql/data/reference/index.md create mode 100644 models/llm-memory-db-mssql/data/tagging/index.md create mode 100644 models/llm-memory-db-mssql/externals/index.md create mode 100644 models/llm-memory-db-mssql/flows/agent-project-setup/index.md create mode 100644 models/llm-memory-db-mssql/flows/artifact-management/index.md create mode 100644 models/llm-memory-db-mssql/flows/index.md create mode 100644 models/llm-memory-db-mssql/flows/memory-lifecycle/index.md create mode 100644 models/llm-memory-db-mssql/flows/note-capture/index.md create mode 100644 models/llm-memory-db-mssql/flows/tag-administration/index.md create mode 100644 models/llm-memory-db-mssql/flows/work-planning/index.md create mode 100644 models/llm-memory-db-mssql/groups/index.md create mode 100644 models/llm-memory-db-mssql/index.md create mode 100644 models/llm-memory-db-mssql/stores/index.md create mode 100644 models/orm-hybrid/data/catalog/index.md create mode 100644 models/orm-hybrid/data/identity/index.md create mode 100644 models/orm-hybrid/data/index.md create mode 100644 models/orm-hybrid/data/reference/index.md create mode 100644 models/orm-hybrid/data/transactional/index.md create mode 100644 models/orm-hybrid/externals/index.md create mode 100644 models/orm-hybrid/flows/index.md create mode 100644 models/orm-hybrid/groups/index.md create mode 100644 models/orm-hybrid/index.md create mode 100644 models/orm-hybrid/stores/index.md create mode 100644 models/orm-pure/data/catalog/index.md create mode 100644 models/orm-pure/data/identity/index.md create mode 100644 models/orm-pure/data/index.md create mode 100644 models/orm-pure/data/reference/index.md create mode 100644 models/orm-pure/data/transactional/index.md create mode 100644 models/orm-pure/externals/index.md create mode 100644 models/orm-pure/flows/index.md create mode 100644 models/orm-pure/groups/index.md create mode 100644 models/orm-pure/index.md create mode 100644 models/orm-pure/stores/index.md create mode 100644 src/router/agents.ts create mode 100644 src/router/build.ts create mode 100644 src/router/detect.ts create mode 100644 src/router/fingerprint.ts create mode 100644 src/router/region.ts create mode 100644 src/router/write.ts create mode 100644 test/checks/test-description-field.ts create mode 100644 test/checks/test-index-config.ts create mode 100644 test/checks/test-router-agents.ts create mode 100644 test/checks/test-router-fingerprint.ts create mode 100644 test/checks/test-router-index.ts create mode 100644 test/checks/test-router-region.ts create mode 100644 test/checks/test-validate-index.ts diff --git a/.claude/rules/wiki/cli.md b/.claude/rules/wiki/cli.md new file mode 100644 index 0000000..e57ed82 --- /dev/null +++ b/.claude/rules/wiki/cli.md @@ -0,0 +1,16 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +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 + version reporting + +Map: + - docs/wiki/cli.md +Contracts: + - docs/spec/cli-and-outputs.md +Designs: + - docs/design/cli-and-outputs.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/docs.md b/.claude/rules/wiki/docs.md new file mode 100644 index 0000000..e2dfa07 --- /dev/null +++ b/.claude/rules/wiki/docs.md @@ -0,0 +1,18 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "docs/design/**" + - "docs/guides/**" + - "docs/research/**" + - "docs/spec/**" + - "docs/glossary.md" +--- + +Domain: docs. Design docs, user guides, research notes, and implementation-contract specs — 76 markdown files plus `docs/glossary.md` across `docs/design/`, `docs/guides/`, `docs/research/`, `docs/spec/` + +Map: + - docs/wiki/docs.md +Related: + - docs/wiki/feature-map.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/flow-view.md b/.claude/rules/wiki/flow-view.md new file mode 100644 index 0000000..3599a6b --- /dev/null +++ b/.claude/rules/wiki/flow-view.md @@ -0,0 +1,23 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/flow-view/**" +--- + +Domain: flow-view. ELK-driven DFD layout (5-band partitioning, orthogonal edge routing); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming + +Map: + - docs/wiki/flow-view.md +Contracts: + - docs/spec/dfd-edge-hover-data.md + - docs/spec/dfd-overhaul.md + - docs/spec/graph-flow-search.md + - docs/spec/viewer-ux-polish.md +Designs: + - docs/design/dfd-edge-hover-data.md + - docs/design/dfd-overhaul.md + - docs/design/graph-flow-search.md +Research: + - docs/research/dfd-layout-and-leveling.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/flows.md b/.claude/rules/wiki/flows.md new file mode 100644 index 0000000..bb9d929 --- /dev/null +++ b/.claude/rules/wiki/flows.md @@ -0,0 +1,27 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/flows/**" +--- + +Domain: flows. SSADM data flow diagrams: `parseFlows` (recursive sub-DFDs + canonical Yourdon leveling via `deriveLevels`), `validateFlows` (12 `flow.*` rules), `buildFlowLayoutKeys`, usage indexing; role-split node model + +Map: + - docs/wiki/flows.md +Contracts: + - docs/spec/dfd-nesting-depth.md + - docs/spec/dfd-overhaul.md + - docs/spec/folder-model.md + - docs/spec/process-flows.md +Designs: + - docs/design/dfd-nesting-depth.md + - docs/design/dfd-overhaul.md + - docs/design/folder-model.md + - docs/design/process-flows.md +Guides: + - docs/guides/flows.md +Research: + - docs/research/dfd-layout-and-leveling.md + - docs/research/ssadm-dfd-rules.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/frontend.md b/.claude/rules/wiki/frontend.md new file mode 100644 index 0000000..21c2757 --- /dev/null +++ b/.claude/rules/wiki/frontend.md @@ -0,0 +1,23 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/app/**" +--- + +Domain: frontend. React 19 unified SPA (Graph/Dictionary/Flows views); shell (`App.tsx`) owns state + composition; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath + +Map: + - docs/wiki/frontend.md +Designs: + - docs/design/app-tsx-decomposition.md + - docs/design/branding.md + - docs/design/dd-spotlight-grid.md + - docs/design/dict-navigation.md + - docs/design/graph-flow-search.md + - docs/design/graph-position-persistence.md + - docs/design/key-inheritance-lineage.md + - docs/design/unified-app.md + - docs/design/viewer-ux-polish.md + - docs/design/wiki-entity-links.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/generators.md b/.claude/rules/wiki/generators.md new file mode 100644 index 0000000..31c9ba6 --- /dev/null +++ b/.claude/rules/wiki/generators.md @@ -0,0 +1,15 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/generators/**" +--- + +Domain: generators. Unified static HTML export via `generateApp` (single file — graph + dict + flows); sole static generator + +Map: + - docs/wiki/generators.md +Guides: + - docs/guides/building-from-source.md + - docs/guides/commands.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/parser.md b/.claude/rules/wiki/parser.md new file mode 100644 index 0000000..b15dadf --- /dev/null +++ b/.claude/rules/wiki/parser.md @@ -0,0 +1,26 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/model/parse.ts" + - "src/model/wikilink.ts" + - "src/model/model-index.ts" +--- + +Domain: parser. `ignatius.yml` config loading → ParseResult: {model, globalErrors}; nodes, edges, cardinality + classification derivation; wiki-link inline rule + two-pass body rendering; `buildModelIndex` — 13 O(1) lookup maps built once per Model + +Map: + - docs/wiki/parser.md +Contracts: + - docs/spec/bidirectional-predicates.md + - docs/spec/derive-classification.md + - docs/spec/folder-model.md + - docs/spec/wiki-entity-links.md +Designs: + - docs/design/bidirectional-predicates.md + - docs/design/folder-model.md + - docs/design/wiki-entity-links.md +Guides: + - docs/guides/folder-format.md + - docs/guides/predicates.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/router.md b/.claude/rules/wiki/router.md new file mode 100644 index 0000000..bc3e283 --- /dev/null +++ b/.claude/rules/wiki/router.md @@ -0,0 +1,16 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/router/**" +--- + +Domain: router. Generates per-folder `index.md` routers with rolled-up SHA-256 digests, in-folder agent guidance (`AGENTS.md`, `CLAUDE.md` shim, `SKILL.md`), and a position-based `` region parser; backs `ignatius index` and `validate --index` + +Map: + - docs/wiki/router.md +Contracts: + - docs/spec/model-index-routing.md +Designs: + - docs/design/model-index-routing.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/scripts.md b/.claude/rules/wiki/scripts.md new file mode 100644 index 0000000..df5093e --- /dev/null +++ b/.claude/rules/wiki/scripts.md @@ -0,0 +1,14 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +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 + +Map: + - docs/wiki/scripts.md +Guides: + - docs/guides/building-from-source.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/server.md b/.claude/rules/wiki/server.md new file mode 100644 index 0000000..9645aa8 --- /dev/null +++ b/.claude/rules/wiki/server.md @@ -0,0 +1,12 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/server/**" +--- + +Domain: server. Bun.serve with `/api/model` + `/api/flow` + `/events` SSE + fs.watch live-reload; `/dict` and `/flow` redirect to unified SPA hash routes; `/flow-dict` redirects to `/#view=dict` + +Map: + - docs/wiki/server.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/skill.md b/.claude/rules/wiki/skill.md new file mode 100644 index 0000000..a169d48 --- /dev/null +++ b/.claude/rules/wiki/skill.md @@ -0,0 +1,23 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +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` + +Map: + - docs/wiki/skill.md +Contracts: + - docs/spec/folder-model.md + - docs/spec/ignatius-modeling-skill.md + - docs/spec/noorm-flow-discovery.md +Designs: + - docs/design/ignatius-modeling-skill.md + - docs/design/noorm-flow-discovery.md +Guides: + - docs/guides/flows.md + - docs/guides/getting-started.md + - docs/guides/modeling-skill.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/theme.md b/.claude/rules/wiki/theme.md new file mode 100644 index 0000000..45db526 --- /dev/null +++ b/.claude/rules/wiki/theme.md @@ -0,0 +1,18 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/theme/**" +--- + +Domain: theme. ThemeConfig + Branding types, default palettes, flow-kind colors, dark/light merging + +Map: + - docs/wiki/theme.md +Contracts: + - docs/spec/branding.md +Designs: + - docs/design/branding.md +Guides: + - docs/guides/themes-and-branding.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/.claude/rules/wiki/validate.md b/.claude/rules/wiki/validate.md new file mode 100644 index 0000000..b1451b5 --- /dev/null +++ b/.claude/rules/wiki/validate.md @@ -0,0 +1,16 @@ +--- +# generated by /refresh-wiki — do not hand-edit; regenerated every refresh +paths: + - "src/model/validate.ts" +--- + +Domain: validate. Pure model validator: 33 RuleIds across 8 prefixes (parse/config/entity/body/edge/cluster/index/flow), two severity tiers (A=warn, B=omit); `validateIndex` reuses `buildRouters` to detect router drift + +Map: + - docs/wiki/validate.md +Contracts: + - docs/spec/schema-lint-and-error-ux.md +Designs: + - docs/design/schema-lint-and-error-ux.md + +Consult the map before changing behavior here. Behavior changes stale the pages above. Renames or removals stale mentions beyond them: grep the old name across docs/ before shipping. diff --git a/README.md b/README.md index dbda4b0..477be62 100644 --- a/README.md +++ b/README.md @@ -55,8 +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`, and model discovery | -| [The folder format](docs/guides/folder-format.md) | `ignatius.yml`, entity files, columns, relationships, groups | +| [Commands](docs/guides/commands.md) | `serve`, `export`, `validate`, `index`, and model discovery | +| [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 | | [Process flows](docs/guides/flows.md) | Data flow diagrams: processes, externals, stores, sub-DFDs | diff --git a/docs/design/model-index-routing.md b/docs/design/model-index-routing.md new file mode 100644 index 0000000..f098895 --- /dev/null +++ b/docs/design/model-index-routing.md @@ -0,0 +1,476 @@ +# Model index routing: generated per-folder routers, rolled-up digests, in-folder agent guidance + + +## Problem + + +A model root is a navigable structure to the SPA and a flat pile of markdown to +everyone else. `models/key-inherited/` holds 26 entity files across four groups, +two flows with nested sub-processes, and three registries. A reader landing on +the root, whether a human in a markdown viewer or an agent with a Read tool, has +two options: glob everything into context, or guess from filenames. + +Filenames carry the entity id and nothing else. `PaymentAllocation.md` does not +say which group it belongs to, whether it is a base type or a subtype, or whether +opening it will answer the question at hand. So the cheap move is to read all 26 +files, which is the move the model's own structure should make unnecessary. + +The information a router needs already exists. `entity:` and `group:` are declared +in frontmatter; classification (Independent, Dependent, Subtype, Reference) is +derived from key shape at parse time; `label:` is declared on groups and `process:` +and `number:` on flow processes. Nothing assembles any of it into a table, and +nothing exposes a model to an agent harness without a hand-written pointer that +goes stale the first time an entity is added. + + +## Goals / Non-goals + + +**Goals** + +- Every organizing folder carries a generated router file listing its children by + name, kind, description, and a clickable relative link. + +- Routers nest, so a reader descends root → section → group → entity by reading + one small table per hop rather than loading the subtree. + +- Per-file hashes roll up into a folder digest, and folder digests roll up to the + root, so one hash proves the whole router is current and `validate` gates CI. + +- Generated content sits inside a delimited region, so hand-authored steering + rules can live in the same file and survive regeneration. + +- Routers are clickable in a plain markdown viewer: GitHub, VS Code preview, + Obsidian, mkdocs, VitePress. + +- The folder orients any agent that opens a file in it, with no install step, and + can be turned into an invocable skill by the user in one command. + +- Nothing is written outside the model root, so the folder stays portable and the + generator never touches a consuming repo's files. + +**Non-goals** + +- Replacing the SPA. `ignatius serve` stays the rich view; this is the flat-file + view for readers who do not have it running. + +- A new link syntax. `[[Entity]]` keeps its current meaning and its current + consumer. + +- A search index, an embedding store, or anything that ranks. The router routes. + +- Config for which folders get routers, which columns the table carries, or how + links are written. See Resolved questions. + +- Migration tooling. Routers are generated, so there is nothing to migrate. + + +## Approaches + + +Three decisions, each with its own row set. + +| # | Decision | Approach | Sketch | Cost | Risk | +|---|----------|----------|--------|------|------| +| A1 | Router placement | Single root manifest | One `index.md` at the model root listing all 26 entities | low | Flat. A 26-row table is the thing progressive disclosure exists to avoid, and it grows with the model | +| A2 | Router placement | Per-folder router | One router per organizing folder, nested to match the tree | med | Reserved filename collides with the `data/` entity scan | +| A3 | Router placement | JSON sidecar | `.ignatius-index.json` per folder | low | Not clickable in a viewer, not readable by an agent without a tool call per hop | +| B1 | Agent guidance | In-folder `SKILL.md` alone | The model root carries a skill file | low | Inert in place. Claude Code discovers skills only under `.claude/skills/`, so nothing reads it until a user installs it | +| B2 | Agent guidance | Adapter written into the consuming repo | A verb writes `.claude/skills//SKILL.md` at the repo root | med | The generator writes outside the model root: it must guess the repo layout, it can overwrite a hand-written `CLAUDE.md`, and the model folder stops being portable | +| B3 | Agent guidance | In-folder `SKILL.md` + harness-detected `CLAUDE.md` / `AGENTS.md` | Every guidance file lives in the model folder; installing it as a live skill is the user's move | low | Two files with overlapping content, resolved by making `CLAUDE.md` a one-line import of `AGENTS.md` | +| C1 | Generated-content delimiting | Whole file generated | Regeneration overwrites the file | low | Nowhere to put hand-authored rules. Anything a human adds is destroyed on the next run | +| C2 | Generated-content delimiting | XML managed region | Generator owns bytes between its own tags; everything outside survives | low | CommonMark requires a blank line inside the tags or the table renders as literal text | + + +## Recommendation + + +**A2 + B3 + C2.** + +A2 because the router's value is the hop it lets a reader skip, and a flat +manifest has no hops. A3 loses the only two readers that matter: a markdown +viewer cannot click a JSON file, and an agent reading JSON pays a tool call per +level to learn what a rendered table gives it for free. + +B3 because the model folder should explain itself to whoever opens it, and +because the generator has no business writing outside the model root. A verb that +drops files into someone's `.claude/` guesses the repo layout and can overwrite a +hand-written `CLAUDE.md`. A verb confined to the model folder cannot, and the +folder stays portable: copy it, submodule it, publish it, and its guidance travels +with it. + +In-folder guidance works passively because Claude Code already looks there. The +memory documentation is explicit: "Claude also discovers `CLAUDE.md` and +`CLAUDE.local.md` files in subdirectories under your current working directory. +Instead of loading them at launch, they are included when Claude reads files in +those subdirectories" (`code.claude.com/docs/en/memory`). An agent that greps into +`data/transactional/` or opens `Party.md` gets the model's guidance with no +install step and no user action. + +`AGENTS.md` is the canonical guide; `CLAUDE.md` is a shim that `@`-imports it. +That is the same page's prescribed pattern, because "Claude Code reads +`CLAUDE.md`, not `AGENTS.md`." One source of truth, no duplicated content, and a +non-Claude harness reads the file it already looks for. + +`SKILL.md` stays in the folder as the invocation surface, inert until a user +wants it. Its `description` is a trigger the harness matches against a request, +which `AGENTS.md` has no contract for, so it earns a separate file rather than a +section. Turning it on is the user's decision and a one-liner: + +```bash +ln -s ../../server/docs/data-model .claude/skills/alkane-model +``` + +C2 because the steering rules a model accumulates ("money columns are `decimal`, +never `float`") belong next to the table they constrain, and a generator that +owns the whole file cannot host them. + + +### The shape + + +Generated files marked `gen`. Every other file is hand-authored. Nothing is +written outside the model root, so the folder is self-contained and movable. + +``` +models/key-inherited/ +├── ignatius.yml fixed name, model-root marker, declares index_file +├── AGENTS.md gen canonical agent guide: how to walk this model +├── CLAUDE.md gen `@AGENTS.md` + Claude-specific lines; auto-loads +├── SKILL.md gen invocation surface; inert until symlinked +├── index.md gen depth 0 · root router +│ +├── groups/ +│ ├── index.md gen depth 1 +│ └── identity.md · catalog.md · transactional.md · reference.md +│ +├── data/ +│ ├── index.md gen depth 1 · fan-out by group +│ ├── identity/ +│ │ ├── index.md gen depth 2 · fan-out by entity +│ │ └── Party.md · Person.md · Identity.md · SSN.md · … +│ ├── catalog/ (index.md gen) +│ ├── transactional/ (index.md gen) +│ └── reference/ (index.md gen) +│ +├── flows/ +│ ├── index.md gen depth 1 +│ └── order-to-cash/ +│ ├── index.md gen depth 2 +│ ├── Create-Sales-Order.md #1 +│ └── Create-Sales-Order/ +│ ├── index.md gen depth 3 · sub-DFDs nest arbitrarily deep +│ └── Validate-Customer.md · Record-Order.md +│ +├── externals/index.md gen +└── stores/index.md gen +``` + + +### Two audiences, two mechanisms, one folder + + +The guidance files carry no model content. Each is a pointer: what this folder +is, how to walk it, and the conventions needed to read what it points at. Adding +entities does not change them beyond a count and a digest. + +| | Passive | Active | +|---|---|---| +| **Reader** | An agent already working in the repo that opens a file here | A user who wants to ask about the model by name | +| **File** | `CLAUDE.md`, or `AGENTS.md` for other harnesses | `SKILL.md` | +| **Trigger** | Reading any file in the folder | The harness matches a request against the skill `description` | +| **Install step** | None | `ln -s`, a copy, or a package | + +The passive path is what makes the folder self-explaining. An agent told "add a +refund column" that opens `data/transactional/Payment.md` gets the key-style +convention and the `[[Entity]]` body rule automatically, instead of inferring +them from one file and getting them wrong. + +The active path is the user's, deliberately. Installing a skill is a choice about +that machine and that repo, and the three ways to make it are all one step: a +symlink into `.claude/skills/`, a copy, or shipping the folder as a package that +lands somewhere already discovered. + + +### Descent is uniform at every depth, so a reader needs one rule + + +Each router carries one `↑` breadcrumb and N rows. A row whose `Kind` is `folder` +leads to another router; any other `Kind` is a leaf with real content. + +``` +index.md → Which section? Data / Flows / Groups / … +└─ data/index.md → Which group? identity / transactional / … + └─ data/identity/index.md → Which entity? Party / Person / Identity / … + └─ data/identity/Party.md LEAF: pk, columns, examples, relationships +``` + +The `Description` column is the routing decision. It is what lets a reader skip a +subtree without opening it, which is the entire mechanism. That is why +`description:` is worth adding to frontmatter: it is not documentation, it is the +router's payload. + +A rendered router: + +```markdown + + +↑ [Data](../index.md) · [Key-Inherited](../../index.md) + + + + + +| Name | Kind | Description | Go | +|------|------|-------------|-----| +| Party | Independent | Root actor; every customer resolves to one. | [Party](Party.md) | +| Person | Subtype | Natural-person subtype of Party. | [Person](Person.md) | +| Identity | Dependent | A government ID owned by a Party. | [Identity](Identity.md) | + + + + +Hand-authored. The generator never touches this block. + +- A new entity here carries `party_id` as its first PK column. +- The government-ID subtypes are exclusive. Adding a fifth means amending the + cluster in [[Identity]], not dropping a file here. + +``` + +Tag attributes are the machine-readable surface: an agent reads `digest`, `count`, +and `depth` without parsing YAML or the table. Unknown tags are stripped by GitHub +and VS Code, so a human sees a clean heading and table while a model reading raw +sees the structure. + + +### Two link dialects, split by job + + +Mixing them produces dead links in one consumer or the other. + +| | Entity cross-reference | Router navigation | +|---|---|---| +| **Form** | `[[Party]]` | `[Party](Party.md)` | +| **Written in** | Entity and process bodies, by hand | Generated router tables | +| **Consumed by** | The SPA, via `src/model/wikilink.ts` | Markdown viewers, static site generators, agents | +| **Fails when misused** | A markdown link in a body renders as a dead relative href | A wikilink in a router is literal text outside Obsidian | + +Router links always name the file. `[Identity](identity/)` shows a directory +listing on GitHub and resolves to nothing in VS Code preview; +`[Identity](identity/index.md)` clicks through in every target viewer. The `.md` +extension stays, because mkdocs and VitePress rewrite it and GitHub requires it. + + +### Fingerprints roll up, so one hash gates the tree + + +Each row carries its file's hash. A folder's digest hashes its row hashes. A +parent router stores only the child folder's digest, never the child's rows. + +``` +Party.md changes + → its row hash changes in data/identity/index.md + → that folder's digest changes + → data/index.md's row for identity changes + → the root digest changes +``` + +Verification is a `validate` concern, not an `index` one, so the router is a CI +gate rather than a file that quietly rots. + + +### CLI surface: writing and checking are different verbs + + +| Command | Writes | Purpose | +|---------|--------|---------| +| `ignatius index` | routers | Regenerate every `index.md` from the current files | +| `ignatius index --agents` | routers + guidance | The above, plus `AGENTS.md`, the `CLAUDE.md` shim, and `SKILL.md` | +| `ignatius validate --index` | nothing | Recompute digests and report drift as a finding | + +Verification belongs on `validate` for three reasons. It already parses the +model, so the check costs a hash pass rather than a second parse. It already owns +the findings pipeline and the Class A/B exit-code machinery, so a stale router +becomes an `index.stale` RuleId alongside the other 27 rather than a bespoke exit +path. And it keeps CI to one command instead of two. + +Making it an explicit `--index` flag rather than default behavior means a plain +`ignatius validate` never pays a full-tree hash. CI asks for both. + +`--agents` is additive rather than exclusive: guidance files name the router +filename and carry the root digest, so writing them against stale routers would +ship a pointer to something that no longer exists. The flag decides *whether* +guidance files are written; `harness:` in `ignatius.yml` decides *which*. +Generating routers is inert, and writing a `CLAUDE.md` injects itself into an +agent's context, so the more invasive of the two is the one behind a flag. + + +### The router filename is config, because the target renderer decides it + + +`ignatius.yml` gains one key: + +```yaml +name: Key-Inherited +index_file: index.md # default. README.md for GitHub, _index.md for Hugo +harness: auto # auto | claude | agents | both +``` + +`harness:` decides which guidance files `--agents` writes. `auto` emits +`AGENTS.md` and `SKILL.md` always, and adds the `CLAUDE.md` shim when a +`.claude/` directory or a `CLAUDE.md` exists anywhere up the tree. There is no +`none` value: not passing `--agents` already expresses that. + +Flat and snake_case to match the existing `flow_rules:` and `sort_key:`. It takes +the whole filename rather than a stem, so the value is what lands on +disk and Hugo's `_index.md` needs no prefix-gluing special case. + +`index.md` is the default because mkdocs, VitePress, and docsify resolve +`folder/` to it, which yields clean folder-level URLs on a rendered site. +`README.md` wins only on GitHub folder browsing. + +Config makes the entry point unknowable to a cold reader, which `ignatius.yml` +already solves: it is the fixed-name model-root marker that discovery finds +(`docs/design/ignatius-project-config.md`). Resolution is +`find ignatius.yml` → `read index_file` → `open /`, and below +the root nothing resolves because every link is written out in full. Only the +first hop consults config, and the guidance files name the router filename +outright, so an agent that arrives through them skips even that. + + +### `index.md` under `data/` is currently a hard parse error + + +`parseModels` globs `data/**/*.md` (`src/model/parse.ts:234`) and raises +`parse.missing_id` as an **error** for any file lacking an `entity:` key +(`src/model/parse.ts:288`). A router dropped into `data/identity/` fails +`ignatius validate` today. + +The flow scans need the same skip. `src/flows/flow-parse.ts` reads every `*.md` +in a flow folder, in `externals/`, and in `stores/` as a definition, so a router +in any of them raises `parse.invalid_yaml`, which is Class B; once +`validate --index` exists, that would fail every indexed model. The skip lands in +all five scans. + +`ignatius.yml` is read at `src/model/parse.ts:173`, before the scan, so the +configured filename is in scope at the skip site with no reordering and no second +read. The skip compares the basename, not a suffix, or an entity file named +`Reindex.md` gets silently dropped. + +Four validation rules come with the key: + +| Rule | Condition | Severity | +|------|-----------|----------| +| `config.index_file_ext` | Value does not end in `.md` | error | +| `config.index_file_path` | Value contains `/` or `..` | error | +| `config.index_file_entity` | A file matching `index_file` under `data/` declares `entity:` | error, with a message naming the reserved filename rather than the generic `parse.missing_id` | +| `index.orphaned` | A file matching a previously-used router name still exists after `index_file` changed | warning from `validate --index` | + +The orphan rule is the one non-obvious failure mode of making the name +configurable. Switching `index.md` to `README.md` leaves the old routers on disk +carrying valid-looking links and a digest nothing will ever check again. + + +## Resolved questions + + +- **What the guidance files contain.** Name, description, the root router + filename, the model's key-style convention, the `[[Entity]]` body rule, and the + digest at generation. No entities, no columns, no relationships. Content in a + guidance file is content that goes stale outside the digest's reach. + +- **A generated `CLAUDE.md` never clobbers a hand-written one.** Its content goes + inside the same `` managed region the routers use, appended when + the file exists and created when it does not. This matters most when a model + root is also a repo root. C2 buys the protection; no separate mechanism. + +- **The write contract: the CLI owns the bytes between its own tags, and nothing + else.** This holds for every generated file, routers and guidance alike. A run + replaces each `` region in place and leaves every byte outside it + untouched, so prose, headings, hand-authored rules, and another tool's content + all survive. A file with no region gets one appended; a file that does not + exist is created. + + The one exception is forced from outside: `SKILL.md` carries YAML frontmatter + (`name`, `description`) because the harness requires it to discover the skill + at all, so the generator owns that block too. It owns nothing else in the file. + Routers need no exception, since tag attributes already carry `digest`, + `count`, and `depth`. + +- **Guidance files stay short.** Claude Code targets "under 200 lines per + CLAUDE.md file" and adherence drops as they grow. A pointer plus conventions + fits well under that; anything approaching it means model content leaked in. + +- **The authoring skill is in scope, not a follow-up.** `skills/ignatius-modeling/` + teaches the file format, so it drifts the moment `description:` becomes a + field an author should fill and `index.md` becomes a name they must not use. + Its templates, its authoring steps, and its verification loop all move with + this change, per the surface-consistency rule in `CLAUDE.md`. + +- **Symlinked skill directories work. Tested, not assumed.** A relative symlink + from `.claude/skills/` to a directory elsewhere in the repo is both + discovered and invocable: a probe skill reached that way appeared in the + available-skills list and returned its body verbatim when invoked. So the + one-line install in the Recommendation is the supported path, and no + `ignatius install-skill` verb is needed. + +- **No maximum descent depth.** Routers nest as deep as the model does, and + flows nest arbitrarily. `depth` in the tag reports which level a router sits + at; it is not a cap and nothing enforces one. The `Kind` column already tells + a reader when it has reached a leaf, which is the only signal the walk needs. + +- **Writing and checking are separate verbs.** `ignatius index` writes, + `ignatius validate --index` checks. Verification is not a mode of the generator, + so there is no flag on `index` to name and no second parse to pay for. + +- **Model-level `description:` already exists.** It is read as top-level meta at + `src/model/parse.ts:181`, so the root router's summary line needs no new field. + The new frontmatter `description:` applies to entity, group, flow, external, and + store files only. + +- **Uniform routers, including for one-file folders.** `stores/` holds a single + file and still gets a router. A missing index at any level breaks the descent + contract, and an agent that must special-case depth is an agent that reads the + whole tree instead. + +- **No config for table columns.** `Kind` and `Description` are the routing + mechanism. Making them optional makes the router optionally useless. + +- **No config for link style.** Extensionless links work only in site generators; + `.md` works in site generators and in GitHub and VS Code. + +- **Blank lines inside the XML region are required, not stylistic.** CommonMark + ends a raw HTML block at a blank line. Without one after the opening tag and + before the closing tag, the table inside renders as literal pipes. + + +- **A region boundary is a position, not a delimiter.** A `` tag + that starts at column 0 and ends its line is a region boundary; anywhere else + on a line it is text. Column-0 boundaries must pair open and close with + matching names; a nested opener, an orphan closer, a mismatched closer, an + unclosed opener, or a duplicate region all throw, with the line number and the + fix in the message. There is no fence exemption and no inline-code exemption. + Three attempts to exempt code spans by their delimiters each opened a new way + for real markup to hide, because a delimiter can be accidental and a position + cannot; markdown itself pushes every prose container (`- `, `> `, `| `, + backtick, indent) off column 0. The one constraint this puts on a hand-authored + `` block: to show a tag as an example, indent the line or write + `<ignatius-index>`. A column-0 fake region with no real sibling is + replaced in place rather than refused, since refusing it would need a second + scanner, which is the class of code the rule removes. + + +## Open questions + + +- **Whether `index.stale` is a Class A or Class B rule.** Class B fails the + build, which is what "gates CI" means, but it also hard-fails anyone who edits + an entity and runs `validate --index` before regenerating. Class A warns and + lets a stale router reach main. Leaning B, since the fix is one command and the + flag is opt-in, but it is a judgment call the spec settles. + +- **Symlinks pointing outside the repo.** The tested case is a relative symlink + within one repo, which is the recommended install. A model shared from a + central location through an absolute symlink is untested. diff --git a/docs/guides/commands.md b/docs/guides/commands.md index db1aa7f..8079570 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -64,17 +64,35 @@ The exit code merges entity global errors, entity Class-B findings, and flow Cla Note: the older `dict`, `graph`, and `flow` subcommands have been removed. Invoking one prints a one-line error pointing to `export`. +## index + + +Writes a navigable router file into every organizing folder of the model root: the root itself, `groups/`, `data/` and every subdirectory holding entity files, `flows/` and every flow and sub-DFD folder, `externals/`, and `stores/`. Each router is a markdown table of the folder's children with their name, kind, description, and a relative link, so a reader descends the model one small table at a time instead of globbing it. The filename comes from `index_file:` in `ignatius.yml` (default `index.md`). + +```bash +ignatius index [path] [--model ] [--agents] +``` + +Every row carries the SHA-256 of its target, and each folder's digest rolls up to the root, so one hash at the top proves the whole tree is current. The generator owns only the bytes inside its own `` and `` tags; prose, headings, and a hand-authored `` block outside them survive regeneration verbatim. Running it twice over an unchanged model is byte-identical. + +`--agents` additionally writes three guidance files into the model root: `AGENTS.md` (how to walk the model and its conventions), `SKILL.md` (frontmatter that lets a harness discover the folder as a skill once it is symlinked into `.claude/skills/`), and, when `harness:` resolves to Claude, a `CLAUDE.md` that imports `AGENTS.md`. The guidance carries no entity content, only how to navigate. Which files land is set by `harness:` in `ignatius.yml` (`auto | claude | agents | both`). Nothing is written outside the model root. + +Findings print to stderr the same way `export` does, routers are still written, and the exit code is `1` on any Class B finding, `0` otherwise. See [The folder format](folder-format.md) for the router shape and the region contract. + + ## validate Checks the model and reports findings without generating any HTML. This is the fast path when you only want to know whether the model is sound: no bundle, no file written. ```bash -ignatius validate [path] [--model ] +ignatius validate [path] [--model ] [--index] ``` It prints each finding to stderr in the same format as `export` and writes a one-line summary to stdout, then exits `1` when the model has errors and `0` otherwise. When the model has a `flows/` directory, the flow rules run too and their findings are included. Use it as a lightweight quality gate while authoring or in CI. +`--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. + ## version @@ -153,4 +171,4 @@ These work even while a text field is focused, since they are not typed characte ## Exit codes -`export` and `validate` print any schema findings to stderr and exit `1` when the model has errors (omitted edges, dangling targets, unparseable files), `0` otherwise. Warnings alone do not fail the command. This makes the commands usable as a CI gate. See [Validation and findings](validation.md) for the rule catalog. +`export`, `validate`, and `index` print any schema findings to stderr and exit `1` when the model has errors (omitted edges, dangling targets, unparseable files, a malformed `index_file`), `0` otherwise. Warnings alone do not fail the command. `validate --index` adds router drift (`index.stale`) to the errors, so running it in CI fails the build when committed routers no longer match the files they describe. See [Validation and findings](validation.md) for the rule catalog. diff --git a/docs/guides/flows.md b/docs/guides/flows.md index 8420179..e7c9ab4 100644 --- a/docs/guides/flows.md +++ b/docs/guides/flows.md @@ -79,6 +79,7 @@ invoice line it pays. A receipt is returned to the [[Customer]]. | `number` | no | Local rank among sibling processes; falls back to file order. Full SSADM numbers (`1.2.1`) are composed from the folder nesting automatically | | `inputs` / `outputs` | yes | The flows. Each names an endpoint (`from:`/`to:`) and the `data:` it carries | | `examples` | no | Sample in/out rows rendered as tables in the process dialog, one entry per flow | +| `description` | no | One line saying what the process does and when it runs. It is the Description cell in the flow folder's generated router, so a reader can open or skip the process without reading its body | The `data:` field is the flow's label and its contract. On a `db:` endpoint it is **always column names** — a string for one column, a list for several — and every name is checked against the entity's `pk` and `columns` (the `flow.unknown_attribute` rule). On any other endpoint it is an opaque label; make it enumerate everything the flow carries rather than a one-word summary. @@ -103,18 +104,19 @@ This prefix set is closed. A store that fits none of the named kinds is authored ### Externals -An external is described once in `externals/.md` at the model root with an `external:` label in frontmatter and a body covering its role, what it does, and what it expects back. Every diagram at any nesting depth can reference `ext:` — there is no per-DFD override. +An external is described once in `externals/.md` at the model root with an `external:` label in frontmatter, an optional one-line `description:` that becomes its row in the `externals/` router, and a body covering its role, what it does, and what it expects back. Every diagram at any nesting depth can reference `ext:` — there is no per-DFD override. ### Stores -A `db:` store needs no extra file — it *is* the entity, documented in the entity's own `.md`. A non-`db` store exists simply by being referenced; an optional `stores/.md` file at the model root adds a `kind:`, an optional `title:` display override, and a body explaining why the store exists: +A `db:` store needs no extra file — it *is* the entity, documented in the entity's own `.md`. A non-`db` store exists simply by being referenced; an optional `stores/.md` file at the model root adds a `kind:`, an optional `title:` display override, an optional `description:` for the `stores/` router, and a body explaining why the store exists: ```markdown --- kind: file title: Payment Gateway Log +description: Raw gateway responses kept for reconciliation and disputes, never read in normal processing. --- Append-only log of raw gateway responses. Used for reconciliation and dispute diff --git a/docs/guides/folder-format.md b/docs/guides/folder-format.md index 6de774e..1f41154 100644 --- a/docs/guides/folder-format.md +++ b/docs/guides/folder-format.md @@ -37,6 +37,59 @@ name: My Schema Top-level keys `name`, `version`, `description`, and `updated` populate the model metadata. You can add a `theme` block to override colors and spacing and a `branding` block to set a logo, title, or copyright line. When the file has only `name`, ignatius uses its built-in defaults for everything else. See [Themes and branding](themes-and-branding.md). +Two more keys control the generated routers described below: + +```yaml +index_file: index.md # default; the router filename written into every organizing folder +harness: auto # auto | claude | agents | both — which agent guidance files `index --agents` writes +``` + +`index_file` names the router file `ignatius index` writes into `data/`, every subdirectory of `data/` that holds entity files, `groups/`, `flows/`, each flow folder, each sub-DFD folder, `externals/`, and `stores/`. It must be a bare filename ending in `.md` — a path or a different extension fails validation (`config.index_file_ext`, `config.index_file_path`). + +That filename is reserved. No entity file may use it: a `data/**/*.md` file matching `index_file` that also declares `entity:` fails validation (`config.index_file_entity`), naming the reserved filename directly rather than the generic "missing id" error. A file matching `index_file` that declares no `entity:` is treated as a router and skipped by the scan, not read as a broken entity. + + +## Generated routers + + +`ignatius index ` writes one router file per organizing folder, so a reader descends the tree one small table at a time instead of opening every file. Each router carries a `Name | Kind | Description | Go` table — `Kind` is `folder` for a subdirectory row or the entity/node classification for a leaf, `Description` is that file's own `description:` frontmatter, and `Go` is a clickable relative link ending in `.md`. + +Every row also carries the SHA-256 of its target. A folder's digest hashes its own row hashes, and a parent's row for a child folder carries that child's digest — so editing one entity file changes exactly the digests on its ancestor path and nothing else. + +Generated content sits inside an `` region, with a `` region for the `↑` navigation line directly above it: + +```markdown + + +↑ [Data](../index.md) · [My Schema](../../index.md) + + + + + +| Name | Kind | Description | Go | +|---|---|---|---| +| Party | Independent | Root actor; every customer resolves to one. | [Party](Party.md) | + + +``` + +**The generator owns only the bytes between its own tags.** A run replaces each `` region in place and leaves every byte outside it untouched — prose, headings, and a hand-authored `` block all survive regeneration verbatim: + +```markdown + +A new entity here carries `party_id` as its first PK column. + +``` + +That is what makes the router files safe to hand-edit: add a rules block once, and `ignatius index` never touches it. A file with no region gets one appended; a missing file is created. Running `ignatius index` twice over an unchanged model produces byte-identical output. + +One constraint follows from how the generator finds its regions: **a line that starts with `` block, keep it off column 0: inline code, an indented line, or `<ignatius-index>`. A nested opener, an orphan or mismatched closer, an unclosed opener, or a duplicate region makes `ignatius index` refuse the file and name the line and the fix. + +`ignatius validate --index ` recomputes every digest and reports drift as `index.stale`, writing nothing — the CI gate for "routers match the files on disk". `index.orphaned` warns when a router file left over from a previous `index_file` value is still on disk. A plain `ignatius validate` never pays this hashing cost; `--index` is opt-in. + +`ignatius index --agents` additionally writes in-folder agent guidance into the model root: `AGENTS.md` (how to walk the model), `SKILL.md` (frontmatter that makes the folder a discoverable skill once it is symlinked into `.claude/skills/`), and, when `harness:` resolves to Claude, a `CLAUDE.md` that imports `AGENTS.md`. Nothing is written outside the model root. See [Commands](commands.md) for the flag, and `harness:` above for which files land. + ## An entity file diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 1e5d0b2..17b79e9 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -89,5 +89,5 @@ 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`, and `validate` in full. +- [Commands](commands.md) — `serve`, `export`, `validate`, and `index` in full. - [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 2b8a693..e5a3a45 100644 --- a/docs/guides/modeling-skill.md +++ b/docs/guides/modeling-skill.md @@ -69,3 +69,5 @@ 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. diff --git a/docs/guides/validation.md b/docs/guides/validation.md index 2107cb7..6d3ecc4 100644 --- a/docs/guides/validation.md +++ b/docs/guides/validation.md @@ -101,6 +101,30 @@ These run whenever the model has a `flows/` directory (see [Process flows](flows | `flow.unbalanced_decomposition` | A | A sub-DFD's boundary flows do not match the parent process's declared inputs and outputs. | +### Config rules + + +These check `ignatius.yml` and the reserved router filename it declares. They are Class B for the exit code because a malformed `index_file` means routers cannot be generated or verified. Only `config.index_file_entity` omits anything: the misnamed entity is dropped, since every scan skips the reserved name before reading it as an entity. + +| Rule ID | Class | Meaning | +|---|---|---| +| `config.index_file_ext` | B | `index_file` does not end in `.md`. | +| `config.index_file_path` | B | `index_file` contains a path separator or `..`; it must be a bare filename. | +| `config.index_file_entity` | B | A file under `data/` uses the reserved `index_file` name but declares `entity:`. Every scan skips that name, so the entity is dropped; rename the file. | + + +### Index rules + + +These run only under `ignatius validate --index` (see [Commands](commands.md)). They compare each router's stored digest against a fresh recomputation and write nothing. `index.stale` is Class B so a drifted router fails a CI gate; the fix is `ignatius index`. + +| Rule ID | Class | Meaning | +|---|---|---| +| `index.stale` | B | A router's digest no longer matches its folder's current files, or the router is missing. | +| `index.orphaned` | A | A router file left behind by an `index_file` change is still on disk. | +| `index.unreadable_target` | B | A file a router row points at could not be read while recomputing, so its digest cannot be trusted. | + + ## Trying it out diff --git a/docs/spec/model-index-routing.md b/docs/spec/model-index-routing.md new file mode 100644 index 0000000..9fd431c --- /dev/null +++ b/docs/spec/model-index-routing.md @@ -0,0 +1,270 @@ +# Spec: Model index routing + + +## Goal + + +Generate a navigable router into every organizing folder of a model root, so a reader descends root → section → group → entity through small tables instead of globbing the tree. Each router is a markdown table of name, kind, description, and a clickable relative link, wrapped in an `` region the generator owns. Per-file hashes roll up into folder digests and a root digest, so `ignatius validate --index` reports drift as a finding. `ignatius index --agents` additionally writes in-folder agent guidance (`AGENTS.md`, a `CLAUDE.md` shim, `SKILL.md`) so a model folder orients any agent that opens a file in it. + + +## Approach + + +Per-folder routers, in-folder agent guidance, XML managed regions, per `docs/design/model-index-routing.md`. + + +## Non-goals + + +- No change to `ignatius serve`, the SPA, or the static export. Routers are a flat-file surface, not a viewer surface. +- No new link syntax. `[[Entity]]` keeps its current meaning and its current consumer; routers use ordinary relative markdown links. +- No search index, ranking, or embedding. +- No config for which folders get routers, which columns a table carries, or link style. +- No maximum descent depth. Routers nest as deep as the model does. +- No writes outside the model root. The generator never touches a consuming repo's files. +- No `ignatius install-skill` verb. Installing `SKILL.md` as a live skill is the user's move. +- No migration tooling. Routers are generated. + + +## Success criteria + + +- SC1 — `ignatius.yml` accepts `index_file:` (default `index.md`) and `harness:` (default `auto`, one of `auto | claude | agents | both`). Both load in `parseModels` alongside the existing `flow_rules:` block and land on `_meta`. A config with neither key parses and defaults. +- SC2 — **Every scan that could read a generated router skips it.** A router is written into `data//`, `groups/`, `flows//`, each sub-DFD folder, `externals/`, and `stores/`, so each of those scans skips a file matching the configured `index_file`: the `data/**/*.md` entity scan and the `groups/*.md` scan in `src/model/parse.ts`, and the process, externals, and stores scans in `src/flows/flow-parse.ts`. A router raises neither `parse.missing_id` nor `parse.invalid_yaml`. Matching is on the **basename** for a recursive glob and on the whole path for a flat one; suffix matching is never used, so a file named `Reindex.md` is still scanned. Running `ignatius index` twice over a model produces clean stderr on the second run. +- SC3 — Three config rules exist and fire: `config.index_file_ext` (value does not end in `.md`), `config.index_file_path` (value contains `/` or `..`), and `config.index_file_entity` (a file matching `index_file` under `data/` declares `entity:`, reported with a message naming the reserved filename, not the generic `parse.missing_id`). +- SC4 — A top-level `description:` string parses on entity, group, flow-process, external, and store files, and reaches the model. Absent `description:` is not an error anywhere. +- SC5 — `ignatius index ` writes one `index_file` into the model root and into `groups/`, `data/`, **every subdirectory of `data/` that holds entity files, at any depth**, `flows/`, each flow folder, each sub-DFD folder, `externals/`, and `stores/`. Each contains an `` region holding a table with a Kind column, a Description column, and a Go column of relative `file.md` links, plus an `↑` breadcrumb region above it. + +- SC5a — **Routers mirror the filesystem, never the declared groups.** A router lists what its own directory actually contains: subdirectory rows and entity rows for the files in that directory. Entity file paths come from the parser's real discovered path, never reconstructed from `group:` plus entity id. A model whose entities sit flat in `data/` gets one `data/index.md` listing them directly; a model that nests gets a router per level. `group:` is a declarative classification that need not match any folder name, so the two must not be conflated. A model must never crash the generator because its layout does not follow the group-as-folder convention. +- SC6 — Rows for child folders carry `Kind: folder`; rows for leaves carry the entity classification (`Independent`/`Dependent`/`Subtype`/`Associative`/`Classifier`) or the node kind (`process`/`external`/`store`). Classification is read from the parsed model, never re-derived in the generator. +- SC7 — Every row carries the SHA-256 of its target. A folder's `digest` attribute hashes its row hashes. A parent's row for a child folder carries that child's digest, never the child's rows. Editing one entity file changes exactly the digests on its ancestor path and no others. +- SC8 — Writing is region-scoped: a second `ignatius index` run over an unchanged model produces a byte-identical tree (idempotent). Bytes outside every `` region survive a run verbatim, including a hand-authored `` block, prose, and headings. A file with no region gets one appended; a missing file is created. +- SC9 — `ignatius validate --index` recomputes digests, writes nothing, and reports drift as `index.stale` through the existing `formatFindingsForStderr` pipeline. A plain `ignatius validate` performs no hashing. `index.orphaned` warns when a router file left behind by an `index_file` change is still on disk. +- SC10 — `ignatius index --agents ` writes routers **and**, into the model root only, `AGENTS.md` (canonical guide), `SKILL.md` (with generated YAML frontmatter carrying `name` and `description`), and, when the harness resolves to Claude, a `CLAUDE.md` whose body is an `@AGENTS.md` import plus Claude-specific lines. `harness: auto` resolves to Claude when a `.claude/` directory or a `CLAUDE.md` exists at or above the model root. +- SC11 — Guidance files carry no entity, column, or relationship content: the model root's name, description, the resolved router filename, the key-style convention, the `[[Entity]]` body rule, and the root digest. Each stays under 200 lines. +- SC12 — `SKILL.md` frontmatter is the sole content the generator owns outside an `` region. Every other generated file, routers included, is region-only. +- SC13 — `skills/ignatius-modeling/` teaches the change: `description:` appears in the entity, group, and flow templates and in the authoring steps that fill them; the reserved `index_file` name is documented as unusable for an entity file; and the verification loop covers `ignatius validate --index`. +- SC14 — `bun run test` exits 0 with every existing check green, and `bunx tsc --noEmit` reports no error in a file the range touches beyond the repo's pre-existing stale-`bun-types` set (CI runs typecheck with continue-on-error). All five in-repo model roots under `models/` and all five fixture roots under `test/fixtures/` still parse and validate. + + +## Checkpoints + + +| # | Checkpoint | Files/areas | Verifies | +|---|------------|-------------|----------| +| CP1 | Config keys + reserved-name scan skip + config rules | `src/model/parse.ts`, `src/model/validate.ts`, `src/types/`, `test/checks/test-index-config.ts` | SC1, SC2, SC3 — an `index.md` in a fixture `data//` parses clean; `Reindex.md` still scanned; all three config rules fire on bad values | +| CP2 | `description:` on the five file kinds | `src/model/parse.ts`, `src/flows/flow-parse.ts`, `src/types/`, `test/checks/test-description-field.ts` | SC4 — description reaches the model for each kind; absence is never an error; existing roots unaffected | +| CP3 | Managed region + fingerprint primitives | `src/router/region.ts`, `src/router/fingerprint.ts`, `test/checks/test-router-region.ts`, `test/checks/test-router-fingerprint.ts` | SC7, SC8 halves — region replace preserves outside bytes and is idempotent; digest roll-up changes only the ancestor path | +| CP4 | `ignatius index` — router build + write | `src/router/build.ts`, `src/router/write.ts`, `src/cli/cli.ts`, `test/checks/test-router-index.ts` | SC5, SC6, SC7, SC8 end-to-end against `models/key-inherited`: every folder gets a router, kinds come from the parsed model, a second run is byte-identical | +| CP5 | `validate --index` | `src/model/validate.ts`, `src/cli/cli.ts`, `test/checks/test-validate-index.ts` | SC9 — `index.stale` fires on a touched entity, plain `validate` hashes nothing, `index.orphaned` warns after a rename | +| CP6 | `index --agents` — guidance files + harness detection | `src/router/agents.ts`, `src/router/detect.ts`, `src/cli/cli.ts`, `test/checks/test-router-agents.ts` | SC10, SC11, SC12 — the three files land in the model root only, `CLAUDE.md` appears only under Claude detection, `SKILL.md` frontmatter is the one non-region write | +| CP7 | Skill + docs | `skills/ignatius-modeling/**`, `docs/guides/`, `docs/wiki/feature-map.md` | SC13 — templates carry `description:`, the reserved name is documented, the verification loop covers `--index`; feature-map row filled to spec/guide/skill | + + +## Change tree + + +``` +M src/model/parse.ts — index_file/harness config read; data- and groups-scan reserved-name skip; description: on entity + group +M src/model/validate.ts — config.index_file_* rules; index.stale; index.orphaned; index.unreadable_target +M src/flows/flow-parse.ts — reserved-name skip in the process, externals, and stores scans; description: on process, external, store +A src/router/region.ts — parse/replace/append an region, outside bytes untouched +A src/router/fingerprint.ts — file SHA, folder digest, ancestor roll-up +A src/router/build.ts — model + folder tree → RouterFile[]; reads bytes to hash, never writes +A src/router/write.ts — region-scoped router writes: the breadcrumb and index regions +A src/router/detect.ts — harness resolution from config + ancestor probe +A src/router/agents.ts — AGENTS.md / CLAUDE.md shim / SKILL.md content, and writeGuidance +M src/cli/cli.ts — indexCmd (+ --agents); validateCmd gains --index +A test/checks/test-index-config.ts — config keys, scan skip, config rules +A test/checks/test-description-field.ts — description: across the five kinds +A test/checks/test-router-region.ts — region replace/append idempotence +A test/checks/test-router-fingerprint.ts — digest roll-up, ancestor-only propagation +A test/checks/test-router-index.ts — end-to-end router generation +A test/checks/test-validate-index.ts — index.stale / index.orphaned +A test/checks/test-router-agents.ts — guidance files + harness detection +M skills/ignatius-modeling/SKILL.md — description-always core rule +M skills/ignatius-modeling/references/templates.md — description: in entity/group templates +M skills/ignatius-modeling/references/entity-flow.md — description step +M skills/ignatius-modeling/references/dfd-authoring.md — description step for processes/stores +M skills/ignatius-modeling/references/flow-templates.md — description: in flow templates +M skills/ignatius-modeling/references/verification.md — validate --index in the loop; reserved name +M skills/ignatius-modeling/references/conventions.md — reserved index_file name +M docs/guides/folder-format.md — routers, description:, index_file/harness config, --agents +M docs/guides/commands.md — index section; --index on validate; exit codes +M docs/guides/validation.md — Config rules and Index rules +M docs/guides/flows.md — description: on process, external, store +M docs/guides/modeling-skill.md — verification loop covers validate --index +M docs/guides/getting-started.md — command list names index +M README.md — command list and folder-format row +M docs/wiki/feature-map.md — fill spec/guide/skill columns on the routing row +A models/*/**/index.md — 53 routers across key-inherited, orm-pure, orm-hybrid, llm-memory-db-mssql +M models/llm-memory-db-mssql/** — description: on 74 files; AGENTS.md, CLAUDE.md, SKILL.md +``` + + +## Outline + + +``` +- src/router/region.ts + - REGION_RE — locate a named block and its inner span + - readRegion — extract inner content, or null when absent + - replaceRegion — swap inner content in place, appending the block when absent +- src/router/fingerprint.ts + - hashFile — SHA-256 of file bytes + - folderDigest — hash of an ordered row-hash list + - RouterNode — one row: name, kind, description, link, hash +- src/router/build.ts + - buildRouters — model + folder tree → RouterFile[], one per organizing folder + - buildDataTree / buildDataFolder — the data/ subtree from each entity's sourcePath, never its declared group + - buildFlowFolder — one router per flow and sub-DFD folder + - safeHashFile — hashes a target, collecting an unreadable one for index.unreadable_target + - breadcrumbFor — the ↑ line for a given depth + - renderTable — RouterNode[] → markdown table +- src/router/detect.ts + - resolveHarness — config value plus ancestor probe → which guidance files to write +- src/router/agents.ts + - deriveKeyStyle — key-inherited, orm-oriented, mixed, or undetermined, from PK shape and identifying edges + - buildAgentsGuide — AGENTS.md body + - buildClaudeShim — @AGENTS.md import plus Claude-specific lines + - buildSkillMeta / buildSkillBody — YAML frontmatter (the one non-region write) plus body + - writeGuidance — region-scoped writes of the three guidance files into the model root +- src/router/write.ts + - writeRouters — region-scoped write per RouterFile, creating missing files; owns the breadcrumb and index regions +- src/model/parse.ts + - config read — index_file, harness onto _meta + - data-scan skip — basename equality against the resolved index_file + - description — entity and group frontmatter passthrough +- src/model/validate.ts + - config.index_file_ext / _path / _entity — config shape rules + - index.stale / index.orphaned — router drift rules, Class assignment per Risks +- src/cli/cli.ts + - indexCmd — path positional, --model, --agents + - validateCmd — --index flag threading the router check +``` + + +## Flows + + +1. **Generate routers** — user runs `ignatius index models/key-inherited` → the CLI resolves the model root and parses it → `buildRouters` walks the organizing folders and builds one `RouterFile` per folder, reading classification from the parsed model → `fingerprint` hashes each target and rolls digests up the ancestor path → `writeRouters` replaces each `` region in place, creating any missing `index.md` → the run reports how many routers were written. + +2. **Edit an entity, then check** — user edits `data/transactional/Payment.md` → runs `ignatius validate --index` → validate parses the model once, recomputes hashes, and finds the stored digest on `data/transactional/index.md` no longer matches → an `index.stale` finding names that folder and its ancestors through `formatFindingsForStderr` → nothing is written → user runs `ignatius index` and the check passes. + +3. **Regenerate without losing hand-authored rules** — user adds an `` block to `data/identity/index.md` and adds a new entity to the group → runs `ignatius index` → the `` region gains the new row and a new digest; the `` block, the heading, and the breadcrumb line are unchanged byte for byte. + +4. **Write guidance and install it** — user runs `ignatius index --agents server/docs/data-model` → routers regenerate first → `resolveHarness` finds a `.claude/` directory above the model root and resolves to Claude → `AGENTS.md`, `SKILL.md`, and a `CLAUDE.md` importing `@AGENTS.md` land in the model root and nowhere else → an agent that later opens `data/transactional/Payment.md` picks up `CLAUDE.md` automatically → the user optionally runs `ln -s ../../server/docs/data-model .claude/skills/alkane-model` to make the skill invocable. + +5. **Author an entity under the new rules** — user invokes the `ignatius-modeling` skill in entity mode → the skill asks for a one-line `description:` alongside the existing questions → it writes the entity file → its verification loop runs `ignatius validate --index`, which reports the routers as stale → the skill runs `ignatius index` and re-verifies. + + +## Risks + + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| `index.stale` as Class B hard-fails anyone who edits an entity and validates before regenerating, turning a routine edit into a red build | Medium | Ship it Class B, since `--index` is opt-in and the fix is one command, but pin the behavior in `test-validate-index.ts` so the class is a deliberate, testable choice rather than an accident. Downgrade to A only if the loop proves noisy in practice. | +| Region writer corrupts a file it does not fully understand (nested tags, orphan or mismatched closers, CRLF) | Medium | `region.ts` is pure and unit-tested before any writer uses it (CP3 precedes CP4). A boundary is a `` tag at column 0 ending its line; anything else is text, and column-0 boundaries must pair by name or the writer throws with the line number and the fix. Tests cover every pairing error, CRLF, an indented example surviving inside a rules block, a column-0 example inside a rules block throwing, and a column-0 fake region with no sibling being replaced in place. | +| A markdown table inside an XML block renders as literal pipes when the blank lines are missing | Medium | The renderer always emits a blank line after the opening tag and before the closing tag; `test-router-index.ts` asserts both are present in generated output. | +| Digest churn: hashing raw bytes means a whitespace-only edit invalidates a digest and dirties a diff | Low | Accepted. Raw-byte hashing is the only rule that catches body edits, which are exactly what a stale description would miss. | +| Adding `src/router/` creates an unregistered wiki domain, so the signals map goes stale | Low | CP7 includes the feature-map row; a signals refresh after the range picks up the new directory. | +| The five in-repo model roots and five fixture roots gain generated files, inflating the diff and perturbing existing checks | Medium | CP4 generates into `models/key-inherited` only; the other roots stay untouched until the feature is green. Existing checks assert model parse results, not directory contents. | + + +## Change log + + +### 2026-09-05 — Initial spec + +**What changed:** First contract for model index routing: per-folder routers with rolled-up digests, `index_file`/`harness` config, `description:` frontmatter, `ignatius index [--agents]`, `ignatius validate --index`, and the authoring-skill updates that keep the format teachable. + +**Why:** A model root is navigable to the SPA and a flat pile of markdown to every other reader, so agents and humans glob the tree instead of descending it. + + +### 2026-09-05 — Reserved-name skip covers every scan, not just `data/` + +**What changed:** SC2 now requires the reserved `index_file` skip in all five scans that can read a generated router: the `data/**/*.md` and `groups/*.md` scans in `src/model/parse.ts`, and the process, externals, and stores scans in `src/flows/flow-parse.ts`. It also pins that a second `ignatius index` run leaves stderr clean. The `parse.ts` and `flow-parse.ts` change-tree lines record the wider scope. + +**Why:** Routers are written into `groups/`, `flows//`, sub-DFD folders, `externals/`, and `stores/`, and each of those scans read the router back as a malformed definition file. A `groups/index.md` made every later `parseModels` throw. The remaining four produced `parse.invalid_yaml`, which is Class B, so once `validate --index` ships, running `ignatius index` would make `ignatius validate` exit 1 on any indexed model. + +**Superseded:** SC2 previously scoped the skip to the `data/**/*.md` scan alone. + + +### 2026-09-05 — Correction: routers follow the folder tree, not declared groups + +**What changed:** SC5 now says a router is written into every subdirectory of `data/` that holds entity files, at any depth, rather than into "each `data//`". New SC5a states that routers mirror the filesystem, that entity paths come from the parser's discovered path, and that the generator must not crash on a layout that does not use group-as-folder. + +**Why:** `group:` is a declarative field, independent of where a file sits. Entities are discovered by a recursive `data/**/*.md` glob, so a model may keep them flat in `data/`, nested by group, or nested by something else entirely. Reconstructing a path as `data//.md` produced an unhandled `ENOENT` from `hashFile` on `models/broken-demo`, whose entities are flat in `data/` while declaring `group: core`. Four of the five in-repo models happen to nest by group, which is why the assumption survived until a non-conforming model was indexed. + +**Superseded:** SC5 previously enumerated `data//` as the only per-group router location, implying group and folder are the same thing. + + +### 2026-09-05 — Correction: `build.ts` does I/O + +**What changed:** The change-tree annotation for `src/router/build.ts` reads "reads bytes to hash, never writes" rather than "pure". + +**Why:** A digest has to exist before a table row renders, so the builder reads file bytes to hash them. Deferring that to `write.ts` would need a second pass over the tree. The contract the split actually buys is that one module writes, not that the builder is side-effect free. + + +### 2026-09-06 — Audit corrections + +**What changed:** SC6 names the parser's actual classification set (`Independent`/`Dependent`/`Subtype`/`Associative`/`Classifier`). SC14 asks for no new type errors in touched files rather than a clean `tsc`, which the repo has never had. The change tree drops `src/types/index.ts`, which never existed, records that `writeGuidance` lives in `agents.ts`, adds `index.unreadable_target`, and lists the guides, README, skill reference, and model files the range touched. The Outline names the pieces that exist at HEAD. + +**Why:** The final audit found six spots where the body described the plan rather than the code. A subagent reading the old Outline would look for `rowsForEntityGroup`, which was replaced when routers switched to mirroring the filesystem. + +**Superseded:** SC6's `Reference` classification; SC14's `bunx tsc --noEmit exit 0`; the change tree's `src/types/index.ts` row and write.ts as the only fs-writing module; the Outline's group-as-folder row builders. + + +### 2026-09-06 — Region boundaries are position-based + +**What changed:** A `` tag is a region boundary only when it starts at column 0 and ends its line; anywhere else on a line it is text. Column-0 boundaries must pair open and close by name; a nested opener, an orphan closer, a mismatched closer, an unclosed opener, or a duplicate region throws with the line number and the fix. The region-writer Risks row names these pairing errors and the three test premises that pin the rule. `validate --index` uses the same column-0 definition to decide whether a file is a router. + +**Why:** Three successive patches tried to exempt code spans by their delimiters (fences, then inline backticks, then full containment) and each opened a new way for real markup to hide, twice as silent truncation behind a success exit. A delimiter can be accidental; a position cannot, and markdown pushes every prose container off column 0. One tokenizer and a pairing scan replace two scanners that could disagree about the same bytes. + +**Superseded:** The fence exemption ("a region inside a fenced block must be ignored") and every inline-code exemption. A column-0 fake region with no real sibling is replaced in place rather than skipped. + + +## Implementation log + + +### built — 2026-09-06, draft PR #35 + +Built across 7 checkpoints of the /autopilot subagent loop, a post-loop pass from live user feedback, and one audit fix iteration. Squashed to a single commit for merge at the user's request, so the per-checkpoint history below is narrative rather than a SHA list. + +- design doc + spec (loop base) +- CP1 `index_file`/`harness` config, basename scan skip, three config rules +- CP3 managed-region + fingerprint primitives, fail-loud on nested/unclosed/duplicate regions +- CP2 `description:` on all five file kinds +- reserved-name skip in all five scans; SC2 and design-doc amendments +- CP4 `ignatius index`, routers mirror the filesystem, breadcrumb region, Class B exit code +- CP5 `validate --index`, `index.unreadable_target` replaces a silent sentinel; CP7 skill + guide +- CP6 `index --agents` in-folder guidance +- routers committed into four demo roots +- key style derived structurally from PK shape +- root router section descriptions and model prose +- `test-validate-index` constructs never-indexed state explicitly +- `llm-memory-db-mssql` seeded with 74 descriptions and made the exemplar +- audit fix iteration: `LEGACY_CRUMB_RE` removed (SC8), four stale comments rewritten, `--agents` docs corrected, demo roots regenerated, spec amended; region boundaries redefined as column-0 tags with a pairing scan after three delimiter-based patches each opened a new hole + +**Out-of-scope work performed during this build:** + +- CP4 added the reserved-name skip to the `groups/` scan in `parse.ts`, outside its declared file list, because a `groups/index.md` made every later `parseModels` throw and blocked SC8. Flagged by the implementer, accepted, and the spec widened to match. +- Four demo roots indexed and `llm-memory-db-mssql` seeded with descriptions and guidance, from user feedback after the loop closed. The spec's Risks table had kept the in-repo roots untouched until green. + +**Unforeseens — surprises that emerged during implementation:** + +- Region detection required interior blank lines, so a well-formed hand-authored `` block written without them was reported as an unclosed tag and hard-failed `ignatius index`. Blank lines are a CommonMark rendering requirement, not a parsing one; every test fixture used the generator's own convention. Found by probing `replaceRegion` directly. +- The region guard was patched three times after the audit and defeated three times. Each patch defined "prose about a tag" by its delimiters: the fence exemption, then inline backticks with text stripped before scanning, then inline spans with full containment. Each reviewer found bytes where the region matcher and the guard disagreed, and twice the result was silent truncation behind a success exit rather than the crash being fixed. A read-only strategist pass found the cause, two scanners over the same bytes, and a fourth live hole no reviewer had filed. The fix is a rule about position, not delimiters, because a delimiter can be accidental and a position cannot; it deletes the whole code-span apparatus. +- `build.ts` reconstructed entity paths as `data//.md`. `group:` is declarative and independent of folder layout, so `models/broken-demo` (flat `data/`, `group: core`) crashed with an unhandled `ENOENT`. Four of five in-repo models nest by group, which hid it. `ModelNode` gained `sourcePath`. +- The crash fix wrapped hashing in a bare `catch` returning a stable all-zeros digest, so an unreadable target would have passed `validate --index` forever. Replaced with `index.unreadable_target`. +- Key style needed two corrections. Edge identifying-ness produced `mixed` for `key-inherited` because edges into catalog tables are non-identifying in both styles. The replacement counted only a PK named exactly `id` as surrogate, so `llm-memory-db-mssql` (surrogates named `_id`) read as pure key-inherited. The structural rule uses identifying edges per entity plus a 20% share threshold for `mixed`. +- The Class A/B tier is inert for `GlobalError`s: `cli.ts` filters flow errors by class but counts every global error toward the exit code. The new config rules were the first class-A globals and would have hard-failed while claiming to warn. Reassigned to B. +- Committing routers into `models/key-inherited` broke `test-validate-index.ts`, which copied that model and assumed it had no routers. The spec's Risks table had named this hazard; the mitigation was dropped once the feature was green. A stale `dist/ignatius` masked it on the first run and a hung Playwright check masked it on the second, so the demo-roots commit was pushed without a green suite. +- The root-router change (section descriptions, model prose) did not regenerate the committed demo routers, so `key-inherited`, `orm-pure`, and `orm-hybrid` sat behind the generator until the audit caught it. `validate --index` cannot see this class of drift because the digest hashes child digests, not rendered text. The same commit's message claimed the root digest changed; it did not. +- Five agent claims did not survive verification, each attributing a real signal to noise or to another agent: a "flaky" suite that was green, a "new" typecheck error that was pre-existing, a "flaky" router failure that was a concurrent edit, a "pre-existing" fixture failure that was a regression from this branch, and a "writes routers then exits 1" that was a crash. Direct probing, not the suite, found every substantive defect above. + +**Deferred items still open:** + +- `ignatius index` reports an unreadable target only through `validate --index`, not from the index verb itself. +- Symlinks into `.claude/skills/` are verified only for a relative link within one repo; an absolute link to a model outside the repo is untested. +- `key-inherited`, `orm-pure`, and `orm-hybrid` are indexed without descriptions or guidance files, so they show structure but not payload. diff --git a/docs/wiki/CLAUDE.md b/docs/wiki/CLAUDE.md index 4bd4fea..a6f0178 100644 --- a/docs/wiki/CLAUDE.md +++ b/docs/wiki/CLAUDE.md @@ -23,7 +23,7 @@ Server is `Bun.serve()` with HTML imports. No Express, no Vite, no webpack, no N ## Domains -Keep the twelve established domains; do not re-derive or merge them. +Keep these thirteen domains; do not re-derive or merge them. `router` was added 2026-09 for the model-index-routing feature (`src/router/`); the other twelve are unchanged. - cli — src/cli/ - server — src/server/ @@ -37,6 +37,7 @@ Keep the twelve established domains; do not re-derive or merge them. - skill — skills/ignatius-modeling/ - docs — docs/ - scripts — scripts/ +- router — src/router/ (per-folder generated index.md routers, agent-guidance files, region parser) ## Ignore for domains diff --git a/docs/wiki/cli.md b/docs/wiki/cli.md index 78e50c7..ccf4f4d 100644 --- a/docs/wiki/cli.md +++ b/docs/wiki/cli.md @@ -1,48 +1,115 @@ --- type: Domain -description: The ignatius binary — citty subcommands, model-root discovery, port fallback, and self-update. +description: The ignatius binary — citty subcommand dispatch, model-root discovery, port/browser handling, self-update. +tags: [cli, dispatch, model-resolution] --- # cli ## What it does -- [`src/cli/cli.ts`](../../src/cli/cli.ts) is the ignatius binary entry point, built on citty (`defineCommand`/`runMain`). It registers `serve`, `server` (alias), `dict`, `graph`, `flow` (removal stubs), `validate`, `export`, `version`, `update`. -- Model-root discovery (`discover.ts`) is a pure, TTY-agnostic helper factored out of the citty command handlers so it can be unit-tested without a terminal; `resolve-model.ts` builds on it but is itself TTY-gated (see below). `serve-port.ts` is unrelated to model-root discovery — it wraps `serveCommand` for port-fallback handling and is separately TTY-gated. -- `update.ts` self-updates the compiled binary against GitHub Releases; `version.ts` bakes the [`package.json`](../../package.json) version into the binary at compile time. +[`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. -## Artifacts +## How it works -No user-facing Claude Code skill/command artifacts in this domain. +| Subcommand | Flags | Behavior | +|---|---|---| +| `serve` (alias `server`) | `path`, `--port`/`-p` (default `3000`), `--model`, `--open`/`-o` | Resolves the model dir via `pickModel`, binds via `serveWithPortFallback`; `--open` dynamically imports `open-browser.ts` | +| `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 | +| `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 | -## CLI 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. -- [`src/cli/cli.ts`](../../src/cli/cli.ts) — binary entry point. Subcommands: - - `serve` (alias `server`): positional `path`, `--port`/`-p` (default `3000`, rejected if non-numeric or `<= 0`), `--model`, `--open`/`-o`. Resolves the model dir via `pickModel`, binds via `serveWithPortFallback`; `--open` dynamically imports `open-browser.ts` and calls `openBrowser`. - - `dict`, `graph`, `flow` — removal stubs. Each writes `" was removed — use: ignatius export -o model.html"` to stderr and exits 1. - - `validate`: `parseModels()` → `validateModel()`; if `/flows` exists, dynamically imports `parseFlows()`/`validateFlows()` and folds flow findings into the same report. Prints findings via `formatFindingsForStderr`, prints a one-line `✓`/`✗` stdout summary, exits 1 on error. Error/warning counts come from `RULES[e.ruleId].class` (`B` = error, `A` = warning) rather than each finding's own severity field, so the exit code and the rule registry can't silently diverge. - - `export`: same parse/validate pipeline as `validate`, plus `-o`/`--out` (required, exits 1 if missing) and `--theme` (`light`|`dark`, default `dark`). Loads the embedded React bundle via `loadEmbeddedBundle()` — on failure, exits 1 with a `bun run build:bundle` hint — then calls `generateApp(model, flowModel, bundle, { themeMode })` and writes one HTML file with `Bun.write`. Exits 1 when entity global errors or flow Class-B errors are present. - - `version`: prints `VERSION`. - - `update`: flags `--check`, `--yes`/`-y`; delegates to `runUpdateCommand()` and exits with its returned code. -- [`src/cli/discover.ts`](../../src/cli/discover.ts) — pure model-root resolver (no TTY, citty, clack, or `process.stdin`). Exports `resolveModel(base, opts): Promise` (discriminated union `single | many | no-match | none`) and the `ModelCandidate`/`ResolveOptions` types. Algorithm: (1) `base/ignatius.yml` exists → single; (2) else search down, skipping `_`-prefixed dirs and `node_modules`, `.git`, `dist`, `tmp`, [`trash`](../../trash), `.worktrees`, [`.claude`](../../.claude) — a directory containing `ignatius.yml` is treated as a leaf and not recursed into further; (3) 0 found → walk up (optionally bounded by `opts.ceiling`) → single or none; (4) exactly 1 found → single; (5) >1 found + `--model` key → filter to single/no-match/many; (6) >1 found + no key → many. -- [`src/cli/resolve-model.ts`](../../src/cli/resolve-model.ts) — exports `pickModel(base, modelKey): Promise`, the shared resolution+UI layer used by `serve`, `validate`, and `export`. A comment explains the isolation: keeps `@clack/prompts` imported in exactly one place so its TTY-gated `select` is never triggered by a spawned (non-TTY) CI process. `single` → dir; `none` → stderr message + exit 1; `no-match` → stderr with available keys + exit 1; `many` + non-TTY → stderr key list + exit 2; `many` + TTY → clack `select` picker (cancel → exit 130). -- [`src/cli/serve-port.ts`](../../src/cli/serve-port.ts) — `serveWithPortFallback(dir, requestedPort): Promise` wraps `serveCommand` from [`src/server/server.ts`](../../src/server/server.ts). On `EADDRINUSE`: non-TTY stdout silently advances to `port + 1` and retries the real bind (no separate probe, so no check-then-bind race); TTY prompts via `@clack/prompts` `text`, defaulting to the next free port found by `findAvailablePort` (probes by binding and immediately releasing a throwaway `Bun.serve`). Also exports `isAddrInUse(err)`. -- [`src/cli/open-browser.ts`](../../src/cli/open-browser.ts) — `browserOpenCommand(platform, url): string[]` is a pure function: `darwin` → `['open', url]`, `win32` → `['cmd', '/c', 'start', '', url]`, else → `['xdg-open', url]`. `openBrowser(url, platform?)` fire-and-forget `Bun.spawn`s that command, swallowing spawn failures to a stderr message so a missing browser opener (e.g. headless Linux) never takes down the server. Dynamically imported by `cli.ts` only when `--open` is passed. -- [`src/cli/version.ts`](../../src/cli/version.ts) — `export const VERSION: string = pkg.version`, a JSON import of [`package.json`](../../package.json). Bun inlines this at `bun build --compile` time, so the compiled binary reports the version it was built from. -- [`src/cli/update.ts`](../../src/cli/update.ts) — `runUpdateCommand(opts): Promise` drives `ignatius update`. Pure, separately-tested helpers: `parseVersion`, `compareVersions` (major.minor.patch), `parseTagFromLocation` (extracts a tag from GitHub's `releases/latest` redirect `Location` header), `assetForPlatform` (darwin/linux/win32 × arm64/x64; windows only ships x64), `parseChecksums` (parses a shasum-format `checksums.txt`). `checkForUpdate()` resolves the latest version via that redirect, no GitHub API token needed. Guards: dev runtime (`process.execPath` basename is `bun`/`node`) → no self-replace, prints a git-update hint instead; `win32` → prints a manual-download message (a running `.exe` cannot replace itself); non-TTY without `--yes` → report-only. Install path: downloads the platform asset, verifies its sha256 against `checksums.txt` when reachable (a genuine mismatch aborts; an unreachable checksums file does not block the update), writes to a temp file beside the target, `chmodSync 0o755`, then `renameSync` atomically over `process.execPath`. +### Model resolution -## Docs +`pickModel` collapses any number of candidate directories to exactly one, or to a specific exit code, never to a silent default: -- [`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md) and [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) — design/spec pair for the CLI and its output modes. +```mermaid +flowchart TD + A[pickModel] --> B{resolveModel result} + B -->|single| C[return dir] + B -->|none or no-match| D[exit 1] + B -->|many| F{TTY?} + F -->|no| G[exit 2] + F -->|yes| H["@clack/prompts select"] + H --> I{cancelled?} + I -->|yes| J[exit 130] + I -->|no| C +``` -## Coupling +`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. + +### Shared validate/export pipeline + +`validate` and `export` share every step through the optional flow pass, and diverge only at the last call: + +```mermaid +flowchart TD + A[parseModels] --> B[validateModel] + B --> C{flows/ dir exists?} + C -->|yes| D[parseFlows] --> E[validateFlows] + C -->|no| F{subcommand} + E --> F + F -->|"validate --index"| G[validateIndex] + F -->|export| H[generateApp] +``` + +`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 + +`index` skips the `flows/`-exists guard that `validate` and `export` use — it always runs the flow pipeline, and `--agents` is its only branch: + +```mermaid +flowchart TD + A[parseModels] --> B[validateModel] + B --> C[parseFlows] --> D[validateFlows] + D --> E["buildRouters + writeRouters"] + E --> F{"--agents?"} + F -->|yes| G[writeGuidance] + F -->|no| H[done] +``` -- `cli.ts` calls into **server** (`serveCommand` in [`src/server/server.ts`](../../src/server/server.ts), reached through `serve-port.ts`), **parser** (`parseModels` in [`src/model/parse.ts`](../../src/model/parse.ts)), **validate** (`validateModel`, `formatFindingsForStderr`, `RULES` 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`. -- `validate` and `export` both 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 both subcommands' exit-code logic. -- The compiled binary (`bun run build:cli` → `dist/ignatius`) or `bun src/cli/cli.ts` (the `cli` / `dev:cli` package.json scripts) is the only production entry point into this domain. Outside that, four [`test/checks/`](../../test/checks) files import individual [`src/cli/`](../../src/cli) modules directly for unit testing: [`test/checks/test-discover.ts`](../../test/checks/test-discover.ts) imports `resolveModel` from `src/cli/discover`, [`test/checks/test-serve-port.ts`](../../test/checks/test-serve-port.ts) imports `findAvailablePort`/`isAddrInUse` from `src/cli/serve-port`, [`test/checks/test-open-browser.ts`](../../test/checks/test-open-browser.ts) imports `browserOpenCommand` from `src/cli/open-browser`, and [`test/checks/test-update-helpers.ts`](../../test/checks/test-update-helpers.ts) imports helpers from `src/cli/update`. +Two other subsystems sit off this pipeline. `serveWithPortFallback` ([`src/cli/serve-port.ts`](../../src/cli/serve-port.ts)) wraps `serveCommand`: on `EADDRINUSE` a non-TTY process silently advances to `port + 1` and retries the real bind, while a TTY prompts via `@clack/prompts` `text`, defaulting to the next free port `findAvailablePort` locates by binding and immediately releasing a throwaway `Bun.serve`. `update.ts` drives `ignatius update` against GitHub Releases: `checkForUpdate()` resolves the latest tag from the `releases/latest` redirect `Location` header (no API token needed), and on a dev runtime (`process.execPath` basename is `bun`/`node`) it reports a git-update hint instead of attempting a self-replace, since there is no standalone binary to overwrite. -## Conventions worth knowing +## Where it lives + +| 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/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/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 | +| [`src/cli/update.ts`](../../src/cli/update.ts) | `runUpdateCommand`, plus separately-tested pure helpers `parseVersion`, `compareVersions`, `parseTagFromLocation`, `assetForPlatform`, `parseChecksums` | +| [`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md), [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) | Design/spec pair for the CLI and its output modes | + +## Constraints + +| Condition | Exit code | +|---|---| +| Normal success | 0 | +| Any other CLI error (missing `-o`, bad `--port`, validation Class-B errors, `index`'s try/catch) | 1 | +| `pickModel`: many candidates found, no `--model`, non-TTY | 2 | +| `pickModel`: `select` prompt cancelled; `serveWithPortFallback`: `text` prompt cancelled | 130 | +| `update.ts`'s `confirm` prompt cancelled or declined | 0 (logs `Update cancelled.`, not treated as an error) | + +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. +- `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. +- The compiled binary or `bun src/cli/cli.ts` is the only production entry point; four [`test/checks/`](../../test/checks) files (`test-discover.ts`, `test-serve-port.ts`, `test-open-browser.ts`, `test-update-helpers.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. + +## Coupling -- `@clack/prompts` is imported dynamically, only inside the files that need a TTY prompt (`resolve-model.ts`, `serve-port.ts`, `update.ts`) — kept out of `cli.ts` and out of every non-interactive code path so a CI run can never trigger a TTY-gated prompt. -- Exit-code convention: a cancelled `select` prompt in `resolve-model.ts` or a cancelled `text` prompt in `serve-port.ts` exits 130; "multiple models, can't resolve non-interactively" always exits 2; every other CLI error exits 1. `update.ts`'s `confirm` prompt is the exception — on cancel or decline it logs `Update cancelled.` and returns 0, not 130. -- `dict`, `graph`, and `flow` stay registered as citty subcommands rather than being deleted, purely so they can print a redirect message to `export` — removing them outright would surface citty's generic "unknown command" error instead. +- `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. +- 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 4e3c6f2..773d49e 100644 --- a/docs/wiki/docs.md +++ b/docs/wiki/docs.md @@ -1,6 +1,7 @@ --- type: Domain -description: Design docs, user guides, research notes, and implementation-contract specs for ignatius — the layered documentation corpus other domains' code and tests cite for coupling. +description: The docs corpus (design, spec, guide, research, glossary) that states a feature's why, contract, how, and vocabulary. +tags: [design, spec, guide] --- # docs @@ -9,132 +10,179 @@ description: Design docs, user guides, research notes, and implementation-contra ## What it does -- Houses ignatius's documentation corpus: [`docs/design/`](../design) (conceptual/approach docs), [`docs/guides/`](../guides) (user-facing how-to), [`docs/research/`](../research) (background investigation notes), and [`docs/spec/`](../spec) (implementation contracts) — 72 markdown files total (27 + 10 + 2 + 33). [`docs/glossary.md`](../glossary.md) sits directly under [`docs/`](..) as the shared-vocabulary reference. -- [`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). Start with [`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) for the entity format and the derivation rules." -- [`docs/wiki/`](.) also lives under [`docs/`](..) as the generated signals wiki — out of scope for this domain file. - - -## Docs - - -### [`docs/design/`](../design) — conceptual/approach docs (27 files) - - -- [`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) (333L) — largest design doc; canonical source for the markdown-driven entity file format. No matching [`docs/spec/`](../spec) file of its own exists — [`docs/wiki/feature-map.md`](feature-map.md)'s own "Markdown entity / folder format" row lists the Spec column as `—`. -- [`docs/design/process-flows.md`](../design/process-flows.md) (218L) — design doc for the SSADM DFD subsystem (processes, externals, stores, sub-DFDs). -- [`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) (205L) — design doc for schema lint + error UX. -- [`docs/design/key-inheritance-lineage.md`](../design/key-inheritance-lineage.md) (175L, grew from 132L) — design doc for the key-inheritance-lineage feature: key-edge rule (FK ⊆ child PK, subset test), transitive connected-component lineage, DD dotted line + DG shift+hover reveal. Change log's newest entries (2026-08-01) record two corrections: associative/junction entities are now traversal BARRIERS (reachable but never passed through, to stop a hub like `Tag` welding every parent it links into one lineage), and the `?lineage=legacy` URL escape hatch used to A/B that fix was removed once the rule was accepted. -- [`docs/design/noorm-flow-discovery.md`](../design/noorm-flow-discovery.md) (179L) — design doc adding two modes to the `ignatius-modeling` skill: `flow` (structured Q&A DFD authoring) and `discover` (Socratic interview generating both ERD entities and DFDs from a business description, including reverse-engineering an external system as an evidence source). -- [`docs/design/unified-app.md`](../design/unified-app.md) (152L) — design doc for the unified SPA collapse. -- [`docs/design/branding.md`](../design/branding.md) (160L) — design doc for the branding system. -- [`docs/design/ignatius-modeling-skill.md`](../design/ignatius-modeling-skill.md) (151L) — design doc for the ignatius modeling skill. -- [`docs/design/viewer-fab-ux.md`](../design/viewer-fab-ux.md) (144L) — design doc for the floating action button UX. -- [`docs/design/app-tsx-decomposition.md`](../design/app-tsx-decomposition.md) (142L) — design doc for the `src/App.tsx` → [`src/app/`](../../src/app) decomposition. -- [`docs/design/cli-and-outputs.md`](../design/cli-and-outputs.md) (135L) — design doc for CLI modes and the static output approach. -- [`docs/design/example-instance-tables.md`](../design/example-instance-tables.md) (135L) — design doc for example/sample-row instance tables. -- [`docs/design/viewer-ux-polish.md`](../design/viewer-ux-polish.md) (133L) — design doc for a 6-fix viewer-ux-polish batch (HTML title, spotlight line separation, native 1:1 zoom, Cmd/Ctrl+/-/0 canvas zoom, process node sizing, entity modal history). -- [`docs/design/folder-model.md`](../design/folder-model.md) (104L) — design doc for the folder-model migration: `_*`-prefix vs hoisted top-level folders, hard-cut migration strategy. -- [`docs/design/keyboard-nav-shortcuts.md`](../design/keyboard-nav-shortcuts.md) (110L) — design doc for single-key keyboard navigation shortcuts (keymap rationale, guard philosophy, `kbd-hint` badge UX). -- [`docs/design/ignatius-project-config.md`](../design/ignatius-project-config.md) (107L) — design doc for `ignatius.yml` config + model discovery. -- [`docs/design/graph-position-persistence.md`](../design/graph-position-persistence.md) (118L) — design doc for graph node position persistence. -- [`docs/design/dict-navigation.md`](../design/dict-navigation.md) (100L) — design doc for data-dictionary side navigation. -- [`docs/design/dfd-edge-hover-data.md`](../design/dfd-edge-hover-data.md) (100L) — design doc for DFD edge-hover data reveal (HTML overlay tooltip chosen for zoom-independence). -- [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) (93L) — design doc for the DFD viewer overhaul: Yourdon/SSADM leveling, ELK-driven layout, 5-band partitioning, orthogonal edge routing. -- [`docs/design/graph-flow-search.md`](../design/graph-flow-search.md) (84L) — design doc for search on the Graph and Flows views: dim-don't-filter approach, title-first matching with an "Include descriptions" toggle, shared `SearchBar` component. -- [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) (75L) — design doc for the arbitrary DFD nesting depth fix (root-cause: `renumberLeaf`'s last-segment-only prefix bug). -- [`docs/design/bidirectional-predicates.md`](../design/bidirectional-predicates.md) (67L) — design doc for the bidirectional predicate feature. -- [`docs/design/help-overlay.md`](../design/help-overlay.md) (62L) — design doc for the view-aware help overlay. -- [`docs/design/dd-spotlight-grid.md`](../design/dd-spotlight-grid.md) (60L) — design doc for the DD browse-lens spotlight grid. -- [`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) (59L) — design doc for wiki-style `[[Entity]]` body links. -- [`docs/design/src-root-organization.md`](../design/src-root-organization.md) (49L) — design doc for the [`src/`](../../src) top-level subdirectory split. -- `docs/design/dict-polish.md`, `docs/design/dfd-polish-round2/3/4.md`, `docs/design/render-perf-indexing.md`, `docs/design/unified-app-polish.md`, `docs/design/derive-classification.md` do NOT exist — their specs (below) shipped without a separate design doc. - +[`docs/`](..) (excluding the generated [`docs/wiki/`](.)) is ignatius's documentation corpus: 76 markdown files plus [`docs/glossary.md`](../glossary.md), split across four directories that each answer a different question about a feature. [`docs/design/`](../design) (29 files) states why a feature exists and which approach was chosen over its alternatives. [`docs/spec/`](../spec) (35 files) is the implementation contract derived from a design: checkpoints, success criteria, and (for two specs so far) a change-tree/outline/flows triad. [`docs/guides/`](../guides) (10 files) teaches a user how to drive the built feature. [`docs/research/`](../research) (2 files) 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. + +[`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)." + + +## 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. + +```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] + 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 (seven 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 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. + +```mermaid +stateDiagram-v2 + [*] --> Current: initial spec drafted + Current --> Amended: behavior added, changed, or corrected + Amended --> Current: body rewritten, log entry appended + Current --> Renamed: topic renamed or split + Renamed --> [*]: stub kept one commit, new file is current +``` + +### 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) and [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) are the only two of 35 specs carrying `## Change tree`, `## Outline`, and `## Flows` sections; the other 33 predate the rule that requires them and are not backfilled by an unrelated amendment. 28 of 35 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 (29 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/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/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 | +| [`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 (35 files) + +| Path | Lines | Covers | +|------|-------|--------| +| [`docs/spec/process-flows.md`](../spec/process-flows.md) | 682 | 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/model-index-routing.md`](../spec/model-index-routing.md) | 270 | Router build/write, fingerprint roll-up, `index_file`/`harness` config, four `config.index_file_*`/`index.*` rules, `--agents` guidance files | +| [`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) | 199 | Graph/Flows search (SC1–SC12) | +| [`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) | 189 | `resolveShortcut`, `useKeyboardShortcuts` | +| [`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) | 155 | 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/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/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) | 69 | DFD nesting-depth fix | +| [`docs/spec/help-overlay.md`](../spec/help-overlay.md) | 64 | Help overlay | +| [`docs/spec/noorm-modeling-skill.md`](../spec/noorm-modeling-skill.md) | 12 | Rename stub; points to `ignatius-modeling-skill.md` | + +Seven 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`. 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: - - -- [`docs/guides/getting-started.md`](../guides/getting-started.md) (93L) — install, build from source, serve the first model. -- [`docs/guides/commands.md`](../guides/commands.md) (156L) — the five CLI subcommands (three model commands + two utility commands) and the full keyboard-shortcut table, including search-focus and canvas-pan chords. -- [`docs/guides/folder-format.md`](../guides/folder-format.md) (159L) — `ignatius.yml`, the five recognized top-level folders (`data/`, `flows/`, `groups/`, `externals/`, `stores/`), entity/column/relationship authoring. -- [`docs/guides/derivation.md`](../guides/derivation.md) (45L) — what gets derived (cardinality, classification, subtype clusters) vs authored by hand. -- [`docs/guides/predicates.md`](../guides/predicates.md) (83L) — bidirectional relationship-edge label authoring. -- [`docs/guides/flows.md`](../guides/flows.md) (148L) — DFDs: processes, externals, stores, sub-DFDs, SSADM/Gane-Sarson rendering. -- [`docs/guides/validation.md`](../guides/validation.md) (113L) — the linter, severity tiers, and where findings surface (live viewer, static dictionary/graph, CLI stderr). -- [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) (83L) — `theme`/`branding` blocks in `ignatius.yml`, shared across all three subcommands. -- [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) (71L) — the `/ignatius-modeling` Claude Code skill: entity, flow, model, and discover Q&A modes, verified via `ignatius validate`. -- [`docs/guides/building-from-source.md`](../guides/building-from-source.md) (50L) — Bun build stages (`bun build --compile`), project layout, tests. - +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) | 256 | ★ `ignatius.yml`, the five top-level folders, entity/column/relationship authoring, `index_file:`/`harness:` config, generated routers, `description:` frontmatter | +| [`docs/guides/commands.md`](../guides/commands.md) | 174 | ★ The CLI subcommands including `index`/`index --agents`, `validate --index`, and the full keyboard-shortcut table | +| [`docs/guides/flows.md`](../guides/flows.md) | 150 | ★ DFDs: processes, externals, stores, sub-DFDs, `description:` on process/external/store | +| [`docs/guides/validation.md`](../guides/validation.md) | 137 | ★ The linter, severity tiers, and the new Config-rules/Index-rules tables (`config.index_file_*`, `index.stale`, `index.orphaned`, `index.unreadable_target`) | +| [`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; 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/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (118L) — research notes on SSADM DFD rules. -- [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) (129L) — research notes on DFD layout engines and Yourdon leveling; primary source for the dfd-overhaul design doc's ELK algorithm selection and band-partitioning option names. - - -### [`docs/spec/`](../spec) — implementation contracts (33 files) +### [`docs/glossary.md`](../glossary.md) (52 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 and the `kind:` store taxonomy (`db`/`cache`/`queue`/`file`/`doc`/`manual`/`other`). -- [`docs/spec/process-flows.md`](../spec/process-flows.md) (682L) — largest spec in the corpus; comprehensive implementation contract for SSADM DFD: parse, 11 `flow.*` rules, flow viewer, sub-DFD drill-down, `db:` store → entity dialog, non-entity store kinds, entity↔process cross-reference, DFD URL navigability. -- [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md) (372L) — implementation contract for key-inheritance-lineage: `buildInheritedConnections` key-edge connected-component algorithm, DG ephemeral `edge.inherited` lifecycle, DD `SpotlightOverlay` dotted lines, shift-gated reveal on both surfaces. Its own change log runs through 2026-06-20 only — the design doc's 2026-08-01 associative-entity-barrier and legacy-escape-hatch entries are NOT yet mirrored here (see Concerns). -- [`docs/spec/app-tsx-decomposition.md`](../spec/app-tsx-decomposition.md) (246L) — implementation contract for the `App.tsx` decomposition. -- [`docs/spec/dfd-polish-round3.md`](../spec/dfd-polish-round3.md) (238L) — implementation contract for CP18–23. -- [`docs/spec/dd-spotlight-grid.md`](../spec/dd-spotlight-grid.md) (239L) — implementation contract for the DD browse-lens spotlight grid (`spotlight.ts`, `flow-spotlight.ts`, `GridCard`, `SpotlightOverlay`). -- [`docs/spec/render-perf-indexing.md`](../spec/render-perf-indexing.md) (231L) — implementation contract for the render-perf-indexing batch: preset-layout cache-skip, ELK cost scaling, O(n²)→Map indexing, ELK-in-worker, `buildModelIndex`. -- [`docs/spec/unified-app.md`](../spec/unified-app.md) (216L) — implementation contract for the unified SPA. -- [`docs/spec/ignatius-modeling-skill.md`](../spec/ignatius-modeling-skill.md) (204L) — implementation contract for the ignatius modeling skill. -- [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) (199L) — implementation contract for Graph/Flows search (SC1–SC12). -- [`docs/spec/unified-app-polish.md`](../spec/unified-app-polish.md) (194L) — implementation contract for the CP1–CP13 unified-app-polish batch. -- [`docs/spec/keyboard-nav-shortcuts.md`](../spec/keyboard-nav-shortcuts.md) (189L) — implementation contract for keyboard navigation shortcuts (`resolveShortcut`, `useKeyboardShortcuts`). -- [`docs/spec/viewer-ux-polish.md`](../spec/viewer-ux-polish.md) (180L) — implementation contract for the viewer-ux-polish batch. -- [`docs/spec/dfd-polish-round2.md`](../spec/dfd-polish-round2.md) (169L) — implementation contract for CP14–17. -- [`docs/spec/dfd-polish-round4.md`](../spec/dfd-polish-round4.md) (159L) — implementation contract for CP24–26 (DD sidebar process nesting, IO endpoint clickability, sample-data tables). -- [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (155L) — implementation contract for the DFD viewer overhaul; success criteria C1–C18. -- [`docs/spec/bidirectional-predicates.md`](../spec/bidirectional-predicates.md) (157L) — implementation contract for bidirectional predicates. -- [`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) (141L) — implementation contract for schema lint + error UX. -- [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) (140L) — implementation contract for example/sample-row instance tables. -- [`docs/spec/cli-and-outputs.md`](../spec/cli-and-outputs.md) (133L) — implementation contract for CLI output modes and the theme system. -- [`docs/spec/folder-model.md`](../spec/folder-model.md) (117L) — implementation contract for the folder-model migration. -- [`docs/spec/ignatius-project-config.md`](../spec/ignatius-project-config.md) (108L) — implementation contract for `ignatius.yml` config loading + model discovery. -- [`docs/spec/graph-position-persistence.md`](../spec/graph-position-persistence.md) (106L) — implementation contract for graph node position persistence. -- [`docs/spec/viewer-fab-ux.md`](../spec/viewer-fab-ux.md) (101L) — implementation contract for FAB UX. -- [`docs/spec/branding.md`](../spec/branding.md) (102L) — implementation contract for branding. -- [`docs/spec/dict-navigation.md`](../spec/dict-navigation.md) (90L) — implementation contract for the dict side nav. -- [`docs/spec/dict-polish.md`](../spec/dict-polish.md) (87L) — implementation contract for dict visual polish; no design-doc counterpart. -- [`docs/spec/noorm-flow-discovery.md`](../spec/noorm-flow-discovery.md) (83L) — implementation contract for the `flow`/`discover` skill modes; skill-markdown-only, no [`src/`](../../src) changes. -- [`docs/spec/dfd-edge-hover-data.md`](../spec/dfd-edge-hover-data.md) (83L) — implementation contract for DFD edge-hover data reveal. -- [`docs/spec/src-root-organization.md`](../spec/src-root-organization.md) (82L) — implementation contract for the [`src/`](../../src) directory split. -- [`docs/spec/wiki-entity-links.md`](../spec/wiki-entity-links.md) (79L) — implementation contract for wiki-entity links. -- [`docs/spec/derive-classification.md`](../spec/derive-classification.md) (72L) — implementation contract for the 5-rule classification derivation; no design-doc counterpart. -- [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (69L) — implementation contract for the DFD nesting-depth fix. -- [`docs/spec/help-overlay.md`](../spec/help-overlay.md) (64L) — implementation contract for the help overlay. +[`docs/wiki/`](.) also lives under [`docs/`](..) as the generated signals wiki; it is separate, self-referential infrastructure, out of scope for this domain. -### [`docs/glossary.md`](../glossary.md) (52L) +## Constraints -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 and EE-vs-DS relationship notes and the `kind:` store taxonomy (`db`/`cache`/`queue`/`file`/`doc`/`manual`/`other`). +| 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 2 of 35 specs (`graph-flow-search.md`, `model-index-routing.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 | +| Reserved router filename | `index_file:` (default `index.md`) is reserved model-wide: an entity file under `data/` sharing that basename and declaring `entity:` fails `config.index_file_entity` ([`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) SC3) | +| Guidance files stay under 200 lines | `AGENTS.md`, [`CLAUDE.md`](../../CLAUDE.md), `SKILL.md` generated by `ignatius index --agents` carry only model name, description, router filename, key-style convention, and the `[[Entity]]` rule (SC11); adding entity/column/relationship content would make the file grow with the model and fail SC11 | +| Managed-region writes are byte-scoped | A generator (routers, or the `--agents` guidance files) owns only the bytes inside its own `` tag; a boundary is a tag starting at column 0 and ending its line, and mismatched/nested/orphan/unclosed tags throw with a line number rather than silently corrupting the file | ## Coupling -- [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) — success criteria C4, C16, C17 are cited by name in the **flow-view** domain ([`src/flow-view/elk-flow-layout.ts`](../../src/flow-view/elk-flow-layout.ts), band-layout contract); all six (C4, C5, C13, C15, C16, C17) are checked directly by tests in **frontend**/root test suites ([`test/checks/test-cp4b-elk-edge-routing.ts`](../../test/checks/test-cp4b-elk-edge-routing.ts), `test-cp4c-single-row-bands.ts`, `test-cp4d-frame-alignment.ts`, `test-elk-flow-positions.ts`, [`test/visual/test-cp2-dfd-edge-labels.ts`](../../test/visual/test-cp2-dfd-edge-labels.ts)). -- [`docs/spec/graph-flow-search.md`](../spec/graph-flow-search.md) — SC5 is cited by name in **frontend** ([`src/app/logic/search.ts`](../../src/app/logic/search.ts)) and CP1 by [`test/checks/test-viewer-search.ts`](../../test/checks/test-viewer-search.ts). -- [`docs/spec/derive-classification.md`](../spec/derive-classification.md) — cited by name in [`test/checks/test-validate-entity.ts`](../../test/checks/test-validate-entity.ts), covering the **parser**/**validate** domains' classification-derivation rules. -- [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) — named as the "canonical source" by [`skills/ignatius-modeling/references/entity-flow.md`](../../skills/ignatius-modeling/references/entity-flow.md), coupling this domain to **skill**. -- [`docs/spec/process-flows.md`](../spec/process-flows.md) — its `flow.*` frontmatter/token grammar is matched by [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md), coupling this domain to **skill**. -- [`docs/guides/themes-and-branding.md`](../guides/themes-and-branding.md) — its worked example is pointed to by [`skills/ignatius-modeling/references/model-flow.md`](../../skills/ignatius-modeling/references/model-flow.md), coupling this domain to **theme** and **skill**. -- [`docs/design/markdown-driven-erd.md`](../design/markdown-driven-erd.md) has no [`docs/spec/`](../spec) counterpart — [`docs/wiki/feature-map.md`](feature-map.md)'s own "Markdown entity / folder format" row lists the Spec column as `—`. - - -## Conventions worth knowing - - -- Design and spec docs are usually paired 1:1 by filename (`docs/design/X.md` ↔ `docs/spec/X.md`) — design states the problem/approach, spec is the checkpoint-and-success-criteria build contract. Seven specs ship without a design-doc counterpart (`derive-classification.md`, `dict-polish.md`, `render-perf-indexing.md`, `unified-app-polish.md`, `dfd-polish-round2/3/4.md` — all follow-up polish/perf batches or a small derivation rule). Exactly one design doc has no spec counterpart of its own: `markdown-driven-erd.md` — no [`docs/spec/`](../spec) file exists for it ([`docs/wiki/feature-map.md`](feature-map.md)'s Spec column reads `—` for the markdown entity/folder format row). -- Specs carry a dated `## Change log` section (see [`docs/spec/key-inheritance-lineage.md`](../spec/key-inheritance-lineage.md)'s 5-entry log) recording corrections and superseded behavior, rather than editing history away silently. -- [`docs/glossary.md`](../glossary.md) is the single canonical abbreviation source; specs, code comments, and UI labels are expected to reuse its terms (DG/DD/DFD/DE/DS/EE) rather than coin new ones. -- [`docs/wiki/`](.) is the generated signals wiki and lives under [`docs/`](..) but is separate, self-referential infrastructure — not part of this domain's content. +| 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/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/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/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 2108caa..f75a37f 100644 --- a/docs/wiki/feature-map.md +++ b/docs/wiki/feature-map.md @@ -8,7 +8,7 @@ description: Which design doc, spec, guide, and skill section covers each ignati **Rule: a feature is not done until every surface that covers it is consistent.** When you add or change functionality, update its row below — the design doc (the *why*), the spec (the *contract*), the user guide (the *how*), and the skill section that authors or verifies it. If a change has no row, add one; if it touches a surface not yet listed, add the surface. Drift between these is a reliability bug — the skill teaches one thing, the spec contracts another, the guide documents a third. -Paths are relative to `docs/design/`, `docs/spec/`, `docs/guides/`, and `skills/ignatius-modeling/`. This map is the human-facing complement to `.claude/project/signals.md` (which maps domains → source code). +Paths are relative to [`docs/design/`](../design), [`docs/spec/`](../spec), [`docs/guides/`](../guides), and [`skills/ignatius-modeling/`](../../skills/ignatius-modeling). This map is the human-facing complement to `.claude/project/signals.md` (which maps domains → source code). | Feature | Design | Spec | Guide | Skill | |---------|--------|------|-------|-------| @@ -29,7 +29,7 @@ Paths are relative to `docs/design/`, `docs/spec/`, `docs/guides/`, and `skills/ | DD browse lens: spotlight grid (entity + flow-node cards, hover/pin spotlight; solid FK predicate lines + dashed cross-domain data-flow lines; hover-reveal labels; off-screen chips; facing-edge anchoring; focus/isolate mode for scale) | dd-spotlight-grid | dd-spotlight-grid | — | — | | Graph viewer FAB UX | viewer-fab-ux | viewer-fab-ux | — | — | | Keyboard navigation shortcuts (g/d/f view switch, l DG layout, b DD lens, `/` or `Cmd`/`Ctrl`+`k` search-focus, arrow-key canvas pan on graph/flow — 5px per keydown, 25px with Shift, viewport-movement `{type:'pan',dx,dy}` action routed to `panBy(dx,dy)` on both view handles, dict/editable/ctrl-meta-alt → null; pure resolver + global hook; editable/modifier guards, `Cmd`/`Ctrl`+`k` resolved in the same pre-editable slot as the zoom chords, arrows in the same post-editable/pre-modifier slot as `?` since Shift is their step multiplier) | keyboard-nav-shortcuts | keyboard-nav-shortcuts | commands | — | -| Help overlay (view-aware orientation modal — "what am I looking at?"; `HelpModal` on the shared `Modal`, switched on `ViewName`; concise term→desc rows: Graph = entity types + layouts + Shift lineage + key-inherited vs surrogate; Dict = lenses + spotlight + Shift lineage + search/focus; Flow = DFD symbols + drill-down/inspect; per-view Keyboard section; footnote to Legend on Graph/Flow. Opened by a top-bar `?` button left of the theme toggle AND the `?` key — `resolveShortcut` returns `{type:'help'}`, resolved after the editable guard but before the bare-key modifier guard since `?` needs Shift, gated off ctrl/meta/alt; `useKeyboardShortcuts` `onHelp`; editable guard keeps `?` inert while typing. Distinct from the symbol `LegendModal`. Tests: `test-shortcuts.ts` T16 + `test/checks/test-help-overlay.ts` Playwright) | help-overlay | help-overlay, keyboard-nav-shortcuts | commands | — | +| Help overlay (view-aware orientation modal — "what am I looking at?"; `HelpModal` on the shared `Modal`, switched on `ViewName`; concise term→desc rows: Graph = entity types + layouts + Shift lineage + key-inherited vs surrogate; Dict = lenses + spotlight + Shift lineage + search/focus; Flow = DFD symbols + drill-down/inspect; per-view Keyboard section; footnote to Legend on Graph/Flow. Opened by a top-bar `?` button left of the theme toggle AND the `?` key — `resolveShortcut` returns `{type:'help'}`, resolved after the editable guard but before the bare-key modifier guard since `?` needs Shift, gated off ctrl/meta/alt; `useKeyboardShortcuts` `onHelp`; editable guard keeps `?` inert while typing. Distinct from the symbol `LegendModal`. Tests: `test-shortcuts.ts` T16 + [`test/checks/test-help-overlay.ts`](../../test/checks/test-help-overlay.ts) Playwright) | help-overlay | help-overlay, keyboard-nav-shortcuts | commands | — | | Graph and Flows search (dim-don't-filter title-first matching with a per-bar body-text toggle; Graph: `search-match`/`search-dim` cytoscape classes, `n of N` count readout, Enter cycles ascending-id matches wrapping via `navigateToEntity`, survives hover/lineage/relayout/SSE refresh; Flows: `searchFlowDiagrams` walks every non-synthetic diagram incl. sub-DFDs, results dropdown grouped by diagram navigates via `selectDiagramById`, in-diagram dim keys off suffix-stripped base token; `/` focuses the active view's search bar (Dictionary via `DictionaryViewHandle.focusSearch()`, unchanged otherwise); bundle-only, no model/layout-store/hash/export-payload writes) | graph-flow-search | graph-flow-search | commands | — | | Graph node position persistence (drag-to-save, reset) | graph-position-persistence | graph-position-persistence | — | — | | Business-narrative body + existence/cascade rules | markdown-driven-erd | ignatius-modeling-skill | modeling-skill | entity-flow E9, templates (body sections) | @@ -46,14 +46,15 @@ Paths are relative to `docs/design/`, `docs/spec/`, `docs/guides/`, and `skills/ | Arbitrary DFD nesting depth (dotted process numbers preserve the full ancestor chain at any depth — renumberLeaf recurses + prefixes the full relative number; skill F8 authors decompositions recursively, no depth cap) | dfd-nesting-depth | dfd-nesting-depth | flows | dfd-authoring (F8) | | HTML title from model name (#1; `document.title` = `model._meta.name` else `Ignatius`; SPA runtime effect in `App.tsx` for live + static; `generateApp` rewrites `` with HTML-escaped name on export) | viewer-ux-polish | viewer-ux-polish | — | — | | Entity-modal history + URL sync (#6/#8; `entity=<id>` in the hash is the single source of truth for "which modal is open"; `useHashRoute` owns the history lifecycle — `openEntity` pushState/dedup, `closeEntity` replaceState-drop, `onEntityChange` popstate reconcile; shell `App.tsx` is the single writer — graph tap / dict click / FK hop / flow `db:` store / findings-panel row all route through it; GraphView no longer writes `entity=`, only viewport zoom/pan with flush-time entity re-merge; Back steps the modal stack, close clears the URL) | viewer-ux-polish | viewer-ux-polish | — | — | -| Zoom 100% = native 1:1 (#3; `100%` = 1 diagram unit → 1 CSS px, model-size-independent; initial view + Home still fit-to-screen but the readout shows the true percent; pure `src/flow-view/zoom-scale.ts` helper maps `internalScale × fitScale`; DFD viewBox stays = world box so drag/minimap/pan untouched; graph readout = `cy.zoom()*100`) | viewer-ux-polish | viewer-ux-polish | — | — | +| Zoom 100% = native 1:1 (#3; `100%` = 1 diagram unit → 1 CSS px, model-size-independent; initial view + Home still fit-to-screen but the readout shows the true percent; pure [`src/flow-view/zoom-scale.ts`](../../src/flow-view/zoom-scale.ts) helper maps `internalScale × fitScale`; DFD viewBox stays = world box so drag/minimap/pan untouched; graph readout = `cy.zoom()*100`) | viewer-ux-polish | viewer-ux-polish | — | — | | Pinch + keyboard zoom → canvas (#4; trackpad pinch = `ctrl`/`meta`+wheel and `Cmd`/`Ctrl` +/-/0 zoom the active canvas, never the browser page, on DG + DFD; native non-passive `wheel` listener on each canvas `preventDefault`s page-zoom while the canvas's own handler still zooms — React `onWheel` is passive so its preventDefault is a no-op; resolver `shortcuts.ts` adds `zoomIn`/`zoomOut`/`zoomReset` actions resolved before the bare-key guards, gated on ctrl/meta only, bypassing the editable guard; `useKeyboardShortcuts` routes to the active view handle, dict no-op) | viewer-ux-polish | viewer-ux-polish, keyboard-nav-shortcuts | commands | — | -| DFD process node sizes to text (#5; pure `processNodeSize(label)` in `src/flow-view/flow-layout.ts` word-wraps the label and returns `{lines,width,height}` with a `120×68` min floor — short names look unchanged, long names grow; same helper feeds ELK `nodeSize` AND the `FlowDiagramSvg` renderer via a shared `sizingLabel(node)` so the rect drawn equals the box ELK laid out — removed the old 130/120 + 64/68 mismatch; `ProcessNode` renders all wrapped lines, badge stays top-left; `test-cp4c-single-row-bands` C16 rephrased to a vertical-overlap strip check since process heights are now label-derived) | viewer-ux-polish | viewer-ux-polish | — | — | -| DD spotlight separate lines (#2; a spotlit DD browse-lens `both`/multi-edge bundle fans into SEPARATE `<path>` elements — one per edge/direction with offset connection points — never one path with arrowheads at both ends; "always separate" at rest; pure `src/app/logic/spotlight-lines.ts` `separateSpotlightLines(base, directions)` offsets perpendicular to the line axis — HORIZONTAL anchor spreads y, VERTICAL spreads x, symmetric `(i−(K−1)/2)·14px` about the base midpoint; K=1 → unchanged base line so the common single-FK look is bit-identical; `SpotlightOverlay` keeps `computeAnchor` DOM measurement then calls the helper and draws one path per spec, single arrowhead each; same separation on the dashed flow lines; `buildSpotlightConnections`/`buildFlowSpotlightConnections` bundling contract + pill second-pass + scrollport-skip + off-screen chips untouched) | viewer-ux-polish | viewer-ux-polish | — | — | -| DD spotlight inherited 1:1 key-inheritance connections (#9, CP7; SUPERSEDED by `key-inheritance-lineage` CP-A — kept for history; bounded to subtype clusters, single-level; pure `src/app/logic/spotlight-inherited.ts` `buildInheritedConnections(index, entityId)` → `InheritedConnection[]` (`{otherId, direction, via}`, `INHERITED_IDENTITY='identity'`); member → basetype + sibling identity links + basetype's direct rels (via=basetype); basetype → members + each member's direct rels (via=member); transitive rels de-dup against the active's own direct edges, identity links exempt; general identifying-1:1 dependent tables were a noted non-goal here — now generalized below; `SpotlightOverlay` draws a THIRD line category DOTTED in `--spotlight-line-inherited` green via the CP6 `separateSpotlightLines` path with "via <basetype>"/"shared key" pills + off-screen `spotlight-chip--inherited` chips; `DictionaryView` folds inherited ids into `spotlitIds`+`focusSet`; `buildSpotlightConnections` unchanged) | viewer-ux-polish | viewer-ux-polish | — | — | -| Key-inheritance lineage (GENERALIZES #9/CP7 above; corrected to the key-edge connected-component model — `src/app/logic/spotlight-inherited.ts` `buildInheritedConnections` keeps its export name + `InheritedConnection {otherId,direction,via}` shape + `INHERITED_IDENTITY='identity'` so `DictionaryView`/`SpotlightOverlay`/`GraphView` are unchanged; **lineage follows ONLY key edges** — an edge whose child-side FK cols (`Object.keys(edge.on)`) are ALL ⊆ the child PK (`pkByNode.get(edge.source)`), a SUBSET test (FK ⊆ PK, non-empty), NEVER a secondary/non-key FK; this one predicate catches identifying-1:many (FK a PROPER subset of the PK, e.g. `SalesInvoice→Party` on `party_no`) AND subtype member→basetype (FK==full PK) — empirically `edge.identifying`==FK⊆PK on `key-inherited`; **lineage** = transitive connected component over key edges in BOTH directions (undirected), cycle-safe visited map; inherited = lineage − self − direct real-edge neighbours (those render solid); `direction='out'` (DD draws ONE source-out line — single arrowhead at the far/member end, pointing FROM the active card OUT to the member; was `'both'`), `via`=nearest key-edge predecessor on the path (or `INHERITED_IDENTITY`); bundle one per otherId, sort by otherId, singleton lineage → []; **DD inherited lines are SHIFT-GATED (mirrors DG):** in the browse lens the dotted inherited lines appear ONLY while Shift is held over an active (hover/pin) card — `DictionaryView` carries a `shiftHeld` state driven by a document `keydown`/`keyup` pair on `Shift` + a `window` blur reset; the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId`, and the inherited-id foldings into `spotlitIds`/`focusSet` are gated on `shiftHeld` too (no lit/extra-focused inherited cards or off-screen inherited chips without Shift); FK (solid) + flow (dashed) lines are UNCHANGED (plain hover/pin); `SpotlightOverlay` unchanged (renders `inheritedConnections=[]` as zero lines/chips); no longer walks subtype-cluster maps (member→basetype IS a key edge) and no longer calls `buildSpotlightConnections` for de-dup; SUPERSEDES the old subtype-cluster + dependent-identifying-1:1 (FK==full PK + 1:1) + per-member secondary-FK expansion, which over-connected via secondary FKs (`SI_Line→Product`/`→LineItemType`) and missed identifying-1:many lineage; proven on `models/key-inherited`: `SSN` reaches the party-keyed family (`SalesInvoice`/`SI_Line`/`SalesOrder`/`SO_Line`/`PaymentAllocation`…) and EXCLUDES `Product`/`Subscription`/`LineItemType`/`PartyType`, `SI_Line` no longer over-connects, ORM surrogate-PK models have zero lineage. **CP-B (DG dotted lines), SHIFT+HOVER trigger:** lineage is revealed by SHIFT+HOVER, NOT click/select. While Shift is held and the pointer is over a node, `GraphView` `enterLineageHover` calls `drawInheritedEdges` to add EPHEMERAL dotted cytoscape edges (class `inherited`, id `_inherited_<sel>__<other>`) from the hovered node to each inherited `otherId` present in cy, then `applyFocusTiers`; styled in `styles.ts` (`edge.inherited`: dotted, arrowless, thin) using the new `SPOTLIGHT_LINE_INHERITED[mode]` constant exported from `theme-css-vars.ts` (single source of truth shared with the DD CSS var so DG==DD); endpoints folded into the focus-fade `keep` set so they stay lit. Trigger wiring: `mouseover` branches on `evt.originalEvent?.shiftKey` (shift → lineage, no-shift → plain direct-neighbour fade); `mouseout` exits (`exitLineageHover`); a document-level `Shift` keydown/keyup pair toggles lineage on the live hovered node (`hoveredNodeIdRef`) so holding/releasing Shift while already hovering works; all state in refs (stale-closure-safe), listeners removed in cy-init cleanup. A plain click now SELECTS + opens the modal only (no lineage); select/navigate/panel/hash-restore paths no longer draw lineage. `clearInheritedEdges` (`cy.remove('edge.inherited')`) on mouseout/shift-release/deselect/reselect/reset/applyLayoutMode-before-ELK/no-entity-restore/teardown; never enters model/`layoutFingerprint`/`layout-store`/static-export/ELK — added after layout, removed before re-layout; no-leak unit check (`test-inherited-edges-no-leak.ts`, trigger-agnostic) + Playwright DG check (`test-graph-inherited-edges.ts`: plain click → 0 inherited; shift+hover Identity → dotted rays; mouseout → 0; shift+hover ITIN strictly larger transitive set; plain hover → 0; deselect → 0) + visual screenshot (`test/visual/test-graph-inherited-lines.ts`: SSN + SI_Line owner cases via shift+hover). **3-tier focus opacity:** `GraphView` `applyFocusTiers(focusNode)` (run on shift+hover lineage + plain hover; cleared on mouseout/shift-release/deselect/reselect/relayout/teardown) splits the focused state into DIRECT (focused node + its REAL graph neighbors via `connectedEdges().not('.inherited')` + identifying lineage/descendants + joiners → opacity **1.0**), INHERITED/ancestral (the `edge.inherited` rays + their target nodes minus direct, `inherited-dim` → **0.5**), UNRELATED (everything else, `faded` → **0.2**); `styles.ts` `.faded` 0.3→0.2, new `.inherited-dim` 0.5, `edge.inherited` opacity 0.85→0.5; direct wins de-dup (`.difference(direct)` + `buildInheritedConnections` already de-dups); visual harness reads per-tier opacity off the live cy and asserts `direct>inherited>unrelated`) | key-inheritance-lineage | key-inheritance-lineage | — | — | +| DFD process node sizes to text (#5; pure `processNodeSize(label)` in [`src/flow-view/flow-layout.ts`](../../src/flow-view/flow-layout.ts) word-wraps the label and returns `{lines,width,height}` with a `120×68` min floor — short names look unchanged, long names grow; same helper feeds ELK `nodeSize` AND the `FlowDiagramSvg` renderer via a shared `sizingLabel(node)` so the rect drawn equals the box ELK laid out — removed the old 130/120 + 64/68 mismatch; `ProcessNode` renders all wrapped lines, badge stays top-left; `test-cp4c-single-row-bands` C16 rephrased to a vertical-overlap strip check since process heights are now label-derived) | viewer-ux-polish | viewer-ux-polish | — | — | +| DD spotlight separate lines (#2; a spotlit DD browse-lens `both`/multi-edge bundle fans into SEPARATE `<path>` elements — one per edge/direction with offset connection points — never one path with arrowheads at both ends; "always separate" at rest; pure [`src/app/logic/spotlight-lines.ts`](../../src/app/logic/spotlight-lines.ts) `separateSpotlightLines(base, directions)` offsets perpendicular to the line axis — HORIZONTAL anchor spreads y, VERTICAL spreads x, symmetric `(i−(K−1)/2)·14px` about the base midpoint; K=1 → unchanged base line so the common single-FK look is bit-identical; `SpotlightOverlay` keeps `computeAnchor` DOM measurement then calls the helper and draws one path per spec, single arrowhead each; same separation on the dashed flow lines; `buildSpotlightConnections`/`buildFlowSpotlightConnections` bundling contract + pill second-pass + scrollport-skip + off-screen chips untouched) | viewer-ux-polish | viewer-ux-polish | — | — | +| DD spotlight inherited 1:1 key-inheritance connections (#9, CP7; SUPERSEDED by `key-inheritance-lineage` CP-A — kept for history; bounded to subtype clusters, single-level; pure [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) `buildInheritedConnections(index, entityId)` → `InheritedConnection[]` (`{otherId, direction, via}`, `INHERITED_IDENTITY='identity'`); member → basetype + sibling identity links + basetype's direct rels (via=basetype); basetype → members + each member's direct rels (via=member); transitive rels de-dup against the active's own direct edges, identity links exempt; general identifying-1:1 dependent tables were a noted non-goal here — now generalized below; `SpotlightOverlay` draws a THIRD line category DOTTED in `--spotlight-line-inherited` green via the CP6 `separateSpotlightLines` path with "via <basetype>"/"shared key" pills + off-screen `spotlight-chip--inherited` chips; `DictionaryView` folds inherited ids into `spotlitIds`+`focusSet`; `buildSpotlightConnections` unchanged) | viewer-ux-polish | viewer-ux-polish | — | — | +| Key-inheritance lineage (GENERALIZES #9/CP7 above; corrected to the key-edge connected-component model — [`src/app/logic/spotlight-inherited.ts`](../../src/app/logic/spotlight-inherited.ts) `buildInheritedConnections` keeps its export name + `InheritedConnection {otherId,direction,via}` shape + `INHERITED_IDENTITY='identity'` so `DictionaryView`/`SpotlightOverlay`/`GraphView` are unchanged; **lineage follows ONLY key edges** — an edge whose child-side FK cols (`Object.keys(edge.on)`) are ALL ⊆ the child PK (`pkByNode.get(edge.source)`), a SUBSET test (FK ⊆ PK, non-empty), NEVER a secondary/non-key FK; this one predicate catches identifying-1:many (FK a PROPER subset of the PK, e.g. `SalesInvoice→Party` on `party_no`) AND subtype member→basetype (FK==full PK) — empirically `edge.identifying`==FK⊆PK on `key-inherited`; **lineage** = transitive connected component over key edges in BOTH directions (undirected), cycle-safe visited map; inherited = lineage − self − direct real-edge neighbours (those render solid); `direction='out'` (DD draws ONE source-out line — single arrowhead at the far/member end, pointing FROM the active card OUT to the member; was `'both'`), `via`=nearest key-edge predecessor on the path (or `INHERITED_IDENTITY`); bundle one per otherId, sort by otherId, singleton lineage → []; **DD inherited lines are SHIFT-GATED (mirrors DG):** in the browse lens the dotted inherited lines appear ONLY while Shift is held over an active (hover/pin) card — `DictionaryView` carries a `shiftHeld` state driven by a document `keydown`/`keyup` pair on `Shift` + a `window` blur reset; the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId`, and the inherited-id foldings into `spotlitIds`/`focusSet` are gated on `shiftHeld` too (no lit/extra-focused inherited cards or off-screen inherited chips without Shift); FK (solid) + flow (dashed) lines are UNCHANGED (plain hover/pin); `SpotlightOverlay` unchanged (renders `inheritedConnections=[]` as zero lines/chips); no longer walks subtype-cluster maps (member→basetype IS a key edge) and no longer calls `buildSpotlightConnections` for de-dup; SUPERSEDES the old subtype-cluster + dependent-identifying-1:1 (FK==full PK + 1:1) + per-member secondary-FK expansion, which over-connected via secondary FKs (`SI_Line→Product`/`→LineItemType`) and missed identifying-1:many lineage; proven on [`models/key-inherited`](../../models/key-inherited): `SSN` reaches the party-keyed family (`SalesInvoice`/`SI_Line`/`SalesOrder`/`SO_Line`/`PaymentAllocation`…) and EXCLUDES `Product`/`Subscription`/`LineItemType`/`PartyType`, `SI_Line` no longer over-connects, ORM surrogate-PK models have zero lineage. **CP-B (DG dotted lines), SHIFT+HOVER trigger:** lineage is revealed by SHIFT+HOVER, NOT click/select. While Shift is held and the pointer is over a node, `GraphView` `enterLineageHover` calls `drawInheritedEdges` to add EPHEMERAL dotted cytoscape edges (class `inherited`, id `_inherited_<sel>__<other>`) from the hovered node to each inherited `otherId` present in cy, then `applyFocusTiers`; styled in `styles.ts` (`edge.inherited`: dotted, arrowless, thin) using the new `SPOTLIGHT_LINE_INHERITED[mode]` constant exported from `theme-css-vars.ts` (single source of truth shared with the DD CSS var so DG==DD); endpoints folded into the focus-fade `keep` set so they stay lit. Trigger wiring: `mouseover` branches on `evt.originalEvent?.shiftKey` (shift → lineage, no-shift → plain direct-neighbour fade); `mouseout` exits (`exitLineageHover`); a document-level `Shift` keydown/keyup pair toggles lineage on the live hovered node (`hoveredNodeIdRef`) so holding/releasing Shift while already hovering works; all state in refs (stale-closure-safe), listeners removed in cy-init cleanup. A plain click now SELECTS + opens the modal only (no lineage); select/navigate/panel/hash-restore paths no longer draw lineage. `clearInheritedEdges` (`cy.remove('edge.inherited')`) on mouseout/shift-release/deselect/reselect/reset/applyLayoutMode-before-ELK/no-entity-restore/teardown; never enters model/`layoutFingerprint`/`layout-store`/static-export/ELK — added after layout, removed before re-layout; no-leak unit check (`test-inherited-edges-no-leak.ts`, trigger-agnostic) + Playwright DG check (`test-graph-inherited-edges.ts`: plain click → 0 inherited; shift+hover Identity → dotted rays; mouseout → 0; shift+hover ITIN strictly larger transitive set; plain hover → 0; deselect → 0) + visual screenshot ([`test/visual/test-graph-inherited-lines.ts`](../../test/visual/test-graph-inherited-lines.ts): SSN + SI_Line owner cases via shift+hover). **3-tier focus opacity:** `GraphView` `applyFocusTiers(focusNode)` (run on shift+hover lineage + plain hover; cleared on mouseout/shift-release/deselect/reselect/relayout/teardown) splits the focused state into DIRECT (focused node + its REAL graph neighbors via `connectedEdges().not('.inherited')` + identifying lineage/descendants + joiners → opacity **1.0**), INHERITED/ancestral (the `edge.inherited` rays + their target nodes minus direct, `inherited-dim` → **0.5**), UNRELATED (everything else, `faded` → **0.2**); `styles.ts` `.faded` 0.3→0.2, new `.inherited-dim` 0.5, `edge.inherited` opacity 0.85→0.5; direct wins de-dup (`.difference(direct)` + `buildInheritedConnections` already de-dups); visual harness reads per-tier opacity off the live cy and asserts `direct>inherited>unrelated`) | key-inheritance-lineage | key-inheritance-lineage | — | — | | Glossary of app terms (DG/DD/DFD/DE/DS/EE; DS⊃DE) | — | — | `../glossary.md` | — | +| Model index routing (generated per-folder `index.md` routers with name/kind/description/link tables; rolled-up SHA digests; `<ignatius-index>` and `<ignatius-breadcrumb>` managed regions beside hand-authored `<ignatius-rules>`; `index_file:` and `harness:` keys in `ignatius.yml`; `ignatius index [--agents]` writes routers and in-folder `AGENTS.md` / [`CLAUDE.md`](../../CLAUDE.md) shim / `SKILL.md` guidance; `ignatius validate --index` reports drift as `index.stale`; `description:` on all five file kinds is the router payload; [`models/llm-memory-db-mssql`](../../models/llm-memory-db-mssql) is the fully realized exemplar) | model-index-routing | model-index-routing | folder-format | SKILL core rule (description-always, reserved `index_file`), entity-flow E1b, dfd-authoring F6a, conventions (reserved filename), verification (`validate --index` in the loop + `config.index_file_*` / `index.*` rule rows) | -◆ **Process flows — implemented and first-class, now an in-app view.** The `ignatius flow` CLI subcommand has been removed; flows are the **Flows** view inside the unified SPA (`serve`) and are included in the single `export -o model.html` file. The process dictionary is fused into the unified **Dictionary** view (no separate `/flow-dict` route). The **flow viewer is a custom SVG renderer** (`src/flow-view/`, separate from the ERD's Cytoscape): Gane-Sarson notation (open-ended `D#` stores, numbered process hubs, green externals), banded DFD layout (hub-and-spoke to stores/externals, no process-to-process, shared store bridging). Every node carries a ⓘ badge — a **`db:` store** opens the rich `SelectedEntityModal` (attributes, relationships, examples) shared with graph nodes; a process / external / non-`db` store opens the plain markdown doc dialog. Flow bodies parse `[[wiki-links]]` that route in-dialog across both flow nodes and ERD entities. `models/key-inherited` carries demo DFDs (`order-to-cash` with a sub-DFD + `refund`). Skill coverage: the `ignatius-modeling` skill's `flow` mode authors flow markdown (see the skill-modes row above). Guide coverage: `docs/guides/flows.md` (folder format, process frontmatter, endpoints, sub-DFDs, viewing) plus the flow rule catalog in `docs/guides/validation.md`. See `docs/spec/process-flows.md` Non-goals + the design's Open questions for the deferred set (queue-payload validation, usage index). +◆ **Process flows — implemented and first-class, now an in-app view.** The `ignatius flow` CLI subcommand has been removed; flows are the **Flows** view inside the unified SPA (`serve`) and are included in the single `export -o model.html` file. The process dictionary is fused into the unified **Dictionary** view (no separate `/flow-dict` route). The **flow viewer is a custom SVG renderer** ([`src/flow-view/`](../../src/flow-view), separate from the ERD's Cytoscape): Gane-Sarson notation (open-ended `D#` stores, numbered process hubs, green externals), banded DFD layout (hub-and-spoke to stores/externals, no process-to-process, shared store bridging). Every node carries a ⓘ badge — a **`db:` store** opens the rich `SelectedEntityModal` (attributes, relationships, examples) shared with graph nodes; a process / external / non-`db` store opens the plain markdown doc dialog. Flow bodies parse `[[wiki-links]]` that route in-dialog across both flow nodes and ERD entities. [`models/key-inherited`](../../models/key-inherited) carries demo DFDs (`order-to-cash` with a sub-DFD + `refund`). Skill coverage: the `ignatius-modeling` skill's `flow` mode authors flow markdown (see the skill-modes row above). Guide coverage: [`docs/guides/flows.md`](../guides/flows.md) (folder format, process frontmatter, endpoints, sub-DFDs, viewing) plus the flow rule catalog in [`docs/guides/validation.md`](../guides/validation.md). See [`docs/spec/process-flows.md`](../spec/process-flows.md) Non-goals + the design's Open questions for the deferred set (queue-payload validation, usage index). **Example instance tables — implemented and reconciled.** Structured `examples:` frontmatter → `ModelNode.examples` (`src/parse.ts`) → dict/graph accordions + the `entity.example_unknown_column` validator rule (live-server-only — `ignatius validate` never prints it, so the skill self-checks example keys at authoring time). The skill authors `examples:` frontmatter (entity-flow E7b, templates); older entities may still carry a prose `## Sample rows` body section, which the skill reads when seeding flow examples but no longer emits. diff --git a/docs/wiki/flows.md b/docs/wiki/flows.md index 1586cb6..1bfb64e 100644 --- a/docs/wiki/flows.md +++ b/docs/wiki/flows.md @@ -1,46 +1,112 @@ --- type: Domain description: SSADM data flow diagram parsing, level derivation, validation, fingerprinting, and usage indexing. +tags: [flows, parser, validate] --- # flows ## What it does -Parses `flows/*/` process markdown (paired with model-root `externals/` and `stores/` registries) into a `FlowModel`, derives a synthetic context (Level 0) + Level-1 overview above the parsed leaf diagrams, validates the tree against 12 `flow.*` rules, and exposes structural fingerprinting plus entity/flow-node usage indexes for other domains to consume. Pure, browser-safe modules (no Bun/Node I/O outside `flow-parse.ts`) — `flow-derive-levels.ts`, `flow-validate.ts`, `flow-fingerprint.ts`, and `flow-usage-index.ts` take a `FlowModel`/`FlowDiagram` and return derived data with no side effects. +[`src/flows/`](../../src/flows) turns a model's `flows/*/` process markdown into the SSADM data-flow-diagram tree that the app renders, validates, and cross-references. Without this domain the app has no DFD view at all: `flow-view` has nothing to lay out, `validate`'s `flow.*` rules have nothing to check, and the entity dialog's Processes tab has nothing to list. Six modules split the job: parse the leaf diagrams, synthesize the context/L1 diagrams above them, validate the whole tree against 12 `flow.*` rules, fingerprint each diagram's topology for layout caching, index which processes read or write each store/external, and turn slugs into display titles. Every module past the parser is pure and browser-safe — no Bun/Node I/O outside `flow-parse.ts` — so `flow-view` and the frontend can call them directly on data already in memory. -## CLI code +## How it works -- [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (771L) — SSADM DFD parser. Exports `parseFlows(modelDir): Promise<FlowParseResult>`, `parseProcessExamples()`, `resolveEndpoint()`, and the core types (`FlowModel`, `FlowDiagram`, `FlowProcess`, `FlowExternal`, `FlowStoreRef`, `FlowEdge`, `FlowEndpoint`, `FlowExample`, `FlowExampleRow`, `FlowParseResult`). Discovers DFD folders under `<modelDir>/flows/`; externals and stores are declared once at `<modelDir>/externals/` and `<modelDir>/stores/` and shared across all diagrams (no per-DFD override). `FlowModel.externals` carries the complete root external registry (all `externals/` entries) for the validator's global-namespace checks; each `FlowDiagram.externals` holds only externals referenced *and* defined in that diagram's rendered set. Recurses into same-named sub-folders to build nested sub-DFDs, composing dotted SSADM numbers (`parentDottedNumbers` + local `number:` frontmatter or folder-order fallback). After raw parsing, calls `deriveLevels(rawFlowModel)` before returning, so every consumer receives the leveled tree. -- [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) (434L) — level derivation. Exports `deriveLevels(flowModel): FlowModel`, `CONTEXT_DIAGRAM_ID = '__context__'`, `SYSTEM_PROCESS_ID = '__system__'`, `SYNTHETIC_DIAGRAM_IDS: ReadonlySet<string>`. Takes the flat parsed leaf diagrams and wraps them in two synthetic diagrams: a context diagram (one system-bubble process + all cross-system externals + external↔system flows) and an L1 overview (one process per leaf, dotted `1`…`N`; stores whose degree across leaves ≥ 2 are promoted to L1, degree-1 stores stay local to their leaf). `renumberDiagram` recursively prefixes the L1 parent number to each process's full relative `dottedNumber` and recurses into `subDfds`, preserving ancestor depth at any nesting level (`N.a`, `N.a.b`, `N.a.b.c`, …); the parser was not changed for this — it already produced correct relative numbers. -- [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) (727L) — exports `validateFlows(flowModel, model, config): FlowValidationResult` and `FlowError`, `FlowRulesConfig` types. Implements 12 `flow.*` rules split into Class B (strip the offending edge: `unknown_store`, `unknown_external`, `unknown_process`, `illegal_connection`) and Class A (record-only: `unknown_attribute`, `ambiguous_endpoint`, `process_to_process` — silenceable via `config.process_to_process === false`, `process_no_input`/`process_no_output`, `duplicate_number`, `unbalanced_decomposition`, `store_naming_collision`). `checkUnbalancedDecomposition` recursively compares each sub-DFD's boundary `db:` columns against the parent process's own inputs/outputs at every seam. `validateDiagram` skips rule checks on the synthetic context/L1 diagrams (`diagram.id === CONTEXT_DIAGRAM_ID || SYSTEM_PROCESS_ID`, imported from `flow-derive-levels.ts`) but still recurses into their `subDfds` to reach real leaves. `checkStoreNamingCollisions` walks the whole tree once, before per-diagram validation, to catch the same store token resolving to conflicting `displayName`s across diagrams. -- [`src/flows/flow-fingerprint.ts`](../../src/flows/flow-fingerprint.ts) (90L) — exports `buildFlowLayoutKeys(flowModel): Record<string, string>` and `layoutFlowFingerprint(diagram): string`. Hand-rolled FNV-1a 32-bit hash (not `Bun.hash`, so the function stays pure and environment-independent) over sorted resolved `kind:name` process/external/store-ref ids and edge pairs — invariant to labels, body text, column names, and numbering; sensitive to topology changes only. -- [`src/flows/flow-usage-index.ts`](../../src/flows/flow-usage-index.ts) (244L) — exports `buildEntityUsageIndex(diagrams): Map<string, ProcessUsage[]>` (legacy, `db:` endpoints only, keyed by bare entity id) and `buildFlowNodeUsageIndex(diagrams): Map<string, ProcessUsage[]>` (token-keyed superset covering all non-`proc` endpoint kinds, e.g. `"ext:Customer"`, `"file:gateway-log"`, `"db:Payment"`). Both walk diagrams recursively including sub-DFDs and merge read/write into a `ProcessUsage.direction` of `'read' | 'write' | 'readwrite'`. -- [`src/flows/titlelize.ts`](../../src/flows/titlelize.ts) (47L) — exports `titlelize(slug): string`. Pure, framework-free. Splits on hyphens/underscores, then on camelCase/ACRONYM/digit boundaries within each segment, title-cases each word, joins with spaces (`order-to-cash` → `"Order To Cash"`, `HTTPRequest` → `"HTTP Request"`). Used throughout the parser to derive display labels (`FlowDiagram.title`, `FlowStoreRef.displayName`, `FlowExternal` label) whenever no `title:` frontmatter override is present. +**Pipeline: parse → level → validate.** `parseFlows` reads the model's DFD folders into flat leaf diagrams, then calls `deriveLevels` before returning, so every caller always receives the leveled tree, never raw leaves. `validateFlows` is a separate call the consumer makes afterward. -## Docs +```mermaid +flowchart LR + A["parseFlows(modelDir)"] --> B["deriveLevels(rawFlowModel)"] + B --> C["FlowParseResult{flowModel, globalErrors}"] + C --> D["validateFlows(flowModel, entityModel, config)"] + D --> E["FlowValidationResult{flowErrors, cleanedFlowModel}"] +``` -- [`docs/design/process-flows.md`](../design/process-flows.md) / [`docs/spec/process-flows.md`](../spec/process-flows.md) — original SSADM DFD design and implementation contract: file-per-process markdown authoring, `db:` store resolution against the entity catalog, the `flow.*` rule registry. -- [`docs/design/folder-model.md`](../design/folder-model.md) / [`docs/spec/folder-model.md`](../spec/folder-model.md) — the `data/` + `flows/` root-registry restructure (issue #16): externals/stores declared once at model root, no `_*`-prefix nesting. -- [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) / [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) — arbitrary nesting depth fix (issue #15) that produced `renumberDiagram` in `flow-derive-levels.ts`. -- [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) / [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) — leveling + layout overhaul; the leveling half (context/L1 derivation, store-promotion degree≥2 rule, balancing at synthetic seams) is this domain's CP4 origin, the layout half belongs to flow-view. -- [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md) — research evidence base backing the leveling hierarchy design. -- [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) — canonical SSADM/DFD notation reference backing the `flow.*` validator rules, with an adoption table recording where ignatius deviates. -- [`docs/guides/flows.md`](../guides/flows.md) — user-facing guide to authoring and reading DFDs in the app. +`deriveLevels` runs unconditionally inside `parseFlows` (`src/flows/flow-parse.ts:812`); `validateFlows` (`src/flows/flow-validate.ts:698`) is invoked separately by cli/server/generators once they also have the entity `Model`. -## Coupling +### Reserved-name skip during folder scans + +`parseDiagramFolder`, `readExternalsDir`, and the root `stores/` scan in `parseFlows` all take the same `indexFileName` parameter (default `'index.md'`) and skip a file matching it before treating the rest as a process, external, or store definition. + +```mermaid +flowchart TD + A["*.md file found in flows/<dfd>/, externals/, or stores/"] --> B{"filename === indexFileName?"} + B -->|yes| C[skip — not parsed as process/external/store] + B -->|no| D[parse frontmatter as process / external / store] +``` + +Skip sites: `src/flows/flow-parse.ts:402` (externals), `:506` (process files), `:728` (stores). + +### Level derivation: leaves wrapped in two synthetic diagrams + +`deriveLevels` (`src/flows/flow-derive-levels.ts:406`) never mutates or re-parses leaves; it wraps the flat leaf array the parser produced inside a context (Level 0) diagram and an L1 overview diagram. + +```mermaid +flowchart TD + subgraph Context["context (id = __context__)"] + SysProc["systemProc (id = __system__)"] + end + subgraph L1["L1 overview (id = __system__)"] + P1["proc per leaf, dottedNumber 1..N"] + Stores["promoted stores (degree >= 2)"] + end + Context -->|"subDfds[0]"| L1 + L1 -->|subDfds| Leaf1["leaf diagram 1 (renumbered)"] + L1 -->|subDfds| Leaf2["leaf diagram 2 (renumbered)"] +``` + +Context collects every external↔process boundary edge across all leaves and re-targets the process end at the single `systemProc`, deduped per `(extId, direction)` (`deriveContext`, `flow-derive-levels.ts:147`). L1 gets one process per leaf plus any store whose degree (distinct referencing leaves) is `>= 2` (`buildStoreDegreeMap` / `collectPromotedStores`, `flow-derive-levels.ts:56-91`); degree-1 stores stay local to their leaf and never appear at L1. `renumberDiagram` prefixes the L1 parent number onto each process's existing relative `dottedNumber` and recurses into `subDfds` at any depth, so a process 3 levels deep under leaf `N` gets `N.a.b.c` rather than losing its ancestry. -- **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)) — imports `type FlowError` from `flow-validate.ts` (type-only, no runtime circular dependency); defines the `flow.*` `RuleId` union and their explanation text; merges `flowErrors` into the combined validation summary. A new `flow.*` rule requires a matching entry in `validate.ts`'s `RuleId` union and explanation table. -- **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)) — dynamically imports `parseFlows` for the `validate` and `export` commands; folds flow Class-B errors into the same exit-code-1 decision as entity errors. -- **server** ([`src/server/server.ts`](../../src/server/server.ts)) — imports `parseFlows`, `validateFlows`, and `buildFlowLayoutKeys` directly for the `/api/flow` route, returning `{ diagrams, entityModel, validation, flowLayoutKeys }`. -- **generators** ([`src/generators/app.ts`](../../src/generators/app.ts)) — imports `type FlowModel` and `buildFlowLayoutKeys` to embed `window.__FLOW_MODEL__` and `window.__FLOW_LAYOUT_KEYS__` into the exported static HTML bundle; a null `flowModel` means no `flows/` directory existed. -- **flow-view** (`src/flow-view/*`) — a separate domain (ELK layout + SVG rendering). `elk-flow-layout.ts`, `flow-layout.ts`, `FlowChrome.tsx`, and `FlowDiagramSvg.tsx` import `type FlowDiagram`/`FlowStoreRef` only — no runtime dependency, but any shape change to `FlowDiagram`, `FlowProcess`, `FlowStoreRef`, or edge endpoint kinds forces a review of all four files. The frontend also reads pre-computed layout keys from `window.__FLOW_LAYOUT_KEYS__` or the `/api/flow` payload rather than importing `flow-fingerprint.ts` directly. -- **frontend** (`src/app/*`) — many components and view/logic modules (`App.tsx`, `hooks/useModelData.ts`, `logic/doc-resolver.ts`, `logic/search.ts`, `logic/flow-spotlight.ts`, `views/flow/FlowsView.tsx`, `views/dict/DictionaryView.tsx`, `components/process/*`, `components/flow-node/*`, `components/entity/*`) import flow types and the usage-index builders directly. `SYNTHETIC_DIAGRAM_IDS` is imported by `DictionaryView.tsx` to exclude the context/L1 diagrams from the DD sidebar process list. +### Validation: Class B strips, Class A records -## Conventions worth knowing +`validateDiagram` (`src/flows/flow-validate.ts:570`) runs four structural checks first, threading a growing `strippedEdgeIds` set through each so a store or process already implicated in one rule isn't double-flagged by the next. Only edges surviving all four (`activeEdges`) reach `checkUnknownAttributes`, `checkAmbiguousEndpoints`, `checkProcessToProcess`, and `checkProcessIsolation`. `checkDuplicateNumbers` reads `diagram.processes` directly, and `checkUnbalancedDecomposition` reads the sub-DFD's own edges and the parent process's declared inputs/outputs, both unfiltered by Class B stripping. + +```mermaid +flowchart TD + Start["diagram.edges"] --> B1["checkUnknownStore"] + B1 --> B2["checkUnknownExternal"] + B2 --> B3["checkUnknownProcess"] + B3 --> B4["checkIllegalConnection"] + B4 --> Active["activeEdges (survivors)"] + Active --> A1["checkUnknownAttributes"] + Active --> A2["checkAmbiguousEndpoints"] + Active --> A3["checkProcessToProcess"] + Active --> A4["checkProcessIsolation"] + Procs["diagram.processes"] --> A5["checkDuplicateNumbers"] + Seam["subDiagram.edges + parentProcess inputs/outputs"] --> A6["checkUnbalancedDecomposition (per sub-DFD seam)"] +``` + +Class B (`flow.unknown_store`, `flow.unknown_external`, `flow.unknown_process`, `flow.illegal_connection`) strips the offending edge from `cleanedFlowModel`; Class A (`flow.unknown_attribute`, `flow.ambiguous_endpoint`, `flow.process_to_process`, `flow.process_no_input`/`flow.process_no_output`, `flow.duplicate_number`, `flow.unbalanced_decomposition`, `flow.store_naming_collision`) records a finding but strips nothing. `flow.process_to_process` is silenceable via `config.process_to_process === false`. `validateDiagram` skips all rule checks on the synthetic context/L1 diagrams (`diagram.id === CONTEXT_DIAGRAM_ID || diagram.id === SYSTEM_PROCESS_ID`, imported from `flow-derive-levels.ts`) but still recurses into their `subDfds` to reach real leaves. `checkStoreNamingCollisions` (`flow-validate.ts:649`) walks the whole tree once, before any per-diagram pass, to catch one store token resolving to conflicting `displayName`s across diagrams. + +## Where it lives + +| Path | Exports | Role | +|---|---|---| +| [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) (818L) | `parseFlows`, `parseProcessExamples`, `resolveEndpoint`, `FlowModel`/`FlowDiagram`/`FlowProcess`/`FlowExternal`/`FlowStoreRef`/`FlowEdge`/`FlowEndpoint`/`FlowExample`/`FlowExampleRow`/`FlowParseResult` types | SSADM DFD parser. Discovers DFD folders under `<modelDir>/flows/`; reads shared `externals/` and `stores/` registries once at model root; recurses into same-named sub-folders for nested sub-DFDs; calls `deriveLevels` before returning. The only module in this domain with Bun I/O (`Bun.file`, `Bun.Glob`). | +| [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) (434L) | `deriveLevels`, `CONTEXT_DIAGRAM_ID`, `SYSTEM_PROCESS_ID`, `SYNTHETIC_DIAGRAM_IDS` | Wraps flat leaves in a context + L1 synthetic diagram pair; store promotion by degree; recursive renumbering. Pure. | +| [`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts) (727L) | `validateFlows`, `FlowError`, `FlowRulesConfig`, `FlowValidationResult` | 12 `flow.*` rules, Class A/B split, cleaned-model rebuild. Pure. | +| [`src/flows/flow-fingerprint.ts`](../../src/flows/flow-fingerprint.ts) (90L) | `buildFlowLayoutKeys`, `layoutFlowFingerprint` | Hand-rolled FNV-1a 32-bit hash over sorted resolved `kind:name` ids and edge pairs, per diagram and recursively across the whole tree. Pure. | +| [`src/flows/flow-usage-index.ts`](../../src/flows/flow-usage-index.ts) (244L) | `buildEntityUsageIndex`, `buildFlowNodeUsageIndex`, `ProcessUsage` | `buildEntityUsageIndex` is the legacy `db:`-only index keyed by bare entity id; `buildFlowNodeUsageIndex` is the token-keyed superset (`"ext:Customer"`, `"file:gateway-log"`, `"db:Payment"`) covering every non-`proc` endpoint kind. Both recurse into `subDfds` and merge into a `'read' \| 'write' \| 'readwrite'` direction. Pure. `ProcessUsage` is consumed directly by seven [`src/app/`](../../src/app) files (`FlowNodeModal.tsx`, `EntityModal.tsx`, `ProcessesTable.tsx`, `ProcessesSection.tsx`, `EntityCard.tsx`, `FlowsView.tsx`, `DictionaryView.tsx`). | +| [`src/flows/titlelize.ts`](../../src/flows/titlelize.ts) (47L) | `titlelize` | Slug → Title Case (`order-to-cash` → `"Order To Cash"`, `HTTPRequest` → `"HTTP Request"`). Pure, framework-free. Used throughout the parser for display labels whenever no `title:` frontmatter override is present. | + +## Constraints + +- Endpoint tokens are always `kind:name` strings (`ext:Customer`, `db:Payment`, `file:gateway-log`, `proc:CreateOrder`); a bare name with no colon is parsed as `kind: 'proc'` (`parseEndpoint`, `flow-parse.ts:174`) and stays `proc` unless `checkAmbiguousEndpoints` (`flow-validate.ts:271`) finds the bare name in two or more of the external/store/process namespaces and fires `flow.ambiguous_endpoint`. `resolveEndpoint()` (`flow-parse.ts:218`) implements the same namespace check but is exercised only by [`test/checks/test-flow-endpoints.ts`](../../test/checks/test-flow-endpoints.ts), never called from production code. +- Every process's `id` must equal the `id` of its corresponding `subDfds` entry — `FlowsView`'s drill-down does `currentDiagram.subDfds.find(d => d.id === processId)`, and `deriveLevels`/`renumberDiagram` preserve this by construction. If the ids ever diverge, `.find()` returns `undefined`; `handleDrill` logs a `console.warn` and returns, so the click on the process silently does nothing in the UI. +- Display labels resolve in the same override order everywhere (process, external, store): explicit `title:` frontmatter → the type-specific field (`process:`/`external:`) or raw value → `titlelize(id)`. A top-level `description:` field on process, external, and store frontmatter is read independently of this label chain and carried onto `FlowProcess.description`, `FlowExternal.description`, and the store body map's `description`. +- `FlowStoreRef.kind` and `FlowExternal.kind` share the vocabulary `'db' | 'cache' | 'queue' | 'file' | 'doc' | 'manual' | 'other'` (externals additionally omit `'db'`); an absent `kind:` on an external means conventional green fill with no visual regression. +- Externals and stores are declared once at `<modelDir>/externals/` and `<modelDir>/stores/` and shared across every diagram and sub-DFD — there is no per-DFD override. `FlowModel.externals` carries the complete root registry (used by the validator's global-namespace checks); each `FlowDiagram.externals` holds only externals both referenced by that diagram's edges and defined in the root registry. `parseDiagramFolder` never globs for an `externals/` or `stores/` folder nested inside a `flows/<dfd>/` directory: one placed there is silently ignored, not treated as an override, and never raises a parse or validation error. +- Structural fingerprints and dotted numbers are never mixed into identity: `layoutFlowFingerprint` deliberately ignores labels, body text, column names, and numbering, so a cosmetic edit never invalidates a cached layout. +- A file named `index.md` inside `flows/<dfd>/`, `externals/`, or `stores/` is never parsed as a process, external, or store definition — the `indexFileName` skip applies identically in all three scan sites. + +## Coupling -- Endpoint tokens are always `kind:name` strings (`ext:Customer`, `db:Payment`, `file:gateway-log`, `proc:CreateOrder`); a bare name with no colon is provisionally parsed as `kind: 'proc'` and resolved later by `resolveEndpoint()` / flagged by `flow.ambiguous_endpoint` if it collides across namespaces. -- Display labels resolve in the same override order everywhere: explicit `title:` frontmatter → the type-specific field (`process:`/`external:`) or raw value → `titlelize(id)`. -- `FlowStoreRef.kind` and `FlowExternal.kind` share the same `'db' | 'cache' | 'queue' | 'file' | 'doc' | 'manual' | 'other'` vocabulary (externals additionally omit `'db'`); absent `kind:` on an external means conventional green fill with no visual regression. -- Every process's `id` must equal the `id` of its corresponding `subDfds` entry — `FlowsView`'s drill-down does `currentDiagram.subDfds.find(d => d.id === processId)`. -- Structural fingerprints and dotted numbers are never mixed into identity: `layoutFlowFingerprint` deliberately ignores labels/numbers so cosmetic edits don't invalidate a cached layout. +- **parser** ([`src/model/wikilink.ts`](../../src/model/wikilink.ts), [`src/model/markdown-highlight.ts`](../../src/model/markdown-highlight.ts), [`src/model/parse.ts`](../../src/model/parse.ts)) is a runtime dependency, not just a type import: `flow-parse.ts` wires `wikiLinkPlugin` and `highlightCodeFence` into its own `MarkdownIt` instance at module load, so `[[Target]]` links and code fences in process/external/store bodies render identically to ERD entity bodies. `flow-validate.ts` imports `type Model` from `parse.ts` and threads `entityModel: Model` through every rule-check function, reading `entityModel.nodes` directly to resolve `db:` endpoints against the entity model. +- **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)) imports `type FlowError` from `flow-validate.ts` (type-only, no runtime circular dependency); defines the `flow.*` `RuleId` union and their explanation text; merges `flowErrors` into the combined validation summary. A new `flow.*` rule requires a matching entry in `validate.ts`'s `RuleId` union and explanation table. +- **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)) dynamically imports `parseFlows` for the `validate`, `export`, and `index` commands; each folds flow Class-B errors into the same exit-code-1 decision as entity errors. +- **server** ([`src/server/server.ts`](../../src/server/server.ts)) imports `parseFlows`, `validateFlows`, and `buildFlowLayoutKeys` directly for the `/api/flow` route, returning `{ diagrams, entityModel, validation, flowLayoutKeys }`. +- **generators** ([`src/generators/app.ts`](../../src/generators/app.ts)) imports `type FlowModel` and `buildFlowLayoutKeys` to embed `window.__FLOW_MODEL__` and `window.__FLOW_LAYOUT_KEYS__` into the exported static HTML bundle; a null `flowModel` means no `flows/` directory existed. +- **flow-view** (`src/flow-view/*`) is a separate domain (ELK layout + SVG rendering). `elk-flow-layout.ts`, `flow-layout.ts`, `FlowChrome.tsx`, and `FlowDiagramSvg.tsx` import `type FlowDiagram`/`FlowStoreRef` only, no runtime dependency, but any shape change to `FlowDiagram`, `FlowProcess`, `FlowStoreRef`, or edge endpoint kinds forces a review of all four files. The frontend reads pre-computed layout keys from `window.__FLOW_LAYOUT_KEYS__` or the `/api/flow` payload rather than importing `flow-fingerprint.ts` directly. +- **router** ([`src/router/build.ts`](../../src/router/build.ts)) imports `FlowDiagram`, `FlowModel`, and `FlowStoreRef` directly and does its own structural walk (`diagram.subDfds.find(d => d.id === process.id)`) to resolve each process's sub-DFD when building router files, duplicating the drill-down lookup pattern documented in Constraints. +- **frontend** (`src/app/*`): `App.tsx`, `hooks/useModelData.ts`, `logic/doc-resolver.ts`, `logic/search.ts`, `logic/flow-spotlight.ts`, `views/flow/FlowsView.tsx`, `views/dict/DictionaryView.tsx`, `components/process/*`, `components/flow-node/*`, and `components/entity/*` import flow types and the usage-index builders directly. `SYNTHETIC_DIAGRAM_IDS` is imported by `DictionaryView.tsx` to exclude the context/L1 diagrams from the DD sidebar process list. +- **docs**: [`docs/design/process-flows.md`](../design/process-flows.md) / [`docs/spec/process-flows.md`](../spec/process-flows.md) (original DFD design and `flow.*` rule registry contract), [`docs/design/folder-model.md`](../design/folder-model.md) / [`docs/spec/folder-model.md`](../spec/folder-model.md) (root-registry restructure), [`docs/design/dfd-nesting-depth.md`](../design/dfd-nesting-depth.md) / [`docs/spec/dfd-nesting-depth.md`](../spec/dfd-nesting-depth.md) (arbitrary nesting depth, source of `renumberDiagram`), [`docs/design/dfd-overhaul.md`](../design/dfd-overhaul.md) / [`docs/spec/dfd-overhaul.md`](../spec/dfd-overhaul.md) (leveling half of the layout overhaul), [`docs/research/dfd-layout-and-leveling.md`](../research/dfd-layout-and-leveling.md), [`docs/research/ssadm-dfd-rules.md`](../research/ssadm-dfd-rules.md) (canonical SSADM/DFD reference backing the `flow.*` rules), and [`docs/guides/flows.md`](../guides/flows.md) (user-facing authoring guide). diff --git a/docs/wiki/index.md b/docs/wiki/index.md index 4651ed0..e11a5ca 100644 --- a/docs/wiki/index.md +++ b/docs/wiki/index.md @@ -4,7 +4,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif --- <wiki-type>repo</wiki-type> -<scan-sha>97a3d19819b3e7d88daf886689cf78f91ff55bbf</scan-sha> +<scan-sha>83f09393b1124f0a352ec25e3154ee852eb8ca26</scan-sha> <wiki-schema>1</wiki-schema> # Project signals @@ -35,7 +35,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif `bun run test` runs a shell loop over `test/checks/*.ts` in order; exits 1 on first failure. CI ([`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)) runs the same `test/checks/*.ts` loop after building the binary. [`test/`](../../test) is not a formal test-framework suite — there are no `*.test.ts` files and nothing imports `bun:test`; a bare `bun test` finds 0 files and exits 1. -- [`test/checks/`](../../test/checks) — 87 raw assertion scripts (PASS/FAIL/throw), run by `bun run test` and CI. Which check covers which behavior is documented per-domain in each `docs/wiki/<domain>.md`. +- [`test/checks/`](../../test/checks) — 97 raw assertion scripts (PASS/FAIL/throw), run by `bun run test` and CI. Needs `dist/` present (`bun run build:cli` first). Which check covers which behavior is documented per-domain in each `docs/wiki/<domain>.md`. - [`test/visual/`](../../test/visual) — 64 Playwright screenshot scripts for manual visual inspection. NOT run by `bun run test`. - [`test/fixtures/`](../../test/fixtures) — YAML fixtures and 5 fixture model roots (`flows-leveling/`, `flows-model/`, `broken-flows-model/`, `broken-flow/`, `flows/`), all using the v0.11.0 folder layout (`data/`, `groups/`, `externals/`, `stores/`). - [`test/notes/`](../../test/notes) — 2 markdown dev notes. @@ -47,12 +47,12 @@ No linter or formatter configured in package.json. | Language | LOC | Files | % | |----------|-----|-------|---| -| TypeScript | 59737 | 267 | 70% | -| Markdown | 20459 | 298 | 24% | -| CSS | 3017 | 2 | 3% | +| TypeScript | 63826 | 289 | 70% | +| Markdown | 22491 | 358 | 24% | +| CSS | 3122 | 2 | 3% | | YAML | 1340 | 14 | 1% | | Shell | 116 | 1 | 0% | -| JSON | 104 | 4 | 0% | +| JSON | 107 | 4 | 0% | | HTML | 27 | 2 | 0% | | TOML | 12 | 2 | 0% | @@ -62,7 +62,7 @@ No linter or formatter configured in package.json. - CI pipeline: install deps → cache Playwright → build bundle + stable-names → compile binary → run all `test/checks/*.ts` → typecheck (`continue-on-error: true`). - Release pipeline: [`.github/workflows/release-please.yml`](../../.github/workflows/release-please.yml) (release-please driven; a `build` job gated on `release_created` compiles the 5 platform binaries + checksums and attaches them to the release in the same push-to-main run). [`install.sh`](../../install.sh) (repo root) is the curl-able CLI installer that pulls those binaries from `releases/latest/download`. - Binary is built locally or in CI via `bun run build:cli`; produces `dist/ignatius`. -- package.json `name` is `ignatius`, version is `0.15.0`. The repo *directory* is still named `derek-db-generator/` — the one remaining derek reference, a known leftover. +- package.json `name` is `ignatius`, version is `0.17.0`. --- @@ -70,24 +70,25 @@ 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/version/update); `dict`/`graph`/`flow` are removal stubs; model-root discovery + interactive picker; port fallback + browser open on serve; self-update + version reporting | [`docs/wiki/cli.md`](cli.md) | +| 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 + version reporting | [`docs/wiki/cli.md`](cli.md) | | server | [`src/server/server.ts`](../../src/server/server.ts) | Bun.serve with `/api/model` + `/api/flow` + `/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; `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: 27 RuleIds across 6 domains (parse/entity/body/edge/cluster/flow), two severity tiers (A=warn, B=omit); coerces invalid pk/columns to safe defaults in cleanedModel | [`docs/wiki/validate.md`](validate.md) | +| validate | [`src/model/validate.ts`](../../src/model/validate.ts) | Pure model validator: 33 RuleIds across 8 prefixes (parse/config/entity/body/edge/cluster/index/flow), two severity tiers (A=warn, B=omit); `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`), `validateFlows` (12 `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); pure coord helpers for polyline rendering; SVG renderer consumes ELK positions + edgeRoutes + search-token dimming | [`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; views own cy/SVG lifecycle; components/logic/hooks/dom layered underneath | [`docs/wiki/frontend.md`](frontend.md) | | generators | [`src/generators/`](../../src/generators) | Unified static HTML export via `generateApp` (single file — graph + dict + flows); sole static generator | [`docs/wiki/generators.md`](generators.md) | | theme | [`src/theme/`](../../src/theme) | ThemeConfig + Branding types, default palettes, flow-kind colors, 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` | [`docs/wiki/skill.md`](skill.md) | -| docs | [`docs/`](..) (excluding [`docs/wiki/`](.)) | Design docs, user guides, research notes, and implementation-contract specs — 72 markdown files across [`docs/design/`](../design), [`docs/guides/`](../guides), [`docs/research/`](../research), [`docs/spec/`](../spec) | [`docs/wiki/docs.md`](docs.md) | +| docs | [`docs/`](..) (excluding [`docs/wiki/`](.)) | Design docs, user guides, research notes, and implementation-contract specs — 76 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) | +| 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 `<ignatius-*>` region parser; backs `ignatius index` and `validate --index` | [`docs/wiki/router.md`](router.md) | ## Cross-cutting -- Domain partitioning basis: pinned by [`docs/wiki/CLAUDE.md`](CLAUDE.md) steering (twelve established domains, not re-derived per refresh). [`src/model/parse.ts`](../../src/model/parse.ts) and [`src/model/validate.ts`](../../src/model/validate.ts) share a directory but are separate domains (parser vs validate); [`src/flows/`](../../src/flows) and [`src/flow-view/`](../../src/flow-view) are separate domains (DFD parse/leveling vs ELK layout/rendering) despite the shared "flow" naming. +- Domain partitioning basis: pinned by [`docs/wiki/CLAUDE.md`](CLAUDE.md) steering (twelve established domains plus `router`, added this refresh and now pinned alongside them — not re-derived per refresh). [`src/model/parse.ts`](../../src/model/parse.ts) and [`src/model/validate.ts`](../../src/model/validate.ts) share a directory but are separate domains (parser vs validate); [`src/flows/`](../../src/flows) and [`src/flow-view/`](../../src/flow-view) are separate domains (DFD parse/leveling vs ELK layout/rendering) despite the shared "flow" naming. - Ignored for domain purposes (never cited as domain content): [`trash/`](../../trash) (v1 YAML-driven engine, superseded, not imported anywhere in [`src/`](../../src)), `tmp/`, `dist/` (build output), `node_modules/`, [`models/`](../../models) (5 sibling demo/fixture model roots — `key-inherited/`, `orm-hybrid/`, `orm-pure/`, `broken-demo/`, `llm-memory-db-mssql/` — reference data, not source), [`test/fixtures/`](../../test/fixtures). -- Findings flow crosses three domains: `parse.ts` → `ParseResult.globalErrors` (parse-time) + `validateModel()` → `ValidationResult.globalErrors + .entityErrors` + optional `validateFlows()` → `FlowValidationResult.flowErrors` → merged by callers (cli, server, frontend) before rendering. -- CLI subcommand status: `serve`/`validate`/`export`/`version`/`update` active; `dict`/`graph`/`flow` are removal stubs pointing at `export`. +- Findings flow crosses domains: `parse.ts` → `ParseResult.globalErrors` (parse-time) + `validateModel()` → `ValidationResult.globalErrors + .entityErrors` + optional `validateFlows()` → `FlowValidationResult.flowErrors` + optional `validateIndex()` → router-drift findings (`config.index_file_*`, `index.*`) → merged by callers (cli, server, frontend) before rendering. +- CLI subcommand status: `serve`/`validate`/`export`/`index`/`version`/`update` active; `dict`/`graph`/`flow` are removal stubs pointing at `export`. `index` (with `--agents`) generates the per-folder routers that `router` builds and `validate --index` checks for drift. - [`docs/wiki/feature-map.md`](feature-map.md) — hand-authored feature-to-doc cross-reference table (design/spec/guide/skill columns per feature); not generated by this signals pipeline, maintained separately. - Deterministic substrate: [`docs/wiki/scan.md`](scan.md). diff --git a/docs/wiki/parser.md b/docs/wiki/parser.md index 507c484..4115319 100644 --- a/docs/wiki/parser.md +++ b/docs/wiki/parser.md @@ -1,46 +1,118 @@ --- type: Domain -description: Parses an ignatius model root (data/, groups/, ignatius.yml) into a typed Model, derives classification/cardinality, and builds O(1) lookup indices. +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] --- # parser ## What it does -Reads a model root directory and produces a `Model`: entity nodes, edges, groups, subtype clusters, theme, and branding. Derives entity classification, edge `identifying`, and cardinality from structure rather than trusting hand-authored values. Renders entity body markdown (including `[[wiki-links]]`) to HTML at parse time. Builds precomputed lookup maps (`ModelIndex`) for O(1) access to nodes, edges, keys, and clusters. +[`src/model/parse.ts`](../../src/model/parse.ts) is the trust boundary between hand-authored model files and everything the app renders: it is the only place in the codebase that reads model source files off disk, and the only place that derives `classification`, edge `identifying`, and `cardinality` from structure rather than trusting whatever a hand-written frontmatter field claims. Without it, the CLI's validator, the live viewer, and the static export would each have to re-derive those fields themselves, or trust stale hand-written values that silently drift from the data. -## CLI code +`parseModels(dir)` turns a model root (`ignatius.yml` plus `data/`, `groups/`) into that trusted `Model`: entity nodes, edges, groups, subtype clusters, theme, and branding, plus each entity's markdown body (including `[[wiki-links]]`) rendered to HTML. It returns `ParseResult = { model, globalErrors }` only; a caller that needs O(1) lookups over the result calls `buildModelIndex(model)` ([`src/model/model-index.ts`](../../src/model/model-index.ts)) separately to get a `ModelIndex` — nodes, edges, keys, and clusters by id instead of scanning arrays. -- [`src/model/parse.ts`](../../src/model/parse.ts) (427L) — exports `parseModels(dir): Promise<ParseResult>` where `ParseResult = { model: Model; globalErrors: GlobalError[] }`. Reads `ignatius.yml` for `_meta` (name/version/desc/updated/flowRules), `theme:` (via `mergeTheme()`), and `branding:` (via `mergeBranding()`). Scans `data/**/*.md` for entity files (frontmatter + body) and `groups/*.md` for group definitions. Exports `normalizePredicate()`, `ModelNode`, `ModelEdge`, `Model`, `Predicate`, `ColumnDef`, `SubtypeCluster`, `GroupConfig`, `Cardinality`, and `ModelMeta` types. -- [`src/model/wikilink.ts`](../../src/model/wikilink.ts) (98L) — exports `WikiLinkEnv`, `splitWikiTarget()`, and `wikiLinkPlugin(md)`, a markdown-it inline rule for `[[Target]]` / `[[Target|label]]` syntax. Registered onto the shared `MarkdownIt` instance in `parse.ts` via `md.use(wikiLinkPlugin)`. -- [`src/model/model-index.ts`](../../src/model/model-index.ts) (222L) — exports `buildModelIndex(model: Model): ModelIndex` and `endpointKey(source, target): string`. Pure module (no Bun/Node/DOM imports), browser-safe. +Every consumer in the repo (`cli`, `server`, `validate`, `frontend`, `flows`, `generators`) sits downstream of this one function. -## Docs +## How it works -- [`docs/design/folder-model.md`](../design/folder-model.md) / [`docs/spec/folder-model.md`](../spec/folder-model.md) — the `data/`/`flows/`/`groups/`/`externals/`/`stores/` folder-root format `parseModels` scans. -- [`docs/design/wiki-entity-links.md`](../design/wiki-entity-links.md) / [`docs/spec/wiki-entity-links.md`](../spec/wiki-entity-links.md) — the `[[…]]` wiki-link syntax implemented in `wikilink.ts`. -- [`docs/spec/derive-classification.md`](../spec/derive-classification.md) — the 5-rule classification and `identifying`-derivation contract implemented in `parse.ts`. -- [`docs/design/bidirectional-predicates.md`](../design/bidirectional-predicates.md) / [`docs/spec/bidirectional-predicates.md`](../spec/bidirectional-predicates.md) — the `{ fwd, rev }` predicate shape implemented by `normalizePredicate()`. -- [`docs/guides/folder-format.md`](../guides/folder-format.md) — user-facing guide to the folder root format. -- [`docs/guides/predicates.md`](../guides/predicates.md) — user-facing guide to bidirectional predicates. +**`parseModels` runs its derivation stages in a fixed order; classification and body rendering share one stage because neither depends on the other.** + +```mermaid +flowchart TD + Config["read ignatius.yml → theme, branding, _meta"] --> Groups["scan groups/*.md → GroupConfig map"] + Groups --> Scan["scan data/**/*.md → RawNode + RawEdge"] + Scan --> Identify["derive edge.identifying (FK cols ⊆ child pk)"] + Identify --> ClassifyRender["per node: deriveClassification + render bodyHtml/bodyLinks"] + ClassifyRender --> Cardinality["deriveCardinality per edge, needs classification + AKs"] + Cardinality --> ModelOut["assemble Model"] +``` + +`identifying` must exist before classification runs (the Associative/Dependent rules count identifying parents per node), and classification must exist before cardinality (the Subtype special case). Body rendering has no data dependency on classification, only on every node id being known so `[[…]]` targets can resolve; both are computed in the same per-node pass (parse.ts:475-492). Both the `groups/*.md` scan and the `data/**/*.md` scan skip a file whose basename equals `_meta.indexFile` (default `index.md`, overridable via `ignatius.yml`'s `index_file:` key, which must be a bare `.md` filename with no path separators or the parser raises `config.index_file_ext` / `config.index_file_path`): the groups scan skips it silently, the data scan additionally re-parses its frontmatter and raises `config.index_file_entity` if that reserved file declares an `entity:` field. + +### Two-pass body rendering + +**Body markdown renders only after every entity id is known, so a `[[Target]]` link can be told apart from a typo.** + +```mermaid +flowchart LR + Pass1["pass 1: scan data/**/*.md → RawNode.body (raw), collect all entity ids"] --> KnownIds["knownIds: Set<string>"] + KnownIds --> Pass2["pass 2: md.render(rawNode.body, env) per node"] + Pass2 --> Known{"target in knownIds?"} + Known -->|yes| Link["a.entity-link href=#entity-Target"] + Known -->|no| Missing["span.entity-link--missing"] + Pass2 --> BodyLinks["env.links → ModelNode.bodyLinks"] +``` + +`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. + +### 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.** + +```mermaid +flowchart TD + Start(["deriveClassification(node)"]) --> R1{"reference:true OR legacyClassification=='Classifier'?"} + R1 -->|yes| Classifier["Classifier"] + R1 -->|no| R2{"node.id in any subtypeCluster.members?"} + R2 -->|yes| Subtype["Subtype"] + R2 -->|no| R3{"≥2 distinct identifying parents?"} + R3 -->|yes| Associative["Associative"] + R3 -->|no| R4{"≥1 identifying parent?"} + R4 -->|yes| Dependent["Dependent"] + R4 -->|no| Independent["Independent"] +``` + +The frontmatter `relationships[].identifying` field is accepted for backward compat but is never read to compute an edge's `identifying` value; the Identify stage (the `derivedEdges` map, parse.ts:423-431) derives it from FK/PK column overlap before classification or cardinality run. `deriveCardinality` only consumes the already-computed `edge.identifying` as a parameter (parse.ts:153-178). + +### Cardinality derivation + +**Cardinality branches first on whether the edge is identifying, then on child classification, key overlap, and nullability.** + +```mermaid +flowchart TD + Start(["deriveCardinality(edge, childNode, childAks)"]) --> Ident{"edge.identifying?"} + Ident -->|yes| SubtypeChk{"childNode.classification == 'Subtype'?"} + SubtypeChk -->|yes| SubtypeCard["parent:1, child:0..1"] + SubtypeChk -->|no| PkMatch{"fkChildCols == childNode.pk?"} + PkMatch -->|yes| OneOne["parent:1, child:1"] + PkMatch -->|no| OneMany["parent:1, child:many"] + Ident -->|no| Nullable{"any FK col nullable?"} + Nullable -->|yes| NullAkChk{"FK cols form an alternate key?"} + NullAkChk -->|yes| ZeroOneOne["parent:0..1, child:1"] + NullAkChk -->|no| ZeroOneMany["parent:0..1, child:many"] + Nullable -->|no| AkChk{"FK cols form an alternate key?"} + AkChk -->|yes| OneOneB["parent:1, child:1"] + AkChk -->|no| OneManyB["parent:1, child:many"] +``` + +A dangling edge (unknown target, or an FK column absent from the child's PK) is carried through as `identifying: false` rather than raised in `parse.ts`; the parser reports only what stops it producing a `Model`, and `validate.ts` owns `edge.unknown_target` / `edge.dangling_fk_column` diagnostics, with fix hints and cleaned-model stripping. Raising it in both layers would report the same defect twice. + +## Where it lives + +| Path | Exports | Role | +|------|---------|------| +| [`src/model/parse.ts`](../../src/model/parse.ts) | `parseModels(dir): Promise<ParseResult>`, `normalizePredicate()`, `ModelNode`, `ModelEdge`, `Model`, `ParseResult`, `ModelMeta`, `Predicate`, `ColumnDef`, `SubtypeCluster`, `GroupConfig`, `Cardinality`, `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/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` | + +`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). `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). + +## Constraints + +- `ModelIndex` maps do not survive JSON serialization: `JSON.stringify` turns a `Map` into `{}`, so a deserialized index has every lookup map emptied rather than absent. `buildModelIndex()` must be called fresh wherever a `Model` enters a consumer (after `parseModels`, after an SSE `model-changed` event, after reading a static global), never attached to a serialized payload; a lookup against the emptied maps returns `undefined` silently instead of throwing. +- `akColumnsByNode` and `fkColumnsByNode` are absent (not an empty `Set`) for nodes with no alternate keys / no outgoing edges. Callers must check for map-key presence, not just `Set` size; calling `.size` on the result of a missing `.get()` throws a `TypeError` (exact message is JS-engine-dependent). +- `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:211-238, :252) — parsing does not fall back to `index.md`, so the reserved-file skip silently never matches any real file. ## 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` documents mirroring `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`; [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) imports `wikiLinkPlugin` from `wikilink.ts` directly (its own markdown-it instance, separate from the one in `parse.ts`). 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. Returns `''` on an untagged fence, an unbundled language, or a grammar throw, which hands the block back to markdown-it's default escaping. Server-side only: app code imports `parse.ts` for types alone, so neither markdown-it nor these grammars reach the browser bundle — the browser's own highlighter, [`src/app/logic/json-highlight.ts`](../../src/app/logic/json-highlight.ts), loads the json grammar by itself for the same reason. -- `frontend` ([`src/app/`](../../src/app)) — multiple modules under [`src/app/logic/`](../../src/app/logic) and [`src/app/hooks/`](../../src/app/hooks) import `Model`, `ModelNode`, `ModelEdge`, `Predicate`, `ThemeConfig`, or `SubtypeCluster` as types from `parse.ts`; `spotlight.ts` and `spotlight-inherited.ts` import `ModelIndex` from `model-index.ts`. `GroupConfig` is imported from `parse.ts` only outside `logic/`/`hooks/`, by [`src/app/App.tsx`](../../src/app/App.tsx), [`src/app/components/ui/FabMenu.tsx`](../../src/app/components/ui/FabMenu.tsx), and [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts). +- `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`; [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts) imports `wikiLinkPlugin` from `wikilink.ts` directly into its own `MarkdownIt` instance (separate from the one in `parse.ts`). 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). - `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. - `generators` ([`src/generators/app.ts`](../../src/generators/app.ts)) imports the `Model` type from `parse.ts`. - 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). - -## Conventions worth knowing - -- Two-pass body rendering: `ModelNode.bodyLinks` and `bodyHtml` are rendered in a second pass after every entity id is known, so `[[…]]` links resolve against the full id set and unknown targets render as `entity-link--missing` spans instead of being silently dropped. -- Classification is derived, not authored: `classification` field in frontmatter is legacy (`legacyClassification`, used only as a Classifier signal alongside the `reference: true` flag); the parser always runs the 5-rule order (Classifier → Subtype → Associative → Dependent → Independent) rather than trusting a hand-written value. -- `identifying` per edge is derived from whether every FK child column in `edge.on` is present in the child node's `pk` — never hand-authored despite the `Frontmatter.relationships[].identifying` field existing for backward compat. -- `deriveCardinality()` treats a `Subtype` classification specially (`{ parent: '1', child: '0..1' }` for identifying edges) before falling through to PK/AK-based cardinality rules. -- `ModelIndex` maps do not survive JSON serialization; `buildModelIndex()` must be called fresh wherever a `Model` enters a consumer (after `parseModels`, after an SSE `model-changed` event, after reading a static global) rather than being attached to a serialized payload. -- `akColumnsByNode` and `fkColumnsByNode` in `ModelIndex` are absent (not empty-Set) for nodes with no alternate keys / no outgoing edges, respectively — callers must check for map-key presence, not just Set size. -- `subtypeMemberToCluster` is first-wins for members appearing in multiple clusters; `clustersByMemberId` is the array form that captures all of them. -- `wikilink.ts` 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. +</content> diff --git a/docs/wiki/router.md b/docs/wiki/router.md new file mode 100644 index 0000000..c141b9a --- /dev/null +++ b/docs/wiki/router.md @@ -0,0 +1,101 @@ +--- +type: Domain +description: Generates nested index.md routers with rolled-up SHA-256 digests, plus in-folder AGENTS.md/CLAUDE.md/SKILL.md guidance. +tags: [model, codegen, fingerprint] +--- + +# router + +## What it does + +A model root (`ignatius.yml` plus `data/`, `groups/`, `flows/`, `externals/`, `stores/`) is a flat pile of markdown to any reader without `ignatius serve` running: a filename like `PaymentAllocation.md` says nothing about which group it belongs to or whether opening it answers the question at hand. [`src/router/`](../../src/router) generates a navigable `index.md` into every organizing folder so a reader descends root → section → group → entity through small tables instead of globbing the tree, and each router carries a rolled-up SHA-256 digest so drift is a `validate` finding rather than a file that quietly rots. + +`buildRouters` ([`src/router/build.ts`](../../src/router/build.ts)) turns a parsed `Model` + `FlowModel` into one `RouterFile` per folder; `writeRouters` ([`src/router/write.ts`](../../src/router/write.ts)) writes each into a `<ignatius-index>` region, leaving every other byte in the file untouched. `--agents` additionally writes `AGENTS.md`, a [`CLAUDE.md`](../../CLAUDE.md) shim, and `SKILL.md` into the model root only, so an agent that opens any file under the root picks up the model's conventions with no install step. + +## How it works + +**Generating routers always runs; writing agent guidance is additive behind `--agents`, and only the [`CLAUDE.md`](../../CLAUDE.md) shim depends on harness detection.** + +```mermaid +flowchart TD + Build["buildRouters(root, model, flowModel)"] --> Hash["hashFile / safeHashFile: SHA-256 of each row's target bytes"] + Hash --> Digest["folderDigest: ordered row-hash list -> folder digest"] + Digest --> Write["writeRouters: replaceRegion('ignatius-index') + ensureBreadcrumb per RouterFile"] + Write --> Flag{"--agents?"} + Flag -->|no| Done(["routers on disk"]) + Flag -->|yes| Detect["resolveHarness: ignatius.yml harness: + ancestorHasClaudeMarker"] + Detect --> Guidance["writeGuidance: AGENTS.md + SKILL.md always, CLAUDE.md shim when harness resolves true"] +``` + +`buildRouters` walks five sections in a fixed order: `groups/` (flat, one row per group file), `data/` (mirrors each entity's resolved `sourcePath`, recursing into `buildDataFolder` per subdirectory), `flows/` (recursing into `buildFlowFolder` per flow and sub-DFD folder), `externals/`, and `stores/`, then assembles a root `RouterFile` whose five rows summarize each section. `safeHashFile` catches only OS-level read failures (a `code` property on the thrown error); anything else propagates. A caught failure is recorded in an `UnreadableTarget[]` list with the real error message and the row gets the sentinel hash `sha256:unreadable`, so the row still renders instead of crashing the whole run. + +### A region boundary is a position, not a delimiter + +**Only a bare column-0 tag advances the region parser out of plain text; every other arrangement of the same bytes is a parse error.** + +```mermaid +stateDiagram-v2 + [*] --> Text + Text --> Text : any other line + Text --> Open : column-0 "<ignatius-name>" ending its line + Open --> Text : column-0 "</ignatius-name>", same name, emits a Region + Open --> [*] : EOF reached + Text --> [*] +``` + +`TAG_RE` (`^<(\/?)(ignatius-[\w-]+)([^<>]*)>[ \t]*$`, multiline) only matches a tag that starts a line and ends it (trailing spaces/tabs aside). A tag anywhere else on a line, inside a fence, inside a code span, indented, or as part of prose, is text the tokenizer never sees. `regions()` then pairs the resulting tokens in order: + +| Arrangement | Result | +|---|---| +| Open tag while another region is already open | throws: regions cannot nest | +| Close tag with no open region pending | throws: no opening tag above it | +| Close tag whose name does not match the open region | throws: closing tag must match | +| Open tag with no matching close by EOF | throws: no closing tag | +| Two regions with the same name in one file | throws: keep one | + +`readRegion` returns a named region's inner text (or `null`) after this scan; `replaceRegion` swaps a region's inner content and attributes in place, or appends a new `openTag\n\n<inner>\n\n</name>` block when the region doesn't exist yet — the double line break is required because CommonMark ends a raw HTML block at a blank line, and without one on each side the table inside renders as literal pipes. + +### A digest change never crosses into a sibling folder + +**A leaf edit changes exactly the digests on its ancestor path; a sibling folder's digest is untouched.** + +```mermaid +flowchart LR + Edit["data/identity/Party.md edited"] --> Row["Party's row hash changes in data/identity/index.md"] + Row --> Folder["data/identity's folderDigest changes"] + Folder --> Parent["identity's row in data/index.md changes"] + Parent --> Root["root index.md digest changes"] + Folder -.->|no effect| Sibling["data/catalog/index.md digest unchanged"] +``` + +`hashFile` hashes a target's raw bytes, so a whitespace-only edit still dirties a digest. `folderDigest` hashes the ordered list of row hashes for one folder; a parent's row for a child folder carries only that child's `folderDigest`, never its rows, which is why the propagation stops at the ancestor path. + +## Where it lives + +| File | Exports | Responsibility | +|---|---|---| +| [`src/router/region.ts`](../../src/router/region.ts) | `readRegion`, `replaceRegion` | Column-0 tag tokenizer, open/close pairing scan, region extraction and in-place replacement | +| [`src/router/fingerprint.ts`](../../src/router/fingerprint.ts) | `hashFile`, `folderDigest`, `RouterNode` | SHA-256 of raw file bytes; SHA-256 of an ordered row-hash list; the one-row shape (`name`, `kind`, `description`, `link`, `hash`) every table row is built from | +| [`src/router/build.ts`](../../src/router/build.ts) | `buildRouters`, `RouterFile`, `UnreadableTarget` | Model + `FlowModel` → `RouterFile[]`; `buildDataTree`/`buildDataFolder` mirror each entity's `sourcePath`, never its declared `group:`; `buildFlowFolder` recurses through sub-DFDs; `safeHashFile` collects unreadable targets instead of throwing | +| [`src/router/write.ts`](../../src/router/write.ts) | `writeRouters` | Region-scoped write of every `RouterFile`'s `<ignatius-index>` table and its `<ignatius-breadcrumb>` line; creates missing files, creates missing intermediate directories via `Bun.write` | +| [`src/router/detect.ts`](../../src/router/detect.ts) | `resolveHarness` | Resolves whether `--agents` writes the [`CLAUDE.md`](../../CLAUDE.md) shim, from `ignatius.yml`'s `harness:` (default `auto`) plus an ancestor filesystem probe for [`.claude/`](../../.claude) or [`CLAUDE.md`](../../CLAUDE.md) | +| [`src/router/agents.ts`](../../src/router/agents.ts) | `deriveKeyStyle`, `buildAgentsGuide`, `buildClaudeShim`, `buildSkillMeta`, `buildSkillBody`, `writeGuidance`, `KeyStyle` | `AGENTS.md`/[`CLAUDE.md`](../../CLAUDE.md)/`SKILL.md` content, derives the model's key-style convention (`key-inherited` / `orm-oriented` / `mixed` / `undetermined`) from PK shape, and writes all three guidance files into the model root only | + +## Constraints + +- `region.ts`'s tokenizer has no fence or code-span exemption: a tag shown as an example inside a hand-authored `<ignatius-rules>` block must be indented or HTML-escaped (`<ignatius-index>`), or it is read as a real boundary. +- `SKILL.md`'s YAML frontmatter (`name`, `description`) is the one write the generator makes outside a region; every other byte of every generated file, including the rest of `SKILL.md`, goes through `replaceRegion`. `writeGuidance` (`src/router/agents.ts:181-184`) rebuilds that frontmatter from `buildSkillMeta(model)` on every call without reading the existing `name`/`description` back, so a hand-edit to either field is silently discarded and replaced on the next `ignatius index --agents` run. +- `writeGuidance` writes `AGENTS.md` and `SKILL.md` unconditionally and the [`CLAUDE.md`](../../CLAUDE.md) shim only when its `writeClaude` argument is true; it writes into `root` only, never into a nested folder. +- `buildDataTree` walks `ModelNode.sourcePath`; a node with `sourcePath === undefined` is skipped rather than placed by its declared `group:`. +- `buildRouters`'s flow walk starts at `flowModel.diagrams[0]?.subDfds[0]?.subDfds`, assuming `flowModel` is already leveled; an unleveled `FlowModel` has no context/L1 wrapper to descend through and the walk would misalign. +- `storeRows` includes only `FlowStoreRef` entries with a defined `body` (actually read from a `stores/*.md` file); `db:<Entity>` tokens and undefined store tokens are excluded. +- `deriveKeyStyle` excludes `Classifier`-classified nodes from its PK-shape count and calls the model `mixed` once the minority signature (key-inherited vs. surrogate) reaches `MIXED_SHARE_THRESHOLD` (0.2) of relevant nodes. +- `resolveHarness`'s `auto` case (`ancestorHasClaudeMarker`) walks from the model root up to the filesystem root via `existsSync`, stopping at the first [`.claude`](../../.claude) directory or [`CLAUDE.md`](../../CLAUDE.md) file. + +## Coupling + +- **validate** ([`src/model/validate.ts`](../../src/model/validate.ts)) — `validateIndex` dynamically imports and reuses `buildRouters` to recompute every digest without writing anything, backing six `RuleId`s: `config.index_file_ext`, `config.index_file_path`, `config.index_file_entity` (frontmatter/config shape), and `index.stale`, `index.orphaned`, `index.unreadable_target` (router drift, read off `STORED_DIGEST_RE` against the current `<ignatius-index>` attribute). +- **cli** ([`src/cli/cli.ts`](../../src/cli/cli.ts)) — `indexCmd` (`ignatius index [path] [--model] [--agents]`) parses the model, runs `buildRouters` + `writeRouters`, and, under `--agents`, calls `resolveHarness` and `writeGuidance`. `validateCmd` gains an `--index` flag that calls `validateIndex` so a plain `ignatius validate` never pays a full-tree hash pass. +- **parser** ([`src/model/parse.ts`](../../src/model/parse.ts)) / **flows** ([`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)) — `build.ts`, `agents.ts`, and `detect.ts` import `Model`, `ModelNode`, `ModelEdge`, `HarnessMode`, `FlowDiagram`, `FlowModel`, and `FlowStoreRef` as read-only inputs; router code never mutates a parsed model. The reserved `index_file` basename skip that keeps a written router from being re-parsed as an entity or flow definition lives in those two domains, not in [`src/router/`](../../src/router). +- **flows** ([`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts)) — `deriveLevels` wraps the parser's flat leaf diagrams in a context (Level 0) diagram and an L1 overview diagram before router ever sees them; `flowModel.diagrams[0]` is that context wrapper and `subDfds[0]` is the L1 wrapper, which is why `buildRouters` starts its walk one level past both. See [`docs/wiki/flows.md`](flows.md). +- **docs** — [`docs/design/model-index-routing.md`](../design/model-index-routing.md) (approach and rationale) and [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) (the SC1–SC14 contract, checkpoints CP1–CP7) are the design/spec pair for this domain. diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md index 7433dab..83f0939 100644 --- a/docs/wiki/scan.md +++ b/docs/wiki/scan.md @@ -2,11 +2,12 @@ ## Tree +``` ├── .claude/ (1) │ └── atomic.toml (da6b99f, 10L, 128ch, 128B) ├── .cursor/ (1) │ └── rules/ (1) -│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (e768165, 51L, 2314ch, 2324B) +│ └── use-bun-instead-of-node-vite-npm-pnpm.mdc (2b92f41, 51L, 2352ch, 2364B) ├── .github/ (1) │ └── workflows/ (2) │ ├── ci.yml (8718b72, 56L, 1568ch, 1568B) @@ -14,28 +15,30 @@ ├── assets/ (1) │ └── noorm-logo.svg (8d46c28, 6L, 2529ch, 2529B) ├── docs/ (5) -│ ├── design/ (27) +│ ├── design/ (29) │ │ ├── app-tsx-decomposition.md (6620907, 142L, 9400ch, 9870B) │ │ ├── bidirectional-predicates.md (5e872cc, 67L, 3485ch, 3522B) │ │ ├── branding.md (6025a97, 160L, 7673ch, 7956B) │ │ ├── cli-and-outputs.md (4822c56, 135L, 5344ch, 5412B) │ │ ├── dd-spotlight-grid.md (9435542, 60L, 7640ch, 7699B) │ │ ├── dfd-edge-hover-data.md (a5e515f, 100L, 5565ch, 5603B) -│ │ ├── dfd-nesting-depth.md (3cea320, 75L, 3761ch, 3793B) +│ │ ├── dfd-nesting-depth.md (4f3261d, 75L, 3767ch, 3799B) │ │ ├── dfd-overhaul.md (74239cb, 93L, 8816ch, 8888B) │ │ ├── dict-navigation.md (0c0f0f7, 100L, 5253ch, 5278B) -│ │ ├── example-instance-tables.md (ba05799, 135L, 9385ch, 9441B) +│ │ ├── example-instance-tables.md (2a61175, 135L, 9391ch, 9447B) │ │ ├── folder-model.md (0c51512, 104L, 5558ch, 5591B) │ │ ├── graph-flow-search.md (6220a4f, 84L, 8535ch, 8581B) │ │ ├── graph-position-persistence.md (7d5bf76, 118L, 7012ch, 7070B) │ │ ├── help-overlay.md (f573c6b, 62L, 3274ch, 3291B) +│ │ ├── ignatius-modeling-skill.md (64af745, 160L, 10830ch, 10894B) │ │ ├── ignatius-project-config.md (854740b, 107L, 6806ch, 6838B) │ │ ├── key-inheritance-lineage.md (63dd0fb, 175L, 10759ch, 10911B) │ │ ├── keyboard-nav-shortcuts.md (37b922e, 110L, 5283ch, 5301B) -│ │ ├── markdown-driven-erd.md (aa374b7, 333L, 12629ch, 12757B) -│ │ ├── noorm-flow-discovery.md (d205c66, 179L, 14142ch, 14240B) -│ │ ├── ignatius-modeling-skill.md (a1dd258, 151L, 9973ch, 10027B) -│ │ ├── process-flows.md (7025e11, 218L, 16377ch, 16502B) +│ │ ├── markdown-driven-erd.md (2a28ec9, 333L, 12635ch, 12763B) +│ │ ├── model-index-routing.md (5775685, 476L, 23244ch, 23505B) +│ │ ├── noorm-flow-discovery.md (2062784, 179L, 14151ch, 14249B) +│ │ ├── noorm-modeling-skill.md (9d8e831, 12L, 415ch, 419B) +│ │ ├── process-flows.md (500469f, 218L, 16380ch, 16505B) │ │ ├── schema-lint-and-error-ux.md (66d2f5e, 205L, 15443ch, 15515B) │ │ ├── src-root-organization.md (d6f979d, 49L, 2713ch, 2719B) │ │ ├── unified-app.md (1e34ad1, 152L, 11415ch, 11479B) @@ -44,43 +47,45 @@ │ │ └── wiki-entity-links.md (22465e6, 59L, 3725ch, 3757B) │ ├── guides/ (10) │ │ ├── building-from-source.md (554c6b2, 50L, 2145ch, 2145B) -│ │ ├── commands.md (617de4d, 156L, 9020ch, 9068B) +│ │ ├── commands.md (5ec0b65, 174L, 11245ch, 11293B) │ │ ├── derivation.md (49e8769, 45L, 2019ch, 2033B) -│ │ ├── flows.md (ec7f7cd, 148L, 8533ch, 8577B) -│ │ ├── folder-format.md (ec20419, 159L, 5499ch, 5517B) -│ │ ├── getting-started.md (3d74720, 93L, 3524ch, 3538B) -│ │ ├── modeling-skill.md (7bba03c, 71L, 3719ch, 3735B) +│ │ ├── flows.md (0eb840e, 150L, 9000ch, 9044B) +│ │ ├── folder-format.md (81923f3, 256L, 11517ch, 11560B) +│ │ ├── getting-started.md (85d1b13, 93L, 3536ch, 3550B) +│ │ ├── modeling-skill.md (e374c8c, 73L, 4105ch, 4121B) │ │ ├── predicates.md (502da1e, 83L, 3790ch, 3790B) │ │ ├── themes-and-branding.md (2290a7b, 83L, 2823ch, 2827B) -│ │ └── validation.md (9a4c51a, 113L, 5383ch, 5397B) +│ │ └── validation.md (f3db1e9, 137L, 6833ch, 6847B) │ ├── research/ (2) │ │ ├── dfd-layout-and-leveling.md (091455c, 129L, 14807ch, 14993B) │ │ └── ssadm-dfd-rules.md (7d83a02, 118L, 8356ch, 8460B) -│ ├── spec/ (33) +│ ├── spec/ (35) │ │ ├── app-tsx-decomposition.md (430d88c, 246L, 21478ch, 22248B) │ │ ├── bidirectional-predicates.md (e6355c0, 157L, 7664ch, 7712B) │ │ ├── branding.md (bd50adc, 102L, 8976ch, 9031B) -│ │ ├── cli-and-outputs.md (5cb56d9, 133L, 12077ch, 12154B) +│ │ ├── cli-and-outputs.md (2010e2f, 144L, 12667ch, 12748B) │ │ ├── dd-spotlight-grid.md (a914108, 239L, 47419ch, 47725B) -│ │ ├── derive-classification.md (0134258, 72L, 4331ch, 4370B) +│ │ ├── derive-classification.md (e6b0392, 72L, 4334ch, 4373B) │ │ ├── dfd-edge-hover-data.md (07e995c, 83L, 10184ch, 10287B) -│ │ ├── dfd-nesting-depth.md (5272e0c, 69L, 8054ch, 8109B) +│ │ ├── dfd-nesting-depth.md (a458218, 69L, 8063ch, 8118B) │ │ ├── dfd-overhaul.md (7b63691, 155L, 34405ch, 34673B) │ │ ├── dfd-polish-round2.md (17f1b5a, 169L, 9418ch, 9488B) │ │ ├── dfd-polish-round3.md (a9bd5eb, 238L, 14549ch, 14692B) │ │ ├── dfd-polish-round4.md (b3be090, 159L, 8446ch, 8514B) │ │ ├── dict-navigation.md (07a7ce2, 90L, 6183ch, 6205B) │ │ ├── dict-polish.md (eef2bdc, 87L, 6588ch, 6618B) -│ │ ├── example-instance-tables.md (a7c76ab, 140L, 15242ch, 15296B) -│ │ ├── folder-model.md (9221504, 117L, 11731ch, 11793B) +│ │ ├── example-instance-tables.md (951cc49, 170L, 19245ch, 19315B) +│ │ ├── folder-model.md (70788dd, 117L, 11755ch, 11817B) │ │ ├── graph-flow-search.md (1080c63, 199L, 18217ch, 18435B) │ │ ├── graph-position-persistence.md (ae7bdcd, 106L, 8067ch, 8126B) │ │ ├── help-overlay.md (e4faa50, 64L, 4840ch, 4862B) +│ │ ├── ignatius-modeling-skill.md (2969d84, 211L, 22984ch, 23115B) │ │ ├── ignatius-project-config.md (9cbf90e, 108L, 9814ch, 9879B) │ │ ├── key-inheritance-lineage.md (d04bc27, 372L, 26421ch, 26657B) │ │ ├── keyboard-nav-shortcuts.md (0c00b77, 189L, 16705ch, 16864B) -│ │ ├── noorm-flow-discovery.md (4b7f5a8, 83L, 10554ch, 10666B) -│ │ ├── ignatius-modeling-skill.md (947190e, 204L, 22457ch, 22582B) +│ │ ├── model-index-routing.md (aaa3982, 270L, 29790ch, 30042B) +│ │ ├── noorm-flow-discovery.md (62f4c01, 83L, 10572ch, 10684B) +│ │ ├── noorm-modeling-skill.md (b2a16d4, 12L, 412ch, 418B) │ │ ├── process-flows.md (e5e6472, 682L, 83302ch, 83868B) │ │ ├── render-perf-indexing.md (cf067c8, 231L, 17191ch, 17394B) │ │ ├── schema-lint-and-error-ux.md (6a3d652, 141L, 20922ch, 20995B) @@ -109,78 +114,112 @@ │ │ ├── groups/ (1) │ │ │ └── core.md (f94ead2, 7L, 91ch, 91B) │ │ └── ignatius.yml (a075bab, 23L, 618ch, 620B) -│ ├── key-inherited/ (6) -│ │ ├── data/ (4) -│ │ │ ├── catalog/ (2 files, 0 dirs) -│ │ │ ├── identity/ (8 files, 0 dirs) -│ │ │ ├── reference/ (3 files, 0 dirs) -│ │ │ └── transactional/ (11 files, 0 dirs) -│ │ ├── externals/ (1) -│ │ │ └── Customer.md (63998d7, 35L, 1690ch, 1698B) -│ │ ├── flows/ (2) -│ │ │ ├── order-to-cash/ (3 files, 1 dir) -│ │ │ └── refund/ (1 file, 0 dirs) -│ │ ├── groups/ (4) +│ ├── key-inherited/ (7) +│ │ ├── data/ (5) +│ │ │ ├── catalog/ (3 files, 0 dirs) +│ │ │ ├── identity/ (9 files, 0 dirs) +│ │ │ ├── reference/ (4 files, 0 dirs) +│ │ │ ├── transactional/ (12 files, 0 dirs) +│ │ │ └── index.md (21f55c7, 16L, 538ch, 540B) +│ │ ├── externals/ (2) +│ │ │ ├── Customer.md (63998d7, 35L, 1690ch, 1698B) +│ │ │ └── index.md (aa2cd56, 13L, 358ch, 360B) +│ │ ├── flows/ (3) +│ │ │ ├── order-to-cash/ (4 files, 1 dir) +│ │ │ ├── refund/ (2 files, 0 dirs) +│ │ │ └── index.md (b756ede, 14L, 420ch, 422B) +│ │ ├── groups/ (5) │ │ │ ├── catalog.md (4c258fd, 8L, 164ch, 166B) │ │ │ ├── identity.md (7526492, 8L, 274ch, 276B) +│ │ │ ├── index.md (f667ec7, 16L, 514ch, 516B) │ │ │ ├── reference.md (1967601, 8L, 234ch, 236B) │ │ │ └── transactional.md (0425821, 8L, 204ch, 204B) +│ │ ├── stores/ (2) +│ │ │ ├── gateway-log.md (89700cd, 12L, 460ch, 462B) +│ │ │ └── index.md (9b481ad, 13L, 358ch, 360B) +│ │ ├── ignatius.yml (3c11369, 20L, 463ch, 465B) +│ │ └── index.md (fe62a9b, 11L, 748ch, 748B) +│ ├── llm-memory-db-mssql/ (10) +│ │ ├── data/ (9) +│ │ │ ├── artifact/ (4 files, 0 dirs) +│ │ │ ├── audit/ (7 files, 0 dirs) +│ │ │ ├── identity/ (3 files, 0 dirs) +│ │ │ ├── knowledge/ (5 files, 0 dirs) +│ │ │ ├── memory/ (4 files, 0 dirs) +│ │ │ ├── planning/ (5 files, 0 dirs) +│ │ │ ├── reference/ (11 files, 0 dirs) +│ │ │ ├── tagging/ (7 files, 0 dirs) +│ │ │ └── index.md (b5d23a6, 20L, 1455ch, 1457B) +│ │ ├── externals/ (2) +│ │ │ ├── LLM-Agent.md (d513fa0, 23L, 2166ch, 2174B) +│ │ │ └── index.md (e443305, 13L, 480ch, 482B) +│ │ ├── flows/ (7) +│ │ │ ├── agent-project-setup/ (5 files, 0 dirs) +│ │ │ ├── artifact-management/ (5 files, 0 dirs) +│ │ │ ├── memory-lifecycle/ (7 files, 0 dirs) +│ │ │ ├── note-capture/ (5 files, 0 dirs) +│ │ │ ├── tag-administration/ (5 files, 0 dirs) +│ │ │ ├── work-planning/ (6 files, 0 dirs) +│ │ │ └── index.md (94b9514, 18L, 794ch, 796B) +│ │ ├── groups/ (9) +│ │ │ ├── artifact.md (f338781, 9L, 290ch, 290B) +│ │ │ ├── audit.md (2163451, 9L, 396ch, 398B) +│ │ │ ├── identity.md (531ef1d, 9L, 393ch, 395B) +│ │ │ ├── index.md (dabcbed, 20L, 1403ch, 1405B) +│ │ │ ├── knowledge.md (618d5b6, 9L, 302ch, 302B) +│ │ │ ├── memory.md (2ecf438, 9L, 327ch, 329B) +│ │ │ ├── planning.md (72720d5, 9L, 353ch, 353B) +│ │ │ ├── reference.md (a18698e, 9L, 437ch, 437B) +│ │ │ └── tagging.md (8f3ee0c, 9L, 320ch, 320B) │ │ ├── stores/ (1) -│ │ │ └── gateway-log.md (89700cd, 12L, 460ch, 462B) -│ │ └── ignatius.yml (3c11369, 20L, 463ch, 465B) -│ ├── llm-memory-db-mssql/ (5) -│ │ ├── data/ (8) -│ │ │ ├── artifact/ (3 files, 0 dirs) -│ │ │ ├── audit/ (6 files, 0 dirs) -│ │ │ ├── identity/ (2 files, 0 dirs) -│ │ │ ├── knowledge/ (4 files, 0 dirs) -│ │ │ ├── memory/ (3 files, 0 dirs) -│ │ │ ├── planning/ (4 files, 0 dirs) -│ │ │ ├── reference/ (10 files, 0 dirs) -│ │ │ └── tagging/ (6 files, 0 dirs) +│ │ │ └── index.md (c4dac7e, 12L, 307ch, 309B) +│ │ ├── 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 (045f97f, 13L, 1039ch, 1041B) +│ ├── orm-hybrid/ (7) +│ │ ├── data/ (5) +│ │ │ ├── catalog/ (3 files, 0 dirs) +│ │ │ ├── identity/ (9 files, 0 dirs) +│ │ │ ├── reference/ (4 files, 0 dirs) +│ │ │ ├── transactional/ (12 files, 0 dirs) +│ │ │ └── index.md (eda8916, 16L, 535ch, 537B) │ │ ├── externals/ (1) -│ │ │ └── LLM-Agent.md (80252e7, 22L, 2041ch, 2049B) -│ │ ├── flows/ (6) -│ │ │ ├── agent-project-setup/ (4 files, 0 dirs) -│ │ │ ├── artifact-management/ (4 files, 0 dirs) -│ │ │ ├── memory-lifecycle/ (6 files, 0 dirs) -│ │ │ ├── note-capture/ (4 files, 0 dirs) -│ │ │ ├── tag-administration/ (4 files, 0 dirs) -│ │ │ └── work-planning/ (5 files, 0 dirs) -│ │ ├── groups/ (8) -│ │ │ ├── artifact.md (503bb39, 8L, 188ch, 188B) -│ │ │ ├── audit.md (e7c02f0, 8L, 302ch, 304B) -│ │ │ ├── identity.md (d5be610, 8L, 264ch, 266B) -│ │ │ ├── knowledge.md (c53eb29, 8L, 223ch, 223B) -│ │ │ ├── memory.md (0485cc3, 8L, 220ch, 222B) -│ │ │ ├── planning.md (5cbadac, 8L, 251ch, 251B) -│ │ │ ├── reference.md (46e1e01, 8L, 335ch, 335B) -│ │ │ └── tagging.md (f583763, 8L, 215ch, 215B) -│ │ └── ignatius.yml (a40a7ad, 13L, 743ch, 749B) -│ ├── orm-hybrid/ (3) -│ │ ├── data/ (4) -│ │ │ ├── catalog/ (2 files, 0 dirs) -│ │ │ ├── identity/ (8 files, 0 dirs) -│ │ │ ├── reference/ (3 files, 0 dirs) -│ │ │ └── transactional/ (11 files, 0 dirs) -│ │ ├── groups/ (4) +│ │ │ └── index.md (be3559c, 12L, 302ch, 304B) +│ │ ├── flows/ (1) +│ │ │ └── index.md (ec168ed, 12L, 294ch, 296B) +│ │ ├── groups/ (5) │ │ │ ├── catalog.md (4c258fd, 8L, 164ch, 166B) │ │ │ ├── identity.md (7526492, 8L, 274ch, 276B) +│ │ │ ├── index.md (3dff87b, 16L, 511ch, 513B) │ │ │ ├── reference.md (1967601, 8L, 234ch, 236B) │ │ │ └── transactional.md (0425821, 8L, 204ch, 204B) -│ │ └── ignatius.yml (d5d7c97, 20L, 462ch, 464B) -│ ├── orm-pure/ (3) -│ │ ├── data/ (4) -│ │ │ ├── catalog/ (2 files, 0 dirs) -│ │ │ ├── identity/ (8 files, 0 dirs) -│ │ │ ├── reference/ (3 files, 0 dirs) -│ │ │ └── transactional/ (11 files, 0 dirs) -│ │ ├── groups/ (4) +│ │ ├── stores/ (1) +│ │ │ └── index.md (dae4e31, 12L, 296ch, 298B) +│ │ ├── ignatius.yml (d5d7c97, 20L, 462ch, 464B) +│ │ └── index.md (286396d, 11L, 750ch, 750B) +│ ├── orm-pure/ (7) +│ │ ├── data/ (5) +│ │ │ ├── catalog/ (3 files, 0 dirs) +│ │ │ ├── identity/ (9 files, 0 dirs) +│ │ │ ├── reference/ (4 files, 0 dirs) +│ │ │ ├── transactional/ (12 files, 0 dirs) +│ │ │ └── index.md (4186cc7, 16L, 533ch, 535B) +│ │ ├── externals/ (1) +│ │ │ └── index.md (655389c, 12L, 300ch, 302B) +│ │ ├── flows/ (1) +│ │ │ └── index.md (029ef0d, 12L, 292ch, 294B) +│ │ ├── groups/ (5) │ │ │ ├── catalog.md (4c258fd, 8L, 164ch, 166B) │ │ │ ├── identity.md (7526492, 8L, 274ch, 276B) +│ │ │ ├── index.md (d30c20a, 16L, 509ch, 511B) │ │ │ ├── reference.md (1967601, 8L, 234ch, 236B) │ │ │ └── transactional.md (0425821, 8L, 204ch, 204B) -│ │ └── ignatius.yml (b69f627, 20L, 450ch, 452B) +│ │ ├── stores/ (1) +│ │ │ └── index.md (a82c903, 12L, 294ch, 296B) +│ │ ├── ignatius.yml (b69f627, 20L, 450ch, 452B) +│ │ └── index.md (563b1c0, 11L, 750ch, 750B) │ └── tmp/ (5) │ ├── current.png (669cf22, 401L, 120636ch, 124830B) │ ├── detail.png (60f9ede, 126L, 35489ch, 36840B) @@ -188,29 +227,29 @@ │ ├── detail3.png (6b7cd20, 4L, 1501ch, 1525B) │ └── zoomed.png (669cf22, 401L, 120636ch, 124830B) ├── scripts/ (6) -│ ├── convert-yaml-to-md.ts (934464c, 257L, 9009ch, 9017B) +│ ├── convert-yaml-to-md.ts (09465eb, 270L, 9508ch, 9516B) │ ├── gen-synthetic-model.ts (3e960ce, 428L, 15552ch, 16414B) │ ├── perf-harness.ts (7ea0ac8, 215L, 7526ch, 8872B) -│ ├── probe.ts (3eb0e3a, 95L, 2865ch, 2865B) +│ ├── probe.ts (8e284c3, 93L, 2664ch, 2666B) │ ├── screenshot.ts (641f151, 82L, 3044ch, 3048B) │ └── stable-names.ts (a69a3aa, 46L, 2038ch, 2040B) ├── skills/ (1) │ └── ignatius-modeling/ (2) │ ├── references/ (10) -│ │ ├── conventions.md (89a838d, 41L, 1459ch, 1475B) -│ │ ├── dfd-authoring.md (0b2b14f, 244L, 13521ch, 13671B) +│ │ ├── conventions.md (d42e9e0, 64L, 3000ch, 3024B) +│ │ ├── dfd-authoring.md (a431528, 248L, 13974ch, 14128B) │ │ ├── discover-flow.md (82abbaa, 175L, 10445ch, 10543B) -│ │ ├── entity-flow.md (bac1114, 242L, 15710ch, 15856B) -│ │ ├── flow-templates.md (7ba9669, 192L, 6181ch, 6201B) +│ │ ├── entity-flow.md (9c72852, 247L, 16390ch, 16546B) +│ │ ├── flow-templates.md (16eb9c1, 195L, 6351ch, 6377B) │ │ ├── interviewing.md (7e93bc1, 13L, 1853ch, 1859B) -│ │ ├── model-flow.md (067c810, 86L, 3681ch, 3720B) +│ │ ├── model-flow.md (260b6af, 86L, 3787ch, 3826B) │ │ ├── reverse-engineering.md (5404fa5, 124L, 7973ch, 8041B) -│ │ ├── templates.md (cb9233f, 438L, 11865ch, 11927B) -│ │ └── verification.md (47ee7fa, 77L, 7649ch, 7707B) -│ └── SKILL.md (cc2af30, 48L, 4879ch, 4925B) +│ │ ├── templates.md (0d1ba67, 440L, 12029ch, 12095B) +│ │ └── verification.md (d6cdba2, 96L, 9489ch, 9551B) +│ └── SKILL.md (5ca02a1, 49L, 5435ch, 5485B) ├── spec/ (1) -│ └── spec.md (1472997, 464L, 20238ch, 20368B) [generated] -├── src/ (9) +│ └── spec.md (478f9ed, 464L, 20340ch, 20470B) [generated] +├── src/ (10) │ ├── app/ (11) │ │ ├── components/ (6) │ │ │ ├── entity/ (9 files, 0 dirs) @@ -218,7 +257,7 @@ │ │ │ ├── flow/ (1 file, 0 dirs) │ │ │ ├── flow-node/ (4 files, 0 dirs) │ │ │ ├── process/ (6 files, 0 dirs) -│ │ │ └── ui/ (5 files, 0 dirs) +│ │ │ └── ui/ (7 files, 0 dirs) │ │ ├── dom/ (2) │ │ │ ├── body-links.ts (d795f15, 70L, 2869ch, 2875B) │ │ │ └── theme-css-vars.ts (61ca750, 114L, 6255ch, 6267B) @@ -227,12 +266,14 @@ │ │ │ ├── useKeyboardShortcuts.ts (226e7b4, 101L, 4029ch, 4049B) │ │ │ ├── useModelData.ts (b582ec4, 173L, 6319ch, 6451B) │ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B) -│ │ ├── logic/ (11) +│ │ ├── logic/ (13) │ │ │ ├── color.ts (8006242, 39L, 1796ch, 1796B) │ │ │ ├── doc-resolver.ts (075dcf2, 125L, 5950ch, 5970B) │ │ │ ├── finding-rows.ts (831abb1, 48L, 1695ch, 1695B) │ │ │ ├── flow-node-ids.ts (aa105f6, 28L, 950ch, 954B) │ │ │ ├── flow-spotlight.ts (0725869, 136L, 5096ch, 5114B) +│ │ │ ├── json-highlight.ts (df29eb8, 41L, 1679ch, 1683B) +│ │ │ ├── json-value.ts (2076a19, 73L, 2566ch, 2570B) │ │ │ ├── relationship-key.ts (21bbb6e, 21L, 950ch, 956B) │ │ │ ├── search.ts (3c76b8b, 272L, 10130ch, 10138B) │ │ │ ├── shortcuts.ts (b246af3, 159L, 6893ch, 6941B) @@ -248,9 +289,9 @@ │ │ ├── hash-router.ts (04e4fa6, 95L, 2804ch, 2806B) │ │ ├── index.html (e560504, 14L, 396ch, 396B) │ │ ├── main.tsx (881fa67, 12L, 293ch, 293B) -│ │ └── styles.css (21a1ed2, 2830L, 66234ch, 67362B) +│ │ └── styles.css (d905e84, 2935L, 69127ch, 70271B) │ ├── cli/ (7) -│ │ ├── cli.ts (284a888, 336L, 11989ch, 14833B) +│ │ ├── cli.ts (d7d3e48, 435L, 16195ch, 19355B) │ │ ├── discover.ts (2a355d6, 176L, 5910ch, 5940B) │ │ ├── open-browser.ts (b9b6fa8, 33L, 1206ch, 1210B) │ │ ├── resolve-model.ts (0c23743, 70L, 2276ch, 2294B) @@ -266,41 +307,50 @@ │ ├── flows/ (6) │ │ ├── flow-derive-levels.ts (44aabf8, 434L, 15982ch, 16052B) │ │ ├── flow-fingerprint.ts (a013654, 90L, 3428ch, 3442B) -│ │ ├── flow-parse.ts (9123dbd, 771L, 29468ch, 29546B) +│ │ ├── flow-parse.ts (add91d9, 818L, 31471ch, 31549B) │ │ ├── flow-usage-index.ts (0d79284, 244L, 7296ch, 7321B) -│ │ ├── flow-validate.ts (b2e2404, 727L, 28290ch, 28336B) +│ │ ├── flow-validate.ts (1799f6a, 727L, 28290ch, 28336B) │ │ └── titlelize.ts (c7f9798, 47L, 1661ch, 1685B) │ ├── generators/ (2) │ │ ├── app.ts (ec0088c, 143L, 6287ch, 6317B) │ │ └── embedded-bundle.ts (5de9575, 119L, 4895ch, 4905B) -│ ├── model/ (5) +│ ├── model/ (6) │ │ ├── layout-fingerprint.ts (e1eb9bc, 34L, 1235ch, 1237B) +│ │ ├── markdown-highlight.ts (6eb6df2, 54L, 2342ch, 2348B) │ │ ├── model-index.ts (9fb3487, 222L, 8166ch, 8210B) -│ │ ├── parse.ts (0aeb616, 427L, 15917ch, 15943B) -│ │ ├── validate.ts (d22e2ba, 639L, 26038ch, 26071B) +│ │ ├── parse.ts (8dbb589, 511L, 20024ch, 20054B) +│ │ ├── validate.ts (c2ded26, 766L, 31353ch, 31396B) │ │ └── wikilink.ts (b6c297b, 98L, 3416ch, 3426B) +│ ├── router/ (6) +│ │ ├── agents.ts (3da0e86, 191L, 9053ch, 9061B) +│ │ ├── build.ts (a67fab6, 359L, 12982ch, 13011B) +│ │ ├── detect.ts (4c3bc20, 33L, 1132ch, 1134B) +│ │ ├── fingerprint.ts (efec556, 33L, 1132ch, 1134B) +│ │ ├── region.ts (d7894db, 134L, 4955ch, 4959B) +│ │ └── write.ts (74540d8, 48L, 2005ch, 2007B) │ ├── server/ (1) -│ │ └── server.ts (0bec9c9, 176L, 6763ch, 6771B) +│ │ └── server.ts (0a05f50, 182L, 7249ch, 7259B) │ ├── theme/ (2) -│ │ ├── branding-defaults.ts (4ede4cb, 78L, 2512ch, 2514B) -│ │ └── theme-defaults.ts (e3cdd69, 183L, 6248ch, 6400B) +│ │ ├── branding-defaults.ts (9e1300e, 134L, 4495ch, 4497B) +│ │ └── theme-defaults.ts (fb1a850, 188L, 6659ch, 6813B) │ └── types/ (4) │ ├── css-highlight.d.ts (68d2921, 38L, 1071ch, 1071B) │ ├── cytoscape-fcose.d.ts (5a609a6, 17L, 673ch, 673B) │ ├── cytoscape-navigator.d.ts (10d3feb, 51L, 1802ch, 1806B) │ └── file-imports.d.ts (c8fc2ce, 46L, 1951ch, 1953B) ├── test/ (5) -│ ├── checks/ (87) +│ ├── checks/ (97) │ │ ├── test-api-model.ts (7cb8b51, 97L, 3807ch, 3809B) │ │ ├── test-app-gen-zero-diagrams.ts (34d2a2f, 130L, 4325ch, 4581B) │ │ ├── test-app-title.ts (53472fb, 144L, 5253ch, 5631B) │ │ ├── test-asset-route.ts (61fbc88, 61L, 2317ch, 2317B) -│ │ ├── test-branding-parse.ts (7a0e37a, 121L, 5474ch, 5474B) +│ │ ├── test-branding-parse.ts (a8701dc, 148L, 7280ch, 7284B) │ │ ├── test-branding-zero-network.ts (a383f56, 205L, 8157ch, 8653B) │ │ ├── test-cli-binary.ts (40de80b, 229L, 8269ch, 11087B) │ │ ├── test-cli-discovery.ts (fa96c0a, 125L, 4728ch, 6610B) │ │ ├── test-cli-stderr.ts (df4019b, 165L, 6932ch, 6946B) │ │ ├── test-cli-validate.ts (c59fa57, 80L, 3787ch, 3797B) +│ │ ├── test-code-highlight.ts (0113870, 98L, 4159ch, 4177B) │ │ ├── test-config-yaml.ts (636fefe, 95L, 4489ch, 4499B) │ │ ├── test-cp15-flow-kind-palette.ts (dc016db, 105L, 4313ch, 4739B) │ │ ├── test-cp16-process-examples.ts (5f6c653, 198L, 10410ch, 10444B) @@ -315,10 +365,11 @@ │ │ ├── test-cp5-title-override.ts (fd3d4d5, 164L, 7764ch, 7780B) │ │ ├── test-deep-nesting.ts (492a7b9, 176L, 7176ch, 7214B) │ │ ├── test-derive-classification.ts (3343b3f, 143L, 4434ch, 4768B) +│ │ ├── test-description-field.ts (50b18bb, 156L, 5410ch, 5412B) │ │ ├── test-dfd-edge-hover.ts (e0457ce, 229L, 9505ch, 9513B) │ │ ├── test-dict-render-coverage.ts (11283b9, 252L, 11650ch, 12035B) │ │ ├── test-dict-route.ts (941d985, 70L, 1848ch, 1848B) -│ │ ├── test-dict-search-branding-overlap.ts (7a2fcae, 135L, 5543ch, 5689B) +│ │ ├── test-dict-search-branding-overlap.ts (431e394, 160L, 6881ch, 7031B) │ │ ├── test-discover.ts (3886e2b, 182L, 7454ch, 7486B) │ │ ├── test-edge-hover-data.ts (8976f0c, 169L, 5555ch, 5799B) │ │ ├── test-elk-flow-positions.ts (a5c93c5, 162L, 5884ch, 6172B) @@ -340,7 +391,9 @@ │ │ ├── test-graph-search.ts (1c9b1be, 318L, 15347ch, 15391B) │ │ ├── test-hash-router.ts (46f7614, 219L, 6942ch, 6998B) │ │ ├── test-help-overlay.ts (8d52edc, 140L, 5469ch, 5697B) +│ │ ├── test-index-config.ts (49bfc7b, 201L, 8715ch, 8721B) │ │ ├── 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-layout-fingerprint.ts (92d8eea, 255L, 8386ch, 8464B) │ │ ├── test-layout-key-injection.ts (1ff0306, 104L, 4296ch, 4300B) @@ -357,9 +410,14 @@ │ │ ├── test-parse-predicate.ts (c631665, 150L, 5407ch, 5473B) │ │ ├── test-process-node-size.ts (73a9c22, 113L, 5581ch, 5885B) │ │ ├── test-relationship-row-key.ts (4642966, 81L, 4077ch, 4097B) +│ │ ├── test-router-agents.ts (9319001, 267L, 13008ch, 13018B) +│ │ ├── test-router-fingerprint.ts (2e3e134, 81L, 3188ch, 3196B) +│ │ ├── test-router-index.ts (4512eda, 424L, 21560ch, 21592B) +│ │ ├── test-router-region.ts (7ebc6f5, 689L, 21800ch, 21872B) │ │ ├── test-semantic-colors.ts (60b1f25, 71L, 2206ch, 2206B) │ │ ├── test-serve-port.ts (6e3e5fb, 52L, 1928ch, 2192B) │ │ ├── test-shortcuts.ts (1edcc27, 506L, 23281ch, 23527B) +│ │ ├── test-skill-no-repo-paths.ts (c7f12c3, 68L, 2714ch, 2734B) │ │ ├── test-spotlight-connections.ts (0806e1c, 282L, 10879ch, 10931B) │ │ ├── test-spotlight-inherited.ts (8f7b2c4, 388L, 18139ch, 18383B) │ │ ├── test-spotlight-lines.ts (e0cb78a, 207L, 9741ch, 9775B) @@ -372,6 +430,7 @@ │ │ ├── test-validate-entity.ts (6988d0e, 238L, 10322ch, 10358B) │ │ ├── test-validate-examples.ts (d90bba4, 301L, 12219ch, 12276B) │ │ ├── test-validate-flows.ts (8ed98dc, 413L, 16252ch, 16268B) +│ │ ├── test-validate-index.ts (2c353b2, 293L, 13603ch, 13617B) │ │ ├── test-validate-refs.ts (59d58ae, 425L, 20360ch, 20436B) │ │ ├── test-viewer-search.ts (6215f06, 324L, 15034ch, 15096B) │ │ ├── test-wikilink.ts (05926d4, 90L, 4473ch, 4489B) @@ -408,7 +467,7 @@ │ ├── notes/ (2) │ │ ├── another-idea.md (1e542c5, 384L, 16674ch, 17352B) │ │ └── branding-screenshots.md (027cad8, 33L, 2524ch, 2537B) -│ ├── visual/ (64) +│ ├── visual/ (65) │ │ ├── screenshot-branding.ts (702ea86, 65L, 2336ch, 2338B) │ │ ├── screenshot-chrome-unification.ts (a7d7479, 326L, 13382ch, 14842B) │ │ ├── screenshot-cp4-live-dict.ts (f0f85bc, 34L, 1043ch, 1043B) @@ -418,6 +477,7 @@ │ │ ├── screenshot-flow-theme.ts (c6603f4, 237L, 9602ch, 10272B) │ │ ├── screenshot-graph-findings.ts (edcbe6d, 132L, 4663ch, 4677B) │ │ ├── screenshot-hover-fade.ts (674ea85, 97L, 3368ch, 3372B) +│ │ ├── screenshot-json-values.ts (fbd5b21, 123L, 5534ch, 5546B) │ │ ├── screenshot-lineage-highlight.ts (4b8cd1e, 104L, 3840ch, 3860B) │ │ ├── screenshot-position-persist.ts (d0580d9, 288L, 12113ch, 12280B) │ │ ├── screenshot-predicate-hover.ts (b45d185, 138L, 5952ch, 5964B) @@ -496,32 +556,33 @@ │ └── App.tsx (8ff50d4, 78L, 2470ch, 2474B) ├── .gitignore (6d3972b, 53L, 809ch, 811B) ├── .signalsignore (50ea1be, 28L, 1036ch, 1050B) -├── CHANGELOG.md (1e3e17f, 253L, 19446ch, 19452B) -├── CLAUDE.md (e768165, 51L, 2314ch, 2324B) +├── CHANGELOG.md (72b07f4, 276L, 20582ch, 20588B) +├── CLAUDE.md (2b92f41, 51L, 2352ch, 2364B) ├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B) ├── LICENSE (cfc7749, 202L, 11358ch, 11358B) ├── NOTICE (3ae39c7, 2L, 38ch, 38B) -├── README.md (b2f170c, 77L, 4356ch, 4364B) +├── README.md (6a15a77, 82L, 4583ch, 4593B) ├── bun-env.d.ts (4404f36, 16L, 284ch, 284B) -├── bun.lock (01dab35, 107L, 7820ch, 7820B) +├── bun.lock (c190846, 202L, 18164ch, 18164B) ├── bunfig.toml (3ab75b0, 2L, 35ch, 35B) ├── install.sh (64a6757, 116L, 3578ch, 3594B) -├── package.json (1d4e1d0, 49L, 1831ch, 1833B) +├── package.json (c317488, 52L, 1931ch, 1933B) ├── release-please-config.json (9f191fe, 15L, 416ch, 416B) -├── release-please-manifest.json (80cf1eb, 3L, 20ch, 20B) +├── release-please-manifest.json (75bd081, 3L, 20ch, 20B) └── tsconfig.json (1d9427f, 37L, 870ch, 870B) +``` ## Manifests -- package.json: name=ignatius, version=0.15.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] +- package.json: name=ignatius, version=0.17.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck] ## Languages -- TypeScript: 59737 LOC (70%), 267 files (45%) -- Markdown: 20459 LOC (24%), 298 files (50%) -- CSS: 3017 LOC (3%), 2 files (0%) +- TypeScript: 63826 LOC (70%), 289 files (43%) +- Markdown: 22491 LOC (24%), 358 files (53%) +- CSS: 3122 LOC (3%), 2 files (0%) - YAML: 1340 LOC (1%), 14 files (2%) - Shell: 116 LOC (0%), 1 file (0%) -- JSON: 104 LOC (0%), 4 files (0%) +- JSON: 107 LOC (0%), 4 files (0%) - HTML: 27 LOC (0%), 2 files (0%) - TOML: 12 LOC (0%), 2 files (0%) diff --git a/docs/wiki/skill.md b/docs/wiki/skill.md index cf1036f..d8567ee 100644 --- a/docs/wiki/skill.md +++ b/docs/wiki/skill.md @@ -1,59 +1,96 @@ --- type: Domain -description: Project-scoped Claude Code skill (`/ignatius-modeling`) that Q&A-authors ignatius entities, models, DFD flows, and Socratic-discovery models, then verifies with `ignatius validate`. +description: Project-scoped Claude Code skill that Q&A-authors ignatius entities, models, DFD flows, and Socratic-discovery models, then verifies with `ignatius validate`. +tags: [skill, parser, validate] --- # skill ## What it does -[`skills/ignatius-modeling/SKILL.md`](../../skills/ignatius-modeling/SKILL.md) frontmatter: `name: ignatius-modeling`, `description: Guided Q&A authoring of ignatius entities, data flow diagrams, and models, plus Socratic discovery. Use when adding entities, flows, or models.`, `argument-hint: "[entity|model|flow|discover]"`, `allowed-tools: Read Write Edit Bash Glob AskUserQuestion`. +[`skills/ignatius-modeling/`](../../skills/ignatius-modeling) is a Claude Code skill (`name: ignatius-modeling`, invoked as `/ignatius-modeling [entity|model|flow|discover]`) that turns a Q&A conversation into real ignatius model files on disk, then checks its own output by shelling out to `ignatius validate`. Without it, an entity, flow, or model file has to be hand-written against the parser's YAML frontmatter rules and the IDEF1X key-shape conventions in `references/conventions.md`, with no in-conversation check that the result parses or validates. -Four modes selected by `$ARGUMENTS`: `entity` (add one entity file), `model` (bootstrap a new model skeleton), `flow` (author a DFD for a user who already knows their processes), `discover` (Socratic five-gate interview that generates both entities and flows; routes to `references/reverse-engineering.md` when a real database/codebase/schema exists to read instead of a user description). +It never invents structure: PK shape, classification, and cardinality are always derived from what the user's data already implies (`references/conventions.md`), and every write is checked against the same `validateModel` logic the CLI's `validate` command and the test suite exercise, so a file the skill produces is held to the same bar as one a human wrote by hand. -Writes real files to disk and, after every write, runs the verification loop in `references/verification.md`, which shells out to `ignatius validate <model-root>` and parses its stderr (`<sev> <ruleId> <location> <message>`) against a hardcoded rule-reference table to report fix hints. +## How it works -## Artifacts +`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. -- [`skills/ignatius-modeling/SKILL.md`](../../skills/ignatius-modeling/SKILL.md) — entry point: frontmatter, mode-dispatch list, the eight "Core rules" that apply to all four modes (derive-never-ask, convention-is-derived, adapt-to-user-conventions, existence-rules-survive-key-style, subtype-independence, predicates-carry-meaning, examples-always, capture-the-business-story), and the reference-file index. -- [`skills/ignatius-modeling/references/interviewing.md`](../../skills/ignatius-modeling/references/interviewing.md) — how to conduct the Q&A, read first, applies to every mode: one question at a time, explain the why, act (write files) rather than just propose, infer from existing files before asking, reflect after verification instead of blind regeneration, self-check before declaring done, prefer the positive form. -- [`skills/ignatius-modeling/references/entity-flow.md`](../../skills/ignatius-modeling/references/entity-flow.md) — `entity` mode steps E0–E10: locate model root, entity id, group (with a group-creation sub-flow E2a), convention detection (E3, derived default not a mode), PK columns, relationships/predicates with a one-time convention nudge, conditional subtype-cluster step (E5a), alternate keys, columns, always-run examples step (E7b), reference-table flag (E8), business context/rules/lifecycle (E9), write file (E10). -- [`skills/ignatius-modeling/references/model-flow.md`](../../skills/ignatius-modeling/references/model-flow.md) — `model` mode steps M1–M8: target directory, model name, model purpose (`description:`), default key-style suggestion recorded as an `ignatius.yml` comment, optional theme (dark/light palette, spacing, per-kind flow colors), optional branding, groups, optional bootstrap entity, write skeleton files. -- [`skills/ignatius-modeling/references/dfd-authoring.md`](../../skills/ignatius-modeling/references/dfd-authoring.md) — `flow` mode steps F0–F9: locate model root and read entities, diagram identity and standalone-vs-decomposition choice, name processes (Title-Case filename = process id), external sources/sinks, the `db:`/`kind:` store fork, per-field data labels, always-run examples (F6), required bodies per node type (F7), recursive sub-DFD decomposition with no depth cap (F8), write + verify (F9). -- [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) — the three DFD node file templates (process, external entity at `externals/<Name>.md`, non-`db` store at `stores/<slug>.md`) plus worked examples (`Collect-Payment.md`, `Customer.md`, `gateway-log.md`); states its frontmatter keys and endpoint tokens match [`docs/spec/process-flows.md`](../spec/process-flows.md). -- [`skills/ignatius-modeling/references/discover-flow.md`](../../skills/ignatius-modeling/references/discover-flow.md) — `discover` mode: verbs-first shape (find the verbs, derive the nouns, write nouns then verbs), the five gates (Identify, Decide, Justify, Derive, Ground) as plain-English questions, a `<constraints>` block banning the gates' underlying formal-logic names (excluded middle, law of identity, non-contradiction, sufficient reason, four causes, three-valued logic, falsifiable, syllogism, a priori, ontology) from ever reaching the user, and "crystallize as you go" incremental file-writing. -- [`skills/ignatius-modeling/references/reverse-engineering.md`](../../skills/ignatius-modeling/references/reverse-engineering.md) — extracting a model from an existing system (live DB/DDL, ORM models, codebase, stored procedures, API spec, sample data) in five IDEF1X-spirit phases R0–R4; explicitly faithful-first ("never silently 'fix' " an anti-pattern during extraction); feeds candidates back through `discover-flow.md`'s five gates. -- [`skills/ignatius-modeling/references/conventions.md`](../../skills/ignatius-modeling/references/conventions.md) — column type list (`text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json`), column-property table, and the classification/cardinality derivation tables the parser applies (read-only reference — the skill never asks the user for these). -- [`skills/ignatius-modeling/references/templates.md`](../../skills/ignatius-modeling/references/templates.md) — the entity `.md`, `groups/<slug>.md`, and `ignatius.yml` templates, plus worked key-inherited, orm-oriented, business-context, and subtype-cluster (base + member) examples. -- [`skills/ignatius-modeling/references/verification.md`](../../skills/ignatius-modeling/references/verification.md) — the `ignatius validate` loop: stderr line format, the full entity/parse/edge/cluster/body rule table and the `flow.*` rule table (each row mapped back to the authoring step that produced the finding), retry policy (max 5 attempts, prefilled re-ask rather than blind rewrite), and the post-clean-validate self-check (business context captured, predicates read as true sentences, example rows checked against `pk ∪ columns` since `entity.example_unknown_column` is live-server-only and `validate` never prints it). +**The mode's argument selects which reference file drives the conversation, and `discover` forks again on where the evidence comes from.** -## Docs +```mermaid +flowchart LR + A["$ARGUMENTS"] --> B{mode} + B -->|entity| C["entity-flow.md"] + B -->|model| D["model-flow.md"] + B -->|flow| E["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"] +``` -- [`docs/design/ignatius-modeling-skill.md`](../design/ignatius-modeling-skill.md) — original design doc for the `entity`/`model` modes: the problem (hand-written frontmatter, easy-to-miss IDEF1X rules, reactive linting) and goals/non-goals. -- [`docs/spec/ignatius-modeling-skill.md`](../spec/ignatius-modeling-skill.md) — spec for the `entity`/`model` modes: mode table, non-goals (no linter reimplementation, no bulk-create, no old-YAML-format migration, no reverse-engineering-to-editable-form, no CLI subcommand, no templating dependency, no automatic `git add`/`commit`). -- [`docs/design/noorm-flow-discovery.md`](../design/noorm-flow-discovery.md) — design doc that added the `flow` and `discover` modes on top of the original `entity`/`model` skill. -- [`docs/spec/noorm-flow-discovery.md`](../spec/noorm-flow-discovery.md) — spec for `flow`/`discover`: no new `flow.*` validator rules, skill-markdown-only change, `discover` as the generative counterpart to `/pressure-test`. -- [`docs/guides/modeling-skill.md`](../guides/modeling-skill.md) — user-facing guide: prerequisites (`ignatius` on `$PATH` or built via `bun run build:cli`) and install via `npx skills add https://github.com/noormdev/ignatius --skill ignatius-modeling`. -- [`docs/guides/flows.md`](../guides/flows.md) — user-facing guide to the `flows/` folder layout and process frontmatter that `references/dfd-authoring.md` and `references/flow-templates.md` author against. -- [`docs/guides/getting-started.md`](../guides/getting-started.md) — links to `modeling-skill.md` as the recommended way to author entity and flow files. -- [`docs/spec/folder-model.md`](../spec/folder-model.md) — spec that drove a full rewrite of all eight `skills/ignatius-modeling/references/*.md` files (plus `SKILL.md`) onto the current five-folder model-root layout (`data/`, `groups/`, `flows/`, `externals/`, `stores/`, `ignatius.yml` — no leading-underscore folder names). +- `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). +- `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. + +Nine 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, generated by the skill, never skipped, and never named after the model's reserved `index_file` basename), and capture-the-business-story (rules, constraints, and lifecycle go in the body with their source). + +`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:` labels 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 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. + +### The verification loop + +**Every write routes through the same validator, and a finding re-asks the Q&A step that produced it rather than triggering a blind rewrite.** + +```mermaid +flowchart TD + 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"] + Q --> W + P -->|no| S["self-check: business context, predicates, examples"] + S --> Done["report: 0 findings, story captured"] +``` + +`references/verification.md` parses each stderr line (`<sev> <ruleId> <location> <message>`) 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. 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. + +A second, separate gate covers router staleness: `ignatius validate --index <model-root>` 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. + +## Where it lives + +| Path | Covers | +|------|--------| +| [`skills/ignatius-modeling/SKILL.md`](../../skills/ignatius-modeling/SKILL.md) | Entry point: frontmatter, mode dispatch, the nine core rules, 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/dfd-authoring.md`](../../skills/ignatius-modeling/references/dfd-authoring.md) | `flow` mode, steps F0-F9 | +| [`skills/ignatius-modeling/references/flow-templates.md`](../../skills/ignatius-modeling/references/flow-templates.md) | Process, external, and non-`db` store 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/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 | + +## Constraints + +| Condition | Consequence | +|---|---| +| A `data/` file is named after the model's `index_file` value (default `index.md`) and declares `entity:` | Fails `config.index_file_entity`; without `entity:` it is silently treated as a router and skipped, not scanned | +| A body links another entity as `[Party](Party.md)` instead of `[[Party]]` | Renders as a dead relative link, invisible to `body.unknown_link`, never reported | +| A non-`db` flow store uses a `<kind>:` prefix outside `cache`/`queue`/`file`/`doc`/`manual`/`other` | Not read as a kind at all; falls through to process-name resolution and fails with a misleading `flow.unknown_process` | +| An `examples:` row has a key outside `pk ∪ columns` | `entity.example_unknown_column` exists but is live-server-only; `ignatius validate` never prints it, so only the skill's own self-check (Step E7b) catches it | +| A sub-DFD is decomposed (Step F8, no depth cap) | `flow.unbalanced_decomposition` checks each level only against its immediate parent, never the root diagram | +| 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; if that derivation logic changes (parser domain), 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) that `ignatius validate` can emit, including the exact stderr line format (`<sev> <ruleId> <location> <message>`) and the non-zero-only-on-Class-B exit code; a new/renamed lint rule or a CLI output-format change (validate/CLI domains) requires a matching edit here or the fix-hint lookup and the parsing loop both break. -- `references/flow-templates.md` states its frontmatter keys and endpoint tokens (`db:`/`ext:`/`<kind>:`) match [`docs/spec/process-flows.md`](../spec/process-flows.md) exactly — a flow frontmatter schema change (flows domain) must be mirrored in this file and in `dfd-authoring.md`. -- `references/entity-flow.md` Step E7b names [`docs/spec/example-instance-tables.md`](../spec/example-instance-tables.md) as its canonical source for `examples:` row shape — a change to that spec must be mirrored in E7b and the entity template in `references/templates.md`. -- `references/model-flow.md` Step M4's theme key/default table is copied from `src/theme-defaults.ts` (theme domain); a change to those defaults must be mirrored here. -- The skill writes files exclusively into the five-folder model-root layout (`data/<group>/`, `groups/`, `flows/`, `externals/`, `stores/`, `ignatius.yml`) that the parser/folder-model contract owns ([`docs/spec/folder-model.md`](../spec/folder-model.md)); another folder-layout change forces another skill-wide reference rewrite, as it already has once. -- The skill has no independent verification logic of its own — every write is checked exclusively by shelling out to `ignatius validate` (CLI/validate domains); `references/verification.md` explicitly flags one exception the CLI cannot catch (`entity.example_unknown_column` is live-server-only, never printed by `validate`), so the skill's own self-check step is the only gate for that class of error, coupling it to the frontend/server domain's in-app rule surface. - -## Conventions worth knowing - -- Convention (key style) is derived, never asked or declared as a mode: a composite PK containing an FK column is `key-inherited`, a single surrogate `id` PK with FKs outside it is `orm-oriented`. It is detected per entity from existing files (or, for a fresh model, from a `# Default key style:` comment at the top of `ignatius.yml`) and only ever offered as a default suggestion. -- Wiki-links (`[[Name]]`) are the only link form that resolves in bodies; a markdown file link like `[Party](Party.md)` renders as a dead relative link and is invisible to validation (`body.unknown_link` only checks `[[…]]`). -- The non-`db` flow store `<kind>:` prefix set is closed to `cache`/`queue`/`file`/`doc`/`manual`/`other`; an unrecognized prefix is not read as a kind at all — it falls through to process-name resolution and fails with a misleading `flow.unknown_process` error rather than a clear one. -- Examples are never optional: every entity carries 2–3 `examples:` rows and every flow process carries an `examples:` block with `in`/`out` entries, generated by the skill itself from context rather than requested from the user. -- A DFD process's filename (spaces→hyphens, Title-Case preserved, e.g. `Collect Payment` → `Collect-Payment.md`) is its id everywhere: the `proc:` token, wiki-link targets, and the sub-DFD folder name at Step F8 must all match it exactly. -- Sub-DFD decomposition (Step F8) is recursive with no depth cap; dotted process numbers compose automatically per level, and `flow.unbalanced_decomposition` is checked at each level against its immediate parent only, never against the root diagram. -- `discover` mode's five gates (Identify, Decide, Justify, Derive, Ground) are translated into plain business questions; their underlying formal-logic names are explicitly banned from ever appearing in anything the user reads. +- `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 five-folder model-root layout (`data/<group>/`, `groups/`, `flows/`, `externals/`, `stores/`, `ignatius.yml`) that [`docs/spec/folder-model.md`](../spec/folder-model.md) and the parser own; a folder-layout change forces another skill-wide reference rewrite, as it already has once. +- `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` (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:`/`<kind>:`) with the flows domain's own parser; a token-set or frontmatter-key change there must be mirrored in both skill files. diff --git a/docs/wiki/validate.md b/docs/wiki/validate.md index 849a10a..daf9f9b 100644 --- a/docs/wiki/validate.md +++ b/docs/wiki/validate.md @@ -1,40 +1,105 @@ --- type: Domain -description: Pure validation layer that checks a parsed Model against a 27-rule catalog and produces a cleaned model plus structured findings. +description: Rule catalog and cleaned-model producer for a parsed Model, plus a router-digest drift checker +tags: [validation, model-index-routing, rule-catalog] --- # validate ## What it does -[`src/model/validate.ts`](../../src/model/validate.ts) runs a fixed catalog of rules against a parsed `Model` (and, via `FlowError`, flow findings) and returns structured findings plus a `cleanedModel` safe for downstream rendering. It has no Node/Bun I/O — only type-only imports — so it is browser-safe and unit-testable with plain `Model` literals. +[`src/model/validate.ts`](../../src/model/validate.ts) decides what a broken or drifted model file looks like to every downstream surface: the CLI's exit code, the live viewer's warning triangles, the static dict/graph export's findings banner, and `ignatius validate --index`'s router-drift report. Every consumer of a parsed `Model` traces its findings back to this one file's `RULES` registry, so a rule added here is what makes a bad entity, edge, cluster, or stale router file visible anywhere in the tool rather than silently rendered wrong. -## CLI code +The module has two halves with different I/O contracts. `validateModel` is pure: no Node/Bun I/O, only type-only imports, safe to run in the browser bundle. `validateIndex` is not: it shells out to `buildRouters` and `node:fs` to recompute router digests from disk, so it only runs where file access exists (CLI, server). -- [`src/model/validate.ts`](../../src/model/validate.ts) — `validateModel(model: Model): ValidationResult`, `formatFindingsForStderr(globalErrors, entityErrors, flowErrors?): string[]`, `RULES: Record<RuleId, RuleEntry>`, and the types `RuleId`, `EntityError`, `GlobalError`, `ValidationResult`, `RuleEntry`. -- `RuleId` is a union of 27 rule ids across 6 prefixes: parse (`parse.invalid_yaml`, `parse.missing_id`, `parse.empty_frontmatter`), entity (`entity.missing_pk`, `entity.missing_columns`, `entity.invalid_field_type`, `entity.unknown_group`, `entity.ak_unknown_column`, `entity.example_unknown_column`), body (`body.unknown_link`), edge (`edge.unknown_target`, `edge.dangling_fk_column`), cluster (`cluster.missing_basetype`, `cluster.missing_member`, `cluster.no_discriminator`), and flow (12 ids: `flow.unknown_store`, `flow.unknown_external`, `flow.unknown_process`, `flow.unknown_attribute`, `flow.ambiguous_endpoint`, `flow.process_no_input`, `flow.process_no_output`, `flow.illegal_connection`, `flow.process_to_process`, `flow.unbalanced_decomposition`, `flow.duplicate_number`, `flow.store_naming_collision`). -- `RULES` is `Record<RuleId, RuleEntry>` — TypeScript compile-errors if any `RuleId` is missing an entry (compiler-enforced exhaustiveness, no runtime check needed). -- `RuleEntry.class`: `'A'` = render degraded + warning triangle; `'B'` = omit + global banner. `RuleEntry.liveOnly?: boolean` — only `entity.example_unknown_column` sets this (`formatFindingsForStderr` and the static dict generator omit `liveOnly` rows; only the live viewer findings panel and `/api/model` surface them). `RuleEntry.silenceable?: boolean` — only `flow.process_to_process` sets this; it is informational for tooling, silenced via the `flow_rules: { process_to_process: false }` key in `ignatius.yml`. -- `ValidationResult = { entityErrors: EntityError[]; globalErrors: GlobalError[]; cleanedModel: Model }`. `cleanedModel` has Class-B-stripped edges (unknown target) and clusters (missing basetype, with missing members filtered out of surviving clusters), plus nodes with invalid `pk`/`columns` coerced to safe defaults (`[]` / `{}`). -- `formatFindingsForStderr` takes an optional third `flowErrors: FlowError[]` param so CLI callers can pass combined entity + flow findings in one call. It filters out `liveOnly` rows, then sorts: errors before warnings, `ruleId` alphabetical, location alphabetical within that. -- `checkAlternateKeys` implements `entity.ak_unknown_column`: an `ak` entry naming a column not in `pk`/`columns` is dropped silently at the derivation layer, which can turn a referential FK that should resolve one-to-one into a one-to-many — the rule exists to catch that before it becomes a wrong cardinality badge. +## How it works -## Docs +**`validateModel`'s per-rule fork decides whether a flagged node, edge, or cluster survives into `cleanedModel`.** -- [`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) — original design doc for this validation layer (rule catalog, render policy, findings panel). -- [`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) — implementation spec/contract for `validateModel`, the `RULES` registry, and `cleanedModel` stripping behavior. +```mermaid +flowchart TD + accTitle: validateModel classification fork + accDescr: Each rule check either leaves an element alone, flags it while keeping it, or flags it and strips it, based on the rule's Class in the RULES registry. + Model["Model"] --> Check{"rule check per<br/>node/edge/cluster"} + Check -->|"no violation"| Keep["kept, no finding"] + Check -->|"Class A"| Warn["EntityError pushed<br/>(kept, flagged)"] + Check -->|"Class B"| Strip["GlobalError pushed<br/>(stripped)"] + Keep --> Cleaned["cleanedModel"] + Warn --> Cleaned + Strip --> Cleaned +``` -## Coupling +Class is looked up from the `RULES` registry, never computed ad hoc at the call site: `entity.*` and `body.unknown_link` rules are always Class A (the offending node stays, flagged); `parse.*`, `config.*`, `edge.unknown_target`, and `cluster.missing_basetype` are always Class B (the offending edge or cluster is dropped from `cleanedModel`); `edge.dangling_fk_column` and `cluster.missing_member`/`no_discriminator` are Class A. Nodes with an invalid `pk` or `columns` shape are additionally coerced to safe defaults (`[]` / `{}`) in `cleanedModel` regardless of class, so downstream renderers never crash on bad data. -- parser ([`src/model/parse.ts`](../../src/model/parse.ts)): validate.ts type-imports `Model`, `ModelNode`, `ModelEdge`, `SubtypeCluster` from `./parse`; parse.ts type-imports `GlobalError` back from `./validate`. Both directions are `import type` only — no runtime circular dependency, but the two files' exported shapes must stay in sync. -- flows ([`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts), [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)): validate.ts type-imports `FlowError` from `../flows/flow-validate`; flow-validate.ts type-imports `GlobalError` and `RuleId` back, and flow-parse.ts type-imports `GlobalError`. flow-validate.ts mirrors validate.ts's structure for the flow layer and implements all `flow.*` ids declared in validate.ts's `RuleId` union — adding a `flow.*` id here requires adding its implementation and `RULES` entry there (or vice versa). -- cli ([`src/cli/cli.ts`](../../src/cli/cli.ts)): dynamically imports `validateModel`, `formatFindingsForStderr`, `RULES` at two call sites; uses `RULES[ruleId].class` (not the finding's own `severity` field) as the authoritative signal for whether flow findings count toward the hard-exit error count, keeping the exit code derived from one source of truth. -- server ([`src/server/server.ts`](../../src/server/server.ts)): imports `validateModel` directly (not dynamically) to validate models served live. -- frontend ([`src/app/`](../../src/app)): `App.tsx`, `EntityCard.tsx`, `EntityModal.tsx`, `FindingsPanel.tsx`, `ProcessCard.tsx` all import `RULES` to look up rule titles for display; `App.tsx` additionally uses it to filter `liveOnly` findings out of the static-mode panel (`findings.entityErrors.filter(e => !RULES[e.ruleId]?.liveOnly)`); `hooks/useModelData.ts` imports `validateModel` directly; `logic/finding-rows.ts` type-imports `EntityError`, `GlobalError`, `RuleId`. -- Any new `RuleId` added here must get a matching `RULES` entry (compiler-enforced) and, if consumer-facing, a title a reader of `App.tsx`/`FindingsPanel.tsx` would recognize — the registry is the single source of human-readable rule text across CLI stderr, dict, graph, and live viewer. +| | Class A (degrade) | Class B (omit) | +|---|---|---| +| Effect on `cleanedModel` | element stays, flagged | element stripped | +| Finding type | `EntityError` | `GlobalError` | +| CLI exit code | never forces exit 1 by itself | forces exit 1 (`errorCount`) | +| Live/static rendering | degraded + warning triangle | omitted + global banner | + +### validateIndex drift detection + +**`validateIndex` recomputes what `ignatius index` should have written and reports where the current files disagree.** + +```mermaid +flowchart TD + accTitle: validateIndex drift detection + accDescr: buildRouters recomputes digests; a stored-vs-recomputed mismatch fires index.stale, and a stray marked file fires index.orphaned. + BR["buildRouters(root, model, flowModel, unreadable)"] --> Digest["recomputed file.digest<br/>per router"] + Digest --> StaleCheck{"stored digest ==<br/>file.digest?"} + StaleCheck -->|no| Stale["index.stale (Class B)"] + StaleCheck -->|yes| Clean["no finding"] + Digest --> DirScan["readdirSync(dir)"] + DirScan --> OrphanCheck{"entry === expectedBasename?"} + OrphanCheck -->|yes| Orphan["index.orphaned (Class A)"] + OrphanCheck -->|no| Skip["ignored"] +``` + +`index.unreadable_target` (Class B) comes from `buildRouters`'s internal `safeHashFile` failures ([`src/router/build.ts`](../../src/router/build.ts)), collected into an `unreadable` out-param that `validateIndex` maps to findings. `validateIndex`'s own two local catches degrade silently instead: a failed `readFileSync` on a router's stored digest sets `content = null`, which the stale check treats as not-yet-generated (`index.stale`) rather than unreadable; a failed `readdirSync` on a router's directory yields an empty `entries` list, so orphan scanning for that directory silently finds nothing. `buildRouters` is reused rather than reimplemented so the drift comparison can never diverge from what `ignatius index` actually writes to disk. + +## Where it lives + +| Symbol | Kind | What | +|---|---|---| +| `src/model/validate.ts:23` `RuleId` | type | union of 33 rule ids across 8 prefixes | +| `src/model/validate.ts:99` `RuleEntry` | type | `{ title, explanation, class: 'A'\|'B', liveOnly?, silenceable? }` | +| `src/model/validate.ts:124` `RULES` | const | `Record<RuleId, RuleEntry>` — TS compile-errors if any `RuleId` lacks an entry | +| `src/model/validate.ts:71,82,89` `EntityError` / `GlobalError` / `ValidationResult` | types | per-entity vs whole-model finding shapes; `ValidationResult = { entityErrors, globalErrors, cleanedModel }` | +| `src/model/validate.ts:527` `validateModel(model)` | function | pure, no I/O — runs all entity/edge/cluster rule predicates | +| `src/model/validate.ts:623,630` `IndexValidationResult` / `validateIndex(root, model, flowModel)` | type / async function | I/O — recomputes router digests via `buildRouters`, walks folders for orphaned index files | +| `src/model/validate.ts:720` `formatFindingsForStderr(globalErrors, entityErrors, flowErrors?)` | function | sorts (error-before-warning, then ruleId, then location) and formats findings for CLI stderr; drops `liveOnly` rows | -## Conventions worth knowing +Rule catalog by prefix (33 ids total; class shown as fired, `L` = `liveOnly`, `S` = `silenceable`): -- One predicate function per entity/edge/cluster rule (e.g. `checkMissingPk`, `checkEdgeDanglingFkColumn`, `checkClusterNoDiscriminator`), each returning an empty array when the rule is satisfied — `validateModel` just concatenates their outputs per node/edge/cluster. Exception: the two Class-B detectors, `checkEdgeUnknownTarget` and `checkClusterMissingBasetype`, return `GlobalError | null` instead of an array; `validateModel` pushes their result conditionally (`if (unknownTargetError) { ... }`) rather than spread-concatenating it. -- Class A vs Class B is encoded per-rule in the `RULES` registry, not computed ad hoc at each call site — consumers look it up rather than hardcoding which rules strip data. -- `checkMissingColumns` treats `columns` as violating the rule only when missing/empty; `parse.ts` already defaults a missing `columns` field to `{}`, so in practice only the empty-object case fires. +| Prefix | Count | Ids | +|---|---|---| +| `parse.*` | 3, all B | `invalid_yaml`, `missing_id`, `empty_frontmatter` | +| `config.*` | 3, all B | `index_file_ext`, `index_file_path`, `index_file_entity` | +| `index.*` | 3 (B, A, B) | `stale`, `orphaned`, `unreadable_target` | +| `entity.*` | 6, all A | `missing_pk`, `missing_columns`, `invalid_field_type`, `unknown_group`, `ak_unknown_column`, `example_unknown_column` (L) | +| `body.*` | 1, A | `unknown_link` | +| `edge.*` | 2 (B, A) | `unknown_target`, `dangling_fk_column` | +| `cluster.*` | 3 (B, A, A) | `missing_basetype`, `missing_member`, `no_discriminator` | +| `flow.*` | 12 (4 B, 8 A) | `unknown_store`, `unknown_external`, `unknown_process` (B); `unknown_attribute`, `ambiguous_endpoint`, `process_no_input`, `process_no_output`, `process_to_process` (A, S), `unbalanced_decomposition`, `duplicate_number`, `store_naming_collision` (A); `illegal_connection` (B) | + +[`docs/design/schema-lint-and-error-ux.md`](../design/schema-lint-and-error-ux.md) and [`docs/spec/schema-lint-and-error-ux.md`](../spec/schema-lint-and-error-ux.md) carry the original rule-catalog design and contract; [`docs/design/model-index-routing.md`](../design/model-index-routing.md) and [`docs/spec/model-index-routing.md`](../spec/model-index-routing.md) cover the `config.*`/`index.*` additions and `validateIndex`. [`test/checks/test-validate-index.ts`](../../test/checks/test-validate-index.ts) covers `validateIndex`. + +## Constraints + +- `validateModel` and `RULES` must stay free of Node/Bun I/O — the file is imported by the browser-side app ([`src/app/`](../../src/app)), so any I/O import here would break that bundle. `validateIndex` is the deliberate exception: it reaches `buildRouters` and `node:fs` through dynamic `import()` so those modules never enter this file's static import graph. +- `RULES` is `Record<RuleId, RuleEntry>` — adding a `RuleId` without a matching `RULES` entry is a TypeScript compile error, not a runtime gap. +- `flow.process_to_process` is the only rule with `silenceable: true`. The flag is informational; `ignatius.yml`'s `flow_rules: { process_to_process: false }` is the actual enforcement, and relying on `silenceable` alone does nothing to suppress the rule. `entity.example_unknown_column` is the only `liveOnly` rule; `formatFindingsForStderr` and the static dict generator drop it, only the live viewer and `/api/model` surface it. +- `index.stale`/`index.orphaned`/`index.unreadable_target` only fire under `ignatius validate --index`; plain `validate` never calls `validateIndex`, so it never hashes router targets. +- `STORED_DIGEST_RE` (`^<ignatius-index[\s>][^>]*\sdigest="([^"]*)"/m`) and the orphan-scan regex (`^<ignatius-index[\s>]/m`) both anchor to line start. A mid-line mention of `<ignatius-index` does not match either, and a differently-named tag like `<ignatius-index-legacy ...>` does not match the stored-digest regex (its next character after `<ignatius-index` is `-`, not whitespace or `>`). +- CLI callers derive the hard-exit decision from `RULES[ruleId].class === 'B'`, never from a finding's own `severity` field, keeping one source of truth for exit code across entity, global, and flow findings. + +## Coupling + +- parser ([`src/model/parse.ts`](../../src/model/parse.ts)): validate.ts type-imports `Model`, `ModelNode`, `ModelEdge`, `SubtypeCluster` from `./parse`; parse.ts type-imports `GlobalError` back from `./validate`. Both directions are `import type` only, no runtime circular dependency, but the two files' exported shapes must stay in sync. +- flows ([`src/flows/flow-validate.ts`](../../src/flows/flow-validate.ts), [`src/flows/flow-parse.ts`](../../src/flows/flow-parse.ts)): both type-import `GlobalError` from `../model/validate`; `flow-validate.ts` additionally type-imports `RuleId` and implements every `flow.*` id declared in this file's `RuleId` union. +- router ([`src/router/build.ts`](../../src/router/build.ts)): `validateIndex` dynamically imports `buildRouters` and the `UnreadableTarget` type from `../router/build` to recompute digests; this dynamic import is the coupling point between validate and router. [`src/cli/cli.ts`](../../src/cli/cli.ts) is the only caller of `validateIndex`, gated behind the `--index` flag on the `validate` subcommand. +- cli ([`src/cli/cli.ts`](../../src/cli/cli.ts)): dynamically imports `validateModel`, `validateIndex`, `formatFindingsForStderr`, `RULES`; uses `RULES[ruleId].class` as the authoritative signal for whether flow findings count toward the hard-exit error count. +- server ([`src/server/server.ts`](../../src/server/server.ts)): imports `validateModel` directly (not dynamically) to validate models served live. +- frontend ([`src/app/`](../../src/app)): `App.tsx`, `EntityCard.tsx`, `EntityModal.tsx`, `FindingsPanel.tsx`, `ProcessCard.tsx` import `RULES` to look up rule titles for display; `App.tsx` additionally filters `liveOnly` findings out of the static-mode panel; `hooks/useModelData.ts` imports `validateModel` directly. +- Any new `RuleId` added here needs a matching `RULES` entry (compiler-enforced) and, if consumer-facing, a title recognizable in `App.tsx`/`FindingsPanel.tsx` — the registry is the single source of human-readable rule text across CLI stderr, dict, graph, and live viewer. diff --git a/models/key-inherited/data/catalog/index.md b/models/key-inherited/data/catalog/index.md new file mode 100644 index 0000000..9d12194 --- /dev/null +++ b/models/key-inherited/data/catalog/index.md @@ -0,0 +1,14 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [Key-Inherited](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/catalog" count="2" depth="2" digest="sha256:d4db5aa8297df01c1a25e4e20eac87bced7fce7ac7cf97d42a52ff2925315c0b"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Product | Independent | | [Product](Product.md) | +| Subscription | Independent | | [Subscription](Subscription.md) | + +</ignatius-index> diff --git a/models/key-inherited/data/identity/index.md b/models/key-inherited/data/identity/index.md new file mode 100644 index 0000000..0d0d5a3 --- /dev/null +++ b/models/key-inherited/data/identity/index.md @@ -0,0 +1,20 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [Key-Inherited](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/identity" count="8" depth="2" digest="sha256:2e255671ef57c089aaba61f1c547f6f7a882ca7e38b5a3f133e15350d0822762"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Business | Subtype | | [Business](Business.md) | +| Identity | Dependent | | [Identity](Identity.md) | +| ITIN | Subtype | | [ITIN](ITIN.md) | +| License | Subtype | | [License](License.md) | +| Party | Independent | | [Party](Party.md) | +| Passport | Subtype | | [Passport](Passport.md) | +| Person | Subtype | | [Person](Person.md) | +| SSN | Subtype | | [SSN](SSN.md) | + +</ignatius-index> diff --git a/models/key-inherited/data/index.md b/models/key-inherited/data/index.md new file mode 100644 index 0000000..26ba8a8 --- /dev/null +++ b/models/key-inherited/data/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Key-Inherited](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="data" path="data" count="4" depth="1" digest="sha256:0fbb11a99ce261416ec8a1a01841757a85305acaeeac4f1b3dfaba2a105e29cd"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| catalog | folder | | [catalog](catalog/index.md) | +| identity | folder | | [identity](identity/index.md) | +| reference | folder | | [reference](reference/index.md) | +| transactional | folder | | [transactional](transactional/index.md) | + +</ignatius-index> diff --git a/models/key-inherited/data/reference/index.md b/models/key-inherited/data/reference/index.md new file mode 100644 index 0000000..f27ad6f --- /dev/null +++ b/models/key-inherited/data/reference/index.md @@ -0,0 +1,15 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [Key-Inherited](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/reference" count="3" depth="2" digest="sha256:b2606c066f23ef8ee51f30b2bebfb6315581b601c0e6d7dfeba34f3b8ab22922"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| LineItemType | Classifier | | [LineItemType](LineItemType.md) | +| PartyType | Classifier | | [PartyType](PartyType.md) | +| PaymentMethodType | Classifier | | [PaymentMethodType](PaymentMethodType.md) | + +</ignatius-index> diff --git a/models/key-inherited/data/transactional/index.md b/models/key-inherited/data/transactional/index.md new file mode 100644 index 0000000..fd38adc --- /dev/null +++ b/models/key-inherited/data/transactional/index.md @@ -0,0 +1,23 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [Key-Inherited](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/transactional" count="11" depth="2" digest="sha256:752d820db4394117a963de2d4c75b6dbc7e69dd112e2a354626200b864d14b29"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Payment | Dependent | | [Payment](Payment.md) | +| PaymentAllocation | Associative | | [PaymentAllocation](PaymentAllocation.md) | +| PaymentMethod | Dependent | | [PaymentMethod](PaymentMethod.md) | +| SalesInvoice | Dependent | | [SalesInvoice](SalesInvoice.md) | +| SalesOrder | Dependent | | [SalesOrder](SalesOrder.md) | +| SI_Line | Dependent | | [SI_Line](SI_Line.md) | +| SIL_Product | Subtype | | [SIL_Product](SIL_Product.md) | +| SIL_Subscription | Subtype | | [SIL_Subscription](SIL_Subscription.md) | +| SO_Line | Dependent | | [SO_Line](SO_Line.md) | +| SOL_Product | Subtype | | [SOL_Product](SOL_Product.md) | +| SOL_Subscription | Subtype | | [SOL_Subscription](SOL_Subscription.md) | + +</ignatius-index> diff --git a/models/key-inherited/externals/index.md b/models/key-inherited/externals/index.md new file mode 100644 index 0000000..123ca55 --- /dev/null +++ b/models/key-inherited/externals/index.md @@ -0,0 +1,13 @@ +<ignatius-breadcrumb> + +↑ [Key-Inherited](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="externals" path="externals" count="1" depth="1" digest="sha256:433b7916d2de94eb40d95b5803dbcb06b9bd55611227de81064369f45394fa43"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Customer | external | | [Customer](Customer.md) | + +</ignatius-index> diff --git a/models/key-inherited/flows/index.md b/models/key-inherited/flows/index.md new file mode 100644 index 0000000..59ddd74 --- /dev/null +++ b/models/key-inherited/flows/index.md @@ -0,0 +1,14 @@ +<ignatius-breadcrumb> + +↑ [Key-Inherited](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flows" path="flows" count="2" depth="1" digest="sha256:01af55299b392684e06a01daa9f3fd8d8601e54bffc5f603056fa565f3167d24"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| order-to-cash | folder | | [order-to-cash](order-to-cash/index.md) | +| refund | folder | | [refund](refund/index.md) | + +</ignatius-index> diff --git a/models/key-inherited/flows/order-to-cash/Create-Sales-Order/index.md b/models/key-inherited/flows/order-to-cash/Create-Sales-Order/index.md new file mode 100644 index 0000000..613cd53 --- /dev/null +++ b/models/key-inherited/flows/order-to-cash/Create-Sales-Order/index.md @@ -0,0 +1,14 @@ +<ignatius-breadcrumb> + +↑ [Order To Cash](../index.md) · [Flows](../../index.md) · [Key-Inherited](../../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/order-to-cash/Create-Sales-Order" count="2" depth="3" digest="sha256:3c7aa164704b1f0d178c4428792338f1103d548b97e38d6eab2a3c533c49127c"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Record-Order | process | | [Record-Order](Record-Order.md) | +| Validate-Customer | process | | [Validate-Customer](Validate-Customer.md) | + +</ignatius-index> diff --git a/models/key-inherited/flows/order-to-cash/index.md b/models/key-inherited/flows/order-to-cash/index.md new file mode 100644 index 0000000..0b972fb --- /dev/null +++ b/models/key-inherited/flows/order-to-cash/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [Key-Inherited](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/order-to-cash" count="4" depth="2" digest="sha256:a67d4c7940a1309393346e2e86470a7e459858a46e5a79f96abb32df44313196"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Collect-Payment | process | | [Collect-Payment](Collect-Payment.md) | +| Create-Sales-Order | process | | [Create-Sales-Order](Create-Sales-Order.md) | +| Create-Sales-Order | folder | | [Create-Sales-Order](Create-Sales-Order/index.md) | +| Issue-Invoice | process | | [Issue-Invoice](Issue-Invoice.md) | + +</ignatius-index> diff --git a/models/key-inherited/flows/refund/index.md b/models/key-inherited/flows/refund/index.md new file mode 100644 index 0000000..a92f6f6 --- /dev/null +++ b/models/key-inherited/flows/refund/index.md @@ -0,0 +1,13 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [Key-Inherited](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/refund" count="1" depth="2" digest="sha256:08c3ded43a788897699d470c4cd11fc20236724a796fd7a11ee566ef65b15377"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Process-Return | process | | [Process-Return](Process-Return.md) | + +</ignatius-index> diff --git a/models/key-inherited/groups/index.md b/models/key-inherited/groups/index.md new file mode 100644 index 0000000..f559290 --- /dev/null +++ b/models/key-inherited/groups/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Key-Inherited](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="groups" path="groups" count="4" depth="1" digest="sha256:efa64c6943285d71aa3ca9368f0de11d9e627cc4775ec771d56af40c85c88760"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| catalog | group | | [catalog](catalog.md) | +| identity | group | | [identity](identity.md) | +| reference | group | | [reference](reference.md) | +| transactional | group | | [transactional](transactional.md) | + +</ignatius-index> diff --git a/models/key-inherited/index.md b/models/key-inherited/index.md new file mode 100644 index 0000000..2bc9266 --- /dev/null +++ b/models/key-inherited/index.md @@ -0,0 +1,11 @@ +<ignatius-index scope="root" path="." count="5" depth="0" digest="sha256:46ea95765fa7b77e9a46b7ef86e0436a668e1ba484ca1accf3d7a88699770881"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Groups | folder | The subject-area registry that labels and colors entities. 4 groups. | [Groups](groups/index.md) | +| Data | folder | The entity model. 24 entities across 4 groups. | [Data](data/index.md) | +| Flows | folder | Data flow diagrams. 2 flows, 6 processes. | [Flows](flows/index.md) | +| Externals | folder | Actors outside the system boundary that DFDs exchange data with. 1 external. | [Externals](externals/index.md) | +| Stores | folder | Non-database stores DFDs read and write. 1 store. | [Stores](stores/index.md) | + +</ignatius-index> diff --git a/models/key-inherited/stores/index.md b/models/key-inherited/stores/index.md new file mode 100644 index 0000000..7400b93 --- /dev/null +++ b/models/key-inherited/stores/index.md @@ -0,0 +1,13 @@ +<ignatius-breadcrumb> + +↑ [Key-Inherited](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="stores" path="stores" count="1" depth="1" digest="sha256:fa8a7e87feab44418b16207801c4294444be7d55c1afaf1c3e1ec668777f28d2"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| gateway-log | store | | [gateway-log](gateway-log.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/AGENTS.md b/models/llm-memory-db-mssql/AGENTS.md new file mode 100644 index 0000000..0fe0d98 --- /dev/null +++ b/models/llm-memory-db-mssql/AGENTS.md @@ -0,0 +1,26 @@ +<ignatius-guide> + +# LLM Memory DB (MSSQL) + +Reverse-engineered IDEF1X model of the llm-memory-db-mssql schema — an agent long-term memory store. Agents record memories, notes, milestones, tasks, and artifacts within projects; everything is taggable and every relevance/tracking change is journaled as an immutable state transition. + +## Walking this model + +Start at [index.md](index.md). Every router lists its folder's +children by name, kind, description, and a link. A row whose Kind is +`folder` leads to another router; any other Kind is a leaf with real +content: an entity, a flow process, an external, or a store. + +## Conventions + +- Key style: mixed. Some entities carry a parent-inherited primary key and others carry a surrogate primary key with foreign keys held outside it, so no single convention holds here. Check each entity's `pk:` before assuming one. +- `[[Entity]]` inside a body is a cross-reference to another entity file. + It is not a router link, and it resolves only in the app's viewer. + +## Currency + +This guide and the routers were generated together. Root router digest: +`sha256:daaeac611ff09e37b12a733a65f899e5932a1f234a8545033e122d3044aacf14`. Run `ignatius validate --index` to check whether the +model has drifted since. + +</ignatius-guide> diff --git a/models/llm-memory-db-mssql/CLAUDE.md b/models/llm-memory-db-mssql/CLAUDE.md new file mode 100644 index 0000000..41543bc --- /dev/null +++ b/models/llm-memory-db-mssql/CLAUDE.md @@ -0,0 +1,8 @@ +<ignatius-claude-shim> + +@AGENTS.md + +This folder is a data model, generated by ignatius. Read AGENTS.md +before editing anything under it. + +</ignatius-claude-shim> diff --git a/models/llm-memory-db-mssql/SKILL.md b/models/llm-memory-db-mssql/SKILL.md new file mode 100644 index 0000000..b25d54e --- /dev/null +++ b/models/llm-memory-db-mssql/SKILL.md @@ -0,0 +1,15 @@ +--- +name: llm-memory-db-mssql +description: "Data model for LLM Memory DB (MSSQL): 38 entities across 8 groups + (artifact, audit, identity, knowledge, memory, planning, reference, tagging). + Use when asked about LLM Memory DB (MSSQL)'s schema, entities, or data flows." +--- + +<ignatius-skill> + +# Model skill + +See [AGENTS.md](AGENTS.md) for how to walk this model, starting at +[index.md](index.md). + +</ignatius-skill> diff --git a/models/llm-memory-db-mssql/data/artifact/Artifact.md b/models/llm-memory-db-mssql/data/artifact/Artifact.md index 212904b..7a01e27 100644 --- a/models/llm-memory-db-mssql/data/artifact/Artifact.md +++ b/models/llm-memory-db-mssql/data/artifact/Artifact.md @@ -1,6 +1,7 @@ --- entity: Artifact group: artifact +description: A file or document produced by an agent during work on a project, versioned through RelevanceStatus. pk: - artifact_id columns: diff --git a/models/llm-memory-db-mssql/data/artifact/Milestone_Artifact.md b/models/llm-memory-db-mssql/data/artifact/Milestone_Artifact.md index 2f80ad5..28f225c 100644 --- a/models/llm-memory-db-mssql/data/artifact/Milestone_Artifact.md +++ b/models/llm-memory-db-mssql/data/artifact/Milestone_Artifact.md @@ -1,6 +1,7 @@ --- entity: Milestone_Artifact group: artifact +description: Junction linking a milestone to the artifacts produced under it; cascades on delete. pk: - milestone_id - artifact_id diff --git a/models/llm-memory-db-mssql/data/artifact/Task_Artifact.md b/models/llm-memory-db-mssql/data/artifact/Task_Artifact.md index 60668bb..87be0c4 100644 --- a/models/llm-memory-db-mssql/data/artifact/Task_Artifact.md +++ b/models/llm-memory-db-mssql/data/artifact/Task_Artifact.md @@ -1,6 +1,7 @@ --- entity: Task_Artifact group: artifact +description: Junction linking a composite-keyed task to the artifacts it produced, finer-grained than Milestone_Artifact. pk: - milestone_id - task_no diff --git a/models/llm-memory-db-mssql/data/artifact/index.md b/models/llm-memory-db-mssql/data/artifact/index.md new file mode 100644 index 0000000..d274755 --- /dev/null +++ b/models/llm-memory-db-mssql/data/artifact/index.md @@ -0,0 +1,15 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/artifact" count="3" depth="2" digest="sha256:99edafb57a7c3053111cc128feb0cc538772d48f6ff9d450d200b1b4306f51b3"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Artifact | Independent | A file or document produced by an agent during work on a project, versioned through RelevanceStatus. | [Artifact](Artifact.md) | +| Milestone_Artifact | Associative | Junction linking a milestone to the artifacts produced under it; cascades on delete. | [Milestone_Artifact](Milestone_Artifact.md) | +| Task_Artifact | Associative | Junction linking a composite-keyed task to the artifacts it produced, finer-grained than Milestone_Artifact. | [Task_Artifact](Task_Artifact.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/audit/Artifact_StateTransition.md b/models/llm-memory-db-mssql/data/audit/Artifact_StateTransition.md index cc20f45..6182d22 100644 --- a/models/llm-memory-db-mssql/data/audit/Artifact_StateTransition.md +++ b/models/llm-memory-db-mssql/data/audit/Artifact_StateTransition.md @@ -1,6 +1,7 @@ --- entity: Artifact_StateTransition group: audit +description: Subtype pinning a StateTransition journal row to the artifact whose relevance status changed. pk: - transition_id columns: diff --git a/models/llm-memory-db-mssql/data/audit/Memory_StateTransition.md b/models/llm-memory-db-mssql/data/audit/Memory_StateTransition.md index fdb6cc0..c9a05d4 100644 --- a/models/llm-memory-db-mssql/data/audit/Memory_StateTransition.md +++ b/models/llm-memory-db-mssql/data/audit/Memory_StateTransition.md @@ -1,6 +1,7 @@ --- entity: Memory_StateTransition group: audit +description: Subtype pinning a StateTransition journal row to the memory whose relevance status changed. pk: - transition_id columns: diff --git a/models/llm-memory-db-mssql/data/audit/Milestone_StateTransition.md b/models/llm-memory-db-mssql/data/audit/Milestone_StateTransition.md index 25b98e1..aa00e07 100644 --- a/models/llm-memory-db-mssql/data/audit/Milestone_StateTransition.md +++ b/models/llm-memory-db-mssql/data/audit/Milestone_StateTransition.md @@ -1,6 +1,7 @@ --- entity: Milestone_StateTransition group: audit +description: Subtype pinning a StateTransition journal row to a milestone's tracking or relevance status change. pk: - transition_id columns: diff --git a/models/llm-memory-db-mssql/data/audit/Note_StateTransition.md b/models/llm-memory-db-mssql/data/audit/Note_StateTransition.md index c265605..83e53f2 100644 --- a/models/llm-memory-db-mssql/data/audit/Note_StateTransition.md +++ b/models/llm-memory-db-mssql/data/audit/Note_StateTransition.md @@ -1,6 +1,7 @@ --- entity: Note_StateTransition group: audit +description: Subtype pinning a StateTransition journal row to the note whose relevance status changed. pk: - transition_id columns: diff --git a/models/llm-memory-db-mssql/data/audit/StateTransition.md b/models/llm-memory-db-mssql/data/audit/StateTransition.md index d01fc1a..1c8cd50 100644 --- a/models/llm-memory-db-mssql/data/audit/StateTransition.md +++ b/models/llm-memory-db-mssql/data/audit/StateTransition.md @@ -1,6 +1,7 @@ --- entity: StateTransition group: audit +description: The immutable, write-once audit journal of every relevance and tracking status change in the system. pk: - transition_id columns: diff --git a/models/llm-memory-db-mssql/data/audit/Task_StateTransition.md b/models/llm-memory-db-mssql/data/audit/Task_StateTransition.md index 8704a94..e5cce33 100644 --- a/models/llm-memory-db-mssql/data/audit/Task_StateTransition.md +++ b/models/llm-memory-db-mssql/data/audit/Task_StateTransition.md @@ -1,6 +1,7 @@ --- entity: Task_StateTransition group: audit +description: Subtype pinning a StateTransition journal row to the composite-keyed task whose tracking status changed. pk: - transition_id columns: diff --git a/models/llm-memory-db-mssql/data/audit/index.md b/models/llm-memory-db-mssql/data/audit/index.md new file mode 100644 index 0000000..61bc87b --- /dev/null +++ b/models/llm-memory-db-mssql/data/audit/index.md @@ -0,0 +1,18 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/audit" count="6" depth="2" digest="sha256:68d2456a4e0e0cda93bb7eb72b152dfec08d52de32af749928d03672601e65f5"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Artifact_StateTransition | Subtype | Subtype pinning a StateTransition journal row to the artifact whose relevance status changed. | [Artifact_StateTransition](Artifact_StateTransition.md) | +| Memory_StateTransition | Subtype | Subtype pinning a StateTransition journal row to the memory whose relevance status changed. | [Memory_StateTransition](Memory_StateTransition.md) | +| Milestone_StateTransition | Subtype | Subtype pinning a StateTransition journal row to a milestone's tracking or relevance status change. | [Milestone_StateTransition](Milestone_StateTransition.md) | +| Note_StateTransition | Subtype | Subtype pinning a StateTransition journal row to the note whose relevance status changed. | [Note_StateTransition](Note_StateTransition.md) | +| StateTransition | Independent | The immutable, write-once audit journal of every relevance and tracking status change in the system. | [StateTransition](StateTransition.md) | +| Task_StateTransition | Subtype | Subtype pinning a StateTransition journal row to the composite-keyed task whose tracking status changed. | [Task_StateTransition](Task_StateTransition.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/identity/Agent.md b/models/llm-memory-db-mssql/data/identity/Agent.md index 93a249d..3f33963 100644 --- a/models/llm-memory-db-mssql/data/identity/Agent.md +++ b/models/llm-memory-db-mssql/data/identity/Agent.md @@ -1,6 +1,7 @@ --- entity: Agent group: identity +description: An AI coding assistant (e.g. Claude, Cursor) whose identity anchors every memory, note, task, and artifact it creates. pk: - agent_id columns: diff --git a/models/llm-memory-db-mssql/data/identity/Project.md b/models/llm-memory-db-mssql/data/identity/Project.md index 2e4b6c2..3c81b49 100644 --- a/models/llm-memory-db-mssql/data/identity/Project.md +++ b/models/llm-memory-db-mssql/data/identity/Project.md @@ -1,6 +1,7 @@ --- entity: Project group: identity +description: A code repository workspace that scopes an agent's memories, notes, milestones, tasks, and artifacts. pk: - project_id columns: diff --git a/models/llm-memory-db-mssql/data/identity/index.md b/models/llm-memory-db-mssql/data/identity/index.md new file mode 100644 index 0000000..8e41ec9 --- /dev/null +++ b/models/llm-memory-db-mssql/data/identity/index.md @@ -0,0 +1,14 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/identity" count="2" depth="2" digest="sha256:019bf4e722800842855365bf077399ea4ad3ca971473e69d55be0e3e1d1e7e69"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Agent | Independent | An AI coding assistant (e.g. Claude, Cursor) whose identity anchors every memory, note, task, and artifact it creates. | [Agent](Agent.md) | +| Project | Independent | A code repository workspace that scopes an agent's memories, notes, milestones, tasks, and artifacts. | [Project](Project.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/index.md b/models/llm-memory-db-mssql/data/index.md new file mode 100644 index 0000000..c007348 --- /dev/null +++ b/models/llm-memory-db-mssql/data/index.md @@ -0,0 +1,20 @@ +<ignatius-breadcrumb> + +↑ [LLM Memory DB (MSSQL)](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="data" path="data" count="8" depth="1" digest="sha256:d4fc1988654cac46855f1db006f20e5341591334edd53fb6d72b5ec3f60d0bac"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| artifact | folder | Files and documents produced during work, plus their attachment to milestones and tasks. | [artifact](artifact/index.md) | +| audit | folder | The immutable, write-once journal of every relevance and tracking status change. | [audit](audit/index.md) | +| identity | folder | The agents that own records and the project workspaces that scope work, referenced as provenance nearly everywhere. | [identity](identity/index.md) | +| knowledge | folder | Free-form notes attached to a single project, milestone, or task. | [knowledge](knowledge/index.md) | +| memory | folder | The long-term memory entity, its self-referential relation graph, and its project attachment. | [memory](memory/index.md) | +| planning | folder | Milestones, the tasks that fulfill them, their dependency graph, and project attachment. | [planning](planning/index.md) | +| reference | folder | Status and type vocabularies plus the tables encoding which state transitions are legal. | [reference](reference/index.md) | +| tagging | folder | The cross-cutting tag vocabulary and the five junctions that attach tags to other entities. | [tagging](tagging/index.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/knowledge/Milestone_Note.md b/models/llm-memory-db-mssql/data/knowledge/Milestone_Note.md index 3a6ba09..b4ec373 100644 --- a/models/llm-memory-db-mssql/data/knowledge/Milestone_Note.md +++ b/models/llm-memory-db-mssql/data/knowledge/Milestone_Note.md @@ -1,6 +1,7 @@ --- entity: Milestone_Note group: knowledge +description: Subtype record attaching a note to the milestone it annotates. pk: - note_id columns: diff --git a/models/llm-memory-db-mssql/data/knowledge/Note.md b/models/llm-memory-db-mssql/data/knowledge/Note.md index aff15c3..2aa653a 100644 --- a/models/llm-memory-db-mssql/data/knowledge/Note.md +++ b/models/llm-memory-db-mssql/data/knowledge/Note.md @@ -1,6 +1,7 @@ --- entity: Note group: knowledge +description: Free-form text recorded by an agent, attached to exactly one project, milestone, or task. pk: - note_id columns: diff --git a/models/llm-memory-db-mssql/data/knowledge/Project_Note.md b/models/llm-memory-db-mssql/data/knowledge/Project_Note.md index f7ca3c0..6484033 100644 --- a/models/llm-memory-db-mssql/data/knowledge/Project_Note.md +++ b/models/llm-memory-db-mssql/data/knowledge/Project_Note.md @@ -1,6 +1,7 @@ --- entity: Project_Note group: knowledge +description: Subtype record attaching a note to the project it annotates. pk: - note_id columns: diff --git a/models/llm-memory-db-mssql/data/knowledge/Task_Note.md b/models/llm-memory-db-mssql/data/knowledge/Task_Note.md index 67fe3c9..ebf0758 100644 --- a/models/llm-memory-db-mssql/data/knowledge/Task_Note.md +++ b/models/llm-memory-db-mssql/data/knowledge/Task_Note.md @@ -1,6 +1,7 @@ --- entity: Task_Note group: knowledge +description: Subtype record attaching a note to the composite-keyed task it annotates. pk: - note_id columns: diff --git a/models/llm-memory-db-mssql/data/knowledge/index.md b/models/llm-memory-db-mssql/data/knowledge/index.md new file mode 100644 index 0000000..2e9bce7 --- /dev/null +++ b/models/llm-memory-db-mssql/data/knowledge/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/knowledge" count="4" depth="2" digest="sha256:7f499b4d7a599c50c618f50d9b0bc46afa8a6fe3b8bd3d8c0a987fa7b85fe5eb"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Milestone_Note | Subtype | Subtype record attaching a note to the milestone it annotates. | [Milestone_Note](Milestone_Note.md) | +| Note | Independent | Free-form text recorded by an agent, attached to exactly one project, milestone, or task. | [Note](Note.md) | +| Project_Note | Subtype | Subtype record attaching a note to the project it annotates. | [Project_Note](Project_Note.md) | +| Task_Note | Subtype | Subtype record attaching a note to the composite-keyed task it annotates. | [Task_Note](Task_Note.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/memory/Memory.md b/models/llm-memory-db-mssql/data/memory/Memory.md index 580ad91..785d6a7 100644 --- a/models/llm-memory-db-mssql/data/memory/Memory.md +++ b/models/llm-memory-db-mssql/data/memory/Memory.md @@ -1,6 +1,7 @@ --- entity: Memory group: memory +description: A durable fact, decision, convention, or gotcha an agent has learned, with provenance flags for how it was acquired. pk: - memory_id columns: diff --git a/models/llm-memory-db-mssql/data/memory/Project_Memory.md b/models/llm-memory-db-mssql/data/memory/Project_Memory.md index 35aaaba..4b7da3a 100644 --- a/models/llm-memory-db-mssql/data/memory/Project_Memory.md +++ b/models/llm-memory-db-mssql/data/memory/Project_Memory.md @@ -1,6 +1,7 @@ --- entity: Project_Memory group: memory +description: Junction attaching a memory to a project, recording which facts and decisions are relevant to it. pk: - project_id - memory_id diff --git a/models/llm-memory-db-mssql/data/memory/Related_Memory.md b/models/llm-memory-db-mssql/data/memory/Related_Memory.md index 8aa6cf6..387a728 100644 --- a/models/llm-memory-db-mssql/data/memory/Related_Memory.md +++ b/models/llm-memory-db-mssql/data/memory/Related_Memory.md @@ -1,6 +1,7 @@ --- entity: Related_Memory group: memory +description: Directed edge asserting one memory supersedes, supports, or contradicts another. pk: - memory_id - related_memory_id diff --git a/models/llm-memory-db-mssql/data/memory/index.md b/models/llm-memory-db-mssql/data/memory/index.md new file mode 100644 index 0000000..1812432 --- /dev/null +++ b/models/llm-memory-db-mssql/data/memory/index.md @@ -0,0 +1,15 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/memory" count="3" depth="2" digest="sha256:42356c75c312526b61229de12b83cc6c50c5c13e26e257fc61c843d6b85f38c7"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Memory | Independent | A durable fact, decision, convention, or gotcha an agent has learned, with provenance flags for how it was acquired. | [Memory](Memory.md) | +| Project_Memory | Associative | Junction attaching a memory to a project, recording which facts and decisions are relevant to it. | [Project_Memory](Project_Memory.md) | +| Related_Memory | Dependent | Directed edge asserting one memory supersedes, supports, or contradicts another. | [Related_Memory](Related_Memory.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/planning/Milestone.md b/models/llm-memory-db-mssql/data/planning/Milestone.md index 5494a23..4ee02c0 100644 --- a/models/llm-memory-db-mssql/data/planning/Milestone.md +++ b/models/llm-memory-db-mssql/data/planning/Milestone.md @@ -1,6 +1,7 @@ --- entity: Milestone group: planning +description: A tracked deliverable scoped to a project, with independent tracking and relevance status axes. pk: - milestone_id columns: diff --git a/models/llm-memory-db-mssql/data/planning/Project_Milestone.md b/models/llm-memory-db-mssql/data/planning/Project_Milestone.md index 6bd3e45..a2e9c8d 100644 --- a/models/llm-memory-db-mssql/data/planning/Project_Milestone.md +++ b/models/llm-memory-db-mssql/data/planning/Project_Milestone.md @@ -1,6 +1,7 @@ --- entity: Project_Milestone group: planning +description: Junction attaching a milestone to one or more projects. pk: - project_id - milestone_id diff --git a/models/llm-memory-db-mssql/data/planning/Task.md b/models/llm-memory-db-mssql/data/planning/Task.md index f0a1e92..782ebe4 100644 --- a/models/llm-memory-db-mssql/data/planning/Task.md +++ b/models/llm-memory-db-mssql/data/planning/Task.md @@ -1,6 +1,7 @@ --- entity: Task group: planning +description: An atomic unit of work advancing a milestone, keyed by a milestone-scoped task number. pk: - milestone_id - task_no diff --git a/models/llm-memory-db-mssql/data/planning/Task_Dependency.md b/models/llm-memory-db-mssql/data/planning/Task_Dependency.md index e0b061c..9ae863b 100644 --- a/models/llm-memory-db-mssql/data/planning/Task_Dependency.md +++ b/models/llm-memory-db-mssql/data/planning/Task_Dependency.md @@ -1,6 +1,7 @@ --- entity: Task_Dependency group: planning +description: Directed edge recording that one task depends on another, both endpoints composite-keyed task references. pk: - milestone_id - task_no diff --git a/models/llm-memory-db-mssql/data/planning/index.md b/models/llm-memory-db-mssql/data/planning/index.md new file mode 100644 index 0000000..87d30b9 --- /dev/null +++ b/models/llm-memory-db-mssql/data/planning/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/planning" count="4" depth="2" digest="sha256:caef7de10e95233a9be7b18968f39991cbe35dc5497dab7fa6c96a73c8056f19"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Milestone | Independent | A tracked deliverable scoped to a project, with independent tracking and relevance status axes. | [Milestone](Milestone.md) | +| Project_Milestone | Associative | Junction attaching a milestone to one or more projects. | [Project_Milestone](Project_Milestone.md) | +| Task | Dependent | An atomic unit of work advancing a milestone, keyed by a milestone-scoped task number. | [Task](Task.md) | +| Task_Dependency | Dependent | Directed edge recording that one task depends on another, both endpoints composite-keyed task references. | [Task_Dependency](Task_Dependency.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/reference/DependencyVerb.md b/models/llm-memory-db-mssql/data/reference/DependencyVerb.md index 07b24aa..0e70d56 100644 --- a/models/llm-memory-db-mssql/data/reference/DependencyVerb.md +++ b/models/llm-memory-db-mssql/data/reference/DependencyVerb.md @@ -1,6 +1,7 @@ --- entity: DependencyVerb group: reference +description: Controlled vocabulary of edge labels (blocks, requires, follows) for the task dependency graph. pk: - dependency_verb columns: diff --git a/models/llm-memory-db-mssql/data/reference/MemoryCategory.md b/models/llm-memory-db-mssql/data/reference/MemoryCategory.md index fd7bf9d..67ac193 100644 --- a/models/llm-memory-db-mssql/data/reference/MemoryCategory.md +++ b/models/llm-memory-db-mssql/data/reference/MemoryCategory.md @@ -1,6 +1,7 @@ --- entity: MemoryCategory group: reference +description: Controlled vocabulary for the epistemic kind of a memory entry (fact, decision, convention). pk: - category columns: diff --git a/models/llm-memory-db-mssql/data/reference/MemoryDomain.md b/models/llm-memory-db-mssql/data/reference/MemoryDomain.md index 80e4874..bf89d2b 100644 --- a/models/llm-memory-db-mssql/data/reference/MemoryDomain.md +++ b/models/llm-memory-db-mssql/data/reference/MemoryDomain.md @@ -1,6 +1,7 @@ --- entity: MemoryDomain group: reference +description: Controlled vocabulary of broad subject areas (coding, architecture, preferences) classifying memories. pk: - domain columns: diff --git a/models/llm-memory-db-mssql/data/reference/MemoryRelationVerb.md b/models/llm-memory-db-mssql/data/reference/MemoryRelationVerb.md index 07e9db9..eb12da5 100644 --- a/models/llm-memory-db-mssql/data/reference/MemoryRelationVerb.md +++ b/models/llm-memory-db-mssql/data/reference/MemoryRelationVerb.md @@ -1,6 +1,7 @@ --- entity: MemoryRelationVerb group: reference +description: Controlled vocabulary of directed edge labels for the memory relation graph, storing both verb directions per row. pk: - verb_forward columns: diff --git a/models/llm-memory-db-mssql/data/reference/NoteType.md b/models/llm-memory-db-mssql/data/reference/NoteType.md index e42c49b..62a8f9e 100644 --- a/models/llm-memory-db-mssql/data/reference/NoteType.md +++ b/models/llm-memory-db-mssql/data/reference/NoteType.md @@ -1,6 +1,7 @@ --- entity: NoteType group: reference +description: Controlled vocabulary classifying a note's structural role — project, milestone, or task. pk: - note_type columns: diff --git a/models/llm-memory-db-mssql/data/reference/RelevanceStatus.md b/models/llm-memory-db-mssql/data/reference/RelevanceStatus.md index c5de177..4c2b1e9 100644 --- a/models/llm-memory-db-mssql/data/reference/RelevanceStatus.md +++ b/models/llm-memory-db-mssql/data/reference/RelevanceStatus.md @@ -1,6 +1,7 @@ --- entity: RelevanceStatus group: reference +description: Controlled vocabulary for lifecycle relevance states (active, archived, deleted) of memories, artifacts, and notes. pk: - relevance_status columns: diff --git a/models/llm-memory-db-mssql/data/reference/RelevanceStatus_Allowed.md b/models/llm-memory-db-mssql/data/reference/RelevanceStatus_Allowed.md index 5004a87..2125fec 100644 --- a/models/llm-memory-db-mssql/data/reference/RelevanceStatus_Allowed.md +++ b/models/llm-memory-db-mssql/data/reference/RelevanceStatus_Allowed.md @@ -1,6 +1,7 @@ --- entity: RelevanceStatus_Allowed group: reference +description: Legal from/to edges of the RelevanceStatus transition graph, checked before journaling a relevance change. pk: - from_status - to_status diff --git a/models/llm-memory-db-mssql/data/reference/StateTransitionType.md b/models/llm-memory-db-mssql/data/reference/StateTransitionType.md index c7e6df2..64de333 100644 --- a/models/llm-memory-db-mssql/data/reference/StateTransitionType.md +++ b/models/llm-memory-db-mssql/data/reference/StateTransitionType.md @@ -1,6 +1,7 @@ --- entity: StateTransitionType group: reference +description: Controlled vocabulary classifying which entity and state dimension a journal entry belongs to. pk: - state_transition_type columns: diff --git a/models/llm-memory-db-mssql/data/reference/TrackingStatus.md b/models/llm-memory-db-mssql/data/reference/TrackingStatus.md index cc84daa..a0e9067 100644 --- a/models/llm-memory-db-mssql/data/reference/TrackingStatus.md +++ b/models/llm-memory-db-mssql/data/reference/TrackingStatus.md @@ -1,6 +1,7 @@ --- entity: TrackingStatus group: reference +description: Controlled vocabulary for progress states (pending, in_progress, done) applied to tasks and milestones. pk: - tracking_status columns: diff --git a/models/llm-memory-db-mssql/data/reference/TrackingStatus_Allowed.md b/models/llm-memory-db-mssql/data/reference/TrackingStatus_Allowed.md index 2d5bc88..bbae380 100644 --- a/models/llm-memory-db-mssql/data/reference/TrackingStatus_Allowed.md +++ b/models/llm-memory-db-mssql/data/reference/TrackingStatus_Allowed.md @@ -1,6 +1,7 @@ --- entity: TrackingStatus_Allowed group: reference +description: Legal from/to edges of the TrackingStatus transition graph, checked before journaling a tracking change. pk: - from_status - to_status diff --git a/models/llm-memory-db-mssql/data/reference/index.md b/models/llm-memory-db-mssql/data/reference/index.md new file mode 100644 index 0000000..0fafc33 --- /dev/null +++ b/models/llm-memory-db-mssql/data/reference/index.md @@ -0,0 +1,22 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/reference" count="10" depth="2" digest="sha256:03ea72a18bfdd8e1326aa2ec5430df517cd327660a6ff3548861da69b9bd8b22"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| DependencyVerb | Classifier | Controlled vocabulary of edge labels (blocks, requires, follows) for the task dependency graph. | [DependencyVerb](DependencyVerb.md) | +| MemoryCategory | Classifier | Controlled vocabulary for the epistemic kind of a memory entry (fact, decision, convention). | [MemoryCategory](MemoryCategory.md) | +| MemoryDomain | Classifier | Controlled vocabulary of broad subject areas (coding, architecture, preferences) classifying memories. | [MemoryDomain](MemoryDomain.md) | +| MemoryRelationVerb | Classifier | Controlled vocabulary of directed edge labels for the memory relation graph, storing both verb directions per row. | [MemoryRelationVerb](MemoryRelationVerb.md) | +| NoteType | Classifier | Controlled vocabulary classifying a note's structural role — project, milestone, or task. | [NoteType](NoteType.md) | +| RelevanceStatus | Classifier | Controlled vocabulary for lifecycle relevance states (active, archived, deleted) of memories, artifacts, and notes. | [RelevanceStatus](RelevanceStatus.md) | +| RelevanceStatus_Allowed | Dependent | Legal from/to edges of the RelevanceStatus transition graph, checked before journaling a relevance change. | [RelevanceStatus_Allowed](RelevanceStatus_Allowed.md) | +| StateTransitionType | Classifier | Controlled vocabulary classifying which entity and state dimension a journal entry belongs to. | [StateTransitionType](StateTransitionType.md) | +| TrackingStatus | Classifier | Controlled vocabulary for progress states (pending, in_progress, done) applied to tasks and milestones. | [TrackingStatus](TrackingStatus.md) | +| TrackingStatus_Allowed | Dependent | Legal from/to edges of the TrackingStatus transition graph, checked before journaling a tracking change. | [TrackingStatus_Allowed](TrackingStatus_Allowed.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/data/tagging/Artifact_Tag.md b/models/llm-memory-db-mssql/data/tagging/Artifact_Tag.md index da622e4..42ef1f6 100644 --- a/models/llm-memory-db-mssql/data/tagging/Artifact_Tag.md +++ b/models/llm-memory-db-mssql/data/tagging/Artifact_Tag.md @@ -1,6 +1,7 @@ --- entity: Artifact_Tag group: tagging +description: Junction attaching a tag to an artifact; cascades on tag delete. pk: - tag_id - artifact_id diff --git a/models/llm-memory-db-mssql/data/tagging/Memory_Tag.md b/models/llm-memory-db-mssql/data/tagging/Memory_Tag.md index 20c376f..c76d4c7 100644 --- a/models/llm-memory-db-mssql/data/tagging/Memory_Tag.md +++ b/models/llm-memory-db-mssql/data/tagging/Memory_Tag.md @@ -1,6 +1,7 @@ --- entity: Memory_Tag group: tagging +description: Junction attaching a tag to a memory; cascades on tag delete. pk: - tag_id - memory_id diff --git a/models/llm-memory-db-mssql/data/tagging/Milestone_Tag.md b/models/llm-memory-db-mssql/data/tagging/Milestone_Tag.md index 2636abe..b59d5f6 100644 --- a/models/llm-memory-db-mssql/data/tagging/Milestone_Tag.md +++ b/models/llm-memory-db-mssql/data/tagging/Milestone_Tag.md @@ -1,6 +1,7 @@ --- entity: Milestone_Tag group: tagging +description: Junction attaching a tag to a milestone; cascades on tag delete. pk: - tag_id - milestone_id diff --git a/models/llm-memory-db-mssql/data/tagging/Project_Tag.md b/models/llm-memory-db-mssql/data/tagging/Project_Tag.md index 94e0d1b..7eed8dd 100644 --- a/models/llm-memory-db-mssql/data/tagging/Project_Tag.md +++ b/models/llm-memory-db-mssql/data/tagging/Project_Tag.md @@ -1,6 +1,7 @@ --- entity: Project_Tag group: tagging +description: Junction attaching a tag to a project; cascades on tag delete. pk: - tag_id - project_id diff --git a/models/llm-memory-db-mssql/data/tagging/Tag.md b/models/llm-memory-db-mssql/data/tagging/Tag.md index c1eb0e8..dba5a60 100644 --- a/models/llm-memory-db-mssql/data/tagging/Tag.md +++ b/models/llm-memory-db-mssql/data/tagging/Tag.md @@ -1,6 +1,7 @@ --- entity: Tag group: tagging +description: A reusable, uniquely-named label an agent applies to classify memories, artifacts, milestones, tasks, and projects. pk: - tag_id columns: diff --git a/models/llm-memory-db-mssql/data/tagging/Task_Tag.md b/models/llm-memory-db-mssql/data/tagging/Task_Tag.md index 3899796..5c82fd8 100644 --- a/models/llm-memory-db-mssql/data/tagging/Task_Tag.md +++ b/models/llm-memory-db-mssql/data/tagging/Task_Tag.md @@ -1,6 +1,7 @@ --- entity: Task_Tag group: tagging +description: Junction attaching a tag to a composite-keyed task, the only tagging junction with a two-column FK. pk: - tag_id - milestone_id diff --git a/models/llm-memory-db-mssql/data/tagging/index.md b/models/llm-memory-db-mssql/data/tagging/index.md new file mode 100644 index 0000000..75247fd --- /dev/null +++ b/models/llm-memory-db-mssql/data/tagging/index.md @@ -0,0 +1,18 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/tagging" count="6" depth="2" digest="sha256:5a5caf2d2bdf43dbee5a1f5c6072682c1eb2c36c3e934400902480ba91069ed9"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Artifact_Tag | Associative | Junction attaching a tag to an artifact; cascades on tag delete. | [Artifact_Tag](Artifact_Tag.md) | +| Memory_Tag | Associative | Junction attaching a tag to a memory; cascades on tag delete. | [Memory_Tag](Memory_Tag.md) | +| Milestone_Tag | Associative | Junction attaching a tag to a milestone; cascades on tag delete. | [Milestone_Tag](Milestone_Tag.md) | +| Project_Tag | Associative | Junction attaching a tag to a project; cascades on tag delete. | [Project_Tag](Project_Tag.md) | +| Tag | Independent | A reusable, uniquely-named label an agent applies to classify memories, artifacts, milestones, tasks, and projects. | [Tag](Tag.md) | +| Task_Tag | Associative | Junction attaching a tag to a composite-keyed task, the only tagging junction with a two-column FK. | [Task_Tag](Task_Tag.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/externals/LLM-Agent.md b/models/llm-memory-db-mssql/externals/LLM-Agent.md index 4061744..4b97818 100644 --- a/models/llm-memory-db-mssql/externals/LLM-Agent.md +++ b/models/llm-memory-db-mssql/externals/LLM-Agent.md @@ -1,5 +1,6 @@ --- external: LLM-Agent +description: The AI coding agent (Claude, Cursor, etc.) that calls MCP operations to read and write its own long-term memory title: LLM Agent --- diff --git a/models/llm-memory-db-mssql/externals/index.md b/models/llm-memory-db-mssql/externals/index.md new file mode 100644 index 0000000..893937b --- /dev/null +++ b/models/llm-memory-db-mssql/externals/index.md @@ -0,0 +1,13 @@ +<ignatius-breadcrumb> + +↑ [LLM Memory DB (MSSQL)](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="externals" path="externals" count="1" depth="1" digest="sha256:562566e673b660efa684255dec2fb697f922587f12ce09364b9951cf959062e1"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| LLM-Agent | external | The AI coding agent (Claude, Cursor, etc.) that calls MCP operations to read and write its own long-term memory | [LLM-Agent](LLM-Agent.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Agent.md b/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Agent.md index f474945..ca4f1cb 100644 --- a/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Agent.md +++ b/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Agent.md @@ -1,6 +1,7 @@ --- process: Create Agent number: 1 +description: Registers a new LLM agent identity and returns the assigned agent_id inputs: - from: ext:LLM-Agent data: agent name and description diff --git a/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Project.md b/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Project.md index f5a2e8f..88affea 100644 --- a/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Project.md +++ b/models/llm-memory-db-mssql/flows/agent-project-setup/Create-Project.md @@ -1,6 +1,7 @@ --- process: Create Project number: 2 +description: Registers a codebase workspace under a validated owning agent inputs: - from: ext:LLM-Agent data: project name, filepath, git_repo, main_branch, git_url, and owning agent_id diff --git a/models/llm-memory-db-mssql/flows/agent-project-setup/Delete-Agent.md b/models/llm-memory-db-mssql/flows/agent-project-setup/Delete-Agent.md index 46765f4..a612b09 100644 --- a/models/llm-memory-db-mssql/flows/agent-project-setup/Delete-Agent.md +++ b/models/llm-memory-db-mssql/flows/agent-project-setup/Delete-Agent.md @@ -1,6 +1,7 @@ --- process: Delete Agent number: 4 +description: Retires an agent, reassigning its dependent rows to a sentinel agent inputs: - from: ext:LLM-Agent data: agent_id of the agent to be removed diff --git a/models/llm-memory-db-mssql/flows/agent-project-setup/Update-Project.md b/models/llm-memory-db-mssql/flows/agent-project-setup/Update-Project.md index 54d898a..8d11546 100644 --- a/models/llm-memory-db-mssql/flows/agent-project-setup/Update-Project.md +++ b/models/llm-memory-db-mssql/flows/agent-project-setup/Update-Project.md @@ -1,6 +1,7 @@ --- process: Update Project number: 3 +description: Amends a project's editable metadata after confirming it exists inputs: - from: ext:LLM-Agent data: project_id and any combination of updated name, filepath, git_repo, main_branch, git_url diff --git a/models/llm-memory-db-mssql/flows/agent-project-setup/index.md b/models/llm-memory-db-mssql/flows/agent-project-setup/index.md new file mode 100644 index 0000000..82814a9 --- /dev/null +++ b/models/llm-memory-db-mssql/flows/agent-project-setup/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/agent-project-setup" count="4" depth="2" digest="sha256:7947ff79b8b58ad0397bef67dd98f8f5fb04856d9023004a1d5f40fef315708d"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Create-Agent | process | Registers a new LLM agent identity and returns the assigned agent_id | [Create-Agent](Create-Agent.md) | +| Create-Project | process | Registers a codebase workspace under a validated owning agent | [Create-Project](Create-Project.md) | +| Delete-Agent | process | Retires an agent, reassigning its dependent rows to a sentinel agent | [Delete-Agent](Delete-Agent.md) | +| Update-Project | process | Amends a project's editable metadata after confirming it exists | [Update-Project](Update-Project.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Milestone.md b/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Milestone.md index fbb49a0..2cedfe7 100644 --- a/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Milestone.md +++ b/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Milestone.md @@ -1,6 +1,7 @@ --- process: Attach Artifact To Milestone number: 2 +description: Idempotently links an artifact to the milestone it supports inputs: - from: ext:LLM-Agent data: artifact_id, milestone_id diff --git a/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Task.md b/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Task.md index b921997..31a629b 100644 --- a/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Task.md +++ b/models/llm-memory-db-mssql/flows/artifact-management/Attach-Artifact-To-Task.md @@ -1,6 +1,7 @@ --- process: Attach Artifact To Task number: 3 +description: Idempotently links an artifact to a specific task within a milestone inputs: - from: ext:LLM-Agent data: artifact_id, milestone_id, task_no diff --git a/models/llm-memory-db-mssql/flows/artifact-management/Create-Artifact.md b/models/llm-memory-db-mssql/flows/artifact-management/Create-Artifact.md index 123caf1..62e9c9c 100644 --- a/models/llm-memory-db-mssql/flows/artifact-management/Create-Artifact.md +++ b/models/llm-memory-db-mssql/flows/artifact-management/Create-Artifact.md @@ -1,6 +1,7 @@ --- process: Create Artifact number: 1 +description: Registers a file the agent produced and returns its new artifact_id inputs: - from: ext:LLM-Agent data: title, description, filepath, reason, provenance project diff --git a/models/llm-memory-db-mssql/flows/artifact-management/Set-Artifact-Relevance.md b/models/llm-memory-db-mssql/flows/artifact-management/Set-Artifact-Relevance.md index 9112e2c..7d66584 100644 --- a/models/llm-memory-db-mssql/flows/artifact-management/Set-Artifact-Relevance.md +++ b/models/llm-memory-db-mssql/flows/artifact-management/Set-Artifact-Relevance.md @@ -1,6 +1,7 @@ --- process: Set Artifact Relevance number: 4 +description: Archives, restores, or soft-deletes an artifact through a gated transition inputs: - from: ext:LLM-Agent data: artifact_id, target status, reason diff --git a/models/llm-memory-db-mssql/flows/artifact-management/index.md b/models/llm-memory-db-mssql/flows/artifact-management/index.md new file mode 100644 index 0000000..514c38c --- /dev/null +++ b/models/llm-memory-db-mssql/flows/artifact-management/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/artifact-management" count="4" depth="2" digest="sha256:bbe468952da3afa532cebde42e8f0f6ffa3ed6442f01a1f872d43bcdc8ad878c"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Attach-Artifact-To-Milestone | process | Idempotently links an artifact to the milestone it supports | [Attach-Artifact-To-Milestone](Attach-Artifact-To-Milestone.md) | +| Attach-Artifact-To-Task | process | Idempotently links an artifact to a specific task within a milestone | [Attach-Artifact-To-Task](Attach-Artifact-To-Task.md) | +| Create-Artifact | process | Registers a file the agent produced and returns its new artifact_id | [Create-Artifact](Create-Artifact.md) | +| Set-Artifact-Relevance | process | Archives, restores, or soft-deletes an artifact through a gated transition | [Set-Artifact-Relevance](Set-Artifact-Relevance.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/index.md b/models/llm-memory-db-mssql/flows/index.md new file mode 100644 index 0000000..1225854 --- /dev/null +++ b/models/llm-memory-db-mssql/flows/index.md @@ -0,0 +1,18 @@ +<ignatius-breadcrumb> + +↑ [LLM Memory DB (MSSQL)](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flows" path="flows" count="6" depth="1" digest="sha256:8d262f740718550b5bb995f20a9f5a9c7cafb028362522195e5065e560a5c4ef"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| agent-project-setup | folder | | [agent-project-setup](agent-project-setup/index.md) | +| artifact-management | folder | | [artifact-management](artifact-management/index.md) | +| memory-lifecycle | folder | | [memory-lifecycle](memory-lifecycle/index.md) | +| note-capture | folder | | [note-capture](note-capture/index.md) | +| tag-administration | folder | | [tag-administration](tag-administration/index.md) | +| work-planning | folder | | [work-planning](work-planning/index.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/Attach-Memory-to-Project.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/Attach-Memory-to-Project.md index e99a924..bac3761 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/Attach-Memory-to-Project.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/Attach-Memory-to-Project.md @@ -1,6 +1,7 @@ --- process: Attach Memory to Project number: 5 +description: Idempotently scopes a memory to a project context inputs: - from: ext:LLM-Agent data: attachment request (memory_id, project_id) diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/Consolidate-Memory.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/Consolidate-Memory.md index 540cd66..10347c9 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/Consolidate-Memory.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/Consolidate-Memory.md @@ -1,6 +1,7 @@ --- process: Consolidate Memory number: 4 +description: Folds a duplicate memory into a canonical one and marks it superseded inputs: - from: ext:LLM-Agent data: consolidation request (duplicate memory_id, canonical memory_id) diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/Create-Memory.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/Create-Memory.md index 74f6fe0..e549233 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/Create-Memory.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/Create-Memory.md @@ -1,6 +1,7 @@ --- process: Create Memory number: 1 +description: Persists a new long-term memory fact after validating domain and category inputs: - from: ext:LLM-Agent data: new memory fact (content, domain, category, reason, was_inferred, was_observed, was_evidenced, was_user_provided) diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/Filter-Memories-by-Tags.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/Filter-Memories-by-Tags.md index c87f8d3..f908611 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/Filter-Memories-by-Tags.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/Filter-Memories-by-Tags.md @@ -1,6 +1,7 @@ --- process: Filter Memories by Tags number: 6 +description: Returns active memories carrying every requested tag, newest-recalled first inputs: - from: ext:LLM-Agent data: tag filter set (one or more tag_ids to intersect) diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/Relate-Memories.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/Relate-Memories.md index 406d597..50ec9c0 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/Relate-Memories.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/Relate-Memories.md @@ -1,6 +1,7 @@ --- process: Relate Memories number: 2 +description: Creates a directed semantic edge between two memories using a controlled verb inputs: - from: ext:LLM-Agent data: semantic link request (source memory_id, target related_memory_id, relation_verb, reason) diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/Set-Memory-Relevance.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/Set-Memory-Relevance.md index 023fe3b..db0f045 100644 --- a/models/llm-memory-db-mssql/flows/memory-lifecycle/Set-Memory-Relevance.md +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/Set-Memory-Relevance.md @@ -1,6 +1,7 @@ --- process: Set Memory Relevance number: 3 +description: Advances a memory's relevance status through a gated, journaled transition inputs: - from: ext:LLM-Agent data: relevance change request (memory_id, target status, reason) diff --git a/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md b/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md new file mode 100644 index 0000000..4138aac --- /dev/null +++ b/models/llm-memory-db-mssql/flows/memory-lifecycle/index.md @@ -0,0 +1,18 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/memory-lifecycle" count="6" depth="2" digest="sha256:422c062f0be00d1195456be2d2d5174c7b116fb8a9c9371463ccce4555402f1b"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Attach-Memory-to-Project | process | Idempotently scopes a memory to a project context | [Attach-Memory-to-Project](Attach-Memory-to-Project.md) | +| Consolidate-Memory | process | Folds a duplicate memory into a canonical one and marks it superseded | [Consolidate-Memory](Consolidate-Memory.md) | +| Create-Memory | process | Persists a new long-term memory fact after validating domain and category | [Create-Memory](Create-Memory.md) | +| Filter-Memories-by-Tags | process | Returns active memories carrying every requested tag, newest-recalled first | [Filter-Memories-by-Tags](Filter-Memories-by-Tags.md) | +| Relate-Memories | process | Creates a directed semantic edge between two memories using a controlled verb | [Relate-Memories](Relate-Memories.md) | +| Set-Memory-Relevance | process | Advances a memory's relevance status through a gated, journaled transition | [Set-Memory-Relevance](Set-Memory-Relevance.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/note-capture/Create-Milestone-Note.md b/models/llm-memory-db-mssql/flows/note-capture/Create-Milestone-Note.md index c78d260..850dff6 100644 --- a/models/llm-memory-db-mssql/flows/note-capture/Create-Milestone-Note.md +++ b/models/llm-memory-db-mssql/flows/note-capture/Create-Milestone-Note.md @@ -1,6 +1,7 @@ --- process: Create Milestone Note number: 2 +description: Records a progress note against a milestone inputs: - from: ext:LLM-Agent data: content, reason, milestone_id diff --git a/models/llm-memory-db-mssql/flows/note-capture/Create-Project-Note.md b/models/llm-memory-db-mssql/flows/note-capture/Create-Project-Note.md index 3881e58..2b87793 100644 --- a/models/llm-memory-db-mssql/flows/note-capture/Create-Project-Note.md +++ b/models/llm-memory-db-mssql/flows/note-capture/Create-Project-Note.md @@ -1,6 +1,7 @@ --- process: Create Project Note number: 1 +description: Records a free-form observation against a project inputs: - from: ext:LLM-Agent data: content, reason, project_id diff --git a/models/llm-memory-db-mssql/flows/note-capture/Create-Task-Note.md b/models/llm-memory-db-mssql/flows/note-capture/Create-Task-Note.md index d0bc577..4a00bde 100644 --- a/models/llm-memory-db-mssql/flows/note-capture/Create-Task-Note.md +++ b/models/llm-memory-db-mssql/flows/note-capture/Create-Task-Note.md @@ -1,6 +1,7 @@ --- process: Create Task Note number: 3 +description: Records a decision or observation against a specific task inputs: - from: ext:LLM-Agent data: content, reason, milestone_id, task_no diff --git a/models/llm-memory-db-mssql/flows/note-capture/Set-Note-Relevance.md b/models/llm-memory-db-mssql/flows/note-capture/Set-Note-Relevance.md index 81bbb2f..12bd912 100644 --- a/models/llm-memory-db-mssql/flows/note-capture/Set-Note-Relevance.md +++ b/models/llm-memory-db-mssql/flows/note-capture/Set-Note-Relevance.md @@ -1,6 +1,7 @@ --- process: Set Note Relevance number: 4 +description: Archives, restores, or soft-deletes a note through a gated transition inputs: - from: ext:LLM-Agent data: note_id, to_status, reason diff --git a/models/llm-memory-db-mssql/flows/note-capture/index.md b/models/llm-memory-db-mssql/flows/note-capture/index.md new file mode 100644 index 0000000..12d83d8 --- /dev/null +++ b/models/llm-memory-db-mssql/flows/note-capture/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/note-capture" count="4" depth="2" digest="sha256:e4d2387784b5d4b48641ced7367e056f948692e1295a4725ed56377522c7b22f"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Create-Milestone-Note | process | Records a progress note against a milestone | [Create-Milestone-Note](Create-Milestone-Note.md) | +| Create-Project-Note | process | Records a free-form observation against a project | [Create-Project-Note](Create-Project-Note.md) | +| Create-Task-Note | process | Records a decision or observation against a specific task | [Create-Task-Note](Create-Task-Note.md) | +| Set-Note-Relevance | process | Archives, restores, or soft-deletes a note through a gated transition | [Set-Note-Relevance](Set-Note-Relevance.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/tag-administration/Attach-Tag-to-Memory.md b/models/llm-memory-db-mssql/flows/tag-administration/Attach-Tag-to-Memory.md index fb678f0..0ab56bc 100644 --- a/models/llm-memory-db-mssql/flows/tag-administration/Attach-Tag-to-Memory.md +++ b/models/llm-memory-db-mssql/flows/tag-administration/Attach-Tag-to-Memory.md @@ -1,6 +1,7 @@ --- process: Attach Tag to Memory number: 2 +description: Idempotently links one tag to one memory inputs: - from: ext:LLM-Agent data: tag and memory reference (tag_id, memory_id) diff --git a/models/llm-memory-db-mssql/flows/tag-administration/Bulk-Attach-Tag-to-Memories.md b/models/llm-memory-db-mssql/flows/tag-administration/Bulk-Attach-Tag-to-Memories.md index ae2d004..5ca9da6 100644 --- a/models/llm-memory-db-mssql/flows/tag-administration/Bulk-Attach-Tag-to-Memories.md +++ b/models/llm-memory-db-mssql/flows/tag-administration/Bulk-Attach-Tag-to-Memories.md @@ -1,6 +1,7 @@ --- process: Bulk Attach Tag to Memories number: 3 +description: Attaches one tag to a batch of memories in a single round trip inputs: - from: ext:LLM-Agent data: tag and memory set (tag_id, memory_ids[ ]) diff --git a/models/llm-memory-db-mssql/flows/tag-administration/Create-Tag.md b/models/llm-memory-db-mssql/flows/tag-administration/Create-Tag.md index c2be299..4680dfb 100644 --- a/models/llm-memory-db-mssql/flows/tag-administration/Create-Tag.md +++ b/models/llm-memory-db-mssql/flows/tag-administration/Create-Tag.md @@ -1,6 +1,7 @@ --- process: Create Tag number: 1 +description: Mints a new uniquely-named tag and returns its tag_id inputs: - from: ext:LLM-Agent data: new tag (name, description, reason, provenance_id) diff --git a/models/llm-memory-db-mssql/flows/tag-administration/Merge-Tag.md b/models/llm-memory-db-mssql/flows/tag-administration/Merge-Tag.md index 5f5e0bc..83464b8 100644 --- a/models/llm-memory-db-mssql/flows/tag-administration/Merge-Tag.md +++ b/models/llm-memory-db-mssql/flows/tag-administration/Merge-Tag.md @@ -1,6 +1,7 @@ --- process: Merge Tag number: 4 +description: Folds a duplicate tag into a canonical one across all junction tables inputs: - from: ext:LLM-Agent data: source and target tag references (source_tag_id, target_tag_id) diff --git a/models/llm-memory-db-mssql/flows/tag-administration/index.md b/models/llm-memory-db-mssql/flows/tag-administration/index.md new file mode 100644 index 0000000..4b3d134 --- /dev/null +++ b/models/llm-memory-db-mssql/flows/tag-administration/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/tag-administration" count="4" depth="2" digest="sha256:49e3a124f34bb60d4934bdad551bbe11ad895ef467a99f6fd6d0d86f1283b13c"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Attach-Tag-to-Memory | process | Idempotently links one tag to one memory | [Attach-Tag-to-Memory](Attach-Tag-to-Memory.md) | +| Bulk-Attach-Tag-to-Memories | process | Attaches one tag to a batch of memories in a single round trip | [Bulk-Attach-Tag-to-Memories](Bulk-Attach-Tag-to-Memories.md) | +| Create-Tag | process | Mints a new uniquely-named tag and returns its tag_id | [Create-Tag](Create-Tag.md) | +| Merge-Tag | process | Folds a duplicate tag into a canonical one across all junction tables | [Merge-Tag](Merge-Tag.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/flows/work-planning/Add-Task-Dependency.md b/models/llm-memory-db-mssql/flows/work-planning/Add-Task-Dependency.md index e3c95b9..5ee7d4c 100644 --- a/models/llm-memory-db-mssql/flows/work-planning/Add-Task-Dependency.md +++ b/models/llm-memory-db-mssql/flows/work-planning/Add-Task-Dependency.md @@ -1,6 +1,7 @@ --- process: Add Task Dependency number: 4 +description: Wires a directed, cycle-checked dependency edge between two tasks inputs: - from: ext:LLM-Agent data: dependency request (milestone_id, task_no, dep_milestone_id, dep_task_no, dependency_verb, reason) diff --git a/models/llm-memory-db-mssql/flows/work-planning/Close-Milestone.md b/models/llm-memory-db-mssql/flows/work-planning/Close-Milestone.md index 9dc743f..7fb0dfd 100644 --- a/models/llm-memory-db-mssql/flows/work-planning/Close-Milestone.md +++ b/models/llm-memory-db-mssql/flows/work-planning/Close-Milestone.md @@ -1,6 +1,7 @@ --- process: Close Milestone number: 5 +description: Closes a milestone and cascades abandonment to its open child tasks inputs: - from: ext:LLM-Agent data: close request (milestone_id) diff --git a/models/llm-memory-db-mssql/flows/work-planning/Create-Milestone.md b/models/llm-memory-db-mssql/flows/work-planning/Create-Milestone.md index 20b8dd3..7815a45 100644 --- a/models/llm-memory-db-mssql/flows/work-planning/Create-Milestone.md +++ b/models/llm-memory-db-mssql/flows/work-planning/Create-Milestone.md @@ -1,6 +1,7 @@ --- process: Create Milestone number: 1 +description: Creates a planned unit of work and returns its new milestone_id inputs: - from: ext:LLM-Agent data: new milestone (title, content, reason, provenance project) diff --git a/models/llm-memory-db-mssql/flows/work-planning/Create-Task.md b/models/llm-memory-db-mssql/flows/work-planning/Create-Task.md index e03766d..11acc01 100644 --- a/models/llm-memory-db-mssql/flows/work-planning/Create-Task.md +++ b/models/llm-memory-db-mssql/flows/work-planning/Create-Task.md @@ -1,6 +1,7 @@ --- process: Create Task number: 2 +description: Adds a task to a milestone, assigning the next milestone-scoped task_no inputs: - from: ext:LLM-Agent data: new task (milestone_id, title, content, reason) diff --git a/models/llm-memory-db-mssql/flows/work-planning/Set-Task-Tracking.md b/models/llm-memory-db-mssql/flows/work-planning/Set-Task-Tracking.md index 1bc4bff..08f5e22 100644 --- a/models/llm-memory-db-mssql/flows/work-planning/Set-Task-Tracking.md +++ b/models/llm-memory-db-mssql/flows/work-planning/Set-Task-Tracking.md @@ -1,6 +1,7 @@ --- process: Set Task Tracking number: 3 +description: Advances or regresses a task's tracking status through a gated transition inputs: - from: ext:LLM-Agent data: status transition request (milestone_id, task_no, target status, reason) diff --git a/models/llm-memory-db-mssql/flows/work-planning/index.md b/models/llm-memory-db-mssql/flows/work-planning/index.md new file mode 100644 index 0000000..67a9658 --- /dev/null +++ b/models/llm-memory-db-mssql/flows/work-planning/index.md @@ -0,0 +1,17 @@ +<ignatius-breadcrumb> + +↑ [Flows](../index.md) · [LLM Memory DB (MSSQL)](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flow-diagram" path="flows/work-planning" count="5" depth="2" digest="sha256:aec5a4c22e33a647c289c9b955463e7287071c13b00253ce6a71bba80d15bc3f"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Add-Task-Dependency | process | Wires a directed, cycle-checked dependency edge between two tasks | [Add-Task-Dependency](Add-Task-Dependency.md) | +| Close-Milestone | process | Closes a milestone and cascades abandonment to its open child tasks | [Close-Milestone](Close-Milestone.md) | +| Create-Milestone | process | Creates a planned unit of work and returns its new milestone_id | [Create-Milestone](Create-Milestone.md) | +| Create-Task | process | Adds a task to a milestone, assigning the next milestone-scoped task_no | [Create-Task](Create-Task.md) | +| Set-Task-Tracking | process | Advances or regresses a task's tracking status through a gated transition | [Set-Task-Tracking](Set-Task-Tracking.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/groups/artifact.md b/models/llm-memory-db-mssql/groups/artifact.md index fa3c7f6..05969ae 100644 --- a/models/llm-memory-db-mssql/groups/artifact.md +++ b/models/llm-memory-db-mssql/groups/artifact.md @@ -1,5 +1,6 @@ --- label: Artifacts +description: Files and documents produced during work, plus their attachment to milestones and tasks. color: "#db6d28" --- diff --git a/models/llm-memory-db-mssql/groups/audit.md b/models/llm-memory-db-mssql/groups/audit.md index 4abb6b9..91baec7 100644 --- a/models/llm-memory-db-mssql/groups/audit.md +++ b/models/llm-memory-db-mssql/groups/audit.md @@ -1,5 +1,6 @@ --- label: State & Audit +description: The immutable, write-once journal of every relevance and tracking status change. color: "#f85149" --- diff --git a/models/llm-memory-db-mssql/groups/identity.md b/models/llm-memory-db-mssql/groups/identity.md index b1231dc..ab8f052 100644 --- a/models/llm-memory-db-mssql/groups/identity.md +++ b/models/llm-memory-db-mssql/groups/identity.md @@ -1,5 +1,6 @@ --- label: Identity & Workspace +description: The agents that own records and the project workspaces that scope work, referenced as provenance nearly everywhere. color: "#2ea043" --- diff --git a/models/llm-memory-db-mssql/groups/index.md b/models/llm-memory-db-mssql/groups/index.md new file mode 100644 index 0000000..3c75cba --- /dev/null +++ b/models/llm-memory-db-mssql/groups/index.md @@ -0,0 +1,20 @@ +<ignatius-breadcrumb> + +↑ [LLM Memory DB (MSSQL)](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="groups" path="groups" count="8" depth="1" digest="sha256:b24d8b4bec303901fc40fa5be611285935202b65a3bbeaa65dbcd874642c43cf"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| artifact | group | Files and documents produced during work, plus their attachment to milestones and tasks. | [artifact](artifact.md) | +| audit | group | The immutable, write-once journal of every relevance and tracking status change. | [audit](audit.md) | +| identity | group | The agents that own records and the project workspaces that scope work, referenced as provenance nearly everywhere. | [identity](identity.md) | +| knowledge | group | Free-form notes attached to a single project, milestone, or task. | [knowledge](knowledge.md) | +| memory | group | The long-term memory entity, its self-referential relation graph, and its project attachment. | [memory](memory.md) | +| planning | group | Milestones, the tasks that fulfill them, their dependency graph, and project attachment. | [planning](planning.md) | +| reference | group | Status and type vocabularies plus the tables encoding which state transitions are legal. | [reference](reference.md) | +| tagging | group | The cross-cutting tag vocabulary and the five junctions that attach tags to other entities. | [tagging](tagging.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/groups/knowledge.md b/models/llm-memory-db-mssql/groups/knowledge.md index dff0022..ba1d053 100644 --- a/models/llm-memory-db-mssql/groups/knowledge.md +++ b/models/llm-memory-db-mssql/groups/knowledge.md @@ -1,5 +1,6 @@ --- label: Notes +description: Free-form notes attached to a single project, milestone, or task. color: "#58a6ff" --- diff --git a/models/llm-memory-db-mssql/groups/memory.md b/models/llm-memory-db-mssql/groups/memory.md index 0a58e48..284c6fa 100644 --- a/models/llm-memory-db-mssql/groups/memory.md +++ b/models/llm-memory-db-mssql/groups/memory.md @@ -1,5 +1,6 @@ --- label: Memory +description: The long-term memory entity, its self-referential relation graph, and its project attachment. color: "#a371f7" --- diff --git a/models/llm-memory-db-mssql/groups/planning.md b/models/llm-memory-db-mssql/groups/planning.md index cda949d..22eea7b 100644 --- a/models/llm-memory-db-mssql/groups/planning.md +++ b/models/llm-memory-db-mssql/groups/planning.md @@ -1,5 +1,6 @@ --- label: Planning & Work +description: Milestones, the tasks that fulfill them, their dependency graph, and project attachment. color: "#d29922" --- diff --git a/models/llm-memory-db-mssql/groups/reference.md b/models/llm-memory-db-mssql/groups/reference.md index 1c44846..1379315 100644 --- a/models/llm-memory-db-mssql/groups/reference.md +++ b/models/llm-memory-db-mssql/groups/reference.md @@ -1,5 +1,6 @@ --- label: Reference & Codes +description: Status and type vocabularies plus the tables encoding which state transitions are legal. color: "#8b949e" --- diff --git a/models/llm-memory-db-mssql/groups/tagging.md b/models/llm-memory-db-mssql/groups/tagging.md index 4c67f28..1667488 100644 --- a/models/llm-memory-db-mssql/groups/tagging.md +++ b/models/llm-memory-db-mssql/groups/tagging.md @@ -1,5 +1,6 @@ --- label: Tagging +description: The cross-cutting tag vocabulary and the five junctions that attach tags to other entities. color: "#ec6cb9" --- diff --git a/models/llm-memory-db-mssql/index.md b/models/llm-memory-db-mssql/index.md new file mode 100644 index 0000000..fb45243 --- /dev/null +++ b/models/llm-memory-db-mssql/index.md @@ -0,0 +1,13 @@ +<ignatius-index scope="root" path="." count="5" depth="0" digest="sha256:daaeac611ff09e37b12a733a65f899e5932a1f234a8545033e122d3044aacf14"> + +Reverse-engineered IDEF1X model of the llm-memory-db-mssql schema — an agent long-term memory store. Agents record memories, notes, milestones, tasks, and artifacts within projects; everything is taggable and every relevance/tracking change is journaled as an immutable state transition. + +| Name | Kind | Description | Go | +|---|---|---|---| +| Groups | folder | The subject-area registry that labels and colors entities. 8 groups. | [Groups](groups/index.md) | +| Data | folder | The entity model. 38 entities across 8 groups. | [Data](data/index.md) | +| Flows | folder | Data flow diagrams. 6 flows, 27 processes. | [Flows](flows/index.md) | +| Externals | folder | Actors outside the system boundary that DFDs exchange data with. 1 external. | [Externals](externals/index.md) | +| Stores | folder | Non-database stores DFDs read and write. 0 stores. | [Stores](stores/index.md) | + +</ignatius-index> diff --git a/models/llm-memory-db-mssql/stores/index.md b/models/llm-memory-db-mssql/stores/index.md new file mode 100644 index 0000000..767117a --- /dev/null +++ b/models/llm-memory-db-mssql/stores/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [LLM Memory DB (MSSQL)](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="stores" path="stores" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/models/orm-hybrid/data/catalog/index.md b/models/orm-hybrid/data/catalog/index.md new file mode 100644 index 0000000..170aa97 --- /dev/null +++ b/models/orm-hybrid/data/catalog/index.md @@ -0,0 +1,14 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Hybrid](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/catalog" count="2" depth="2" digest="sha256:098ce9d166507c44bc2a8fb79c3f9bb1bc95176bcb6a6bcdacac2d3678ec5364"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Product | Independent | | [Product](Product.md) | +| Subscription | Independent | | [Subscription](Subscription.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/data/identity/index.md b/models/orm-hybrid/data/identity/index.md new file mode 100644 index 0000000..dd17f44 --- /dev/null +++ b/models/orm-hybrid/data/identity/index.md @@ -0,0 +1,20 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Hybrid](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/identity" count="8" depth="2" digest="sha256:ad677540ee85bfa35e378ead60ea4f38d508fbfdd282eaf7011f7abe553eb5dc"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Business | Subtype | | [Business](Business.md) | +| Identity | Independent | | [Identity](Identity.md) | +| ITIN | Subtype | | [ITIN](ITIN.md) | +| License | Subtype | | [License](License.md) | +| Party | Independent | | [Party](Party.md) | +| Passport | Subtype | | [Passport](Passport.md) | +| Person | Subtype | | [Person](Person.md) | +| SSN | Subtype | | [SSN](SSN.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/data/index.md b/models/orm-hybrid/data/index.md new file mode 100644 index 0000000..498990a --- /dev/null +++ b/models/orm-hybrid/data/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [ORM Hybrid](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="data" path="data" count="4" depth="1" digest="sha256:f60c27004361032e423e364b71fbf649bfb3dbf014ad5fab8780b2f4f07020b4"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| catalog | folder | | [catalog](catalog/index.md) | +| identity | folder | | [identity](identity/index.md) | +| reference | folder | | [reference](reference/index.md) | +| transactional | folder | | [transactional](transactional/index.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/data/reference/index.md b/models/orm-hybrid/data/reference/index.md new file mode 100644 index 0000000..f55d7c3 --- /dev/null +++ b/models/orm-hybrid/data/reference/index.md @@ -0,0 +1,15 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Hybrid](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/reference" count="3" depth="2" digest="sha256:b2606c066f23ef8ee51f30b2bebfb6315581b601c0e6d7dfeba34f3b8ab22922"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| LineItemType | Classifier | | [LineItemType](LineItemType.md) | +| PartyType | Classifier | | [PartyType](PartyType.md) | +| PaymentMethodType | Classifier | | [PaymentMethodType](PaymentMethodType.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/data/transactional/index.md b/models/orm-hybrid/data/transactional/index.md new file mode 100644 index 0000000..edd54d9 --- /dev/null +++ b/models/orm-hybrid/data/transactional/index.md @@ -0,0 +1,23 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Hybrid](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/transactional" count="11" depth="2" digest="sha256:9913406a045a29228ef04e2a7c741a48cd5bed0292be7fda19c817b84cc10050"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Payment | Independent | | [Payment](Payment.md) | +| PaymentAllocation | Independent | | [PaymentAllocation](PaymentAllocation.md) | +| PaymentMethod | Independent | | [PaymentMethod](PaymentMethod.md) | +| SalesInvoice | Independent | | [SalesInvoice](SalesInvoice.md) | +| SalesOrder | Independent | | [SalesOrder](SalesOrder.md) | +| SI_Line | Independent | | [SI_Line](SI_Line.md) | +| SIL_Product | Subtype | | [SIL_Product](SIL_Product.md) | +| SIL_Subscription | Subtype | | [SIL_Subscription](SIL_Subscription.md) | +| SO_Line | Independent | | [SO_Line](SO_Line.md) | +| SOL_Product | Subtype | | [SOL_Product](SOL_Product.md) | +| SOL_Subscription | Subtype | | [SOL_Subscription](SOL_Subscription.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/externals/index.md b/models/orm-hybrid/externals/index.md new file mode 100644 index 0000000..aef6723 --- /dev/null +++ b/models/orm-hybrid/externals/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [ORM Hybrid](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="externals" path="externals" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/models/orm-hybrid/flows/index.md b/models/orm-hybrid/flows/index.md new file mode 100644 index 0000000..ecd22db --- /dev/null +++ b/models/orm-hybrid/flows/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [ORM Hybrid](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flows" path="flows" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/models/orm-hybrid/groups/index.md b/models/orm-hybrid/groups/index.md new file mode 100644 index 0000000..fa5c138 --- /dev/null +++ b/models/orm-hybrid/groups/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [ORM Hybrid](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="groups" path="groups" count="4" depth="1" digest="sha256:efa64c6943285d71aa3ca9368f0de11d9e627cc4775ec771d56af40c85c88760"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| catalog | group | | [catalog](catalog.md) | +| identity | group | | [identity](identity.md) | +| reference | group | | [reference](reference.md) | +| transactional | group | | [transactional](transactional.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/index.md b/models/orm-hybrid/index.md new file mode 100644 index 0000000..12ee269 --- /dev/null +++ b/models/orm-hybrid/index.md @@ -0,0 +1,11 @@ +<ignatius-index scope="root" path="." count="5" depth="0" digest="sha256:e3489e02af39cfb5710deb4bdcb8f6b6e264b07210040e639f0614dd20013e16"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Groups | folder | The subject-area registry that labels and colors entities. 4 groups. | [Groups](groups/index.md) | +| Data | folder | The entity model. 24 entities across 4 groups. | [Data](data/index.md) | +| Flows | folder | Data flow diagrams. 0 flows, 0 processes. | [Flows](flows/index.md) | +| Externals | folder | Actors outside the system boundary that DFDs exchange data with. 0 externals. | [Externals](externals/index.md) | +| Stores | folder | Non-database stores DFDs read and write. 0 stores. | [Stores](stores/index.md) | + +</ignatius-index> diff --git a/models/orm-hybrid/stores/index.md b/models/orm-hybrid/stores/index.md new file mode 100644 index 0000000..9c9fdc3 --- /dev/null +++ b/models/orm-hybrid/stores/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [ORM Hybrid](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="stores" path="stores" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/models/orm-pure/data/catalog/index.md b/models/orm-pure/data/catalog/index.md new file mode 100644 index 0000000..f3fcb07 --- /dev/null +++ b/models/orm-pure/data/catalog/index.md @@ -0,0 +1,14 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Pure](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/catalog" count="2" depth="2" digest="sha256:098ce9d166507c44bc2a8fb79c3f9bb1bc95176bcb6a6bcdacac2d3678ec5364"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Product | Independent | | [Product](Product.md) | +| Subscription | Independent | | [Subscription](Subscription.md) | + +</ignatius-index> diff --git a/models/orm-pure/data/identity/index.md b/models/orm-pure/data/identity/index.md new file mode 100644 index 0000000..86788a6 --- /dev/null +++ b/models/orm-pure/data/identity/index.md @@ -0,0 +1,20 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Pure](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/identity" count="8" depth="2" digest="sha256:1bc022ac384111988935b30b39680b4933360e9905bceb63e6bb19fecaf72ac6"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Business | Independent | | [Business](Business.md) | +| Identity | Independent | | [Identity](Identity.md) | +| ITIN | Independent | | [ITIN](ITIN.md) | +| License | Independent | | [License](License.md) | +| Party | Independent | | [Party](Party.md) | +| Passport | Independent | | [Passport](Passport.md) | +| Person | Independent | | [Person](Person.md) | +| SSN | Independent | | [SSN](SSN.md) | + +</ignatius-index> diff --git a/models/orm-pure/data/index.md b/models/orm-pure/data/index.md new file mode 100644 index 0000000..b8039a6 --- /dev/null +++ b/models/orm-pure/data/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [ORM Pure](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="data" path="data" count="4" depth="1" digest="sha256:84e9ccb94804d882eb403c9efdff93a621833fc6c141c07417cb17abe456c532"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| catalog | folder | | [catalog](catalog/index.md) | +| identity | folder | | [identity](identity/index.md) | +| reference | folder | | [reference](reference/index.md) | +| transactional | folder | | [transactional](transactional/index.md) | + +</ignatius-index> diff --git a/models/orm-pure/data/reference/index.md b/models/orm-pure/data/reference/index.md new file mode 100644 index 0000000..2ac386b --- /dev/null +++ b/models/orm-pure/data/reference/index.md @@ -0,0 +1,15 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Pure](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/reference" count="3" depth="2" digest="sha256:b2606c066f23ef8ee51f30b2bebfb6315581b601c0e6d7dfeba34f3b8ab22922"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| LineItemType | Classifier | | [LineItemType](LineItemType.md) | +| PartyType | Classifier | | [PartyType](PartyType.md) | +| PaymentMethodType | Classifier | | [PaymentMethodType](PaymentMethodType.md) | + +</ignatius-index> diff --git a/models/orm-pure/data/transactional/index.md b/models/orm-pure/data/transactional/index.md new file mode 100644 index 0000000..be7acba --- /dev/null +++ b/models/orm-pure/data/transactional/index.md @@ -0,0 +1,23 @@ +<ignatius-breadcrumb> + +↑ [Data](../index.md) · [ORM Pure](../../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="entity-group" path="data/transactional" count="11" depth="2" digest="sha256:c38c78e53ab7fe19f7d52a92866953498ab246353c5038ec62e5ec716950d7a2"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Payment | Independent | | [Payment](Payment.md) | +| PaymentAllocation | Independent | | [PaymentAllocation](PaymentAllocation.md) | +| PaymentMethod | Independent | | [PaymentMethod](PaymentMethod.md) | +| SalesInvoice | Independent | | [SalesInvoice](SalesInvoice.md) | +| SalesOrder | Independent | | [SalesOrder](SalesOrder.md) | +| SI_Line | Independent | | [SI_Line](SI_Line.md) | +| SIL_Product | Independent | | [SIL_Product](SIL_Product.md) | +| SIL_Subscription | Independent | | [SIL_Subscription](SIL_Subscription.md) | +| SO_Line | Independent | | [SO_Line](SO_Line.md) | +| SOL_Product | Independent | | [SOL_Product](SOL_Product.md) | +| SOL_Subscription | Independent | | [SOL_Subscription](SOL_Subscription.md) | + +</ignatius-index> diff --git a/models/orm-pure/externals/index.md b/models/orm-pure/externals/index.md new file mode 100644 index 0000000..79c9077 --- /dev/null +++ b/models/orm-pure/externals/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [ORM Pure](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="externals" path="externals" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/models/orm-pure/flows/index.md b/models/orm-pure/flows/index.md new file mode 100644 index 0000000..ec2455a --- /dev/null +++ b/models/orm-pure/flows/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [ORM Pure](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="flows" path="flows" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/models/orm-pure/groups/index.md b/models/orm-pure/groups/index.md new file mode 100644 index 0000000..ad9f981 --- /dev/null +++ b/models/orm-pure/groups/index.md @@ -0,0 +1,16 @@ +<ignatius-breadcrumb> + +↑ [ORM Pure](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="groups" path="groups" count="4" depth="1" digest="sha256:efa64c6943285d71aa3ca9368f0de11d9e627cc4775ec771d56af40c85c88760"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| catalog | group | | [catalog](catalog.md) | +| identity | group | | [identity](identity.md) | +| reference | group | | [reference](reference.md) | +| transactional | group | | [transactional](transactional.md) | + +</ignatius-index> diff --git a/models/orm-pure/index.md b/models/orm-pure/index.md new file mode 100644 index 0000000..001a05c --- /dev/null +++ b/models/orm-pure/index.md @@ -0,0 +1,11 @@ +<ignatius-index scope="root" path="." count="5" depth="0" digest="sha256:8d5dd33fa6ce82d6d6997182cfe70498bf47edcaba85479b03a95b79a967a7db"> + +| Name | Kind | Description | Go | +|---|---|---|---| +| Groups | folder | The subject-area registry that labels and colors entities. 4 groups. | [Groups](groups/index.md) | +| Data | folder | The entity model. 24 entities across 4 groups. | [Data](data/index.md) | +| Flows | folder | Data flow diagrams. 0 flows, 0 processes. | [Flows](flows/index.md) | +| Externals | folder | Actors outside the system boundary that DFDs exchange data with. 0 externals. | [Externals](externals/index.md) | +| Stores | folder | Non-database stores DFDs read and write. 0 stores. | [Stores](stores/index.md) | + +</ignatius-index> diff --git a/models/orm-pure/stores/index.md b/models/orm-pure/stores/index.md new file mode 100644 index 0000000..82ed9b9 --- /dev/null +++ b/models/orm-pure/stores/index.md @@ -0,0 +1,12 @@ +<ignatius-breadcrumb> + +↑ [ORM Pure](../index.md) + +</ignatius-breadcrumb> + +<ignatius-index scope="stores" path="stores" count="0" depth="1" digest="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"> + +| Name | Kind | Description | Go | +|---|---|---|---| + +</ignatius-index> diff --git a/skills/ignatius-modeling/SKILL.md b/skills/ignatius-modeling/SKILL.md index d45b3db..9ec7330 100644 --- a/skills/ignatius-modeling/SKILL.md +++ b/skills/ignatius-modeling/SKILL.md @@ -30,6 +30,7 @@ After writing any file, always run the verification loop in `references/verifica - Subtype clusters are an independent authoring choice; classification as Subtype is derived from the cluster declaration, never asked. - Predicates carry business meaning, not cardinality: push for the domain verb a stakeholder would say ("makes payments using", "settles") over "has many" / "belongs to". The crow's-foot already shows cardinality; the predicate makes the line read as a true sentence. - Examples always, in every mode: every entity carries 2–3 `examples:` rows and every process carries in/out `examples:` — never skipped, never offered as optional. Generate them yourself from the business context (realistic domain values, not `foo`/`1`/`test`), show them, and let the user adjust. Concrete instances expose wrong rules that pass every structural check; a model without examples is unverified. +- `description:` always, in every mode: every entity, group, process, external, and store carries a one-line `description:` in frontmatter — never skipped, never offered as optional. It is not documentation; it is the payload of the generated router tables, the line a reader uses to decide whether to open the file. 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. - 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. ## Reference files diff --git a/skills/ignatius-modeling/references/conventions.md b/skills/ignatius-modeling/references/conventions.md index 122733c..c2c0d04 100644 --- a/skills/ignatius-modeling/references/conventions.md +++ b/skills/ignatius-modeling/references/conventions.md @@ -1,5 +1,9 @@ ## Conventions reference +### Reserved filename: `index_file` + +`ignatius.yml` names a router file the CLI generates into every organizing folder — `index_file:` (default `index.md`). That basename is reserved: no entity, process, external, or store file may use it. A file with that name under `data/` that declares `entity:` fails `config.index_file_entity`; one that doesn't declare `entity:` is treated as a router and silently skipped, not scanned. The reservation is on the basename, not a suffix — an entity named `Reindex.md` is unaffected. + ### Column types `text`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `binary`, `json` diff --git a/skills/ignatius-modeling/references/dfd-authoring.md b/skills/ignatius-modeling/references/dfd-authoring.md index 321a672..be319ae 100644 --- a/skills/ignatius-modeling/references/dfd-authoring.md +++ b/skills/ignatius-modeling/references/dfd-authoring.md @@ -169,6 +169,10 @@ Write the rows as the `examples:` block in the process frontmatter, split into ` each entry titled by its `from`/`to` endpoint and a `label`. See the worked example in `references/flow-templates.md`. +### Step F6a — Descriptions (always, per node) + +Always run this step — do not skip it. Every process, external, and store carries a one-line `description:` frontmatter field: the router table's payload, not documentation. Generate it yourself for each node from what the flow has established so far (the process's verb phrase, the external's role, the store's purpose), show it, and let the user adjust. One sentence; the full story is Step F7's body. + ### Step F7 — Bodies: the business context (per node) Every node carries a markdown body. This is where the *why* lives — the reason the thing exists. diff --git a/skills/ignatius-modeling/references/entity-flow.md b/skills/ignatius-modeling/references/entity-flow.md index 8715fcc..681a74c 100644 --- a/skills/ignatius-modeling/references/entity-flow.md +++ b/skills/ignatius-modeling/references/entity-flow.md @@ -13,6 +13,12 @@ Ask: "Entity name (becomes the file name and the id used in relationships, wiki- The id is free-form — the parser enforces no casing. PascalCase (`SalesOrder`) is the greenfield convention; suggest it for new models, but match the prevailing style when the model already has one. When entities come from an existing system (reverse-engineering), keep the source's names verbatim — `sales_orders` stays `sales_orders`; renaming to convention is a user decision, never an automatic cleanup. +Never name the entity file after the model's configured `index_file` (default `index.md`) — that basename is reserved for the generated router; a `data/` file with that name that also declares `entity:` fails validation (`config.index_file_entity`). + +### Step E1b — Description (always) + +Always run this step — do not skip or make it optional. Write a one-line `description:` frontmatter field: the text a router table shows beside this entity's name, the payload that lets a reader decide whether to open the file without opening it. Generate it yourself from the entity name, group, and any context gathered so far — don't ask the user to draft it — then show it and let them adjust. One sentence; the fuller story is Step E9. + ### Step E2 — Group Ask: "Which group? (existing group names: `<list from groups/*.md>`)" diff --git a/skills/ignatius-modeling/references/flow-templates.md b/skills/ignatius-modeling/references/flow-templates.md index 0639871..691b512 100644 --- a/skills/ignatius-modeling/references/flow-templates.md +++ b/skills/ignatius-modeling/references/flow-templates.md @@ -17,6 +17,7 @@ Endpoint tokens used in `inputs:`/`outputs:`/`examples:`: --- process: <Imperative Verb Phrase> # e.g. Collect Payment number: <n> # process id within the diagram +description: "<one-line description — the router table's payload>" inputs: - from: ext:<Name> data: <full payload phrase> # name every field that crosses @@ -104,6 +105,7 @@ relationship with the business. ```markdown --- external: <Name> +description: "<one-line description — the router table's payload>" # title: <Display Label> # optional; omit to derive the label from the name --- @@ -154,6 +156,7 @@ Body is required: state why the store exists and show sample values. --- kind: <cache|queue|file|doc|manual|other> title: <Display Label> # optional; omit to derive from the slug +description: "<one-line description — the router table's payload>" --- <Why this store exists — the reason it is a resting place distinct from the diff --git a/skills/ignatius-modeling/references/templates.md b/skills/ignatius-modeling/references/templates.md index 653c8b8..67a65c4 100644 --- a/skills/ignatius-modeling/references/templates.md +++ b/skills/ignatius-modeling/references/templates.md @@ -6,6 +6,7 @@ --- entity: <EntityName> group: <group-slug> +description: "<one-line description — the router table's payload, not a summary of the body>" pk: - <pk_col_1> # - <pk_col_2> # key-inherited: add parent PK cols before local discriminator @@ -380,6 +381,7 @@ the surrogate key verbatim, never introducing a renamed `<base>_id` column._ --- label: <Human Readable Label> color: "#<hex>" +description: "<one-line description — the router table's payload>" --- <Optional one-line description of what this group contains.> diff --git a/skills/ignatius-modeling/references/verification.md b/skills/ignatius-modeling/references/verification.md index 9cd007d..e6b9ed0 100644 --- a/skills/ignatius-modeling/references/verification.md +++ b/skills/ignatius-modeling/references/verification.md @@ -9,6 +9,19 @@ ignatius validate <model-root> authoring loops. It prints findings to stderr and a one-line summary to stdout, and exits non-zero when global (Class B) errors are present. +Then run the router-staleness gate: +``` +ignatius validate --index <model-root> +``` +`--index` recomputes the digest of every generated router against the current files +and reports drift as `index.stale` — nothing is written. Run it whenever the model +has been indexed before (`ignatius index` has run at least once); a model that has +never been indexed has no routers to go stale. Fix a stale-router finding by running +`ignatius index <model-root>` to regenerate, then re-run `--index` to confirm clean. +`ignatius index --agents` additionally writes in-folder agent guidance (`AGENTS.md`, +a `CLAUDE.md` shim, `SKILL.md`) into the model root. Run it when the model is meant +to orient an agent that opens it; plain `ignatius index` is enough for the routers. + Parse stderr. Format: `<sev> <ruleId> <location> <message>` (two spaces between fields). **Rule reference table** (for reporting fix hints without grepping source): @@ -29,6 +42,12 @@ Parse stderr. Format: `<sev> <ruleId> <location> <message>` (two spaces betwe | `cluster.missing_member` | warn | A | Subtype cluster member not in model | Add the member entity file or remove it from `members:` | | `cluster.no_discriminator` | warn | A | Exclusive subtype cluster has no discriminator | Convert `members:` from list form to map form with discriminator values | | `entity.example_unknown_column` | warn | A | Example row contains unknown key | Remove or rename the key — every key in an `examples:` row must be in `pk ∪ columns`. **This rule is live-server-only: `ignatius validate` never prints it.** Self-check example keys manually when writing (Step E7b); the warning only appears in the running app | +| `config.index_file_ext` | error | B | `index_file` value doesn't end in `.md` | Change `index_file:` in `ignatius.yml` to a `.md` filename | +| `config.index_file_path` | error | B | `index_file` value is a path, not a bare filename | Use a bare filename (e.g. `index.md`), no `/` or `..` | +| `config.index_file_entity` | error | B | Entity file uses the reserved `index_file` name | Rename the entity file — that basename is reserved for the generated router (Step E1) | +| `index.stale` | error | B | A router's digest doesn't match its current files (`validate --index` only) | Run `ignatius index <model-root>` to regenerate, then re-run `validate --index` | +| `index.orphaned` | warn | A | A router file left behind after `index_file` changed (`validate --index` only) | Delete the orphaned file, or restore the prior `index_file` value | +| `index.unreadable_target` | error | B | A file a router row points at could not be read, so its digest cannot be trusted (`validate --index` only) | Fix file access or remove the stale reference, then run `ignatius index <model-root>` | **Flow rule reference table** (`flow.*` findings appear when the model has a `flows/` directory; each maps back to a DFD authoring step in `references/dfd-authoring.md`): diff --git a/src/cli/cli.ts b/src/cli/cli.ts index e6ed651..cdda495 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -5,6 +5,7 @@ import { serveWithPortFallback } from './serve-port'; import { parseModels } from '../model/parse'; import { pickModel } from './resolve-model'; import { VERSION } from './version'; +import type { RouterFile } from '../router/build'; // ────────────────────────────────────────────────────────────────────────────── // serve @@ -104,6 +105,11 @@ const validateCmd = defineCommand({ type: 'string', description: 'Model key to use when multiple models are found', }, + index: { + type: 'boolean', + description: 'Recompute router digests and report drift (index.stale / index.orphaned) without writing anything', + default: false, + }, }, async run({ args }) { const base = args.path ? resolve(args.path) : process.cwd(); @@ -118,12 +124,14 @@ const validateCmd = defineCommand({ // Guard with existsSync so models without flows/ are unaffected (no latency). let flowErrors: import('../flows/flow-validate').FlowError[] = []; let hasClassBFlowErrors = false; + let flowModel: import('../flows/flow-parse').FlowModel | null = null; const flowsDir = `${dir}/flows`; if (existsSync(flowsDir)) { const { parseFlows } = await import('../flows/flow-parse'); const { validateFlows } = await import('../flows/flow-validate'); - const { flowModel, globalErrors: flowParseErrors } = await parseFlows(dir); - allGlobalErrors.push(...flowParseErrors); + const parsed = await parseFlows(dir); + flowModel = parsed.flowModel; + allGlobalErrors.push(...parsed.globalErrors); const flowConfig = model._meta?.flowRules; const flowValidation = validateFlows(flowModel, model, flowConfig); flowErrors = flowValidation.flowErrors; @@ -132,6 +140,17 @@ const validateCmd = defineCommand({ hasClassBFlowErrors = flowErrors.some(e => RULES[e.ruleId].class === 'B'); } + // --index is what triggers hashing at all — a plain `validate` never reads + // router targets, so it stays cheap and never reports index.stale/orphaned. + if (args.index) { + const { parseFlows } = await import('../flows/flow-parse'); + const { validateIndex } = await import('../model/validate'); + const resolvedFlowModel = flowModel ?? (await parseFlows(dir)).flowModel; + const indexResult = await validateIndex(dir, model, resolvedFlowModel); + allGlobalErrors.push(...indexResult.globalErrors); + validation.entityErrors.push(...indexResult.entityErrors); + } + const stderrLines = formatFindingsForStderr(allGlobalErrors, validation.entityErrors, flowErrors); for (const line of stderrLines) { process.stderr.write(line + '\n'); @@ -155,6 +174,85 @@ const validateCmd = defineCommand({ }, }); +// ────────────────────────────────────────────────────────────────────────────── +// index — router build + write +// ────────────────────────────────────────────────────────────────────────────── + +const indexCmd = defineCommand({ + meta: { + name: 'index', + description: 'Generate navigable index.md routers into every organizing folder of a model root', + }, + args: { + path: { + type: 'positional', + description: 'Path to search for a model root (default: cwd)', + required: false, + }, + model: { + type: 'string', + description: 'Model key to use when multiple models are found', + }, + agents: { + type: 'boolean', + description: 'Also write in-folder agent guidance (AGENTS.md / CLAUDE.md / SKILL.md)', + default: false, + }, + }, + async run({ args }) { + const base = args.path ? resolve(args.path) : process.cwd(); + const dir = await pickModel(base, args.model); + + // Findings are reported, not fatal: routers still get written, and the + // exit code reflects Class B findings, matching `export`'s posture. + const { model, globalErrors: parseGlobalErrors } = await parseModels(dir); + const { validateModel, formatFindingsForStderr, RULES } = await import('../model/validate'); + const validation = validateModel(model); + const allGlobalErrors = [...parseGlobalErrors, ...validation.globalErrors]; + + const { parseFlows } = await import('../flows/flow-parse'); + const { flowModel, globalErrors: flowParseErrors } = await parseFlows(dir); + allGlobalErrors.push(...flowParseErrors); + const { validateFlows } = await import('../flows/flow-validate'); + const flowConfig = model._meta?.flowRules; + const flowValidation = validateFlows(flowModel, model, flowConfig); + const flowErrors = flowValidation.flowErrors; + const hasClassBFlowErrors = flowErrors.some(e => RULES[e.ruleId].class === 'B'); + + const stderrLines = formatFindingsForStderr(allGlobalErrors, validation.entityErrors, flowErrors); + for (const line of stderrLines) process.stderr.write(line + '\n'); + + const { buildRouters } = await import('../router/build'); + const { writeRouters } = await import('../router/write'); + let routers: RouterFile[]; + let writeClaude = false; + try { + routers = await buildRouters(dir, model, flowModel); + await writeRouters(dir, routers); + + if (args.agents) { + const { resolveHarness } = await import('../router/detect'); + const { writeGuidance } = await import('../router/agents'); + const indexFile = model._meta?.indexFile ?? 'index.md'; + const rootFile = routers.find(f => f.relPath === indexFile); + writeClaude = resolveHarness(dir, model._meta?.harness); + await writeGuidance(dir, model, rootFile?.digest ?? '', writeClaude); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`Error: ${msg}\n`); + process.exit(1); + } + + console.log(`Wrote ${routers.length} router(s) into ${dir}`); + if (args.agents) { + console.log(`Wrote agent guidance (AGENTS.md, SKILL.md${writeClaude ? ', CLAUDE.md' : ''}) into ${dir}`); + } + + process.exit(allGlobalErrors.length > 0 || hasClassBFlowErrors ? 1 : 0); + }, +}); + // ────────────────────────────────────────────────────────────────────────────── // flow (removed — stub prints a helpful pointer) // ────────────────────────────────────────────────────────────────────────────── @@ -326,6 +424,7 @@ const main = defineCommand({ dict: dictCmd, graph: graphCmd, validate: validateCmd, + index: indexCmd, flow: flowCmd, export: exportCmd, version: versionCmd, diff --git a/src/flows/flow-parse.ts b/src/flows/flow-parse.ts index 8524db3..174757a 100644 --- a/src/flows/flow-parse.ts +++ b/src/flows/flow-parse.ts @@ -75,6 +75,7 @@ export type FlowProcess = { bodyHtml: string; hasSubDfd: boolean; flowId: string; + description?: string; /** Optional in/out data examples for rendering as tables in the process dialog. */ examples?: { in: FlowExample[]; out: FlowExample[] }; }; @@ -87,6 +88,7 @@ export type FlowExternal = { body: string; bodyHtml: string; flowId: string; + description?: string; }; export type FlowStoreRef = { @@ -99,6 +101,7 @@ export type FlowStoreRef = { body?: string; bodyHtml?: string; flowId: string; + description?: string; }; export type FlowDiagram = { @@ -352,7 +355,7 @@ function buildEdgeFromOutput( function collectStoreRefsFromEdges( edges: FlowEdge[], flowId: string, - storeBodyByKindName: Map<string, { displayName: string; body: string; bodyHtml: string }>, + storeBodyByKindName: Map<string, { displayName: string; body: string; bodyHtml: string; description?: string }>, ): FlowStoreRef[] { const seen = new Map<string, FlowStoreRef>(); for (const edge of edges) { @@ -368,6 +371,7 @@ function collectStoreRefsFromEdges( displayName: stored?.displayName ?? titlelize(ep.name), flowId, ...(stored !== undefined ? { body: stored.body, bodyHtml: stored.bodyHtml } : {}), + ...(stored?.description !== undefined ? { description: stored.description } : {}), }; seen.set(key, ref); } @@ -379,7 +383,7 @@ function collectStoreRefsFromEdges( // External description loading // --------------------------------------------------------------------------- -type ExternalDef = { label: string; kind?: FlowStoreRef['kind']; body: string; bodyHtml: string }; +type ExternalDef = { label: string; kind?: FlowStoreRef['kind']; body: string; bodyHtml: string; description?: string }; /** * Read an `externals/*.md` folder into a map of `extId → {label, body, html}`. @@ -389,11 +393,13 @@ type ExternalDef = { label: string; kind?: FlowStoreRef['kind']; body: string; b async function readExternalsDir( dir: string, globalErrors: GlobalError[], + indexFileName: string, ): Promise<Map<string, ExternalDef>> { const map = new Map<string, ExternalDef>(); const glob = new Bun.Glob('*.md'); try { for await (const extPath of glob.scan(dir)) { + if (extPath === indexFileName) continue; const extFilePath = `${dir}/${extPath}`; try { const content = await Bun.file(extFilePath).text(); @@ -419,7 +425,15 @@ async function readExternalsDir( const extRawKind = frontmatter['kind']; const extKind: FlowStoreRef['kind'] | undefined = isKnownStoreKind(extRawKind) ? extRawKind : undefined; - map.set(extId, { label: resolvedExtLabel, kind: extKind, body, bodyHtml: md.render(body) }); + const extRawDescription = frontmatter['description']; + const extDescription = typeof extRawDescription === 'string' ? extRawDescription : undefined; + map.set(extId, { + label: resolvedExtLabel, + kind: extKind, + body, + bodyHtml: md.render(body), + ...(extDescription !== undefined ? { description: extDescription } : {}), + }); } catch (err) { globalErrors.push({ ruleId: 'parse.invalid_yaml', @@ -445,8 +459,9 @@ async function parseDiagramFolder( parentDottedNumbers: number[], visitedPaths: Set<string>, rootExternals: Map<string, ExternalDef>, - rootStoreBodyByKindName: Map<string, { displayName: string; body: string; bodyHtml: string }>, + rootStoreBodyByKindName: Map<string, { displayName: string; body: string; bodyHtml: string; description?: string }>, globalErrors: GlobalError[], + indexFileName: string, ): Promise<FlowDiagram> { // Cycle guard: refuse to re-enter an ancestor folder const resolved = folderPath; @@ -488,6 +503,7 @@ async function parseDiagramFolder( for (let i = 0; i < processFiles.length; i++) { const fileName = processFiles[i]!; + if (fileName === indexFileName) continue; const filePath = `${folderPath}/${fileName}`; const processId = fileName.replace(/\.md$/, ''); @@ -515,6 +531,9 @@ async function parseDiagramFolder( continue; } + const rawDescription = frontmatter['description']; + const processDescription = typeof rawDescription === 'string' ? rawDescription : undefined; + // Resolve display label: title: (explicit) → process: value → titlelize(id) const titleOverride = frontmatter['title']; const resolvedProcessLabel = @@ -605,6 +624,7 @@ async function parseDiagramFolder( bodyHtml: md.render(body), hasSubDfd, flowId, + ...(processDescription !== undefined ? { description: processDescription } : {}), ...(parsedExamples ? { examples: parsedExamples } : {}), }); @@ -618,6 +638,7 @@ async function parseDiagramFolder( rootExternals, rootStoreBodyByKindName, globalErrors, + indexFileName, ); subDfds.push(subDiagram); } @@ -638,7 +659,15 @@ async function parseDiagramFolder( for (const name of referencedExtNames) { const def = rootExternals.get(name); if (!def) continue; // referenced but not defined → validator flags unknown_external - externals.push({ id: name, label: def.label, kind: def.kind, body: def.body, bodyHtml: def.bodyHtml, flowId }); + externals.push({ + id: name, + label: def.label, + kind: def.kind, + body: def.body, + bodyHtml: def.bodyHtml, + flowId, + ...(def.description !== undefined ? { description: def.description } : {}), + }); } // Build deduplicated store refs from all collected edges using the shared root store registry @@ -659,7 +688,7 @@ async function parseDiagramFolder( // parseFlows — top-level entry // --------------------------------------------------------------------------- -export async function parseFlows(modelDir: string): Promise<FlowParseResult> { +export async function parseFlows(modelDir: string, indexFileName = 'index.md'): Promise<FlowParseResult> { const globalErrors: GlobalError[] = []; const diagrams: FlowDiagram[] = []; @@ -688,14 +717,15 @@ export async function parseFlows(modelDir: string): Promise<FlowParseResult> { // Shared externals declared once at <modelDir>/externals/ — usable by any DFD // at any depth (passed down the recursion). - const rootExternals = await readExternalsDir(`${modelDir}/externals`, globalErrors); + const rootExternals = await readExternalsDir(`${modelDir}/externals`, globalErrors, indexFileName); // Shared stores declared once at <modelDir>/stores/ — usable by any DFD. - const rootStoreBodyByKindName = new Map<string, { displayName: string; body: string; bodyHtml: string }>(); + const rootStoreBodyByKindName = new Map<string, { displayName: string; body: string; bodyHtml: string; description?: string }>(); const storeGlob = new Bun.Glob('*.md'); const storesDir = `${modelDir}/stores`; try { for await (const storePath of storeGlob.scan(storesDir)) { + if (storePath === indexFileName) continue; const storeFilePath = `${storesDir}/${storePath}`; try { const content = await Bun.file(storeFilePath).text(); @@ -711,7 +741,14 @@ export async function parseFlows(modelDir: string): Promise<FlowParseResult> { typeof storeTitleOverride === 'string' && storeTitleOverride.trim() ? storeTitleOverride.trim() : titlelize(storeName); - rootStoreBodyByKindName.set(key, { displayName: resolvedDisplayName, body, bodyHtml: md.render(body) }); + const storeRawDescription = frontmatter['description']; + const storeDescription = typeof storeRawDescription === 'string' ? storeRawDescription : undefined; + rootStoreBodyByKindName.set(key, { + displayName: resolvedDisplayName, + body, + bodyHtml: md.render(body), + ...(storeDescription !== undefined ? { description: storeDescription } : {}), + }); } catch (err) { globalErrors.push({ ruleId: 'parse.invalid_yaml', @@ -750,6 +787,7 @@ export async function parseFlows(modelDir: string): Promise<FlowParseResult> { rootExternals, rootStoreBodyByKindName, globalErrors, + indexFileName, ); diagrams.push(diagram); } @@ -759,7 +797,15 @@ export async function parseFlows(modelDir: string): Promise<FlowParseResult> { // defined externals regardless of which ones each diagram references. const rootExternalsList: FlowExternal[] = []; for (const [name, def] of rootExternals) { - rootExternalsList.push({ id: name, label: def.label, kind: def.kind, body: def.body, bodyHtml: def.bodyHtml, flowId: '' }); + rootExternalsList.push({ + id: name, + label: def.label, + kind: def.kind, + body: def.body, + bodyHtml: def.bodyHtml, + flowId: '', + ...(def.description !== undefined ? { description: def.description } : {}), + }); } const rawFlowModel: FlowModel = { diagrams, modelDir, externals: rootExternalsList }; diff --git a/src/model/parse.ts b/src/model/parse.ts index 1e82bee..6594e3c 100644 --- a/src/model/parse.ts +++ b/src/model/parse.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs'; +import { basename } from 'node:path'; import { parse as parseYaml } from 'yaml'; import MarkdownIt from 'markdown-it'; import { defaultTheme, mergeTheme, type ThemeConfig } from '../theme/theme-defaults'; @@ -39,6 +40,7 @@ type SubtypeClusterDef = { type Frontmatter = { // Optional so the per-file try/catch can detect parse.missing_id at runtime. entity?: string; + description?: string; // Optional in CP-1 for backward compat; CP-2 will remove hand-authored values. // Used only as a legacy Classifier signal — parser derives all other values. classification?: string; @@ -61,7 +63,7 @@ type Frontmatter = { examples?: Record<string, unknown>[]; }; -export type GroupConfig = { label: string; color: string; desc?: string; sort_key?: number }; +export type GroupConfig = { label: string; color: string; desc?: string; description?: string; sort_key?: number }; export type Cardinality = '1' | '0..1' | 'many'; @@ -69,12 +71,15 @@ export type ModelNode = { id: string; classification: string; group?: string; + /** Path to the source file, relative to the model root, e.g. `data/catalog/Product.md`. Always set by `parseModels`; optional only for hand-built test fixtures. */ + sourcePath?: string; pk: string[]; columns: Record<string, ColumnDef>; alternateKeys: { rule: string; columns: string[] }[]; bodyHtml: string; /** Entity ids referenced via `[[…]]` wiki-links in the body, in source order. */ bodyLinks?: string[]; + description?: string; examples?: Record<string, unknown>[]; /** singleton: true marks a one-row entity (config/settings); suppresses entity.missing_pk. */ singleton?: boolean; @@ -100,11 +105,17 @@ export type SubtypeCluster = { export type { ThemeConfig } from '../theme/theme-defaults'; export type { Branding } from '../theme/branding-defaults'; +export type HarnessMode = 'auto' | 'claude' | 'agents' | 'both'; + +const HARNESS_MODES: readonly HarnessMode[] = ['auto', 'claude', 'agents', 'both']; + export type ModelMeta = { name?: string; version?: string; desc?: string; updated?: string; + indexFile?: string; + harness?: HarnessMode; /** Loaded from ignatius.yml `flow_rules:` block; passed to validateFlows. */ flowRules?: import('../flows/flow-validate').FlowRulesConfig; }; @@ -119,6 +130,10 @@ export type Model = { _meta?: ModelMeta; }; +function isHarnessMode(x: string): x is HarnessMode { + return HARNESS_MODES.some(mode => mode === x); +} + function parseFrontmatter(content: string): { frontmatter: Frontmatter; body: string } { const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) throw new Error('No YAML frontmatter found'); @@ -169,17 +184,21 @@ export async function parseModels(dir: string): Promise<ParseResult> { let theme: ThemeConfig = defaultTheme; let branding: Branding = defaultBranding; let _meta: ModelMeta | undefined; + const globalErrors: GlobalError[] = []; const configFile = Bun.file(`${dir}/ignatius.yml`); if (await configFile.exists()) { const parsed: unknown = parseYaml(await configFile.text()); const raw: Record<string, unknown> = isRecord(parsed) ? parsed : {}; // Meta lives at top-level keys (name, version, description, updated) - const { name, version, description, updated, theme: themeRaw, branding: brandingRaw, flow_rules: flowRulesRaw } = raw; + const { name, version, description, updated, theme: themeRaw, branding: brandingRaw, flow_rules: flowRulesRaw, index_file: indexFileRaw, harness: harnessRaw } = raw; const metaName = typeof name === 'string' ? name : undefined; const metaVersion = typeof version === 'string' ? version : undefined; const metaDescription = typeof description === 'string' ? description : undefined; const metaUpdated = typeof updated === 'string' ? updated : undefined; + const metaIndexFile = typeof indexFileRaw === 'string' ? indexFileRaw : undefined; + const metaHarness = + typeof harnessRaw === 'string' && isHarnessMode(harnessRaw) ? harnessRaw : undefined; // Load flow_rules: block into _meta.flowRules const flowRules: import('../flows/flow-validate').FlowRulesConfig | undefined = isRecord(flowRulesRaw) @@ -189,13 +208,31 @@ export async function parseModels(dir: string): Promise<ParseResult> { : {}), } : undefined; + if (metaIndexFile !== undefined && !metaIndexFile.endsWith('.md')) { + globalErrors.push({ + ruleId: 'config.index_file_ext', + severity: 'error', + omitted: { kind: 'file', id: `${dir}/ignatius.yml` }, + reason: `"index_file" value "${metaIndexFile}" must end in ".md".`, + }); + } + if (metaIndexFile !== undefined && (metaIndexFile.includes('/') || metaIndexFile.includes('\\') || metaIndexFile.includes('..'))) { + globalErrors.push({ + ruleId: 'config.index_file_path', + severity: 'error', + omitted: { kind: 'file', id: `${dir}/ignatius.yml` }, + reason: `"index_file" value "${metaIndexFile}" must be a bare filename, not a path.`, + }); + } // _meta is only populated when at least one meta key is present; remains undefined if all are absent - if (metaName !== undefined || metaVersion !== undefined || metaDescription !== undefined || metaUpdated !== undefined || flowRules !== undefined) { + if (metaName !== undefined || metaVersion !== undefined || metaDescription !== undefined || metaUpdated !== undefined || flowRules !== undefined || metaIndexFile !== undefined || metaHarness !== undefined) { _meta = { ...(metaName !== undefined ? { name: metaName } : {}), ...(metaVersion !== undefined ? { version: metaVersion } : {}), ...(metaDescription !== undefined ? { desc: metaDescription } : {}), ...(metaUpdated !== undefined ? { updated: metaUpdated } : {}), + ...(metaIndexFile !== undefined ? { indexFile: metaIndexFile } : {}), + ...(metaHarness !== undefined ? { harness: metaHarness } : {}), ...(flowRules !== undefined ? { flowRules } : {}), }; } @@ -212,14 +249,17 @@ export async function parseModels(dir: string): Promise<ParseResult> { } } + const indexFileName = _meta?.indexFile ?? 'index.md'; + const groups: Record<string, GroupConfig> = {}; const groupsDir = `${dir}/groups`; const groupGlob = new Bun.Glob('*.md'); if (existsSync(groupsDir)) for await (const path of groupGlob.scan(groupsDir)) { + if (path === indexFileName) continue; const name = path.replace(/\.md$/, ''); const content = await Bun.file(`${groupsDir}/${path}`).text(); const { frontmatter, body } = parseFrontmatter(content); - const fm = frontmatter as unknown as { label: string; color: string; sort_key?: unknown }; + const fm = frontmatter as unknown as { label: string; color: string; description?: unknown; sort_key?: unknown }; if (fm.sort_key !== undefined && typeof fm.sort_key !== 'number') { throw new Error(`Group "${name}": sort_key must be a number, got ${JSON.stringify(fm.sort_key)}`); } @@ -227,6 +267,7 @@ export async function parseModels(dir: string): Promise<ParseResult> { label: fm.label, color: fm.color, desc: md.render(body), + ...(typeof fm.description === 'string' ? { description: fm.description } : {}), ...(fm.sort_key !== undefined ? { sort_key: fm.sort_key } : {}), }; } @@ -252,11 +293,37 @@ export async function parseModels(dir: string): Promise<ParseResult> { const rawNodes: RawNode[] = []; const rawEdges: RawEdge[] = []; const subtypeClusters: SubtypeCluster[] = []; - const globalErrors: GlobalError[] = []; if (existsSync(dataDir)) for await (const path of glob.scan(dataDir)) { const filePath = `${dataDir}/${path}`; + if (basename(path) === indexFileName) { + const reservedContent = await Bun.file(filePath).text(); + if (/^---\n[\s\S]*?\n---\n/.test(reservedContent)) { + let reservedFrontmatter: Frontmatter; + try { + reservedFrontmatter = parseFrontmatter(reservedContent).frontmatter; + } catch (err) { + globalErrors.push({ + ruleId: 'parse.invalid_yaml', + severity: 'error', + omitted: { kind: 'entity', id: filePath }, + reason: `Cannot parse YAML frontmatter in "${filePath}": ${err instanceof Error ? err.message : String(err)}`, + }); + continue; + } + if (reservedFrontmatter?.entity) { + globalErrors.push({ + ruleId: 'config.index_file_entity', + severity: 'error', + omitted: { kind: 'entity', id: filePath }, + reason: `File "${filePath}" is the reserved index file ("${indexFileName}") but declares an "entity" field. Reserved index filenames cannot be entity files — rename the entity file or change "index_file" in ignatius.yml.`, + }); + } + } + continue; + } + let frontmatter: Frontmatter; let body: string; try { @@ -303,11 +370,13 @@ export async function parseModels(dir: string): Promise<ParseResult> { referenceFlag: frontmatter.reference === true, singleton: frontmatter.singleton === true, group: frontmatter.group, + sourcePath: `data/${path}`, // Default pk to [] and columns to {} when absent pk: frontmatter.pk ?? [], columns: frontmatter.columns ?? {}, alternateKeys: frontmatter.ak ?? [], body, + ...(typeof frontmatter.description === 'string' ? { description: frontmatter.description } : {}), ...(frontmatter.examples !== undefined ? { examples: frontmatter.examples } : {}), }); @@ -410,11 +479,13 @@ export async function parseModels(dir: string): Promise<ParseResult> { id: rawNode.id, classification: deriveClassification(rawNode), group: rawNode.group, + sourcePath: rawNode.sourcePath, pk: rawNode.pk, columns: rawNode.columns, alternateKeys: rawNode.alternateKeys, bodyHtml, bodyLinks: env.links, + ...(rawNode.description !== undefined ? { description: rawNode.description } : {}), ...(rawNode.examples !== undefined ? { examples: rawNode.examples } : {}), ...(rawNode.singleton ? { singleton: true } : {}), }; diff --git a/src/model/validate.ts b/src/model/validate.ts index b672974..248f336 100644 --- a/src/model/validate.ts +++ b/src/model/validate.ts @@ -25,6 +25,14 @@ export type RuleId = | 'parse.invalid_yaml' | 'parse.missing_id' | 'parse.empty_frontmatter' + // config (model index routing) + | 'config.index_file_ext' + | 'config.index_file_path' + | 'config.index_file_entity' + // router index drift + | 'index.stale' + | 'index.orphaned' + | 'index.unreadable_target' // entity (CP-1) | 'entity.missing_pk' | 'entity.missing_columns' @@ -165,6 +173,36 @@ export const RULES: Record<RuleId, RuleEntry> = { explanation: 'The file has YAML fences (`---`) but no content between them. Every entity file must have frontmatter with at least an `entity` field. The file is excluded from the model.', class: 'B', }, + 'config.index_file_ext': { + title: 'index_file must end in .md', + explanation: '`ignatius.yml`\'s `index_file` value does not end in ".md". The router writer only ever produces markdown files; correct the value.', + class: 'B', + }, + 'config.index_file_path': { + title: 'index_file must be a bare filename', + explanation: '`ignatius.yml`\'s `index_file` value contains a path separator or "..". It must be a bare filename (e.g. "index.md"), matched by basename in every organizing folder, not a path.', + class: 'B', + }, + 'config.index_file_entity': { + title: 'Reserved index filename used as an entity file', + explanation: 'A file under `data/` matches the configured `index_file` name but declares an `entity` field. Its frontmatter is read to detect this, and once detected the entity is dropped from the model. Rename the entity file or change `index_file` in `ignatius.yml`.', + class: 'B', + }, + 'index.stale': { + title: 'Router digest is out of date', + explanation: 'A generated router\'s stored digest no longer matches the folder it describes — the model changed since `ignatius index` last ran. Run `ignatius index` to regenerate.', + class: 'B', + }, + 'index.orphaned': { + title: 'Orphaned router file', + explanation: 'A file carrying an <ignatius-index> region sits in this folder under a name other than the configured index_file. It is likely left behind by a prior index_file change and can be deleted.', + class: 'A', + }, + 'index.unreadable_target': { + title: 'Router target could not be read', + explanation: 'A file a router row points at could not be read while recomputing digests, so its digest cannot be trusted. Fix file access (or remove the stale reference) and run `ignatius index` again.', + class: 'B', + }, 'edge.unknown_target': { title: 'Edge target not in model', explanation: 'An edge references a target entity that does not exist in the model. The dangling edge is stripped from the cleaned model. Add the missing entity file or correct the target name.', @@ -572,6 +610,95 @@ export function validateModel(model: Model): ValidationResult { }; } +// --------------------------------------------------------------------------- +// validateIndex — router digest drift (SC9) +// +// Unlike `validateModel`, this one does real I/O: it reuses `buildRouters` to +// recompute every folder's digest from the current files, so the comparison +// can never drift from what `ignatius index` actually writes. Dynamic imports +// keep `router/build` and `node:fs` out of this module's static graph, since +// `validateModel`/`RULES` are also imported by the browser-side app. +// --------------------------------------------------------------------------- + +export type IndexValidationResult = { + globalErrors: GlobalError[]; + entityErrors: EntityError[]; +}; + +const STORED_DIGEST_RE = /^<ignatius-index[\s>][^>]*\sdigest="([^"]*)"/m; + +export async function validateIndex( + root: string, + model: Model, + flowModel: import('../flows/flow-parse').FlowModel, +): Promise<IndexValidationResult> { + const { buildRouters } = await import('../router/build'); + const { readFileSync, readdirSync } = await import('node:fs'); + + const unreadable: import('../router/build').UnreadableTarget[] = []; + const routers = await buildRouters(root, model, flowModel, unreadable); + + const globalErrors: GlobalError[] = unreadable.map(target => ({ + ruleId: 'index.unreadable_target', + severity: 'error', + omitted: { kind: 'file', id: target.path }, + reason: `Could not read router target for digest computation: ${target.message}`, + })); + const entityErrors: EntityError[] = []; + + const indexFile = model._meta?.indexFile ?? 'index.md'; + + for (const file of routers) { + const path = `${root}/${file.relPath}`; + let content: string | null; + try { + content = readFileSync(path, 'utf8'); + } catch { + content = null; + } + const storedDigest = content?.match(STORED_DIGEST_RE)?.[1] ?? null; + + if (storedDigest !== file.digest) { + globalErrors.push({ + ruleId: 'index.stale', + severity: 'error', + omitted: { kind: 'file', id: file.relPath }, + reason: content === null + ? `Router '${file.relPath}' has not been generated yet — run \`ignatius index\`.` + : `Router '${file.relPath}' digest no longer matches the current model — run \`ignatius index\` to regenerate.`, + }); + } + + const relDir = file.attrs.path === '.' ? '' : file.attrs.path; + const dir = relDir === '' ? root : `${root}/${relDir}`; + const expectedBasename = file.relPath.slice(file.relPath.lastIndexOf('/') + 1); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + entries = []; + } + for (const entry of entries) { + if (entry === expectedBasename) continue; + let entryContent: string; + try { + entryContent = readFileSync(`${dir}/${entry}`, 'utf8'); + } catch { + continue; + } + if (!/^<ignatius-index[\s>]/m.test(entryContent)) continue; + entityErrors.push({ + ruleId: 'index.orphaned', + entityId: relDir === '' ? entry : `${relDir}/${entry}`, + severity: 'warning', + message: `'${entry}' carries an <ignatius-index> region but does not match the configured index_file ('${indexFile}') — likely left behind by a prior index_file change.`, + }); + } + } + + return { globalErrors, entityErrors }; +} + // --------------------------------------------------------------------------- // CLI stderr helper // --------------------------------------------------------------------------- diff --git a/src/router/agents.ts b/src/router/agents.ts new file mode 100644 index 0000000..77eb758 --- /dev/null +++ b/src/router/agents.ts @@ -0,0 +1,191 @@ +/** + * agents.ts — `AGENTS.md` / `CLAUDE.md` shim / `SKILL.md` content, written + * into the model root only. Every file carries no entity, column, or + * relationship content: name, description, the router filename, the + * key-style convention, the `[[Entity]]` body rule, and the root digest are + * the only payload, so adding entities never touches these files beyond a + * count and a digest. + * + * `AGENTS.md` and the `CLAUDE.md` shim are entirely region-owned, via + * `replaceRegion`. `SKILL.md` is the one exception (spec SC12): the harness + * needs `name`/`description` frontmatter to discover the skill, so the + * generator owns that block directly and puts everything else in a region. + */ + +import { stringify as stringifyYaml } from 'yaml'; +import type { Model, ModelEdge, ModelNode } from '../model/parse'; +import { replaceRegion } from './region'; + +export type KeyStyle = 'key-inherited' | 'orm-oriented' | 'mixed' | 'undetermined'; + +/** + * A model's roots (entities nothing else migrates a key from, and nothing + * else declares as its parent) always carry a plain surrogate PK, even in an + * otherwise pure key-inherited model — `Party` in `models/key-inherited` is + * exactly this. So a single root or two is noise, not a second convention; + * `MIXED_SHARE_THRESHOLD` is the point past which the minority signature is + * common enough to call the model genuinely mixed rather than pure-with-roots. + */ +const MIXED_SHARE_THRESHOLD = 0.2; + +/** + * Derived from PK shape, not from `ModelEdge.identifying` alone: a + * non-identifying edge into a reference/catalog table (a line item pointing + * at its `Product`) is normal in a healthy key-inherited model, so an + * entity's outgoing edges being non-identifying doesn't by itself mean + * "surrogate" — every real key-inherited model has a mix of both. What does + * discriminate is PK shape, checked structurally rather than by column name: + * a composite PK is always the parent's key columns migrated in. A + * single-column PK is a migrated key only if that entity is the source of an + * identifying edge (its own PK column was contributed by a parent); a + * single-column PK on an entity that sources no identifying edge is a + * surrogate, whatever the column is named — `id`, `artifact_id`, `code`, or + * anything else a real schema's naming convention produces. Classifier/lookup + * entities (`reference: true`) are excluded outright: an enum table's PK + * shape says nothing about how the model's real entities key themselves. + */ +export function deriveKeyStyle(nodes: ModelNode[], edges: ModelEdge[]): KeyStyle { + if (nodes.length === 0) return 'undetermined'; + const identifyingSources = new Set(edges.filter(e => e.identifying).map(e => e.source)); + const relevant = nodes.filter(n => n.classification !== 'Classifier'); + const compositeCount = relevant.filter(n => n.pk.length > 1).length; + const singlePkNodes = relevant.filter(n => n.pk.length === 1); + const migratedSingleCount = singlePkNodes.filter(n => identifyingSources.has(n.id)).length; + const surrogateCount = singlePkNodes.length - migratedSingleCount; + const keyInheritedCount = compositeCount + migratedSingleCount; + const total = keyInheritedCount + surrogateCount; + if (total === 0) return 'undetermined'; + const minorityShare = Math.min(keyInheritedCount, surrogateCount) / total; + if (minorityShare >= MIXED_SHARE_THRESHOLD) return 'mixed'; + return surrogateCount > keyInheritedCount ? 'orm-oriented' : 'key-inherited'; +} + +function keyStyleSentence(style: KeyStyle): string { + switch (style) { + case 'key-inherited': + return 'key-inherited. A child entity carries its parent\'s key columns inside its own primary key.'; + case 'orm-oriented': + return 'orm-oriented. Entities use a surrogate `id` primary key, with foreign keys held outside the primary key.'; + case 'mixed': + return 'mixed. Some entities carry a parent-inherited primary key and others carry a surrogate primary key with foreign keys held outside it, so no single convention holds here. Check each entity\'s `pk:` before assuming one.'; + case 'undetermined': + return 'not determinable. This model declares no relationships to derive a convention from.'; + } +} + +export function buildAgentsGuide(model: Model, indexFile: string, keyStyle: KeyStyle, rootDigest: string): string { + const name = model._meta?.name ?? 'This model'; + const description = model._meta?.desc; + const lines = [ + `# ${name}`, + '', + ...(description ? [description, ''] : []), + '## Walking this model', + '', + `Start at [${indexFile}](${indexFile}). Every router lists its folder's`, + 'children by name, kind, description, and a link. A row whose Kind is', + '`folder` leads to another router; any other Kind is a leaf with real', + 'content: an entity, a flow process, an external, or a store.', + '', + '## Conventions', + '', + `- Key style: ${keyStyleSentence(keyStyle)}`, + '- `[[Entity]]` inside a body is a cross-reference to another entity file.', + " It is not a router link, and it resolves only in the app's viewer.", + '', + '## Currency', + '', + 'This guide and the routers were generated together. Root router digest:', + `\`${rootDigest}\`. Run \`ignatius validate --index\` to check whether the`, + 'model has drifted since.', + ]; + return lines.join('\n'); +} + +export function buildClaudeShim(): string { + return [ + '@AGENTS.md', + '', + 'This folder is a data model, generated by ignatius. Read AGENTS.md', + 'before editing anything under it.', + ].join('\n'); +} + +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'model'; +} + +export function buildSkillMeta(model: Model): { name: string; description: string } { + const modelName = model._meta?.name ?? 'Model'; + const groupNames = Object.keys(model.groups).sort(); + const entityCount = model.nodes.length; + const groupList = groupNames.length > 0 ? ` (${groupNames.join(', ')})` : ''; + const description = + `Data model for ${modelName}: ${entityCount} ${entityCount === 1 ? 'entity' : 'entities'} ` + + `across ${groupNames.length} group${groupNames.length === 1 ? '' : 's'}${groupList}. ` + + `Use when asked about ${modelName}'s schema, entities, or data flows.`; + return { name: slugify(modelName), description }; +} + +export function buildSkillBody(indexFile: string): string { + return [ + '# Model skill', + '', + 'See [AGENTS.md](AGENTS.md) for how to walk this model, starting at', + `[${indexFile}](${indexFile}).`, + ].join('\n'); +} + +async function readIfExists(path: string): Promise<string> { + return (await Bun.file(path).exists()) ? await Bun.file(path).text() : ''; +} + +/** Splits a file into its `---`-delimited YAML frontmatter block (if any) and the remaining body. */ +function splitFrontmatter(content: string): { frontmatter: string | null; body: string } { + const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/); + if (!match) return { frontmatter: null, body: content }; + return { frontmatter: match[0], body: content.slice(match[0].length) }; +} + +/** + * Writes `AGENTS.md`, `SKILL.md`, and, when `writeClaude`, the `CLAUDE.md` + * shim into `root` only. Each write is region-scoped: existing bytes outside + * the generator's own region (a hand-written `CLAUDE.md`'s prose, for + * instance) survive verbatim. + */ +export async function writeGuidance(root: string, model: Model, rootDigest: string, writeClaude: boolean): Promise<void> { + const indexFile = model._meta?.indexFile ?? 'index.md'; + const keyStyle = deriveKeyStyle(model.nodes, model.edges); + + const withPathPrefix = <T>(relPath: string, fn: () => T): T => { + try { + return fn(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`${relPath}: ${message}`); + } + }; + + const agentsPath = `${root}/AGENTS.md`; + const agentsExisting = await readIfExists(agentsPath); + const guide = buildAgentsGuide(model, indexFile, keyStyle, rootDigest); + await Bun.write(agentsPath, withPathPrefix('AGENTS.md', () => replaceRegion(agentsExisting, 'ignatius-guide', {}, guide))); + + const skillPath = `${root}/SKILL.md`; + const skillExisting = await readIfExists(skillPath); + const { body: skillBodyExisting } = splitFrontmatter(skillExisting); + const normalizedBody = skillBodyExisting.replace(/^(\r?\n)+/, ''); + const { name, description } = buildSkillMeta(model); + const frontmatter = stringifyYaml({ name, description }).trimEnd(); + const skillBody = withPathPrefix('SKILL.md', () => replaceRegion(normalizedBody, 'ignatius-skill', {}, buildSkillBody(indexFile))); + await Bun.write(skillPath, `---\n${frontmatter}\n---\n\n${skillBody}`); + + if (!writeClaude) return; + const claudePath = `${root}/CLAUDE.md`; + const claudeExisting = await readIfExists(claudePath); + const shim = buildClaudeShim(); + await Bun.write(claudePath, withPathPrefix('CLAUDE.md', () => replaceRegion(claudeExisting, 'ignatius-claude-shim', {}, shim))); +} diff --git a/src/router/build.ts b/src/router/build.ts new file mode 100644 index 0000000..160f796 --- /dev/null +++ b/src/router/build.ts @@ -0,0 +1,359 @@ +/** + * build.ts — model + parsed flow tree in, RouterFile[] out. + * + * The `data/` tree mirrors each entity's resolved `sourcePath` on disk, + * never the declared `group:` field (SC5a) — see `buildDataTree`. `flows/` + * mirrors `flowModel.diagrams` (recursive `subDfds`). No separate filesystem + * walk carries any information these two structures don't already have. + * + * The one read this module performs is `hashFile` on each leaf's bytes, since + * a row's fingerprint (spec SC7) has to be real content, not derived state. + * It never writes — `write.ts` owns every byte on disk. + */ + +import type { Model, ModelNode } from '../model/parse'; +import type { FlowDiagram, FlowModel, FlowStoreRef } from '../flows/flow-parse'; +import { hashFile, folderDigest, type RouterNode } from './fingerprint'; + +export type RouterFile = { + /** Path to the router file, relative to the model root, e.g. `data/identity/index.md`. */ + relPath: string; + /** The `↑` line above the region, or `''` for the root file (no ancestors). */ + breadcrumb: string; + attrs: { scope: string; path: string; count: string; depth: string; digest: string }; + table: string; + /** This folder's own digest — the value the parent row for this folder carries. */ + digest: string; +}; + +type Ancestor = { label: string }; + +function pluralize(count: number, singular: string, plural: string = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +function countProcesses(diagrams: FlowDiagram[]): number { + return diagrams.reduce((sum, d) => sum + d.processes.length + countProcesses(d.subDfds), 0); +} + +function breadcrumbFor(ancestors: Ancestor[], indexFile: string): string { + if (ancestors.length === 0) return ''; + const parts = ancestors.map((a, i) => `[${a.label}](${'../'.repeat(i + 1)}${indexFile})`); + return `↑ ${parts.join(' · ')}`; +} + +function renderTable(rows: RouterNode[]): string { + const lines = [ + '| Name | Kind | Description | Go |', + '|---|---|---|---|', + ...rows.map(r => `| ${r.name} | ${r.kind} | ${r.description} | [${r.name}](${r.link}) |`), + ]; + return lines.join('\n'); +} + +function toRouterFile( + relDir: string, + scope: string, + depth: number, + ancestors: Ancestor[], + indexFile: string, + rows: RouterNode[], + prose?: string, +): RouterFile { + const digest = folderDigest(rows.map(r => r.hash)); + return { + relPath: `${relDir}/${indexFile}`.replace(/^\//, ''), + breadcrumb: breadcrumbFor(ancestors, indexFile), + attrs: { scope, path: relDir === '' ? '.' : relDir, count: String(rows.length), depth: String(depth), digest }, + table: prose ? `${prose}\n\n${renderTable(rows)}` : renderTable(rows), + digest, + }; +} + +/** A router row whose target could not be read for hashing — reported so `validate --index` doesn't treat a broken reference as a clean, unchanging digest. */ +export type UnreadableTarget = { path: string; message: string }; + +function hasCode(err: unknown): err is { code: unknown } { + return typeof err === 'object' && err !== null && 'code' in err; +} + +/** + * `hashFile` can throw when a model references a file that's since gone + * missing or unreadable on disk; a router still has to write rather than + * crash on that (SC5a). Every failure is recorded in `unreadable` with the + * real error message rather than silently coerced to a stable sentinel — a + * fixed fake digest would make `validate --index` treat a broken row as + * permanently up to date. Only OS-level read failures are caught; anything + * else propagates. + */ +async function safeHashFile(path: string, unreadable: UnreadableTarget[]): Promise<string> { + try { + return await hashFile(path); + } catch (err) { + if (!hasCode(err)) throw err; + unreadable.push({ path, message: err instanceof Error ? err.message : String(err.code) }); + return 'sha256:unreadable'; + } +} + +/** An entity paired with its confirmed, resolved sourcePath (never undefined). */ +type PlacedNode = { node: ModelNode; sourcePath: string }; + +/** Mirrors the directories under `data/` an entity's sourcePath actually passes through — never the declared `group:` field. */ +type DataDirNode = { entities: PlacedNode[]; children: Map<string, DataDirNode> }; + +function buildDataTree(nodes: ModelNode[]): DataDirNode { + const root: DataDirNode = { entities: [], children: new Map() }; + for (const node of nodes) { + const sourcePath = node.sourcePath; + if (sourcePath === undefined) continue; + const segments = sourcePath.replace(/^data\//, '').split('/'); + segments.pop(); + let dir = root; + for (const segment of segments) { + let child = dir.children.get(segment); + if (!child) { + child = { entities: [], children: new Map() }; + dir.children.set(segment, child); + } + dir = child; + } + dir.entities.push({ node, sourcePath }); + } + return root; +} + +async function buildDataFolder( + root: string, + dirNode: DataDirNode, + relDir: string, + scope: string, + depth: number, + ancestors: Ancestor[], + ownLabel: string, + indexFile: string, + model: Model, + out: RouterFile[], + unreadable: UnreadableTarget[], +): Promise<RouterFile> { + const rows: RouterNode[] = []; + + for (const dirName of [...dirNode.children.keys()].sort()) { + const child = dirNode.children.get(dirName)!; + const childLabel = model.groups[dirName]?.label ?? dirName; + const childFile = await buildDataFolder( + root, + child, + `${relDir}/${dirName}`, + 'entity-group', + depth + 1, + [{ label: ownLabel }, ...ancestors], + childLabel, + indexFile, + model, + out, + unreadable, + ); + rows.push({ + name: dirName, + kind: 'folder', + description: model.groups[dirName]?.description ?? '', + link: `${dirName}/${indexFile}`, + hash: childFile.digest, + }); + } + + for (const { node, sourcePath } of [...dirNode.entities].sort((a, b) => a.node.id.localeCompare(b.node.id))) { + const filename = sourcePath.slice(sourcePath.lastIndexOf('/') + 1); + rows.push({ + name: node.id, + kind: node.classification, + description: node.description ?? '', + link: filename, + hash: await safeHashFile(`${root}/${sourcePath}`, unreadable), + }); + } + + const file = toRouterFile(relDir, scope, depth, ancestors, indexFile, rows); + out.push(file); + return file; +} + +function collectStoreRefs(diagram: FlowDiagram, out: Map<string, FlowStoreRef>): void { + for (const ref of diagram.storeRefs) { + if (!out.has(ref.name)) out.set(ref.name, ref); + } + for (const sub of diagram.subDfds) collectStoreRefs(sub, out); +} + +async function buildFlowFolder( + root: string, + diagram: FlowDiagram, + relDir: string, + depth: number, + ancestors: Ancestor[], + indexFile: string, + out: RouterFile[], + unreadable: UnreadableTarget[], +): Promise<RouterNode> { + const rows: RouterNode[] = []; + + for (const process of diagram.processes) { + const link = `${process.id}.md`; + rows.push({ + name: process.id, + kind: 'process', + description: process.description ?? '', + link, + hash: await safeHashFile(`${root}/${relDir}/${link}`, unreadable), + }); + + if (process.hasSubDfd) { + const subDiagram = diagram.subDfds.find(d => d.id === process.id); + if (subDiagram) { + const subRow = await buildFlowFolder( + root, + subDiagram, + `${relDir}/${process.id}`, + depth + 1, + [{ label: diagram.title }, ...ancestors], + indexFile, + out, + unreadable, + ); + rows.push(subRow); + } + } + } + + const file = toRouterFile(relDir, 'flow-diagram', depth, ancestors, indexFile, rows); + out.push(file); + + return { name: diagram.id, kind: 'folder', description: '', link: `${diagram.id}/${indexFile}`, hash: file.digest }; +} + +export async function buildRouters( + root: string, + model: Model, + flowModel: FlowModel, + unreadable: UnreadableTarget[] = [], +): Promise<RouterFile[]> { + const indexFile = model._meta?.indexFile ?? 'index.md'; + const rootLabel = model._meta?.name ?? 'Model'; + const files: RouterFile[] = []; + + // groups/ + const groupNames = Object.keys(model.groups).sort(); + const groupRows: RouterNode[] = []; + for (const name of groupNames) { + const link = `${name}.md`; + groupRows.push({ + name, + kind: 'group', + description: model.groups[name]?.description ?? '', + link, + hash: await safeHashFile(`${root}/groups/${link}`, unreadable), + }); + } + const groupsFile = toRouterFile('groups', 'groups', 1, [{ label: rootLabel }], indexFile, groupRows); + files.push(groupsFile); + + // data/, mirroring the filesystem an entity's sourcePath actually walks + const dataTree = buildDataTree(model.nodes); + const dataFile = await buildDataFolder(root, dataTree, 'data', 'data', 1, [{ label: rootLabel }], 'Data', indexFile, model, files, unreadable); + + // flows/ and each flow/sub-DFD folder. `parseFlows` wraps the real, + // on-disk top-level diagrams two levels deep — a synthetic context + // diagram, then a synthetic L1 overview — for the flow *viewer*'s + // drill-down (`flow-derive-levels.ts`). Neither synthetic diagram has a + // folder on disk; the router walks their grandchildren, the actual + // `flows/<id>/` diagrams the parser read from files. + const topLevelDiagrams = flowModel.diagrams[0]?.subDfds[0]?.subDfds ?? []; + const flowsRows: RouterNode[] = []; + for (const diagram of topLevelDiagrams) { + const row = await buildFlowFolder(root, diagram, `flows/${diagram.id}`, 2, [{ label: 'Flows' }, { label: rootLabel }], indexFile, files, unreadable); + flowsRows.push(row); + } + const flowsFile = toRouterFile('flows', 'flows', 1, [{ label: rootLabel }], indexFile, flowsRows); + files.push(flowsFile); + + // externals/ — the full root registry, not just flows-referenced externals + const externalRows: RouterNode[] = []; + for (const ext of [...flowModel.externals].sort((a, b) => a.id.localeCompare(b.id))) { + const link = `${ext.id}.md`; + externalRows.push({ + name: ext.id, + kind: 'external', + description: ext.description ?? '', + link, + hash: await safeHashFile(`${root}/externals/${link}`, unreadable), + }); + } + const externalsFile = toRouterFile('externals', 'externals', 1, [{ label: rootLabel }], indexFile, externalRows); + files.push(externalsFile); + + // stores/ — a FlowStoreRef also covers `db:<Entity>` tokens (real entities, + // already routed under data/) and undefined store tokens the validator + // flags separately. Only a ref with a `body` was actually read from a + // stores/*.md file, so that's the filter for "this row belongs here". + const storeRefs = new Map<string, FlowStoreRef>(); + for (const diagram of flowModel.diagrams) collectStoreRefs(diagram, storeRefs); + const fileBackedStoreRefs = [...storeRefs.values()].filter(ref => ref.body !== undefined); + const storeRows: RouterNode[] = []; + for (const ref of fileBackedStoreRefs.sort((a, b) => a.name.localeCompare(b.name))) { + const link = `${ref.name}.md`; + storeRows.push({ + name: ref.name, + kind: 'store', + description: ref.description ?? '', + link, + hash: await safeHashFile(`${root}/stores/${link}`, unreadable), + }); + } + const storesFile = toRouterFile('stores', 'stores', 1, [{ label: rootLabel }], indexFile, storeRows); + files.push(storesFile); + + // root — the five sections are fixed by the folder-model spec, so their + // meaning is the same in every model; only the counts are model-specific. + const rootRows: RouterNode[] = [ + { + name: 'Groups', + kind: 'folder', + description: `The subject-area registry that labels and colors entities. ${pluralize(groupNames.length, 'group')}.`, + link: `groups/${indexFile}`, + hash: groupsFile.digest, + }, + { + name: 'Data', + kind: 'folder', + description: `The entity model. ${pluralize(model.nodes.length, 'entity', 'entities')} across ${pluralize(groupNames.length, 'group')}.`, + link: `data/${indexFile}`, + hash: dataFile.digest, + }, + { + name: 'Flows', + kind: 'folder', + description: `Data flow diagrams. ${pluralize(topLevelDiagrams.length, 'flow')}, ${pluralize(countProcesses(topLevelDiagrams), 'process', 'processes')}.`, + link: `flows/${indexFile}`, + hash: flowsFile.digest, + }, + { + name: 'Externals', + kind: 'folder', + description: `Actors outside the system boundary that DFDs exchange data with. ${pluralize(flowModel.externals.length, 'external')}.`, + link: `externals/${indexFile}`, + hash: externalsFile.digest, + }, + { + name: 'Stores', + kind: 'folder', + description: `Non-database stores DFDs read and write. ${pluralize(fileBackedStoreRefs.length, 'store')}.`, + link: `stores/${indexFile}`, + hash: storesFile.digest, + }, + ]; + const rootFile = toRouterFile('', 'root', 0, [], indexFile, rootRows, model._meta?.desc); + files.push(rootFile); + + return files; +} diff --git a/src/router/detect.ts b/src/router/detect.ts new file mode 100644 index 0000000..3a51dbd --- /dev/null +++ b/src/router/detect.ts @@ -0,0 +1,33 @@ +/** + * detect.ts — resolves `harness:` config plus an ancestor filesystem probe + * into whether `--agents` should write the `CLAUDE.md` shim. `AGENTS.md` and + * `SKILL.md` are unconditional; only the shim depends on this. + */ + +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import type { HarnessMode } from '../model/parse'; + +/** Walks from `root` up to the filesystem root, stopping at the first `.claude/` directory or `CLAUDE.md` file. */ +function ancestorHasClaudeMarker(root: string): boolean { + let dir = resolve(root); + while (true) { + if (existsSync(`${dir}/.claude`) || existsSync(`${dir}/CLAUDE.md`)) return true; + const parent = dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** Whether `--agents` should write the `CLAUDE.md` shim, per `harness:` (default `auto`). */ +export function resolveHarness(root: string, mode: HarnessMode | undefined): boolean { + switch (mode ?? 'auto') { + case 'claude': + case 'both': + return true; + case 'agents': + return false; + case 'auto': + return ancestorHasClaudeMarker(root); + } +} diff --git a/src/router/fingerprint.ts b/src/router/fingerprint.ts new file mode 100644 index 0000000..6e8f0ee --- /dev/null +++ b/src/router/fingerprint.ts @@ -0,0 +1,33 @@ +/** + * fingerprint.ts — SHA-256 primitives behind the router's digest roll-up. + * Every row hashes its target's raw bytes (not normalized text, so a + * whitespace-only edit still dirties the digest); a folder digest hashes + * its ordered row-hash list, so a leaf change propagates to its folder and + * every ancestor, and to no sibling. + */ + +function sha256Hex(bytes: Uint8Array | string): string { + const hasher = new Bun.CryptoHasher('sha256'); + hasher.update(bytes); + return hasher.digest('hex'); +} + +/** SHA-256 of a file's raw bytes, as `sha256:<hex>`. */ +export async function hashFile(path: string): Promise<string> { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + return `sha256:${sha256Hex(bytes)}`; +} + +/** SHA-256 of an ordered row-hash list, as `sha256:<hex>`. Order-sensitive. */ +export function folderDigest(rowHashes: string[]): string { + return `sha256:${sha256Hex(rowHashes.join('\n'))}`; +} + +/** One router row: a child folder or a leaf entity/process/external/store. */ +export interface RouterNode { + name: string; + kind: string; + description: string; + link: string; + hash: string; +} diff --git a/src/router/region.ts b/src/router/region.ts new file mode 100644 index 0000000..a944a97 --- /dev/null +++ b/src/router/region.ts @@ -0,0 +1,134 @@ +/** + * region.ts — parse and rewrite `<ignatius-*>` managed regions in a markdown + * file. The generator owns only the bytes between its own tags; everything + * else in the file, including a hand-authored `<ignatius-rules>` block, + * survives untouched. + * + * A tag is a region boundary only when it starts at column 0 and ends its + * own line. Markdown pushes every prose container (a paragraph, a list item, + * a fenced example) off column 0, and a fence or a code span is just + * characters a stray backtick can produce by accident — position is the one + * thing that can't be spoofed. Everything else on a line, however tag-shaped, + * is text. + */ + +const TAG_RE = /^<(\/?)(ignatius-[\w-]+)([^<>]*)>[ \t]*$/gm; + +type Token = { close: boolean; name: string; start: number; end: number; line: number }; +type Region = { name: string; start: number; end: number; inner: string; line: number }; + +function lineOf(content: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (content.charCodeAt(i) === 10) line++; + } + return line; +} + +function hint(token: Token): string { + const tag = token.close ? `/${token.name}` : token.name; + return `A line that starts with an <ignatius-*> tag is always a region boundary; to mention a tag as text, indent the line or write <${tag}>.`; +} + +function tokenize(content: string): Token[] { + const tokens: Token[] = []; + for (const match of content.matchAll(TAG_RE)) { + if (match.index === undefined) continue; + const [whole, slash, name] = match; + tokens.push({ close: slash === '/', name, start: match.index, end: match.index + whole.length, line: lineOf(content, match.index) }); + } + return tokens; +} + +function trimOneBlankLine(text: string, side: 'lead' | 'trail'): string { + const re = side === 'lead' ? /^\r?\n(?:\r?\n)?/ : /\r?\n(?:\r?\n)?$/; + return text.replace(re, ''); +} + +/** + * Pair every column-0 `<ignatius-*>`/`</ignatius-*>` token in the file and + * return the resulting regions. Throws on the first boundary arrangement + * that isn't a clean alternation of open/close pairs with matching names. + */ +function regions(content: string): Region[] { + const out: Region[] = []; + let open: Token | null = null; + + for (const token of tokenize(content)) { + if (!token.close) { + if (open) { + throw new Error( + `<${token.name}> at line ${token.line} opens inside <${open.name}> (opened at line ${open.line}). Regions cannot nest. ${hint(token)}`, + ); + } + open = token; + continue; + } + + if (!open) { + throw new Error( + `</${token.name}> at line ${token.line} has no opening <${token.name}> above it. Remove it, or restore the opening tag. ${hint(token)}`, + ); + } + + if (open.name !== token.name) { + throw new Error( + `</${token.name}> at line ${token.line} closes <${open.name}> (opened at line ${open.line}). The closing tag must be </${open.name}>.`, + ); + } + + const inner = trimOneBlankLine(trimOneBlankLine(content.slice(open.end, token.start), 'lead'), 'trail'); + out.push({ name: open.name, start: open.start, end: token.end, inner, line: open.line }); + open = null; + } + + if (open) { + throw new Error( + `<${open.name}> at line ${open.line} has no closing </${open.name}>. Add the closing tag on its own line at column 0, or delete the file if it is a leftover partial write.`, + ); + } + + const firstLineOf = new Map<string, number>(); + for (const region of out) { + const seenAt = firstLineOf.get(region.name); + if (seenAt !== undefined) { + throw new Error(`two <${region.name}> regions (lines ${seenAt} and ${region.line}). Keep one.`); + } + firstLineOf.set(region.name, region.line); + } + + return out; +} + +/** Extract a named region's inner content, or null when the region is absent. */ +export function readRegion(content: string, name: string): string | null { + const region = regions(content).find((r) => r.name === name); + return region ? region.inner : null; +} + +/** + * Swap a named region's inner content and attributes in place. Appends the + * region, with the required blank lines, when it does not already exist. + */ +export function replaceRegion( + content: string, + name: string, + attrs: Record<string, string>, + inner: string, +): string { + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const attrStr = Object.entries(attrs) + .map(([key, value]) => `${key}="${value}"`) + .join(' '); + const openTag = attrStr ? `<${name} ${attrStr}>` : `<${name}>`; + const normalizedInner = inner.replace(/\r?\n/g, eol); + const block = `${openTag}${eol}${eol}${normalizedInner}${eol}${eol}</${name}>`; + + const region = regions(content).find((r) => r.name === name); + if (!region) { + const sep = content.length === 0 ? '' : content.endsWith(eol) ? eol : eol + eol; + return `${content}${sep}${block}${eol}`; + } + + return content.slice(0, region.start) + block + content.slice(region.end); +} diff --git a/src/router/write.ts b/src/router/write.ts new file mode 100644 index 0000000..83f0dfe --- /dev/null +++ b/src/router/write.ts @@ -0,0 +1,48 @@ +/** + * write.ts — replaces the `<ignatius-index>` region (and the sibling + * `<ignatius-breadcrumb>` region directly above it) in each `RouterFile`'s + * target path, creating the file when it doesn't exist. Region-scoped + * writes only; bytes outside a generator-owned region survive a run + * untouched. Guidance files (AGENTS.md/CLAUDE.md/SKILL.md) are written by + * `agents.ts`. + */ + +import { readRegion, replaceRegion } from './region'; +import type { RouterFile } from './build'; + +function ensureBreadcrumb(content: string, breadcrumb: string): string { + if (breadcrumb === '') return content; + + if (readRegion(content, 'ignatius-breadcrumb') !== null) { + return replaceRegion(content, 'ignatius-breadcrumb', {}, breadcrumb); + } + + const eol = content.includes('\r\n') ? '\r\n' : '\n'; + const lines = content.split(/\r?\n/); + const tagLineIdx = lines.findIndex(line => /^<ignatius-index[\s>]/.test(line)); + if (tagLineIdx === -1) return content; + + const before = lines.slice(0, tagLineIdx); + const after = lines.slice(tagLineIdx); + + const block = `<ignatius-breadcrumb>${eol}${eol}${breadcrumb}${eol}${eol}</ignatius-breadcrumb>`; + const insertion = before.length > 0 && before[before.length - 1] !== '' ? ['', block, ''] : [block, '']; + return [...before, ...insertion, ...after].join(eol); +} + +export async function writeRouters(root: string, files: RouterFile[]): Promise<void> { + for (const file of files) { + const path = `${root}/${file.relPath}`; + + // Bun.write creates any missing intermediate directories. + const existing = await Bun.file(path).exists() ? await Bun.file(path).text() : ''; + try { + const withRegion = replaceRegion(existing, 'ignatius-index', file.attrs, file.table); + const withBreadcrumb = ensureBreadcrumb(withRegion, file.breadcrumb); + await Bun.write(path, withBreadcrumb); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`${file.relPath}: ${message}`); + } + } +} diff --git a/test/checks/test-description-field.ts b/test/checks/test-description-field.ts new file mode 100644 index 0000000..1a58383 --- /dev/null +++ b/test/checks/test-description-field.ts @@ -0,0 +1,156 @@ +/** + * test-description-field.ts — `description:` frontmatter across the five file kinds. + * + * Verifies SC4: a top-level `description:` string parses on entity, group, + * flow-process, external, and store files and reaches the model; absence is + * never an error. + * + * Generates its fixture at runtime under tmp/ (self-contained, no dependency + * on pre-existing state). + */ + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { assert } from '../assert'; +import { parseModels } from '../../src/model/parse'; +import { parseFlows } from '../../src/flows/flow-parse'; +import type { FlowDiagram } from '../../src/flows/flow-parse'; + +function findDiagramInTree(diagrams: FlowDiagram[], id: string): FlowDiagram | undefined { + for (const d of diagrams) { + if (d.id === id) return d; + const found = findDiagramInTree(d.subDfds, id); + if (found) return found; + } + return undefined; +} + +const MODEL_FIXTURE = 'tmp/description-field-model-fixture'; +const FLOW_FIXTURE = 'tmp/description-field-flow-fixture'; + +// --- entity + group model fixture --- + +rmSync(MODEL_FIXTURE, { recursive: true, force: true }); +mkdirSync(`${MODEL_FIXTURE}/data/identity`, { recursive: true }); +mkdirSync(`${MODEL_FIXTURE}/groups`, { recursive: true }); + +writeFileSync(`${MODEL_FIXTURE}/groups/identity.md`, `--- +label: Identity +color: "#4a90e2" +description: Entities that identify a person or account. +--- + +Group body text. +`); + +writeFileSync(`${MODEL_FIXTURE}/data/identity/Widget.md`, `--- +entity: Widget +description: A thing the system tracks. +pk: [id] +columns: + id: { type: uuid } +--- +`); + +writeFileSync(`${MODEL_FIXTURE}/data/identity/Plain.md`, `--- +entity: Plain +pk: [id] +columns: + id: { type: uuid } +--- +`); + +{ + const { model, globalErrors } = await parseModels(MODEL_FIXTURE); + + assert(globalErrors.length === 0, `FAIL: expected no global errors, got ${JSON.stringify(globalErrors)}`); + + const widget = model.nodes.find(n => n.id === 'Widget'); + assert(widget?.description === 'A thing the system tracks.', `FAIL: Widget.description = ${JSON.stringify(widget?.description)}`); + console.log('PASS: entity description: reaches ModelNode.description'); + + const plain = model.nodes.find(n => n.id === 'Plain'); + assert(plain !== undefined && plain.description === undefined, `FAIL: Plain.description should be undefined, got ${JSON.stringify(plain?.description)}`); + console.log('PASS: entity with no description: parses clean, description is undefined'); + + const identityGroup = model.groups['identity']; + assert(identityGroup?.description === 'Entities that identify a person or account.', `FAIL: groups.identity.description = ${JSON.stringify(identityGroup?.description)}`); + console.log('PASS: group description: reaches GroupConfig.description'); +} + +// --- flow-process, external, store fixture --- + +rmSync(FLOW_FIXTURE, { recursive: true, force: true }); +mkdirSync(`${FLOW_FIXTURE}/flows/sample-dfd`, { recursive: true }); +mkdirSync(`${FLOW_FIXTURE}/externals`, { recursive: true }); +mkdirSync(`${FLOW_FIXTURE}/stores`, { recursive: true }); + +writeFileSync(`${FLOW_FIXTURE}/externals/Buyer.md`, `--- +external: Buyer +description: A customer who places orders. +--- + +Buyer body. +`); + +writeFileSync(`${FLOW_FIXTURE}/externals/Unreferenced.md`, `--- +external: Unreferenced +description: Declared but never used by any flow edge. +--- + +Unreferenced body. +`); + +writeFileSync(`${FLOW_FIXTURE}/stores/hot-cache.md`, `--- +kind: cache +description: In-memory cache for frequently accessed data. +--- + +Cache body. +`); + +writeFileSync(`${FLOW_FIXTURE}/flows/sample-dfd/Handle-Request.md`, `--- +process: Handle Request +description: Validates and stores an incoming request. +number: 1 +inputs: + - from: ext:Buyer + data: request data + - from: cache:hot-cache + data: cached item +outputs: + - to: ext:Buyer + data: response +--- + +Process body. +`); + +{ + const { flowModel, globalErrors } = await parseFlows(FLOW_FIXTURE); + + assert(globalErrors.length === 0, `FAIL: expected no global errors, got ${JSON.stringify(globalErrors)}`); + + const diagram = findDiagramInTree(flowModel.diagrams, 'sample-dfd'); + assert(diagram !== undefined, `FAIL: diagram 'sample-dfd' not found`); + + const proc = diagram!.processes[0]; + assert(proc?.description === 'Validates and stores an incoming request.', `FAIL: process.description = ${JSON.stringify(proc?.description)}`); + console.log('PASS: flow-process description: reaches FlowProcess.description'); + + const ext = diagram!.externals[0]; + assert(ext?.description === 'A customer who places orders.', `FAIL: external.description = ${JSON.stringify(ext?.description)}`); + console.log('PASS: external description: reaches FlowExternal.description'); + + const store = diagram!.storeRefs.find(s => s.kind === 'cache'); + assert(store?.description === 'In-memory cache for frequently accessed data.', `FAIL: store.description = ${JSON.stringify(store?.description)}`); + console.log('PASS: store description: reaches FlowStoreRef.description'); + + const unreferenced = flowModel.externals.find(e => e.id === 'Unreferenced'); + assert( + unreferenced?.description === 'Declared but never used by any flow edge.', + `FAIL: unreferenced external's description = ${JSON.stringify(unreferenced?.description)}`, + ); + console.log('PASS: unreferenced external description: reaches root FlowModel.externals'); +} + +console.log('\nAll description-field tests passed.'); diff --git a/test/checks/test-index-config.ts b/test/checks/test-index-config.ts new file mode 100644 index 0000000..52793f9 --- /dev/null +++ b/test/checks/test-index-config.ts @@ -0,0 +1,201 @@ +// Verification: index_file/harness config keys, reserved-name scan skip, config rules +// Covers: SC1 (config keys + defaults), SC2 (basename skip), SC3 (three config rules) +import { assert } from '../assert'; +import { parseModels } from '../../src/model/parse'; +import { parseFlows } from '../../src/flows/flow-parse'; +import { resolve } from 'path'; +import { mkdirSync, rmSync, writeFileSync } from 'fs'; + +const BASE_TMP = resolve(import.meta.dir, '../../tmp/fixtures/index-config-test'); + +const MINIMAL_ENTITY = (id: string) => `--- +entity: ${id} +pk: [id] +columns: + id: { type: uuid } +--- +`; + +function makeFixtureDir(name: string): string { + const dir = `${BASE_TMP}/${name}`; + rmSync(dir, { recursive: true, force: true }); + mkdirSync(`${dir}/data/identity`, { recursive: true }); + return dir; +} + +// --- (a) neither key present — parses, defaults apply --- +{ + const dir = makeFixtureDir('defaults'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + + const { model, globalErrors } = await parseModels(dir); + + assert(globalErrors.length === 0, `FAIL (a): expected no global errors, got ${JSON.stringify(globalErrors)}`); + const widget = model.nodes[0]; + if (widget === undefined) throw new Error('FAIL (a): expected one node, got none'); + assert(model.nodes.length === 1 && widget.id === 'Widget', 'FAIL (a): Widget should parse'); + console.log('PASS (a): no ignatius.yml — index_file/harness default, model parses clean'); +} + +// --- (b) explicit index_file + harness land on _meta --- +{ + const dir = makeFixtureDir('explicit-keys'); + writeFileSync(`${dir}/ignatius.yml`, 'index_file: router.md\nharness: claude\n'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + + const { model } = await parseModels(dir); + + assert(model._meta?.indexFile === 'router.md', `FAIL (b): _meta.indexFile = ${model._meta?.indexFile}`); + assert(model._meta?.harness === 'claude', `FAIL (b): _meta.harness = ${model._meta?.harness}`); + console.log('PASS (b): index_file + harness load onto _meta'); +} + +// --- (c) default index_file name: a router named index.md in data/identity/ is skipped, no parse.missing_id --- +{ + const dir = makeFixtureDir('skip-default'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + writeFileSync(`${dir}/data/identity/index.md`, '# Identity\n\nJust a router, no frontmatter.\n'); + + const { model, globalErrors } = await parseModels(dir); + + assert(globalErrors.length === 0, `FAIL (c): expected no global errors, got ${JSON.stringify(globalErrors)}`); + const widget = model.nodes[0]; + if (widget === undefined) throw new Error('FAIL (c): expected one node, got none'); + assert(model.nodes.length === 1 && widget.id === 'Widget', 'FAIL (c): only Widget should parse'); + console.log('PASS (c): index.md in data/identity/ is skipped, no parse.missing_id'); +} + +// --- (d) suffix matching is not used: Reindex.md is still scanned and still raises parse.missing_id --- +{ + const dir = makeFixtureDir('suffix-not-skipped'); + writeFileSync(`${dir}/data/identity/Reindex.md`, '# Not an entity\n\nNo frontmatter at all.\n'); + + const { globalErrors } = await parseModels(dir); + + assert( + globalErrors.some(e => e.ruleId === 'parse.empty_frontmatter' || e.ruleId === 'parse.invalid_yaml'), + `FAIL (d): Reindex.md should still be scanned (and fail parsing), got ${JSON.stringify(globalErrors)}`, + ); + console.log('PASS (d): Reindex.md is still scanned — suffix matching is not used'); +} + +// --- (e) config.index_file_ext fires when index_file does not end in .md --- +{ + const dir = makeFixtureDir('bad-ext'); + writeFileSync(`${dir}/ignatius.yml`, 'index_file: router.txt\n'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + + const { globalErrors } = await parseModels(dir); + + assert( + globalErrors.some(e => e.ruleId === 'config.index_file_ext'), + `FAIL (e): expected config.index_file_ext, got ${JSON.stringify(globalErrors)}`, + ); + console.log('PASS (e): config.index_file_ext fires on a non-.md value'); +} + +// --- (f) config.index_file_path fires when index_file contains a path separator or ".." --- +{ + const dir = makeFixtureDir('bad-path'); + writeFileSync(`${dir}/ignatius.yml`, 'index_file: ../router.md\n'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + + const { globalErrors } = await parseModels(dir); + + assert( + globalErrors.some(e => e.ruleId === 'config.index_file_path'), + `FAIL (f): expected config.index_file_path, got ${JSON.stringify(globalErrors)}`, + ); + console.log('PASS (f): config.index_file_path fires on a value containing ".."'); +} + +// --- (f2) config.index_file_path also fires on a bare nested path with no ".." --- +{ + const dir = makeFixtureDir('bad-path-nested'); + writeFileSync(`${dir}/ignatius.yml`, 'index_file: sub/router.md\n'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + + const { globalErrors } = await parseModels(dir); + + assert( + globalErrors.some(e => e.ruleId === 'config.index_file_path'), + `FAIL (f2): expected config.index_file_path, got ${JSON.stringify(globalErrors)}`, + ); + console.log('PASS (f2): config.index_file_path fires on a nested path with no ".."'); +} + +// --- (g) config.index_file_entity fires when a reserved-name file declares entity:, not parse.missing_id --- +{ + const dir = makeFixtureDir('reserved-entity'); + writeFileSync(`${dir}/data/identity/index.md`, MINIMAL_ENTITY('SneakyEntity')); + + const { globalErrors } = await parseModels(dir); + + assert( + globalErrors.some(e => e.ruleId === 'config.index_file_entity'), + `FAIL (g): expected config.index_file_entity, got ${JSON.stringify(globalErrors)}`, + ); + assert( + !globalErrors.some(e => e.ruleId === 'parse.missing_id'), + `FAIL (g): parse.missing_id should not fire for a reserved-name file, got ${JSON.stringify(globalErrors)}`, + ); + const entityFinding = globalErrors.find(e => e.ruleId === 'config.index_file_entity'); + assert( + entityFinding !== undefined && entityFinding.reason.includes('index.md'), + `FAIL (g): message should name the reserved filename, got ${JSON.stringify(entityFinding)}`, + ); + console.log('PASS (g): config.index_file_entity fires, names the reserved filename, not parse.missing_id'); +} + +// --- (h) parse.invalid_yaml fires when a reserved-name file has malformed YAML frontmatter --- +{ + const dir = makeFixtureDir('reserved-bad-yaml'); + writeFileSync(`${dir}/data/identity/index.md`, '---\nentity: [unclosed\n---\nRouter body.\n'); + writeFileSync(`${dir}/data/identity/Widget.md`, MINIMAL_ENTITY('Widget')); + + const { globalErrors } = await parseModels(dir); + + assert( + globalErrors.some(e => e.ruleId === 'parse.invalid_yaml'), + `FAIL (h): expected parse.invalid_yaml for malformed YAML in reserved index file, got ${JSON.stringify(globalErrors)}`, + ); + console.log('PASS (h): parse.invalid_yaml fires for malformed YAML in a reserved index file'); +} + +// --- (i) a reserved-name router in a flows/ diagram folder is skipped, no parse.invalid_yaml --- +{ + const dir = makeFixtureDir('skip-flows-diagram-router'); + mkdirSync(`${dir}/flows/order-to-cash`, { recursive: true }); + writeFileSync(`${dir}/flows/order-to-cash/index.md`, '# Order to Cash\n\nJust a router, no frontmatter.\n'); + + const { globalErrors } = await parseFlows(dir); + + assert(globalErrors.length === 0, `FAIL (i): expected no global errors, got ${JSON.stringify(globalErrors)}`); + console.log('PASS (i): index.md in a flows/ diagram folder is skipped, no parse.invalid_yaml'); +} + +// --- (j) a reserved-name router in externals/ is skipped, no parse.invalid_yaml --- +{ + const dir = makeFixtureDir('skip-externals-router'); + mkdirSync(`${dir}/externals`, { recursive: true }); + writeFileSync(`${dir}/externals/index.md`, '# Externals\n\nJust a router, no frontmatter.\n'); + + const { globalErrors } = await parseFlows(dir); + + assert(globalErrors.length === 0, `FAIL (j): expected no global errors, got ${JSON.stringify(globalErrors)}`); + console.log('PASS (j): index.md in externals/ is skipped, no parse.invalid_yaml'); +} + +// --- (k) a reserved-name router in stores/ is skipped, no parse.invalid_yaml --- +{ + const dir = makeFixtureDir('skip-stores-router'); + mkdirSync(`${dir}/stores`, { recursive: true }); + writeFileSync(`${dir}/stores/index.md`, '# Stores\n\nJust a router, no frontmatter.\n'); + + const { globalErrors } = await parseFlows(dir); + + assert(globalErrors.length === 0, `FAIL (k): expected no global errors, got ${JSON.stringify(globalErrors)}`); + console.log('PASS (k): index.md in stores/ is skipped, no parse.invalid_yaml'); +} + +console.log('All index-config tests passed.'); diff --git a/test/checks/test-router-agents.ts b/test/checks/test-router-agents.ts new file mode 100644 index 0000000..9d92a1f --- /dev/null +++ b/test/checks/test-router-agents.ts @@ -0,0 +1,267 @@ +/** + * test-router-agents.ts — `ignatius index --agents` guidance files against a + * disposable copy of models/key-inherited (spec SC10–SC12). + * + * Covers: AGENTS.md/SKILL.md/CLAUDE.md land in the model root; CLAUDE.md + * appears only when the harness resolves to Claude, not under + * `harness: agents`; a hand-written CLAUDE.md survives outside its region; + * SKILL.md frontmatter parses with a model-specific description; nothing is + * written outside the model root; every guidance file stays under 200 lines; + * no entity name leaks into a guidance file; a second `--agents` run is + * byte-identical. + */ + +import { cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { resolve, join } from 'path'; +import { tmpdir } from 'os'; +import { parse as parseYaml } from 'yaml'; +import { parseModels } from '../../src/model/parse'; +import { parseFlows } from '../../src/flows/flow-parse'; +import { buildRouters } from '../../src/router/build'; +import { resolveHarness } from '../../src/router/detect'; +import { writeGuidance, deriveKeyStyle } from '../../src/router/agents'; +import { assert } from '../assert'; + +const ROOT = resolve(import.meta.dir, '../..'); +const SOURCE_MODEL = `${ROOT}/models/key-inherited`; +const WORK_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test/key-inherited'); +const GUIDANCE_FILES = ['AGENTS.md', 'SKILL.md', 'CLAUDE.md']; + +rmSync(resolve(ROOT, 'tmp/fixtures/router-agents-test'), { recursive: true, force: true }); +mkdirSync(resolve(ROOT, 'tmp/fixtures/router-agents-test'), { recursive: true }); +cpSync(SOURCE_MODEL, WORK_DIR, { recursive: true }); + +async function parseAndRoute(dir: string) { + const { model, globalErrors } = await parseModels(dir); + const { flowModel, globalErrors: flowErrors } = await parseFlows(dir); + const allErrors = [...globalErrors, ...flowErrors]; + const routers = await buildRouters(dir, model, flowModel); + const indexFile = model._meta?.indexFile ?? 'index.md'; + const rootFile = routers.find(f => f.relPath === indexFile); + assert(!!rootFile, `FAIL: setup — no root router found for ${dir}`); + return { model, allErrors, rootDigest: rootFile!.digest }; +} + +function listAllFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) out.push(...listAllFiles(path)); + else out.push(path); + } + return out; +} + +// --- WORK_DIR sits inside the repo tree, which carries its own ancestor +// .claude/ + CLAUDE.md, so `harness: auto` here proves the positive case. +{ + const { model, allErrors, rootDigest } = await parseAndRoute(WORK_DIR); + assert(allErrors.length === 0, `FAIL: unexpected parse errors before --agents: ${JSON.stringify(allErrors)}`); + + const writeClaude = resolveHarness(WORK_DIR, model._meta?.harness); + assert(writeClaude, 'FAIL: expected harness: auto to detect Claude via the repo ancestor .claude/'); + await writeGuidance(WORK_DIR, model, rootDigest, writeClaude); + + for (const name of GUIDANCE_FILES) { + let exists = true; + try { + readFileSync(`${WORK_DIR}/${name}`, 'utf8'); + } catch { + exists = false; + } + assert(exists, `FAIL: expected ${name} in the model root`); + } +} +console.log('PASS: AGENTS.md, SKILL.md, and CLAUDE.md all land in the model root under harness: auto with Claude detected'); + +// --- isolated dir with no ancestor .claude/ or CLAUDE.md: auto resolves to no Claude --- +{ + const ISOLATED_DIR = join(tmpdir(), `ignatius-agents-isolated-${process.pid}`); + rmSync(ISOLATED_DIR, { recursive: true, force: true }); + mkdirSync(ISOLATED_DIR, { recursive: true }); + cpSync(SOURCE_MODEL, ISOLATED_DIR, { recursive: true }); + + const { model, allErrors, rootDigest } = await parseAndRoute(ISOLATED_DIR); + assert(allErrors.length === 0, `FAIL: unexpected parse errors in isolated fixture: ${JSON.stringify(allErrors)}`); + + const writeClaude = resolveHarness(ISOLATED_DIR, model._meta?.harness); + assert(!writeClaude, 'FAIL: expected harness: auto to resolve to no Claude with no ancestor marker'); + await writeGuidance(ISOLATED_DIR, model, rootDigest, writeClaude); + + let claudeExists = true; + try { + readFileSync(`${ISOLATED_DIR}/CLAUDE.md`, 'utf8'); + } catch { + claudeExists = false; + } + assert(!claudeExists, 'FAIL: CLAUDE.md must not be written when auto resolves to no Claude'); + + for (const name of ['AGENTS.md', 'SKILL.md']) { + let exists = true; + try { + readFileSync(`${ISOLATED_DIR}/${name}`, 'utf8'); + } catch { + exists = false; + } + assert(exists, `FAIL: expected ${name} even with no Claude detected`); + } + + rmSync(ISOLATED_DIR, { recursive: true, force: true }); +} +console.log('PASS: harness: auto resolves to no Claude with no ancestor .claude/ or CLAUDE.md, but still writes AGENTS.md/SKILL.md'); + +// --- harness: agents forces no CLAUDE.md, even inside the repo tree --- +{ + assert(resolveHarness(WORK_DIR, 'agents') === false, 'FAIL: harness: agents must never write CLAUDE.md'); + assert(resolveHarness(WORK_DIR, 'claude') === true, 'FAIL: harness: claude must always write CLAUDE.md'); + assert(resolveHarness(WORK_DIR, 'both') === true, 'FAIL: harness: both must always write CLAUDE.md'); +} +console.log('PASS: harness: agents/claude/both resolve without consulting the ancestor probe'); + +// --- a hand-written CLAUDE.md survives --agents verbatim outside its region --- +{ + const HANDWRITTEN_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test/handwritten-claude'); + rmSync(HANDWRITTEN_DIR, { recursive: true, force: true }); + cpSync(SOURCE_MODEL, HANDWRITTEN_DIR, { recursive: true }); + const handwritten = '# My Project\n\nUnrelated project instructions that must survive.\n'; + writeFileSync(`${HANDWRITTEN_DIR}/CLAUDE.md`, handwritten); + + const { model, rootDigest } = await parseAndRoute(HANDWRITTEN_DIR); + await writeGuidance(HANDWRITTEN_DIR, model, rootDigest, true); + + const after = readFileSync(`${HANDWRITTEN_DIR}/CLAUDE.md`, 'utf8'); + assert(after.startsWith(handwritten), `FAIL: hand-written CLAUDE.md content must survive verbatim, got:\n${after}`); + assert(after.includes('@AGENTS.md'), `FAIL: expected the generated shim to import AGENTS.md, got:\n${after}`); +} +console.log('PASS: a hand-written CLAUDE.md survives --agents verbatim, with the shim appended'); + +// --- SKILL.md frontmatter parses and names this model specifically --- +{ + const skillContent = readFileSync(`${WORK_DIR}/SKILL.md`, 'utf8'); + const match = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/); + assert(!!match, `FAIL: SKILL.md must open with a YAML frontmatter block, got:\n${skillContent}`); + const frontmatter = parseYaml(match![1]!); + assert(typeof frontmatter.name === 'string' && frontmatter.name.length > 0, 'FAIL: SKILL.md frontmatter must carry a non-empty name'); + assert(typeof frontmatter.description === 'string' && frontmatter.description.length > 0, 'FAIL: SKILL.md frontmatter must carry a non-empty description'); + assert( + frontmatter.description.includes('Key-Inherited'), + `FAIL: expected the description to mention this model by name, got: ${frontmatter.description}`, + ); +} +console.log('PASS: SKILL.md frontmatter parses and its description names this model specifically'); + +// --- nothing is written outside the model root --- +{ + const PARENT_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test'); + const before = new Set(listAllFiles(PARENT_DIR).filter(f => !f.startsWith(WORK_DIR) && !f.includes('handwritten-claude'))); + + const { model, rootDigest } = await parseAndRoute(WORK_DIR); + await writeGuidance(WORK_DIR, model, rootDigest, true); + + const after = new Set(listAllFiles(PARENT_DIR).filter(f => !f.startsWith(WORK_DIR) && !f.includes('handwritten-claude'))); + assert(before.size === after.size, `FAIL: files appeared outside the model root: before ${before.size}, after ${after.size}`); + for (const f of after) assert(before.has(f), `FAIL: unexpected file written outside the model root: ${f}`); +} +console.log('PASS: --agents writes nothing outside the model root'); + +// --- each guidance file stays under 200 lines --- +{ + for (const name of GUIDANCE_FILES) { + const content = readFileSync(`${WORK_DIR}/${name}`, 'utf8'); + const lineCount = content.split(/\r?\n/).length; + assert(lineCount < 200, `FAIL: ${name} has ${lineCount} lines, expected under 200`); + } +} +console.log('PASS: every guidance file stays under 200 lines'); + +// --- no entity name from the model appears in any guidance file --- +{ + const { model } = await parseAndRoute(WORK_DIR); + for (const name of GUIDANCE_FILES) { + const content = readFileSync(`${WORK_DIR}/${name}`, 'utf8'); + for (const node of model.nodes) { + assert(!content.includes(node.id), `FAIL: entity name "${node.id}" leaked into ${name}:\n${content}`); + } + } +} +console.log('PASS: no entity name from the model appears in any guidance file'); + +// --- running --agents twice is byte-identical --- +{ + const { model, rootDigest } = await parseAndRoute(WORK_DIR); + const before = new Map<string, string>(); + for (const name of GUIDANCE_FILES) before.set(name, readFileSync(`${WORK_DIR}/${name}`, 'utf8')); + + await writeGuidance(WORK_DIR, model, rootDigest, true); + + for (const name of GUIDANCE_FILES) { + const after = readFileSync(`${WORK_DIR}/${name}`, 'utf8'); + assert(before.get(name) === after, `FAIL: ${name} was not byte-identical across an unchanged --agents rerun`); + } +} +console.log('PASS: running --agents twice is byte-identical'); + +// --- deriveKeyStyle classifies on PK shape, not edge identifying-ness --- +// key-inherited has non-identifying edges into its reference tables +// (SI_Line -> LineItemType, Party -> PartyType, etc.) — that's normal for a +// healthy key-inherited model, not a deviation from it, so it must still +// resolve to key-inherited rather than mixed. +{ + const KEY_INHERITED_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test/key-style-key-inherited'); + rmSync(KEY_INHERITED_DIR, { recursive: true, force: true }); + cpSync(`${ROOT}/models/key-inherited`, KEY_INHERITED_DIR, { recursive: true }); + const { model } = await parseModels(KEY_INHERITED_DIR); + assert( + deriveKeyStyle(model.nodes, model.edges) === 'key-inherited', + `FAIL: expected models/key-inherited to derive key-inherited, got ${deriveKeyStyle(model.nodes, model.edges)}`, + ); +} +console.log('PASS: deriveKeyStyle resolves models/key-inherited to key-inherited despite non-identifying edges into reference tables'); + +{ + const ORM_PURE_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test/key-style-orm-pure'); + rmSync(ORM_PURE_DIR, { recursive: true, force: true }); + cpSync(`${ROOT}/models/orm-pure`, ORM_PURE_DIR, { recursive: true }); + const { model } = await parseModels(ORM_PURE_DIR); + assert( + deriveKeyStyle(model.nodes, model.edges) === 'orm-oriented', + `FAIL: expected models/orm-pure to derive orm-oriented, got ${deriveKeyStyle(model.nodes, model.edges)}`, + ); +} +console.log('PASS: deriveKeyStyle resolves models/orm-pure to orm-oriented'); + +// orm-hybrid pairs a surrogate `id` PK on every entity with alternate-key +// uniqueness constraints for natural business keys — the hybrid quality +// lives in its AKs, not its PKs, so PK shape alone correctly reports +// orm-oriented rather than mixed. +{ + const ORM_HYBRID_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test/key-style-orm-hybrid'); + rmSync(ORM_HYBRID_DIR, { recursive: true, force: true }); + cpSync(`${ROOT}/models/orm-hybrid`, ORM_HYBRID_DIR, { recursive: true }); + const { model } = await parseModels(ORM_HYBRID_DIR); + assert( + deriveKeyStyle(model.nodes, model.edges) === 'orm-oriented', + `FAIL: expected models/orm-hybrid to derive orm-oriented, got ${deriveKeyStyle(model.nodes, model.edges)}`, + ); +} +console.log('PASS: deriveKeyStyle resolves models/orm-hybrid to orm-oriented (hybrid quality is in its AKs, not its PKs)'); + +// llm-memory-db-mssql was reverse-engineered from a real SQL Server schema +// with base entities on surrogate `<entity>_id` IDENTITY keys and every +// subtype/associative table migrating its parent's key in. The discriminator +// is structural (is this entity the source of an identifying edge?), not the +// literal column name `id`, so both signatures register and the model reads +// as mixed rather than pure key-inherited. +{ + const MIXED_DIR = resolve(ROOT, 'tmp/fixtures/router-agents-test/key-style-mixed'); + rmSync(MIXED_DIR, { recursive: true, force: true }); + cpSync(`${ROOT}/models/llm-memory-db-mssql`, MIXED_DIR, { recursive: true }); + const { model } = await parseModels(MIXED_DIR); + assert( + deriveKeyStyle(model.nodes, model.edges) === 'mixed', + `FAIL: expected models/llm-memory-db-mssql to derive mixed, got ${deriveKeyStyle(model.nodes, model.edges)}`, + ); +} +console.log('PASS: deriveKeyStyle resolves models/llm-memory-db-mssql to mixed (surrogate `_id` PKs plus migrated subtype/associative keys)'); + +console.log('test-router-agents: OK'); diff --git a/test/checks/test-router-fingerprint.ts b/test/checks/test-router-fingerprint.ts new file mode 100644 index 0000000..d945e02 --- /dev/null +++ b/test/checks/test-router-fingerprint.ts @@ -0,0 +1,81 @@ +/** + * test-router-fingerprint.ts — verifies the SHA-256 fingerprint primitives + * behind the router's digest roll-up (spec SC7): every row hashes its + * target, a folder digest hashes its ordered row-hash list, and a change to + * one leaf changes its folder digest and every ancestor digest, and no + * sibling digest. + */ + +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { folderDigest, hashFile } from '../../src/router/fingerprint'; +import { assert } from '../assert'; + +const BASE_TMP = resolve(import.meta.dir, '../../tmp/fixtures/router-fingerprint-test'); +rmSync(BASE_TMP, { recursive: true, force: true }); +mkdirSync(BASE_TMP, { recursive: true }); + +// 1 — hashFile is deterministic and content-sensitive +{ + const file = `${BASE_TMP}/a.md`; + writeFileSync(file, 'hello'); + const first = await hashFile(file); + const second = await hashFile(file); + assert(first === second, 'FAIL(1): hashFile should be deterministic for unchanged bytes'); + assert(/^sha256:[0-9a-f]{64}$/.test(first), `FAIL(1): expected sha256:<hex>, got ${first}`); + + writeFileSync(file, 'hello!'); + const third = await hashFile(file); + assert(first !== third, 'FAIL(1): hashFile should change when file bytes change'); +} + +// 2 — folderDigest hashes the ordered row-hash list; order matters +{ + const digestAB = folderDigest(['hash-a', 'hash-b']); + const digestBA = folderDigest(['hash-b', 'hash-a']); + const digestAB2 = folderDigest(['hash-a', 'hash-b']); + assert(digestAB === digestAB2, 'FAIL(2): folderDigest should be deterministic for the same ordered list'); + assert(digestAB !== digestBA, 'FAIL(2): folderDigest should be order-sensitive'); + assert(/^sha256:[0-9a-f]{64}$/.test(digestAB), `FAIL(2): expected sha256:<hex>, got ${digestAB}`); +} + +// 3 — ancestor-only propagation on a synthetic tree: +// +// root/ +// groupA/leaf1.md, leaf2.md +// groupB/leaf3.md +// +// editing leaf1 changes groupA's digest and the root digest, and leaves +// groupB's digest untouched. +{ + const groupA = `${BASE_TMP}/groupA`; + const groupB = `${BASE_TMP}/groupB`; + mkdirSync(groupA, { recursive: true }); + mkdirSync(groupB, { recursive: true }); + + const leaf1 = `${groupA}/leaf1.md`; + const leaf2 = `${groupA}/leaf2.md`; + const leaf3 = `${groupB}/leaf3.md`; + writeFileSync(leaf1, 'one'); + writeFileSync(leaf2, 'two'); + writeFileSync(leaf3, 'three'); + + async function digests() { + const groupADigest = folderDigest([await hashFile(leaf1), await hashFile(leaf2)]); + const groupBDigest = folderDigest([await hashFile(leaf3)]); + const rootDigest = folderDigest([groupADigest, groupBDigest]); + return { groupADigest, groupBDigest, rootDigest }; + } + + const before = await digests(); + + writeFileSync(leaf1, 'one, edited'); + + const after = await digests(); + + assert(before.groupADigest !== after.groupADigest, 'FAIL(3): editing leaf1 should change groupA digest'); + assert(before.rootDigest !== after.rootDigest, 'FAIL(3): editing leaf1 should change the root digest'); + assert(before.groupBDigest === after.groupBDigest, 'FAIL(3): editing leaf1 must not change groupB digest, a sibling'); +} + +console.log('test-router-fingerprint: OK'); diff --git a/test/checks/test-router-index.ts b/test/checks/test-router-index.ts new file mode 100644 index 0000000..9aad9ca --- /dev/null +++ b/test/checks/test-router-index.ts @@ -0,0 +1,424 @@ +/** + * test-router-index.ts — end-to-end router generation against a disposable + * copy of models/key-inherited (spec SC5–SC8). + * + * Covers: every organizing folder gets a router; kinds come from the parsed + * model, never re-derived; a second run is byte-identical; a hand-authored + * <ignatius-rules> block survives; links name files and are relative; the + * breadcrumb line is present above the region and doesn't accumulate + * duplicates when reworded; a folder digest never leaks into a sibling's + * (SC7 negative); the Description column renders real entity/group text; + * every row's link resolves to a real file (SC5a); a flat data/ layout + * (entities directly under data/, group not a folder) indexes without + * throwing. + */ + +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { parseModels } from '../../src/model/parse'; +import { parseFlows } from '../../src/flows/flow-parse'; +import { buildRouters } from '../../src/router/build'; +import { writeRouters } from '../../src/router/write'; +import { hashFile } from '../../src/router/fingerprint'; +import { assert } from '../assert'; +import type { RouterFile } from '../../src/router/build'; + +function assertRowLinksResolve(baseDir: string, routers: RouterFile[]): void { + for (const file of routers) { + const routerDir = file.relPath.includes('/') ? file.relPath.slice(0, file.relPath.lastIndexOf('/')) : ''; + const rows = file.table.split('\n').slice(2); + for (const row of rows) { + const match = row.match(/\(([^)]+)\)\s*\|\s*$/); + assert(!!match, `FAIL: could not parse the Go-column link from row: ${row}`); + const link = match![1]!; + const linkedPath = routerDir === '' ? `${baseDir}/${link}` : `${baseDir}/${routerDir}/${link}`; + let exists = true; + try { + readFileSync(linkedPath, 'utf8'); + } catch { + exists = false; + } + assert(exists, `FAIL: router ${file.relPath} row link "${link}" does not resolve to a real file at ${linkedPath}`); + } + } +} + +const ROOT = resolve(import.meta.dir, '../..'); +const SOURCE_MODEL = `${ROOT}/models/key-inherited`; +const WORK_DIR = resolve(ROOT, 'tmp/fixtures/router-index-test/key-inherited'); + +rmSync(resolve(ROOT, 'tmp/fixtures/router-index-test'), { recursive: true, force: true }); +mkdirSync(resolve(ROOT, 'tmp/fixtures/router-index-test'), { recursive: true }); +cpSync(SOURCE_MODEL, WORK_DIR, { recursive: true }); + +async function parse() { + const { model, globalErrors } = await parseModels(WORK_DIR); + const { flowModel, globalErrors: flowErrors } = await parseFlows(WORK_DIR); + return { model, flowModel, allErrors: [...globalErrors, ...flowErrors] }; +} + +const first = await parse(); +assert(first.allErrors.length === 0, `FAIL: unexpected parse errors before indexing: ${JSON.stringify(first.allErrors)}`); + +const firstRouters = await buildRouters(WORK_DIR, first.model, first.flowModel); +await writeRouters(WORK_DIR, firstRouters); + +// --- every expected folder gets a router --- +const expectedRelPaths = [ + 'index.md', + 'groups/index.md', + 'data/index.md', + 'data/identity/index.md', + 'data/catalog/index.md', + 'data/transactional/index.md', + 'data/reference/index.md', + 'flows/index.md', + 'flows/order-to-cash/index.md', + 'flows/order-to-cash/Create-Sales-Order/index.md', + 'flows/refund/index.md', + 'externals/index.md', + 'stores/index.md', +]; +for (const relPath of expectedRelPaths) { + const path = `${WORK_DIR}/${relPath}`; + let exists = true; + try { + readFileSync(path, 'utf8'); + } catch { + exists = false; + } + assert(exists, `FAIL: expected router at ${relPath}`); +} +console.log(`PASS: every expected folder (${expectedRelPaths.length}) got a router`); + +assertRowLinksResolve(WORK_DIR, firstRouters); +console.log('PASS: every row link in every generated router resolves to a real file on disk'); + +// --- kinds come from the parsed model, not re-derived --- +{ + const identityContent = readFileSync(`${WORK_DIR}/data/identity/index.md`, 'utf8'); + const partyNode = first.model.nodes.find(n => n.id === 'Party'); + assert(!!partyNode, 'FAIL: Party node missing from parsed model'); + assert( + identityContent.includes(`| Party | ${partyNode!.classification} |`), + `FAIL: expected Party row to carry classification "${partyNode!.classification}", got:\n${identityContent}`, + ); + + const flowsContent = readFileSync(`${WORK_DIR}/flows/order-to-cash/index.md`, 'utf8'); + assert( + flowsContent.includes('| Create-Sales-Order | process |') || flowsContent.includes('| Create-Sales-Order | folder |'), + `FAIL: expected a Create-Sales-Order row in the order-to-cash router, got:\n${flowsContent}`, + ); + + const externalsContent = readFileSync(`${WORK_DIR}/externals/index.md`, 'utf8'); + assert( + externalsContent.includes('| Customer | external |'), + `FAIL: expected Customer row with Kind "external", got:\n${externalsContent}`, + ); + + const storesContent = readFileSync(`${WORK_DIR}/stores/index.md`, 'utf8'); + assert( + /\| \S+ \| store \|/.test(storesContent), + `FAIL: expected at least one row with Kind "store", got:\n${storesContent}`, + ); +} +console.log('PASS: row kinds come from the parsed model'); + +// --- links name files and are relative, never a bare folder or a wikilink --- +{ + const dataContent = readFileSync(`${WORK_DIR}/data/index.md`, 'utf8'); + assert(!/\[[^\]]+\]\([^)]*\/\)/.test(dataContent), `FAIL: a router link must not be a bare folder link, got:\n${dataContent}`); + assert(!/\[\[[^\]]+\]\]/.test(dataContent), `FAIL: a router must not use wikilink syntax, got:\n${dataContent}`); + assert(dataContent.includes('[identity](identity/index.md)'), `FAIL: expected a file-naming relative link to identity/index.md, got:\n${dataContent}`); +} +console.log('PASS: links name files and are relative'); + +// --- root section rows all carry a non-empty Description --- +{ + const rootContent = readFileSync(`${WORK_DIR}/index.md`, 'utf8'); + const sectionRows = rootContent.split('\n').filter(l => /^\| (Groups|Data|Flows|Externals|Stores) \| folder \|/.test(l)); + assert(sectionRows.length === 5, `FAIL: expected 5 root section rows, got:\n${rootContent}`); + for (const row of sectionRows) { + const description = row.split('|')[3]?.trim() ?? ''; + assert(description !== '', `FAIL: expected a non-empty Description cell, got row: ${row}`); + } +} +console.log('PASS: root section rows all carry a non-empty Description'); + +// --- counts in the section descriptions match the parsed model --- +{ + const rootContent = readFileSync(`${WORK_DIR}/index.md`, 'utf8'); + const entityCount = first.model.nodes.length; + const groupCount = Object.keys(first.model.groups).length; + assert(rootContent.includes(`${entityCount} entities`), `FAIL: expected the Data description to cite ${entityCount} entities, got:\n${rootContent}`); + assert(rootContent.includes(`${groupCount} groups`), `FAIL: expected a group count of ${groupCount}, got:\n${rootContent}`); + assert(rootContent.includes('2 flows') && rootContent.includes('6 processes'), `FAIL: expected the Flows description to cite 2 flows, 6 processes, got:\n${rootContent}`); + assert(rootContent.includes('1 external'), `FAIL: expected the Externals description to cite 1 external, got:\n${rootContent}`); + assert(rootContent.includes('1 store'), `FAIL: expected the Stores description to cite 1 store, got:\n${rootContent}`); +} +console.log('PASS: section description counts match the parsed model'); + +// --- no model description => no stray blank paragraph above the table --- +{ + const rootContent = readFileSync(`${WORK_DIR}/index.md`, 'utf8'); + assert(first.model._meta?.desc === undefined, 'FAIL: setup — key-inherited unexpectedly carries a model description'); + const regionMatch = rootContent.match(/<ignatius-index[^>]*>\r?\n\r?\n([\s\S]*?)\r?\n\r?\n<\/ignatius-index>/); + assert(!!regionMatch, `FAIL: could not find the <ignatius-index> region in:\n${rootContent}`); + assert( + regionMatch![1]!.startsWith('| Name | Kind | Description | Go |'), + `FAIL: expected the region to open directly with the table when there is no model description, got:\n${regionMatch![1]}`, + ); +} +console.log('PASS: absent model description leaves no stray blank paragraph above the table'); + +// --- a model description renders as prose above the table, and parses as a real table --- +{ + const DESC_DIR = resolve(ROOT, 'tmp/fixtures/router-index-test/llm-memory-db-mssql'); + rmSync(DESC_DIR, { recursive: true, force: true }); + cpSync(`${ROOT}/models/llm-memory-db-mssql`, DESC_DIR, { recursive: true }); + + const { model: descModel, globalErrors: descParseErrors } = await parseModels(DESC_DIR); + const { flowModel: descFlowModel, globalErrors: descFlowErrors } = await parseFlows(DESC_DIR); + assert([...descParseErrors, ...descFlowErrors].length === 0, 'FAIL: unexpected parse errors in llm-memory-db-mssql fixture'); + assert(!!descModel._meta?.desc, 'FAIL: setup — llm-memory-db-mssql is expected to carry a model description'); + + const descRouters = await buildRouters(DESC_DIR, descModel, descFlowModel); + await writeRouters(DESC_DIR, descRouters); + + const descRootContent = readFileSync(`${DESC_DIR}/index.md`, 'utf8'); + const regionMatch = descRootContent.match(/<ignatius-index[^>]*>\r?\n\r?\n([\s\S]*?)\r?\n\r?\n<\/ignatius-index>/); + assert(!!regionMatch, `FAIL: could not find the <ignatius-index> region in:\n${descRootContent}`); + const region = regionMatch![1]!; + + assert(region.includes(descModel._meta!.desc!), `FAIL: expected the model description in the root region, got:\n${region}`); + const descIdx = region.indexOf(descModel._meta!.desc!); + const tableIdx = region.indexOf('| Name | Kind | Description | Go |'); + assert(descIdx !== -1 && descIdx < tableIdx, `FAIL: expected the description above the table, got:\n${region}`); + + const tableLines = region.slice(tableIdx).split('\n'); + assert(tableLines[1] === '|---|---|---|---|', `FAIL: expected the table separator right after the header, got:\n${region}`); +} +console.log('PASS: a model description renders as prose above the table, and the table still parses'); + +// --- non-root routers are byte-identical to the pre-fix snapshot --- +{ + const SNAPSHOT_DIR = resolve(ROOT, 'tmp/fixtures/router-index-test/snapshot-key-inherited'); + rmSync(SNAPSHOT_DIR, { recursive: true, force: true }); + cpSync(SOURCE_MODEL, SNAPSHOT_DIR, { recursive: true }); + + const { model: snapModel } = await parseModels(SNAPSHOT_DIR); + const { flowModel: snapFlowModel } = await parseFlows(SNAPSHOT_DIR); + const snapRouters = await buildRouters(SNAPSHOT_DIR, snapModel, snapFlowModel); + await writeRouters(SNAPSHOT_DIR, snapRouters); + + const expectedDigests: Record<string, string> = { + 'groups/index.md': 'f667ec73daf1ee3f64746df06b5909b22f399b19b35c2c7a734fcb3e5f43b03d', + 'data/catalog/index.md': '32c2d5f79b6b4639bd53a96b5581de26dc6ad9d57d38ebe37dc6f86afc634198', + 'data/identity/index.md': 'be917abfd12a8e1c42bb3c5de76633f0ae392f5f0a0f3a2b89ab8f1e8364cc68', + 'data/reference/index.md': 'fc611cf29d5df47dcba4e28a153ecede239189c4cfe3db6c69d2aa7ea21497ba', + 'data/transactional/index.md': '21173c18f26eb074c38cd352327bc828042f126b5a780a68820125b707e35584', + 'data/index.md': '21f55c7d894082bcff037c1bf246a71a6ce5b29f847e9e977320cbf5a286a529', + 'flows/order-to-cash/Create-Sales-Order/index.md': 'f7131c6e9571cd7a257c13ec912b8fa0e384aa96ffb68aa32743f444a1a88b59', + 'flows/order-to-cash/index.md': '11052d003f55363cd694dbf8daf9b11ffe10555b458d141fe37fb2cbdb912173', + 'flows/refund/index.md': 'a2fef644871cc49e08e4905a01655f2f0abf2708782869a3430e2e5f1ea36d4f', + 'flows/index.md': 'b756ede91705704e61a6ae5cf5060665d30e5e3c73519d736c0d60b080091611', + 'externals/index.md': 'aa2cd56036f7b8617dfb7c3a1e59d3d3ba10f36b9b929710a0aa858f2271124b', + 'stores/index.md': '9b481adc5a077c6ce4773b63e3cfb825cda4217ea07f27aa2acebe7d3edfffd0', + }; + + for (const file of snapRouters) { + if (file.attrs.scope === 'root') continue; + const expected = expectedDigests[file.relPath]; + assert(!!expected, `FAIL: no snapshot digest recorded for ${file.relPath}`); + const actual = await hashFile(`${SNAPSHOT_DIR}/${file.relPath}`); + assert(actual === `sha256:${expected}`, `FAIL: ${file.relPath} drifted from the pre-fix snapshot (root-only change expected), got digest ${actual}`); + } +} +console.log('PASS: non-root routers are byte-identical to the pre-fix snapshot'); + +// --- breadcrumb present above the region --- +{ + const identityContent = readFileSync(`${WORK_DIR}/data/identity/index.md`, 'utf8'); + assert( + identityContent.includes('↑ [Data](../index.md) · [Key-Inherited](../../index.md)'), + `FAIL: expected breadcrumb above the region, got:\n${identityContent}`, + ); + const crumbIdx = identityContent.indexOf('↑ '); + const tagIdx = identityContent.indexOf('<ignatius-index'); + assert(crumbIdx !== -1 && crumbIdx < tagIdx, 'FAIL: breadcrumb must sit above the <ignatius-index> region'); +} +console.log('PASS: breadcrumb line present above the region'); + +// --- a hand-authored <ignatius-rules> block survives regeneration --- +{ + const identityPath = `${WORK_DIR}/data/identity/index.md`; + const before = readFileSync(identityPath, 'utf8'); + const rulesBlock = '<ignatius-rules>\n\n- money columns are decimal, never float\n\n</ignatius-rules>'; + writeFileSync(identityPath, `${before}\n${rulesBlock}\n`); + + const second = await parse(); + const secondRouters = await buildRouters(WORK_DIR, second.model, second.flowModel); + await writeRouters(WORK_DIR, secondRouters); + + const after = readFileSync(identityPath, 'utf8'); + assert(after.includes(rulesBlock), `FAIL: hand-authored <ignatius-rules> block did not survive regeneration, got:\n${after}`); +} +console.log('PASS: hand-authored <ignatius-rules> block survives regeneration'); + +// --- a second run over an unchanged model is byte-identical --- +{ + const before = new Map<string, string>(); + for (const relPath of expectedRelPaths) before.set(relPath, readFileSync(`${WORK_DIR}/${relPath}`, 'utf8')); + + const third = await parse(); + const thirdRouters = await buildRouters(WORK_DIR, third.model, third.flowModel); + await writeRouters(WORK_DIR, thirdRouters); + + for (const relPath of expectedRelPaths) { + const after = readFileSync(`${WORK_DIR}/${relPath}`, 'utf8'); + assert(before.get(relPath) === after, `FAIL: ${relPath} was not byte-identical across an unchanged rerun`); + } +} +console.log('PASS: an unchanged rerun is byte-identical'); + +// --- a hand-reworded breadcrumb does not accumulate duplicates --- +{ + const identityPath = `${WORK_DIR}/data/identity/index.md`; + const before = readFileSync(identityPath, 'utf8'); + const reworded = before.replace( + '↑ [Data](../index.md) · [Key-Inherited](../../index.md)', + '↑ [Data](../index.md)\n· [Key-Inherited](../../index.md) — reworded by a human', + ); + assert(reworded !== before, 'FAIL: setup — reword did not change the fixture'); + writeFileSync(identityPath, reworded); + + const fourth = await parse(); + const fourthRouters = await buildRouters(WORK_DIR, fourth.model, fourth.flowModel); + await writeRouters(WORK_DIR, fourthRouters); + + const after = readFileSync(identityPath, 'utf8'); + const crumbTagCount = (after.match(/<ignatius-breadcrumb>/g) ?? []).length; + assert( + crumbTagCount === 1, + `FAIL: expected exactly one <ignatius-breadcrumb> region after a rerun over a reworded breadcrumb, got ${crumbTagCount}:\n${after}`, + ); + assert( + after.includes('↑ [Data](../index.md) · [Key-Inherited](../../index.md)') && !after.includes('reworded by a human'), + `FAIL: reworded breadcrumb text should be reset to the canonical breadcrumb, got:\n${after}`, + ); +} +console.log('PASS: a reworded breadcrumb does not accumulate duplicates'); + +// --- SC7 negative: editing one folder's entity leaves a sibling folder's digest untouched --- +{ + function digestOf(content: string): string { + const match = content.match(/<ignatius-index[^>]*\sdigest="([^"]+)"/); + assert(!!match, `FAIL: no digest attribute found in:\n${content}`); + return match![1]!; + } + + const catalogPath = `${WORK_DIR}/data/catalog/index.md`; + const identityPath = `${WORK_DIR}/data/identity/index.md`; + const rootPath = `${WORK_DIR}/index.md`; + + const catalogBefore = digestOf(readFileSync(catalogPath, 'utf8')); + const identityBefore = digestOf(readFileSync(identityPath, 'utf8')); + const rootBefore = digestOf(readFileSync(rootPath, 'utf8')); + + const partyPath = `${WORK_DIR}/data/identity/Party.md`; + const partySource = readFileSync(partyPath, 'utf8'); + writeFileSync(partyPath, `${partySource}\n\nA sentence added to change this entity's content hash.\n`); + + const fifth = await parse(); + const fifthRouters = await buildRouters(WORK_DIR, fifth.model, fifth.flowModel); + await writeRouters(WORK_DIR, fifthRouters); + + const catalogAfter = digestOf(readFileSync(catalogPath, 'utf8')); + const identityAfter = digestOf(readFileSync(identityPath, 'utf8')); + const rootAfter = digestOf(readFileSync(rootPath, 'utf8')); + + assert( + catalogAfter === catalogBefore, + `FAIL: editing an entity under data/identity must not change data/catalog's digest, got ${catalogBefore} -> ${catalogAfter}`, + ); + assert(identityAfter !== identityBefore, 'FAIL: editing an entity under data/identity should change data/identity\'s digest'); + assert(rootAfter !== rootBefore, 'FAIL: editing an entity under data/identity should change the root digest'); +} +console.log('PASS: a sibling folder digest is unaffected by an edit elsewhere (SC7 negative)'); + +// --- rendered Description column is proven non-empty, not just wired --- +{ + const partyDescription = 'Canonical identity record for a customer, vendor, or employee.'; + const groupDescription = 'Party identity, subtypes, and ID documents.'; + + const partyPath = `${WORK_DIR}/data/identity/Party.md`; + const partySource = readFileSync(partyPath, 'utf8'); + assert(!partySource.includes('description:'), 'FAIL: setup — Party.md already carries a description'); + writeFileSync(partyPath, partySource.replace('entity: Party\n', `entity: Party\ndescription: "${partyDescription}"\n`)); + + const groupPath = `${WORK_DIR}/groups/identity.md`; + const groupSource = readFileSync(groupPath, 'utf8'); + assert(!groupSource.includes('description:'), 'FAIL: setup — groups/identity.md already carries a description'); + writeFileSync(groupPath, groupSource.replace('color: "#2ea043"\n', `color: "#2ea043"\ndescription: "${groupDescription}"\n`)); + + const sixth = await parse(); + const sixthRouters = await buildRouters(WORK_DIR, sixth.model, sixth.flowModel); + await writeRouters(WORK_DIR, sixthRouters); + + const partyNode = sixth.model.nodes.find(n => n.id === 'Party'); + assert(!!partyNode, 'FAIL: Party node missing from parsed model'); + const identityIndex = readFileSync(`${WORK_DIR}/data/identity/index.md`, 'utf8'); + assert( + identityIndex.includes(`| Party | ${partyNode!.classification} | ${partyDescription} |`), + `FAIL: expected Party's row to carry its description, got:\n${identityIndex}`, + ); + + const dataIndex = readFileSync(`${WORK_DIR}/data/index.md`, 'utf8'); + assert( + dataIndex.includes(`| identity | folder | ${groupDescription} |`), + `FAIL: expected the identity group row to carry its description, got:\n${dataIndex}`, + ); +} +console.log('PASS: Description column renders non-empty entity and group descriptions'); + +// --- flat-layout model (broken-demo shape): entities sit directly in data/, +// declaring a group that is not a folder — must index without throwing (SC5a) --- +{ + const FLAT_DIR = resolve(ROOT, 'tmp/fixtures/router-index-test/flat-layout'); + rmSync(FLAT_DIR, { recursive: true, force: true }); + mkdirSync(`${FLAT_DIR}/data`, { recursive: true }); + writeFileSync( + `${FLAT_DIR}/data/Admin.md`, + '---\nentity: Admin\ngroup: core\npk:\n - user_id\ncolumns:\n user_id:\n type: integer\n---\n\nAdmin entity.\n', + ); + writeFileSync( + `${FLAT_DIR}/data/Guest.md`, + '---\nentity: Guest\ngroup: core\npk:\n - session_id\ncolumns:\n session_id:\n type: text\n---\n\nGuest entity.\n', + ); + + const { model: flatModel, globalErrors: flatParseErrors } = await parseModels(FLAT_DIR); + const { flowModel: flatFlowModel, globalErrors: flatFlowErrors } = await parseFlows(FLAT_DIR); + const flatErrors = [...flatParseErrors, ...flatFlowErrors]; + assert(flatErrors.length === 0, `FAIL: unexpected parse errors in flat-layout fixture: ${JSON.stringify(flatErrors)}`); + + const flatRouters = await buildRouters(FLAT_DIR, flatModel, flatFlowModel); + await writeRouters(FLAT_DIR, flatRouters); + + const flatDataIndex = readFileSync(`${FLAT_DIR}/data/index.md`, 'utf8'); + assert(flatDataIndex.includes('| Admin |'), `FAIL: expected flat data/index.md to list Admin directly, got:\n${flatDataIndex}`); + assert(flatDataIndex.includes('| Guest |'), `FAIL: expected flat data/index.md to list Guest directly, got:\n${flatDataIndex}`); + assert(!flatDataIndex.includes('| core |'), `FAIL: a flat layout must not synthesize a "core" group folder row, got:\n${flatDataIndex}`); + + let coreRouterExists = true; + try { + readFileSync(`${FLAT_DIR}/data/core/index.md`, 'utf8'); + } catch { + coreRouterExists = false; + } + assert(!coreRouterExists, 'FAIL: a flat layout must not write a data/core/index.md router'); + + assertRowLinksResolve(FLAT_DIR, flatRouters); +} +console.log('PASS: flat-layout model indexes without throwing, listing entities directly in data/index.md'); + +console.log('test-router-index: OK'); diff --git a/test/checks/test-router-region.ts b/test/checks/test-router-region.ts new file mode 100644 index 0000000..97f846f --- /dev/null +++ b/test/checks/test-router-region.ts @@ -0,0 +1,689 @@ +/** + * test-router-region.ts — verifies the managed-region primitives that back + * the router writer: locate an `<ignatius-*>` block, read its inner content, + * and replace it in place without disturbing anything outside it. + * + * Cases from docs/spec/model-index-routing.md's Risks table: + * 1. no region in the file + * 2. exactly one region + * 3. two differently-named regions in one file, only the targeted one replaced + * 4a. a fenced example inside <ignatius-rules> whose tag lines are indented + * survives verbatim, and the index region is appended + * 4b. the same fenced example with its tag lines at column 0 throws, + * naming indentation as the fix + * 4c. a column-0 fenced example with no real sibling region IS the region + * — replaced in place, fence lines and prose survive + * 5. CRLF input, including line-ending consistency of the output + * 6. replacing preserves every byte outside the region, including a + * hand-authored <ignatius-rules> block + * 7. replace is idempotent, and survives pre-existing hand-authored prose + * 8. nested same-name regions throw rather than silently drop content + * 9. an orphaned/unclosed opening tag throws rather than being treated as + * appendable + * 10. two regions sharing the same name throw rather than orphaning one + * 11. a wrapped (multi-line) opening tag attribute list still parses + * 12. a tight, hand-authored <ignatius-rules> block (no interior blank + * lines) survives an <ignatius-index> append + * 13. a tight region of the same name being replaced also works + * 14. a hand-authored `↑ ...` line above a region-less index survives + * verbatim, and the generator-owned <ignatius-breadcrumb> region is + * inserted alongside it (both by design) + * 15. an <ignatius-*> tag name mentioned mid-line (backticks included) is + * text, not a boundary + * 15b/15c. genuine nested/duplicate-closer cases still throw with inline + * code present elsewhere in the file + * 16. one-space- and tab-indented tag mentions inside rules are text, not + * boundaries + * 17. an orphan closing tag alone throws + * 18. a mismatched closer throws, naming both tags + * 19. a whole column-0 tag inside inline code, plus a second closer, throws + * 20. a back-to-back tag pair is an empty region, not an unclosed one + * 21. thrown messages carry a 1-based line number (and "indent" where + * applicable) + */ + +import { readRegion, replaceRegion } from '../../src/router/region'; +import { writeRouters } from '../../src/router/write'; +import type { RouterFile } from '../../src/router/build'; +import { assert } from '../assert'; +import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// 1 — no region present +{ + const content = '# Heading\n\nSome prose.\n'; + assert( + readRegion(content, 'ignatius-index') === null, + 'FAIL(1): readRegion should return null when no region exists', + ); +} + +// 2 — exactly one region, read and replace +{ + const content = [ + '# Heading', + '', + '<ignatius-index scope="entity-group">', + '', + '| A |', + '|---|', + '| 1 |', + '', + '</ignatius-index>', + '', + 'Trailing prose.', + '', + ].join('\n'); + + assert( + readRegion(content, 'ignatius-index') === '| A |\n|---|\n| 1 |', + `FAIL(2): readRegion should extract the table, got ${JSON.stringify(readRegion(content, 'ignatius-index'))}`, + ); + + const replaced = replaceRegion(content, 'ignatius-index', { scope: 'entity-group' }, '| B |\n|---|\n| 2 |'); + assert( + readRegion(replaced, 'ignatius-index') === '| B |\n|---|\n| 2 |', + 'FAIL(2): replaceRegion should swap the inner content', + ); + assert( + replaced.includes('# Heading') && replaced.includes('Trailing prose.'), + 'FAIL(2): replaceRegion should leave surrounding prose untouched', + ); +} + +// 3 — two differently-named regions, only the targeted one replaced +{ + const content = [ + '<ignatius-index scope="a">', + '', + 'first', + '', + '</ignatius-index>', + '', + '<ignatius-rules>', + '', + 'hand-authored', + '', + '</ignatius-rules>', + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'second'); + assert( + readRegion(replaced, 'ignatius-index') === 'second', + 'FAIL(3): the targeted region should be replaced', + ); + assert( + readRegion(replaced, 'ignatius-rules') === 'hand-authored', + 'FAIL(3): the other region should be untouched', + ); +} + +// 4a — a fenced example inside <ignatius-rules>, tag lines indented one +// space, survives verbatim and the index region is appended +{ + const content = [ + '<ignatius-rules>', + '', + '```markdown', + ' <ignatius-index scope="fake">', + '', + ' example', + '', + ' </ignatius-index>', + '```', + '', + '</ignatius-rules>', + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', {}, 'ROWS'); + assert( + replaced.includes(' <ignatius-index scope="fake">') && replaced.includes(' </ignatius-index>'), + 'FAIL(4a): the indented fenced example must survive verbatim', + ); + assert( + readRegion(replaced, 'ignatius-index') === 'ROWS', + 'FAIL(4a): the new region should be appended', + ); +} + +// 4b — the same fenced example with its tag lines at column 0 throws, +// naming indentation as the fix +{ + const content = [ + '<ignatius-rules>', + '', + '```markdown', + '<ignatius-index scope="fake">', + '', + 'example', + '', + '</ignatius-index>', + '```', + '', + '</ignatius-rules>', + '', + ].join('\n'); + + let message = ''; + try { + replaceRegion(content, 'ignatius-index', {}, 'ROWS'); + } catch (e) { + message = e instanceof Error ? e.message : String(e); + } + assert(message !== '', 'FAIL(4b): a column-0 tag inside a fence nested in another region must throw'); + assert( + message.includes('indent'), + `FAIL(4b): the error should tell the author to indent the line, got ${JSON.stringify(message)}`, + ); +} + +// 4c — a column-0 fenced example with no real sibling region IS the region: +// replaced in place, fence lines and surrounding prose survive +{ + const content = [ + 'Some prose.', + '', + '```markdown', + '<ignatius-index scope="fake">', + '', + 'fenced content, not a real region', + '', + '</ignatius-index>', + '```', + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', { scope: 'real' }, 'real content'); + assert(replaced.includes('Some prose.'), 'FAIL(4c): surrounding prose must survive'); + assert(replaced.includes('```markdown') && replaced.includes('```\n'), 'FAIL(4c): the fence lines must survive'); + assert( + !replaced.includes('fenced content, not a real region'), + 'FAIL(4c): the column-0 pair inside the fence IS the region, so its old content must not survive', + ); + assert( + readRegion(replaced, 'ignatius-index') === 'real content', + 'FAIL(4c): the region must be replaced in place, not appended a second time', + ); +} + +// 5 — CRLF input +{ + const content = '# Heading\r\n\r\n<ignatius-index scope="a">\r\n\r\n| A |\r\n\r\n</ignatius-index>\r\n\r\nTail.\r\n'; + assert( + readRegion(content, 'ignatius-index') === '| A |', + `FAIL(5): readRegion should handle CRLF, got ${JSON.stringify(readRegion(content, 'ignatius-index'))}`, + ); + + const replaced = replaceRegion(content, 'ignatius-index', { scope: 'a' }, '| B |'); + assert( + readRegion(replaced, 'ignatius-index') === '| B |', + 'FAIL(5): replaceRegion should handle CRLF input', + ); + assert( + replaced.includes('Tail.'), + 'FAIL(5): CRLF replace should preserve trailing content', + ); + assert( + !/(?<!\r)\n/.test(replaced), + `FAIL(5): replaceRegion must not introduce bare LF into a CRLF file, got ${JSON.stringify(replaced)}`, + ); +} + +// 6 — a hand-authored <ignatius-rules> block survives a replace of a +// different region, byte for byte +{ + const rulesBlock = '<ignatius-rules>\n\n- money columns are decimal, never float\n\n</ignatius-rules>'; + const content = [ + '<ignatius-index scope="a">', + '', + 'old', + '', + '</ignatius-index>', + '', + rulesBlock, + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'new'); + assert( + replaced.includes(rulesBlock), + 'FAIL(6): the hand-authored rules block must survive byte for byte', + ); +} + +// 7 — replace is idempotent, and preserves pre-existing hand-authored prose +{ + const content = '# Heading\n\nProse.\n'; + const once = replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'row'); + const twice = replaceRegion(once, 'ignatius-index', { scope: 'a' }, 'row'); + assert(once === twice, 'FAIL(7): applying the same content twice should be byte-identical'); + assert( + once.includes('# Heading') && once.includes('Prose.'), + 'FAIL(7): appending onto a hand-authored file must preserve its existing prose', + ); +} + +// 8 — nested same-name regions throw rather than silently dropping content +{ + const content = [ + '<ignatius-index scope="a">', + '', + 'outer-start', + '<ignatius-index scope="b">', + '', + 'inner', + '', + '</ignatius-index>', + '', + 'outer-end', + '', + '</ignatius-index>', + '', + ].join('\n'); + + let threw = false; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'SAFE'); + } catch { + threw = true; + } + assert(threw, 'FAIL(8): nested same-name regions must throw instead of destroying content'); +} + +// 9 — an orphaned/unclosed opening tag throws rather than being treated as +// appendable +{ + const content = '# Heading\n\n<ignatius-index scope="a">\n\npartial write, never closed\n'; + + let threw = false; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'new content'); + } catch { + threw = true; + } + assert(threw, 'FAIL(9): an unclosed opening tag must throw instead of being appended past'); +} + +// 10 — two regions sharing the same name throw rather than orphaning one +{ + const content = [ + '<ignatius-index scope="a">', + '', + 'first', + '', + '</ignatius-index>', + '', + '<ignatius-index scope="b">', + '', + 'second', + '', + '</ignatius-index>', + '', + ].join('\n'); + + let threw = false; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'replacement'); + } catch { + threw = true; + } + assert(threw, 'FAIL(10): duplicate same-name regions must throw rather than silently pick one'); +} + +// 11 — a wrapped (multi-line) opening tag attribute list still parses +{ + const content = [ + '<ignatius-index scope="entity-group" path="data/identity" count="8" depth="2"', + ' digest="sha256:4f2ac19">', + '', + '| A |', + '|---|', + '| 1 |', + '', + '</ignatius-index>', + '', + ].join('\n'); + + assert( + readRegion(content, 'ignatius-index') === '| A |\n|---|\n| 1 |', + `FAIL(11): readRegion should parse a wrapped opening tag, got ${JSON.stringify(readRegion(content, 'ignatius-index'))}`, + ); +} + +// missing file gets a region appended; blank lines around the inner content +// are always present, per CommonMark's raw-HTML-block rule +{ + const created = replaceRegion('', 'ignatius-index', { scope: 'a' }, 'row'); + assert( + created.includes('<ignatius-index scope="a">\n\nrow\n\n</ignatius-index>'), + `FAIL: region on an empty file must carry the required blank lines, got ${JSON.stringify(created)}`, + ); +} + +// 12 — a tight, hand-authored <ignatius-rules> block (no interior blank +// lines) survives an <ignatius-index> append rather than being reported as +// an unclosed opening tag +{ + const content = [ + '# Heading', + '', + 'Prose.', + '', + '<ignatius-rules>', + 'my rules', + '</ignatius-rules>', + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', {}, 'ROWS'); + assert( + replaced.includes('my rules') && replaced.includes('# Heading'), + 'FAIL(12): a tight hand-authored region must survive an unrelated append', + ); + assert( + readRegion(replaced, 'ignatius-index') === 'ROWS', + 'FAIL(12): the new region should be appended', + ); +} + +// 13 — a tight region of the same name being replaced also works +{ + const content = [ + '<ignatius-index scope="a">', + 'first', + '</ignatius-index>', + '', + ].join('\n'); + + assert( + readRegion(content, 'ignatius-index') === 'first', + `FAIL(13): readRegion should parse a tight region, got ${JSON.stringify(readRegion(content, 'ignatius-index'))}`, + ); + + const replaced = replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'second'); + assert( + readRegion(replaced, 'ignatius-index') === 'second', + 'FAIL(13): replaceRegion should swap a tight region in place', + ); +} + +// 14 — a hand-authored `↑ ...` line above a region-less index survives +// writeRouters verbatim (SC8: bytes outside every region survive) +{ + const dir = resolve(import.meta.dir, '../../tmp/fixtures/router-region-crumb-test'); + rmSync(dir, { recursive: true, force: true }); + mkdirSync(`${dir}/externals`, { recursive: true }); + writeFileSync( + `${dir}/externals/index.md`, + '↑ hand-authored back link\n\n# Externals\n\nHand-authored prose.\n', + ); + + const file: RouterFile = { + relPath: 'externals/index.md', + breadcrumb: '↑ [Model](../index.md)', + attrs: { scope: 'externals', path: 'externals', count: '0', depth: '1', digest: 'sha256:empty' }, + table: '| Name | Kind | Description | Go |\n|---|---|---|---|', + digest: 'sha256:empty', + }; + + await writeRouters(dir, [file]); + const written = readFileSync(`${dir}/externals/index.md`, 'utf8'); + assert( + written.includes('↑ hand-authored back link'), + `FAIL(14): hand-authored ↑ line must survive verbatim, got ${JSON.stringify(written)}`, + ); + assert( + written.includes('Hand-authored prose.'), + 'FAIL(14): hand-authored prose must survive verbatim', + ); + // SC8: hand-authored bytes always survive, so the old crumb is never + // stripped; the <ignatius-breadcrumb> region is generator-owned and gets + // inserted regardless, so both breadcrumbs coexist by design. + assert( + written.includes('<ignatius-breadcrumb>') && written.includes('↑ [Model](../index.md)'), + `FAIL(14): the generator-owned breadcrumb region must still be inserted, got ${JSON.stringify(written)}`, + ); + rmSync(dir, { recursive: true, force: true }); +} + +// 15 — an <ignatius-*> tag name mentioned mid-line is text, not a boundary: +// this holds for any mid-line mention, backticks included, since position — +// not delimiters — is what makes a line a region boundary +{ + const content = [ + '<ignatius-rules>', + '', + 'never edit the `<ignatius-index>` block', + '', + '</ignatius-rules>', + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', {}, 'ROWS'); + assert( + replaced.includes('never edit the `<ignatius-index>` block'), + 'FAIL(15): the hand-authored rules block must survive untouched', + ); + assert( + readRegion(replaced, 'ignatius-index') === 'ROWS', + 'FAIL(15): the new region should be appended', + ); +} + +// 15b — genuine nested/unclosed/duplicate cases still throw with inline +// code present elsewhere in the file +{ + const content = [ + 'See the `<ignatius-rules>` tag name mentioned in code.', + '', + '<ignatius-index scope="a">', + '', + 'outer-start', + '<ignatius-index scope="b">', + '', + 'inner', + '', + '</ignatius-index>', + '', + 'outer-end', + '', + '</ignatius-index>', + '', + ].join('\n'); + + let threw = false; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'SAFE'); + } catch { + threw = true; + } + assert(threw, 'FAIL(15b): a genuinely nested same-name region must still throw'); +} + +// 15c — a line starting with a backtick is text, whatever it contains; the +// stray second column-0 closer below is what makes this file throw +{ + const content = [ + '<ignatius-index scope="a">', + '', + 'outer', + '`<ignatius-index scope="b"`>', + 'inner', + '</ignatius-index>', + 'outer-end', + '</ignatius-index>', + '', + ].join('\n'); + + let threw = false; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'SAFE'); + } catch { + threw = true; + } + assert(threw, 'FAIL(15c): a second column-0 closer must still throw even with a backtick-led line nearby'); +} + +// 16 — a one-space-indented and a tab-indented <ignatius-index> tag inside +// rules are text, not boundaries: no throw, bytes survive +{ + const content = [ + '<ignatius-rules>', + '', + ' <ignatius-index>', + '\t<ignatius-index>', + '', + '</ignatius-rules>', + '', + ].join('\n'); + + const replaced = replaceRegion(content, 'ignatius-index', {}, 'ROWS'); + assert( + replaced.includes(' <ignatius-index>') && replaced.includes('\t<ignatius-index>'), + 'FAIL(16): indented tag mentions inside rules must survive verbatim', + ); + assert( + readRegion(replaced, 'ignatius-index') === 'ROWS', + 'FAIL(16): the new region should be appended', + ); +} + +// 17 — an orphan closer alone throws +{ + const content = ['# Heading', '', '</ignatius-index>', ''].join('\n'); + let threw = false; + try { + replaceRegion(content, 'ignatius-index', {}, 'ROWS'); + } catch { + threw = true; + } + assert(threw, 'FAIL(17): an orphan closing tag with nothing open must throw'); +} + +// 18 — a mismatched closer throws, naming both tags +{ + const content = ['<ignatius-index scope="a">', '', 'rows', '', '</ignatius-rules>', ''].join('\n'); + let message = ''; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'new'); + } catch (e) { + message = e instanceof Error ? e.message : String(e); + } + assert( + message.includes('ignatius-index') && message.includes('ignatius-rules'), + `FAIL(18): a mismatched closer must name both tags, got ${JSON.stringify(message)}`, + ); +} + +// 19 — a whole column-0 tag inside inline code, plus a second closer, throws +// (this used to silently truncate the file) +{ + const content = [ + '<ignatius-index scope="a">', + '', + '`<ignatius-index scope="b">`', + 'inner', + '</ignatius-index>', + 'outer-end', + '</ignatius-index>', + '', + ].join('\n'); + + let threw = false; + try { + replaceRegion(content, 'ignatius-index', { scope: 'a' }, 'SAFE'); + } catch { + threw = true; + } + assert(threw, 'FAIL(19): a stray second column-0 closer must throw even when a whole tag sits in inline code'); +} + +// 20 — a tag pair with nothing but the line break between them: an empty +// region, not an unclosed one +{ + const content = '<ignatius-x>\n</ignatius-x>'; + assert( + readRegion(content, 'ignatius-x') === '', + `FAIL(20): readRegion should return an empty string for a back-to-back pair, got ${JSON.stringify(readRegion(content, 'ignatius-x'))}`, + ); + const replaced = replaceRegion(content, 'ignatius-x', {}, 'new'); + assert( + readRegion(replaced, 'ignatius-x') === 'new', + 'FAIL(20): replaceRegion must be able to replace a back-to-back pair', + ); +} + +// 21 — every thrown message carries a 1-based line number, and the nested / +// orphan-closer messages carry the word "indent" +{ + const nestedContent = [ + '<ignatius-rules>', + '', + '<ignatius-index scope="a">', + '', + '</ignatius-rules>', + '', + ].join('\n'); + let nestedMessage = ''; + try { + replaceRegion(nestedContent, 'ignatius-index', { scope: 'a' }, 'x'); + } catch (e) { + nestedMessage = e instanceof Error ? e.message : String(e); + } + assert(/line \d+/.test(nestedMessage), `FAIL(21): nested message must carry a line number, got ${JSON.stringify(nestedMessage)}`); + assert(nestedMessage.includes('indent'), `FAIL(21): nested message must mention indenting, got ${JSON.stringify(nestedMessage)}`); + + const orphanContent = ['# H', '', '</ignatius-index>', ''].join('\n'); + let orphanMessage = ''; + try { + replaceRegion(orphanContent, 'ignatius-index', {}, 'x'); + } catch (e) { + orphanMessage = e instanceof Error ? e.message : String(e); + } + assert(/line \d+/.test(orphanMessage), `FAIL(21): orphan-closer message must carry a line number, got ${JSON.stringify(orphanMessage)}`); + assert(orphanMessage.includes('indent'), `FAIL(21): orphan-closer message must mention indenting, got ${JSON.stringify(orphanMessage)}`); + + const mismatchContent = ['<ignatius-index scope="a">', '', 'x', '', '</ignatius-rules>', ''].join('\n'); + let mismatchMessage = ''; + try { + replaceRegion(mismatchContent, 'ignatius-index', { scope: 'a' }, 'x'); + } catch (e) { + mismatchMessage = e instanceof Error ? e.message : String(e); + } + assert(/line \d+/.test(mismatchMessage), `FAIL(21): mismatch message must carry a line number, got ${JSON.stringify(mismatchMessage)}`); + + const unclosedContent = ['<ignatius-index scope="a">', '', 'x', ''].join('\n'); + let unclosedMessage = ''; + try { + replaceRegion(unclosedContent, 'ignatius-index', { scope: 'a' }, 'x'); + } catch (e) { + unclosedMessage = e instanceof Error ? e.message : String(e); + } + assert(/line \d+/.test(unclosedMessage), `FAIL(21): unclosed message must carry a line number, got ${JSON.stringify(unclosedMessage)}`); + + const duplicateContent = [ + '<ignatius-index scope="a">', + '', + 'first', + '', + '</ignatius-index>', + '', + '<ignatius-index scope="b">', + '', + 'second', + '', + '</ignatius-index>', + '', + ].join('\n'); + let duplicateMessage = ''; + try { + replaceRegion(duplicateContent, 'ignatius-index', { scope: 'a' }, 'x'); + } catch (e) { + duplicateMessage = e instanceof Error ? e.message : String(e); + } + assert(/lines \d+ and \d+/.test(duplicateMessage), `FAIL(21): duplicate message must carry both line numbers, got ${JSON.stringify(duplicateMessage)}`); +} + +console.log('test-router-region: OK'); diff --git a/test/checks/test-validate-index.ts b/test/checks/test-validate-index.ts new file mode 100644 index 0000000..1f26db9 --- /dev/null +++ b/test/checks/test-validate-index.ts @@ -0,0 +1,293 @@ +/** + * test-validate-index.ts — `ignatius validate --index` (spec SC9). + * + * Covers: an indexed model reports no drift; editing an entity makes + * `index.stale` fire naming the affected folder; a never-indexed model is + * reported entirely stale; plain `validate` does no hashing at all (the + * `--index` flag is what triggers the work); `--index` writes nothing; + * `index.stale` is Class B and drives a non-zero exit while `index.orphaned` + * is Class A and does not; an unreadable router target surfaces + * `index.unreadable_target` instead of a silent, permanently-clean digest; + * a mid-line `<ignatius-index` mention is not `index.orphaned`; a column-0 + * `<ignatius-index-legacy digest="...">` tag is not read as the router's own + * stored digest. + */ + +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { resolve } from 'path'; +import { parseModels } from '../../src/model/parse'; +import { parseFlows } from '../../src/flows/flow-parse'; +import { buildRouters } from '../../src/router/build'; +import { writeRouters } from '../../src/router/write'; +import { validateIndex, RULES } from '../../src/model/validate'; +import { defaultTheme } from '../../src/theme/theme-defaults'; +import { defaultBranding } from '../../src/theme/branding-defaults'; +import { assert } from '../assert'; + +const ROOT = resolve(import.meta.dir, '../..'); +const SOURCE_MODEL = `${ROOT}/models/key-inherited`; +const BASE_TMP = resolve(ROOT, 'tmp/fixtures/validate-index-test'); + +rmSync(BASE_TMP, { recursive: true, force: true }); +mkdirSync(BASE_TMP, { recursive: true }); + +async function parseAndIndex(dir: string) { + const { model } = await parseModels(dir); + const { flowModel } = await parseFlows(dir); + const routers = await buildRouters(dir, model, flowModel); + await writeRouters(dir, routers); + return { model, flowModel, routers }; +} + +function deleteRouters(dir: string, indexFileName: string): void { + for (const entry of readdirSync(dir)) { + const path = `${dir}/${entry}`; + if (statSync(path).isDirectory()) { + deleteRouters(path, indexFileName); + } else if (entry === indexFileName) { + rmSync(path); + } + } +} + +function snapshotTree(dir: string): Map<string, string> { + const snapshot = new Map<string, string>(); + function walk(sub: string): void { + for (const entry of readdirSync(sub)) { + const path = `${sub}/${entry}`; + if (statSync(path).isDirectory()) { + walk(path); + } else { + snapshot.set(path.slice(dir.length + 1), readFileSync(path, 'utf8')); + } + } + } + walk(dir); + return snapshot; +} + +async function run(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const proc = Bun.spawn(['bun', `${ROOT}/src/cli/cli.ts`, ...args], { stdout: 'pipe', stderr: 'pipe' }); + const timer = setTimeout(() => proc.kill(), 30_000); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + clearTimeout(timer); + return { exitCode, stdout, stderr }; +} + +// --- (1) an indexed, unmodified model reports no drift --- +{ + const dir = `${BASE_TMP}/clean`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + const { model, flowModel } = await parseAndIndex(dir); + + const result = await validateIndex(dir, model, flowModel); + const stale = result.globalErrors.filter(e => e.ruleId === 'index.stale'); + assert(stale.length === 0, `FAIL (1): expected no index.stale on a freshly indexed model, got ${JSON.stringify(stale)}`); + assert(result.entityErrors.length === 0, `FAIL (1): expected no index.orphaned on a freshly indexed model, got ${JSON.stringify(result.entityErrors)}`); + console.log('PASS (1): an indexed, unmodified model reports no index.stale/index.orphaned'); +} + +// --- (2) editing one entity makes index.stale fire, naming the affected folder --- +{ + const dir = `${BASE_TMP}/edited`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + await parseAndIndex(dir); + + const partyPath = `${dir}/data/identity/Party.md`; + writeFileSync(partyPath, `${readFileSync(partyPath, 'utf8')}\n\nA sentence added to change this entity's content hash.\n`); + + const { model } = await parseModels(dir); + const { flowModel } = await parseFlows(dir); + const result = await validateIndex(dir, model, flowModel); + const staleIds = result.globalErrors.filter(e => e.ruleId === 'index.stale').map(e => e.omitted.id); + assert(staleIds.includes('data/identity/index.md'), `FAIL (2): expected data/identity/index.md among stale routers, got ${JSON.stringify(staleIds)}`); + console.log('PASS (2): editing an entity makes index.stale fire, naming its folder'); +} + +// --- (3) a model with no routers at all is reported entirely stale --- +{ + const dir = `${BASE_TMP}/never-indexed`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + const { model } = await parseModels(dir); + const { flowModel } = await parseFlows(dir); + const indexFile = model._meta?.indexFile ?? 'index.md'; + deleteRouters(dir, indexFile); + + const routers = await buildRouters(dir, model, flowModel); + const result = await validateIndex(dir, model, flowModel); + const stale = result.globalErrors.filter(e => e.ruleId === 'index.stale'); + const allStale = stale.length === routers.length; + assert( + allStale, + `FAIL (3): expected every one of ${routers.length} organizing folders to report index.stale on a never-indexed model, got ${stale.length}`, + ); + const wroteNothing = !existsSync(`${dir}/${indexFile}`); + assert(wroteNothing, 'FAIL (3): validateIndex must not write a router even for a never-indexed model'); + if (allStale && wroteNothing) { + console.log('PASS (3): a never-indexed model is reported entirely stale, and nothing is written'); + } +} + +// --- (4) plain `validate` does no hashing; `--index` is what triggers the work --- +{ + const dir = `${BASE_TMP}/flag-gated`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + await parseAndIndex(dir); + const partyPath = `${dir}/data/identity/Party.md`; + writeFileSync(partyPath, `${readFileSync(partyPath, 'utf8')}\n\nDirties the digest without touching parse-time validity.\n`); + + const plain = await run(['validate', dir]); + assert(plain.exitCode === 0, `FAIL (4): plain validate on a stale-but-parseable model should exit 0, got ${plain.exitCode}\n${plain.stderr}`); + assert(!plain.stderr.includes('index.stale'), `FAIL (4): plain validate must never report index.stale, got:\n${plain.stderr}`); + + const indexed = await run(['validate', dir, '--index']); + assert(indexed.exitCode === 1, `FAIL (4): validate --index on the same stale model should exit 1, got ${indexed.exitCode}`); + assert(indexed.stderr.includes('index.stale'), `FAIL (4): validate --index should report index.stale, got:\n${indexed.stderr}`); + console.log('PASS (4): the --index flag — not model content alone — is what triggers digest checking'); +} + +// --- (5) `validate --index` writes nothing --- +{ + const dir = `${BASE_TMP}/no-writes`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + const { model, flowModel } = await parseAndIndex(dir); + + const before = snapshotTree(dir); + await validateIndex(dir, model, flowModel); + const after = snapshotTree(dir); + + assert(before.size === after.size, `FAIL (5): file count changed after validate --index: ${before.size} -> ${after.size}`); + for (const [path, content] of before) { + assert(after.get(path) === content, `FAIL (5): ${path} changed after validate --index (writes nothing)`); + } + console.log('PASS (5): validate --index writes nothing (tree snapshot identical before/after)'); +} + +// --- (6) index.stale is Class B and drives a non-zero exit; index.orphaned is Class A and does not --- +{ + assert(RULES['index.stale'].class === 'B', 'FAIL (6): index.stale must be Class B — --index is opt-in and the fix is one command, so it hard-fails deliberately, not by accident'); + assert(RULES['index.orphaned'].class === 'A', 'FAIL (6): index.orphaned must be Class A — a leftover file warns, it does not fail the build'); + + const dir = `${BASE_TMP}/orphaned`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + const { model, flowModel } = await parseAndIndex(dir); + + // Leave behind a router-shaped file under a name the current index_file + // config no longer produces — simulating the aftermath of an index_file + // rename without deleting the old file. Placed at the model root: root is + // not glob-scanned for entity/group/flow frontmatter, so this stays a pure + // index.orphaned case, uncomplicated by an unrelated parse.invalid_yaml on + // a leftover file the parser would otherwise mistake for an entity. + const rootIndex = readFileSync(`${dir}/index.md`, 'utf8'); + writeFileSync(`${dir}/old-index.md`, rootIndex); + + const result = await validateIndex(dir, model, flowModel); + const stale = result.globalErrors.filter(e => e.ruleId === 'index.stale'); + const orphaned = result.entityErrors.filter(e => e.ruleId === 'index.orphaned'); + assert(stale.length === 0, `FAIL (6): the orphaned leftover must not itself be reported as index.stale, got ${JSON.stringify(stale)}`); + assert( + orphaned.some(e => e.entityId === 'old-index.md'), + `FAIL (6): expected index.orphaned naming old-index.md, got ${JSON.stringify(orphaned)}`, + ); + + const cliResult = await run(['validate', dir, '--index']); + assert(cliResult.exitCode === 0, `FAIL (6): index.orphaned alone must not drive a non-zero exit, got ${cliResult.exitCode}\n${cliResult.stderr}`); + assert(cliResult.stderr.includes('index.orphaned'), `FAIL (6): expected an index.orphaned warn line, got:\n${cliResult.stderr}`); + console.log('PASS (6): index.stale (Class B) hard-fails, index.orphaned (Class A) only warns'); +} + +// --- (7) an unreadable entity target surfaces index.unreadable_target, not a zero digest --- +{ + const dir = `${BASE_TMP}/unreadable`; + mkdirSync(dir, { recursive: true }); + + const model = { + groups: {}, + nodes: [{ + id: 'Ghost', + classification: 'Independent', + sourcePath: 'data/Ghost.md', // never written to disk + pk: ['id'], + columns: {}, + alternateKeys: [], + bodyHtml: '', + }], + edges: [], + subtypeClusters: [], + theme: defaultTheme, + branding: defaultBranding, + }; + const flowModel = { diagrams: [], modelDir: dir, externals: [] }; + + const routers = await buildRouters(dir, model, flowModel); + const dataRouter = routers.find(r => r.relPath === 'data/index.md'); + assert(!!dataRouter, 'FAIL (7): expected a data/index.md router even with an unreadable target'); + assert( + dataRouter!.digest !== 'sha256:0000000000000000000000000000000000000000000000000000000000000000', + 'FAIL (7): an unreadable target must not fall back to the old stable all-zero digest', + ); + + const result = await validateIndex(dir, model, flowModel); + const unreadableFindings = result.globalErrors.filter(e => e.ruleId === 'index.unreadable_target'); + assert( + unreadableFindings.some(e => e.omitted.id === `${dir}/data/Ghost.md`), + `FAIL (7): expected index.unreadable_target naming ${dir}/data/Ghost.md, got ${JSON.stringify(unreadableFindings)}`, + ); + console.log('PASS (7): an unreadable router target surfaces index.unreadable_target instead of a silent zero digest'); +} + +// --- (8) a non-router file that merely mentions `<ignatius-index` mid-line +// is not reported as index.orphaned --- +{ + const dir = `${BASE_TMP}/orphaned-mid-line-mention`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + const { model, flowModel } = await parseAndIndex(dir); + + writeFileSync( + `${dir}/orphaned-mention.md`, + 'Some prose that mentions `<ignatius-index>` as an example, not a real region.\n', + ); + + const result = await validateIndex(dir, model, flowModel); + const orphaned = result.entityErrors.filter(e => e.ruleId === 'index.orphaned'); + assert( + !orphaned.some(e => e.entityId === 'orphaned-mention.md'), + `FAIL (8): a mid-line mention of <ignatius-index must not be reported as index.orphaned, got ${JSON.stringify(orphaned)}`, + ); + console.log('PASS (8): a mid-line mention of <ignatius-index is not reported as index.orphaned'); +} + +// --- (9) a column-0 <ignatius-index-legacy digest="..."> tag must not be +// read as the router's own stored digest --- +{ + const dir = `${BASE_TMP}/legacy-tag-name`; + cpSync(SOURCE_MODEL, dir, { recursive: true }); + const { model, flowModel, routers } = await parseAndIndex(dir); + + const target = routers.find(r => r.relPath === 'data/identity/index.md'); + assert(!!target, 'FAIL (9): expected data/identity/index.md among the built routers'); + + // Rename the real router's opening tag to a differently-named tag but keep + // its true digest as the attribute value — if the digest regex is read + // matching this tag anyway, the mismatch it should report gets masked. + const path = `${dir}/data/identity/index.md`; + const rewritten = readFileSync(path, 'utf8').replace( + '<ignatius-index ', + `<ignatius-index-legacy digest="${target!.digest}" `, + ); + writeFileSync(path, rewritten); + + const result = await validateIndex(dir, model, flowModel); + const stale = result.globalErrors.filter(e => e.ruleId === 'index.stale' && e.omitted.id === 'data/identity/index.md'); + assert( + stale.length === 1, + `FAIL (9): a column-0 <ignatius-index-legacy> tag must not be mistaken for the router's own digest tag, expected index.stale, got ${JSON.stringify(stale)}`, + ); + console.log('PASS (9): a column-0 <ignatius-index-legacy> tag is not mistaken for the router digest'); +} + +console.log('test-validate-index: OK');