Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

67 changes: 66 additions & 1 deletion docs/component-markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -444,3 +447,65 @@ TypeScript, NestJS, and PostgreSQL.

The live [groma.md system](../groma/systems/groma/system.md) and
[MVP draft](../groma/drafts/mvp.md) are a complete package example.


## Plan bundles

A plan is an OKF 0.2 bundle containing a selected architecture fragment. Its
root `index.md` uses the normal `okf_version: "0.2"` declaration and Markdown
links. The supporting `plan.md` record has `type: Groma Plan`, a standard
`title` and outcome prose. The record has no C4 identity, containment level,
or box on the map. It is a Groma application-profile record, not a type
required by OKF or C4. Import copies its title and outcome into a draft record.

A selected C4 document is carried with its Markdown body as written: the
overview, its formatting and links, and every authored section. A plan replaces
two things only. In the front matter, a system, container or component gets
`status: draft`, while a person or an external system stays a `stable`
declaration, because those are declared and never drafted. `groma.code`,
`groma.group` and an earlier `groma.draft` tag are left out. Every other field
stays, including the ID, type, title, description, technology and parent ID.
In the body, the relationship sections are rebuilt as described below. Only
selected elements are copied. A parent that is not selected appears by ID only,
in the `groma.parent` of its children, and the parent IDs remain authoritative.
Documents use the normal C4 paths relative to the bundle.

Relationships within the selection become authored rows under
`## Draft relationships`, using links to the selected concept documents. No
other relationship row of a document is carried. Unlike current code
relationships, planned relationships may link internal software concepts
without source files. Only `groma plan import` writes such rows:
`groma draft relation` still requires source files, or a person or an external
system at one end. They remain draft rows when scanning or accepting an
element. This permits architecture outcomes to be described before
implementation exists. Existing edit and remove controls accept these
concept IDs. Accepting a planned internal relationship uses the unique current
source-file connection between its component owners, transfers the planned text
to that exact pair, and removes the concept draft. If no unique current pair
exists, acceptance leaves the draft unchanged and explains the missing or
ambiguous evidence. Scanning does not invent evidence from planned intent.
The existing exact-file rules for current code relationships are unchanged.

Flows are included only when every step's endpoints are selected. A flow is
written again from its title, description, overview text and steps, so its
ordinary linked Steps table retains order and actions while formatting inside
its overview is not carried. A plan carries no implementation steps: a flow
describes an architecture scenario.

`groma plan export <ids...> --to <directory>` creates a new bundle directory;
its basename supplies the plan title. The directory must not exist yet and
must lie outside the Groma directory, where every Markdown file is read as
architecture. `groma plan import <directory> [--parent <id>]` imports into an
initialized architecture. Without `--parent`, all referenced parents must
already exist. With it, external parent references are replaced by that
existing destination parent. Import validates the complete result before
writing, including ID collisions and normal containment rules. It preserves IDs
and bodies, writes each document at its canonical destination path with the
status described above, and tags every imported element with a new draft record
named after the plan. A self-contained selection including its system and
containers can be imported into an empty initialized architecture. Existing
scanner matching and element acceptance remain in use.

Ordinary Markdown readers can browse the bundle and read outcomes and linked
collaborations. Groma interprets parent resolution, draft lifecycle and the
plan record. See [Share architecture plans](plans.md) for an example.
102 changes: 102 additions & 0 deletions docs/plans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Share architecture plans

A plan shares an intended architecture without source ownership or map layout.
Export the elements you want, then import them in another initialized Groma
project. Imported systems, containers and components arrive as ghosts. People
and external systems arrive as stable declarations, because those are declared
and never drafted. One draft record tags everything a plan brought in.

```sh
# In the source repository, select two components.
groma plan export checkout payments --to plans/payment-plan

# In the destination repository, use its existing application container.
groma plan import ../source/plans/payment-plan --parent application
```

On the live browser map, select several components and use **Export as plan**
beside **Group as** and **Combine into**. Enter a new directory inside the
repository; Groma writes the same bundle as the CLI and confirms where it went.
The command line may export to a new directory anywhere.

The directory name becomes the plan title. Export refuses a directory that
already exists or lies inside the Groma directory.
The bundle contains an OKF `index.md`, a `plan.md` record, selected element
Markdown files, and flows whose steps stay entirely inside the selection.
Unselected parents are referenced by ID and must already exist at import.
`--parent` replaces these references with an existing destination parent;
normal C4 containment rules still apply. To carry a complete architecture into
an empty initialized project, explicitly select its system and containers as
well as its components.

An example `plan.md`:

```markdown
---
type: Groma Plan
title: payment-plan
---

Architecture to implement.
```

Export writes that body as a placeholder. Replace it with the outcome the plan
is for before you share the bundle: import copies it into the draft record.

The selected `checkout` document remains normal architecture Markdown:

```markdown
---
type: C4 Component
title: Checkout
status: draft
groma:
id: checkout
parent: source-application
---

Collects an order and asks Payments to authorize it.

## Draft relationships

| Source | Target | Description | Technology |
| --- | --- | --- | --- |
| [checkout](checkout.md) | [payments](payments.md) | Requests authorization | HTTPS |
```

Both component documents live together at their source architecture paths
inside the bundle. Their parent is an ID reference, not a copied container.

A plan carries each document's Markdown body as written: the overview with its
formatting and links, and sections such as `## Requirements`. It replaces only
the status, leaves out code ownership, the group and an earlier draft tag, and
rebuilds the relationship sections from the relationships inside the selection.
Import computes the destination paths from the destination parent, keeps IDs
and bodies, and adds `groma.draft: payment-plan`. It also creates
`drafts/payment-plan.md`. Relationships remain draft rows with concept links.

A colliding element, flow or draft ID, or a missing parent, rejects the whole
import before any architecture document is written. Import never renames IDs.

Implement the imported responsibilities in source, run `groma scan`, and use
`groma accept <id>` for each matched ghost. The existing scanner matching rules
apply; import does not invent source paths or force a match.

An imported relationship stays a draft row between its two element IDs.
Scanning and accepting the elements do not change it. Use those IDs to edit,
remove or accept it:

```sh
groma edit relation checkout payments --description "Requests a refund"
groma remove relation checkout payments
groma accept relation checkout payments
```

Accepting needs exactly one current relationship from a source file of the
first component to a source file of the second, which a scan records once the
code exists. The planned description and technology move to that file pair.
With none or more than one, the command says how many it found and the draft
stays as it is. A relationship with a person or an external system needs no
code.

The [Markdown contract](component-markdown.md#plan-bundles) defines the format.
15 changes: 15 additions & 0 deletions groma/systems/groma-md/containers/cli/components/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
type: C4 Component
title: Architecture plans
status: stable
groma:
id: plan
parent: cli
code:
- scanner: typescript
file: src/plan.ts
group: Architecture records
description: Shares selected architecture as portable OKF drafts
---

Exports selected C4 elements as ordinary OKF Markdown without source ownership or layout: each document's body as written, the relationships inside the selection as draft rows, and the flows that stay inside it. Import resolves existing parents, validates IDs and the complete architecture before saving, and writes software as ghosts and people and external systems as stable declarations at their canonical paths, all tagged with one named draft record. Export never writes into the Groma directory; the command line may use any other new directory, and the browser multi-selection only one inside the repository. Scanner matching and element acceptance retain their existing responsibilities.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 35 additions & 0 deletions src/architecture-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,41 @@ import type {

type InvalidMarkdown = (code: string, sourceFilename: string, message: string) => never

/**
* Where Comark, the parser behind the architecture reader, reads inline syntax in text. Each match gets a
* backslash; all other punctuation is stored as written, so documents stay ordinary Markdown.
* Block syntax at the start of a line, such as "# " or "- ", is not covered.
* Guarded by "relationship text is stored as written and read back unchanged" in test-bun/source-relationships.test.ts.
*/
const markdownSyntax = new RegExp([
// Escape, code, emphasis, link or span, HTML, strikethrough, attributes, table cell.
/[\\`*[<~{|]/,
// Emphasis. An underscore between two letters or digits, as in user_id, never starts it and stays plain.
/(?<![a-zA-Z\d])_|_(?![a-zA-Z\d])/,
// A character reference such as &amp; or &#35;. Any other ampersand, as in "Auth & billing", stays plain.
/&(?=#?[a-zA-Z\d]+;)/,
// An inline component such as :icon. Comark accepts one only at the start of the text or after a space, *, _
// or [, and its name starts with a letter or $. The colon of /users/:id or "note: text" fits neither and stays plain.
/(?<=^|[\s*_[]):(?=[a-zA-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('|'), 'g')

/**
* Literal text as Markdown that the reader shows unchanged. Pass text, never Markdown: formatting would be
* stored as visible characters, and escaping twice adds visible backslashes. Line breaks are kept, which
* suits prose paragraphs.
*/
export function escapeMarkdownText(text: string): string {
return text.replaceAll(markdownSyntax, '\\$&')
}

/** The same for one table cell. A line break would end the row, so the text continues after a space. */
export function escapeTableCell(text: string): string {
return escapeMarkdownText(text).replaceAll(/\s*[\r\n]\s*/g, ' ')
}

export function nodeText(node: MarkdownNode | undefined): string {
if (typeof node === 'string') return node
if (!Array.isArray(node)) return ''
Expand Down
16 changes: 12 additions & 4 deletions src/architecture-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<ArchitectureDocument> {
let tree: Awaited<ReturnType<typeof parseMarkdown>>
try {
tree = await parseMarkdown(source)
Expand All @@ -139,9 +147,9 @@ async function requireGromaPackage(
filesystem: GromaFileSystem,
onFilesystemAccess?: FilesystemAccessHandler,
): Promise<void> {
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)
}
Expand All @@ -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/')) {
Expand Down
11 changes: 5 additions & 6 deletions src/flow-authoring.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -27,9 +25,10 @@ async function writeFlow(repositoryRoot: string, records: ArchitectureRecords, f
export async function addFlow(repositoryRoot: string, title: string, input: FlowChanges): Promise<string> {
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
Expand Down
10 changes: 3 additions & 7 deletions src/groma-filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlySet<string>>()
const LOCK_TIMEOUT_MS = 5000
const LOCK_FILENAME = '.groma.lock'
Expand Down Expand Up @@ -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)
}
Expand Down
13 changes: 4 additions & 9 deletions src/http-relationships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading