Interactive view. The markdown and the JSON record are authoritative.
+Architecture map
+-
+
-
+++
+
+
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(""); + if (!ALLOWED_TAGS.has(name)) { + failures.push(`tag:${name}`); + } + if (!closing) { + for (const attr of parseAttributes(match[2])) { + if (!ALLOWED_ATTRS.has(attr.name)) { + failures.push(`attr:${attr.name}`); + } + if (!ESCAPED_TEXT.test(attr.value)) { + failures.push("unescaped"); + } + } + } + match = tagRe.exec(html); + } + + // Style text is raw CSS, which can fetch a resource with no HTML-significant + // character. A backslash escape can spell any of these, so it is refused too. + // The capture ends where a browser ends style text: ``, or end of input for an unclosed element. + const styleRe = / + + +Interactive view. The markdown and the JSON record are authoritative.
+