From 1146eeb87538ce33a5e7aedfa71fd4a7ef1478ad Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Fri, 9 Oct 2026 10:56:55 +0200 Subject: [PATCH 1/5] TASK-562 - Export a selection of components as a plan and import it as drafts --- ...nents-as-a-plan-and-import-it-as-drafts.md | 59 ++++- docs/component-markdown.md | 43 ++++ docs/plans.md | 77 +++++++ .../containers/cli/components/plan.md | 15 ++ src/authoring.ts | 3 + src/plan.ts | 213 ++++++++++++++++++ src/relationship-markdown.ts | 3 +- src/viewers/web/authoring.ts | 1 + src/viewers/web/data.ts | 3 + src/viewers/web/map-session.ts | 1 + src/viewers/web/organisms/writes.ts | 21 +- src/write-commands.ts | 22 ++ 12 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 docs/plans.md create mode 100644 groma/systems/groma-md/containers/cli/components/plan.md create mode 100644 src/plan.ts 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 95463db5e..b9061f63b 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-09 08:02' labels: - senior dependencies: [] @@ -19,6 +20,18 @@ references: - relationship-markdown - docs/component-markdown.md - docs/product-model.md +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 type: feature ordinal: 2 --- @@ -33,18 +46,44 @@ 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. +- [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. - [ ] #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] #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] #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. Implement a portable OKF directory bundle containing selected draft concepts, concept-linked draft relationships, internal flows, and external parent IDs. Use existing Markdown emitters, canonical paths and locked writes; validate the complete import before saving. 2. Register plan export/import/help commands and add a repository-path export action beside web multi-selection controls. 3. Document the profile and an example. Keep scan matching and element acceptance unchanged. 4. Review the diff and update the architecture via groma scan. No installs, builds, tests or commits per stage instructions; unexecuted verification will be reported rather than claimed. + + +## 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. + + +## Final Summary + + +Implemented portable OKF plan export/import, whole-import ID and parent validation, canonical ghost placement with a named draft, concept-linked draft relationships and internal flows, CLI help, live browser multi-selection export, documentation and the Architecture plans map component. CLI walkthrough verified equal element/relationship/flow meaning after round trip, parent override, rejection without partial writes, and scan/accept of imported ghosts. AC 3 browser interaction remains unverified; builds and test suites were not run per instruction. + diff --git a/docs/component-markdown.md b/docs/component-markdown.md index 880dbdeb8..a465c08a8 100644 --- a/docs/component-markdown.md +++ b/docs/component-markdown.md @@ -444,3 +444,46 @@ 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`, outcome prose, and `groma.parents`, a list of required external parent +IDs. It 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. + +Selected C4 documents retain their IDs, types, titles, descriptions, +technologies, parent IDs and overviews, with `status: draft`. Code ownership, +map layout, groups and previous draft tags are omitted. Only selected elements +are copied; unselected parents appear by ID only. Documents use the normal C4 +paths relative to the bundle, and the parent IDs remain authoritative. + +Relationships within the selection become authored rows under +`## Draft relationships`, using links to the selected concept documents. +Unlike current code relationships, planned relationships may link internal +software concepts without source files. They remain draft rows when scanning +or accepting an element. This permits architecture outcomes to be described +before implementation exists. The existing exact-file rules for current code +relationships are unchanged. + +Flows are included only when every step's endpoints are selected. Their +ordinary linked Steps tables retain order and actions. 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. `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, writes ghosts at their canonical destination paths, and tags +each 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 000000000..4839e0b0e --- /dev/null +++ b/docs/plans.md @@ -0,0 +1,77 @@ +# 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 elements are ghosts under one draft record. + +```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 relative to the +repository; Groma writes the same bundle as the CLI. + +The directory name becomes the plan title. Export requires a new 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 +groma: + parents: + - source-application +--- + +Architecture to implement. +``` + +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. +Import computes their destination paths from the destination parent, keeps +IDs and overviews, 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. Scanning and +accepting elements do not accept their planned relationships. + +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 000000000..1a74505ce --- /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 responsibilities, their internal relationships and flows as ordinary OKF Markdown without source ownership or layout. Import resolves existing parents, validates IDs and the complete architecture before saving, and writes ghosts at their canonical paths under one named draft record. The shared authoring API exposes the same operation to command-line users and browser multi-selection. Scanner matching and element acceptance retain their existing responsibilities. diff --git a/src/authoring.ts b/src/authoring.ts index 115879098..f3150a58f 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -1,3 +1,4 @@ +import { exportPlan, importPlan } from './plan.ts' import { addThing } from './add.ts' import type { AddInput } from './add.ts' import { acceptGhost } from './accept.ts' @@ -50,6 +51,8 @@ function protectedWrite Promise { + const tree = await parseMarkdown(source) + return { sourceFilename, body: parseFrontmatter(source).content, nodes: tree.nodes, frontmatter: tree.frontmatter } as ArchitectureDocument +} + +function frontmatter(data: Record, body: string): string { + return `---\n${renderFrontmatter(data)}\n---\n\n${body.trim()}\n` +} + +function conceptSource(element: ArchitectureElement, parent = element.parentId, draft?: string): string { + return renderArchitectureDocument({ + id: element.id, kind: element.kind, name: element.title, + parent, description: element.description, overview: element.overview, + technology: element.technology, status: 'draft', draft, + }) +} + +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)} | ${step.action} |`) + return frontmatter({ type: FLOW_TYPE, title: flow.title, description: flow.description, groma: { id: flow.id } }, + `${flow.overview}\n\n## Steps\n\n| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}`) +} + +function selectedRelationships(model: ArchitectureModel, ids: ReadonlySet, filenames: ReadonlyMap): StoredRelationship[] { + 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, document: filenames.get(row.sourceId)!, + })) +} + +/** A plan contains C4 meaning and parent identities, never source ownership or layout. */ +export async function exportPlan(repositoryRoot: string, input: PlanExportInput): Promise { + const to = path.resolve(repositoryRoot, requireText(input.to, '--to')) + const filesystem = GromaFileSystem.open(repositoryRoot) + 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 filenames = new Map(selected.map(element => [element.id, element.sourceFilename])) + const rows = selectedRelationships(model, ids, filenames) + const files = new Map() + const parents = [...new Set(selected.flatMap(element => element.parentId && !ids.has(element.parentId) ? [element.parentId] : []))].sort() + const title = path.basename(to) + files.set('plan.md', frontmatter({ type: 'Groma Plan', title, groma: { parents } }, 'Architecture to implement.')) + for (const element of selected) { + files.set(filesystem.relative(element.sourceFilename), withStoredRelationships( + conceptSource(element), element.sourceFilename, rows.filter(row => row.source === element.id), 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)) + } + const entries = [...new Set([...files.keys()].map(file => file.includes('/') ? `${file.split('/')[0]}/` : file))].sort() + files.set('index.md', frontmatter({ okf_version: '0.2' }, `# Contents\n\n${entries.map(file => `- [${file}](<${file}>)`).join('\n')}`)) + return files + }) + // A fresh directory keeps an earlier bundle from contributing stale concepts. + 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 +} + +async function readBundleFiles(directory: string, relative = ''): Promise> { + const files = new Map() + for (const entry of await readdir(path.join(directory, relative), { withFileTypes: true })) { + const filename = path.posix.join(relative, entry.name) + if (entry.isDirectory()) { + for (const [name, source] of await readBundleFiles(directory, filename)) files.set(name, source) + } else if (entry.isFile() && entry.name.endsWith('.md')) { + files.set(filename, await readFile(path.join(directory, filename), 'utf8')) + } + } + return files +} + +async function readPlan(directory: string): Promise { + const files = await readBundleFiles(directory) + const index = await documentOf('groma/index.md', requireText(files.get('index.md'), 'plan index.md')) + requireBundleIndex(index.frontmatter, index.sourceFilename) + const plan = await documentOf('groma/plan.md', requireText(files.get('plan.md'), 'plan.md')) + if (plan.frontmatter.type !== 'Groma Plan') throw new Error('plan.md requires type Groma Plan') + const parents = requireGromaMapping(plan.frontmatter, plan.sourceFilename).parents + if (!Array.isArray(parents) || parents.some(id => typeof id !== 'string')) throw new Error('plan parents must be a list of IDs') + const documents: ArchitectureDocument[] = [], flows: ArchitectureDocument[] = [] + for (const [filename, source] of files) { + if (filename === 'index.md' || filename === 'plan.md') continue + const document = await documentOf(`groma/${filename}`, source) + if (c4Kind(document.frontmatter.type)) documents.push(document) + else if (document.frontmatter.type === FLOW_TYPE) flows.push(document) + else throw new Error(`unsupported plan document "${filename}"`) + } + if (documents.length === 0) throw new Error('plan contains no elements') + return { title: requireText(plan.frontmatter.title as string, 'plan title'), overview: plan.body.trim(), parents: parents as string[], documents, flows } +} + +function validateImportIds(bundle: PlanBundle, records: Awaited>, existing: ArchitectureModel): string { + const draftId = freeId(records, existing, bundle.title) + for (const document of [...bundle.documents, ...bundle.flows]) { + const id = String(requireGromaMapping(document.frontmatter, document.sourceFilename).id) + freeId(records, existing, id) + if (id === draftId) throw new Error(`id "${draftId}" also names the plan`) + } + return draftId +} + +function importedDocuments(bundle: PlanBundle, existing: ArchitectureModel, parent: string | undefined, draft: string): ArchitectureDocument[] { + for (const id of new Set([...bundle.parents, ...(parent === undefined ? [] : [parent])])) { + const required = parent ?? id + if (!existing.elements.some(element => element.id === required)) throw new Error(`unknown parent "${required}"`) + } + const externalParents = new Set(bundle.parents) + return bundle.documents.map(document => { + const groma = requireGromaMapping(document.frontmatter, document.sourceFilename) + const parentId = parent !== undefined && externalParents.has(String(groma.parent)) ? parent : groma.parent + return { ...document, frontmatter: { ...document.frontmatter, status: 'draft', groma: { ...groma, parent: parentId, code: undefined, draft } } } + }) +} + +function importLocations(filesystem: GromaFileSystem, imported: ArchitectureElement[], existing: ArchitectureModel): Map { + const locations = new Map(existing.elements.map(element => [element.id, element.sourceFilename])) + const pending = [...imported] + while (pending.length) { + const index = pending.findIndex(element => element.parentId === null || locations.has(element.parentId)) + if (index < 0) throw new Error(`unresolved parent for "${pending[0]!.id}"`) + const element = pending.splice(index, 1)[0]! + locations.set(element.id, architectureElementPath({ root: filesystem.sourceFilename(), kind: element.kind, + id: element.id, external: element.external, parentSourceFilename: element.parentId ? locations.get(element.parentId) : undefined })) + } + return locations +} + +function renderImport(bundle: PlanBundle, filesystem: GromaFileSystem, model: ArchitectureModel, existing: ArchitectureModel, draft: string): Map { + const ids = new Set(bundle.documents.map(document => String(requireGromaMapping(document.frontmatter, document.sourceFilename).id))) + const imported = model.elements.filter(element => ids.has(element.id)) + const locations = importLocations(filesystem, imported, existing) + const rows = selectedRelationships(model, ids, locations) + const output = new Map() + for (const element of imported) { + const filename = locations.get(element.id)! + output.set(filename, withStoredRelationships(conceptSource(element, element.parentId, draft), filename, + rows.filter(row => row.source === element.id), locations)) + } + for (const flow of resolveFlows(bundle.flows, model)) { + const filename = filesystem.sourceFilename(`flows/${flow.id}.md`) + output.set(filename, flowSource(flow, locations, filename)) + } + return output +} + +/** 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 = validateImportIds(bundle, records, existing) + const documents = importedDocuments(bundle, existing, input.parent, draftId) + const model = buildArchitectureModel([...records.documents, ...documents]) + const output = renderImport(bundle, filesystem, model, existing, draftId) + const finalDocuments = await Promise.all([...output].map(([filename, source]) => documentOf(filename, source))) + const finalModel = buildArchitectureModel([...records.documents, ...finalDocuments.filter(document => c4Kind(document.frontmatter.type))]) + resolveFlows([...records.flows, ...finalDocuments.filter(document => document.frontmatter.type === FLOW_TYPE)], finalModel) + const draftFilename = draftRecordPath(filesystem.sourceFilename(), draftId) + output.set(draftFilename, renderDraftDocument({ id: draftId, title: bundle.title, outcome: bundle.overview })) + for (const filename of output.keys()) { + if (filesystem.exists(filesystem.relative(filename))) throw new Error(`architecture document already exists at ${filename}`) + } + draftRecordOf(await documentOf(draftFilename, output.get(draftFilename)!)) + for (const [filename, source] of output) await filesystem.writeSource(filename, source) + return draftId + }) +} diff --git a/src/relationship-markdown.ts b/src/relationship-markdown.ts index 2beb4297f..9ee86e02f 100644 --- a/src/relationship-markdown.ts +++ b/src/relationship-markdown.ts @@ -50,7 +50,8 @@ function readRow( 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 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/viewers/web/authoring.ts b/src/viewers/web/authoring.ts index 4ee7e2baa..f19170bb9 100644 --- a/src/viewers/web/authoring.ts +++ b/src/viewers/web/authoring.ts @@ -21,6 +21,7 @@ export function createAuthoring(host: HTMLElement, map: IsoMap, data: WebDataSou if (ids.length < 2 || add === undefined || edit === undefined) return undefined return { members: ids.map(id => ({ id, title: titleOf(id) })), + onExport: data.exportPlan === undefined ? undefined : to => data.exportPlan!({ ids: [...ids], to }), onGroup: name => add({ thing: 'group', name, members: [...ids] }), onCombine: survivor => edit({ id: survivor, combine: ids.filter(id => id !== survivor) }), } diff --git a/src/viewers/web/data.ts b/src/viewers/web/data.ts index 560dc48d8..ecbe9e808 100644 --- a/src/viewers/web/data.ts +++ b/src/viewers/web/data.ts @@ -1,3 +1,4 @@ +import type { PlanExportInput } from '../../plan.ts' import type { ScannerSettings, ScannerSettingsAction } from '../../scanner/modules/settings-model.ts' import type { AcceptInput, AddInput, DraftInput, EditArchitectureInput, RemoveInput } from '../../authoring.ts' import type { WorkItemDetails } from '../../types.ts' @@ -18,6 +19,7 @@ export interface WebDataSource { readTask(id: string): Promise readTaskDiff(id: string): Promise /** The writers, absent in the published delivery, which has none. */ + exportPlan?(input: PlanExportInput): Promise draft?(input: DraftInput): Promise add?(input: AddInput): Promise remove?(input: RemoveInput): Promise @@ -83,6 +85,7 @@ function liveDataSource(): WebDataSource { readTaskDiff(id) { return responseJson(selected('/task-diff.json', { task: id })) }, + exportPlan: input => send('/plan/export', input), draft: input => send('/draft', input), add: input => send('/add', input), remove: input => send('/remove', input), diff --git a/src/viewers/web/map-session.ts b/src/viewers/web/map-session.ts index 044f96ab4..776ac701f 100644 --- a/src/viewers/web/map-session.ts +++ b/src/viewers/web/map-session.ts @@ -416,6 +416,7 @@ 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 }) } }], + ['/plan/export', request => writeResponse(request, writes.exportPlan)], ['/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 295ac2aaa..1ad2b5d23 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 } @@ -104,6 +105,24 @@ 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) + if (selection.onExport) { + const form = document.createElement('form') + form.className = 'group-as' + const destination = document.createElement('input') + destination.required = true + destination.value = `plans/${selection.members[0]!.id}-plan` + destination.setAttribute('aria-label', 'Plan directory') + const button = document.createElement('button') + button.type = 'submit' + button.textContent = 'Export as plan' + form.append(destination, button) + submit(form, async () => { + await selection.onExport!(destination.value) + error.textContent = `Exported to ${destination.value}` + }) + box.append(form) + } + box.append(error) body.append(box) } diff --git a/src/write-commands.ts b/src/write-commands.ts index 6cf5ba801..be2f78752 100644 --- a/src/write-commands.ts +++ b/src/write-commands.ts @@ -52,6 +52,28 @@ 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 writes.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 as ghosts belonging to one draft') + .option('--parent ', 'use this existing parent for the selected fragment roots') + .action(async (path: string, options: { parent?: string }) => { + try { printWriteResult(await writes.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') From 0fecb6ba81116df2f40a423b8e22f5310c656b05 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Fri, 9 Oct 2026 15:19:28 +0200 Subject: [PATCH 2/5] TASK-562 - Export a selection of components as a plan and import it as drafts --- ...nents-as-a-plan-and-import-it-as-drafts.md | 34 ++++++-- docs/component-markdown.md | 14 +++- docs/plans.md | 10 ++- .../cli/components/relationship-markdown.md | 2 +- src/architecture-markdown.ts | 5 ++ src/http-relationships.ts | 13 +-- src/plan.ts | 26 +++--- src/relation.ts | 38 +++++++-- src/relationship-storage.ts | 3 +- test-bun/http-relationships.test.ts | 2 +- test-bun/plan.test.ts | 84 +++++++++++++++++++ test-bun/source-relationships.test.ts | 77 ++++++++++++++++- 12 files changed, 260 insertions(+), 48 deletions(-) create mode 100644 test-bun/plan.test.ts 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 b9061f63b..87189f1a0 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 @@ -5,7 +5,7 @@ status: Done assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-09 08:02' +updated_date: '2026-10-09 13:19' labels: - senior dependencies: [] @@ -20,6 +20,12 @@ 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 @@ -32,6 +38,14 @@ modified_files: - 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 type: feature ordinal: 2 --- @@ -48,7 +62,7 @@ Build on drafts (src/draft.ts, src/accept.ts, draft records in src/architecture- - [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. -- [ ] #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] #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. @@ -56,8 +70,8 @@ Build on drafts (src/draft.ts, src/accept.ts, draft records in src/architecture- ## Definition of Done -- [ ] #1 Acceptance criteria have objective verification evidence. -- [ ] #2 Relevant checks pass and changes remain task-scoped. +- [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. @@ -65,7 +79,11 @@ Build on drafts (src/draft.ts, src/accept.ts, draft records in src/architecture- ## Implementation Plan -1. Implement a portable OKF directory bundle containing selected draft concepts, concept-linked draft relationships, internal flows, and external parent IDs. Use existing Markdown emitters, canonical paths and locked writes; validate the complete import before saving. 2. Register plan export/import/help commands and add a repository-path export action beside web multi-selection controls. 3. Document the profile and an example. Keep scan matching and element acceptance unchanged. 4. Review the diff and update the architecture via groma scan. No installs, builds, tests or commits per stage instructions; unexecuted verification will be reported rather than claimed. +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. ## Implementation Notes @@ -80,10 +98,14 @@ A two-component export lists only api as its required parent and omits the exter 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. ## Final Summary -Implemented portable OKF plan export/import, whole-import ID and parent validation, canonical ghost placement with a named draft, concept-linked draft relationships and internal flows, CLI help, live browser multi-selection export, documentation and the Architecture plans map component. CLI walkthrough verified equal element/relationship/flow meaning after round trip, parent override, rejection without partial writes, and scan/accept of imported ghosts. AC 3 browser interaction remains unverified; builds and test suites were not run per instruction. +Portable OKF plans preserve selected element, relationship and ordered flow meaning, including literal overview characters and table pipes. Required external parents are derived from authoritative element parent IDs. Imported concept-ID draft relationships support editing and removal; explicit acceptance transfers their planned text only to one existing current exact-file pair, leaving missing or ambiguous evidence unchanged. Final rendered-document validation and existing scanner/ghost lifecycle remain in use. Verified 115 focused tests, the real live-browser multi-selection export and a separate import of its bundle, bare CLI help, and bun run check (16 Node tests; 781 Bun passed, 51 optional-runtime skips, zero failures). Both external reviews and implementer specification/quality reviews passed. Updated contracts and the existing architecture relationship owner. All task criteria and Definition of Done items are checked. Changes are committed locally without publication. diff --git a/docs/component-markdown.md b/docs/component-markdown.md index a465c08a8..f3d800013 100644 --- a/docs/component-markdown.md +++ b/docs/component-markdown.md @@ -451,8 +451,9 @@ The live [groma.md system](../groma/systems/groma/system.md) and 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`, outcome prose, and `groma.parents`, a list of required external parent -IDs. It has no C4 identity, containment level, or box on the map. It is a +`title` and outcome prose. Required external parents come from the selected +elements' `groma.parent` IDs that do not name another selected element. It 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. Selected C4 documents retain their IDs, types, titles, descriptions, @@ -466,8 +467,13 @@ Relationships within the selection become authored rows under Unlike current code relationships, planned relationships may link internal software concepts without source files. They remain draft rows when scanning or accepting an element. This permits architecture outcomes to be described -before implementation exists. The existing exact-file rules for current code -relationships are unchanged. +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. Their ordinary linked Steps tables retain order and actions. A plan carries no diff --git a/docs/plans.md b/docs/plans.md index 4839e0b0e..0bfb4bd40 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -31,9 +31,6 @@ An example `plan.md`: --- type: Groma Plan title: payment-plan -groma: - parents: - - source-application --- Architecture to implement. @@ -72,6 +69,11 @@ 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. Scanning and -accepting elements do not accept their planned relationships. +accepting elements do not accept their planned relationships. Use the imported +concept IDs with `groma edit relation` or `groma remove relation`. Explicit +`groma accept relation checkout payments` transfers the planned text to the +unique existing current source-file connection between those components. Without +one unique connection, the relationship remains planned; the command explains +why acceptance cannot proceed. The [Markdown contract](component-markdown.md#plan-bundles) defines the format. 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 df1b2c39b..18386fe22 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 162423275..05a3f32a2 100644 --- a/src/architecture-markdown.ts +++ b/src/architecture-markdown.ts @@ -8,6 +8,11 @@ import type { type InvalidMarkdown = (code: string, sourceFilename: string, message: string) => never +/** Render decoded model text as literal Markdown, including inside table cells. */ +export function escapeMarkdownText(text: string): string { + return text.replaceAll(/[!-/:-@[-`{-~]/g, '\\$&') +} + export function nodeText(node: MarkdownNode | undefined): string { if (typeof node === 'string') return node if (!Array.isArray(node)) return '' diff --git a/src/http-relationships.ts b/src/http-relationships.ts index 0eee5a9dc..edc530b8f 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/plan.ts b/src/plan.ts index e6ac260b4..b33a18092 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -5,6 +5,7 @@ import { renderFrontmatter } from 'comark/render' import { buildArchitectureModel, draftRecordOf, freeId } from './architecture-model.ts' import { architectureElementPath, draftRecordPath } from './architecture-path.ts' import { loadArchitecture } from './architecture-reader.ts' +import { escapeMarkdownText } from './architecture-markdown.ts' import { resolveFlows } from './flow-model.ts' import { GromaFileSystem } from './groma-filesystem.ts' import { renderArchitectureDocument, renderDraftDocument } from './markdown-emitter.ts' @@ -20,7 +21,6 @@ export interface PlanImportInput { path: string; parent?: string } interface PlanBundle { title: string overview: string - parents: string[] documents: ArchitectureDocument[] flows: ArchitectureDocument[] } @@ -37,16 +37,16 @@ function frontmatter(data: Record, body: string): string { function conceptSource(element: ArchitectureElement, parent = element.parentId, draft?: string): string { return renderArchitectureDocument({ id: element.id, kind: element.kind, name: element.title, - parent, description: element.description, overview: element.overview, + parent, description: element.description, overview: escapeMarkdownText(element.overview), technology: element.technology, status: 'draft', draft, }) } 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)} | ${step.action} |`) + const rows = flow.steps.map(step => `| ${link(step.source)} | ${link(step.target)} | ${escapeMarkdownText(step.action)} |`) return frontmatter({ type: FLOW_TYPE, title: flow.title, description: flow.description, groma: { id: flow.id } }, - `${flow.overview}\n\n## Steps\n\n| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}`) + `${escapeMarkdownText(flow.overview)}\n\n## Steps\n\n| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}`) } function selectedRelationships(model: ArchitectureModel, ids: ReadonlySet, filenames: ReadonlyMap): StoredRelationship[] { @@ -73,9 +73,8 @@ export async function exportPlan(repositoryRoot: string, input: PlanExportInput) const filenames = new Map(selected.map(element => [element.id, element.sourceFilename])) const rows = selectedRelationships(model, ids, filenames) const files = new Map() - const parents = [...new Set(selected.flatMap(element => element.parentId && !ids.has(element.parentId) ? [element.parentId] : []))].sort() const title = path.basename(to) - files.set('plan.md', frontmatter({ type: 'Groma Plan', title, groma: { parents } }, 'Architecture to implement.')) + files.set('plan.md', frontmatter({ type: 'Groma Plan', title }, 'Architecture to implement.')) for (const element of selected) { files.set(filesystem.relative(element.sourceFilename), withStoredRelationships( conceptSource(element), element.sourceFilename, rows.filter(row => row.source === element.id), filenames, @@ -119,8 +118,6 @@ async function readPlan(directory: string): Promise { requireBundleIndex(index.frontmatter, index.sourceFilename) const plan = await documentOf('groma/plan.md', requireText(files.get('plan.md'), 'plan.md')) if (plan.frontmatter.type !== 'Groma Plan') throw new Error('plan.md requires type Groma Plan') - const parents = requireGromaMapping(plan.frontmatter, plan.sourceFilename).parents - if (!Array.isArray(parents) || parents.some(id => typeof id !== 'string')) throw new Error('plan parents must be a list of IDs') const documents: ArchitectureDocument[] = [], flows: ArchitectureDocument[] = [] for (const [filename, source] of files) { if (filename === 'index.md' || filename === 'plan.md') continue @@ -130,7 +127,7 @@ async function readPlan(directory: string): Promise { else throw new Error(`unsupported plan document "${filename}"`) } if (documents.length === 0) throw new Error('plan contains no elements') - return { title: requireText(plan.frontmatter.title as string, 'plan title'), overview: plan.body.trim(), parents: parents as string[], documents, flows } + return { title: requireText(plan.frontmatter.title as string, 'plan title'), overview: plan.body.trim(), documents, flows } } function validateImportIds(bundle: PlanBundle, records: Awaited>, existing: ArchitectureModel): string { @@ -144,11 +141,14 @@ function validateImportIds(bundle: PlanBundle, records: Awaited element.id === required)) throw new Error(`unknown parent "${required}"`) + const ids = new Set(bundle.documents.map(document => String(requireGromaMapping(document.frontmatter, document.sourceFilename).id))) + const externalParents = new Set(bundle.documents.flatMap(document => { + const parentId = requireGromaMapping(document.frontmatter, document.sourceFilename).parent + return typeof parentId === 'string' && !ids.has(parentId) ? [parentId] : [] + })) + for (const id of parent === undefined ? externalParents : [parent]) { + if (!existing.elements.some(element => element.id === id)) throw new Error(`unknown parent "${id}"`) } - const externalParents = new Set(bundle.parents) return bundle.documents.map(document => { const groma = requireGromaMapping(document.frontmatter, document.sourceFilename) const parentId = parent !== undefined && externalParents.has(String(groma.parent)) ? parent : groma.parent diff --git a/src/relation.ts b/src/relation.ts index ca2636c76..74ba6c4af 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -8,7 +8,7 @@ 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 @@ -41,15 +41,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) + const declaredConcept = [source, target].some(element => element.kind === 'actor' || element.external) + const plannedConcepts = row?.status === 'draft' && input.source === source.id && input.target === target.id + if (!declaredConcept && !plannedConcepts && (!index.byFile.has(input.source) || !index.byFile.has(input.target))) { + throw new Error('code relationships require source-file endpoints') + } return { rows: connections, records, model, input, source, target, row } } @@ -61,9 +62,10 @@ function requireRow(ends: Ends): RelationshipConnection { async function writeRow( repositoryRoot: string, ends: Ends, - change: { drop?: RelationshipConnection; add?: Sentence }, + change: { drop?: RelationshipConnection | RelationshipConnection[]; add?: Sentence }, ): Promise { - const rows = ends.rows.filter(row => row !== change.drop) + const dropped = new Set(Array.isArray(change.drop) ? change.drop : [change.drop]) + const rows = ends.rows.filter(row => !dropped.has(row)) if (change.add) rows.push({ ...ends.input, ...change.add, authored: true, document: ends.source.sourceFilename }) await saveRelationships(repositoryRoot, ends.records.documents, ends.model.elements, ends.rows, placeRelationships(rows, ends.model.elements)) @@ -98,11 +100,31 @@ export async function editRelation(repositoryRoot: string, input: RelationInput) }) } +/** Planned concept links become current only through one existing exact-file connection. */ +function acceptanceEnds(ends: Ends): RelationEnds { + const index = sourceIndex(ends.model.elements) + const declaredConcept = [ends.source, ends.target].some(element => element.kind === 'actor' || element.external) + if (declaredConcept || (index.byFile.has(ends.input.source) && index.byFile.has(ends.input.target))) return ends.input + const pairs = new Map(ends.rows.filter(row => row.status === 'stable' + && index.byFile.get(row.source) === ends.source.id && 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, input: accepted }, { + drop: [current, ...overwritten], + add: { description: current.description, technology: current.technology, status: 'stable' }, + }) } export async function removeRelation(repositoryRoot: string, input: RelationEnds): Promise { diff --git a/src/relationship-storage.ts b/src/relationship-storage.ts index 0b7aeb624..2ba69330b 100644 --- a/src/relationship-storage.ts +++ b/src/relationship-storage.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { parseMarkdown, parseFrontmatter } from 'comark' import { buildArchitectureModel } from './architecture-model.ts' import { sourceIndex } from './source-index.ts' +import { escapeMarkdownText } 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' @@ -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)}) | ${escapeMarkdownText(row.description)} | ${escapeMarkdownText(row.technology)} |`) sections.set(heading, lines) } const tables = [...sections].map(([heading, lines]) => diff --git a/test-bun/http-relationships.test.ts b/test-bun/http-relationships.test.ts index 43fcc947e..876bbc2d5 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 000000000..f416ea244 --- /dev/null +++ b/test-bun/plan.test.ts @@ -0,0 +1,84 @@ +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 { 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' + +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' + +test.concurrent('a plan round trip preserves literal overview, relationship and ordered flow meaning', async () => { + const { root, source, destination } = await repositories() + try { + const entry = path.join(source, entryPath) + await writeFile(entry, (await readFile(entry, 'utf8')) + .replace('Coordinates a request.', 'Coordinates the `*pending*` request and `©` value.') + .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) + 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 readdir(path.join(destination, 'groma'), { recursive: true }) + await expect(importPlan(destination, { path: bundle })).rejects.toThrow('api') + expect(await readdir(path.join(destination, 'groma'), { recursive: true })).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 A | B', technology: 'Callback | queue' }) + const changed = (await architecture(destination)).relationships.find(row => row.sourceId === 'entry' && row.targetId === 'worker')! + expect(changed).toMatchObject({ status: 'draft', description: 'Dispatches A | B', technology: 'Callback | 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 }) } +}) diff --git a/test-bun/source-relationships.test.ts b/test-bun/source-relationships.test.ts index b7bd33a5a..0719593d8 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,77 @@ 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 }) } }) + +test.concurrent('an imported concept draft accepts only through a unique existing current file pair', async () => { + const root = await repository() + const destination = await mkdtemp(path.join(os.tmpdir(), 'groma-imported-relations-')) + try { + 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 source = owner(model, 'worker'), target = owner(model, 'provider') + const planned = { source: source.id, target: target.id } + 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 }) + 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 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')) + 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 = await repository() + const destination = await mkdtemp(path.join(os.tmpdir(), 'groma-ambiguous-relations-')) + try { + 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 source = owner(model, 'worker'), target = owner(model, 'provider') + 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 }) + 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')) + 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 }) + } +}) From cf4d525b0e7e94dab1ac97391258ff06ca160c99 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 18:29:03 +0200 Subject: [PATCH 3/5] TASK-562 - Export a selection of components as a plan and import it as drafts --- ...nents-as-a-plan-and-import-it-as-drafts.md | 24 +++++- docs/component-markdown.md | 5 +- docs/plans.md | 33 +++++--- .../containers/cli/components/plan.md | 2 +- src/architecture-markdown.ts | 24 +++++- src/architecture-reader.ts | 16 +++- src/authoring.ts | 7 +- src/markdown-emitter.ts | 3 +- src/okf-profile.ts | 1 + src/plan.ts | 84 +++++++++++-------- src/relationship-storage.ts | 4 +- src/viewers/web/authoring.ts | 6 +- src/viewers/web/data.ts | 6 +- src/viewers/web/map-session.ts | 7 +- src/viewers/web/organisms/writes.ts | 31 ++++--- src/viewers/web/page.ts | 2 + src/write-commands.ts | 2 +- test-bun/plan.test.ts | 43 +++++++++- 18 files changed, 217 insertions(+), 83 deletions(-) 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 87189f1a0..12462399a 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,11 +1,11 @@ --- id: TASK-562 title: Export a selection of components as a plan and import it as drafts -status: Done +status: In Progress assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-09 13:19' +updated_date: '2026-10-11 16:28' labels: - senior dependencies: [] @@ -46,6 +46,10 @@ modified_files: - 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 type: feature ordinal: 2 --- @@ -84,6 +88,12 @@ Build on drafts (src/draft.ts, src/accept.ts, draft records in src/architecture- 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. ## Implementation Notes @@ -102,6 +112,16 @@ Repository groma scan completed: created 1, refreshed 111, matched 0, findings 3 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. ## Final Summary diff --git a/docs/component-markdown.md b/docs/component-markdown.md index f3d800013..d5a28f370 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 diff --git a/docs/plans.md b/docs/plans.md index 0bfb4bd40..556b3ce3a 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -13,10 +13,12 @@ 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 relative to the -repository; Groma writes the same bundle as the CLI. +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 requires a new directory. +The directory name becomes the plan title. Export refuses a directory that +already exists. 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. @@ -68,12 +70,23 @@ 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. Scanning and -accepting elements do not accept their planned relationships. Use the imported -concept IDs with `groma edit relation` or `groma remove relation`. Explicit -`groma accept relation checkout payments` transfers the planned text to the -unique existing current source-file connection between those components. Without -one unique connection, the relationship remains planned; the command explains -why acceptance cannot proceed. +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 index 1a74505ce..e8e3c9576 100644 --- a/groma/systems/groma-md/containers/cli/components/plan.md +++ b/groma/systems/groma-md/containers/cli/components/plan.md @@ -12,4 +12,4 @@ groma: description: Shares selected architecture as portable OKF drafts --- -Exports selected C4 responsibilities, their internal relationships and flows as ordinary OKF Markdown without source ownership or layout. Import resolves existing parents, validates IDs and the complete architecture before saving, and writes ghosts at their canonical paths under one named draft record. The shared authoring API exposes the same operation to command-line users and browser multi-selection. Scanner matching and element acceptance retain their existing responsibilities. +Exports selected C4 responsibilities, their internal relationships and flows as ordinary OKF Markdown without source ownership or layout. Import resolves existing parents, validates IDs and the complete architecture before saving, and writes ghosts at their canonical paths under one named draft record. The command line exports to any new directory; the browser multi-selection exports only into a new directory inside the repository. Scanner matching and element acceptance retain their existing responsibilities. diff --git a/src/architecture-markdown.ts b/src/architecture-markdown.ts index 05a3f32a2..299232b1b 100644 --- a/src/architecture-markdown.ts +++ b/src/architecture-markdown.ts @@ -8,9 +8,29 @@ import type { type InvalidMarkdown = (code: string, sourceFilename: string, message: string) => never -/** Render decoded model text as literal Markdown, including inside table cells. */ +/** + * 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. + */ +const markdownSyntax = new RegExp([ + // Escape, code, emphasis, link or span, HTML, character reference, strikethrough, attributes, table cell. + /[\\`*_[<&~{|]/, + // An inline component such as :icon. It starts a word, so the colon of /users/:id or "note: text" stays plain. + /(?<=^|[\s*_[]):(?=[a-z$])/, + // The colon of an address such as https://example.com. Unescaped, Comark links the address and copies it + // without reading the backslashes added above. + /:(?=\/\/)/, +].map(rule => rule.source).join('|'), 'gi') + +/** This text as Markdown the reader shows literally. Line breaks are kept, which suits prose paragraphs. */ export function escapeMarkdownText(text: string): string { - return text.replaceAll(/[!-/:-@[-`{-~]/g, '\\$&') + 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 { diff --git a/src/architecture-reader.ts b/src/architecture-reader.ts index bc4122810..d66d56400 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/authoring.ts b/src/authoring.ts index f3150a58f..cc0a42825 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -1,4 +1,3 @@ -import { exportPlan, importPlan } from './plan.ts' import { addThing } from './add.ts' import type { AddInput } from './add.ts' import { acceptGhost } from './accept.ts' @@ -9,6 +8,7 @@ import { editArchitecture } from './edit.ts' import type { EditArchitectureInput } from './edit.ts' import { removeThing } from './remove.ts' import type { RemoveInput } from './remove.ts' +import { exportPlan, importPlan } from './plan.ts' import { GromaFileSystem } from './groma-filesystem.ts' export interface AcceptInput { @@ -51,11 +51,12 @@ function protectedWrite Promise, content: string, ): string { diff --git a/src/okf-profile.ts b/src/okf-profile.ts index 624306425..a8a2de375 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', diff --git a/src/plan.ts b/src/plan.ts index b33a18092..6e6b7845c 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -1,38 +1,35 @@ -import path from 'node:path' +import { existsSync } from 'node:fs' import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' -import { parseFrontmatter, parseMarkdown } from 'comark' -import { renderFrontmatter } from 'comark/render' +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 } from './architecture-reader.ts' -import { escapeMarkdownText } from './architecture-markdown.ts' +import { loadArchitecture, parseArchitectureDocument } from './architecture-reader.ts' import { resolveFlows } from './flow-model.ts' import { GromaFileSystem } from './groma-filesystem.ts' -import { renderArchitectureDocument, renderDraftDocument } from './markdown-emitter.ts' +import { renderArchitectureDocument, renderDraftDocument, sourceWithFrontmatter } from './markdown-emitter.ts' import { requireText } from './naming.ts' -import { c4Kind, FLOW_TYPE, requireBundleIndex, requireGromaMapping } from './okf-profile.ts' +import { c4Kind, FLOW_TYPE, OKF_VERSION, PLAN_TYPE, requireBundleIndex, requireGromaMapping } from './okf-profile.ts' import { withStoredRelationships } from './relationship-storage.ts' import type { StoredRelationship } from './relationship-markdown.ts' -import type { ArchitectureDocument, ArchitectureElement, ArchitectureFlow, ArchitectureModel } from './types.ts' +import type { ArchitectureDocument, ArchitectureElement, ArchitectureFlow, ArchitectureModel, ArchitectureRecords } from './types.ts' export interface PlanExportInput { ids: string[]; to: string } export interface PlanImportInput { path: string; parent?: string } interface PlanBundle { title: string - overview: string + /** The Markdown body of plan.md. It becomes the outcome of the draft record. */ + outcome: string documents: ArchitectureDocument[] flows: ArchitectureDocument[] } -async function documentOf(sourceFilename: string, source: string): Promise { - const tree = await parseMarkdown(source) - return { sourceFilename, body: parseFrontmatter(source).content, nodes: tree.nodes, frontmatter: tree.frontmatter } as ArchitectureDocument -} - -function frontmatter(data: Record, body: string): string { - return `---\n${renderFrontmatter(data)}\n---\n\n${body.trim()}\n` -} +/** + * 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' function conceptSource(element: ArchitectureElement, parent = element.parentId, draft?: string): string { return renderArchitectureDocument({ @@ -44,9 +41,9 @@ function conceptSource(element: ArchitectureElement, parent = element.parentId, 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)} | ${escapeMarkdownText(step.action)} |`) - return frontmatter({ type: FLOW_TYPE, title: flow.title, description: flow.description, groma: { id: flow.id } }, - `${escapeMarkdownText(flow.overview)}\n\n## Steps\n\n| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}`) + const rows = flow.steps.map(step => `| ${link(step.source)} | ${link(step.target)} | ${escapeTableCell(step.action)} |`) + return sourceWithFrontmatter({ type: FLOW_TYPE, title: flow.title, description: flow.description, groma: { id: flow.id } }, + `\n\n${escapeMarkdownText(flow.overview)}\n\n## Steps\n\n| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}\n`) } function selectedRelationships(model: ArchitectureModel, ids: ReadonlySet, filenames: ReadonlyMap): StoredRelationship[] { @@ -56,9 +53,14 @@ function selectedRelationships(model: ArchitectureModel, ids: ReadonlySet { 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`) const filesystem = GromaFileSystem.open(repositoryRoot) const files = await filesystem.withAccess(async () => { const records = await loadArchitecture(repositoryRoot) @@ -74,7 +76,7 @@ export async function exportPlan(repositoryRoot: string, input: PlanExportInput) const rows = selectedRelationships(model, ids, filenames) const files = new Map() const title = path.basename(to) - files.set('plan.md', frontmatter({ type: 'Groma Plan', title }, 'Architecture to implement.')) + files.set('plan.md', sourceWithFrontmatter({ type: PLAN_TYPE, title }, '\n\nArchitecture to implement.\n')) for (const element of selected) { files.set(filesystem.relative(element.sourceFilename), withStoredRelationships( conceptSource(element), element.sourceFilename, rows.filter(row => row.source === element.id), filenames, @@ -85,10 +87,10 @@ export async function exportPlan(repositoryRoot: string, input: PlanExportInput) files.set(filesystem.relative(flow.sourceFilename), flowSource(flow, filenames, flow.sourceFilename)) } const entries = [...new Set([...files.keys()].map(file => file.includes('/') ? `${file.split('/')[0]}/` : file))].sort() - files.set('index.md', frontmatter({ okf_version: '0.2' }, `# Contents\n\n${entries.map(file => `- [${file}](<${file}>)`).join('\n')}`)) + files.set('index.md', sourceWithFrontmatter({ okf_version: OKF_VERSION }, + `\n\n# Contents\n\n${entries.map(file => `- [${file}](<${file}>)`).join('\n')}\n`)) return files }) - // A fresh directory keeps an earlier bundle from contributing stale concepts. await mkdir(path.dirname(to), { recursive: true }) await mkdir(to) for (const [filename, source] of files) { @@ -99,6 +101,19 @@ export async function exportPlan(repositoryRoot: string, input: PlanExportInput) return to } +/** + * The export of the live web map. Its destination arrives from a browser, so it must lie inside the + * repository the map serves; everything else about the export is the same. + */ +export async function exportPlanIntoRepository(repositoryRoot: string, input: PlanExportInput): Promise { + const to = requireText(input.to, 'plan directory') + const fromRoot = path.relative(repositoryRoot, path.resolve(repositoryRoot, to)) + if (fromRoot.split(path.sep)[0] === '..' || path.isAbsolute(fromRoot)) { + throw new Error(`plan directory "${to}" must be inside the repository`) + } + return exportPlan(repositoryRoot, input) +} + async function readBundleFiles(directory: string, relative = ''): Promise> { const files = new Map() for (const entry of await readdir(path.join(directory, relative), { withFileTypes: true })) { @@ -114,23 +129,26 @@ async function readBundleFiles(directory: string, relative = ''): Promise { const files = await readBundleFiles(directory) - const index = await documentOf('groma/index.md', requireText(files.get('index.md'), 'plan index.md')) + const bundleDocument = (filename: string, source: string): Promise => + parseArchitectureDocument(`${BUNDLE_ROOT}/${filename}`, source) + const index = await bundleDocument('index.md', requireText(files.get('index.md'), 'plan index.md')) requireBundleIndex(index.frontmatter, index.sourceFilename) - const plan = await documentOf('groma/plan.md', requireText(files.get('plan.md'), 'plan.md')) - if (plan.frontmatter.type !== 'Groma Plan') throw new Error('plan.md requires type Groma Plan') + const plan = await bundleDocument('plan.md', requireText(files.get('plan.md'), 'plan.md')) + const { type, title } = plan.frontmatter + if (type !== PLAN_TYPE) throw new Error(`plan.md requires type ${PLAN_TYPE}`) const documents: ArchitectureDocument[] = [], flows: ArchitectureDocument[] = [] for (const [filename, source] of files) { if (filename === 'index.md' || filename === 'plan.md') continue - const document = await documentOf(`groma/${filename}`, source) + const document = await bundleDocument(filename, source) if (c4Kind(document.frontmatter.type)) documents.push(document) else if (document.frontmatter.type === FLOW_TYPE) flows.push(document) else throw new Error(`unsupported plan document "${filename}"`) } if (documents.length === 0) throw new Error('plan contains no elements') - return { title: requireText(plan.frontmatter.title as string, 'plan title'), overview: plan.body.trim(), documents, flows } + return { title: requireText(typeof title === 'string' ? title : undefined, 'plan title'), outcome: plan.body.trim(), documents, flows } } -function validateImportIds(bundle: PlanBundle, records: Awaited>, existing: ArchitectureModel): string { +function validateImportIds(bundle: PlanBundle, records: ArchitectureRecords, existing: ArchitectureModel): string { const draftId = freeId(records, existing, bundle.title) for (const document of [...bundle.documents, ...bundle.flows]) { const id = String(requireGromaMapping(document.frontmatter, document.sourceFilename).id) @@ -198,15 +216,15 @@ export async function importPlan(repositoryRoot: string, input: PlanImportInput) const documents = importedDocuments(bundle, existing, input.parent, draftId) const model = buildArchitectureModel([...records.documents, ...documents]) const output = renderImport(bundle, filesystem, model, existing, draftId) - const finalDocuments = await Promise.all([...output].map(([filename, source]) => documentOf(filename, source))) + const finalDocuments = await Promise.all([...output].map(([filename, source]) => parseArchitectureDocument(filename, source))) const finalModel = buildArchitectureModel([...records.documents, ...finalDocuments.filter(document => c4Kind(document.frontmatter.type))]) resolveFlows([...records.flows, ...finalDocuments.filter(document => document.frontmatter.type === FLOW_TYPE)], finalModel) const draftFilename = draftRecordPath(filesystem.sourceFilename(), draftId) - output.set(draftFilename, renderDraftDocument({ id: draftId, title: bundle.title, outcome: bundle.overview })) + output.set(draftFilename, renderDraftDocument({ id: draftId, title: bundle.title, outcome: bundle.outcome })) for (const filename of output.keys()) { if (filesystem.exists(filesystem.relative(filename))) throw new Error(`architecture document already exists at ${filename}`) } - draftRecordOf(await documentOf(draftFilename, output.get(draftFilename)!)) + draftRecordOf(await parseArchitectureDocument(draftFilename, output.get(draftFilename)!)) for (const [filename, source] of output) await filesystem.writeSource(filename, source) return draftId }) diff --git a/src/relationship-storage.ts b/src/relationship-storage.ts index 2ba69330b..80b605792 100644 --- a/src/relationship-storage.ts +++ b/src/relationship-storage.ts @@ -2,7 +2,7 @@ import path from 'node:path' import { parseMarkdown, parseFrontmatter } from 'comark' import { buildArchitectureModel } from './architecture-model.ts' import { sourceIndex } from './source-index.ts' -import { escapeMarkdownText } from './architecture-markdown.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' @@ -80,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)}) | ${escapeMarkdownText(row.description)} | ${escapeMarkdownText(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 f19170bb9..0d90eb39f 100644 --- a/src/viewers/web/authoring.ts +++ b/src/viewers/web/authoring.ts @@ -14,16 +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) })), - onExport: data.exportPlan === undefined ? undefined : to => data.exportPlan!({ ids: [...ids], to }), 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 ecbe9e808..75ca4be64 100644 --- a/src/viewers/web/data.ts +++ b/src/viewers/web/data.ts @@ -1,6 +1,6 @@ -import type { PlanExportInput } from '../../plan.ts' 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' @@ -19,12 +19,12 @@ export interface WebDataSource { readTask(id: string): Promise readTaskDiff(id: string): Promise /** The writers, absent in the published delivery, which has none. */ - exportPlan?(input: PlanExportInput): Promise draft?(input: DraftInput): Promise add?(input: AddInput): Promise 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 @@ -85,12 +85,12 @@ function liveDataSource(): WebDataSource { readTaskDiff(id) { return responseJson(selected('/task-diff.json', { task: id })) }, - exportPlan: input => send('/plan/export', input), draft: input => send('/draft', input), add: input => send('/add', input), 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 776ac701f..8c3bb5aab 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,7 +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 }) } }], - ['/plan/export', request => writeResponse(request, writes.exportPlan)], + // 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 1ad2b5d23..a2faed195 100644 --- a/src/viewers/web/organisms/writes.ts +++ b/src/viewers/web/organisms/writes.ts @@ -61,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') @@ -92,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) { @@ -106,23 +109,25 @@ export function paintSelectionControls(body: Element, selection: SelectionWrites submit(group, () => selection.onGroup(name.value)) submit(combine, () => selection.onCombine(survivor.value)) box.append(group, combine) - if (selection.onExport) { - const form = document.createElement('form') - form.className = 'group-as' + 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 button = document.createElement('button') - button.type = 'submit' - button.textContent = 'Export as plan' - form.append(destination, button) - submit(form, async () => { - await selection.onExport!(destination.value) - error.textContent = `Exported to ${destination.value}` + 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(form) + box.append(exportForm) } - box.append(error) + box.append(error, confirmation) body.append(box) } diff --git a/src/viewers/web/page.ts b/src/viewers/web/page.ts index bd6a18f8e..e640b5961 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 be2f78752..4527e7fbe 100644 --- a/src/write-commands.ts +++ b/src/write-commands.ts @@ -50,7 +50,7 @@ function addedIds(thing: string, ids: string[]): Pick row.sourceId === 'entry' && row.targetId === 'worker')! - expect(changed).toMatchObject({ status: 'draft', description: 'Dispatches A | B', technology: 'Callback | queue' }) + const relationship = async () => (await architecture(destination)).relationships.find(row => row.sourceId === 'entry' && row.targetId === 'worker')! + // 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(destination, { ...ends, ...syntax }) + expect(await relationship()).toMatchObject({ status: 'draft', ...syntax }) + const ordinary = 'Reads user.name, then retries (HTTPS/JSON): 3 times; why? #12 + a=b - done!' + await editRelation(destination, { ...ends, description: `${ordinary}\nSecond line`, technology: 'HTTPS' }) + expect(await readFile(path.join(destination, entryPath), 'utf8')).toContain(`| ${ordinary} Second line | HTTPS |`) + expect((await relationship()).description).toBe(`${ordinary} Second line`) 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 }) + } +}) From be37bc225d2cd5930ffed4b465088e0ada394d34 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 21:18:16 +0200 Subject: [PATCH 4/5] TASK-562 - Export a selection of components as a plan and import it as drafts --- ...nents-as-a-plan-and-import-it-as-drafts.md | 16 +- docs/component-markdown.md | 67 +++-- docs/plans.md | 18 +- .../containers/cli/components/plan.md | 2 +- src/architecture-markdown.ts | 22 +- src/authoring.ts | 4 - src/flow-authoring.ts | 11 +- src/groma-filesystem.ts | 10 +- src/markdown-emitter.ts | 19 +- src/okf-profile.ts | 13 + src/plan.ts | 266 +++++++++++------- src/relation.ts | 46 +-- src/relationship-markdown.ts | 9 +- src/relationship-storage.ts | 4 +- src/write-commands.ts | 9 +- test-bun/plan.test.ts | 52 ++-- test-bun/source-relationships.test.ts | 78 +++-- 17 files changed, 410 insertions(+), 236 deletions(-) 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 12462399a..74e6b7887 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 @@ -5,7 +5,7 @@ status: In Progress assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-11 16:28' +updated_date: '2026-10-11 19:18' labels: - senior dependencies: [] @@ -50,6 +50,8 @@ modified_files: - 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 --- @@ -94,6 +96,12 @@ Build on drafts (src/draft.ts, src/accept.ts, draft records in src/architecture- 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 @@ -122,6 +130,12 @@ Verification of this pass. Both tests were run before the fixes and failed: the 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. ## Final Summary diff --git a/docs/component-markdown.md b/docs/component-markdown.md index d5a28f370..4abedf43f 100644 --- a/docs/component-markdown.md +++ b/docs/component-markdown.md @@ -454,23 +454,31 @@ The live [groma.md system](../groma/systems/groma/system.md) and 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. Required external parents come from the selected -elements' `groma.parent` IDs that do not name another selected element. It 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. - -Selected C4 documents retain their IDs, types, titles, descriptions, -technologies, parent IDs and overviews, with `status: draft`. Code ownership, -map layout, groups and previous draft tags are omitted. Only selected elements -are copied; unselected parents appear by ID only. Documents use the normal C4 -paths relative to the bundle, and the parent IDs remain authoritative. +`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. -Unlike current code relationships, planned relationships may link internal -software concepts without source files. 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 +`## 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 @@ -478,20 +486,25 @@ 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. Their -ordinary linked Steps tables retain order and actions. A plan carries no -implementation steps: a flow describes an architecture scenario. +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. `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, writes ghosts at their canonical destination paths, and tags -each 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. +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 diff --git a/docs/plans.md b/docs/plans.md index 556b3ce3a..a013ed709 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -2,7 +2,9 @@ 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 elements are ghosts under one draft record. +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. @@ -18,7 +20,7 @@ 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. +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. @@ -38,6 +40,9 @@ 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 @@ -61,8 +66,13 @@ Collects an order and asks Payments to authorize it. Both component documents live together at their source architecture paths inside the bundle. Their parent is an ID reference, not a copied container. -Import computes their destination paths from the destination parent, keeps -IDs and overviews, and adds `groma.draft: payment-plan`. It also creates + +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 diff --git a/groma/systems/groma-md/containers/cli/components/plan.md b/groma/systems/groma-md/containers/cli/components/plan.md index e8e3c9576..88bdc8013 100644 --- a/groma/systems/groma-md/containers/cli/components/plan.md +++ b/groma/systems/groma-md/containers/cli/components/plan.md @@ -12,4 +12,4 @@ groma: description: Shares selected architecture as portable OKF drafts --- -Exports selected C4 responsibilities, their internal relationships and flows as ordinary OKF Markdown without source ownership or layout. Import resolves existing parents, validates IDs and the complete architecture before saving, and writes ghosts at their canonical paths under one named draft record. The command line exports to any new directory; the browser multi-selection exports only into a new directory inside the repository. Scanner matching and element acceptance retain their existing responsibilities. +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/src/architecture-markdown.ts b/src/architecture-markdown.ts index 299232b1b..a79c58a9e 100644 --- a/src/architecture-markdown.ts +++ b/src/architecture-markdown.ts @@ -12,18 +12,28 @@ type InvalidMarkdown = (code: string, sourceFilename: string, message: string) = * 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, character reference, strikethrough, attributes, table cell. - /[\\`*_[<&~{|]/, - // An inline component such as :icon. It starts a word, so the colon of /users/:id or "note: text" stays plain. - /(?<=^|[\s*_[]):(?=[a-z$])/, + // 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('|'), 'gi') +].map(rule => rule.source).join('|'), 'g') -/** This text as Markdown the reader shows literally. Line breaks are kept, which suits prose paragraphs. */ +/** + * 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, '\\$&') } diff --git a/src/authoring.ts b/src/authoring.ts index cc0a42825..115879098 100644 --- a/src/authoring.ts +++ b/src/authoring.ts @@ -8,7 +8,6 @@ import { editArchitecture } from './edit.ts' import type { EditArchitectureInput } from './edit.ts' import { removeThing } from './remove.ts' import type { RemoveInput } from './remove.ts' -import { exportPlan, importPlan } from './plan.ts' import { GromaFileSystem } from './groma-filesystem.ts' export interface AcceptInput { @@ -56,7 +55,4 @@ export const writes = { edit: protectedWrite(editArchitecture), remove: protectedWrite(removeThing), accept: protectedWrite(acceptThing), - // Command line only: the web exports through exportPlanIntoRepository and has no import. - exportPlan: protectedWrite(exportPlan), - importPlan: protectedWrite(importPlan), } diff --git a/src/flow-authoring.ts b/src/flow-authoring.ts index 6922ebb55..4991422b0 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 3fb8ab0c2..fffde5e4b 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/markdown-emitter.ts b/src/markdown-emitter.ts index 7747e8817..01a64ffba 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. */ @@ -171,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 a8a2de375..87359dd4c 100644 --- a/src/okf-profile.ts +++ b/src/okf-profile.ts @@ -90,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 index 6e6b7845c..f44f992df 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -7,22 +7,32 @@ 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 { renderArchitectureDocument, renderDraftDocument, sourceWithFrontmatter } from './markdown-emitter.ts' +import { renderDraftDocument, renderFlowDocument, sourceWithFrontmatter } from './markdown-emitter.ts' import { requireText } from './naming.ts' -import { c4Kind, FLOW_TYPE, OKF_VERSION, PLAN_TYPE, requireBundleIndex, requireGromaMapping } from './okf-profile.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 { StoredRelationship } from './relationship-markdown.ts' -import type { ArchitectureDocument, ArchitectureElement, ArchitectureFlow, ArchitectureModel, ArchitectureRecords } from './types.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 - documents: ArchitectureDocument[] - flows: ArchitectureDocument[] + elements: BundleDocuments + flows: BundleDocuments } /** @@ -31,37 +41,74 @@ interface PlanBundle { */ const BUNDLE_ROOT = 'plan' -function conceptSource(element: ArchitectureElement, parent = element.parentId, draft?: string): string { - return renderArchitectureDocument({ - id: element.id, kind: element.kind, name: element.title, - parent, description: element.description, overview: escapeMarkdownText(element.overview), - technology: element.technology, status: 'draft', draft, - }) +/** 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 sourceWithFrontmatter({ type: FLOW_TYPE, title: flow.title, description: flow.description, groma: { id: flow.id } }, - `\n\n${escapeMarkdownText(flow.overview)}\n\n## Steps\n\n| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}\n`) + return renderFlowDocument({ + id: flow.id, title: flow.title, description: flow.description, overview: escapeMarkdownText(flow.overview), + steps: `| From | To | Action |\n| --- | --- | --- |\n${rows.join('\n')}`, + }) } -function selectedRelationships(model: ArchitectureModel, ids: ReadonlySet, filenames: ReadonlyMap): StoredRelationship[] { +/** 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, document: filenames.get(row.sourceId)!, + 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 on disk. + * 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`) - const filesystem = GromaFileSystem.open(repositoryRoot) + // 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) @@ -72,23 +119,22 @@ export async function exportPlan(repositoryRoot: string, input: PlanExportInput) 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, filenames) + const rows = selectedRelationships(model, ids) const files = new Map() - const title = path.basename(to) - files.set('plan.md', sourceWithFrontmatter({ type: PLAN_TYPE, title }, '\n\nArchitecture to implement.\n')) + files.set('plan.md', sourceWithFrontmatter({ type: PLAN_TYPE, title: path.basename(to) }, '\n\nArchitecture to implement.\n')) for (const element of selected) { - files.set(filesystem.relative(element.sourceFilename), withStoredRelationships( - conceptSource(element), element.sourceFilename, rows.filter(row => row.source === element.id), filenames, - )) + 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)) } - const entries = [...new Set([...files.keys()].map(file => file.includes('/') ? `${file.split('/')[0]}/` : file))].sort() - files.set('index.md', sourceWithFrontmatter({ okf_version: OKF_VERSION }, - `\n\n# Contents\n\n${entries.map(file => `- [${file}](<${file}>)`).join('\n')}\n`)) + // 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 }) @@ -102,109 +148,102 @@ export async function exportPlan(repositoryRoot: string, input: PlanExportInput) } /** - * The export of the live web map. Its destination arrives from a browser, so it must lie inside the - * repository the map serves; everything else about the export is the same. + * 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') - const fromRoot = path.relative(repositoryRoot, path.resolve(repositoryRoot, to)) - if (fromRoot.split(path.sep)[0] === '..' || path.isAbsolute(fromRoot)) { + if (!isInside(path.resolve(repositoryRoot), path.resolve(repositoryRoot, to))) { throw new Error(`plan directory "${to}" must be inside the repository`) } return exportPlan(repositoryRoot, input) } -async function readBundleFiles(directory: string, relative = ''): Promise> { - const files = new Map() +/** 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()) { - for (const [name, source] of await readBundleFiles(directory, filename)) files.set(name, source) - } else if (entry.isFile() && entry.name.endsWith('.md')) { - files.set(filename, await readFile(path.join(directory, filename), 'utf8')) - } + if (entry.isDirectory()) filenames.push(...await bundleFilenames(directory, filename)) + else if (entry.isFile() && entry.name.endsWith('.md')) filenames.push(filename) } - return files + return filenames } -async function readPlan(directory: string): Promise { - const files = await readBundleFiles(directory) - const bundleDocument = (filename: string, source: string): Promise => - parseArchitectureDocument(`${BUNDLE_ROOT}/${filename}`, source) - const index = await bundleDocument('index.md', requireText(files.get('index.md'), 'plan index.md')) +/** 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 bundleDocument('plan.md', requireText(files.get('plan.md'), 'plan.md')) + 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}`) - const documents: ArchitectureDocument[] = [], flows: ArchitectureDocument[] = [] - for (const [filename, source] of files) { - if (filename === 'index.md' || filename === 'plan.md') continue - const document = await bundleDocument(filename, source) - if (c4Kind(document.frontmatter.type)) documents.push(document) - else if (document.frontmatter.type === FLOW_TYPE) flows.push(document) - else throw new Error(`unsupported plan document "${filename}"`) + 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 (documents.length === 0) throw new Error('plan contains no elements') - return { title: requireText(typeof title === 'string' ? title : undefined, 'plan title'), outcome: plan.body.trim(), documents, flows } + if (elements.documents.length === 0) throw new Error('plan contains no elements') + return { ...record, elements, flows } } -function validateImportIds(bundle: PlanBundle, records: ArchitectureRecords, existing: ArchitectureModel): string { +/** 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 document of [...bundle.documents, ...bundle.flows]) { - const id = String(requireGromaMapping(document.frontmatter, document.sourceFilename).id) + 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 } -function importedDocuments(bundle: PlanBundle, existing: ArchitectureModel, parent: string | undefined, draft: string): ArchitectureDocument[] { - const ids = new Set(bundle.documents.map(document => String(requireGromaMapping(document.frontmatter, document.sourceFilename).id))) - const externalParents = new Set(bundle.documents.flatMap(document => { - const parentId = requireGromaMapping(document.frontmatter, document.sourceFilename).parent - return typeof parentId === 'string' && !ids.has(parentId) ? [parentId] : [] - })) - for (const id of parent === undefined ? externalParents : [parent]) { - if (!existing.elements.some(element => element.id === id)) throw new Error(`unknown parent "${id}"`) - } - return bundle.documents.map(document => { +/** 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) - const parentId = parent !== undefined && externalParents.has(String(groma.parent)) ? parent : groma.parent - return { ...document, frontmatter: { ...document.frontmatter, status: 'draft', groma: { ...groma, parent: parentId, code: undefined, draft } } } + if (typeof groma.parent !== 'string' || elements.ids.has(groma.parent)) return document + return { ...document, frontmatter: { ...document.frontmatter, groma: { ...groma, parent } } } }) } -function importLocations(filesystem: GromaFileSystem, imported: ArchitectureElement[], existing: ArchitectureModel): Map { +/** 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 pending = [...imported] - while (pending.length) { - const index = pending.findIndex(element => element.parentId === null || locations.has(element.parentId)) - if (index < 0) throw new Error(`unresolved parent for "${pending[0]!.id}"`) - const element = pending.splice(index, 1)[0]! - locations.set(element.id, architectureElementPath({ root: filesystem.sourceFilename(), kind: element.kind, - id: element.id, external: element.external, parentSourceFilename: element.parentId ? locations.get(element.parentId) : undefined })) + 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 } -function renderImport(bundle: PlanBundle, filesystem: GromaFileSystem, model: ArchitectureModel, existing: ArchitectureModel, draft: string): Map { - const ids = new Set(bundle.documents.map(document => String(requireGromaMapping(document.frontmatter, document.sourceFilename).id))) - const imported = model.elements.filter(element => ids.has(element.id)) - const locations = importLocations(filesystem, imported, existing) - const rows = selectedRelationships(model, ids, locations) - const output = new Map() - for (const element of imported) { - const filename = locations.get(element.id)! - output.set(filename, withStoredRelationships(conceptSource(element, element.parentId, draft), filename, - rows.filter(row => row.source === element.id), locations)) - } - for (const flow of resolveFlows(bundle.flows, model)) { - const filename = filesystem.sourceFilename(`flows/${flow.id}.md`) - output.set(filename, flowSource(flow, locations, filename)) - } - return output -} - /** 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)) @@ -212,19 +251,36 @@ export async function importPlan(repositoryRoot: string, input: PlanImportInput) return filesystem.withAccess(async () => { const records = await loadArchitecture(repositoryRoot) const existing = buildArchitectureModel(records.documents) - const draftId = validateImportIds(bundle, records, existing) - const documents = importedDocuments(bundle, existing, input.parent, draftId) - const model = buildArchitectureModel([...records.documents, ...documents]) - const output = renderImport(bundle, filesystem, model, existing, draftId) - const finalDocuments = await Promise.all([...output].map(([filename, source]) => parseArchitectureDocument(filename, source))) - const finalModel = buildArchitectureModel([...records.documents, ...finalDocuments.filter(document => c4Kind(document.frontmatter.type))]) - resolveFlows([...records.flows, ...finalDocuments.filter(document => document.frontmatter.type === FLOW_TYPE)], finalModel) - const draftFilename = draftRecordPath(filesystem.sourceFilename(), draftId) - output.set(draftFilename, renderDraftDocument({ id: draftId, title: bundle.title, outcome: bundle.outcome })) + 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}`) } - draftRecordOf(await parseArchitectureDocument(draftFilename, output.get(draftFilename)!)) for (const [filename, source] of output) await filesystem.writeSource(filename, source) return draftId }) diff --git a/src/relation.ts b/src/relation.ts index 74ba6c4af..a8522a7ba 100644 --- a/src/relation.ts +++ b/src/relation.ts @@ -2,7 +2,7 @@ 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' @@ -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 @@ -46,12 +49,11 @@ async function loadEnds(repositoryRoot: string, input: RelationEnds): Promise Number(right.authored) - Number(left.authored)) .find(item => item.source === input.source && item.target === input.target) - const declaredConcept = [source, target].some(element => element.kind === 'actor' || element.external) - const plannedConcepts = row?.status === 'draft' && input.source === source.id && input.target === target.id - if (!declaredConcept && !plannedConcepts && (!index.byFile.has(input.source) || !index.byFile.has(input.target))) { - throw new Error('code relationships require source-file endpoints') - } - 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 { @@ -62,11 +64,12 @@ function requireRow(ends: Ends): RelationshipConnection { async function writeRow( repositoryRoot: string, ends: Ends, - change: { drop?: RelationshipConnection | 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 dropped = new Set(Array.isArray(change.drop) ? change.drop : [change.drop]) - const rows = ends.rows.filter(row => !dropped.has(row)) - 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 @@ -78,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 { @@ -91,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'), @@ -100,13 +104,13 @@ export async function editRelation(repositoryRoot: string, input: RelationInput) }) } -/** Planned concept links become current only through one existing exact-file connection. */ +/** The endpoints under which an accepted relationship is stored. */ function acceptanceEnds(ends: Ends): RelationEnds { - const index = sourceIndex(ends.model.elements) - const declaredConcept = [ends.source, ends.target].some(element => element.kind === 'actor' || element.external) - if (declaredConcept || (index.byFile.has(ends.input.source) && index.byFile.has(ends.input.target))) return ends.input + // 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' - && index.byFile.get(row.source) === ends.source.id && index.byFile.get(row.target) === ends.target.id) + && 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}`) @@ -121,10 +125,10 @@ export async function acceptRelation(repositoryRoot: string, input: RelationEnds if (!current.authored || current.status !== 'draft') throw new Error('not a draft relationship') const accepted = acceptanceEnds(ends) const overwritten = ends.rows.filter(row => row.authored && row.source === accepted.source && row.target === accepted.target) - return writeRow(repositoryRoot, { ...ends, input: accepted }, { + 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 { @@ -134,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 9ee86e02f..1d2d03315 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,7 +56,7 @@ 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) + 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') diff --git a/src/relationship-storage.ts b/src/relationship-storage.ts index 80b605792..7a89fd493 100644 --- a/src/relationship-storage.ts +++ b/src/relationship-storage.ts @@ -5,7 +5,7 @@ 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 @@ -72,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) diff --git a/src/write-commands.ts b/src/write-commands.ts index 4527e7fbe..e1b960268 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 { @@ -63,14 +64,14 @@ export function registerWriteCommands(program: Command): void { .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 writes.exportPlan(process.cwd(), { ids, to: options.to })) } + 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 as ghosts belonging to one draft') + .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 writes.importPlan(process.cwd(), { path, parent: options.parent })) } + try { printWriteResult(await importPlan(process.cwd(), { path, parent: options.parent })) } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 } }) @@ -79,7 +80,7 @@ export function registerWriteCommands(program: Command): void { .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/plan.test.ts b/test-bun/plan.test.ts index 795c7cbac..ac47101db 100644 --- a/test-bun/plan.test.ts +++ b/test-bun/plan.test.ts @@ -28,12 +28,21 @@ async function architecture(root: string) { const entryPath = 'groma/systems/service/containers/api/components/entry.md' -test.concurrent('a plan round trip preserves literal overview, relationship and ordered flow meaning', async () => { +/** 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('Coordinates a request.', 'Coordinates the `*pending*` request and `©` value.') + .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')) @@ -43,6 +52,15 @@ test.concurrent('a plan round trip preserves literal overview, relationship and 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 }) => @@ -59,9 +77,9 @@ test.concurrent('element parent IDs drive missing-parent validation and destinat const { root, source, destination } = await repositories() try { const bundle = await exportPlan(source, { ids: ['entry', 'worker'], to: '../shared-plan' }) - const before = await readdir(path.join(destination, 'groma'), { recursive: true }) - await expect(importPlan(destination, { path: bundle })).rejects.toThrow('api') - expect(await readdir(path.join(destination, 'groma'), { recursive: true })).toEqual(before) + 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' }) @@ -77,18 +95,9 @@ test.concurrent('imported concept draft relationships can be edited and removed const bundle = await exportPlan(source, { ids: ['service', 'api', 'entry', 'worker'], to: '../shared-plan' }) await importPlan(destination, { path: bundle }) const ends = { source: 'entry', target: 'worker' } - const relationship = async () => (await architecture(destination)).relationships.find(row => row.sourceId === 'entry' && row.targetId === 'worker')! - // 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(destination, { ...ends, ...syntax }) - expect(await relationship()).toMatchObject({ status: 'draft', ...syntax }) - const ordinary = 'Reads user.name, then retries (HTTPS/JSON): 3 times; why? #12 + a=b - done!' - await editRelation(destination, { ...ends, description: `${ordinary}\nSecond line`, technology: 'HTTPS' }) - expect(await readFile(path.join(destination, entryPath), 'utf8')).toContain(`| ${ordinary} Second line | HTTPS |`) - expect((await relationship()).description).toBe(`${ordinary} Second line`) + 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 }) } @@ -119,3 +128,12 @@ test.concurrent('the live map exports a plan only into a new directory inside it 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 0719593d8..e0888650c 100644 --- a/test-bun/source-relationships.test.ts +++ b/test-bun/source-relationships.test.ts @@ -288,28 +288,38 @@ run({ deliver: choose ? send : wrap }) } finally { await rm(root, { recursive: true, force: true }) } }) -test.concurrent('an imported concept draft accepts only through a unique existing current file pair', async () => { +/** 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 { - 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 source = owner(model, 'worker'), target = owner(model, 'provider') const planned = { source: source.id, target: target.id } - 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 }) 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 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')) + await addImplementation(root, destination) await scanRepository(destination) expect(await acceptGhost(destination, source.id)).toBe('accepted') expect(await acceptGhost(destination, target.id)).toBe('accepted') @@ -331,22 +341,9 @@ test.concurrent('an imported concept draft accepts only through a unique existin }) test.concurrent('ambiguous current file evidence leaves an imported concept relationship planned', async () => { - const root = await repository() - const destination = await mkdtemp(path.join(os.tmpdir(), 'groma-ambiguous-relations-')) + const { root, destination, source, target } = await importedPlan() try { - 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 source = owner(model, 'worker'), target = owner(model, 'provider') - 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 }) - 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')) + 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') @@ -361,3 +358,26 @@ test.concurrent('ambiguous current file evidence leaves an imported concept rela 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 }) } +}) From 2e07df3ded7977aa51890d681ab9ab567ffa91a0 Mon Sep 17 00:00:00 2001 From: Alex Gavrilescu Date: Sun, 11 Oct 2026 21:21:30 +0200 Subject: [PATCH 5/5] TASK-562 - Export a selection of components as a plan and import it as drafts --- ...ion-of-components-as-a-plan-and-import-it-as-drafts.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 74e6b7887..e19b4979d 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,11 +1,11 @@ --- id: TASK-562 title: Export a selection of components as a plan and import it as drafts -status: In Progress +status: Done assignee: - '@codex' created_date: '2026-10-07 23:16' -updated_date: '2026-10-11 19:18' +updated_date: '2026-10-11 19:21' labels: - senior dependencies: [] @@ -136,10 +136,12 @@ Second pass on the consolidated review list, 11 October. D1: plannedSource in sr 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 -Portable OKF plans preserve selected element, relationship and ordered flow meaning, including literal overview characters and table pipes. Required external parents are derived from authoritative element parent IDs. Imported concept-ID draft relationships support editing and removal; explicit acceptance transfers their planned text only to one existing current exact-file pair, leaving missing or ambiguous evidence unchanged. Final rendered-document validation and existing scanner/ghost lifecycle remain in use. Verified 115 focused tests, the real live-browser multi-selection export and a separate import of its bundle, bare CLI help, and bun run check (16 Node tests; 781 Bun passed, 51 optional-runtime skips, zero failures). Both external reviews and implementer specification/quality reviews passed. Updated contracts and the existing architecture relationship owner. All task criteria and Definition of Done items are checked. Changes are committed locally without publication. +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).