diff --git a/backlog/tasks/task-562 - Export-a-selection-of-components-as-a-plan-and-import-it-as-drafts.md b/backlog/tasks/task-562 - Export-a-selection-of-components-as-a-plan-and-import-it-as-drafts.md index 95463db5..e19b4979 100644 --- a/backlog/tasks/task-562 - Export-a-selection-of-components-as-a-plan-and-import-it-as-drafts.md +++ b/backlog/tasks/task-562 - Export-a-selection-of-components-as-a-plan-and-import-it-as-drafts.md @@ -1,10 +1,11 @@ --- id: TASK-562 title: Export a selection of components as a plan and import it as drafts -status: To Do -assignee: [] +status: Done +assignee: + - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-08 15:31' +updated_date: '2026-10-11 19:21' labels: - senior dependencies: [] @@ -19,6 +20,38 @@ references: - relationship-markdown - docs/component-markdown.md - docs/product-model.md + - src/plan.ts + - src/relation.ts + - src/relationship-storage.ts + - test-bun/plan.test.ts + - src/http-relationships.ts + - test-bun/http-relationships.test.ts +modified_files: + - src/plan.ts + - src/relationship-markdown.ts + - src/authoring.ts + - src/write-commands.ts + - src/viewers/web/data.ts + - src/viewers/web/map-session.ts + - src/viewers/web/authoring.ts + - src/viewers/web/organisms/writes.ts + - docs/plans.md + - docs/component-markdown.md + - groma/systems/groma-md/containers/cli/components/plan.md + - test-bun/plan.test.ts + - src/architecture-markdown.ts + - src/relationship-storage.ts + - src/relation.ts + - test-bun/source-relationships.test.ts + - groma/systems/groma-md/containers/cli/components/relationship-markdown.md + - src/http-relationships.ts + - test-bun/http-relationships.test.ts + - src/architecture-reader.ts + - src/markdown-emitter.ts + - src/okf-profile.ts + - src/viewers/web/page.ts + - src/groma-filesystem.ts + - src/flow-authoring.ts type: feature ordinal: 2 --- @@ -33,18 +66,82 @@ Build on drafts (src/draft.ts, src/accept.ts, draft records in src/architecture- ## Acceptance Criteria -- [ ] #1 groma plan export --to writes a plan bundle in the OKF profile: the selected elements with status draft and their overviews, the relationships between them as draft rows, the flows that lie entirely within the selection, and the parents they need by id only. The Markdown contract documents the bundle. -- [ ] #2 groma plan import [--parent ] creates the elements as ghosts at the paths they will keep, the relationships as draft rows, and a draft record named after the plan that tags every imported element. Ids are kept. A colliding id or a missing parent fails the whole import with a message naming it, and nothing is written. -- [ ] #3 On the web map, a multi-selection of components offers Export as plan beside Group as and Combine into, and the bundle is written into the repository or downloaded. -- [ ] #4 After an import, a scan matches the ghosts when their code arrives and groma accept works unchanged. Existing draft behaviour and its tests stay as they are. -- [ ] #5 A round trip works: a plan exported from one fixture repository and imported into an empty one yields equal elements, relationships and overviews. -- [ ] #6 groma plan without arguments prints what a plan is and the two commands, and docs/ gets a page with an example plan. +- [x] #1 groma plan export --to writes a plan bundle in the OKF profile: the selected elements with status draft and their overviews, the relationships between them as draft rows, the flows that lie entirely within the selection, and the parents they need by id only. The Markdown contract documents the bundle. +- [x] #2 groma plan import [--parent ] creates the elements as ghosts at the paths they will keep, the relationships as draft rows, and a draft record named after the plan that tags every imported element. Ids are kept. A colliding id or a missing parent fails the whole import with a message naming it, and nothing is written. +- [x] #3 On the web map, a multi-selection of components offers Export as plan beside Group as and Combine into, and the bundle is written into the repository or downloaded. +- [x] #4 After an import, a scan matches the ghosts when their code arrives and groma accept works unchanged. Existing draft behaviour and its tests stay as they are. +- [x] #5 A round trip works: a plan exported from one fixture repository and imported into an empty one yields equal elements, relationships and overviews. +- [x] #6 groma plan without arguments prints what a plan is and the two commands, and docs/ gets a page with an example plan. ## Definition of Done -- [ ] #1 Acceptance criteria have objective verification evidence. -- [ ] #2 Relevant checks pass and changes remain task-scoped. -- [ ] #3 Public contracts or documentation are updated when behavior changes. -- [ ] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. +- [x] #1 Acceptance criteria have objective verification evidence. +- [x] #2 Relevant checks pass and changes remain task-scoped. +- [x] #3 Public contracts or documentation are updated when behavior changes. +- [x] #4 Implementation Plan reflects the final approach; correction history and verification are recorded in Implementation Notes. + +## Implementation Plan + + +1. Export selected C4 meaning through existing plan and Markdown storage owners. Escape decoded prose/table values at serialization, and keep HTTP inference text plain so it is escaped once. +2. Derive external parents from element groma.parent IDs. Import resolves existing destination parents, builds canonical ghost paths, and validates the final rendered documents before writing. The supporting plan record has only type, title and outcome prose; it adds no C4 concept. +3. Existing stored concept-ID draft relationships may be edited and removed. As confirmed by Alex, explicit acceptance transfers their authored text only to the unique existing current exact-file pair between their component owners. Zero or multiple pairs retain the draft with a clear error. Scanner matching and ghost acceptance keep their existing lifecycle. +4. Verification: fixture round-trip checks literal overview, relationship description/technology and ordered flow action meaning (AC1/AC5); element parent IDs check missing-parent atomic rejection and --parent replacement (AC2); imported edit/remove and scanner-backed zero/one/multiple evidence checks cover the reported lifecycle failure (AC4). Existing HTTP literal-path coverage checks that inference and storage do not escape twice. Test additions address gaps in plan and concept-draft coverage, not new behavior. +5. Verify real live browser multi-selection export, run focused checks and bun run check, complete cold simplicity and full-context complexity reviews, and perform implementer specification/quality reviews. Curate task-caused architecture through the CLI and make one local task commit without publishing. + +6. Maintainability pass, 11 October (review findings A to F). A: table text gets a backslash only where Comark reads syntax, so ordinary punctuation is stored as written; one owner in src/architecture-markdown.ts serves relationship rows and the plan Steps table, and a cell is always one line. B: a destination named by the browser must be a new directory inside the repository; the rule sits beside exportPlan in src/plan.ts and the web route uses it. C: the pane confirms an export in its own element and form class, and the route stops reloading the map, which erased the confirmation. D: src/plan.ts reuses the reader's document parser and the emitter's front matter, drops its casts, and names bundle documents with an explicit label. E: imports follow each file's grouping. F: docs/plans.md states how to edit, remove and accept an imported relationship. + +7. Test: extend 'imported concept draft relationships can be edited and removed' in test-bun/plan.test.ts. Rule and authority: review decision A and OKF readability, stored relationship text reads back as written and ordinary punctuation carries no backslash. Wrong result caught: 'Reads user.name, then retries (HTTPS/JSON)' stored as 'Reads user\.name\, ...', or text Comark reads as syntax (*pending*, __init__, A | B, :name, {id}, a URL with an underscore) lost or changed on read. Gap: current tests read only the model for '|' and '*' and never inspect the stored row. + +8. Test: add 'the live map exports a plan only into a new directory inside its repository' to test-bun/plan.test.ts, through the /plan/export route of the web map session. Rule and authority: acceptance criterion 3 (the web writes the bundle into the repository) and review decision B. Wrong result caught: a POST with to '../x' or an absolute path creates a directory outside the repository. Gap: no test drives this route; exportPlan tests cover only the command-line function, which may write anywhere. + +9. Review list of 11 October, second pass. D1: a plan carries each element's Markdown body as written; export and import replace only the front matter (status, parent, no code, no group, the draft tag on import) and rebuild the relationship sections from the plan's draft rows. D2: people and external systems travel and arrive as stable declarations, tagged with the plan's draft record. D3: exportPlan refuses a destination inside the Groma directory, for the command line and the web. D4: an underscore between two ASCII letters or digits and an ampersand that starts no character reference are stored plain. Also: the plan writes leave the shared authoring writes and the command line calls plan.ts; one declared-concept predicate; relation.ts keeps planned and the index in Ends, always passes drop as an array and passes the accepted pair explicitly; one OKF index renderer and one flow renderer; readPlan requires index.md and plan.md first and reads the bundle ids once; placement is one pass ordered by kind. + +10. Tests of the second pass. (a) Extend the plan round trip in test-bun/plan.test.ts. Rule and authority: decisions D1 and D2. Wrong result caught: a code span, link, emphasis or a whole section such as '## Requirements' missing from an imported document, an overview that starts with an escaped block marker imported empty, a person or external system imported as a draft that no command can make stable. Gap: the test compares decoded text only and never reads status. (b) New test: exportPlan refuses a directory inside the Groma directory. Rule and authority: decision D3. Wrong result caught: groma/plans/x is written and every later load fails. Gap: none covers the destination of the shared export. (c) Move the escape assertions to a relationship storage test in test-bun/source-relationships.test.ts and add 'user_id' and 'Auth & billing'. Rule and authority: decisions A and D4. Wrong result caught: user\_id or Auth \& billing in a stored row, or text Comark reads as syntax changed on read. Gap: the assertions sit in a plan test named for edit and remove, and the two plain cases are untested. (d) Anchor the missing-parent assertion on the quoted id, so a path containing /api/ in an unrelated error no longer satisfies it. (e) Extract the setup that two imported-relationship tests repeat; no assertion changes. + +11. Test, added to the plan round trip after a mutation check. Rule and authority: the task description and the Markdown contract, a plan carries no code ownership and no group. Wrong result caught: an imported ghost keeps the groma.code or groma.group of the repository it came from. Gap: nothing asserted it, and D1 changed how the front matter is produced (keeping groma.code passed every test). + + +## Implementation Notes + + +Added the plan bundle and import/export path, CLI help, live multi-selection export, and docs/plans.md. Verified bare plan help with bun src/cli.ts plan (the global groma command points to the separately installed package). No installs, builds or test runners have been run. Existing scanner and accept implementations and their tests remain unchanged. + +CLI walkthrough using a temporary copy of test/fixtures/flows: exported service, api, entry, worker, requester and journal; imported into an empty initialized destination. Inspection found 6 equal element meanings/overviews, 4 equal relationship meanings, and 1 equal ordered flow, excluding intentional status/code/draft differences. Reimport failed naming the existing request-plan draft; all destination file hashes remained unchanged. The imported entry view renders its four incoming/outgoing draft relationships. + +A two-component export lists only api as its required parent and omits the external flow. Import into an empty initialized architecture failed naming api. After drafting a destination system/container with existing commands, importing that same bundle with --parent application succeeded at the destination canonical paths. The cold simplicity review found no blocking defects or necessary simplifications. + +Extended the temporary CLI walkthrough with source files and the configured local TypeScript scanner. Scan reported matched 2, entry acquired src/entry.ts while staying draft, and accept entry succeeded unchanged. Existing draft tests were neither changed nor run. Implementer specification/quality review and the separate final complexity review found no blocking supported-flow defect. Live browser interaction remains unverified because no browser/build/test run was performed. + +Repository groma scan completed: created 1, refreshed 111, matched 0, findings 38. Curated the task-owned plan component in the Groma application as Architecture plans, under Architecture records; existing unrelated agents architecture changes were left alone. git diff --check passed for the touched files. Browser export is implemented via the existing selection controls and shared write route, but AC 3 remains unchecked pending actual browser interaction. No installs, builds, test runners, commits or task-status changes were performed. + +Review fixes, 9 October: Added focused regressions and first observed literal overview loss plus the concept-ID edit rejection before fixes. All 115 focused plan, relationship, flow and HTTP tests now pass. Cold simplicity review passed with no blocking findings; applied its RelationEnds comment clarification. The first full check exposed double escaping at the existing HTTP inference boundary; removed that duplicate serializer responsibility, kept the existing loaded-path assertion, and reran checks. Scanner matching test uses one explicit module boundary so it exercises the existing matching contract rather than changing that contract. The live browser walkthrough selected Entry and Worker, used Export as plan, and wrote plans/browser-plan. A separate import preserved literal *pending*, description Dispatches A | B and technology Callback | queue. Evidence is /private/tmp/groma-pr120-browser-evidence.json with screenshot /private/tmp/groma-pr120-browser-export.jpg. Implementer specification review maps the fixes to AC1/AC2/AC4/AC5 and now verifies AC3 through actual browser interaction; bare CLI plan help and docs prove AC6. Implementer quality review traced export → serializer → import validation → ghost storage and relationship control → existing evidence → validated storage, checked test failures against their claims, and found no remaining supported-flow defect or added complexity. The latest full check is still running, so its result is not yet claimed. + +Final verification: bun run check exited 0 after the HTTP serialization correction. Biome checked 602 files; changed files have no warnings or complexity findings. TypeScript 7.1.0-dev.20260924.1 passed. Node: 16 passed, 0 failed. Bun: 781 passed, 51 skipped for unavailable optional runtimes, 0 failed across 147 files. Successful output is /private/tmp/groma-pr120-final-check.log. The cold simplicity and final full-context complexity reviews both passed with no blocking finding or material simplification; the final reviewer confirmed clear domain ownership and requested only this evidence/status update. Two local scans created no components; the existing relationship architecture record was updated through the CLI and survived rescan. Existing unavailable native scanner workers kept saved data and do not affect the TypeScript fixture or passing check. The implementation uses Markdown text and existing source ownership rather than a project, language or framework-specific rule; TypeScript is only the supported regression fixture. git diff --check passed. All acceptance criteria and Definition of Done items now have evidence, including the formerly unverified live browser Export as plan action. + +Maintainability pass, 11 October (review findings A to F). A: escapeMarkdownText in src/architecture-markdown.ts now puts a backslash only where Comark reads inline syntax: the characters \ ` * _ [ < & ~ { |, a colon that starts an inline component (:name at the start of a word), and the colon of scheme:// (Comark links such an address and copies it raw, which kept our backslashes as text and grew on every rewrite). The set was derived by probing Comark 0.7.0 per character and checked with 60,000 random punctuation-heavy strings through the reader's parser: the only differences left are percent-encoded bytes that Comark shows decoded inside a linked address, which main does too. escapeTableCell adds the one-line rule for cells and serves relationship rows and the plan Steps table; overviews keep their line breaks. Ordinary punctuation is stored as written, HTTP labels keep the stored form main gave them, and none of this repository's 126 relationship cells would change on a rewrite. + +B: exportPlanIntoRepository in src/plan.ts refuses a destination outside the repository; the /plan/export route calls it, the command line keeps exportPlan. exportPlan now refuses an existing directory with a sentence instead of a raw EEXIST. C: headless Chromium showed the old confirmation never appeared: the route reloaded the map after every export and the repaint cleared the form. The route no longer goes through writeResponse (an export changes nothing the map shows), the form has its own class export-as-plan, and the pane confirms in a separate .confirmation element. D: plan.ts uses parseArchitectureDocument (split out of the reader's file read), the emitter's sourceWithFrontmatter, OKF_VERSION, a new PLAN_TYPE and ArchitectureRecords; its casts are gone; bundle documents carry the explicit label plan/ instead of a made-up groma/ path, so import errors name the plan's files and work the same in a .groma project. E: plan imports and writer entries follow each file's grouping in src/authoring.ts, src/viewers/web/data.ts and src/viewers/web/authoring.ts. F: docs/plans.md lists the three relation commands and what acceptance needs; docs/component-markdown.md states how cell text is stored. The plan component overview was updated through groma edit. + +Verification of this pass. Both tests were run before the fixes and failed: the extended relationship test on the refused line break, the route test with status 200 for '../outside-plan'. Each escape rule was then removed in turn and the relationship test failed every time. bun run typecheck passed. bunx biome lint: 1 warning and 2 infos, the same three as before the pass, none in a changed file. bun test --parallel=2 --max-concurrency 1 --timeout 20000 over 17 focused files: 167 passed. node --import=tsx --test test/*.test.ts: 16 passed. bun scripts/lint-web-scrollbars.ts exited 0. Headless Chromium on a copy of test/fixtures/flows: exporting two selected components shows 'Exported to plans/browser-plan' and keeps the typed directory; '../p120-outside' shows the refusal and creates nothing; exporting to the same directory again shows the already-exists sentence. A command-line walkthrough on fixture copies covered export, the existing-directory refusal, import, and edit, accept and remove of the imported relationship. The full bun run check was not run in this pass; the orchestrator runs it. No source file was added, moved or deleted, so no scan was run. + +Not changed, for a decision. 1) The reader parses stored files with Comark's streaming auto-close and linkify left on. Auto-close rewrites the last line of every document, so text such as a[0](x in the last relationship row is refused on save, and an unclosed [ in a last-line overview gains a placeholder; linkify shows %XX decoded. Both predate this task and no escaping can prevent them; parsing with autoClose and linkify off in the reader would. 2) Plan export re-encodes decoded overviews, so an overview line that begins with an escaped block marker (\# or \-) would turn into a heading or list on import. 3) Exporting into the Groma directory itself is not refused. 4) Four older copies of the parse-and-cast (markdown-emitter, flow-authoring, relationship-storage, curate) could call parseArchitectureDocument. 5) An underscore inside a word and an ampersand in prose still get a backslash (user\_id, Auth \& billing); a positional rule for each is possible if quieter storage is wanted. + +Checked for item 3: exporting to groma/plans/x makes the architecture unloadable (component entry 'is not stored at its canonical C4 path') until that directory is deleted. Checked for item 1: 'Reads a[0](x' in the last row of a document is refused with 'description and technology must not be empty'; an overview ending in 'Reads [a' on the last line reads back with '(comark:incomplete-link)' appended. + +Second pass on the consolidated review list, 11 October. D1: plannedSource in src/plan.ts builds an element document for both directions from the source document: the Markdown body as written, the front matter with status replaced and groma.code, groma.group and an earlier draft tag removed (plus the draft tag on import), and the relationship sections rebuilt by withStoredRelationships from the plan's draft rows. The prose escape is used in plan.ts only for a flow overview; flows are still written again from their meaning because their Steps links depend on where the elements land. D2: status is stable for a person or an external system and draft for software, in the bundle and after import; everything imported carries the plan's draft tag. D3: exportPlan refuses a destination inside the Groma directory, beside the existing-directory refusal, so the command line and the web share it. D4: an underscore between two ASCII letters or digits and an ampersand that starts no character reference are stored plain. + +Second pass, the rest of the list. The plan entries left the shared authoring writes; src/write-commands.ts calls plan.ts, which takes the lock itself. isDeclaredConcept in src/relationship-markdown.ts is the one declared-concept test, used by the reader, relation.ts and plan.ts. relation.ts keeps the source index and planned in Ends, always passes drop as an array, passes the accepted endpoints to writeRow explicitly and labels both cases of acceptanceEnds. withStoredRelationships takes RelationshipConnection rows. renderBundleIndex in src/okf-profile.ts writes the OKF index for the Groma directory and for a plan (the plan index now escapes labels and encodes links). renderFlowDocument in src/markdown-emitter.ts serves flow authoring and plan.ts. readPlan requires index.md and plan.md before it walks the directory and reads the bundle ids once. Import no longer overrides front matter for its first model (only the --parent replacement), leaves the missing-parent refusal to the model, and places elements in one pass ordered by kind. The escape functions state their input contract, the reason for each character in the colon rule and the guarding test. Docs: what a plan carries, declarations, the destination rules, that only an import writes a planned relationship between two internal ids, and that the plan.md body is the draft outcome and meant to be edited. The plan component overview was updated through groma edit. + +Verification of the second pass. Written first and failing before the changes: the round trip (formatting, the escaped '1986\.' paragraph and the Requirements section were lost), the Groma-directory refusal (the export succeeded) and the relationship text test (user\_id and Auth \& billing were stored). Mutations that then failed the round trip: status always draft, no draft tag, keeping groma.code, keeping groma.group. bun run typecheck passed. bunx biome lint: 1 warning and 2 infos, the same three as before, none in a changed file. bun test --parallel=2 --max-concurrency 1 --timeout 20000 over 20 focused files: 187 passed. node --import=tsx --test test/*.test.ts: 16 passed. Fuzz of escapeTableCell through the reader's parser, 59,741 strings including underscore and ampersand cases: 7 differences, all percent-encoded bytes shown decoded inside a linked address. None of this repository's 126 relationship cells would change. Command-line walkthrough on fixture copies: export with a formatted body and a Requirements section, the already-exists and inside-groma refusals, import (body identical, person stable and tagged), edit (stored as written), accept of an internal planned relationship (found 0), accept of a relationship with a person (accepted), remove (refused because a flow uses it), draft relation between two internal ids (refused). Headless Chromium: confirmation shown, and the outside, inside-groma and already-exists refusals shown in the error element. The full bun run check was not run. No source file was added, moved or deleted, so no scan was run. + +Orchestrator verification, 11 October 2026, on main with Bun 1.4.3 merged: bun run check exit 0 (788 Bun tests passed, 52 skipped, 0 failed). Repeated the two failures reported in review against this head: an imported relationship is now edited and removed by id, and export to ../ through the web rule and into groma/ is refused. A scan after the last commit changed nothing under groma/. + + +## Final Summary + + +groma plan export writes a selection as an OKF bundle and groma plan import brings it into another repository under one draft record. An element's Markdown body is carried as written; only its front matter (status, parent, no code or group, the plan's draft tag) and its relationship tables are replaced. People and external systems stay stable declarations; software arrives as drafts. Relationships between two imported concepts are draft rows that can be edited and removed by id, and accepted once exactly one current file pair joins the two owners. One function owns how relationship and flow table text is stored: it escapes only what Markdown would interpret, so ordinary punctuation, user_id and 'Auth & billing' are stored as written. Export refuses an existing directory and a directory inside the Groma folder; the web form additionally refuses a directory outside the repository. Verified: bun run check (16 Node tests; 788 Bun tests passed, 52 skipped for missing optional toolchains, 0 failed), a command-line walkthrough of export, both refusals, import, edit and remove on fixture copies, and the export form in headless Chromium. Open follow-ups for the owner: a flow's overview is re-encoded, so its inline formatting is not carried; relative links in a carried body can break when --parent moves the element; the reader's auto-close and linkify settings change some stored text on read (older than this task). + diff --git a/docs/component-markdown.md b/docs/component-markdown.md index 880dbdeb..4abedf43 100644 --- a/docs/component-markdown.md +++ b/docs/component-markdown.md @@ -286,7 +286,10 @@ system IDs as endpoints. This includes interactions without imports, such as an HTTP client and its endpoint. An actor or external-system declaration may use C4 concept links. Each ordered endpoint pair has one authored row. `Description` states the interaction; `Technology` states its mechanism or a -required constraint. Both cells are required and non-empty. A stored row may +required constraint. Both cells are required and non-empty. Groma stores each +on one line and puts a backslash before characters Markdown would read as +syntax, such as `*`, `_` or `|`, so the text reads back as written. A stored +row may name a file that currently has no component owner, for example after a detach: the row stays in its previous document and stays off the map. Once a scan gives that file an owner, it transfers the row into the new owner document and the @@ -444,3 +447,65 @@ TypeScript, NestJS, and PostgreSQL. The live [groma.md system](../groma/systems/groma/system.md) and [MVP draft](../groma/drafts/mvp.md) are a complete package example. + + +## Plan bundles + +A plan is an OKF 0.2 bundle containing a selected architecture fragment. Its +root `index.md` uses the normal `okf_version: "0.2"` declaration and Markdown +links. The supporting `plan.md` record has `type: Groma Plan`, a standard +`title` and outcome prose. The record has no C4 identity, containment level, +or box on the map. It is a Groma application-profile record, not a type +required by OKF or C4. Import copies its title and outcome into a draft record. + +A selected C4 document is carried with its Markdown body as written: the +overview, its formatting and links, and every authored section. A plan replaces +two things only. In the front matter, a system, container or component gets +`status: draft`, while a person or an external system stays a `stable` +declaration, because those are declared and never drafted. `groma.code`, +`groma.group` and an earlier `groma.draft` tag are left out. Every other field +stays, including the ID, type, title, description, technology and parent ID. +In the body, the relationship sections are rebuilt as described below. Only +selected elements are copied. A parent that is not selected appears by ID only, +in the `groma.parent` of its children, and the parent IDs remain authoritative. +Documents use the normal C4 paths relative to the bundle. + +Relationships within the selection become authored rows under +`## Draft relationships`, using links to the selected concept documents. No +other relationship row of a document is carried. Unlike current code +relationships, planned relationships may link internal software concepts +without source files. Only `groma plan import` writes such rows: +`groma draft relation` still requires source files, or a person or an external +system at one end. They remain draft rows when scanning or accepting an +element. This permits architecture outcomes to be described before +implementation exists. Existing edit and remove controls accept these +concept IDs. Accepting a planned internal relationship uses the unique current +source-file connection between its component owners, transfers the planned text +to that exact pair, and removes the concept draft. If no unique current pair +exists, acceptance leaves the draft unchanged and explains the missing or +ambiguous evidence. Scanning does not invent evidence from planned intent. +The existing exact-file rules for current code relationships are unchanged. + +Flows are included only when every step's endpoints are selected. A flow is +written again from its title, description, overview text and steps, so its +ordinary linked Steps table retains order and actions while formatting inside +its overview is not carried. A plan carries no implementation steps: a flow +describes an architecture scenario. + +`groma plan export --to ` creates a new bundle directory; +its basename supplies the plan title. The directory must not exist yet and +must lie outside the Groma directory, where every Markdown file is read as +architecture. `groma plan import [--parent ]` imports into an +initialized architecture. Without `--parent`, all referenced parents must +already exist. With it, external parent references are replaced by that +existing destination parent. Import validates the complete result before +writing, including ID collisions and normal containment rules. It preserves IDs +and bodies, writes each document at its canonical destination path with the +status described above, and tags every imported element with a new draft record +named after the plan. A self-contained selection including its system and +containers can be imported into an empty initialized architecture. Existing +scanner matching and element acceptance remain in use. + +Ordinary Markdown readers can browse the bundle and read outcomes and linked +collaborations. Groma interprets parent resolution, draft lifecycle and the +plan record. See [Share architecture plans](plans.md) for an example. diff --git a/docs/plans.md b/docs/plans.md new file mode 100644 index 00000000..a013ed70 --- /dev/null +++ b/docs/plans.md @@ -0,0 +1,102 @@ +# Share architecture plans + +A plan shares an intended architecture without source ownership or map layout. +Export the elements you want, then import them in another initialized Groma +project. Imported systems, containers and components arrive as ghosts. People +and external systems arrive as stable declarations, because those are declared +and never drafted. One draft record tags everything a plan brought in. + +```sh +# In the source repository, select two components. +groma plan export checkout payments --to plans/payment-plan + +# In the destination repository, use its existing application container. +groma plan import ../source/plans/payment-plan --parent application +``` + +On the live browser map, select several components and use **Export as plan** +beside **Group as** and **Combine into**. Enter a new directory inside the +repository; Groma writes the same bundle as the CLI and confirms where it went. +The command line may export to a new directory anywhere. + +The directory name becomes the plan title. Export refuses a directory that +already exists or lies inside the Groma directory. +The bundle contains an OKF `index.md`, a `plan.md` record, selected element +Markdown files, and flows whose steps stay entirely inside the selection. +Unselected parents are referenced by ID and must already exist at import. +`--parent` replaces these references with an existing destination parent; +normal C4 containment rules still apply. To carry a complete architecture into +an empty initialized project, explicitly select its system and containers as +well as its components. + +An example `plan.md`: + +```markdown +--- +type: Groma Plan +title: payment-plan +--- + +Architecture to implement. +``` + +Export writes that body as a placeholder. Replace it with the outcome the plan +is for before you share the bundle: import copies it into the draft record. + +The selected `checkout` document remains normal architecture Markdown: + +```markdown +--- +type: C4 Component +title: Checkout +status: draft +groma: + id: checkout + parent: source-application +--- + +Collects an order and asks Payments to authorize it. + +## Draft relationships + +| Source | Target | Description | Technology | +| --- | --- | --- | --- | +| [checkout](checkout.md) | [payments](payments.md) | Requests authorization | HTTPS | +``` + +Both component documents live together at their source architecture paths +inside the bundle. Their parent is an ID reference, not a copied container. + +A plan carries each document's Markdown body as written: the overview with its +formatting and links, and sections such as `## Requirements`. It replaces only +the status, leaves out code ownership, the group and an earlier draft tag, and +rebuilds the relationship sections from the relationships inside the selection. +Import computes the destination paths from the destination parent, keeps IDs +and bodies, and adds `groma.draft: payment-plan`. It also creates +`drafts/payment-plan.md`. Relationships remain draft rows with concept links. + +A colliding element, flow or draft ID, or a missing parent, rejects the whole +import before any architecture document is written. Import never renames IDs. + +Implement the imported responsibilities in source, run `groma scan`, and use +`groma accept ` for each matched ghost. The existing scanner matching rules +apply; import does not invent source paths or force a match. + +An imported relationship stays a draft row between its two element IDs. +Scanning and accepting the elements do not change it. Use those IDs to edit, +remove or accept it: + +```sh +groma edit relation checkout payments --description "Requests a refund" +groma remove relation checkout payments +groma accept relation checkout payments +``` + +Accepting needs exactly one current relationship from a source file of the +first component to a source file of the second, which a scan records once the +code exists. The planned description and technology move to that file pair. +With none or more than one, the command says how many it found and the draft +stays as it is. A relationship with a person or an external system needs no +code. + +The [Markdown contract](component-markdown.md#plan-bundles) defines the format. diff --git a/groma/systems/groma-md/containers/cli/components/plan.md b/groma/systems/groma-md/containers/cli/components/plan.md new file mode 100644 index 00000000..88bdc801 --- /dev/null +++ b/groma/systems/groma-md/containers/cli/components/plan.md @@ -0,0 +1,15 @@ +--- +type: C4 Component +title: Architecture plans +status: stable +groma: + id: plan + parent: cli + code: + - scanner: typescript + file: src/plan.ts + group: Architecture records +description: Shares selected architecture as portable OKF drafts +--- + +Exports selected C4 elements as ordinary OKF Markdown without source ownership or layout: each document's body as written, the relationships inside the selection as draft rows, and the flows that stay inside it. Import resolves existing parents, validates IDs and the complete architecture before saving, and writes software as ghosts and people and external systems as stable declarations at their canonical paths, all tagged with one named draft record. Export never writes into the Groma directory; the command line may use any other new directory, and the browser multi-selection only one inside the repository. Scanner matching and element acceptance retain their existing responsibilities. diff --git a/groma/systems/groma-md/containers/cli/components/relationship-markdown.md b/groma/systems/groma-md/containers/cli/components/relationship-markdown.md index df1b2c39..18386fe2 100644 --- a/groma/systems/groma-md/containers/cli/components/relationship-markdown.md +++ b/groma/systems/groma-md/containers/cli/components/relationship-markdown.md @@ -20,4 +20,4 @@ groma: description: Stores directed interactions and resolves them to component owners --- -Reads and writes directed interactions. Resolves exact source files to their component owners for display on the map. +Reads and writes directed interactions. Planned internal relationships may name architecture concepts before source exists, while current code interactions name exact source files. Editing and removal preserve planned concept links. Explicit acceptance transfers planned text only to one existing current file pair between the same component owners; missing or ambiguous evidence leaves the plan unchanged. Source ownership projects each stored claim onto its current component pair for the map. diff --git a/src/architecture-markdown.ts b/src/architecture-markdown.ts index 16242327..a79c58a9 100644 --- a/src/architecture-markdown.ts +++ b/src/architecture-markdown.ts @@ -8,6 +8,41 @@ import type { type InvalidMarkdown = (code: string, sourceFilename: string, message: string) => never +/** + * Where Comark, the parser behind the architecture reader, reads inline syntax in text. Each match gets a + * backslash; all other punctuation is stored as written, so documents stay ordinary Markdown. + * Block syntax at the start of a line, such as "# " or "- ", is not covered. + * Guarded by "relationship text is stored as written and read back unchanged" in test-bun/source-relationships.test.ts. + */ +const markdownSyntax = new RegExp([ + // Escape, code, emphasis, link or span, HTML, strikethrough, attributes, table cell. + /[\\`*[<~{|]/, + // Emphasis. An underscore between two letters or digits, as in user_id, never starts it and stays plain. + /(? rule.source).join('|'), 'g') + +/** + * Literal text as Markdown that the reader shows unchanged. Pass text, never Markdown: formatting would be + * stored as visible characters, and escaping twice adds visible backslashes. Line breaks are kept, which + * suits prose paragraphs. + */ +export function escapeMarkdownText(text: string): string { + return text.replaceAll(markdownSyntax, '\\$&') +} + +/** The same for one table cell. A line break would end the row, so the text continues after a space. */ +export function escapeTableCell(text: string): string { + return escapeMarkdownText(text).replaceAll(/\s*[\r\n]\s*/g, ' ') +} + export function nodeText(node: MarkdownNode | undefined): string { if (typeof node === 'string') return node if (!Array.isArray(node)) return '' diff --git a/src/architecture-reader.ts b/src/architecture-reader.ts index bc412281..d66d5640 100644 --- a/src/architecture-reader.ts +++ b/src/architecture-reader.ts @@ -96,7 +96,7 @@ async function listMarkdownFiles( /** The two root documents describe the bundle and the project, never an element. */ const rootDocuments = new Set(['index.md', 'project.md']) -async function parseDocument( +async function readArchitectureDocument( filesystem: GromaFileSystem, relativeFilename: string, onFilesystemAccess?: FilesystemAccessHandler, @@ -115,6 +115,14 @@ async function parseDocument( throw new ArchitectureReadError(sourceFilename, 'read', error) } + return parseArchitectureDocument(sourceFilename, source) +} + +/** The record every architecture rule reads, made from Markdown text that need not be a stored file yet. */ +export async function parseArchitectureDocument( + sourceFilename: string, + source: string, +): Promise { let tree: Awaited> try { tree = await parseMarkdown(source) @@ -139,9 +147,9 @@ async function requireGromaPackage( filesystem: GromaFileSystem, onFilesystemAccess?: FilesystemAccessHandler, ): Promise { - const index = await parseDocument(filesystem, 'index.md', onFilesystemAccess) + const index = await readArchitectureDocument(filesystem, 'index.md', onFilesystemAccess) requireBundleIndex(index.frontmatter, index.sourceFilename) - const project = await parseDocument(filesystem, 'project.md', onFilesystemAccess) + const project = await readArchitectureDocument(filesystem, 'project.md', onFilesystemAccess) requireProjectMetadata(project.frontmatter, project.sourceFilename) requireProjectOverview(project.nodes, project.body, project.sourceFilename) } @@ -168,7 +176,7 @@ async function readArchitecture( .filter(filename => !rootDocuments.has(filename) && !isReservedDocument(filename)) const parsed = await Promise.all(filenames.map(async filename => [ filename, - await parseDocument(filesystem, filename, onFilesystemAccess), + await readArchitectureDocument(filesystem, filename, onFilesystemAccess), ] as const)) for (const [filename, document] of parsed) { if (filename.startsWith('drafts/')) { diff --git a/src/flow-authoring.ts b/src/flow-authoring.ts index 6922ebb5..4991422b 100644 --- a/src/flow-authoring.ts +++ b/src/flow-authoring.ts @@ -1,12 +1,10 @@ import { parseMarkdown, parseFrontmatter } from 'comark' -import { renderFrontmatter } from 'comark/render' import { buildArchitectureModel, freeId } from './architecture-model.ts' import { loadArchitecture } from './architecture-reader.ts' import { resolveFlows } from './flow-model.ts' import { GromaFileSystem } from './groma-filesystem.ts' -import { readDocument, replaceLeadProse, withDescription, withTitle, writeDocument } from './markdown-emitter.ts' +import { readDocument, renderFlowDocument, replaceLeadProse, withDescription, withTitle, writeDocument } from './markdown-emitter.ts' import { requireText } from './naming.ts' -import { FLOW_TYPE } from './okf-profile.ts' import type { ArchitectureDocument, ArchitectureRecords } from './types.ts' export interface FlowChanges { @@ -27,9 +25,10 @@ async function writeFlow(repositoryRoot: string, records: ArchitectureRecords, f export async function addFlow(repositoryRoot: string, title: string, input: FlowChanges): Promise { const records = await loadArchitecture(repositoryRoot) const id = freeId(records, buildArchitectureModel(records.documents), title) - const source = `---\n${renderFrontmatter({ - type: FLOW_TYPE, title, ...(input.description === undefined ? {} : { description: input.description }), groma: { id }, - })}\n---\n\n${requireText(input.overview, '--overview')}\n\n## Steps\n\n${requireText(input.steps, '--steps')}\n` + const source = renderFlowDocument({ + id, title, description: input.description, + overview: requireText(input.overview, '--overview'), steps: requireText(input.steps, '--steps'), + }) const filename = GromaFileSystem.open(repositoryRoot).sourceFilename(`flows/${id}.md`) await writeFlow(repositoryRoot, records, filename, source) return id diff --git a/src/groma-filesystem.ts b/src/groma-filesystem.ts index 3fb8ab0c..fffde5e4 100644 --- a/src/groma-filesystem.ts +++ b/src/groma-filesystem.ts @@ -17,6 +17,8 @@ import { import path from 'node:path' import { setTimeout as delay } from 'node:timers/promises' +import { renderBundleIndex } from './okf-profile.ts' + const access = new AsyncLocalStorage>() const LOCK_TIMEOUT_MS = 5000 const LOCK_FILENAME = '.groma.lock' @@ -200,13 +202,7 @@ export class GromaFileSystem { const entries = (await this.list('')) .filter(entry => entry.isDirectory() || (entry.isFile() && entry.name.endsWith('.md') && entry.name !== 'index.md')) .map(entry => `${entry.name}${entry.isDirectory() ? '/' : ''}`) - .sort() - const links = entries.map(name => { - const label = name.replace(/[\\[\]]/g, '\\$&') - const href = name.split('/').map(encodeURIComponent).join('/') - return `- [${label}](<${href}>)` - }) - const source = `---\nokf_version: "0.2"\n---\n\n# Contents\n\n${links.join('\n')}\n` + const source = renderBundleIndex(entries) if (this.exists('index.md') && await this.read('index.md') === source) return await this.write('index.md', source) } diff --git a/src/http-relationships.ts b/src/http-relationships.ts index 0eee5a9d..edc530b8 100644 --- a/src/http-relationships.ts +++ b/src/http-relationships.ts @@ -277,16 +277,11 @@ function provider(request: SentRequest, endpoints: readonly ServedEndpoint[]): S return files.size === 1 && (by === 'specificity' || labels.size === 1) ? chosen.map(match => match.endpoint) : [] } -/** Markdown emphasis would consume these characters inside a stored path. */ -function escapeLabel(text: string): string { - return text.replaceAll(/[*_]/g, character => `\\${character}`) -} - -/** `:id`, `:id?`, `:rest+` and `:rest*` survive the Markdown table, unlike braces. */ +/** Display route parameters as :id, :id?, :rest+ or :rest*. Storage owns Markdown escaping. */ function segmentLabel(segment: HttpEndpointSegment): string { - if (segment.kind === 'literal') return escapeLabel(segment.value) - if (segment.kind === 'parameter') return `:${escapeLabel(segment.name)}${segment.optional ? '?' : ''}` - return `:${escapeLabel(segment.name)}${segment.optional ? '*' : '+'}` + if (segment.kind === 'literal') return segment.value + if (segment.kind === 'parameter') return `:${segment.name}${segment.optional ? '?' : ''}` + return `:${segment.name}${segment.optional ? '*' : '+'}` } function pathLabel(endpoint: ServedEndpoint): string { diff --git a/src/markdown-emitter.ts b/src/markdown-emitter.ts index 85e86e91..01a64ffb 100644 --- a/src/markdown-emitter.ts +++ b/src/markdown-emitter.ts @@ -4,7 +4,7 @@ import { renderFrontmatter } from 'comark/render' import { GromaFileSystem } from './groma-filesystem.ts' import { buildArchitectureModel } from './architecture-model.ts' import { requireText } from './naming.ts' -import { DRAFT_TYPE, c4Type, requireGromaMapping } from './okf-profile.ts' +import { DRAFT_TYPE, FLOW_TYPE, c4Type, requireGromaMapping } from './okf-profile.ts' import type { ArchitectureDocument, C4Kind, CodeReference, ElementStatus } from './types.ts' /** Save must pass the same semantic validation as the next read before changing the file. */ @@ -36,7 +36,8 @@ function normalizeNewlines(source: string): string { return source.replaceAll('\r\n', '\n') } -function sourceWithFrontmatter( +/** A document from its front matter and everything after the closing fence, which starts with a blank line. */ +export function sourceWithFrontmatter( frontmatter: Record, content: string, ): string { @@ -170,6 +171,23 @@ export function renderDraftDocument(input: { }, `\n\n${input.outcome.trim()}\n`) } +export function renderFlowDocument(input: { + id: string + title: string + description?: string + /** Markdown prose. */ + overview: string + /** The complete From | To | Action Markdown table, including any link definitions. */ + steps: string +}): string { + return sourceWithFrontmatter({ + type: FLOW_TYPE, + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + groma: { id: input.id }, + }, `\n\n${input.overview}\n\n## Steps\n\n${input.steps}\n`) +} + export async function upsertCode( repositoryRoot: string, sourceFilename: string, diff --git a/src/okf-profile.ts b/src/okf-profile.ts index 62430642..87359dd4 100644 --- a/src/okf-profile.ts +++ b/src/okf-profile.ts @@ -5,6 +5,7 @@ export const GROMA_PROJECT_TYPE = 'Groma Project' export const GROMA_PROFILE = 'architecture' export const DRAFT_TYPE = 'Draft' export const FLOW_TYPE = 'Groma Flow' +export const PLAN_TYPE = 'Groma Plan' const typeByKind: Record = { actor: 'C4 Actor', @@ -89,6 +90,19 @@ export function requireBundleIndex( } } +/** + * The index.md of an OKF bundle: one link per entry of its root directory, a directory named with a + * trailing slash. The architecture directory and a plan bundle both use it. + */ +export function renderBundleIndex(entries: readonly string[]): string { + const links = [...entries].sort().map(name => { + const label = name.replace(/[\\[\]]/g, '\\$&') + const href = name.split('/').map(encodeURIComponent).join('/') + return `- [${label}](<${href}>)` + }) + return `---\nokf_version: "${OKF_VERSION}"\n---\n\n# Contents\n\n${links.join('\n')}\n` +} + export interface GromaProjectMetadata { title: string description?: string diff --git a/src/plan.ts b/src/plan.ts new file mode 100644 index 00000000..f44f992d --- /dev/null +++ b/src/plan.ts @@ -0,0 +1,287 @@ +import { existsSync } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { escapeMarkdownText, escapeTableCell } from './architecture-markdown.ts' +import { buildArchitectureModel, draftRecordOf, freeId } from './architecture-model.ts' +import { architectureElementPath, draftRecordPath } from './architecture-path.ts' +import { loadArchitecture, parseArchitectureDocument } from './architecture-reader.ts' +import { resolveFlows } from './flow-model.ts' +import { GromaFileSystem } from './groma-filesystem.ts' +import { renderDraftDocument, renderFlowDocument, sourceWithFrontmatter } from './markdown-emitter.ts' +import { requireText } from './naming.ts' +import { c4Kind, FLOW_TYPE, PLAN_TYPE, renderBundleIndex, requireBundleIndex, requireGromaMapping } from './okf-profile.ts' +import { isDeclaredConcept } from './relationship-markdown.ts' +import { withStoredRelationships } from './relationship-storage.ts' +import type { + ArchitectureDocument, ArchitectureElement, ArchitectureFlow, ArchitectureModel, ArchitectureRecords, C4Kind, + RelationshipConnection, +} from './types.ts' + +export interface PlanExportInput { ids: string[]; to: string } +export interface PlanImportInput { path: string; parent?: string } + +/** The documents of one sort in a bundle, with the ids they declare. */ +interface BundleDocuments { + documents: ArchitectureDocument[] + /** Every groma.id, read once. An id that is not text is absent; the model refuses that document when it is built. */ + ids: Set +} + +interface PlanBundle { + title: string + /** The Markdown body of plan.md. It becomes the outcome of the draft record. */ + outcome: string + elements: BundleDocuments + flows: BundleDocuments +} + +/** + * Bundle documents are named as if this were their architecture directory, because links between documents and + * the flows/ folder are resolved below one. The name appears in import errors and is never a path on disk. + */ +const BUNDLE_ROOT = 'plan' + +/** True when `target` is `directory` or lies below it. Both are compared as written; links are not followed. */ +function isInside(directory: string, target: string): boolean { + const fromDirectory = path.relative(directory, target) + return fromDirectory.split(path.sep)[0] !== '..' && !path.isAbsolute(fromDirectory) +} + +/** + * An element document as a plan carries it, in a bundle or after import. The Markdown body travels as written. + * Only what belongs to one repository is replaced: software becomes a draft while a person or an external system + * stays a stable declaration, code ownership, the group and an earlier draft tag are dropped, and the + * relationship sections are rebuilt from the plan's draft rows with links that fit `filename`. + */ +function plannedSource(input: { + document: ArchitectureDocument + element: ArchitectureElement + /** Where the document will be stored. */ + filename: string + rows: readonly RelationshipConnection[] + /** The document path of every element a row may name. */ + filenames: ReadonlyMap + /** The draft record an imported element is tagged with. */ + draft?: string +}): string { + const { document, element, draft } = input + const { code: _code, group: _group, draft: _draft, ...groma } = requireGromaMapping(document.frontmatter, document.sourceFilename) + const frontmatter = { + ...document.frontmatter, + status: isDeclaredConcept(element) ? 'stable' : 'draft', + groma: draft === undefined ? groma : { ...groma, draft }, + } + return withStoredRelationships(sourceWithFrontmatter(frontmatter, document.body), input.filename, + input.rows.filter(row => row.source === element.id), input.filenames) +} + +/** + * A flow is written again from its meaning, because the links of its Steps table depend on where the elements + * are stored. Its overview is the text the reader decoded, so formatting inside it is not carried. + */ +function flowSource(flow: ArchitectureFlow, filenames: ReadonlyMap, filename: string): string { + const link = (id: string): string => `[${id}](${path.posix.relative(path.posix.dirname(filename), filenames.get(id)!)})` + const rows = flow.steps.map(step => `| ${link(step.source)} | ${link(step.target)} | ${escapeTableCell(step.action)} |`) + return renderFlowDocument({ + id: flow.id, title: flow.title, description: flow.description, overview: escapeMarkdownText(flow.overview), + steps: `| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}`, + }) +} + +/** The relationships inside the selection, as the draft rows a plan carries between element documents. */ +function selectedRelationships(model: ArchitectureModel, ids: ReadonlySet): RelationshipConnection[] { + return model.relationships.filter(row => ids.has(row.sourceId) && ids.has(row.targetId)).map(row => ({ + source: row.sourceId, target: row.targetId, description: row.description, + technology: row.technology, status: 'draft', authored: true, + })) +} + +/** + * A plan contains C4 meaning and parent identities, never source ownership or layout. + * The command line calls this directly, so `to` may name a new directory anywhere outside the Groma directory. + */ +export async function exportPlan(repositoryRoot: string, input: PlanExportInput): Promise { + const filesystem = GromaFileSystem.open(repositoryRoot) + const to = path.resolve(repositoryRoot, requireText(input.to, '--to')) + // A fresh directory keeps an earlier bundle from contributing stale concepts. + if (existsSync(to)) throw new Error(`"${input.to}" already exists; a plan is exported into a new directory`) + // Every Markdown file below the Groma directory is read as architecture, so a bundle there would break the next load. + if (isInside(filesystem.absolute(), to)) { + throw new Error(`"${input.to}" is inside ${filesystem.directory}/, where a plan would be read as architecture`) + } + const files = await filesystem.withAccess(async () => { + const records = await loadArchitecture(repositoryRoot) + const model = buildArchitectureModel(records.documents) + const ids = new Set(input.ids) + if (ids.size === 0) throw new Error('select at least one element') + const selected = [...ids].map(id => { + const element = model.elements.find(candidate => candidate.id === id) + if (!element) throw new Error(`unknown element "${id}"`) + return element + }) + const documents = new Map(records.documents.map(document => [document.sourceFilename, document])) + const filenames = new Map(selected.map(element => [element.id, element.sourceFilename])) + const rows = selectedRelationships(model, ids) + const files = new Map() + files.set('plan.md', sourceWithFrontmatter({ type: PLAN_TYPE, title: path.basename(to) }, '\n\nArchitecture to implement.\n')) + for (const element of selected) { + const filename = element.sourceFilename + files.set(filesystem.relative(filename), plannedSource({ document: documents.get(filename)!, element, filename, rows, filenames })) + } + for (const flow of resolveFlows(records.flows, model)) { + if (!flow.steps.every(step => ids.has(step.source) && ids.has(step.target))) continue + files.set(filesystem.relative(flow.sourceFilename), flowSource(flow, filenames, flow.sourceFilename)) + } + // The index lists the top level of the bundle: plan.md and each first folder. + const topLevel = new Set([...files.keys()].map(file => file.includes('/') ? `${file.split('/')[0]}/` : file)) + files.set('index.md', renderBundleIndex([...topLevel])) + return files + }) + await mkdir(path.dirname(to), { recursive: true }) + await mkdir(to) + for (const [filename, source] of files) { + const destination = path.join(to, filename) + await mkdir(path.dirname(destination), { recursive: true }) + await writeFile(destination, source) + } + return to +} + +/** + * The export of the live web map, whose destination arrives from a browser. Beyond what exportPlan refuses (an + * existing directory, the Groma directory), the resolved path must lie inside the repository the map serves. + */ +export async function exportPlanIntoRepository(repositoryRoot: string, input: PlanExportInput): Promise { + const to = requireText(input.to, 'plan directory') + if (!isInside(path.resolve(repositoryRoot), path.resolve(repositoryRoot, to))) { + throw new Error(`plan directory "${to}" must be inside the repository`) + } + return exportPlan(repositoryRoot, input) +} + +/** Every Markdown file of a bundle, as a path relative to it. */ +async function bundleFilenames(directory: string, relative = ''): Promise { + const filenames: string[] = [] + for (const entry of await readdir(path.join(directory, relative), { withFileTypes: true })) { + const filename = path.posix.join(relative, entry.name) + if (entry.isDirectory()) filenames.push(...await bundleFilenames(directory, filename)) + else if (entry.isFile() && entry.name.endsWith('.md')) filenames.push(filename) + } + return filenames +} + +/** index.md and plan.md make a directory a plan. */ +const planRecordFiles = ['index.md', 'plan.md'] + +async function readBundleDocument(directory: string, filename: string): Promise { + return parseArchitectureDocument(`${BUNDLE_ROOT}/${filename}`, await readFile(path.join(directory, filename), 'utf8')) +} + +/** The two root files are required before anything below the directory is read. */ +async function readPlanRecord(directory: string): Promise> { + for (const filename of planRecordFiles) { + if (!existsSync(path.join(directory, filename))) throw new Error(`${directory} is not a plan: it has no ${filename}`) + } + const index = await readBundleDocument(directory, 'index.md') + requireBundleIndex(index.frontmatter, index.sourceFilename) + const plan = await readBundleDocument(directory, 'plan.md') + const { type, title } = plan.frontmatter + if (type !== PLAN_TYPE) throw new Error(`plan.md requires type ${PLAN_TYPE}`) + return { title: requireText(typeof title === 'string' ? title : undefined, 'plan title'), outcome: plan.body.trim() } +} + +async function readPlan(directory: string): Promise { + const record = await readPlanRecord(directory) + const elements: BundleDocuments = { documents: [], ids: new Set() } + const flows: BundleDocuments = { documents: [], ids: new Set() } + for (const filename of await bundleFilenames(directory)) { + if (planRecordFiles.includes(filename)) continue + const document = await readBundleDocument(directory, filename) + const sort = c4Kind(document.frontmatter.type) ? elements : document.frontmatter.type === FLOW_TYPE ? flows : undefined + if (sort === undefined) throw new Error(`unsupported plan document "${filename}"`) + sort.documents.push(document) + const { id } = requireGromaMapping(document.frontmatter, document.sourceFilename) + if (typeof id === 'string') sort.ids.add(id) + } + if (elements.documents.length === 0) throw new Error('plan contains no elements') + return { ...record, elements, flows } +} + +/** The id of the plan's draft record, once it and every id in the bundle are known to be free in this repository. */ +function requireFreeIds(bundle: PlanBundle, records: ArchitectureRecords, existing: ArchitectureModel): string { + const draftId = freeId(records, existing, bundle.title) + for (const id of [...bundle.elements.ids, ...bundle.flows.ids]) { + // Called for its refusal only: freeId throws when an element, a flow or a draft record holds the id, or it is reserved. + freeId(records, existing, id) + if (id === draftId) throw new Error(`id "${draftId}" also names the plan`) + } + return draftId +} + +/** With --parent, a parent that the bundle names but does not contain becomes that element of the destination. */ +function withDestinationParent(elements: BundleDocuments, parent: string | undefined): ArchitectureDocument[] { + if (parent === undefined) return elements.documents + return elements.documents.map(document => { + const groma = requireGromaMapping(document.frontmatter, document.sourceFilename) + if (typeof groma.parent !== 'string' || elements.ids.has(groma.parent)) return document + return { ...document, frontmatter: { ...document.frontmatter, groma: { ...groma, parent } } } + }) +} + +/** A path is built from the path of the parent, so parents are placed first. */ +const placementOrder: C4Kind[] = ['actor', 'system', 'container', 'component'] + +/** The document path of every existing element and, at its canonical place, of every imported one. */ +function importLocations(root: string, imported: readonly ArchitectureElement[], existing: ArchitectureModel): Map { + const locations = new Map(existing.elements.map(element => [element.id, element.sourceFilename])) + const parentsFirst = [...imported].sort((left, right) => placementOrder.indexOf(left.kind) - placementOrder.indexOf(right.kind)) + for (const element of parentsFirst) { + locations.set(element.id, architectureElementPath({ + root, kind: element.kind, id: element.id, external: element.external, + parentSourceFilename: element.parentId === null ? undefined : locations.get(element.parentId), + })) + } + return locations +} + +/** Validate identities, containment, relationships and flows before the first write. */ +export async function importPlan(repositoryRoot: string, input: PlanImportInput): Promise { + const bundle = await readPlan(path.resolve(repositoryRoot, input.path)) + const filesystem = GromaFileSystem.open(repositoryRoot) + return filesystem.withAccess(async () => { + const records = await loadArchitecture(repositoryRoot) + const existing = buildArchitectureModel(records.documents) + const draftId = requireFreeIds(bundle, records, existing) + const root = filesystem.sourceFilename() + // The first model places the elements: read beside the existing architecture, each has a kind and a parent, and so a path. + const documents = new Map(withDestinationParent(bundle.elements, input.parent).map(document => [document.sourceFilename, document])) + const placed = buildArchitectureModel([...records.documents, ...documents.values()]) + const imported = placed.elements.filter(element => documents.has(element.sourceFilename)) + const locations = importLocations(root, imported, existing) + const rows = selectedRelationships(placed, bundle.elements.ids) + const output = new Map() + for (const element of imported) { + const filename = locations.get(element.id)! + output.set(filename, plannedSource({ + document: documents.get(element.sourceFilename)!, element, filename, rows, filenames: locations, draft: draftId, + })) + } + for (const flow of resolveFlows(bundle.flows.documents, placed)) { + const filename = `${root}/flows/${flow.id}.md` + output.set(filename, flowSource(flow, locations, filename)) + } + // The second model validates what will be stored: the same documents at their destination paths. + const stored = await Promise.all([...output].map(([filename, source]) => parseArchitectureDocument(filename, source))) + const model = buildArchitectureModel([...records.documents, ...stored.filter(document => c4Kind(document.frontmatter.type))]) + resolveFlows([...records.flows, ...stored.filter(document => document.frontmatter.type === FLOW_TYPE)], model) + const draftFilename = draftRecordPath(root, draftId) + const draftSource = renderDraftDocument({ id: draftId, title: bundle.title, outcome: bundle.outcome }) + draftRecordOf(await parseArchitectureDocument(draftFilename, draftSource)) + output.set(draftFilename, draftSource) + for (const filename of output.keys()) { + if (filesystem.exists(filesystem.relative(filename))) throw new Error(`architecture document already exists at ${filename}`) + } + for (const [filename, source] of output) await filesystem.writeSource(filename, source) + return draftId + }) +} diff --git a/src/relation.ts b/src/relation.ts index ca2636c7..a8522a7b 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -2,13 +2,13 @@ import { buildArchitectureModel } from './architecture-model.ts' import { loadArchitecture } from './architecture-reader.ts' import { resolveFlows } from './flow-model.ts' import { requireText } from './naming.ts' -import { storedRelationships, type StoredRelationship } from './relationship-markdown.ts' +import { isDeclaredConcept, storedRelationships, type StoredRelationship } from './relationship-markdown.ts' import { placeRelationships, saveRelationships } from './relationship-storage.ts' import { sourceIndex } from './source-index.ts' import type { ArchitectureElement, RelationshipConnection, ElementStatus } from './types.ts' import { checkEdit, type EditValues } from './authoring-conflict.ts' -/** Code interactions use repository-relative files; actor/external declarations may use concept IDs. */ +/** Current code interactions use files; actor/external declarations and existing internal drafts may use concept IDs. */ export interface RelationEnds { source: string target: string @@ -24,10 +24,13 @@ interface Ends { rows: StoredRelationship[] records: Awaited> model: ReturnType + index: ReturnType input: RelationEnds source: ArchitectureElement target: ArchitectureElement row: StoredRelationship | undefined + /** A draft row between two internal concepts. Only a plan import writes one; no source file stands behind it yet. */ + planned: boolean } type Sentence = Pick @@ -41,16 +44,16 @@ async function loadEnds(repositoryRoot: string, input: RelationEnds): Promise element.kind === 'actor' || element.external) - if (!declaredConcept && (!index.byFile.has(input.source) || !index.byFile.has(input.target))) { - throw new Error('code relationships require source-file endpoints') - } const connections = storedRelationships(documents, model.elements, (_code, filename, message) => { throw new Error(`${filename}: ${message}`) }) const row = [...connections].sort((left, right) => Number(right.authored) - Number(left.authored)) .find(item => item.source === input.source && item.target === input.target) - return { rows: connections, records, model, input, source, target, row } + const declared = [source, target].some(isDeclaredConcept) + const files = index.byFile.has(input.source) && index.byFile.has(input.target) + const planned = !declared && !files && row?.status === 'draft' && input.source === source.id && input.target === target.id + if (!declared && !files && !planned) throw new Error('code relationships require source-file endpoints') + return { rows: connections, records, model, index, input, source, target, row, planned } } function requireRow(ends: Ends): RelationshipConnection { @@ -61,10 +64,12 @@ function requireRow(ends: Ends): RelationshipConnection { async function writeRow( repositoryRoot: string, ends: Ends, - change: { drop?: RelationshipConnection; add?: Sentence }, + change: { drop: readonly RelationshipConnection[]; add?: Sentence }, + /** Where the added row is stored. Only the acceptance of a planned relationship names other endpoints than the input. */ + { source, target }: RelationEnds = ends.input, ): Promise { - const rows = ends.rows.filter(row => row !== change.drop) - if (change.add) rows.push({ ...ends.input, ...change.add, authored: true, document: ends.source.sourceFilename }) + const rows = ends.rows.filter(row => !change.drop.includes(row)) + if (change.add) rows.push({ source, target, ...change.add, authored: true, document: ends.source.sourceFilename }) await saveRelationships(repositoryRoot, ends.records.documents, ends.model.elements, ends.rows, placeRelationships(rows, ends.model.elements)) return ends.source.id @@ -76,7 +81,8 @@ export async function addRelation(repositoryRoot: string, input: RelationInput, const technology = requireText(input.technology, '--technology') const ends = await loadEnds(repositoryRoot, input) if (ends.row?.authored) throw new Error(`${input.source} already relates to ${input.target}; use groma edit relation`) - return writeRow(repositoryRoot, ends, { drop: status === 'stable' ? ends.row : undefined, add: { description, technology, status } }) + const replaced = status === 'stable' && ends.row ? [ends.row] : [] + return writeRow(repositoryRoot, ends, { drop: replaced, add: { description, technology, status } }) } export async function editRelation(repositoryRoot: string, input: RelationInput): Promise { @@ -89,7 +95,7 @@ export async function editRelation(repositoryRoot: string, input: RelationInput) description: input.description?.trim(), technology: input.technology?.trim(), }) return writeRow(repositoryRoot, ends, { - drop: current, + drop: [current], add: { status: current.status, description: input.description === undefined ? current.description : requireText(input.description, '--description'), @@ -98,11 +104,31 @@ export async function editRelation(repositoryRoot: string, input: RelationInput) }) } +/** The endpoints under which an accepted relationship is stored. */ +function acceptanceEnds(ends: Ends): RelationEnds { + // A declared concept or a pair of source files: the draft already names the endpoints of a current relationship. + if (!ends.planned) return ends.input + // Planned: the text moves to the one current file pair that connects the two components. + const pairs = new Map(ends.rows.filter(row => row.status === 'stable' + && ends.index.byFile.get(row.source) === ends.source.id && ends.index.byFile.get(row.target) === ends.target.id) + .map(row => [`${row.source}\0${row.target}`, row])) + if (pairs.size !== 1) { + throw new Error(`cannot accept ${ends.input.source} → ${ends.input.target}: requires one current source-file connection, found ${pairs.size}`) + } + const { source, target } = [...pairs.values()][0]! + return { source, target } +} + export async function acceptRelation(repositoryRoot: string, input: RelationEnds): Promise { const ends = await loadEnds(repositoryRoot, input) const current = requireRow(ends) if (!current.authored || current.status !== 'draft') throw new Error('not a draft relationship') - return writeRow(repositoryRoot, ends, { drop: current, add: { ...current, status: 'stable' } }) + const accepted = acceptanceEnds(ends) + const overwritten = ends.rows.filter(row => row.authored && row.source === accepted.source && row.target === accepted.target) + return writeRow(repositoryRoot, ends, { + drop: [current, ...overwritten], + add: { description: current.description, technology: current.technology, status: 'stable' }, + }, accepted) } export async function removeRelation(repositoryRoot: string, input: RelationEnds): Promise { @@ -112,5 +138,5 @@ export async function removeRelation(repositoryRoot: string, input: RelationEnds .filter(flow => flow.steps.some(step => step.source === ends.source.id && step.target === ends.target.id)) if (flows.length) throw new Error(`cannot remove relationship: used by flows ${flows.map(flow => flow.id).join(', ')}`) if (!current.authored || current.status !== 'draft') throw new Error('only draft relationships can be removed') - return writeRow(repositoryRoot, ends, { drop: current }) + return writeRow(repositoryRoot, ends, { drop: [current] }) } diff --git a/src/relationship-markdown.ts b/src/relationship-markdown.ts index 2beb4297..1d2d0331 100644 --- a/src/relationship-markdown.ts +++ b/src/relationship-markdown.ts @@ -16,6 +16,13 @@ import type { const columns = ['Source', 'Target', 'Description', 'Technology'] type Invalid = (code: string, filename: string, message: string) => never +/** + * A person or an external system is declared by hand and never drafted. A relationship with one may name + * concepts, because no source file stands for it. + */ +export function isDeclaredConcept(element: Pick | undefined): boolean { + return element?.kind === 'actor' || element?.external === true +} function endpoint( cell: MarkdownElement | undefined, @@ -49,8 +56,9 @@ function readRow( if (cells.length !== 4) invalid('INVALID_RELATIONSHIP', filename, 'relationship row must contain four cells') const source = endpoint(cells[0], filename, byDocument, invalid) const target = endpoint(cells[1], filename, byDocument, invalid) - const declaredConcept = [source, target].some(end => end.element?.kind === 'actor' || end.element?.external === true) - if (!declaredConcept && (!source.file || !target.file)) { + const declaredConcept = [source, target].some(end => isDeclaredConcept(end.element)) + const plannedConcepts = status === 'draft' && source.element !== undefined && target.element !== undefined + if (!declaredConcept && !plannedConcepts && (!source.file || !target.file)) { invalid('INVALID_RELATIONSHIP', filename, 'code relationships require source-file endpoints') } const description = nodeText(cells[2]).trim() diff --git a/src/relationship-storage.ts b/src/relationship-storage.ts index 0b7aeb62..7a89fd49 100644 --- a/src/relationship-storage.ts +++ b/src/relationship-storage.ts @@ -2,9 +2,10 @@ import path from 'node:path' import { parseMarkdown, parseFrontmatter } from 'comark' import { buildArchitectureModel } from './architecture-model.ts' import { sourceIndex } from './source-index.ts' +import { escapeTableCell } from './architecture-markdown.ts' import { readDocument, writeDocument } from './markdown-emitter.ts' import type { StoredRelationship } from './relationship-markdown.ts' -import type { ArchitectureDocument, CodeReference } from './types.ts' +import type { ArchitectureDocument, CodeReference, RelationshipConnection } from './types.ts' interface Owner { id: string @@ -71,7 +72,7 @@ export function changedRelationshipDocuments( export function withStoredRelationships( source: string, filename: string, - rows: readonly StoredRelationship[], + rows: readonly RelationshipConnection[], concepts: ReadonlyMap, ): string { const href = (endpoint: string) => path.posix.relative(path.posix.dirname(filename), concepts.get(endpoint) ?? endpoint) @@ -79,7 +80,7 @@ export function withStoredRelationships( for (const row of rows) { const heading = row.authored ? (row.status === 'draft' ? '## Draft relationships' : '## Relationships') : '## Derived relationships' const lines = sections.get(heading) ?? [] - lines.push(`| [${row.source}](${href(row.source)}) | [${row.target}](${href(row.target)}) | ${row.description} | ${row.technology} |`) + lines.push(`| [${row.source}](${href(row.source)}) | [${row.target}](${href(row.target)}) | ${escapeTableCell(row.description)} | ${escapeTableCell(row.technology)} |`) sections.set(heading, lines) } const tables = [...sections].map(([heading, lines]) => diff --git a/src/viewers/web/authoring.ts b/src/viewers/web/authoring.ts index 4ee7e2ba..0d90eb39 100644 --- a/src/viewers/web/authoring.ts +++ b/src/viewers/web/authoring.ts @@ -14,15 +14,16 @@ export interface AuthoringDependencies { /** Shared write operations for details and group actions. */ export function createAuthoring(host: HTMLElement, map: IsoMap, data: WebDataSource, deps: AuthoringDependencies) { const gestures = createMapEditor(host, map, data, () => deps.world().elements, deps.live) - const { accept, add, edit, remove } = data + const { accept, add, edit, exportPlan, remove } = data const titleOf = (id: string): string => deps.world().elements.find(element => element.id === id)?.title ?? id - /** Group as and Combine into, while several components are selected. */ + /** Group as, Combine into and Export as plan, while several components are selected. */ function selectionWrites(ids: readonly string[]): SelectionWrites | undefined { if (ids.length < 2 || add === undefined || edit === undefined) return undefined return { members: ids.map(id => ({ id, title: titleOf(id) })), onGroup: name => add({ thing: 'group', name, members: [...ids] }), onCombine: survivor => edit({ id: survivor, combine: ids.filter(id => id !== survivor) }), + ...(exportPlan === undefined ? {} : { onExport: to => exportPlan({ ids: [...ids], to }) }), } } diff --git a/src/viewers/web/data.ts b/src/viewers/web/data.ts index 560dc48d..75ca4be6 100644 --- a/src/viewers/web/data.ts +++ b/src/viewers/web/data.ts @@ -1,5 +1,6 @@ import type { ScannerSettings, ScannerSettingsAction } from '../../scanner/modules/settings-model.ts' import type { AcceptInput, AddInput, DraftInput, EditArchitectureInput, RemoveInput } from '../../authoring.ts' +import type { PlanExportInput } from '../../plan.ts' import type { WorkItemDetails } from '../../types.ts' import { PUBLISHED_EVENT, PUBLISHED_VERSION_EVENT } from './payload.ts' import type { WebBootPayload, WebPayload, WebRevision, WebWorkPayload } from './payload.ts' @@ -23,6 +24,7 @@ export interface WebDataSource { remove?(input: RemoveInput): Promise edit?(input: EditArchitectureInput): Promise accept?(input: AcceptInput): Promise + exportPlan?(input: PlanExportInput): Promise subscribe(handlers: { world(payload: WebPayload): void work(payload: WebWorkPayload): void @@ -88,6 +90,7 @@ function liveDataSource(): WebDataSource { remove: input => send('/remove', input), edit: input => send('/edit', input), accept: input => send('/accept', input), + exportPlan: input => send('/plan/export', input), subscribe(handlers) { const events = new EventSource('/events') events.addEventListener('scanners', event => this.onScanners?.(JSON.parse(event.data) as ScannerSettings)) diff --git a/src/viewers/web/map-session.ts b/src/viewers/web/map-session.ts index 044f96ab..8c3bb5aa 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -7,6 +7,7 @@ import { writes } from '../../authoring.ts' import { EditConflict } from '../../authoring-conflict.ts' import { GromaBusyError } from '../../groma-filesystem.ts' import type { StructuralResult } from '../../curate.ts' +import { exportPlanIntoRepository } from '../../plan.ts' import { createScannerSession } from '../../scanner/session.ts' import { parseScannerSettingsAction, withScannerUpgrades } from '../../scanner/modules/settings.ts' import { pinsOf } from '../../work/pins.ts' @@ -416,6 +417,11 @@ export async function createWebMapSession( try { await scannerSession.change(parseScannerSettingsAction(await request.json())); return Response.json(scannerSession.state) } catch (error) { return new Response(error instanceof Error ? error.message : String(error), { status: 400 }) } }], + // A plan export changes nothing the map shows, so unlike the writes below it does not reload the world. + ['/plan/export', async request => { + try { await exportPlanIntoRepository(repositoryRoot, await request.json()); return new Response(null, { status: 204 }) } + catch (error) { return new Response(error instanceof Error ? error.message : String(error), { status: 400 }) } + }], ['/draft', request => writeResponse(request, writes.draft)], ['/add', request => writeResponse(request, writes.add)], ['/edit', request => writeResponse(request, writes.edit)], diff --git a/src/viewers/web/organisms/writes.ts b/src/viewers/web/organisms/writes.ts index 295ac2aa..a2faed19 100644 --- a/src/viewers/web/organisms/writes.ts +++ b/src/viewers/web/organisms/writes.ts @@ -13,6 +13,7 @@ export interface ParentOption { export interface SelectionWrites { members: { id: string; title: string }[] + onExport?: (to: string) => Promise onGroup: (name: string) => Promise onCombine: (survivor: string) => Promise } @@ -60,7 +61,7 @@ export function paintAcceptControl(body: Element, accept: () => Promise): body.append(box) } -/** Several selected components: name them as one group, or fold them into the survivor the person picks. */ +/** Several selected components: name them as one group, fold them into the survivor the person picks, or export them as a plan. */ export function paintSelectionControls(body: Element, selection: SelectionWrites): void { body.append(heading(`${selection.members.length} selected`)) const box = document.createElement('div') @@ -91,10 +92,13 @@ export function paintSelectionControls(body: Element, selection: SelectionWrites combine.append(survivor, combineButton) const error = paragraph('error', '') error.setAttribute('role', 'status') + const confirmation = paragraph('confirmation', '') + confirmation.setAttribute('role', 'status') const submit = (form: HTMLFormElement, write: () => Promise): void => { form.addEventListener('submit', async event => { event.preventDefault() error.textContent = '' + confirmation.textContent = '' try { await write() } catch (cause) { @@ -104,6 +108,26 @@ export function paintSelectionControls(body: Element, selection: SelectionWrites } submit(group, () => selection.onGroup(name.value)) submit(combine, () => selection.onCombine(survivor.value)) - box.append(group, combine, error) + box.append(group, combine) + const { onExport } = selection + if (onExport) { + const exportForm = document.createElement('form') + exportForm.className = 'export-as-plan' + const destination = document.createElement('input') + destination.required = true + destination.value = `plans/${selection.members[0]!.id}-plan` + destination.setAttribute('aria-label', 'Plan directory') + const exportButton = document.createElement('button') + exportButton.type = 'submit' + exportButton.textContent = 'Export as plan' + exportForm.append(destination, exportButton) + // The other two writes repaint the pane with their result. An export changes nothing on the map, so it is confirmed here. + submit(exportForm, async () => { + await onExport(destination.value) + confirmation.textContent = `Exported to ${destination.value}` + }) + box.append(exportForm) + } + box.append(error, confirmation) body.append(box) } diff --git a/src/viewers/web/page.ts b/src/viewers/web/page.ts index bd6a18f8..e640b596 100644 --- a/src/viewers/web/page.ts +++ b/src/viewers/web/page.ts @@ -290,6 +290,8 @@ const style = ` #details .selection-writes button:hover { color: var(--ink); background: var(--hover); } #details .selection-writes .error { margin: 0; color: var(--highlight-text); font-size: 11px; } #details .selection-writes .error:empty { display: none; } + #details .selection-writes .confirmation { margin: 0; color: var(--muted); font-size: 11px; } + #details .selection-writes .confirmation:empty { display: none; } #details .overview { margin: 0 0 10px; line-height: 1.65; } #hierarchy .section, #details .section { padding-bottom: 6px; border-bottom: 1px solid var(--hairline); } #details .section { diff --git a/src/write-commands.ts b/src/write-commands.ts index 6cf5ba80..e1b96026 100644 --- a/src/write-commands.ts +++ b/src/write-commands.ts @@ -4,6 +4,7 @@ import { writes } from './authoring.ts' import type { AddInput, RemoveInput } from './authoring.ts' import type { StructuralResult } from './curate.ts' import { isGroupAddress } from './naming.ts' +import { exportPlan, importPlan } from './plan.ts' import { scanRepository } from './scanner.ts' function printWriteResult(result: string | StructuralResult): void { @@ -50,14 +51,36 @@ function addedIds(thing: string, ids: string[]): Pick { + console.log('A plan is an architecture fragment: elements, their relationships and flows, without code or layout.') + console.log('groma plan export --to ') + console.log('groma plan import [--parent ]') + }) + plan.command('export ') + .description('Write selected elements as a plan bundle in a new directory') + .requiredOption('--to ', 'new plan directory; its name becomes the plan title') + .action(async (ids: string[], options: { to: string }) => { + try { printWriteResult(await exportPlan(process.cwd(), { ids, to: options.to })) } + catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } + }) + plan.command('import ') + .description('Import a plan under one draft: software as ghosts, people and external systems as declarations') + .option('--parent ', 'use this existing parent for the selected fragment roots') + .action(async (path: string, options: { parent?: string }) => { + try { printWriteResult(await importPlan(process.cwd(), { path, parent: options.parent })) } + catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } + }) + program .command('draft') .description('Draft software or a directed relationship') .argument('', 'system, container, component, or relation') .argument('', 'element name') - .argument('[target]', 'target file or concept ID when drafting a relation') + .argument('[target]', 'when drafting a relation: the target file, or the ID of a person or external system') .option('--overview ', 'full explanation in Markdown') .option('--description ', 'optional short summary') .option('--parent ', 'parent element id') diff --git a/test-bun/http-relationships.test.ts b/test-bun/http-relationships.test.ts index 43fcc947..876bbc2d 100644 --- a/test-bun/http-relationships.test.ts +++ b/test-bun/http-relationships.test.ts @@ -369,7 +369,7 @@ test.concurrent('scans store derived HTTP rows, keep them while a contributing s test.concurrent('a literal segment with Markdown characters is escaped in the row and restored from storage', async () => { const endpoints = [endpoint(talks, 'GET', '/__debug__/:id')] const sent = [request('GET', '/__debug__/{}')] - expect(derive(endpoints, sent)[0]!.description).toBe('Calls HTTP endpoint: GET /\\_\\_debug\\_\\_/:id') + expect(derive(endpoints, sent)[0]!.description).toBe('Calls HTTP endpoint: GET /__debug__/:id') const root = await repository() try { await reconcileScanObservations(root, [scan('server', { httpEndpoints: endpoints }), scan('client', { httpRequests: sent })]) diff --git a/test-bun/plan.test.ts b/test-bun/plan.test.ts new file mode 100644 index 00000000..ac47101d --- /dev/null +++ b/test-bun/plan.test.ts @@ -0,0 +1,139 @@ +import { expect, test } from 'bun:test' +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { EMPTY_WORK_SNAPSHOT } from '@groma/work-source' + +import { buildArchitectureModel } from '../src/architecture-model.ts' +import { loadArchitecture } from '../src/architecture-reader.ts' +import { draftElement } from '../src/draft.ts' +import { resolveFlows } from '../src/flow-model.ts' +import { exportPlan, importPlan } from '../src/plan.ts' +import { editRelation, removeRelation } from '../src/relation.ts' +import { createWebMapSession } from '../src/viewers/web/map-session.ts' + +async function repositories() { + const root = await mkdtemp(path.join(os.tmpdir(), 'groma-plan-')) + const source = path.join(root, 'source'), destination = path.join(root, 'destination') + await cp(path.resolve(import.meta.dir, '../test/fixtures/flows'), source, { recursive: true }) + await cp(path.resolve(import.meta.dir, '../test/fixtures/empty-project'), destination, { recursive: true }) + return { root, source, destination } +} + +async function architecture(root: string) { + const records = await loadArchitecture(root) + const model = buildArchitectureModel(records.documents) + return { ...model, flows: resolveFlows(records.flows, model) } +} + +const entryPath = 'groma/systems/service/containers/api/components/entry.md' + +/** Every path below the Groma directory, in a stable order: the listing order of a directory is not. */ +async function architectureFiles(root: string): Promise { + return (await readdir(path.join(root, 'groma'), { recursive: true })).sort() +} + +test.concurrent('a plan round trip carries element bodies as written, declarations as stable, and relationship and flow meaning', async () => { + const { root, source, destination } = await repositories() + try { + // Formatting, an escaped list marker and a section of its own: none of it survives a decode and re-encode. + const body = 'Coordinates the `*pending*` request for [the team](https://example.com/team), **in order**.\n\n' + + '1986\\. A great year.\n\n## Requirements\n\nAnswers within one second.' + const entry = path.join(source, entryPath) + await writeFile(entry, (await readFile(entry, 'utf8')) + .replace(' parent: api\n', ' parent: api\n group: Core\n') + .replace('Coordinates a request.', body) + .replace('Dispatches work | Function call', 'Dispatches A \\| B | Function A \\| B')) + const flowPath = path.join(source, 'groma/flows/process-request.md') + await writeFile(flowPath, (await readFile(flowPath, 'utf8')) + .replace('The requester submits work.', 'The requester submits `*pending*` work.') + .replace('Run the first unit', 'Run A \\| B')) + const before = await architecture(source) + const bundle = await exportPlan(source, { ids: before.elements.map(element => element.id), to: '../shared-plan' }) + await importPlan(destination, { path: bundle }) + const after = await architecture(destination) + expect(await readFile(path.join(destination, entryPath), 'utf8')).toContain(body) + // Software is planned and becomes a ghost. A person and an external system are declared, in the plan and after import. + expect(await readFile(path.join(bundle, 'actors/requester.md'), 'utf8')).toContain('status: stable') + expect(Object.fromEntries(after.elements.map(element => [element.id, element.status]))).toEqual({ + requester: 'stable', journal: 'stable', service: 'draft', api: 'draft', entry: 'draft', worker: 'draft', + }) + expect(after.elements.map(element => element.draft)).toEqual(after.elements.map(() => 'shared-plan')) + // Code ownership and groups belong to the repository the elements came from. + expect(after.elements.filter(element => element.code.length > 0 || element.group !== undefined)).toEqual([]) + const elements = (model: typeof before) => model.elements.map(({ id, kind, title, description, technology, parentId, overview }) => + ({ id, kind, title, description, technology, parentId, overview })) + const relationships = (model: typeof before) => model.relationships.map(({ sourceId, targetId, description, technology }) => + ({ sourceId, targetId, description, technology })) + const flows = (model: typeof before) => model.flows.map(({ id, title, description, overview, steps }) => + ({ id, title, description, overview, steps: steps.map(({ source, target, action }) => ({ source, target, action })) })) + expect(elements(after)).toEqual(elements(before)) + expect(relationships(after)).toEqual(relationships(before)) + expect(flows(after)).toEqual(flows(before)) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test.concurrent('element parent IDs drive missing-parent validation and destination replacement', async () => { + const { root, source, destination } = await repositories() + try { + const bundle = await exportPlan(source, { ids: ['entry', 'worker'], to: '../shared-plan' }) + const before = await architectureFiles(destination) + await expect(importPlan(destination, { path: bundle })).rejects.toThrow('"api"') + expect(await architectureFiles(destination)).toEqual(before) + await draftElement(destination, { kind: 'system', name: 'Destination', overview: '' }) + await draftElement(destination, { kind: 'container', name: 'Application', parent: 'destination', overview: '' }) + await importPlan(destination, { path: bundle, parent: 'application' }) + const imported = (await architecture(destination)).elements.filter(element => ['entry', 'worker'].includes(element.id)) + expect(imported.map(element => element.parentId)).toEqual(['application', 'application']) + expect(imported.every(element => element.sourceFilename.includes('/containers/application/components/'))).toBe(true) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test.concurrent('imported concept draft relationships can be edited and removed before implementation', async () => { + const { root, source, destination } = await repositories() + try { + const bundle = await exportPlan(source, { ids: ['service', 'api', 'entry', 'worker'], to: '../shared-plan' }) + await importPlan(destination, { path: bundle }) + const ends = { source: 'entry', target: 'worker' } + await editRelation(destination, { ...ends, description: 'Dispatches work in batches', technology: 'Queue' }) + const changed = (await architecture(destination)).relationships.find(row => row.sourceId === 'entry' && row.targetId === 'worker')! + expect(changed).toMatchObject({ status: 'draft', description: 'Dispatches work in batches', technology: 'Queue' }) + await removeRelation(destination, ends) + expect((await architecture(destination)).relationships.some(row => row.sourceId === 'entry' && row.targetId === 'worker')).toBe(false) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +test.concurrent('the live map exports a plan only into a new directory inside its repository', async () => { + const { root, source } = await repositories() + let session: Awaited> | undefined + try { + session = await createWebMapSession(source, { scan: false, workSource: { + async read() { return EMPTY_WORK_SNAPSHOT }, + async readItem() { throw new Error('No task') }, + watch() { return { close() {} } }, + } }) + const send = (to: string) => session!.fetch(new Request('http://localhost/plan/export', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ['entry', 'worker'], to }), + })) + // Outside by a relative path, outside by an absolute path, and the repository itself, which is not new. + for (const refused of ['../outside-plan', path.join(root, 'absolute-plan'), '.']) { + expect((await send(refused)).status).toBe(400) + } + expect((await readdir(root)).sort()).toEqual(['destination', 'source']) + expect((await send('plans/inside')).ok).toBe(true) + expect(await readdir(path.join(source, 'plans/inside'))).toContain('plan.md') + expect((await send('plans/inside')).status).toBe(400) + } finally { + await session?.close() + await rm(root, { recursive: true, force: true }) + } +}) + +test.concurrent('a plan is never exported into the Groma directory', async () => { + const { root, source } = await repositories() + try { + const before = await architectureFiles(source) + await expect(exportPlan(source, { ids: ['entry', 'worker'], to: 'groma/plans/inside' })).rejects.toThrow('inside groma/') + expect(await architectureFiles(source)).toEqual(before) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/test-bun/source-relationships.test.ts b/test-bun/source-relationships.test.ts index b7bd33a5..e0888650 100644 --- a/test-bun/source-relationships.test.ts +++ b/test-bun/source-relationships.test.ts @@ -7,7 +7,8 @@ import { addScanner } from '../src/scanner/modules/inventory.ts' import manifest from '../plugins/scanners/typescript/package.json' import { scanTypeScriptSource } from '../plugins/scanners/typescript/src/scan.ts' import { loadArchitecture } from '../src/architecture-reader.ts' -import { loadAnnotatedArchitecture } from '../src/core.ts' +import { acceptGhost, loadAnnotatedArchitecture } from '../src/core.ts' +import { exportPlan, importPlan } from '../src/plan.ts' import { editArchitecture } from '../src/edit.ts' import { addRelation, acceptRelation, editRelation, removeRelation } from '../src/relation.ts' import { inferRelationships } from '../src/relationship-inference.ts' @@ -286,3 +287,97 @@ run({ deliver: choose ? send : wrap }) expect(inferRelationships([observation], owners).map(row => row.target)).toEqual(['src/provider.ts', 'src/wrapper.ts']) } finally { await rm(root, { recursive: true, force: true }) } }) + +/** A scanned repository, exported whole as a plan and imported into an empty one. */ +async function importedPlan() { + const root = await repository() + const destination = await mkdtemp(path.join(os.tmpdir(), 'groma-imported-relations-')) + await rm(path.join(root, 'src/wrapper.ts')) + await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'Caller', bin: 'src/caller.ts' })) + await scanRepository(root) + const model = await loadAnnotatedArchitecture(root) + const bundle = await exportPlan(root, { ids: model.elements.map(element => element.id), to: 'shared-plan' }) + await cp(path.resolve(import.meta.dir, '../test/fixtures/empty-project'), destination, { recursive: true }) + await importPlan(destination, { path: bundle }) + return { root, destination, source: owner(model, 'worker'), target: owner(model, 'provider') } +} + +/** The planned code arrives in the destination, together with the scanner that reads it. */ +async function addImplementation(root: string, destination: string): Promise { + await cp(path.join(root, 'src'), path.join(destination, 'src'), { recursive: true }) + await cp(path.join(root, 'package.json'), path.join(destination, 'package.json')) + const git = Bun.spawn(['git', 'init', '--quiet'], { cwd: destination, stdout: 'ignore', stderr: 'pipe' }) + expect(await git.exited, await new Response(git.stderr).text()).toBe(0) + await addScanner(destination, path.resolve(import.meta.dir, '../plugins/scanners/typescript')) +} + +test.concurrent('an imported concept draft accepts only through a unique existing current file pair', async () => { + const { root, destination, source, target } = await importedPlan() + try { + const planned = { source: source.id, target: target.id } + await editRelation(destination, { ...planned, description: 'Publishes the completed result', technology: 'Callback' }) + const before = await loadAnnotatedArchitecture(destination) + await expect(acceptRelation(destination, planned)).rejects.toThrow('found 0') + expect(await loadAnnotatedArchitecture(destination)).toEqual(before) + await addImplementation(root, destination) + await scanRepository(destination) + expect(await acceptGhost(destination, source.id)).toBe('accepted') + expect(await acceptGhost(destination, target.id)).toBe('accepted') + await editRelation(destination, { ...ends, description: 'Earlier current text', technology: 'Function call' }) + await scanRepository(destination) + await acceptRelation(destination, planned) + const accepted = (await loadAnnotatedArchitecture(destination)).relationships.find(row => + row.source === source.id && row.target === target.id)! + expect(accepted.connections).toEqual([expect.objectContaining({ + ...ends, status: 'stable', authored: true, description: 'Publishes the completed result', technology: 'Callback', + })]) + await scanRepository(destination) + expect((await loadAnnotatedArchitecture(destination)).relationships.find(row => + row.source === source.id && row.target === target.id)).toEqual(accepted) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(destination, { recursive: true, force: true }) + } +}) + +test.concurrent('ambiguous current file evidence leaves an imported concept relationship planned', async () => { + const { root, destination, source, target } = await importedPlan() + try { + await addImplementation(root, destination) + await writeFile(path.join(destination, 'src/secondary.ts'), 'export function secondary(): void {}\n') + await scanRepository(destination) + const second = owner(await loadAnnotatedArchitecture(destination), 'secondary') + await editArchitecture(destination, { id: second.id, parent: source.parent! }) + await editArchitecture(destination, { id: source.id, combine: [second.id] }) + await addRelation(destination, { source: 'src/secondary.ts', target: ends.target, description: 'Another current interaction', technology: 'Callback' }) + const before = await loadAnnotatedArchitecture(destination) + await expect(acceptRelation(destination, { source: source.id, target: target.id })).rejects.toThrow('found 2') + expect(await loadAnnotatedArchitecture(destination)).toEqual(before) + } finally { + await rm(root, { recursive: true, force: true }) + await rm(destination, { recursive: true, force: true }) + } +}) + +test.concurrent('relationship text is stored as written and read back unchanged', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'groma-relationship-text-')) + try { + await cp(path.resolve(import.meta.dir, '../test/fixtures/flows'), root, { recursive: true }) + const pair = { source: 'src/entry.ts', target: 'src/worker.ts' } + const read = async () => (await loadAnnotatedArchitecture(root)).relationships.flatMap(row => row.connections ?? []) + .find(row => row.source === pair.source && row.target === pair.target)! + // Every part of this text is Markdown syntax to the reader unless storage escapes it. + const syntax = { + description: 'Sets *pending* and __init__ for A | B, `code` [text] ~~kept~~ & C:\\.git', + technology: ':queue {id} at https://example.com/a_b', + } + await editRelation(root, { ...pair, ...syntax }) + expect(await read()).toMatchObject(syntax) + // Ordinary punctuation, an underscore inside a word and a plain ampersand need no backslash. A cell is one line. + const ordinary = 'Reads user.name and user_id, then retries (HTTPS/JSON): Auth & billing; why? #12 + a=b - done!' + await editRelation(root, { ...pair, description: `${ordinary}\nSecond line`, technology: 'HTTPS' }) + const stored = await readFile(path.join(root, 'groma/systems/service/containers/api/components/entry.md'), 'utf8') + expect(stored).toContain(`| ${ordinary} Second line | HTTPS |`) + expect((await read()).description).toBe(`${ordinary} Second line`) + } finally { await rm(root, { recursive: true, force: true }) } +})