Skip to content
Merged
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
6 changes: 6 additions & 0 deletions docs/conventions/rendered-views/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).**
Expand Down
4 changes: 3 additions & 1 deletion docs/conventions/rendered-views/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion plugins/architecture/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
15 changes: 15 additions & 0 deletions plugins/architecture/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions plugins/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
214 changes: 214 additions & 0 deletions plugins/architecture/lib/html-escape.mjs
Original file line number Diff line number Diff line change
@@ -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 = "<!-- rv-gen:escape-helper-v1 sha256:";
const MARKER_SUFFIX = " -->";

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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#x27;");
}

/**
* @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 `<head>`. 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 = "<head>";
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: `</style` followed by
// whitespace, `/` or `>`, or end of input for an unclosed element.
const styleRe = /<style\b[^>]*>([\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(/<!--[\s\S]*?-->/g, "");
rest = rest.replace(/<!doctype html>/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 = /<!-- rv-gen:escape-helper-v1 sha256:([0-9a-f]{64}) -->/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 };
}
Loading
Loading