diff --git a/docs/conventions/rendered-views/CHANGELOG.md b/docs/conventions/rendered-views/CHANGELOG.md index 7b5be8c5d7..8feda3f345 100644 --- a/docs/conventions/rendered-views/CHANGELOG.md +++ b/docs/conventions/rendered-views/CHANGELOG.md @@ -3,6 +3,12 @@ Notable changes to the rendered-views contract. The contract is not versioned; this log records each change to it. +## The map-* skills offer views on the builder, 2026-10-03 + +- **The `architecture` `map-*` skills offer interactive views built by `lib/view-builder.mjs` (#5863).** + One checked-in template plus the skill's JSON record as data, through the interactive profile, with the + destination taken from the `medium` key. The markdown and the record stay the record. + ## Post-mortem and blindspot views on the builder, 2026-10-03 - **`debugging:debug` and `discovery:blindspot` offer interactive views built by `lib/view-builder.mjs` (#5864).** diff --git a/docs/conventions/rendered-views/README.md b/docs/conventions/rendered-views/README.md index ec5f7641a0..0226b7cbc1 100644 --- a/docs/conventions/rendered-views/README.md +++ b/docs/conventions/rendered-views/README.md @@ -438,7 +438,9 @@ grandfathered list when they moved onto it. Emitters on the shared builder (`lib/view-builder.mjs`, interactive profile): `planning:plan` and `planning:brainstorm`, each offering its view from a checked-in template plus the session's JSON as data (`plugins/planning/scripts/build-view.mjs`); `debugging:debug` (post-mortem) and `discovery:blindspot`, built -the same way (`plugins/debugging/scripts/build-view.mjs`, `plugins/discovery/scripts/build-view.mjs`). +the same way (`plugins/debugging/scripts/build-view.mjs`, `plugins/discovery/scripts/build-view.mjs`); and the +`architecture` `map-*` skills, each offering a view of its JSON record from one checked-in template +(`plugins/architecture/scripts/build-view.mjs`). Retrofit list (existing lanes rendering untrusted-ish content, aligned to the security baseline by the tracked retrofit issue, not silently): `adhd:clarify`, diff --git a/plugins/architecture/.claude-plugin/plugin.json b/plugins/architecture/.claude-plugin/plugin.json index b09b6d4930..7ece66d229 100644 --- a/plugins/architecture/.claude-plugin/plugin.json +++ b/plugins/architecture/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "architecture", - "version": "0.20.1", + "version": "0.21.0", "description": "improve uses Ousterhout's deep-module lens to find shallow modules, seam leaks, and locality gaps, reports them as HTML, and interviews the chosen one. map-landscape charts a C4 system landscape and portfolio table with drift checks. map-dependencies, map-components, map-containers, map-context, map-flow, map-events, map-data, and map-deployment draw dependency, C4, sequence, event, data, and deployment views from committed files. record-decision writes an ADR in the repo's convention.", "author": { "name": "Melodic Software", diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index 4277e1946c..cfbba17083 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to the `architecture` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.0] - 2026-10-03 + +### Added + +- **Interactive views for the `map-*` records (#5863).** Each of `map-landscape`, `map-containers`, + `map-components`, `map-dependencies`, `map-data`, `map-events`, `map-flow`, `map-context` and + `map-deployment` offers a view of its record: a filter that traces an id through every row that names it, + and rows that open to their fields and citations. `scripts/build-view.mjs` fills the checked-in + `templates/map-view.html` with the record as escaped JSON data through `lib/view-builder.mjs` and + `lib/view-runtime.js`, which the plugin now carries as generated copies with `lib/html-escape.mjs`. No page + carries model-written markup or script, so repository text stays data. The markdown and the JSON record stay + the record. The publish destination comes from the `medium` key of the `rendered-views` cascade (`file` when + unset); the procedure is in `reference/rendered-view.md`. `map-components` passes `--from` to chart the + chosen deployable's closure. + ## [0.20.1] - 2026-10-03 ### Changed diff --git a/plugins/architecture/README.md b/plugins/architecture/README.md index efd884ac86..ab24f97864 100644 --- a/plugins/architecture/README.md +++ b/plugins/architecture/README.md @@ -127,6 +127,14 @@ picture is `diagram_dialect.system`. Pulumi projects of any other runtime, Helm stops. Resources a reader parses and does not map are listed, and a read that places no container is refused. `--live` is refused. +## Interactive views + +Each `map-*` skill offers an interactive view of its JSON record after the report: a filter that +traces an id through every row naming it, and rows that open to their fields and citations. The markdown +and the record stay authoritative. The page is a checked-in template plus the record as escaped data, so +repository text never becomes markup or script. Where it is delivered comes from the `medium` key of the +`rendered-views` cascade (`file` when unset). The procedure is in `reference/rendered-view.md`. + ## Record a decision `/architecture:record-decision` discovers the ADR convention the repository diff --git a/plugins/architecture/lib/html-escape.mjs b/plugins/architecture/lib/html-escape.mjs new file mode 100644 index 0000000000..75d4a15c47 --- /dev/null +++ b/plugins/architecture/lib/html-escape.mjs @@ -0,0 +1,214 @@ +// GENERATED from lib/html-escape.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Deterministic HTML escape for text and double-quoted attribute positions, +// plus a generator marker whose digest shows a page was not edited after it +// was stamped. Anyone can stamp a page, so the marker proves no provenance: +// whether a page is safe rests on the structural scan in validateRenderedPage. +// +// Claim: the five HTML-significant characters encode as & < > " +// ', ampersand first, and a double-quoted attribute value must not contain +// a raw quotation mark. Apostrophe uses the semicolon form from the OWASP +// example table. A numeric reference without the semicolon would keep consuming +// hex digits (WHATWG character-reference parsing). +// Basis: OWASP XSS Prevention Cheat Sheet, HTML entity example table and the +// "Output Encoding Rules Summary" HTML Entity row, fetched 2026-09-28 from +// https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html +// (the example table writes '; the summary row names the same mapping). +// WHATWG HTML living standard, last updated 25 September 2026, text and +// double-quoted attribute restrictions: +// https://html.spec.whatwg.org/multipage/syntax.html#elements-2 +// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 +// Recheck: that OWASP table or those WHATWG restrictions change what a quoted +// attribute or a text node may contain. +// +// This encoding is the text and quoted-attribute rule only. It does not make +// a URL, an event-handler name, or the contents of script or style safe. The +// page builder never interpolates into those positions. validateRenderedPage +// rejects script, every URL-bearing attribute, and inside style any `url(`, +// `@import`, `expression(` or backslash escape; quotes are rejected there as +// everywhere in text, which removes the string argument of image-set(). + +import { createHash } from "node:crypto"; + +const MARKER_PREFIX = ""; + +const ALLOWED_TAGS = new Set([ + "html", + "head", + "meta", + "title", + "style", + "body", + "p", + "h1", + "h2", + "h3", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "section", + "code", + "ol", + "li", + "details", + "summary", +]); + +const ALLOWED_ATTRS = new Set([ + "charset", + "class", + "content", + "id", + "lang", + "name", + "title", +]); + +// A value this module emits: raw text, or one of the five entities, and nothing +// else that is HTML-significant. Used on attribute values and on the text left +// after tags and comments are removed. +const ESCAPED_TEXT = + /^(?:[^&<>"']|&(?:amp|lt|gt|quot|#x27);)*$/; + +/** + * Escape for HTML text and for a quoted attribute. Ampersand is replaced + * first so an existing entity is not double-decoded. Null and undefined + * become the empty string. The same input always produces the same output. + * + * @param {unknown} value + * @returns {string} + */ +export function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** + * @param {string} html page bytes before the marker is inserted + * @returns {string} + */ +export function pageDigest(html) { + return createHash("sha256").update(html, "utf8").digest("hex"); +} + +/** + * Insert the generator marker immediately after the first ``. The digest + * covers the page before insertion, so stripping that one comment restores the + * digested bytes. + * + * @param {string} html + * @returns {string} + */ +export function stampPage(html) { + const marker = `${MARKER_PREFIX}${pageDigest(html)}${MARKER_SUFFIX}`; + const token = ""; + const at = html.indexOf(token); + if (at < 0) { + return marker + html; + } + const cut = at + token.length; + return html.slice(0, cut) + marker + html.slice(cut); +} + +/** + * @param {string} raw attribute source inside a tag, excluding the tag name + * @returns {{ name: string, value: string }[]} + */ +function parseAttributes(raw) { + const attrs = []; + const re = + /([^\s"'>=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match = re.exec(raw); + while (match) { + attrs.push({ + name: match[1].toLowerCase(), + value: match[2] ?? match[3] ?? match[4] ?? "", + }); + match = re.exec(raw); + } + return attrs; +} + +/** + * @param {string} html + * @param {string[]} failures + */ +function scanStructure(html, failures) { + const tagRe = /<\/?([A-Za-z][A-Za-z0-9]*)\b([^>]*)>/g; + let match = tagRe.exec(html); + while (match) { + const name = match[1].toLowerCase(); + const closing = match[0].startsWith("`, or end of input for an unclosed element. + const styleRe = /]*>([\s\S]*?)(?:<\/style[\s/>]|$)/gi; + let style = styleRe.exec(html); + while (style) { + if (/url\(|@import|expression\(|\\/i.test(style[1])) { + failures.push("style"); + } + style = styleRe.exec(html); + } + + let rest = html.replace(//g, ""); + rest = rest.replace(//gi, ""); + rest = rest.replace(/<\/?[A-Za-z][A-Za-z0-9]*\b[^>]*>/g, ""); + if (rest.includes("<")) { + failures.push("raw-lt"); + } + if (!ESCAPED_TEXT.test(rest)) { + failures.push("unescaped"); + } +} + +/** + * A page with no marker, or edited after it was stamped, fails on the marker. + * The digest can be recomputed by anyone, so a page carrying a valid marker is + * judged by the structural scan alone: hostile tags, attributes, unescaped + * text or resource-loading CSS fail however the page was stamped. + * + * @param {string} html + * @returns {{ ok: boolean, failures: string[] }} + */ +export function validateRenderedPage(html) { + const failures = []; + const markerPattern = //g; + const markers = html.match(markerPattern) ?? []; + if (markers.length !== 1) { + failures.push("marker"); + } else { + const digest = markers[0].slice(MARKER_PREFIX.length, MARKER_PREFIX.length + 64); + const stripped = html.replace(markers[0], ""); + if (pageDigest(stripped) !== digest) { + failures.push("marker-digest"); + } + } + scanStructure(html, failures); + return { ok: failures.length === 0, failures }; +} diff --git a/plugins/architecture/lib/view-builder.mjs b/plugins/architecture/lib/view-builder.mjs new file mode 100644 index 0000000000..173024327a --- /dev/null +++ b/plugins/architecture/lib/view-builder.mjs @@ -0,0 +1,517 @@ +// GENERATED from lib/view-builder.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Build a rendered view from a checked-in template plus data, and validate it +// against one of the two profiles in the rendered-views convention +// (docs/conventions/rendered-views/README.md, "The interactive validator profile"). +// +// report the template's {{key}} and {{#each key}}...{{/each}} slots are +// filled with escaped text; no script. Validated by +// validateRenderedPage in html-escape.mjs. +// interactive the template is static markup with data-rv-* bindings. The data +// goes only into a JSON data block; view-runtime.js, inlined and +// pinned by hash in the page's content security policy, renders it +// through textContent. +// +// The template is checked-in markup; the data may be attacker-controlled (K2). +// No data value is ever written into markup by the interactive profile. +// +// CLI: +// node view-builder.mjs --profile report|interactive --template --data --out +// node view-builder.mjs --check +// Exit 0 ok, 1 the page or input fails its profile, 2 usage or environment. + +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { escapeHtml, pageDigest, stampPage, validateRenderedPage } from "./html-escape.mjs"; + +export const INTERACTIVE_MARKER = "rv-gen:view-builder-interactive"; +const MARKER_RE = //g; +const DATA_ID = "rv-data"; +const DATA_OPEN = ``; + const html = + template.slice(0, headEnd) + + meta + + template.slice(headEnd, bodyEnd) + + scripts + + template.slice(bodyEnd); + const page = stamp(html); + const result = validateInteractivePage(page, runtime); + if (!result.ok) { + throw new ViewBuildError(result.failures); + } + return page; +} + +function stamp(html) { + const marker = ``; + const at = html.indexOf(""); + return at < 0 ? marker + html : html.slice(0, at + 6) + marker + html.slice(at + 6); +} + +function parseAttributes(raw) { + const attrs = []; + const re = /([^\s"'>=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + for (const m of raw.matchAll(re)) { + attrs.push({ name: m[1].toLowerCase(), value: m[2] ?? m[3] ?? m[4] ?? "" }); + } + return attrs; +} + +// Finds each script element and removes the two permitted ones. A body holding +// `\s*)?\s*/i.exec( + rest.trimStart(), + ); + const expected = escapeHtml(contentSecurityPolicy(lf(runtime), styles.length ? styles[0][1] : null)); + if (!csp) { + failures.push("csp-position"); + } else if (csp[1] !== expected) { + failures.push("csp"); + } + + let httpEquiv = 0; + for (const m of rest.matchAll(/<\/?([A-Za-z][A-Za-z0-9]*)\b([^>]*)>/g)) { + const tag = m[1].toLowerCase(); + if (!TAGS.has(tag)) { + failures.push(`tag:${tag}`); + continue; + } + if (m[0].startsWith("/g, ""); + text = text.replace(//gi, ""); + text = text.replace(/<\/?[A-Za-z][A-Za-z0-9]*\b[^>]*>/g, ""); + if (text.includes("<")) { + failures.push("raw-lt"); + } + if (!ESCAPED_TEXT.test(text)) { + failures.push("unescaped"); + } + return { ok: failures.length === 0, failures: [...new Set(failures)] }; +} + +// ------------------------------------------------------------------- CLI + +function main(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 2) { + args[argv[i].replace(/^--/, "")] = argv[i + 1]; + } + if (args.check) { + let html; + try { + html = readFileSync(args.check, "utf8"); + } catch (err) { + console.error(err.message); + return 2; + } + const result = validateView(html); + console.log(result.ok ? "ok" : `FAIL: ${result.failures.join(", ")}`); + return result.ok ? 0 : 1; + } + if (!args.profile || !args.template || !args.data || !args.out) { + console.error( + "usage: view-builder.mjs --profile report|interactive --template --data --out \n" + + " view-builder.mjs --check ", + ); + return 2; + } + try { + const page = buildView({ + profile: args.profile, + template: readFileSync(args.template, "utf8"), + data: JSON.parse(readFileSync(args.data, "utf8")), + }); + writeFileSync(args.out, page); + console.log(`wrote ${args.out}`); + return 0; + } catch (err) { + console.error(err.message); + return err instanceof ViewBuildError ? 1 : 2; + } +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/plugins/architecture/lib/view-runtime.js b/plugins/architecture/lib/view-runtime.js new file mode 100644 index 0000000000..f4a8fbbabc --- /dev/null +++ b/plugins/architecture/lib/view-runtime.js @@ -0,0 +1,201 @@ +// GENERATED from lib/view-runtime.js by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Client runtime for pages view-builder.mjs builds. The builder inlines this +// file into each interactive page and pins it in the page's content security +// policy by SHA-256, so any change here changes every page's hash. +// +// Rules this file keeps (rendered-views README, interactive validator profile): +// - It reads the data block only through JSON.parse of its text. +// - Data reaches the page only through textContent. No data value is ever +// written to an attribute, a URL, a selector, or a form control's value. +// - Every id the runtime assigns is built from a template key and a list +// position, never from data, so a copied payload carries no data text. +// - Payloads hold the reader's own input and those ids, nothing else. +// - No storage, no network. The one URL it makes is a blob: URL for a download. +// - It names no window globals an element id could shadow. +// - The file holds no HTML comment opener and no script tag text in any case, +// so it cannot move the end of the element it is inlined into. +(() => { + "use strict"; + + const KEY = /^[a-z0-9-]{1,32}$/; + const ROW_ID = /^[a-z0-9-]{1,128}$/; + // Data never pre-fills a form control, so a payload holds only what the reader entered, + // and never becomes CSS, metadata, or code. + const UNBOUND = new Set(["input", "textarea", "select", "option", "html", "head", "title", "meta", "style", "script"]); + const rowsByKey = new Map(); + + const readData = () => { + const block = document.getElementById("rv-data"); + if (!block) { + return {}; + } + try { + return JSON.parse(block.textContent); + } catch { + return {}; + } + }; + + const own = (scope, key) => + scope !== null && typeof scope === "object" && KEY.test(key) && Object.hasOwn(scope, key) + ? scope[key] + : undefined; + + const asText = (value) => + ["string", "number", "boolean"].includes(typeof value) ? String(value) : null; + + // root and the elements under it that match selector and whose nearest + // list container is eachRoot. A nested list's rows bind against their own item. + const scoped = (root, selector, eachRoot) => { + const all = [...root.querySelectorAll(selector)]; + if (root.matches(selector)) { + all.unshift(root); + } + return all.filter((el) => (el.parentElement?.closest("[data-rv-each]") ?? null) === eachRoot); + }; + + const bindText = (root, scope, eachRoot) => { + for (const el of scoped(root, "[data-rv-text]", eachRoot)) { + if (UNBOUND.has(el.localName)) { + continue; + } + const text = + scope !== null && typeof scope === "object" + ? asText(own(scope, el.getAttribute("data-rv-text"))) + : asText(scope); + if (text !== null) { + el.textContent = text; + } + } + for (const el of scoped(root, "[data-rv-count]", eachRoot)) { + if (UNBOUND.has(el.localName)) { + continue; + } + const list = own(scope, el.getAttribute("data-rv-count")); + el.textContent = Array.isArray(list) ? String(list.length) : "0"; + } + }; + + const bindLists = (root, scope, eachRoot, rowId) => { + for (const container of scoped(root, "[data-rv-each]", eachRoot)) { + const key = container.getAttribute("data-rv-each"); + const proto = container.firstElementChild; + if (container === root || !proto || !KEY.test(key) || UNBOUND.has(container.localName)) { + continue; + } + container.removeChild(proto); + const items = own(scope, key); + if (!Array.isArray(items)) { + continue; + } + const prefix = rowId ? `${rowId}-${key}` : key; + const rows = rowsByKey.get(key) ?? []; + rowsByKey.set(key, rows); + items.forEach((item, n) => { + const row = proto.cloneNode(true); + const id = `${prefix}-${n + 1}`; + row.id = id; + container.appendChild(row); + bind(row, item, container, id); + for (const pick of scoped(row, "input[data-rv-pick]", container)) { + pick.value = id; + } + rows.push(row); + }); + } + }; + + const bind = (root, scope, eachRoot, rowId) => { + bindLists(root, scope, eachRoot, rowId); + bindText(root, scope, eachRoot); + }; + + const filter = (input) => { + const query = input.value.trim().toLowerCase(); + for (const row of rowsByKey.get(input.getAttribute("data-rv-filter")) ?? []) { + row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query); + } + }; + + const payload = (label) => { + const picked = [...document.querySelectorAll("input[data-rv-pick]")] + .filter((pick) => pick.checked && ROW_ID.test(pick.value)) + .map((pick) => pick.value); + const lines = [label, `picked: ${picked.length ? picked.join(" ") : "none"}`]; + for (const note of document.querySelectorAll("textarea[data-rv-note]")) { + if (note.value.trim() !== "") { + lines.push(`${note.getAttribute("data-rv-note")}: ${note.value.trim()}`); + } + } + return lines.join("\n"); + }; + + const status = (text) => { + for (const el of document.querySelectorAll("[data-rv-status]")) { + el.textContent = text; + } + }; + + const showPayload = (text) => { + for (const el of document.querySelectorAll("[data-rv-out]")) { + el.textContent = text; + el.hidden = false; + } + }; + + const copy = (button) => { + const text = payload(button.getAttribute("data-rv-copy")); + showPayload(text); + try { + navigator.clipboard.writeText(text).then( + () => status("Copied. Paste it back into the session."), + () => status("Copy was refused. Select the text below and copy it."), + ); + } catch { + status("Copy is not available here. Select the text below and copy it."); + } + }; + + const download = (button) => { + const text = payload(button.getAttribute("data-rv-download")); + showPayload(text); + try { + const url = URL.createObjectURL(new Blob([text], { type: "text/plain" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `${button.getAttribute("data-rv-download")}.txt`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + status("Saved the file. Where saving is blocked, copy the text below."); + } catch { + status("Saving is not available here. Select the text below and copy it."); + } + }; + + const start = () => { + bind(document.body, readData(), null, ""); + document.addEventListener("input", (event) => { + if (event.target.matches?.("input[data-rv-filter]")) { + filter(event.target); + } + }); + document.addEventListener("click", (event) => { + const button = event.target.closest?.("button"); + if (button?.hasAttribute("data-rv-copy")) { + copy(button); + } else if (button?.hasAttribute("data-rv-download")) { + download(button); + } + }); + document.documentElement.classList.add("rv-ready"); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})(); diff --git a/plugins/architecture/reference/rendered-view.md b/plugins/architecture/reference/rendered-view.md new file mode 100644 index 0000000000..4b660a3b61 --- /dev/null +++ b/plugins/architecture/reference/rendered-view.md @@ -0,0 +1,58 @@ +# Interactive views of the map records + +How the `map-*` skills offer an interactive view of their record. The markdown file and the JSON record +stay authoritative. A view never sits beside them, and nothing reads a view back as a finding. + +A record holds repository text: manifest paths, connection hosts, type names, file citations. That text is +attacker-controllable, so a view is built from a checked-in template plus the record as escaped data, and +never from markup or script written in the session. + +## Procedure + +1. **Write the record and its markdown first.** The view is built from the record the skill just wrote. +2. **Offer the view in one sentence.** Build only when the reader accepts and the environment can serve a + file. A CI or other non-interactive run builds nothing: the record stands. +3. **Resolve `medium`.** Read the `rendered-views` cascade surface: `~/.claude/rendered-views.md`, then + `/.claude/rendered-views.md`, then `/.claude/rendered-views.local.md`, whichever exist. + The last layer that states `medium:` wins (`auto`, `terminal`, `file`, `artifact`). A team layer that is not + tracked is a hard stop; an overlay that is staged or not gitignored is reported, not honored; a malformed + layer is reported and treated as absent. Name the layer that decided. Absent or `auto` means `file`. +4. **Build.** Pass the record file to the builder. It prints the path of the page it wrote under the OS + temp directory: + + ```bash + node "${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs" dependencies --record "/dependency-graph.json" + ``` + + The first argument is the skill's kind, and the record is the file in the table below. Never write markup + or script for the page, and never hand-edit the output. When `node` is missing, deliver the markdown and say + the view was not built. A non-zero exit means the record is not a schema_version 1 map record or the page + failed its checks: report the builder's message and deliver the markdown. +5. **Deliver by `medium`.** + - `file`: hand back the printed path. + - `artifact`: publish that file with the Artifact tool (private by default) and give the link. When the + Artifact tool is unavailable, hand back the path and say why in one line. + - `terminal`: build nothing, name the layer that chose it, and stop. + +| Skill | Kind | Record | +|---|---|---| +| `map-landscape` | `landscape` | `landscape.json` | +| `map-containers` | `containers` | `containers.json` | +| `map-components` | `components` | `dependency-graph.json`, with `--from ` | +| `map-dependencies` | `dependencies` | `dependency-graph.json` | +| `map-data` | `data` | `data-model.json` | +| `map-events` | `events` | `events.json` | +| `map-flow` | `flow` | `flow.json` | +| `map-context` | `context` | `context.json` | +| `map-deployment` | `deployment` | `deployment.json` | + +`--from ` keeps the project nodes reachable from that deployable over resolved project edges, and the edges between them, which is the +closure a component view charts, and drops the record's other arrays. + +## What the page shows + +The page lists the record's own rows, in record order. Each array in the record is counted in a header fact and +each item is a row labeled with its array (`nodes`, `edges`, `findings`). An edge row is named `from -> to`; any +other row takes the first of `id`, `name`, `message`, `title`, `path`, `entry`, `resource`. Open a row for every +field it holds, evidence citations included. The filter matches a row's whole text, so typing a node id lists the +node and every edge, finding and cycle that names it. The page draws no diagram: the markdown holds that. diff --git a/plugins/architecture/scripts/build-view.mjs b/plugins/architecture/scripts/build-view.mjs new file mode 100755 index 0000000000..8428917a64 --- /dev/null +++ b/plugins/architecture/scripts/build-view.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +// Build an interactive view of one map-* record: the checked-in template plus +// the record's own rows as escaped JSON data, through the shared view builder +// (interactive profile). Writes the page to one deterministic path under the OS +// temp directory, never beside the record, and prints that path. +// +// node build-view.mjs --record [--from ] +// +// kind: landscape containers components dependencies data events flow context deployment. +// --from keeps the nodes reachable from that node id and the edges between +// them (the closure a component view charts), and drops the record's other +// arrays; it needs a nodes and an edges array. +// +// Every value in the record reaches the page as data, never as markup or script. +// +// Exit 0 built, 1 the record or the page fails its checks, 2 usage or environment. + +import { chmodSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildView, ViewBuildError } from "../lib/view-builder.mjs"; + +const TITLES = { + landscape: "Landscape", + containers: "Containers", + components: "Components", + dependencies: "Dependencies", + data: "Data model", + events: "Events", + flow: "Flow", + context: "Context", + deployment: "Deployment", +}; +const NAME_KEYS = ["id", "name", "message", "title", "path", "entry", "resource"]; + +const isText = (value) => typeof value === "string"; +const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const show = (value) => (isText(value) ? value : JSON.stringify(value)); + +class RecordError extends Error {} + +const rowOf = (kind, item) => { + if (!isObject(item)) return { kind, name: show(item), fields: [] }; + const key = NAME_KEYS.find((candidate) => isText(item[candidate]) && item[candidate] !== ""); + const name = isText(item.from) && isText(item.to) ? `${item.from} -> ${item.to}` : key ? item[key] : kind; + return { kind, name, fields: Object.entries(item).map(([field, value]) => `${field}: ${show(value)}`) }; +}; + +// The project nodes reachable from `start` over resolved project edges, `start` included. +// Package edges and unresolved edges are not reachability, as in render-components.sh. +const closureFrom = (record, start) => { + const { nodes, edges } = record; + if (!Array.isArray(nodes) || !Array.isArray(edges)) throw new RecordError("--from needs a record with nodes and edges"); + if (!nodes.some((node) => isObject(node) && node.id === start)) throw new RecordError(`no node ${start} in the record`); + const projects = new Set(nodes.filter((node) => isObject(node) && node.kind === "project").map((node) => node.id)); + const seen = new Set([start]); + for (let grew = true; grew; ) { + grew = false; + for (const edge of edges) { + if ( + isObject(edge) && + edge.kind === "project" && + edge.status !== "unresolved" && + seen.has(edge.from) && + projects.has(edge.to) && + !seen.has(edge.to) + ) { + seen.add(edge.to); + grew = true; + } + } + } + // Findings and cycles name nodes outside the closure, so only the scalars, nodes and edges stay. + const scalars = Object.entries(record).filter(([, value]) => value === null || typeof value !== "object"); + return { + ...Object.fromEntries(scalars), + nodes: nodes.filter((node) => isObject(node) && seen.has(node.id)), + edges: edges.filter((edge) => isObject(edge) && seen.has(edge.from) && seen.has(edge.to)), + }; +}; + +// Scalars become facts; each array is counted and its items become rows; an object becomes one row. +const viewData = (kind, record, from) => { + const source = from ? closureFrom(record, from) : record; + const facts = from ? [`scope: reachable from ${from}`] : []; + const rows = []; + for (const [key, value] of Object.entries(source)) { + if (Array.isArray(value)) { + facts.push(`${key}: ${value.length}`); + rows.push(...value.map((item) => rowOf(key, item))); + } else if (isObject(value)) { + rows.push(rowOf(key, { name: key, ...value })); + } else if (value !== "" && value !== null) { + facts.push(`${key}: ${value}`); + } + } + return { title: `${TITLES[kind]} view`, facts, rows }; +}; + +const [kind, ...rest] = process.argv.slice(2); +const flags = {}; +for (let i = 0; i < rest.length; i += 2) flags[rest[i]] = rest[i + 1]; +if (!Object.hasOwn(TITLES, kind) || !flags["--record"] || Object.keys(flags).some((flag) => !["--record", "--from"].includes(flag))) { + console.error(`usage: build-view.mjs ${Object.keys(TITLES).join("|")} --record [--from ]`); + process.exit(2); +} + +try { + let record; + try { + record = JSON.parse(readFileSync(flags["--record"], "utf8")); + } catch (err) { + throw err instanceof SyntaxError ? new RecordError("the record is not JSON") : err; + } + if (!isObject(record) || record.schema_version !== 1) throw new RecordError("the record is not a schema_version 1 map record"); + const page = buildView({ + profile: "interactive", + template: readFileSync(fileURLToPath(new URL("../templates/map-view.html", import.meta.url)), "utf8"), + data: viewData(kind, record, flags["--from"]), + }); + // The temp root is shared: keep the directory private and never write through a link. + const dir = join(tmpdir(), "architecture-views"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (!lstatSync(dir).isDirectory()) + throw new Error("build-view: refusing a symlinked or non-directory output path"); + chmodSync(dir, 0o700); + const out = join(dir, `${kind}.html`); + rmSync(out, { force: true }); + writeFileSync(out, page, { mode: 0o600 }); + console.log(out); +} catch (err) { + console.error(err instanceof RecordError ? `build-view: ${err.message}` : err.message); + process.exit(err instanceof RecordError || err instanceof ViewBuildError ? 1 : 2); +} diff --git a/plugins/architecture/scripts/build-view.test.sh b/plugins/architecture/scripts/build-view.test.sh new file mode 100755 index 0000000000..ce163e0826 --- /dev/null +++ b/plugins/architecture/scripts/build-view.test.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Behavioral tests for scripts/build-view.mjs: every map kind builds through the +# shared view builder, passes the interactive profile, keeps hostile record text +# as data, and narrows to a closure under --from. When a Chrome or Chromium +# binary is found (CHROME, google-chrome, chromium, or Playwright's headless +# shell) the pages are also opened from file:// to prove the runtime renders them. +# +# bash plugins/architecture/scripts/build-view.test.sh +# +# Exit 0 clean, 1 findings, 2 environment (node missing). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 2 + +if ! command -v node >/dev/null 2>&1; then + echo "build-view: node not found on PATH" >&2 + exit 2 +fi + +chrome="${CHROME:-}" +if [[ -z "$chrome" ]]; then + for candidate in google-chrome google-chrome-stable chromium chromium-browser; do + if command -v "$candidate" >/dev/null 2>&1; then + chrome="$(command -v "$candidate")" + break + fi + done +fi +if [[ -z "$chrome" ]]; then + for candidate in "$HOME"/.cache/ms-playwright/chromium_headless_shell-*/chrome-*/chrome-headless-shell; do + [[ -x "$candidate" ]] && chrome="$candidate" && break + done +fi + +work="$(mktemp -d)" || exit 2 +trap 'rm -rf "$work"' EXIT + +node --input-type=module - "$SCRIPT_DIR" "$work" "$chrome" <<'NODE' +import { copyFileSync, lstatSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const [dir, work, chrome] = process.argv.slice(2); +const env = { ...process.env, TMPDIR: work, TEMP: work, TMP: work }; + +let failed = 0; +const check = (name, cond, detail) => { + if (cond) { + console.log(`ok: ${name}`); + } else { + console.error(`FAIL: ${name}${detail ? ` - ${detail}` : ""}`); + failed += 1; + } +}; + +const hostile = "\"'`${1}"; +const record = { + schema_version: 1, + generated_on: "2026-10-03", + result: "ok", + message: "", + nodes: [ + { id: "A/A.csproj", name: "A", kind: "project" }, + { id: "B/B.csproj", name: "B", kind: "project" }, + { id: "C/C.csproj", name: "C", kind: "project" }, + ], + edges: [ + { from: "A/A.csproj", to: "B/B.csproj", kind: "project", evidence: "A/A.csproj: " }, + { from: "B/B.csproj", to: "C/C.csproj", kind: "project", evidence: "B/B.csproj: " }, + ], + cycles: [["B/B.csproj", "C/C.csproj"]], + findings: [], + actor: { name: "operator", role: "person" }, +}; +const hostileRecord = { + schema_version: 1, + result: hostile, + nodes: [{ id: hostile, name: hostile }], + edges: [{ from: hostile, to: hostile, evidence: hostile }], +}; +const kinds = ["landscape", "containers", "components", "dependencies", "data", "events", "flow", "context", "deployment"]; + +const write = (name, data) => { + const path = `${work}/${name}.json`; + writeFileSync(path, typeof data === "string" ? data : JSON.stringify(data)); + return path; +}; +const build = (kind, path, ...extra) => spawnSync("node", [`${dir}/build-view.mjs`, kind, "--record", path, ...extra], { encoding: "utf8", env }); +const verify = (path) => spawnSync("node", [`${dir}/../lib/view-builder.mjs`, "--check", path], { encoding: "utf8" }); +const dump = (path) => + spawnSync(chrome, ["--headless", "--no-sandbox", "--disable-gpu", "--dump-dom", pathToFileURL(path).href], { encoding: "utf8", timeout: 60000 }).stdout ?? ""; +const dataOf = (path) => JSON.parse(/ { + const target = `${work}/symlink-target.html`; + writeFileSync(target, "untouched"); + const link = `${outDir}/flow.html`; + rmSync(link, { force: true }); + symlinkSync(target, link); + const built = build("flow", good); + return built.status === 0 && readFileSync(target, "utf8") === "untouched" && !lstatSync(link).isSymbolicLink(); +})()); + +process.exit(failed ? 1 : 0); +NODE diff --git a/plugins/architecture/skills/map-components/SKILL.md b/plugins/architecture/skills/map-components/SKILL.md index d08ba612ab..d0925620ac 100644 --- a/plugins/architecture/skills/map-components/SKILL.md +++ b/plugins/architecture/skills/map-components/SKILL.md @@ -200,6 +200,14 @@ End every run with this block, in this order: - **Dialect**: `diagram_dialect.system`, its value, and the layer: `argument`, `team convention doc `, or `unset (no C4 view emitted)`. +## Interactive view + +After the report, offer an interactive view of the chosen deployable's closure in one sentence. The markdown +and `dependency-graph.json` stay authoritative. Build it only with +`${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs components --record --from `, never hand-written; the publish destination comes from the `medium` cascade key. +Procedure: [`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Class-level or code-rung diagrams, behavior, or deployment. Those are other diff --git a/plugins/architecture/skills/map-components/evals/evals.json b/plugins/architecture/skills/map-components/evals/evals.json index be7b2abdd1..4ca524aee5 100644 --- a/plugins/architecture/skills/map-components/evals/evals.json +++ b/plugins/architecture/skills/map-components/evals/evals.json @@ -69,6 +69,18 @@ "The artifact says the ecosystem is unknown and does not draw an empty diagram", "Writes the collected record to the architecture directory so the next run can read it" ] + }, + { + "id": 6, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-components\n\ncomponents.md is written for the deployable src/Api/Api.csproj. I would like to filter and trace its components rather than read the tables.", + "expected_output": "After the report, offers an interactive view of the deployable's closure in one sentence, builds it only with scripts/build-view.mjs components from dependency-graph.json with --from naming the deployable, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "components.md and dependency-graph.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs components --record dependency-graph.json --from src/Api/Api.csproj and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-containers/SKILL.md b/plugins/architecture/skills/map-containers/SKILL.md index eea3d4d36a..b7f95eefb6 100644 --- a/plugins/architecture/skills/map-containers/SKILL.md +++ b/plugins/architecture/skills/map-containers/SKILL.md @@ -206,6 +206,13 @@ End every run with this block, in this order, filled from the record and the scr - **Redaction**: the record keeps host, service kind, and a sql database name. It does not keep the raw value. +## Interactive view + +After the report, offer an interactive view of `containers.json` in one sentence. The markdown and the record +stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs containers`, never +hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Draw environment topology, replicas, or scaling. That is `/architecture:map-deployment`. diff --git a/plugins/architecture/skills/map-containers/evals/evals.json b/plugins/architecture/skills/map-containers/evals/evals.json index b81d5ede60..73c5f7c677 100644 --- a/plugins/architecture/skills/map-containers/evals/evals.json +++ b/plugins/architecture/skills/map-containers/evals/evals.json @@ -58,6 +58,18 @@ "Keeps the host and drops the admin key from the record, the diagram, and stdout", "Lists search among the store kinds read in the report" ] + }, + { + "id": 6, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-containers\n\ncontainers.json and containers.md are written. I would like to filter and trace the containers rather than read the tables.", + "expected_output": "After the report, offers an interactive view of containers.json in one sentence, builds it only with scripts/build-view.mjs containers from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "containers.md and containers.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs containers --record containers.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-context/SKILL.md b/plugins/architecture/skills/map-context/SKILL.md index a87a865b57..c24b496d99 100644 --- a/plugins/architecture/skills/map-context/SKILL.md +++ b/plugins/architecture/skills/map-context/SKILL.md @@ -167,6 +167,13 @@ End every run with this block, in this order, filled from the record and the scr - **Redaction**: the record stores host, kind, port, file, and key. No credential was copied into the report. +## Interactive view + +After the report, offer an interactive view of `context.json` in one sentence. The markdown and the record +stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs context`, never +hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Invent an actor, or treat CODEOWNERS, commit authors, or prose as people. diff --git a/plugins/architecture/skills/map-context/evals/evals.json b/plugins/architecture/skills/map-context/evals/evals.json index a87c575a8f..02619c2f0f 100644 --- a/plugins/architecture/skills/map-context/evals/evals.json +++ b/plugins/architecture/skills/map-context/evals/evals.json @@ -120,6 +120,18 @@ "The untracked host is not a node", "Must not echo the raw connection value while explaining the redaction" ] + }, + { + "id": 9, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-context\n\ncontext.json and context.md are written. I would like to filter and trace the external systems rather than read the tables.", + "expected_output": "After the report, offers an interactive view of context.json in one sentence, builds it only with scripts/build-view.mjs context from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "context.md and context.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs context --record context.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-data/SKILL.md b/plugins/architecture/skills/map-data/SKILL.md index bcecc65ddc..4f6356e948 100644 --- a/plugins/architecture/skills/map-data/SKILL.md +++ b/plugins/architecture/skills/map-data/SKILL.md @@ -159,6 +159,13 @@ End every run with this block, in this order: - **Mismatches**: the count. A mismatch is reported, not silently resolved. - **Live**: not requested, or requested and refused. No connection was opened. +## Interactive view + +After the report, offer an interactive view of `data-model.json` in one sentence. The markdown and the record +stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs data`, never +hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Open a database connection, read production data, or compare live rows to the declaration. diff --git a/plugins/architecture/skills/map-data/evals/evals.json b/plugins/architecture/skills/map-data/evals/evals.json index 7125c92803..388a528059 100644 --- a/plugins/architecture/skills/map-data/evals/evals.json +++ b/plugins/architecture/skills/map-data/evals/evals.json @@ -93,6 +93,18 @@ "The EF file is reported as not-compared, not as ef-fluent-unreadable", "The mismatch list makes no missing-in-other claim about the EF file" ] + }, + { + "id": 8, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-data\n\ndata-model.json and data-model.md are written. I would like to filter and trace the entities rather than read the diagram.", + "expected_output": "After the report, offers an interactive view of data-model.json in one sentence, builds it only with scripts/build-view.mjs data from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "data-model.md and data-model.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs data --record data-model.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-dependencies/SKILL.md b/plugins/architecture/skills/map-dependencies/SKILL.md index 22eb2aeea0..cdb9453854 100644 --- a/plugins/architecture/skills/map-dependencies/SKILL.md +++ b/plugins/architecture/skills/map-dependencies/SKILL.md @@ -210,6 +210,13 @@ says it aggregated to directory level. - **Diagram**: mermaid flowchart, or no diagram because the result is unknown or a filter left nothing to draw. +## Interactive view + +After the report, offer an interactive view of `dependency-graph.json` in one sentence. The markdown and the +record stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs dependencies`, +never hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Import graphs, call graphs, or runtime discovery. diff --git a/plugins/architecture/skills/map-dependencies/evals/evals.json b/plugins/architecture/skills/map-dependencies/evals/evals.json index 2f0cc5d747..0788570d3e 100644 --- a/plugins/architecture/skills/map-dependencies/evals/evals.json +++ b/plugins/architecture/skills/map-dependencies/evals/evals.json @@ -68,6 +68,18 @@ "No node or edge is derived from the Gemfile", "Does not run the script once per ecosystem or merge records by hand" ] + }, + { + "id": 6, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-dependencies\n\ndependency-graph.json and dependency-graph.md are written. I would like to filter and trace the projects rather than read the flowchart.", + "expected_output": "After the report, offers an interactive view of dependency-graph.json in one sentence, builds it only with scripts/build-view.mjs dependencies from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "dependency-graph.md and dependency-graph.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs dependencies --record dependency-graph.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-deployment/SKILL.md b/plugins/architecture/skills/map-deployment/SKILL.md index 403fc87434..ece1fc5d14 100644 --- a/plugins/architecture/skills/map-deployment/SKILL.md +++ b/plugins/architecture/skills/map-deployment/SKILL.md @@ -265,6 +265,13 @@ End every run with this block, in this order: - **Secrets**: redacted. No secret value was written. - **Live**: not requested, or requested and refused. No cloud API was called. +## Interactive view + +After the report, offer an interactive view of `deployment.json` in one sentence. The markdown and the record +stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs deployment`, never +hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Call a cloud API, use credentials, or compare live state to the declaration. diff --git a/plugins/architecture/skills/map-deployment/evals/evals.json b/plugins/architecture/skills/map-deployment/evals/evals.json index ec6055b0d2..2dabb73f4e 100644 --- a/plugins/architecture/skills/map-deployment/evals/evals.json +++ b/plugins/architecture/skills/map-deployment/evals/evals.json @@ -187,6 +187,18 @@ "The artifact states partial-read", "Does not run terraform or helm" ] + }, + { + "id": 15, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-deployment\n\ndeployment.json and deployment.md are written. I would like to filter and trace the placements rather than read the tables.", + "expected_output": "After the report, offers an interactive view of deployment.json in one sentence, builds it only with scripts/build-view.mjs deployment from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "deployment.md and deployment.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs deployment --record deployment.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-events/SKILL.md b/plugins/architecture/skills/map-events/SKILL.md index 65d6bbddb6..af894fdb42 100644 --- a/plugins/architecture/skills/map-events/SKILL.md +++ b/plugins/architecture/skills/map-events/SKILL.md @@ -100,6 +100,13 @@ Exit 1 means the record is unreadable or not one object per line. Nothing was wr - **Dialect**: mermaid flowchart. `landscape_dialect` was not read. No key was added. - **Handoff**: that cross-process edges are in the Handoff section, keyed by `file:line` to match a map-flow hop cite. +## Interactive view + +After the report, offer an interactive view of `events.json` in one sentence. The markdown and the record +stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs events`, never +hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - In-process synchronous calls. Those are `/architecture:map-flow`. diff --git a/plugins/architecture/skills/map-events/evals/evals.json b/plugins/architecture/skills/map-events/evals/evals.json index b2f498b6a4..cc457516a2 100644 --- a/plugins/architecture/skills/map-events/evals/evals.json +++ b/plugins/architecture/skills/map-events/evals/evals.json @@ -54,6 +54,18 @@ "Raises a competing-consumer finding only for the same message and queue", "Gives no queue to a publish edge" ] + }, + { + "id": 6, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-events\n\nevents.json and events.md are written. I would like to filter and trace the messages rather than read the flowchart.", + "expected_output": "After the report, offers an interactive view of events.json in one sentence, builds it only with scripts/build-view.mjs events from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "events.md and events.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs events --record events.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-flow/SKILL.md b/plugins/architecture/skills/map-flow/SKILL.md index 06e5cc82fc..309e53963c 100644 --- a/plugins/architecture/skills/map-flow/SKILL.md +++ b/plugins/architecture/skills/map-flow/SKILL.md @@ -173,6 +173,13 @@ End every run with this block, in this order, filled from the record and the scr tree, or on a receiver of unknown type), `di=` (interface and service-locator hops), and the remainder, and that none were bound to a guessed implementation. +## Interactive view + +After the report, offer an interactive view of `flow.json` in one sentence. The markdown and the record stay +authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs flow`, never hand-written; the +publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Bind an interface, a service locator, or reflection to an implementation, or bind any call by diff --git a/plugins/architecture/skills/map-flow/evals/evals.json b/plugins/architecture/skills/map-flow/evals/evals.json index 3ff59a726d..d5f2a71215 100644 --- a/plugins/architecture/skills/map-flow/evals/evals.json +++ b/plugins/architecture/skills/map-flow/evals/evals.json @@ -146,6 +146,18 @@ "Reports the refusal instead of picking one handler", "Does not treat the class-level [Route] as part of the route" ] + }, + { + "id": 11, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-flow GET /orders/{id}\n\nflow.json and flow.md are written. I would like to open each hop and its citation rather than read the sequence diagram.", + "expected_output": "After the report, offers an interactive view of flow.json in one sentence, builds it only with scripts/build-view.mjs flow from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "flow.md and flow.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs flow --record flow.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/skills/map-landscape/SKILL.md b/plugins/architecture/skills/map-landscape/SKILL.md index af52642704..5e31e6cf84 100644 --- a/plugins/architecture/skills/map-landscape/SKILL.md +++ b/plugins/architecture/skills/map-landscape/SKILL.md @@ -199,6 +199,13 @@ End every run with this block, in this order, filled from the record and the scr the missing backend named. - **Drift**: none, the drift summary, or no committed record to compare against. +## Interactive view + +After the report, offer an interactive view of `landscape.json` in one sentence. The markdown and the record +stay authoritative. Build it only with `${CLAUDE_PLUGIN_ROOT}/scripts/build-view.mjs landscape`, never +hand-written; the publish destination comes from the `medium` cascade key. Procedure: +[`${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md`](${CLAUDE_PLUGIN_ROOT}/reference/rendered-view.md). + ## What this skill does NOT do - Baseline-versus-target gap analysis, capability maps, or work-breakdown structures. diff --git a/plugins/architecture/skills/map-landscape/evals/evals.json b/plugins/architecture/skills/map-landscape/evals/evals.json index cc8a3ef05e..76b42eb057 100644 --- a/plugins/architecture/skills/map-landscape/evals/evals.json +++ b/plugins/architecture/skills/map-landscape/evals/evals.json @@ -199,6 +199,18 @@ "Does NOT offer `--remote` as a remedy: it fills facts for existing nodes and adds no system and no edge", "Does not invent an edge or a neighboring system to make the diagram look fuller" ] + }, + { + "id": 14, + "name": "offers-built-interactive-view", + "prompt": "/architecture:map-landscape\n\nlandscape.json and the landscape artifact are written. I would like to filter and trace the systems rather than read the tables.", + "expected_output": "After the report, offers an interactive view of landscape.json in one sentence, builds it only with scripts/build-view.mjs landscape from the record, and resolves the publish destination from the medium cascade key.", + "expectations": [ + "The markdown artifact and landscape.json are written first and stay the record", + "Offers the view in one sentence and builds only after the reader accepts", + "Builds with scripts/build-view.mjs landscape --record landscape.json and writes no HTML or script itself", + "The publish destination comes from the medium key of the rendered-views cascade, with file as the default when unset" + ] } ] } diff --git a/plugins/architecture/templates/map-view.html b/plugins/architecture/templates/map-view.html new file mode 100644 index 0000000000..ec3cb6d1a9 --- /dev/null +++ b/plugins/architecture/templates/map-view.html @@ -0,0 +1,83 @@ + + + + + +Architecture Map View + + + +
+
+

Interactive view. The markdown and the JSON record are authoritative.

+

Architecture map

+
+

0 rows. Open a row for its fields. Type an id or a name in the filter to trace every row that names it.

+
+
+ + +
+
    +
  1. +
    + +
    +
    +
  2. +
+
+ + diff --git a/scripts/cross-plugin-source-registry.txt b/scripts/cross-plugin-source-registry.txt index 44b53585f7..ace2db5c30 100644 --- a/scripts/cross-plugin-source-registry.txt +++ b/scripts/cross-plugin-source-registry.txt @@ -274,7 +274,7 @@ lib/exec-bash.mjs -> plugins/*/hooks/exec-bash.mjs lib/plugin_cache_versions.py # Dedicated check: scripts/sync-shared-copies.sh --check. Canonical: -# lib/view-builder.mjs. The review, planning, debugging and discovery plugins carry the generated +# lib/view-builder.mjs. The review, planning, debugging, discovery and architecture plugins carry the generated # copies, which stay byte-identical to each other. lib/view-builder.mjs @@ -283,7 +283,7 @@ lib/view-builder.mjs lib/view-builder.mjs -> plugins/*/lib/view-builder.mjs # Dedicated check: scripts/sync-shared-copies.sh --check. Canonical: -# lib/view-runtime.js. The review, planning, debugging and discovery plugins carry the generated +# lib/view-runtime.js. The review, planning, debugging, discovery and architecture plugins carry the generated # copies, which stay byte-identical to each other. lib/view-runtime.js diff --git a/scripts/shared-copies.txt b/scripts/shared-copies.txt index bae0b5db66..4d20eb8cf8 100644 --- a/scripts/shared-copies.txt +++ b/scripts/shared-copies.txt @@ -2,6 +2,7 @@ # One copy per line: ` `, both repo-relative. The canonical is # the only file to edit; each copy sits inside its plugin because a plugin cache # cannot see the repository root. Register a copy here, then run the script. +lib/html-escape.mjs plugins/architecture/lib/html-escape.mjs lib/html-escape.mjs plugins/debugging/lib/html-escape.mjs lib/html-escape.mjs plugins/discovery/lib/html-escape.mjs lib/html-escape.mjs plugins/education/lib/html-escape.mjs @@ -129,6 +130,8 @@ lib/view-builder.mjs plugins/review/lib/view-builder.mjs lib/view-runtime.js plugins/review/lib/view-runtime.js lib/view-builder.mjs plugins/planning/lib/view-builder.mjs lib/view-runtime.js plugins/planning/lib/view-runtime.js +lib/view-builder.mjs plugins/architecture/lib/view-builder.mjs +lib/view-runtime.js plugins/architecture/lib/view-runtime.js lib/view-builder.mjs plugins/debugging/lib/view-builder.mjs lib/view-runtime.js plugins/debugging/lib/view-runtime.js lib/view-builder.mjs plugins/discovery/lib/view-builder.mjs