diff --git a/docs/src/components/SystemMap.astro b/docs/src/components/SystemMap.astro new file mode 100644 index 00000000..1f2ebdba --- /dev/null +++ b/docs/src/components/SystemMap.astro @@ -0,0 +1,198 @@ +--- +/* + * Capabilities, specs and decision records as one graph, with traceability linking code and tests into it. + * Every node and edge kind drawn here is one `osq graph` prints; the example names come from the traceability spec. + */ +type Kind = 'adr' | 'capability' | 'requirement' | 'scenario' | 'function' | 'test' | 'file'; +interface Node { kind: Kind; name: string; gap?: string } +interface Edge { from: string; to: string; label: string } + +const H = 48; + +const nodes: Record = { + adr: { kind: 'adr', name: '001 Use cents' }, + cap: { kind: 'capability', name: 'pricing' }, + req: { kind: 'requirement', name: 'Discounts' }, + scn: { kind: 'scenario', name: 'Bulk tiers' }, + gap: { kind: 'scenario', name: 'Member price', gap: 'untested' }, + fn: { kind: 'function', name: 'quote()' }, + test: { kind: 'test', name: 'quote.test.ts' }, + file: { kind: 'file', name: 'scripts/seed.ts', gap: 'unowned' }, +}; + +const edges: Edge[] = [ + { from: 'adr', to: 'cap', label: 'applies to' }, + { from: 'cap', to: 'req', label: 'contains' }, + { from: 'req', to: 'scn', label: 'contains' }, + { from: 'req', to: 'gap', label: 'contains' }, + { from: 'cap', to: 'fn', label: 'owns' }, + { from: 'fn', to: 'scn', label: 'serves' }, + { from: 'fn', to: 'adr', label: 'follows' }, + { from: 'test', to: 'fn', label: 'covers' }, + { from: 'test', to: 'scn', label: 'proves' }, +]; + +type Layout = { w: number; width: number; height: number; at: Record; bends?: Record }; + +// three columns on wide screens, two on phones, so the labels stay readable at either size +const wide: Layout = { + w: 150, + width: 630, + height: 330, + at: { adr: [0, 0], cap: [230, 0], req: [460, 0], fn: [230, 141], scn: [460, 141], test: [230, 282], gap: [460, 282], file: [0, 282] }, + bends: { 'req>gap': [[628, 24], [628, 306]] }, +}; +const narrow: Layout = { + w: 135, + width: 352, + height: 470, + at: { adr: [0, 0], cap: [205, 0], fn: [0, 141], req: [205, 141], test: [0, 282], scn: [205, 282], file: [0, 422], gap: [205, 422] }, + bends: { 'req>gap': [[350, 165], [350, 446]] }, +}; + +/** Where the line from a box's centre toward `[tx, ty]` leaves the box. */ +function exit(W: number, [x, y]: [number, number], [tx, ty]: [number, number]): [number, number] { + const cx = x + W / 2; + const cy = y + H / 2; + const dx = tx - cx; + const dy = ty - cy; + const t = Math.min(dx ? W / 2 / Math.abs(dx) : Infinity, dy ? H / 2 / Math.abs(dy) : Infinity); + return [cx + dx * t, cy + dy * t]; +} + +const centre = (W: number, [x, y]: [number, number]): [number, number] => [x + W / 2, y + H / 2]; +const fmt = (pts: [number, number][]) => 'M' + pts.map(([x, y]) => `${x.toFixed(1)} ${y.toFixed(1)}`).join(' L'); + +function draw(layout: Layout) { + const W = layout.w; + return edges.map((edge) => { + const a = layout.at[edge.from]; + const b = layout.at[edge.to]; + const bend = layout.bends?.[`${edge.from}>${edge.to}`]; + const pts: [number, number][] = bend + ? [[a[0] + W, bend[0][1]], ...bend, [b[0] + W, bend[bend.length - 1][1]]] + : [exit(W, a, centre(W, b)), exit(W, b, centre(W, a))]; + // a bent edge runs along the frame and goes unlabelled; its twin edge already names the link + const mid: [number, number] = [(pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2]; + return { d: fmt(pts), mid, label: bend ? '' : edge.label, gap: Boolean(nodes[edge.to].gap) }; + }); +} + +const layouts = [ + { name: 'wide', layout: wide }, + { name: 'narrow', layout: narrow }, +].map(({ name, layout }) => ({ name, layout, lines: draw(layout) })); + +const kindLabel: Record = { + adr: 'Decision', + capability: 'Capability', + requirement: 'Requirement', + scenario: 'Scenario', + function: 'Function', + test: 'Test', + file: 'File', +}; +const codeKinds: Kind[] = ['function', 'test', 'file']; + +const parts = [ + { kind: 'Capabilities', body: 'Each one names a part of what your software does and the files that belong to it.' }, + { kind: 'Specs', body: 'Requirements and the scenarios that prove them, in plain words. They change only when an approved change lands.' }, + { kind: 'Decisions', body: 'Architecture decision records name the capabilities they govern. Accepted ones become rules every agent reads.' }, + { + kind: 'Traceability', + opt: true, + body: 'Tag a function with the scenario it serves and the decision it follows, and name each test after its scenario. osq links code and tests into the graph and points out the gaps: a scenario no test proves, a function no scenario claims, a file no capability owns.', + }, +]; +--- + +
+
+
+ A map of your software +

Know what you built, and why.

+

Capabilities, specs and decision records are plain files in your repo that link into one graph: what the software does, how each part behaves, and why it was built that way. People use it to find their way around. Agents get the same context before they plan or build.

+
    + {parts.map((part) => ( +
  1. +

    {part.kind}{part.opt && Opt in}

    +

    {part.body}

    +
  2. + ))} +
+ osq graph · osq spec · osq report +
+ +
+ {layouts.map(({ name, layout, lines }) => ( + + ))} +
+ Specs and decisions + Code and tests, with traceability + A gap osq points out +
+
+
+
+ + diff --git a/docs/src/pages/index.astro b/docs/src/pages/index.astro index 43f093b1..e9850ee8 100644 --- a/docs/src/pages/index.astro +++ b/docs/src/pages/index.astro @@ -3,6 +3,7 @@ import Base from '../layouts/Base.astro'; import Hero from '../components/Hero.astro'; import Why from '../components/Why.astro'; import HowItWorks from '../components/HowItWorks.astro'; +import SystemMap from '../components/SystemMap.astro'; import Features from '../components/Features.astro'; import GetStarted from '../components/GetStarted.astro'; --- @@ -14,6 +15,7 @@ import GetStarted from '../components/GetStarted.astro'; +