From 137102079e12d4e5fe52cbaf0c96396874a46b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 17:29:33 +0000 Subject: [PATCH 01/15] docs(architecture): chart this repository as a single-system landscape Run /architecture:map-landscape in explicit --repos mode against this checkout and land the two artifacts under docs/architecture, the home the skill's own inference proposes when nothing is declared. Both files carry the generated-on line, the discovery source, and per-fact evidence from portfolio-facts.sh; no relationship line is drawn because a single repository has nothing to name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape.md | 15 +++++++++++++++ docs/architecture/portfolio.md | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 docs/architecture/landscape.md create mode 100644 docs/architecture/portfolio.md diff --git a/docs/architecture/landscape.md b/docs/architecture/landscape.md new file mode 100644 index 0000000000..6e2260d7b4 --- /dev/null +++ b/docs/architecture/landscape.md @@ -0,0 +1,15 @@ +# System Landscape + +Generated on 2026-09-08 from an explicit repository list (`--repos`). Every fact +below traces to the file named in `portfolio.md`; nothing was fetched, and the +date reflects the local HEAD of each checkout. + +```mermaid +C4Context + title System Landscape + System(claude_code_plugins, "claude-code-plugins", "node, >=24") +``` + +One repository, one owner (`melodic-software`), so no enterprise boundary is +drawn and no relationship line exists: an edge needs a fact in one repository +that names another, and there is no second repository here to name. diff --git a/docs/architecture/portfolio.md b/docs/architecture/portfolio.md new file mode 100644 index 0000000000..40fb159d13 --- /dev/null +++ b/docs/architecture/portfolio.md @@ -0,0 +1,20 @@ +# Application portfolio + +Generated on 2026-09-08 from an explicit repository list (`--repos`). Last +touched is the local HEAD of each checkout; nothing was fetched, so a stale +checkout reports a stale date. Every value comes from the file the +`portfolio-facts.sh` probe named; `unknown` means no probe could derive it. + +| Repository | Owner | Target framework | Runtime | Dependencies | Last touched (local HEAD) | +|---|---|---|---|---|---| +| claude-code-plugins | melodic-software | >=24 | node,python | iniconfig, packaging, pluggy, pygments, pytest, pyyaml, ruff, tree-sitter, tree-sitter-bash, tree-sitter-c-sharp (+6) | 2026-09-08T12:45:20-04:00 | + +## Evidence + +| Fact | Source | +|---|---| +| Owner | origin remote URL (no CODEOWNERS default rule) | +| Runtime | node: `package.json`; python: `.github/requirements-ci.txt` | +| Target framework | `package.json` (`engines.node`) | +| Dependencies | `package.json` (dependencies + peerDependencies), `.github/requirements-ci.txt` | +| Last touched | `git log -1 --format=%cI` (local HEAD) | From 8eb390bc22c3f73b7f74bca768c867a50434f250 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:07:20 +0000 Subject: [PATCH 02/15] docs(architecture): lock the map-landscape redesign brief Persist the interview contract for reworking the map-landscape skill: a bare run charts the current repository plus its one-hop reference graph, edges are typed and script-extracted, docs/architecture/landscape.json is the committed record with drift reporting, the facts script gains a runtime-versus-development scope axis, and --remote facts are opt-in. Twelve interview questions answered, none deferred; sequencing is one tracking issue with four children. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/topics/map-landscape-redesign/PLAN.md | 90 ++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 docs/topics/map-landscape-redesign/PLAN.md diff --git a/docs/topics/map-landscape-redesign/PLAN.md b/docs/topics/map-landscape-redesign/PLAN.md new file mode 100644 index 0000000000..bf7573c2e4 --- /dev/null +++ b/docs/topics/map-landscape-redesign/PLAN.md @@ -0,0 +1,90 @@ +# map-landscape-redesign + +## Brief + +### TLDR + +- A bare `/architecture:map-landscape` charts the current repository plus its reference graph, one + hop out; `--repos` and `--root` stay as explicit overrides. +- References become typed, counted edges extracted per source type by a tested script; other-owner + repositories render as external systems, kept read-only. +- The committed record is `docs/architecture/landscape.json`; both rendered artifacts derive from it + through a reasoning-free render script, and every re-run reports drift, with `--check` for CI. +- The facts script gains a runtime-versus-development scope axis, prunes `.github/` and other + dot-directories from runtime probes, and adds a Tooling column. +- `--remote` facts are opt-in and presence-gated; a fixed closing report and a rewritten description + make single-repository-plus-references the primary use. + +### Goal + +Rework the `map-landscape` skill so that one run in one repository yields a useful, committed, +re-runnable landscape of that repository and everything it references. Today the skill only reads +local checkouts and stops on a bare invocation, so on a cloud checkout of this repository it drew one +node with no edges even though the tracked files name nineteen other organisation repositories. + +### Constraints + +- Facts and edges come from tested scripts; the model adds prose annotations only. Rendering scripts + do only reasoning-free work (tables, alias sanitising, boundary grouping, labels). +- The declared-home doctrine in `plugins/architecture/reference/config.md` stays: an interactive run + with no home proposes `docs/architecture`, `--out ` overrides one run, and a non-interactive run + with neither still stops. +- No network call is made unless `--remote` is passed. Externals are never fetched from unless + `--remote=all`, and nothing ever writes to another repository. +- Scripts stay bash plus POSIX awk/grep/sed, matching `portfolio-facts.sh`; no jq, no python. +- The working directory is never walked for nested repositories. +- Skill bodies follow `.claude/rules/skill-bodies-state-current-rules.md`; validation runs through + `scripts/affected-tests.sh --run`. + +### Acceptance criteria + +- A bare run in this checkout produces a landscape with `ci-workflows` and `standards` as nodes and + typed edges to them, without any argument. +- Every edge cites its source file and type; every fact cites its evidence. +- IF no home is declared and the run is non-interactive, THEN nothing is written. +- IF `--remote` is absent, THEN no network call is made. +- WHILE a committed `docs/architecture/landscape.json` exists, a re-run reports drift instead of + silently overwriting, and `--check` exits non-zero on drift. +- This checkout no longer reports Python as a runtime; its CI pins appear under Tooling. +- Rendering a fixed facts-plus-edges fixture twice yields byte-identical artifacts. +- Existing evals are updated, and the `no-scope-names-both-forms` eval is replaced by one asserting + the bare default. + +### Captured assumptions + +- The GitHub MCP or an authenticated `gh` is available when `--remote` is used; otherwise the flag + reports unavailability and continues local-only. Revisit if a consumer needs remote facts with + neither. +- Mermaid stays the default dialect. Revisit if Structurizr becomes the organisation default. +- Same-owner is decided from the current repository's `origin` owner segment. Revisit if a consumer + has repositories split across owners it considers one enterprise. +- A separate `ci_tooling` bucket was rejected in favour of a scope axis because every surveyed + standard (CycloneDX scope, SPDX dependency relationships, npm devDependencies, PEP 735 dependency + groups, GitHub dependency-graph scope) separates runtime from development on scope, and GitHub + Linguist vendors `.github/` out of language statistics. Revisit if a manifest family appears whose + scope cannot be read from the file. + +### Out-of-scope + +- Transitive hops beyond one without `--remote`. +- Branch or worktree hygiene (`/repo-fleet-hygiene:audit`), GitHub organisation settings + (`/github:audit`), doc-versus-code drift inside one repository (`/codebase-health:audit`), and + module-level structure (`/architecture:improve`). +- Container-level or component-level C4 views. +- Writing the consumer's root instruction file; `/architecture:setup apply` owns that. + +### Deferred questions + +None. All twelve interview questions were answered; see the memory-tier ledger. + +### Sequencing + +One tracking issue with four children, in order, each shipped as its own draft PR: + +1. Manifest scope hygiene in `portfolio-facts.sh` (scope axis, dot-directory pruning, Tooling). +2. Core reshape: bare default scope, reference-edge script, `landscape.json` record with drift and + `--check`, closing report, description and README rewrite, evals. +3. Render script for both dialects. +4. `--remote` facts. + +## Plan From 2cb2c1ad74cb0e83c791c0ca069bb060481ed9eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:50:21 +0000 Subject: [PATCH 03/15] fix(architecture): separate runtime scope from development scope in portfolio facts The facts probe classified this repository as node,python with sixteen dependencies, all of them CI pins, because a requirements file under .github/ matched the Python glob and a devDependencies-only package.json counted as a Node runtime. A repository runs on its runtime and is built with its tooling, and every standard that separates the two draws the line on scope rather than path: CycloneDX scope, SPDX DEV_DEPENDENCY_OF and BUILD_TOOL_OF, npm devDependencies, PEP 735 dependency groups, and the GitHub dependency graph. The record gains tooling and dev_dependencies beside runtime and dependencies, each with its own evidence. target_framework follows the primary runtime only, so an engine constraint for a linter no longer fills it. Cache and build dot-directories are pruned from the manifest index, the CI and container config directories are kept, and every manifest under a dot-directory is pinned to development scope: the path rule decides visibility, the scope axis decides runtime versus tooling. A root-level manifest now beats a deeper one, which previously reported whichever component sorted first. This checkout now reports runtime shell with tooling node,python, and its CI pins as development-scope dependencies citing both manifests. Refs #4034 Refs #4033 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- .../architecture/.claude-plugin/plugin.json | 2 +- plugins/architecture/CHANGELOG.md | 13 + .../skills/map-landscape/SKILL.md | 26 +- .../map-landscape/scripts/portfolio-facts.sh | 298 +++++++++++++++--- .../scripts/portfolio-facts.test.sh | 120 ++++++- 5 files changed, 404 insertions(+), 55 deletions(-) diff --git a/plugins/architecture/.claude-plugin/plugin.json b/plugins/architecture/.claude-plugin/plugin.json index 78ebc1bd9e..57603d0e78 100644 --- a/plugins/architecture/.claude-plugin/plugin.json +++ b/plugins/architecture/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "architecture", - "version": "0.8.5", + "version": "0.8.6", "description": "Scans an existing codebase for module-level architecture friction — shallow modules, seam leaks, and locality gaps — using Ousterhout's deep-module lens, presents candidates as a self-contained HTML report, and runs an interview loop on the selected candidate before handing off for planning. Also charts a discovered set of repositories as a C4 system landscape plus an application-portfolio table, and records an architecture decision into the repository's existing ADR convention.", "author": { "name": "Melodic Software", diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index 2378479cf9..0fbdac5b51 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -3,6 +3,19 @@ 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.8.6] + +### Changed + +- **`map-landscape`:** `portfolio-facts.sh` separates runtime scope from development scope. The + record gains `tooling` and `dev_dependencies` beside `runtime` and `dependencies`, each with its + own evidence; `target_framework` follows the primary runtime only. Cache and build dot-directories + are pruned from the manifest index, the CI and container config directories are kept, and every + manifest under a dot-directory is pinned to development scope. A manifest whose only dependency + section is `devDependencies` reports tooling rather than a runtime, and a root-level manifest now + beats a deeper one instead of whichever sorted first. The portfolio table gains a `Tooling` + column. + ## [0.8.5] ### Added diff --git a/plugins/architecture/skills/map-landscape/SKILL.md b/plugins/architecture/skills/map-landscape/SKILL.md index 8b5ccc9662..93adad819a 100644 --- a/plugins/architecture/skills/map-landscape/SKILL.md +++ b/plugins/architecture/skills/map-landscape/SKILL.md @@ -83,10 +83,16 @@ Facts come from the helper script, never derived by hand: The `${CLAUDE_SKILL_DIR}` anchor matters. A bare relative path resolves against the session's working directory, which is not where the script lives. -It emits one JSON object per repository: `name`, `path`, `remote`, `owner`, `runtime`, -`target_framework`, `dependencies[]`, `last_touched`, `evidence{}`. Anything no probe could derive is -the literal `unknown`. Carry `unknown` through to the artifacts as-is; never replace it with a guess, -and never fill it from a commit author, a directory name, or ecosystem memory. +It emits one JSON object per repository: `name`, `path`, `remote`, `owner`, `runtime`, `tooling`, +`target_framework`, `dependencies[]`, `dev_dependencies[]`, `last_touched`, `evidence{}`. Anything no +probe could derive is the literal `unknown`. Carry `unknown` through to the artifacts as-is; never +replace it with a guess, and never fill it from a commit author, a directory name, or ecosystem +memory. + +`runtime` and `dependencies` are runtime scope, what the repository RUNS ON. `tooling` and +`dev_dependencies` are development scope, what it is BUILT WITH: npm `devDependencies`, PEP 735 +dependency groups, a `requirements-ci.txt`, anything under a dot-directory. Report them as separate +facts; a linter is not a runtime. ## Draw relationships @@ -141,10 +147,11 @@ C4Context **Artifact two, `portfolio.md`.** A heading, a generated-on line carrying the date AND the discovery source (explicit list / fleet-hygiene plan / bundled walk), then one table: -`Repository | Owner | Target framework | Runtime | Dependencies | Last touched (local HEAD)` +`Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched (local HEAD)` One row per repository, sorted by name. Render `unknown` as-is. Comma-join dependencies, truncating -to 10 with a trailing `(+N)`. +to 10 with a trailing `(+N)`. `Tooling` is the record's `tooling` field, the development-scope +families; the development-scope dependency names stay in the record rather than the table. ## What this skill does NOT do @@ -169,6 +176,13 @@ to 10 with a trailing `(+N)`. experimental and warns the syntax may change (source: , verified 2026-09-06). Recheck this entry when a mermaid release adds a landscape type or drops the experimental notice. +- **A dot-directory manifest is tooling, never a runtime.** Cache and build directories (`.venv`, + `.mypy_cache`, `.tox`) are pruned outright; the CI and container config directories (`.github`, + `.gitlab`, `.circleci`, `.devcontainer`) are kept, and every manifest under one is pinned to + development scope whatever its content says. So a repository of shell and markdown whose CI + installs `ruff` reports `runtime: shell` with `tooling: python`, not a Python runtime. The same + rule makes a `package.json` carrying only `devDependencies` report tooling rather than a runtime, + which is why `target_framework` can be `unknown` while a `Tooling` entry is present. - **`owner` is a ladder, and commit authors are not on it.** `CODEOWNERS` (root, `.github/`, or `docs/`) default `*` rule's first owner, then the owner segment of the `origin` remote URL, then `unknown`. Who edits a repository most is not who owns it, so the script never looks at git diff --git a/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.sh b/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.sh index 123fb14a3c..8e08742c61 100755 --- a/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.sh +++ b/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.sh @@ -14,19 +14,25 @@ # # Output: JSON Lines on stdout, one object per repository, in argument order: # -# {"name":…,"path":…,"remote":…,"owner":…,"runtime":…, -# "target_framework":…,"dependencies":[…],"last_touched":…,"evidence":{…}} +# {"name":…,"path":…,"remote":…,"owner":…,"runtime":…,"tooling":…, +# "target_framework":…,"dependencies":[…],"dev_dependencies":[…], +# "last_touched":…,"evidence":{…}} # # name directory basename # path absolute path, as the shell resolved it # remote `origin` URL, or "unknown" # owner CODEOWNERS default rule, else the remote's owner segment, # else "unknown". Never a commit author. -# runtime comma-joined list of every detected runtime, primary first, -# or "unknown" -# target_framework the primary runtime's framework/version declaration, or -# "unknown" -# dependencies sorted, de-duplicated, capped at 25 +# runtime comma-joined list of every runtime-scope family, primary +# first, or "unknown" +# tooling comma-joined list of every family found ONLY at +# development scope, or "unknown" +# target_framework the primary RUNTIME's framework/version declaration, or +# "unknown". A development-scope declaration never fills it: +# an engine constraint for a linter is not what the +# repository runs on. +# dependencies runtime scope; sorted, de-duplicated, capped at 25 +# dev_dependencies development scope; same sort, de-duplication, and cap # last_touched `git log -1 --format=%cI` on the given checkout (local # HEAD; nothing here fetches), or "unknown" # evidence per fact, the repo-relative file that supplied it, or the @@ -92,12 +98,34 @@ json_escape() { # cached list. Thirteen separate `find` invocations over the same tree is the # obvious spelling and is minutes slower per repository on a filesystem with # per-open overhead, which is exactly where a fleet-wide run lives. +# +# Dot-directories are pruned by default, because the ones that accumulate are +# caches and build output (`.venv`, `.mypy_cache`, `.tox`, `.next`) whose +# vendored manifests describe someone else's package, not this repository. +# `.git` goes with them. Enumerating cache directories is a losing game; +# enumerating the CI and container config directories is not, so those few are +# kept: their manifests are real evidence about the repository's TOOLING. +# +# A manifest under any dot-directory can therefore only ever be development +# scope (see `effective_scope`). That is the path rule doing exactly one job, +# with the runtime-versus-tooling call left to the scope axis: a +# `requirements-ci.txt` under `.github/` names the linters CI installs, and +# reporting it as a Python RUNTIME makes every shell-and-markdown repository +# "a Python project". GitHub Linguist draws the same line from the other side, +# vendoring `(^|/)\.github/` out of its language statistics. +# +# `-mindepth 1` keeps the prune off the starting point. Without it a repository +# checked out at a dotted path (`~/.local/src/billing`) prunes ITSELF and every +# probe reports unknown. REPO_FILES="" index_repo_files() { local root="$1" REPO_FILES="$( - find "$root" -maxdepth "$PROBE_DEPTH" \ - \( -name node_modules -o -name vendor -o -name .venv -o -name .git \) -prune -o \ + find "$root" -mindepth 1 -maxdepth "$PROBE_DEPTH" \ + \( \( -name '.?*' \ + ! -name '.github' ! -name '.gitlab' ! -name '.circleci' \ + ! -name '.devcontainer' \) \ + -o -name node_modules -o -name vendor \) -prune -o \ -type f \( \ -name '*.csproj' -o -name '*.fsproj' -o -name 'global.json' \ -o -name 'package.json' \ @@ -111,13 +139,19 @@ index_repo_files() { )" } -# First indexed file whose basename matches the glob, into FIND_HIT. Returns 1 +# The indexed file whose basename matches the glob, into FIND_HIT. Returns 1 # and clears FIND_HIT when nothing matches. Assigns rather than prints for the # same reason json_escape does: a command substitution per probe is a fork per # probe, and there are a dozen probes per repository. +# +# A ROOT-LEVEL manifest wins over any deeper one, whatever the sort order says. +# The root manifest describes the repository; a deeper one describes a single +# component inside it. Taking the first sorted hit instead reports whichever +# component happens to sort first, so a monorepo whose root `package.json` +# targets Node 22 was reported as `apps/inner`'s Node 18. FIND_HIT="" find_first() { - local pattern="$1" line base + local pattern="$1" line base first_hit="" FIND_HIT="" while IFS= read -r line; do [[ -n "$line" ]] || continue @@ -127,13 +161,22 @@ find_first() { # shellcheck disable=SC2254 case "$base" in $pattern) - FIND_HIT="$line" - return 0 + [[ -n "$first_hit" ]] || first_hit="$line" + # No slash means the hit sits at the repository root. + case "$line" in + */*) ;; + *) + FIND_HIT="$line" + return 0 + ;; + esac ;; *) ;; esac done <<<"$REPO_FILES" - return 1 + [[ -n "$first_hit" ]] || return 1 + FIND_HIT="$first_hit" + return 0 } # Every indexed file whose basename matches the glob, one per line. @@ -349,6 +392,78 @@ requirements_names() { ' } +# --------------------------------------------------------------------------- +# Manifest scope +# --------------------------------------------------------------------------- +# +# A repository RUNS ON its runtime and is BUILT WITH its tooling. Every +# ecosystem that separates the two does it on scope rather than on path: +# CycloneDX `scope`, SPDX DEV_DEPENDENCY_OF and BUILD_TOOL_OF, npm +# devDependencies, PEP 735 dependency groups, and the GitHub dependency graph's +# runtime/development. Each classifier reports `runtime` or `development` for +# ONE manifest; the caller routes the family and its dependencies accordingly. + +# A package.json names tooling when devDependencies are its only dependency +# section. Absence of every section is NOT evidence of tooling, so a manifest +# carrying no dependencies at all stays runtime: it still declares a package. +node_manifest_scope() { + local file="$1" + if [[ -n "$( + json_object_keys "$file" dependencies + json_object_keys "$file" peerDependencies + )" ]]; then + printf 'runtime' + elif [[ -n "$(json_object_keys "$file" devDependencies)" ]]; then + printf 'development' + else + printf 'runtime' + fi +} + +# A requirements file names its own scope in its filename: `-dev`, `-ci`, and +# `-test` are the pre-PEP-735 convention for a dependency group. Each token is +# matched WITH its separator, because a bare substring test catches real +# names — `requirements-scientific.txt` contains "ci" and is not tooling. +requirements_scope() { + case "${1##*/}" in + *-dev.txt | *_dev.txt | *-dev-*.txt | dev-*.txt | \ + *-ci.txt | *_ci.txt | *-ci-*.txt | ci-*.txt | \ + *-test.txt | *_test.txt | test-*.txt | \ + *-lint.txt | *-docs.txt | *-typing.txt) + printf 'development' + ;; + *) printf 'runtime' ;; + esac +} + +# True when a repo-relative path has a dot-directory segment. +dotdir_path() { + case "/$1" in + */.*/*) return 0 ;; + *) return 1 ;; + esac +} + +# The scope a manifest actually carries: its content's scope, unless it sits +# inside a dot-directory, which pins it to development however it reads. The +# kept dot-directories are CI and container config, so a manifest there +# describes the build, never what the repository runs on. +effective_scope() { + if dotdir_path "$1"; then printf 'development'; else printf '%s' "$2"; fi +} + +# A `[project]` table declares a Python project. A pyproject whose only +# dependency surface is PEP 735 `[dependency-groups]` declares tooling. +pyproject_scope() { + if grep -q '^[[:space:]]*\[project\]' "$1" 2>/dev/null; then + printf 'runtime' + elif grep -q '^[[:space:]]*\[dependency-groups\]' "$1" 2>/dev/null; then + printf 'development' + else + printf 'runtime' + fi +} + # --------------------------------------------------------------------------- # Per-repository collection # --------------------------------------------------------------------------- @@ -393,13 +508,26 @@ for raw_path in "$@"; do fi fi - # --- runtime (first hit is primary, every hit is listed) ------------------ + # --- runtime and tooling (first RUNTIME hit is primary) ------------------- runtimes=() + toolings=() runtime_evidence="" - add_runtime() { - runtimes+=("$1") - [[ -n "$runtime_evidence" ]] && runtime_evidence="$runtime_evidence, " - runtime_evidence="$runtime_evidence$1: $2" + tooling_evidence="" + # $1 family, $2 manifest, $3 content scope. The dot-directory rule is applied + # here so every family gets it, including the ones that pass a literal + # `runtime` because their manifest format has no development scope of its own. + add_family() { + local scope + scope="$(effective_scope "$2" "$3")" + if [[ "$scope" == "development" ]]; then + toolings+=("$1") + [[ -n "$tooling_evidence" ]] && tooling_evidence="$tooling_evidence, " + tooling_evidence="$tooling_evidence$1: $2 (development scope)" + else + runtimes+=("$1") + [[ -n "$runtime_evidence" ]] && runtime_evidence="$runtime_evidence, " + runtime_evidence="$runtime_evidence$1: $2" + fi } dotnet_proj="" @@ -410,39 +538,61 @@ for raw_path in "$@"; do break fi done - [[ -n "$dotnet_proj" ]] && add_runtime dotnet "$dotnet_proj" + [[ -n "$dotnet_proj" ]] && add_family dotnet "$dotnet_proj" runtime node_manifest="" + node_scope="runtime" if find_first 'package.json'; then node_manifest="$FIND_HIT" + node_scope="$(effective_scope "$node_manifest" "$(node_manifest_scope "$repo/$node_manifest")")" js_runtime="node" if [[ -f "$repo/bun.lockb" || -f "$repo/bun.lock" || -f "$repo/bunfig.toml" ]]; then js_runtime="bun" elif [[ -f "$repo/deno.json" || -f "$repo/deno.jsonc" || -f "$repo/deno.lock" ]]; then js_runtime="deno" fi - add_runtime "$js_runtime" "$node_manifest" + add_family "$js_runtime" "$node_manifest" "$node_scope" else node_manifest="" fi + # A pyproject settles the scope on its own. Otherwise a RUNTIME-scope + # requirements file is preferred over a development-scope one: taking the + # first sorted hit reports the repository as tooling merely because + # `requirements-dev.txt` sorts ahead of `requirements.txt`. py_manifest="" - for pattern in 'pyproject.toml' 'requirements*.txt' 'setup.py'; do - if find_first "$pattern"; then - hit="$FIND_HIT" - py_manifest="$hit" - break + py_dev_manifest="" + py_scope="runtime" + if find_first 'pyproject.toml'; then + py_manifest="$FIND_HIT" + py_scope="$(effective_scope "$py_manifest" "$(pyproject_scope "$repo/$py_manifest")")" + else + while IFS= read -r hit; do + [[ -n "$hit" ]] || continue + if [[ "$(effective_scope "$hit" "$(requirements_scope "$hit")")" == "development" ]]; then + [[ -n "$py_dev_manifest" ]] || py_dev_manifest="$hit" + else + py_manifest="$hit" + break + fi + done < <(find_all 'requirements*.txt') + if [[ -z "$py_manifest" ]] && find_first 'setup.py'; then + py_manifest="$FIND_HIT" fi - done - [[ -n "$py_manifest" ]] && add_runtime python "$py_manifest" + if [[ -z "$py_manifest" && -n "$py_dev_manifest" ]]; then + py_manifest="$py_dev_manifest" + py_scope="development" + fi + fi + [[ -n "$py_manifest" ]] && add_family python "$py_manifest" "$py_scope" go_manifest="" if find_first 'go.mod'; then go_manifest="$FIND_HIT"; else go_manifest=""; fi - [[ -n "$go_manifest" ]] && add_runtime go "$go_manifest" + [[ -n "$go_manifest" ]] && add_family go "$go_manifest" runtime rust_manifest="" if find_first 'Cargo.toml'; then rust_manifest="$FIND_HIT"; else rust_manifest=""; fi - [[ -n "$rust_manifest" ]] && add_runtime rust "$rust_manifest" + [[ -n "$rust_manifest" ]] && add_family rust "$rust_manifest" runtime jvm_manifest="" for pattern in 'pom.xml' 'build.gradle*'; do @@ -452,22 +602,24 @@ for raw_path in "$@"; do break fi done - [[ -n "$jvm_manifest" ]] && add_runtime jvm "$jvm_manifest" + [[ -n "$jvm_manifest" ]] && add_family jvm "$jvm_manifest" runtime ruby_manifest="" if find_first 'Gemfile'; then ruby_manifest="$FIND_HIT"; else ruby_manifest=""; fi - [[ -n "$ruby_manifest" ]] && add_runtime ruby "$ruby_manifest" + [[ -n "$ruby_manifest" ]] && add_family ruby "$ruby_manifest" runtime php_manifest="" if find_first 'composer.json'; then php_manifest="$FIND_HIT"; else php_manifest=""; fi - [[ -n "$php_manifest" ]] && add_runtime php "$php_manifest" + [[ -n "$php_manifest" ]] && add_family php "$php_manifest" runtime - # `shell` only when nothing else claimed the repository. + # `shell` only when no RUNTIME-scope family claimed the repository. A + # development-scope family never suppresses it: a shell-and-markdown + # repository that lints with Node still runs on shell. if [[ ${#runtimes[@]} -eq 0 ]]; then for pattern in '*.sh' '*.ps1'; do if find_first "$pattern"; then hit="$FIND_HIT" - add_runtime shell "$hit" + add_family shell "$hit" runtime break fi done @@ -475,7 +627,7 @@ for raw_path in "$@"; do if [[ ${#runtimes[@]} -eq 0 ]]; then runtime="unknown" - runtime_evidence="no runtime manifest under depth $PROBE_DEPTH" + runtime_evidence="no runtime-scope manifest under depth $PROBE_DEPTH" else runtime="$( IFS=, @@ -483,6 +635,16 @@ for raw_path in "$@"; do )" fi + if [[ ${#toolings[@]} -eq 0 ]]; then + tooling="unknown" + tooling_evidence="no development-scope manifest under depth $PROBE_DEPTH" + else + tooling="$( + IFS=, + printf '%s' "${toolings[*]}" + )" + fi + primary="${runtimes[0]:-unknown}" # --- target framework (from the primary runtime's declaration) ------------ @@ -540,13 +702,23 @@ for raw_path in "$@"; do *) ;; esac - # --- dependencies (every detected runtime family contributes) ------------- + # --- dependencies, split by scope ----------------------------------------- + # Runtime-scope names land in `dependencies`; development-scope names land in + # `dev_dependencies`. devDependencies are collected either way: a runtime + # manifest still has tooling, and reporting it is what keeps the two columns + # honest instead of silently dropping half the manifest. dep_raw="" dep_sources="" + dev_dep_raw="" + dev_dep_sources="" note_dep_source() { [[ -n "$dep_sources" ]] && dep_sources="$dep_sources, " dep_sources="$dep_sources$1" } + note_dev_dep_source() { + [[ -n "$dev_dep_sources" ]] && dev_dep_sources="$dev_dep_sources, " + dev_dep_sources="$dev_dep_sources$1" + } # `global.json` alone marks the runtime but carries no references, so it must # not claim a dependency source that produced nothing. @@ -564,12 +736,19 @@ for raw_path in "$@"; do fi if [[ -n "$node_manifest" ]]; then - hits="$( - json_object_keys "$repo/$node_manifest" dependencies - json_object_keys "$repo/$node_manifest" peerDependencies - )" - [[ -n "$hits" ]] && dep_raw="$dep_raw$hits"$'\n' - note_dep_source "$node_manifest (dependencies + peerDependencies)" + if [[ "$node_scope" != "development" ]]; then + hits="$( + json_object_keys "$repo/$node_manifest" dependencies + json_object_keys "$repo/$node_manifest" peerDependencies + )" + [[ -n "$hits" ]] && dep_raw="$dep_raw$hits"$'\n' + note_dep_source "$node_manifest (dependencies + peerDependencies)" + fi + hits="$(json_object_keys "$repo/$node_manifest" devDependencies)" + if [[ -n "$hits" ]]; then + dev_dep_raw="$dev_dep_raw$hits"$'\n' + note_dev_dep_source "$node_manifest (devDependencies)" + fi fi if [[ -n "$py_manifest" ]]; then @@ -595,8 +774,15 @@ for raw_path in "$@"; do else hits="$(requirements_names <"$repo/$py_manifest")" fi - [[ -n "$hits" ]] && dep_raw="$dep_raw$hits"$'\n' - note_dep_source "$py_manifest" + if [[ "$py_scope" == "development" ]]; then + if [[ -n "$hits" ]]; then + dev_dep_raw="$dev_dep_raw$hits"$'\n' + note_dev_dep_source "$py_manifest (development scope)" + fi + else + [[ -n "$hits" ]] && dep_raw="$dep_raw$hits"$'\n' + note_dep_source "$py_manifest" + fi fi if [[ -n "$go_manifest" ]]; then @@ -624,7 +810,16 @@ for raw_path in "$@"; do dependencies+=("$dep") done < <(printf '%s' "$dep_raw" | grep -v '^[[:space:]]*$' | LC_ALL=C sort -u | head -"$DEP_CAP") fi - [[ -n "$dep_sources" ]] || dep_sources="no dependency manifest" + [[ -n "$dep_sources" ]] || dep_sources="no runtime-scope dependency manifest" + + dev_dependencies=() + if [[ -n "$dev_dep_raw" ]]; then + while IFS= read -r dep; do + [[ -n "$dep" ]] || continue + dev_dependencies+=("$dep") + done < <(printf '%s' "$dev_dep_raw" | grep -v '^[[:space:]]*$' | LC_ALL=C sort -u | head -"$DEP_CAP") + fi + [[ -n "$dev_dep_sources" ]] || dev_dep_sources="no development-scope dependency manifest" # --- last touched (local HEAD; nothing here fetches) ---------------------- last_touched="$(git -C "$repo" log -1 --format=%cI 2>/dev/null)" || last_touched="" @@ -642,6 +837,7 @@ for raw_path in "$@"; do json_escape "$remote" && record="$record\"remote\":\"$JSON_ESC\"," json_escape "$owner" && record="$record\"owner\":\"$JSON_ESC\"," json_escape "$runtime" && record="$record\"runtime\":\"$JSON_ESC\"," + json_escape "$tooling" && record="$record\"tooling\":\"$JSON_ESC\"," json_escape "$target_framework" && record="$record\"target_framework\":\"$JSON_ESC\"," record="$record\"dependencies\":[" for ((di = 0; di < ${#dependencies[@]}; di++)); do @@ -649,18 +845,26 @@ for raw_path in "$@"; do json_escape "${dependencies[$di]}" record="$record\"$JSON_ESC\"" done + record="$record],\"dev_dependencies\":[" + for ((di = 0; di < ${#dev_dependencies[@]}; di++)); do + [[ "$di" -gt 0 ]] && record="$record," + json_escape "${dev_dependencies[$di]}" + record="$record\"$JSON_ESC\"" + done record="$record]," json_escape "$last_touched" && record="$record\"last_touched\":\"$JSON_ESC\"," record="$record\"evidence\":{" json_escape "$owner_evidence" && record="$record\"owner\":\"$JSON_ESC\"," json_escape "$runtime_evidence" && record="$record\"runtime\":\"$JSON_ESC\"," + json_escape "$tooling_evidence" && record="$record\"tooling\":\"$JSON_ESC\"," json_escape "$tf_evidence" && record="$record\"target_framework\":\"$JSON_ESC\"," json_escape "$dep_sources" && record="$record\"dependencies\":\"$JSON_ESC\"," + json_escape "$dev_dep_sources" && record="$record\"dev_dependencies\":\"$JSON_ESC\"," json_escape "$lt_evidence" && record="$record\"last_touched\":\"$JSON_ESC\"" record="$record}}" printf '%s\n' "$record" - unset -f add_runtime note_dep_source + unset -f add_family note_dep_source note_dev_dep_source done exit "$exit_code" diff --git a/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.test.sh b/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.test.sh index 839ae73f3f..6954487f85 100755 --- a/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/portfolio-facts.test.sh @@ -83,6 +83,22 @@ field() { ' } +# Read one JSON array field out of a single-object JSON Lines record. The +# leading quote in the search pattern is what keeps `"dependencies":[` from +# matching inside `"dev_dependencies":[`. +array_field() { + printf '%s' "$1" | awk -v key="$2" ' + { + pat = "\"" key "\":[" + i = index($0, pat) + if (i == 0) { print ""; exit } + rest = substr($0, i + length(pat)) + j = index(rest, "]") + print substr(rest, 1, j - 1) + } + ' +} + if ! command -v git >/dev/null 2>&1; then echo "SKIP: git not installed" >&2 exit 0 @@ -130,7 +146,11 @@ assert_equals "node: runtime" "$(field "$out" runtime)" "node" assert_equals "node: target_framework from engines.node" "$(field "$out" target_framework)" ">=22" assert_contains "node: dependencies key collected" "$out" '"react"' assert_contains "node: peerDependencies key collected" "$out" '"typescript"' -assert_not_contains "node: devDependencies are NOT collected" "$out" '"vitest"' +assert_not_contains "node: devDependencies stay out of dependencies" \ + "$(array_field "$out" dependencies)" '"vitest"' +assert_contains "node: devDependencies are reported at development scope" \ + "$(array_field "$out" dev_dependencies)" '"vitest"' +assert_equals "node: a manifest with runtime deps claims no tooling family" "$(field "$out" tooling)" "unknown" assert_not_contains "node: version ranges are not mistaken for keys" "$out" '"^19.0.0"' # --- Case group 3: a Python repository -------------------------------------- @@ -308,6 +328,104 @@ commit_repo "$mixed_repo" out="$(bash "$SCRIPT" "$mixed_repo")" assert_equals "shell: suppressed when another runtime matched" "$(field "$out" runtime)" "node" +# --- Case group 9a: runtime scope versus development scope ------------------ +# A repository runs on its runtime; it is BUILT with its tooling. Every +# ecosystem that distinguishes the two draws the line on scope (CycloneDX +# `scope`, SPDX DEV_DEPENDENCY_OF, npm devDependencies, PEP 735 dependency +# groups, the GitHub dependency graph's runtime/development), so a manifest +# whose only content is development scope names a tool, never a runtime. + +# A CI config directory is kept and pinned to development scope: its manifest +# is real evidence about the tooling and none at all about the runtime. A cache +# directory is pruned outright, because its vendored manifests describe someone +# else's package. +dotdir_repo="$(make_repo ci-pinned)" +mkdir -p "$dotdir_repo/.github" "$dotdir_repo/.mypy_cache/vendored" +cat >"$dotdir_repo/.github/requirements-ci.txt" <<'REQ' +ruff==0.16.5 +pytest==9.1.1 +REQ +cat >"$dotdir_repo/.mypy_cache/vendored/setup.py" <<'PY' +from setuptools import setup +PY +printf '#!/usr/bin/env bash\necho hi\n' >"$dotdir_repo/run.sh" +commit_repo "$dotdir_repo" +out="$(bash "$SCRIPT" "$dotdir_repo")" +assert_equals "dot-dir: a .github manifest does not make the repo python" "$(field "$out" runtime)" "shell" +assert_equals "dot-dir: it reports python as tooling instead" "$(field "$out" tooling)" "python" +assert_contains "dot-dir: the pinned CI tool is a dev dependency" \ + "$(array_field "$out" dev_dependencies)" '"ruff"' +assert_not_contains "dot-dir: the CI pin is not a runtime dependency" \ + "$(array_field "$out" dependencies)" '"ruff"' +assert_contains "dot-dir: evidence cites the CI manifest" "$out" '.github/requirements-ci.txt' +assert_not_contains "dot-dir: a cache directory is pruned outright" "$out" '.mypy_cache' + +# A devDependencies-only package.json is the shape every plugin repository in +# this marketplace has: linters and formatters, no runtime dependency. +devonly_repo="$(make_repo lint-only)" +cat >"$devonly_repo/package.json" <<'PKG' +{ + "name": "lint-only", + "engines": { "node": ">=24" }, + "devDependencies": { "markdownlint-cli2": "^0.23.2", "@biomejs/biome": "^2.0.0" } +} +PKG +printf '#!/usr/bin/env bash\necho hi\n' >"$devonly_repo/run.sh" +commit_repo "$devonly_repo" +out="$(bash "$SCRIPT" "$devonly_repo")" +assert_equals "dev-only: node is tooling, not the runtime" "$(field "$out" tooling)" "node" +assert_equals "dev-only: the shell fallback still names the runtime" "$(field "$out" runtime)" "shell" +assert_contains "dev-only: the dev dependency is reported" \ + "$(array_field "$out" dev_dependencies)" '"markdownlint-cli2"' +assert_contains "dev-only: runtime dependencies stay empty" "$out" '"dependencies":[]' +assert_contains "dev-only: evidence names the development scope" "$out" 'development scope' + +# A dev-scoped requirements file names a tool by its filename alone. +devreq_repo="$(make_repo py-tooling)" +cat >"$devreq_repo/requirements-dev.txt" <<'REQ' +pytest==9.1.1 +REQ +printf '#!/usr/bin/env bash\necho hi\n' >"$devreq_repo/run.sh" +commit_repo "$devreq_repo" +out="$(bash "$SCRIPT" "$devreq_repo")" +assert_equals "dev-req: python is tooling, not the runtime" "$(field "$out" tooling)" "python" +assert_contains "dev-req: the tool is a dev dependency" "$(array_field "$out" dev_dependencies)" '"pytest"' + +# Runtime and tooling coexist: the runtime is claimed by the runtime-scope +# manifest, and the dev-only manifest still reports its family as tooling. +both_repo="$(make_repo svc-with-tooling)" +cat >"$both_repo/go.mod" <<'GOMOD' +module example.invalid/svc-with-tooling + +go 1.23 + +require github.com/spf13/cobra v1.8.1 +GOMOD +cat >"$both_repo/package.json" <<'PKG' +{ "name": "svc-with-tooling", "devDependencies": { "prettier": "^3.0.0" } } +PKG +commit_repo "$both_repo" +out="$(bash "$SCRIPT" "$both_repo")" +assert_equals "both: the runtime-scope manifest owns the runtime" "$(field "$out" runtime)" "go" +assert_equals "both: the dev-only manifest owns the tooling" "$(field "$out" tooling)" "node" +assert_equals "both: target_framework follows the runtime" "$(field "$out" target_framework)" "1.23" +assert_contains "both: the runtime dependency is collected" "$(array_field "$out" dependencies)" '"github.com/spf13/cobra"' +assert_contains "both: the tool is collected separately" "$(array_field "$out" dev_dependencies)" '"prettier"' + +# A root manifest beats a deeper one, whatever the sort order would say. +rooted_repo="$(make_repo rooted)" +mkdir -p "$rooted_repo/apps/inner" +cat >"$rooted_repo/apps/inner/package.json" <<'PKG' +{ "name": "inner", "engines": { "node": ">=18" }, "dependencies": { "inner-dep": "^1.0.0" } } +PKG +cat >"$rooted_repo/package.json" <<'PKG' +{ "name": "rooted", "engines": { "node": ">=22" }, "dependencies": { "root-dep": "^1.0.0" } } +PKG +commit_repo "$rooted_repo" +out="$(bash "$SCRIPT" "$rooted_repo")" +assert_equals "root-first: the root manifest supplies the framework" "$(field "$out" target_framework)" ">=22" +assert_contains "root-first: the root dependency is collected" "$(array_field "$out" dependencies)" '"root-dep"' + # --- Case group 10: multiple repositories, and a bad path ------------------- out="$(bash "$SCRIPT" "$dotnet_repo" "$node_repo")" line_count="$(printf '%s\n' "$out" | grep -c '^{')" From 3bc25b12e22908e65b4cdb638b9c5bcc2ea2cd5c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 16:53:04 +0000 Subject: [PATCH 04/15] docs(architecture): regenerate the landscape artifacts from the corrected facts The first run recorded this repository as a node,python runtime with sixteen dependencies, all of them CI tool pins. The scope split corrects that to a shell runtime with node and python tooling and no runtime dependencies, so the committed artifacts are regenerated to match rather than left asserting facts the probe no longer produces. The portfolio table gains the Tooling column and states the scope split it now reports. The landscape names why the nineteen repositories this checkout references are absent from the diagram. Refs #4034 Refs #4033 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape.md | 10 ++++++++-- docs/architecture/portfolio.md | 26 +++++++++++++++++++------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/docs/architecture/landscape.md b/docs/architecture/landscape.md index 6e2260d7b4..f15b9b12b3 100644 --- a/docs/architecture/landscape.md +++ b/docs/architecture/landscape.md @@ -1,15 +1,21 @@ # System Landscape -Generated on 2026-09-08 from an explicit repository list (`--repos`). Every fact +Generated on 2026-09-10 from an explicit repository list (`--repos`). Every fact below traces to the file named in `portfolio.md`; nothing was fetched, and the date reflects the local HEAD of each checkout. ```mermaid C4Context title System Landscape - System(claude_code_plugins, "claude-code-plugins", "node, >=24") + System(claude_code_plugins, "claude-code-plugins", "shell; tooling: node, python") ``` One repository, one owner (`melodic-software`), so no enterprise boundary is drawn and no relationship line exists: an edge needs a fact in one repository that names another, and there is no second repository here to name. + +This checkout references nineteen other repositories in its tracked files, and +none of them appear above, because the current skill draws an edge only when the +other repository is itself in the charted set. Charting the reference graph is +the redesign tracked in +[#4033](https://github.com/melodic-software/claude-code-plugins/issues/4033). diff --git a/docs/architecture/portfolio.md b/docs/architecture/portfolio.md index 40fb159d13..af0a50b1ad 100644 --- a/docs/architecture/portfolio.md +++ b/docs/architecture/portfolio.md @@ -1,20 +1,32 @@ # Application portfolio -Generated on 2026-09-08 from an explicit repository list (`--repos`). Last +Generated on 2026-09-10 from an explicit repository list (`--repos`). Last touched is the local HEAD of each checkout; nothing was fetched, so a stale checkout reports a stale date. Every value comes from the file the `portfolio-facts.sh` probe named; `unknown` means no probe could derive it. -| Repository | Owner | Target framework | Runtime | Dependencies | Last touched (local HEAD) | -|---|---|---|---|---|---| -| claude-code-plugins | melodic-software | >=24 | node,python | iniconfig, packaging, pluggy, pygments, pytest, pyyaml, ruff, tree-sitter, tree-sitter-bash, tree-sitter-c-sharp (+6) | 2026-09-08T12:45:20-04:00 | +`Runtime` and `Dependencies` are runtime scope, what the repository runs on. +`Tooling` is development scope, what it is built with: npm `devDependencies`, +CI requirement pins, anything under a dot-directory. This repository is shell +and markdown that lints with Node and Python tooling, which is why it has no +runtime dependencies at all. + +| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched (local HEAD) | +|---|---|---|---|---|---|---| +| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-10T16:50:21+00:00 | + +Development-scope dependencies, truncated to ten: `@anthropic-ai/claude-code`, +`@biomejs/biome`, `htmlhint`, `iniconfig`, `markdownlint-cli2`, `packaging`, +`pluggy`, `pygments`, `pytest`, `pyyaml` (+10). ## Evidence | Fact | Source | |---|---| | Owner | origin remote URL (no CODEOWNERS default rule) | -| Runtime | node: `package.json`; python: `.github/requirements-ci.txt` | -| Target framework | `package.json` (`engines.node`) | -| Dependencies | `package.json` (dependencies + peerDependencies), `.github/requirements-ci.txt` | +| Runtime | shell: `lib/hook-utils.sh` | +| Tooling | node: `package.json` (development scope); python: `.github/requirements-ci.txt` (development scope) | +| Target framework | no framework declaration for runtime shell | +| Dependencies | no runtime-scope dependency manifest | +| Development dependencies | `package.json` (devDependencies), `.github/requirements-ci.txt` | | Last touched | `git log -1 --format=%cI` (local HEAD) | From a5ef14b98ddc96dbac2593ea10170568f5b22def Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 17:02:36 +0000 Subject: [PATCH 05/15] feat(architecture): extract typed reference edges from a repository's tracked files A landscape drawn only from locally checked-out repositories shows one node and no edges: the related systems are named by reference, not by adjacency on disk. This checkout names eleven sibling repositories and several third parties, none of which the current skill can draw. reference-edges.sh reads tracked files and emits one JSON record per (target, type) pair with a reference count and the citing files. Four types, each anchored on a syntax that only ever names a repository: uses-workflow (a workflow or composite-action step), installs-plugin (a marketplace source or install line), depends-on (a Go module path), and cites (a github.com URL, or a bare owner/repo whose owner matches this repository's own). The owner requirement on bare tokens is what makes the weakest type usable. A single owner/repo regex over all tracked text is why this was left to the model before: on this repository it matches sponsors/ out of a funding URL, en/ out of a docs.github.com path, and every acme/billing in a fixture. Alongside that, the extractors reject test and eval files, GitHub's own product surfaces, documentation placeholders, local marketplace sources, and self-references folded case-insensitively; ref pins, .git suffixes, and sentence-ending periods are stripped from the repository segment. Against this checkout it finds 77 edges, including 24 uses-workflow references to ci-workflows, matching an independent count of the same workflow lines. Refs #4035 Refs #4033 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- .../map-landscape/scripts/reference-edges.sh | 390 ++++++++++++++++++ .../scripts/reference-edges.test.sh | 255 ++++++++++++ 2 files changed, 645 insertions(+) create mode 100755 plugins/architecture/skills/map-landscape/scripts/reference-edges.sh create mode 100755 plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh diff --git a/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh new file mode 100755 index 0000000000..90b3220c98 --- /dev/null +++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env bash +# Extract typed reference edges from ONE repository's tracked files, as JSON. +# +# WHY. A landscape drawn only from repositories that happen to be checked out +# locally shows one node and no edges, because the related systems are named by +# REFERENCE, not by adjacency on disk. This script reads what a repository says +# about other repositories and types each reference by the surface that carries +# it, so an edge means something specific instead of "these names co-occur". +# +# Extraction is per source type on purpose. A single `owner/repo` regex over all +# tracked text is what makes a landscape untrustworthy: on a docs-heavy +# repository it matches `sponsors/...` out of a funding URL, `en/...` out of a +# documentation path, and every `acme/billing` in a fixture. Each rule below +# either anchors on a syntax that only ever names a repository (`uses:`, a +# module path, a marketplace source) or requires the owner to match this +# repository's own. +# +# Usage: +# reference-edges.sh [--owner ] +# reference-edges.sh --help +# +# --owner overrides the owner segment taken from the `origin` remote. It decides +# which references are `internal` (same owner) and which are `external`, and it +# is the only way a bare `owner/repo` token is trusted at all. +# +# Output: JSON Lines on stdout, one object per (target, type) pair, sorted: +# +# {"from":…,"to":…,"type":…,"relation":…,"count":N,"files":[…]} +# +# from this repository's directory basename +# to `owner/repo` +# type uses-workflow | installs-plugin | depends-on | cites +# relation internal (owner matches this repository's) | external +# count how many references of this type name that target +# files up to FILE_CAP repo-relative files carrying them, sorted +# +# Edge types, and the syntax each one trusts: +# +# uses-workflow a `uses:` step in a workflow or composite action. Names a +# reusable workflow or action this repository RUNS. +# installs-plugin a marketplace `source`, or a `/plugin marketplace add` +# line. Names a repository this one INSTALLS FROM. +# depends-on a Go module path, or a git dependency URL in a package +# manifest. Names code this repository BUILDS AGAINST. +# cites a github.com URL, or a bare `owner/repo` whose owner is this +# repository's own, anywhere else in tracked text. The weakest +# type: it means "mentioned", nothing more. +# +# Nothing here fetches, and nothing is inferred from a name's resemblance to +# another. A repository referenced only by a name that looks like a sibling +# produces no edge. +# +# Portability: bash plus POSIX awk/grep/sed, and git for the tracked-file scope. +# No jq, no `grep -P`, no python. +# +# Exit: 0 = edges emitted (possibly none); 1 = the path is not a readable git +# repository; 2 = usage. +set -uo pipefail + +FILE_CAP=5 + +usage() { + # Print the header comment block only, selected by comment marker so --help + # stays correct as the block grows. + sed -n '2,${/^#/!q;p;}' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' +} + +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +repo_arg="" +owner_override="" +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) + shift + [[ $# -gt 0 ]] || { + printf 'reference-edges.sh: --owner needs a value\n' >&2 + exit 2 + } + owner_override="$1" + ;; + --owner=*) owner_override="${1#--owner=}" ;; + -*) + printf 'reference-edges.sh: unknown option: %s\n' "$1" >&2 + exit 2 + ;; + *) + [[ -z "$repo_arg" ]] || { + printf 'reference-edges.sh: one repository path only\n' >&2 + exit 2 + } + repo_arg="$1" + ;; + esac + shift +done + +if [[ -z "$repo_arg" ]]; then + printf 'usage: reference-edges.sh [--owner ]\n' >&2 + exit 2 +fi + +if [[ ! -d "$repo_arg" ]]; then + printf 'reference-edges.sh: not a directory: %s\n' "$repo_arg" >&2 + exit 1 +fi + +repo="$(cd "$repo_arg" 2>/dev/null && pwd)" || { + printf 'reference-edges.sh: unreadable: %s\n' "$repo_arg" >&2 + exit 1 +} +name="$(basename "$repo")" + +if ! git -C "$repo" rev-parse --git-dir >/dev/null 2>&1; then + printf 'reference-edges.sh: not a git repository, no tracked files to read: %s\n' "$repo" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# Owner +# --------------------------------------------------------------------------- + +# The owner segment of a github.com remote. Only github.com is read here: this +# owner decides which BARE `owner/repo` tokens are trusted, and trusting a bare +# token on a host whose path shape we have not verified is how fixture names +# become systems. +remote_owner_segment() { + local url + url="$(git -C "$repo" remote get-url origin 2>/dev/null)" || return 1 + url="${url%.git}" + case "$url" in + *github.com[:/]*) + url="${url#*github.com}" + url="${url#:}" + url="${url#/}" + case "$url" in + */*) printf '%s' "${url%%/*}" ;; + *) return 1 ;; + esac + ;; + *) return 1 ;; + esac +} + +owner="$owner_override" +[[ -n "$owner" ]] || owner="$(remote_owner_segment)" || owner="" + +# --------------------------------------------------------------------------- +# Reserved GitHub path prefixes +# --------------------------------------------------------------------------- +# +# `github.com//` is only an owner/repo pair when is an +# account. These first segments are GitHub's own product surfaces, so a funding +# link (`github.com/sponsors/acme`) or a marketplace page is not a repository. +is_reserved_owner() { + case "$1" in + sponsors | features | orgs | settings | apps | marketplace | topics | \ + collections | about | pricing | security | login | join | new | notifications | \ + explore | trending | events | site | contact | readme | pulls | issues | \ + codespaces | enterprise | customer-stories | organizations) + return 0 + ;; + *) return 1 ;; + esac +} + +# A segment that can actually be a GitHub owner or repository name. This is the +# backstop for every extractor: a regex tuned to one surface still catches +# neighbouring punctuation and documentation templates, so ``, +# ``acme-tools` ``, and `claude-code-plugins`;` are rejected here rather than by +# making each pattern progressively more baroque. +is_valid_segment() { + case "$1" in + "" | . | ..) return 1 ;; + *[!A-Za-z0-9_.-]*) return 1 ;; + *) return 0 ;; + esac +} + +# Documentation placeholders. `github.com/owner/repo` in a usage example names +# the SHAPE of a reference, not a repository, and charting it invents a system +# called `owner/repo` that every docs-heavy repository would appear to depend +# on. `example` and `acme` are the conventional stand-in names. +is_placeholder_owner() { + case "$1" in + owner | org | user | username | your-org | your-owner | myorg | my-org | \ + example | example-org | acme | acme-corp | foo | bar | OWNER | ORG | USER) + return 0 + ;; + *) return 1 ;; + esac +} + +# --------------------------------------------------------------------------- +# Emission +# --------------------------------------------------------------------------- + +# A test, fixture, or eval file names repositories that do not exist: `acme/api` +# and `Owner/Repo` are scaffolding for an assertion, not systems this repository +# relates to. Charting them fills the landscape with invented nodes, so the +# whole file is out of scope for every extractor. +is_fixture_file() { + case "$1" in + *.test.sh | *.test.ts | *.test.js | *.spec.ts | *.spec.js) return 0 ;; + test_*.py | */test_*.py | *_test.py | *_test.go) return 0 ;; + */tests/* | tests/*) return 0 ;; + */evals/* | evals/*) return 0 ;; + */fixtures/* | fixtures/*) return 0 ;; + */testdata/* | testdata/*) return 0 ;; + *) return 1 ;; + esac +} + +# GitHub treats an owner and a repository name case-insensitively, so +# `Melodic-Software/Claude-Code-Plugins` and `melodic-software/claude-code-plugins` +# are one target, and one of them is this repository referring to itself. Folded +# with `nocasematch` rather than a `tr` subshell: this runs once per hit, and a +# fork per hit is the cost that dominated the sibling collector. The prior +# setting is restored so the shopt never leaks into the extractors' own globs. +is_self_reference() { + local result=1 had_nocase=0 + [[ -n "$owner" ]] || return 1 + shopt -q nocasematch && had_nocase=1 + shopt -s nocasematch + [[ "$1" == "$owner/$name" ]] && result=0 + [[ $had_nocase -eq 1 ]] || shopt -u nocasematch + return "$result" +} + +# Raw hits accumulate here as `typeowner/repofile`, one per line, and +# are aggregated once at the end. Collecting first and counting later keeps each +# extractor a plain producer with no shared counter to get wrong. +HITS="" +add_hit() { + # $1 type, $2 owner/repo, $3 file + local target="$2" file="$3" o r + is_fixture_file "$file" && return 0 + # A bare token with no slash names no repository. Without this guard the + # expansions below both return the whole token and it emits as `X/X`. + case "$target" in + */*) ;; + *) return 0 ;; + esac + o="${target%%/*}" + r="${target#*/}" + r="${r%%/*}" + # A `uses:` pin carries its ref; the repository is not named `checkout@`. + r="${r%%@*}" + # A clone URL carries the suffix; the repository is not named `repo.git`. + r="${r%.git}" + # Prose ends sentences. A dot is legal INSIDE a repository name (`docs.rs`) + # and never terminates one, so `owner/repo.` in running text is `owner/repo` + # plus the full stop that followed it. + while [[ "$r" == *. ]]; do r="${r%.}"; done + is_valid_segment "$o" || return 0 + is_valid_segment "$r" || return 0 + is_reserved_owner "$o" && return 0 + is_placeholder_owner "$o" && return 0 + # A reference to this repository itself is not an edge. + is_self_reference "$o/$r" && return 0 + HITS="$HITS$1 $o/$r $file"$'\n' +} + +# --------------------------------------------------------------------------- +# Extractors +# --------------------------------------------------------------------------- + +# `git grep -I` skips binary files; `-o` prints each match on its own line +# prefixed by the file, which is where the per-edge `files` list comes from. +grep_tracked() { + local pattern="$1" + shift + git -C "$repo" grep -I -o -E -e "$pattern" -- "$@" 2>/dev/null +} + +# 1. uses-workflow. A `uses:` step names a reusable workflow or action. Local +# (`./path`) and container (`docker://`) forms name no repository. +while IFS= read -r line; do + [[ -n "$line" ]] || continue + file="${line%%:*}" + match="${line#*:}" + ref="${match#*uses:}" + ref="${ref#"${ref%%[![:space:]]*}"}" + case "$ref" in + ./* | docker://*) continue ;; + *) ;; + esac + add_hit uses-workflow "$ref" "$file" +done < <(grep_tracked '^[[:space:]]*(-[[:space:]]+)?uses:[[:space:]]*[^[:space:]]+' \ + '.github/workflows' '.github/actions' '*.yml' '*.yaml') + +# 2. installs-plugin. A marketplace source, or the documented install line. A +# LOCAL source (`./plugins/foo`) is this repository's own component, not +# another repository: a monorepo marketplace declares one per plugin, so +# reading them as references invents an edge per directory. +while IFS= read -r line; do + [[ -n "$line" ]] || continue + file="${line%%:*}" + match="${line#*:}" + ref="${match##*[ \"]}" + case "$ref" in + ./* | ../* | /* | "") continue ;; + *) ;; + esac + add_hit installs-plugin "$ref" "$file" +done < <(grep_tracked '(plugin marketplace add[[:space:]]+|"source"[[:space:]]*:[[:space:]]*")[A-Za-z0-9_./-]+' \ + '*.json' '*.md') + +# 3. depends-on. A Go module path names its repository directly. +while IFS= read -r line; do + [[ -n "$line" ]] || continue + file="${line%%:*}" + match="${line#*:}" + ref="${match#*github.com/}" + add_hit depends-on "$ref" "$file" +done < <(grep_tracked '(^|[^A-Za-z0-9.-])github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+' 'go.mod' '*/go.mod') + +# 4. cites, from a github.com URL. Any owner; the reserved-prefix filter in +# add_hit drops GitHub's own product pages. +while IFS= read -r line; do + [[ -n "$line" ]] || continue + file="${line%%:*}" + match="${line#*:}" + case "$file" in + go.mod | */go.mod) continue ;; + *) ;; + esac + ref="${match#*github.com/}" + add_hit cites "$ref" "$file" +done < <(grep_tracked '(^|[^A-Za-z0-9.-])github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+') + +# 5. cites, from a BARE `owner/repo` token, and only when the owner is this +# repository's own. This is the rule that finds a sibling named in prose or +# in a rule file without a URL, and the owner requirement is what keeps +# `acme/billing` in a fixture from becoming a system. +if [[ -n "$owner" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] || continue + file="${line%%:*}" + match="${line#*:}" + ref="${match#*"$owner/"}" + add_hit cites "$owner/$ref" "$file" + done < <(grep_tracked "(^|[^A-Za-z0-9_./-])${owner}/[A-Za-z0-9_.-]+") +fi + +# --------------------------------------------------------------------------- +# Aggregate and emit +# --------------------------------------------------------------------------- +# +# One record per (target, type). A target reached by two surfaces earns two +# records, because "runs its CI" and "is mentioned in a doc" are different +# claims and collapsing them loses the stronger one. + +[[ -n "$HITS" ]] || exit 0 + +printf '%s' "$HITS" | LC_ALL=C sort | awk -F'\t' \ + -v from="$name" -v owner="$owner" -v cap="$FILE_CAP" ' + function esc(s) { + gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s) + return s + } + function flush( i, rel, out) { + if (key == "") return + rel = (owner != "" && curowner == owner) ? "internal" : "external" + out = "{\"from\":\"" esc(from) "\",\"to\":\"" esc(curto) "\"," + out = out "\"type\":\"" esc(curtype) "\",\"relation\":\"" rel "\"," + out = out "\"count\":" count ",\"files\":[" + for (i = 1; i <= nfiles && i <= cap; i++) { + if (i > 1) out = out "," + out = out "\"" esc(files[i]) "\"" + } + out = out "]}" + print out + } + { + k = $2 SUBSEP $1 + if (k != key) { + flush() + key = k; curtype = $1; curto = $2 + split(curto, parts, "/"); curowner = parts[1] + count = 0; nfiles = 0; delete files; delete seen + } + count++ + if (!($3 in seen)) { seen[$3] = 1; files[++nfiles] = $3 } + } + END { flush() } +' diff --git a/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh new file mode 100755 index 0000000000..2de39683ab --- /dev/null +++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh @@ -0,0 +1,255 @@ +#!/usr/bin/env bash +# Self-contained tests for reference-edges.sh (skill-script shape, per +# docs/conventions/shell-test-helpers/README.md: per-plugin assertion +# primitives are duplicated on purpose, never shared across plugins). +# +# Every fixture is built in a mktemp directory and torn down on exit; nothing +# here reads or writes a real repository. +set -uo pipefail + +# Isolate the fixture repositories from any ambient git environment, for the +# reason the sibling suite documents: `git -C` changes directory but does not +# override discovery, so an exported GIT_DIR would land these throwaway +# identities in the CALLER's .git/config. +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/reference-edges.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3 + actual: $2" ;; + esac +} +assert_not_contains() { + case "$2" in + *"$3"*) fail "$1" "unexpected substring: $3 + actual: $2" ;; + *) pass "$1" ;; + esac +} +assert_equals() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected [$3], got [$2]"; fi +} + +make_repo() { + local dir="$TEST_TMPDIR/$1" + mkdir -p "$dir" + git -C "$dir" init --quiet 2>/dev/null + git -C "$dir" config user.email "fixture@example.invalid" + git -C "$dir" config user.name "Fixture" + git -C "$dir" config commit.gpgsign false + git -C "$dir" config core.autocrlf false + git -C "$dir" remote add origin "https://github.com/fixture-owner/$1.git" + printf '%s' "$dir" +} + +commit_repo() { + git -C "$1" add -A 2>/dev/null + git -C "$1" commit --quiet --no-verify -m "fixture" 2>/dev/null +} + +# The record for one (to, type) pair, or the empty string. +edge() { + printf '%s\n' "$1" | grep -F "\"to\":\"$2\"" | grep -F "\"type\":\"$3\"" | head -1 +} + +# One scalar out of an edge record. +field() { + printf '%s' "$1" | awk -v key="$2" ' + { + pat = "\"" key "\":" + i = index($0, pat) + if (i == 0) { print ""; exit } + rest = substr($0, i + length(pat)) + if (substr(rest, 1, 1) == "\"") { + rest = substr(rest, 2) + print substr(rest, 1, index(rest, "\"") - 1) + } else { + j = 1 + while (j <= length(rest) && substr(rest, j, 1) ~ /[0-9]/) j++ + print substr(rest, 1, j - 1) + } + } + ' +} + +if ! command -v git >/dev/null 2>&1; then + echo "SKIP: git not installed" >&2 + exit 0 +fi + +# --- Case group 1: uses-workflow -------------------------------------------- +wf_repo="$(make_repo runner)" +mkdir -p "$wf_repo/.github/workflows" +cat >"$wf_repo/.github/workflows/ci.yml" <<'YML' +jobs: + build: + uses: fixture-owner/ci-workflows/.github/workflows/build.yml@v2 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: ./.github/actions/local-thing + - uses: docker://alpine:3.20 + test: + uses: fixture-owner/ci-workflows/.github/workflows/test.yml@v2 +YML +commit_repo "$wf_repo" +out="$(bash "$SCRIPT" "$wf_repo")" +e="$(edge "$out" fixture-owner/ci-workflows uses-workflow)" +assert_equals "uses: the reusable workflow is an edge" "$(field "$e" to)" "fixture-owner/ci-workflows" +assert_equals "uses: both references are counted" "$(field "$e" count)" "2" +assert_equals "uses: a same-owner target is internal" "$(field "$e" relation)" "internal" +assert_contains "uses: the workflow file is cited" "$e" '.github/workflows/ci.yml' +e="$(edge "$out" actions/checkout uses-workflow)" +assert_equals "uses: the pinned ref is stripped from the repository name" "$(field "$e" to)" "actions/checkout" +assert_equals "uses: a third-party action is external" "$(field "$e" relation)" "external" +assert_not_contains "uses: a local action path is not a repository" "$out" '"to":"."' +assert_not_contains "uses: a docker image is not a repository" "$out" 'alpine' + +# --- Case group 2: installs-plugin ------------------------------------------ +mk_repo="$(make_repo marketplace)" +mkdir -p "$mk_repo/.claude-plugin" +cat >"$mk_repo/.claude-plugin/marketplace.json" <<'JSON' +{ + "plugins": [ + { "name": "local-one", "source": "./plugins/local-one" }, + { "name": "remote-one", "source": "vendor-org/their-plugins" } + ] +} +JSON +commit_repo "$mk_repo" +out="$(bash "$SCRIPT" "$mk_repo")" +assert_contains "install: a remote source is an edge" "$out" '"to":"vendor-org/their-plugins"' +assert_contains "install: it is typed as installs-plugin" "$out" '"type":"installs-plugin"' +assert_not_contains "install: a local source is this repository's own component" "$out" '"to":"./plugins' +assert_not_contains "install: no edge is invented from the local path" "$out" 'local-one' + +# --- Case group 3: depends-on ----------------------------------------------- +go_repo="$(make_repo service)" +cat >"$go_repo/go.mod" <<'GOMOD' +module github.com/fixture-owner/service + +go 1.23 + +require github.com/spf13/cobra v1.8.1 +GOMOD +commit_repo "$go_repo" +out="$(bash "$SCRIPT" "$go_repo")" +e="$(edge "$out" spf13/cobra depends-on)" +assert_equals "go: the required module is a depends-on edge" "$(field "$e" to)" "spf13/cobra" +assert_not_contains "go: the module's own path is not a self-edge" "$out" '"to":"fixture-owner/service"' + +# --- Case group 4: cites, and what is NOT cited ----------------------------- +docs_repo="$(make_repo handbook)" +mkdir -p "$docs_repo/docs" +cat >"$docs_repo/docs/notes.md" <<'MD' +Our conventions live in fixture-owner/standards and we mirror +fixture-owner/standards again here. + +See for the CLI, and + for the API. + +Sponsor us at . + +A usage example: `git clone https://github.com/owner/repo.git`. + +An unrelated third party's bare token, other-org/their-thing, is not a link. +MD +commit_repo "$docs_repo" +out="$(bash "$SCRIPT" "$docs_repo")" +e="$(edge "$out" fixture-owner/standards cites)" +assert_equals "cites: a bare same-owner token is an edge" "$(field "$e" to)" "fixture-owner/standards" +assert_equals "cites: repeated references are counted, not de-duplicated per file" "$(field "$e" count)" "2" +assert_contains "cites: a github.com URL is an edge" "$out" '"to":"anthropics/claude-code"' +assert_not_contains "cites: a docs.github.com path is not an owner" "$out" '"to":"en/' +assert_not_contains "cites: a sponsors URL is not a repository" "$out" '"to":"sponsors/' +assert_not_contains "cites: a documentation placeholder is not a repository" "$out" '"to":"owner/repo"' +assert_not_contains "cites: a bare token from another owner is not trusted" "$out" 'other-org/their-thing' + +# --- Case group 5: fixtures and self-references ----------------------------- +noise_repo="$(make_repo charted)" +mkdir -p "$noise_repo/plugins/thing/evals" "$noise_repo/docs" +cat >"$noise_repo/plugins/thing/audit.test.sh" <<'SH' +# fixture data: acme-tools/api and Gone/Away and fixture-owner/ghost +SH +cat >"$noise_repo/plugins/thing/evals/evals.json" <<'JSON' +{ "note": "see fixture-owner/eval-only for the shape" } +JSON +cat >"$noise_repo/docs/real.md" <<'MD' +We depend on fixture-owner/ci-workflows. +This repository is Fixture-Owner/Charted, and also fixture-owner/charted. +MD +commit_repo "$noise_repo" +out="$(bash "$SCRIPT" "$noise_repo")" +assert_contains "noise: a real doc reference is still charted" "$out" '"to":"fixture-owner/ci-workflows"' +assert_not_contains "noise: a .test.sh fixture name is not a system" "$out" 'Gone/Away' +assert_not_contains "noise: a test-file same-owner token is not a system" "$out" 'fixture-owner/ghost' +assert_not_contains "noise: an evals fixture is not a system" "$out" 'fixture-owner/eval-only' +assert_not_contains "noise: the repository does not cite itself" "$out" '"to":"fixture-owner/charted"' +assert_not_contains "noise: nor in another case" "$out" '"to":"Fixture-Owner/Charted"' + +# --- Case group 6: the .git suffix ------------------------------------------ +clone_repo="$(make_repo cloner)" +mkdir -p "$clone_repo/docs" +cat >"$clone_repo/docs/setup.md" <<'MD' +Clone to get started. +MD +commit_repo "$clone_repo" +out="$(bash "$SCRIPT" "$clone_repo")" +assert_contains "clone: the .git suffix is not part of the repository name" "$out" '"to":"vendor-org/toolkit"' +assert_not_contains "clone: and the suffixed form is not emitted" "$out" 'toolkit.git' + +# --- Case group 7: --owner overrides the remote ----------------------------- +out="$(bash "$SCRIPT" "$docs_repo" --owner other-org)" +assert_contains "owner: the override trusts that owner's bare tokens" "$out" '"to":"other-org/their-thing"' +assert_contains "owner: and marks them internal" "$out" '"relation":"internal"' +out="$(bash "$SCRIPT" "$docs_repo" --owner=other-org)" +assert_contains "owner: the = spelling works too" "$out" '"to":"other-org/their-thing"' + +# --- Case group 8: a repository with nothing to say ------------------------- +quiet_repo="$(make_repo quiet)" +printf 'Nothing references anything here.\n' >"$quiet_repo/README.md" +commit_repo "$quiet_repo" +out="$(bash "$SCRIPT" "$quiet_repo")" +rc=$? +assert_equals "quiet: no edges is exit 0, not an error" "$rc" "0" +assert_equals "quiet: and emits nothing" "$out" "" + +# --- Case group 9: usage and bad paths -------------------------------------- +bash "$SCRIPT" >/dev/null 2>&1 +assert_equals "usage: no arguments exits 2" "$?" "2" + +bash "$SCRIPT" "$TEST_TMPDIR/does-not-exist" >/dev/null 2>&1 +assert_equals "usage: a missing path exits 1" "$?" "1" + +plain_dir="$TEST_TMPDIR/not-a-repo" +mkdir -p "$plain_dir" +bad_out="$(bash "$SCRIPT" "$plain_dir" 2>&1)" +assert_equals "usage: a non-git directory exits 1" "$?" "1" +assert_contains "usage: and says why" "$bad_out" "not a git repository" + +bash "$SCRIPT" "$quiet_repo" --owner >/dev/null 2>&1 +assert_equals "usage: --owner without a value exits 2" "$?" "2" + +bash "$SCRIPT" "$quiet_repo" "$docs_repo" >/dev/null 2>&1 +assert_equals "usage: two repository paths exit 2" "$?" "2" + +printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" +[[ "$FAILED" -eq 0 ]] || exit 1 +exit 0 From e2de646a80a60bfe70d80e5d634c2f50aa8494ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 17:03:52 +0000 Subject: [PATCH 06/15] docs(architecture): draw the reference graph in the landscape The landscape showed one node because the skill can only relate repositories that are checked out locally, and none of this repository's siblings are. With the edge extractor those references become edges, so the diagram now carries the seven same-owner systems this repository names most, the two most-referenced external ones, and a typed, counted relationship for each. The omissions are stated rather than silently dropped: five same-owner repositories referenced once or twice, and sixty-three further external ones that are tool and documentation citations rather than related systems. Node descriptions are marked as annotations, since the extractor reports that a repository is referenced and how, never what it is for. Refs #4035 Refs #4033 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape.md | 55 ++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/architecture/landscape.md b/docs/architecture/landscape.md index f15b9b12b3..899c416c8b 100644 --- a/docs/architecture/landscape.md +++ b/docs/architecture/landscape.md @@ -1,21 +1,52 @@ # System Landscape -Generated on 2026-09-10 from an explicit repository list (`--repos`). Every fact -below traces to the file named in `portfolio.md`; nothing was fetched, and the -date reflects the local HEAD of each checkout. +Generated on 2026-09-10 from this repository plus its reference graph, one hop +out. Facts come from `portfolio-facts.sh`; edges come from `reference-edges.sh`, +which reads tracked files only. Nothing was fetched, so a stale checkout reports +a stale date and a repository this one never names does not appear at all. + +Every edge is typed by the syntax that carries it and labelled with how many +references support it. `uses-workflow` is a workflow step this repository runs. +`cites` is the weakest type: a mention in prose, configuration, or a rule. ```mermaid C4Context title System Landscape - System(claude_code_plugins, "claude-code-plugins", "shell; tooling: node, python") + Enterprise_Boundary(b0, "melodic-software") { + System(claude_code_plugins, "claude-code-plugins", "shell; tooling: node, python") + System(ci_workflows, "ci-workflows", "reusable CI workflows") + System(standards, "standards", "engineering conventions") + System(github_iac, "github-iac", "GitHub configuration as code") + System(medley, "medley", "application") + System(provisioning, "provisioning", "machine provisioning") + System(dotfiles, "dotfiles", "developer environment") + } + System_Ext(anthropics_claude_code, "anthropics/claude-code", "the CLI these plugins target") + System_Ext(actions_checkout, "actions/checkout", "GitHub Action") + + Rel(claude_code_plugins, ci_workflows, "uses-workflow (24)") + Rel(claude_code_plugins, ci_workflows, "cites (142)") + Rel(claude_code_plugins, standards, "cites (53)") + Rel(claude_code_plugins, github_iac, "cites (18)") + Rel(claude_code_plugins, medley, "cites (17)") + Rel(claude_code_plugins, provisioning, "cites (5)") + Rel(claude_code_plugins, dotfiles, "cites (4)") + Rel(claude_code_plugins, anthropics_claude_code, "cites (37)") + Rel(claude_code_plugins, actions_checkout, "uses-workflow (15)") ``` -One repository, one owner (`melodic-software`), so no enterprise boundary is -drawn and no relationship line exists: an edge needs a fact in one repository -that names another, and there is no second repository here to name. +## What the diagram leaves out + +Five further same-owner repositories are referenced once or twice each and are +omitted to keep the diagram readable: `knowledge-corpus` (2), plus +`runner-policy-runtime`, `miro-mcp`, and `claude-code-plugins-ci` (1 each). + +Sixty-five external repositories are referenced in total. Only the two most +referenced are drawn. The rest are tool and documentation citations rather than +systems this repository relates to, and they belong in the record instead of the +diagram. -This checkout references nineteen other repositories in its tracked files, and -none of them appear above, because the current skill draws an edge only when the -other repository is itself in the charted set. Charting the reference graph is -the redesign tracked in -[#4033](https://github.com/melodic-software/claude-code-plugins/issues/4033). +Node descriptions above are annotations, not extracted facts: the extractor +reports that a repository is referenced and how, never what it is for. Only the +`claude-code-plugins` node carries probe-derived runtime and tooling, because it +is the only repository checked out here. From 35465ae80df9ed27b092b4c56f82d07165166138 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:21:37 +0000 Subject: [PATCH 07/15] feat(architecture): record the landscape and report drift against it A landscape collected fresh on every run has no memory: nothing tells the operator that a system appeared, an edge vanished, or a runtime changed since the last time anyone looked. landscape-record.sh composes the fact and edge collectors into one schema_version 1 record the repository commits, one object per line so review sees a real diff, and compares a fresh collection against a committed one. The drift report names repositories and edges added or removed, fields whose value changed, and cited evidence files that are no longer in the checkout. `path` is excluded from the comparison: it says where a checkout sits on one machine, not what the system is. Exit 3 on drift gives callers a check gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- .../map-landscape/scripts/landscape-record.sh | 386 ++++++++++++++++++ .../scripts/landscape-record.test.sh | 224 ++++++++++ 2 files changed, 610 insertions(+) create mode 100755 plugins/architecture/skills/map-landscape/scripts/landscape-record.sh create mode 100644 plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh new file mode 100755 index 0000000000..88bb481db6 --- /dev/null +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +# Assemble the committed landscape record, and report drift against an earlier one. +# +# WHY. Facts and edges are collected fresh on every run, so without a committed +# record a landscape is a snapshot with no memory: nothing tells the operator +# that a system appeared, an edge vanished, or a runtime changed since the last +# time anyone looked. This script is that memory. It composes the two collectors +# into one file the repository commits, and it compares a fresh collection with +# the committed one so a re-run reports what moved instead of silently +# overwriting the answer. +# +# Usage: +# landscape-record.sh [options] ... +# landscape-record.sh --help +# +# Options: +# --edges-from Repository whose tracked files supply the edges. +# Defaults to the first . +# --owner Passed to the edge extractor; decides internal vs +# external. Defaults to the edges-from origin owner. +# --source Discovery source recorded verbatim in the record. +# --remote Remote-facts status recorded verbatim in the record. +# --drift-against Compare the fresh collection with committed record . +# Prints a drift report instead of the record. +# +# Output without --drift-against: the record on stdout. +# +# { +# "schema_version": 1, +# "generated_on": "YYYY-MM-DD", +# "discovery_source": "…", +# "remote": "…", +# "repositories": [ ], +# "edges": [ ] +# } +# +# One object per line is deliberate: it keeps the record diffable in review and +# parseable here without a JSON library. +# +# Output with --drift-against: a plain-text report naming repositories added or +# removed, edges added or removed, facts whose value changed, and cited evidence +# files that no longer exist. `path` is excluded from fact comparison because it +# records where a checkout happens to sit on one machine, which is not a fact +# about the architecture. +# +# Nothing here fetches and nothing is written: the record goes to stdout, and the +# caller decides where it lands. +# +# Portability: bash plus POSIX awk/grep/sed. No jq, no `grep -P`, no python. +# +# Exit: 0 = record emitted, or compared with no drift; 1 = a path is not a +# readable git repository, or the compared record is unreadable or not +# schema_version 1; 2 = usage; 3 = drift found. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FACTS="$SCRIPT_DIR/portfolio-facts.sh" +EDGES="$SCRIPT_DIR/reference-edges.sh" + +usage() { + sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//; $d' +} + +die() { + printf 'landscape-record.sh: %s\n' "$1" >&2 + exit "$2" +} + +# --- Arguments -------------------------------------------------------------- + +repos=() +edges_from="" +owner="" +source_text="explicit list" +remote_text="not used" +compare_to="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + usage + exit 0 + ;; + --edges-from) + [[ $# -ge 2 ]] || die "--edges-from needs a path" 2 + edges_from="$2" + shift 2 + ;; + --edges-from=*) + edges_from="${1#--edges-from=}" + shift + ;; + --owner) + [[ $# -ge 2 ]] || die "--owner needs a value" 2 + owner="$2" + shift 2 + ;; + --owner=*) + owner="${1#--owner=}" + shift + ;; + --source) + [[ $# -ge 2 ]] || die "--source needs a value" 2 + source_text="$2" + shift 2 + ;; + --source=*) + source_text="${1#--source=}" + shift + ;; + --remote) + [[ $# -ge 2 ]] || die "--remote needs a value" 2 + remote_text="$2" + shift 2 + ;; + --remote=*) + remote_text="${1#--remote=}" + shift + ;; + --drift-against) + [[ $# -ge 2 ]] || die "--drift-against needs a path" 2 + compare_to="$2" + shift 2 + ;; + --drift-against=*) + compare_to="${1#--drift-against=}" + shift + ;; + -*) + die "unknown option: $1" 2 + ;; + *) + repos+=("$1") + shift + ;; + esac +done + +[[ "${#repos[@]}" -gt 0 ]] || { + usage >&2 + exit 2 +} +[[ -x "$FACTS" || -r "$FACTS" ]] || die "collector not found: $FACTS" 1 +[[ -x "$EDGES" || -r "$EDGES" ]] || die "collector not found: $EDGES" 1 + +[[ -n "$edges_from" ]] || edges_from="${repos[0]}" +[[ -d "$edges_from" ]] || die "not a directory: $edges_from" 1 + +# --- Collect ---------------------------------------------------------------- + +facts_out="$(bash "$FACTS" "${repos[@]}")" || die "fact collection failed" 1 + +edge_args=("$edges_from") +[[ -n "$owner" ]] && edge_args+=(--owner "$owner") +edges_out="$(bash "$EDGES" "${edge_args[@]}")" || die "edge extraction failed" 1 + +# --- Emit ------------------------------------------------------------------- + +# One top-level key/value split, shared by emission and comparison. Walks the +# object rather than matching a pattern, so a value carrying a brace, a comma or +# an escaped quote does not split the record in the wrong place. +read -r -d '' SPLIT_AWK <<'AWK' || true +function split_object(line, keys, vals, i, n, c, k, v, depth, instr, esc, start) { + n = 0 + i = index(line, "{") + if (i == 0) return 0 + i++ + while (i <= length(line)) { + c = substr(line, i, 1) + if (c == " " || c == ",") { i++; continue } + if (c == "}") break + if (c != "\"") return n + i++ + start = i + while (i <= length(line)) { + c = substr(line, i, 1) + if (c == "\\") { i += 2; continue } + if (c == "\"") break + i++ + } + k = substr(line, start, i - start) + i++ + while (substr(line, i, 1) == " " || substr(line, i, 1) == ":") i++ + start = i + c = substr(line, i, 1) + if (c == "\"") { + i++ + while (i <= length(line)) { + c = substr(line, i, 1) + if (c == "\\") { i += 2; continue } + if (c == "\"") break + i++ + } + i++ + } else if (c == "[" || c == "{") { + depth = 0 + instr = 0 + while (i <= length(line)) { + c = substr(line, i, 1) + if (instr) { + if (c == "\\") { i += 2; continue } + if (c == "\"") instr = 0 + } else if (c == "\"") { + instr = 1 + } else if (c == "[" || c == "{") { + depth++ + } else if (c == "]" || c == "}") { + depth-- + if (depth == 0) { i++; break } + } + i++ + } + } else { + while (i <= length(line) && substr(line, i, 1) != "," && substr(line, i, 1) != "}") i++ + } + v = substr(line, start, i - start) + n++ + keys[n] = k + vals[n] = v + } + return n +} +function field(line, want, keys, vals, n, i) { + n = split_object(line, keys, vals) + for (i = 1; i <= n; i++) if (keys[i] == want) return vals[i] + return "" +} +function unquote(v) { + if (substr(v, 1, 1) == "\"") return substr(v, 2, length(v) - 2) + return v +} +AWK + +emit_array() { + # $1 the JSON key, $2 the newline-separated object lines, $3 the trailing + # comma ("," for every array but the last). + if [[ -z "$2" ]]; then + printf ' "%s": []%s\n' "$1" "$3" + return + fi + printf ' "%s": [\n' "$1" + printf '%s\n' "$2" | awk ' + NF { lines[++n] = $0 } + END { + for (i = 1; i <= n; i++) printf " %s%s\n", lines[i], (i < n ? "," : "") + } + ' + printf ' ]%s\n' "$3" +} + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +if [[ -z "$compare_to" ]]; then + printf '{\n' + printf ' "schema_version": 1,\n' + printf ' "generated_on": "%s",\n' "$(date -u +%Y-%m-%d)" + printf ' "discovery_source": "%s",\n' "$(json_escape "$source_text")" + printf ' "remote": "%s",\n' "$(json_escape "$remote_text")" + emit_array repositories "$facts_out" "," + emit_array edges "$edges_out" "" + printf '}\n' + exit 0 +fi + +# --- Drift ------------------------------------------------------------------ + +[[ -r "$compare_to" ]] || die "cannot read record: $compare_to" 1 +grep -q '"schema_version"[[:space:]]*:[[:space:]]*1' "$compare_to" || + die "not a schema_version 1 record: $compare_to" 1 + +# The committed record's own arrays, one object per line, recovered by shape: +# a repository object opens with "name", an edge object with "from". +old_repos="$(sed -n 's/^[[:space:]]*\({"name":.*}\),\{0,1\}$/\1/p' "$compare_to")" +old_edges="$(sed -n 's/^[[:space:]]*\({"from":.*}\),\{0,1\}$/\1/p' "$compare_to")" + +drift=0 +report="" +say() { + report="$report$1"$'\n' + drift=1 +} + +compare_set() { + # $1 label, $2 key expression, $3 old lines, $4 new lines + local label="$1" keyexpr="$2" + local old_keys new_keys + # shellcheck disable=SC2016 # an awk program: $0 belongs to awk, not the shell. + local keyprog=' + NF { + n = split(e, parts, ",") + k = "" + for (i = 1; i <= n; i++) k = k (i > 1 ? "\t" : "") unquote(field($0, parts[i])) + print k + } + ' + old_keys="$(printf '%s\n' "$3" | awk -v e="$keyexpr" "$SPLIT_AWK$keyprog" | sort)" + new_keys="$(printf '%s\n' "$4" | awk -v e="$keyexpr" "$SPLIT_AWK$keyprog" | sort)" + local line + while IFS= read -r line; do + [[ -n "$line" ]] || continue + printf '%s\n' "$new_keys" | grep -qxF "$line" || + say " removed $label: ${line//$'\t'/ }" + done <<<"$old_keys" + while IFS= read -r line; do + [[ -n "$line" ]] || continue + printf '%s\n' "$old_keys" | grep -qxF "$line" || + say " added $label: ${line//$'\t'/ }" + done <<<"$new_keys" +} + +report="$report"'Landscape drift, fresh collection versus '"$compare_to"$'\n' + +compare_set repository "name" "$old_repos" "$facts_out" +compare_set edge "to,type" "$old_edges" "$edges_out" + +# Field-level comparison for records present in both collections. `path` is +# skipped: it says where a checkout sits on one machine, not what the system is. +compare_fields() { + # $1 label, $2 identity key, $3 old lines, $4 new lines + local label="$1" idkey="$2" new_line id old_line changed c + while IFS= read -r new_line; do + [[ -n "$new_line" ]] || continue + id="$(printf '%s' "$new_line" | awk -v k="$idkey" "$SPLIT_AWK"'{ print unquote(field($0, k)) }')" + old_line="$(printf '%s\n' "$3" | grep -F "\"$idkey\":\"$id\"" | head -1)" + [[ -n "$old_line" ]] || continue + changed="$(printf '%s\n%s\n' "$old_line" "$new_line" | awk "$SPLIT_AWK"' + NR == 1 { on = split_object($0, ok, ov) } + NR == 2 { nn = split_object($0, nk, nv) + for (i = 1; i <= nn; i++) { + if (nk[i] == "path") continue + for (j = 1; j <= on; j++) { + if (ok[j] == nk[i]) { + if (ov[j] != nv[i]) printf "%s: %s -> %s\n", nk[i], ov[j], nv[i] + break + } + } + } + } + ')" + while IFS= read -r c; do + [[ -n "$c" ]] || continue + say " changed $label on $id: $c" + done <<<"$changed" + done <<<"$4" +} + +compare_fields fact name "$old_repos" "$facts_out" + +# An edge is identified by its target and type together, so the field pass runs +# per type rather than collapsing two edges to the same repository into one. +for t in uses-workflow installs-plugin depends-on cites; do + compare_fields "edge ($t)" to \ + "$(printf '%s\n' "$old_edges" | grep -F "\"type\":\"$t\"")" \ + "$(printf '%s\n' "$edges_out" | grep -F "\"type\":\"$t\"")" +done + +# Evidence the COMMITTED record cites that is no longer in the checkout. Reading +# the committed side is the point: a fresh collection can only ever cite files +# that exist, so checking it would find nothing by construction. +missing="$(printf '%s\n' "$old_edges" | awk "$SPLIT_AWK"' + NF { + to = unquote(field($0, "to")) + files = field($0, "files") + gsub(/^\[|\]$/, "", files) + n = split(files, parts, "\",\"") + for (i = 1; i <= n; i++) { + f = parts[i] + gsub(/^"|"$/, "", f) + if (f != "") print to "\t" f + } + } +' | sort -u)" +while IFS=$'\t' read -r to f; do + [[ -n "$f" ]] || continue + [[ -e "$edges_from/$f" ]] || say " missing evidence for $to: $f" +done <<<"$missing" + +if [[ "$drift" -eq 0 ]]; then + printf 'Landscape drift: none. The committed record matches a fresh collection.\n' + exit 0 +fi + +printf '%s' "$report" +exit 3 diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh new file mode 100644 index 0000000000..8f2c280cef --- /dev/null +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Self-contained tests for landscape-record.sh (skill-script shape, per +# docs/conventions/shell-test-helpers/README.md: per-plugin assertion +# primitives are duplicated on purpose, never shared across plugins). +# +# Every fixture is built in a mktemp directory and torn down on exit; nothing +# here reads or writes a real repository. +set -uo pipefail + +# Isolate the fixture repositories from any ambient git environment: `git -C` +# changes directory but does not override discovery, so an exported GIT_DIR +# would land these throwaway identities in the CALLER's .git/config. +unset GIT_DIR GIT_WORK_TREE GIT_CONFIG + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/landscape-record.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3 + actual: $2" ;; + esac +} +assert_not_contains() { + case "$2" in + *"$3"*) fail "$1" "unexpected substring: $3 + actual: $2" ;; + *) pass "$1" ;; + esac +} +assert_equals() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected [$3], got [$2]"; fi +} + +make_repo() { + local dir="$TEST_TMPDIR/$1" + mkdir -p "$dir" + git -C "$dir" init --quiet 2>/dev/null + git -C "$dir" config user.email "fixture@example.invalid" + git -C "$dir" config user.name "Fixture" + git -C "$dir" config commit.gpgsign false + git -C "$dir" config core.autocrlf false + git -C "$dir" remote add origin "https://github.com/fixture-owner/$1.git" + printf '%s' "$dir" +} + +commit_repo() { + git -C "$1" add -A 2>/dev/null + git -C "$1" commit --quiet --no-verify -m "fixture" 2>/dev/null +} + +if ! command -v git >/dev/null 2>&1; then + echo "SKIP: git not installed" >&2 + exit 0 +fi + +# --- The fixture repository ------------------------------------------------- +repo="$(make_repo hub)" +mkdir -p "$repo/.github/workflows" "$repo/docs" +cat >"$repo/.github/workflows/ci.yml" <<'YML' +jobs: + build: + uses: fixture-owner/ci-workflows/.github/workflows/build.yml@v2 +YML +cat >"$repo/docs/notes.md" <<'MD' +Conventions live in fixture-owner/standards. +MD +printf 'echo hi\n' >"$repo/run.sh" +commit_repo "$repo" + +# --- Case group 1: the record's shape --------------------------------------- +out="$(bash "$SCRIPT" "$repo")" +rc=$? +assert_equals "record: a clean build exits 0" "$rc" "0" +assert_contains "record: it declares schema_version 1" "$out" '"schema_version": 1' +assert_contains "record: it carries a generated-on date" "$out" '"generated_on": "' +assert_contains "record: the discovery source defaults to the explicit list" "$out" '"discovery_source": "explicit list"' +assert_contains "record: remote is off unless asked for" "$out" '"remote": "not used"' +assert_contains "record: the repository's facts are embedded whole" "$out" '{"name":"hub",' +assert_contains "record: so are the edges" "$out" '"to":"fixture-owner/ci-workflows"' +assert_contains "record: including the weaker cites edge" "$out" '"to":"fixture-owner/standards"' + +# One object per line keeps the record diffable and parseable without a JSON +# library, so the shape itself is asserted rather than left to chance. +obj_lines="$(printf '%s\n' "$out" | grep -c '^ {')" +assert_equals "record: every array element sits on its own line" "$obj_lines" "4" + +if command -v node >/dev/null 2>&1; then + printf '%s\n' "$out" >"$TEST_TMPDIR/parse.json" + node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$TEST_TMPDIR/parse.json" 2>/dev/null + assert_equals "record: it parses as JSON" "$?" "0" +else + pass "record: JSON parse skipped, node not installed" +fi + +# --- Case group 2: the recorded provenance is what the caller said ---------- +out="$(bash "$SCRIPT" "$repo" --source "current repository plus reference graph" --remote "used, owned only")" +assert_contains "provenance: --source is recorded verbatim" "$out" '"discovery_source": "current repository plus reference graph"' +assert_contains "provenance: --remote is recorded verbatim" "$out" '"remote": "used, owned only"' +out="$(bash "$SCRIPT" "$repo" --source=inline --remote=inline)" +assert_contains "provenance: the = spelling works too" "$out" '"discovery_source": "inline"' + +# --- Case group 3: a repository with nothing to relate ---------------------- +quiet="$(make_repo quiet)" +printf 'Nothing references anything here.\n' >"$quiet/README.md" +commit_repo "$quiet" +out="$(bash "$SCRIPT" "$quiet")" +assert_contains "empty: an edgeless repository still records its facts" "$out" '{"name":"quiet",' +assert_contains "empty: and an empty edge array, not a missing key" "$out" '"edges": []' + +# --- Case group 4: --edges-from picks the citing repository ----------------- +out="$(bash "$SCRIPT" "$quiet" "$repo" --edges-from "$repo")" +assert_contains "edges-from: facts cover both repositories" "$out" '{"name":"quiet",' +assert_contains "edges-from: and edges come from the named one" "$out" '"from":"hub"' +out="$(bash "$SCRIPT" "$quiet" "$repo")" +assert_contains "edges-from: it defaults to the first path" "$out" '"edges": []' + +# --- Case group 5: --owner reaches the edge extractor ----------------------- +out="$(bash "$SCRIPT" "$repo" --owner other-org)" +assert_contains "owner: an override marks a same-name target external" "$out" '"relation":"external"' +assert_not_contains "owner: and no longer trusts the old owner's bare tokens" "$out" '"to":"fixture-owner/standards"' + +# --- Case group 6: drift against an identical record ------------------------ +bash "$SCRIPT" "$repo" >"$TEST_TMPDIR/committed.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/committed.json")" +rc=$? +assert_equals "drift: an unchanged repository exits 0" "$rc" "0" +assert_contains "drift: and says so plainly" "$out" "none" + +# --- Case group 7: drift the caller must see -------------------------------- +sed 's|"fixture-owner/ci-workflows"|"fixture-owner/gone-away"|' \ + "$TEST_TMPDIR/committed.json" >"$TEST_TMPDIR/edge-drift.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/edge-drift.json")" +rc=$? +assert_equals "drift: a changed edge exits 3, the check-failure code" "$rc" "3" +assert_contains "drift: the vanished edge is named" "$out" "removed edge: fixture-owner/gone-away" +assert_contains "drift: so is the new one" "$out" "added edge: fixture-owner/ci-workflows" + +sed 's|"runtime":"shell"|"runtime":"rust"|' \ + "$TEST_TMPDIR/committed.json" >"$TEST_TMPDIR/fact-drift.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/fact-drift.json")" +assert_equals "drift: a changed fact exits 3 too" "$?" "3" +assert_contains "drift: it names the field, the old value and the new" "$out" 'changed fact on hub: runtime: "rust" -> "shell"' + +sed 's|"path":"[^"]*"|"path":"/somewhere/else"|' \ + "$TEST_TMPDIR/committed.json" >"$TEST_TMPDIR/path-drift.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/path-drift.json")" +assert_equals "drift: a moved checkout is not architecture drift" "$?" "0" + +# A repository that left the record, and one that joined it. +out="$(bash "$SCRIPT" "$repo" "$quiet" --edges-from "$repo" \ + --drift-against "$TEST_TMPDIR/committed.json")" +assert_equals "drift: a new repository exits 3" "$?" "3" +assert_contains "drift: and is named as added" "$out" "added repository: quiet" + +bash "$SCRIPT" "$repo" "$quiet" --edges-from "$repo" >"$TEST_TMPDIR/two.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/two.json")" +assert_contains "drift: a dropped repository is named as removed" "$out" "removed repository: quiet" + +# --- Case group 8: evidence that no longer exists --------------------------- +rm "$repo/docs/notes.md" +commit_repo "$repo" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/committed.json")" +assert_equals "evidence: a deleted citation is drift" "$?" "3" +assert_contains "evidence: the edge it supported is named as removed" "$out" "removed edge: fixture-owner/standards" + +# A record whose cited file is gone while the edge survives: the report says so +# rather than leaving a dangling citation in a committed artifact. +ev_repo="$(make_repo evidence)" +mkdir -p "$ev_repo/docs" +printf 'We use fixture-owner/toolkit here.\n' >"$ev_repo/docs/a.md" +printf 'And fixture-owner/toolkit here too.\n' >"$ev_repo/docs/b.md" +commit_repo "$ev_repo" +bash "$SCRIPT" "$ev_repo" >"$TEST_TMPDIR/ev.json" +rm "$ev_repo/docs/b.md" +commit_repo "$ev_repo" +out="$(bash "$SCRIPT" "$ev_repo" --drift-against "$TEST_TMPDIR/ev.json")" +assert_not_contains "evidence: an edge still cited by one file survives" "$out" "removed edge: fixture-owner/toolkit" +assert_contains "evidence: but the count change is reported" "$out" "changed" + +# --- Case group 9: a record this script will not compare against ------------ +printf '{"schema_version": 2, "repositories": [], "edges": []}\n' >"$TEST_TMPDIR/v2.json" +bad="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/v2.json" 2>&1)" +assert_equals "schema: an unknown version exits 1" "$?" "1" +assert_contains "schema: and says which version it wanted" "$bad" "schema_version 1" + +bad="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/absent.json" 2>&1)" +assert_equals "schema: an unreadable record exits 1" "$?" "1" + +# --- Case group 10: usage --------------------------------------------------- +bash "$SCRIPT" >/dev/null 2>&1 +assert_equals "usage: no repository path exits 2" "$?" "2" + +bash "$SCRIPT" "$repo" --edges-from >/dev/null 2>&1 +assert_equals "usage: --edges-from without a value exits 2" "$?" "2" + +bash "$SCRIPT" "$repo" --nonsense >/dev/null 2>&1 +assert_equals "usage: an unknown option exits 2" "$?" "2" + +bash "$SCRIPT" "$repo" --edges-from "$TEST_TMPDIR/nowhere" >/dev/null 2>&1 +assert_equals "usage: an --edges-from path that is not there exits 1" "$?" "1" + +help_out="$(bash "$SCRIPT" --help 2>&1)" +assert_equals "usage: --help exits 0" "$?" "0" +assert_contains "usage: and describes the record" "$help_out" "schema_version" + +printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" +[[ "$FAILED" -eq 0 ]] || exit 1 +exit 0 From 8f55c30c37d9858dd267c4da818bc7ec466be1af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:21:45 +0000 Subject: [PATCH 08/15] feat(architecture): render both landscape dialects from the record Table rows, dependency truncation, diagram alias sanitising, boundary grouping and node labels are all mechanical, and rendering them by hand made two runs on identical facts produce different files. render-landscape.sh reads the record and writes landscape.md (mermaid C4Context) or landscape.dsl (Structurizr systemLandscape) plus portfolio.md, doing only work the record decides. Internal systems sort by owner first so each owner forms one contiguous boundary; externals sort by reference count so --top-external keeps the ones that carry the most evidence, and the remainder is counted rather than dropped silently. Prose comes from an annotations file this script only appends. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- .../map-landscape/scripts/render-landscape.sh | 528 ++++++++++++++++++ .../scripts/render-landscape.test.sh | 261 +++++++++ 2 files changed, 789 insertions(+) create mode 100755 plugins/architecture/skills/map-landscape/scripts/render-landscape.sh create mode 100644 plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh new file mode 100755 index 0000000000..c4b59652db --- /dev/null +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh @@ -0,0 +1,528 @@ +#!/usr/bin/env bash +# Render the landscape artifacts from the committed record. +# +# WHY. Table rows, dependency truncation, diagram alias sanitising, boundary +# grouping and node labels are all mechanical, and rendering them by hand makes +# two runs on identical facts produce different files. Everything this script +# does is decided by the record: nothing here weighs, judges, or describes. Prose +# comes from the annotations file, written by a person or a model, and this +# script only appends it. +# +# Usage: +# render-landscape.sh --record --out [options] +# render-landscape.sh --help +# +# Options: +# --record The landscape record. Required. +# --out Directory the artifacts are written into. Required. +# --dialect mermaid (default) or structurizr. +# --notes Annotations appended verbatim to the landscape artifact. +# --top-external How many external systems the diagram draws, most +# referenced first (default 5). Every internal system is +# always drawn. The remainder is counted in a line under +# the diagram, and stays in the record and the portfolio. +# +# Writes, into : +# +# landscape.md with --dialect mermaid: a `C4Context` block used without a +# focal system, one `System` per internal repository inside an +# `Enterprise_Boundary` per owner, one `System_Ext` per external +# repository, and one `Rel` per drawn edge. +# landscape.dsl with --dialect structurizr: a `workspace` whose `model` holds +# the same systems, grouped by owner, plus a `systemLandscape` +# view. +# portfolio.md the application-portfolio table, one row per repository in +# the record, sorted by name. +# +# Determinism is the contract: the same record and flags produce byte-identical +# files, so a re-run shows a diff only when the facts moved. +# +# Portability: bash plus POSIX awk/grep/sed. No jq, no `grep -P`, no python. +# +# Exit: 0 = written; 1 = the record is unreadable or not schema_version 1, or +# the output directory does not exist; 2 = usage. +set -uo pipefail + +usage() { + sed -n '2,/^set -uo/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//; $d' +} + +die() { + printf 'render-landscape.sh: %s\n' "$1" >&2 + exit "$2" +} + +record="" +outdir="" +dialect="mermaid" +notes="" +top_external=5 + +while [[ $# -gt 0 ]]; do + case "$1" in + --help | -h) + usage + exit 0 + ;; + --record) + [[ $# -ge 2 ]] || die "--record needs a path" 2 + record="$2" + shift 2 + ;; + --record=*) + record="${1#--record=}" + shift + ;; + --out) + [[ $# -ge 2 ]] || die "--out needs a path" 2 + outdir="$2" + shift 2 + ;; + --out=*) + outdir="${1#--out=}" + shift + ;; + --dialect) + [[ $# -ge 2 ]] || die "--dialect needs a value" 2 + dialect="$2" + shift 2 + ;; + --dialect=*) + dialect="${1#--dialect=}" + shift + ;; + --notes) + [[ $# -ge 2 ]] || die "--notes needs a path" 2 + notes="$2" + shift 2 + ;; + --notes=*) + notes="${1#--notes=}" + shift + ;; + --top-external) + [[ $# -ge 2 ]] || die "--top-external needs a number" 2 + top_external="$2" + shift 2 + ;; + --top-external=*) + top_external="${1#--top-external=}" + shift + ;; + *) + die "unknown argument: $1" 2 + ;; + esac +done + +[[ -n "$record" && -n "$outdir" ]] || { + usage >&2 + exit 2 +} +case "$dialect" in +mermaid | structurizr) ;; +*) die "unknown dialect: $dialect (mermaid or structurizr)" 2 ;; +esac +case "$top_external" in +'' | *[!0-9]*) die "--top-external needs a whole number, got: $top_external" 2 ;; +*) ;; +esac +[[ -r "$record" ]] || die "cannot read record: $record" 1 +[[ -d "$outdir" ]] || die "not a directory: $outdir" 1 +[[ -z "$notes" || -r "$notes" ]] || die "cannot read notes: $notes" 1 +grep -q '"schema_version"[[:space:]]*:[[:space:]]*1' "$record" || + die "not a schema_version 1 record: $record" 1 + +# The top-level key/value split, shared with landscape-record.sh: it walks the +# object rather than matching a pattern, so a value carrying a brace, a comma or +# an escaped quote does not split the record in the wrong place. +read -r -d '' SPLIT_AWK <<'AWK' || true +function split_object(line, keys, vals, i, n, c, k, v, depth, instr, start) { + n = 0 + i = index(line, "{") + if (i == 0) return 0 + i++ + while (i <= length(line)) { + c = substr(line, i, 1) + if (c == " " || c == ",") { i++; continue } + if (c == "}") break + if (c != "\"") return n + i++ + start = i + while (i <= length(line)) { + c = substr(line, i, 1) + if (c == "\\") { i += 2; continue } + if (c == "\"") break + i++ + } + k = substr(line, start, i - start) + i++ + while (substr(line, i, 1) == " " || substr(line, i, 1) == ":") i++ + start = i + c = substr(line, i, 1) + if (c == "\"") { + i++ + while (i <= length(line)) { + c = substr(line, i, 1) + if (c == "\\") { i += 2; continue } + if (c == "\"") break + i++ + } + i++ + } else if (c == "[" || c == "{") { + depth = 0 + instr = 0 + while (i <= length(line)) { + c = substr(line, i, 1) + if (instr) { + if (c == "\\") { i += 2; continue } + if (c == "\"") instr = 0 + } else if (c == "\"") { + instr = 1 + } else if (c == "[" || c == "{") { + depth++ + } else if (c == "]" || c == "}") { + depth-- + if (depth == 0) { i++; break } + } + i++ + } + } else { + while (i <= length(line) && substr(line, i, 1) != "," && substr(line, i, 1) != "}") i++ + } + v = substr(line, start, i - start) + n++ + keys[n] = k + vals[n] = v + } + return n +} +function field(line, want, keys, vals, n, i) { + n = split_object(line, keys, vals) + for (i = 1; i <= n; i++) if (keys[i] == want) return vals[i] + return "" +} +function unquote(v) { + if (substr(v, 1, 1) == "\"") return substr(v, 2, length(v) - 2) + return v +} +# A JSON string array to a plain list, "" when empty. +function arraylist(v, sep, inner, parts, n, i, out) { + gsub(/^\[|\]$/, "", v) + if (v == "") return "" + n = split(v, parts, "\",\"") + out = "" + for (i = 1; i <= n; i++) { + inner = parts[i] + gsub(/^"|"$/, "", inner) + out = out (i > 1 ? sep : "") inner + } + return out +} +function arraycount(v, parts) { + gsub(/^\[|\]$/, "", v) + if (v == "") return 0 + return split(v, parts, "\",\"") +} +AWK + +# --- The shared node and edge model ---------------------------------------- +# +# Both renderers read the same three streams, so a system drawn in one dialect +# is the same system in the other. +# +# nodealiasowner/repodisplayrelationdescriptionowner +# edgefrom-aliasto-aliaslabelrelation +# omitexternal-systems-not-drawn + +model="$(awk -v top="$top_external" "$SPLIT_AWK"' +function alias(s, a) { + a = s + gsub(/[^A-Za-z0-9]/, "_", a) + if (a ~ /^[0-9]/) a = "n_" a + return a +} +function primary(list, parts) { + split(list, parts, ",") + return parts[1] +} +/^[[:space:]]*\{"name":/ { + name = unquote(field($0, "name")) + owner = unquote(field($0, "owner")) + key = (owner == "unknown" || owner == "" ? name : owner "/" name) + run = unquote(field($0, "runtime")) + fw = unquote(field($0, "target_framework")) + desc = primary(run) + if (fw != "unknown" && fw != "") desc = desc ", " fw + if (desc == "unknown" || desc == "") desc = "no probed runtime" + order[++ln] = key + ldesc[key] = desc + ldisp[key] = name + localkey[name] = key + next +} +/^[[:space:]]*\{"from":/ { + from = unquote(field($0, "from")) + to = unquote(field($0, "to")) + type = unquote(field($0, "type")) + rel = unquote(field($0, "relation")) + cnt = unquote(field($0, "count")) + 0 + ++en + efrom[en] = from + etarget[en] = to + elabel[en] = type " (" cnt ")" + erel[en] = rel + if (!(to in seen)) { seen[to] = 1; tos[++tn] = to } + torel[to] = rel + weight[to] += cnt + next +} +END { + # Every locally collected repository is a node, keyed by owner/name. + for (i = 1; i <= ln; i++) { + k = order[i] + isnode[k] = 1 + nrel[k] = "internal" + ndesc[k] = ldesc[k] + ndisp[k] = ldisp[k] + } + # Edge targets that are not local checkouts become nodes with no probed facts. + for (i = 1; i <= tn; i++) { + k = tos[i] + if (k in isnode) continue + isnode[k] = 1 + nrel[k] = torel[k] + ndesc[k] = "not checked out here" + # Inside an owner boundary the owner prefix is redundant, so an internal + # system shows its bare repository name and an external shows owner/repo. + ndisp[k] = k + if (nrel[k] == "internal") { + split(k, oseg, "/") + if (oseg[2] != "") ndisp[k] = oseg[2] + } + } + # Drawn set: every internal node, and the most referenced externals. + ni = 0 + ne = 0 + for (k in isnode) { + if (nrel[k] == "internal") ints[++ni] = k + else exts[++ne] = k + } + # Sort internals by display name, externals by weight then name; both without + # calling out to sort(1), so the ordering here is stable and self-contained. + # Internals sort by owner FIRST so each owner forms one contiguous run and the + # boundary opened for it is never reopened further down the list. + for (i = 1; i <= ni; i++) { + split(ints[i], oseg, "/") + sortkey[ints[i]] = (oseg[2] == "" ? "unknown" : oseg[1]) "\t" ndisp[ints[i]] + } + for (i = 1; i <= ni; i++) + for (j = i + 1; j <= ni; j++) + if (sortkey[ints[j]] < sortkey[ints[i]]) { t = ints[i]; ints[i] = ints[j]; ints[j] = t } + for (i = 1; i <= ne; i++) + for (j = i + 1; j <= ne; j++) { + a = exts[i]; b = exts[j] + if (weight[b] > weight[a] || (weight[b] == weight[a] && b < a)) { exts[i] = b; exts[j] = a } + } + for (i = 1; i <= ni; i++) drawn[ints[i]] = 1 + shown_ext = (top < ne ? top : ne) + for (i = 1; i <= shown_ext; i++) drawn[exts[i]] = 1 + for (i = 1; i <= ni; i++) { + k = ints[i] + split(k, seg, "/") + printf "node\t%s\t%s\t%s\t%s\t%s\t%s\n", alias(k), k, ndisp[k], "internal", ndesc[k], (seg[2] == "" ? "unknown" : seg[1]) + } + for (i = 1; i <= shown_ext; i++) { + k = exts[i] + split(k, seg, "/") + printf "node\t%s\t%s\t%s\t%s\t%s\t%s\n", alias(k), k, ndisp[k], "external", ndesc[k], (seg[2] == "" ? "unknown" : seg[1]) + } + for (i = 1; i <= en; i++) { + if (!(etarget[i] in drawn)) continue + # The edge source names a checkout by basename, so resolve it through the + # map the repository pass built. Iterating the node array instead would put + # the answer at the mercy of the unspecified array order in awk. + fk = localkey[efrom[i]] + if (fk == "" || !(fk in isnode)) continue + printf "edge\t%s\t%s\t%s\t%s\n", alias(fk), alias(etarget[i]), elabel[i], erel[i] + } + printf "omit\t%d\n", ne - shown_ext +} +' "$record")" + +gen_on="$(sed -n 's/^[[:space:]]*"generated_on"[[:space:]]*:[[:space:]]*"\(.*\)".*$/\1/p' "$record" | head -1)" +disco="$(sed -n 's/^[[:space:]]*"discovery_source"[[:space:]]*:[[:space:]]*"\(.*\)".*$/\1/p' "$record" | head -1)" +remote_state="$(sed -n 's/^[[:space:]]*"remote"[[:space:]]*:[[:space:]]*"\(.*\)".*$/\1/p' "$record" | head -1)" +[[ -n "$gen_on" ]] || gen_on="unknown" +[[ -n "$disco" ]] || disco="unknown" +[[ -n "$remote_state" ]] || remote_state="not used" + +omit_ext="$(printf '%s\n' "$model" | awk -F'\t' '$1 == "omit" { print $2 }')" + +# --- Artifact one, the landscape ------------------------------------------- + +if [[ "$dialect" == "mermaid" ]]; then + target="$outdir/landscape.md" + { + printf '# System Landscape\n\n' + printf 'Generated on %s from %s. Remote facts: %s.\n\n' \ + "$gen_on" "$disco" "$remote_state" + printf 'Every fact traces to the file the probe named. Every edge is typed by the\n' + printf 'syntax that carries it and labelled with how many references support it.\n' + printf 'A system with no probed runtime is one this checkout names but does not\n' + printf 'contain.\n\n' + printf '```mermaid\nC4Context\n title System Landscape\n' + printf '%s\n' "$model" | awk -F'\t' ' + $1 == "node" && $5 == "internal" { owners[$7] = 1; io[++n] = $0 } + $1 == "node" && $5 == "external" { eo[++m] = $0 } + END { + b = 0 + for (i = 1; i <= n; i++) { + split(io[i], f, "\t") + if (f[7] != cur) { + if (cur != "") print " }" + printf " Enterprise_Boundary(b%d, \"%s\") {\n", b++, f[7] + cur = f[7] + } + printf " System(%s, \"%s\", \"%s\")\n", f[2], f[4], f[6] + } + if (cur != "") print " }" + for (i = 1; i <= m; i++) { + split(eo[i], f, "\t") + printf " System_Ext(%s, \"%s\", \"%s\")\n", f[2], f[4], f[6] + } + } + ' + printf '\n' + printf '%s\n' "$model" | awk -F'\t' '$1 == "edge" { printf " Rel(%s, %s, \"%s\")\n", $2, $3, $4 }' + printf '```\n' + if [[ "$omit_ext" -gt 0 ]]; then + printf '\n%s external repositories are referenced but not drawn; the record carries\nevery one of them.\n' "$omit_ext" + fi + } >"$target" +else + target="$outdir/landscape.dsl" + { + printf 'workspace {\n model {\n' + printf '%s\n' "$model" | awk -F'\t' ' + $1 == "node" && $5 == "internal" { io[++n] = $0 } + $1 == "node" && $5 == "external" { eo[++m] = $0 } + END { + for (i = 1; i <= n; i++) { + split(io[i], f, "\t") + if (f[7] != cur) { + if (cur != "") print " }" + printf " group \"%s\" {\n", f[7] + cur = f[7] + } + printf " %s = softwareSystem \"%s\" \"%s\"\n", f[2], f[4], f[6] + } + if (cur != "") print " }" + for (i = 1; i <= m; i++) { + split(eo[i], f, "\t") + printf " %s = softwareSystem \"%s\" \"%s\" \"External\"\n", f[2], f[4], f[6] + } + } + ' + printf '%s\n' "$model" | awk -F'\t' '$1 == "edge" { printf " %s -> %s \"%s\"\n", $2, $3, $4 }' + printf ' }\n views {\n systemLandscape "landscape" {\n' + printf ' include *\n autoLayout\n }\n }\n}\n' + } >"$target" +fi + +if [[ -n "$notes" ]]; then + { + printf '\n' + cat "$notes" + } >>"$target" +fi + +# --- Artifact two, the portfolio table ------------------------------------- + +{ + printf '# Application portfolio\n\n' + printf 'Generated on %s from %s. Remote facts: %s.\n\n' \ + "$gen_on" "$disco" "$remote_state" + printf 'Last touched is the local HEAD of each checkout unless a remote fact says\n' + # shellcheck disable=SC2016 # backticks are markdown code spans, not substitution. + printf 'otherwise, so a stale checkout reports a stale date. `unknown` means no probe\n' + printf 'could derive the value.\n\n' + # shellcheck disable=SC2016 # backticks are markdown code spans, not substitution. + printf '`Runtime` and `Dependencies` are runtime scope, what the repository runs on.\n' + # shellcheck disable=SC2016 # backticks are markdown code spans, not substitution. + printf '`Tooling` and the development-scope dependencies below the table are what it\n' + printf 'is built with.\n\n' + printf '| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched |\n' + printf '|---|---|---|---|---|---|---|\n' + awk "$SPLIT_AWK"' + function cell(v) { return (v == "" ? "unknown" : v) } + function deplist(v, n, list, parts, i, out) { + n = arraycount(v) + if (n == 0) return "(none)" + list = arraylist(v, ", ") + if (n <= 10) return list + split(list, parts, ", ") + out = "" + for (i = 1; i <= 10; i++) out = out (i > 1 ? ", " : "") parts[i] + return out " (+" (n - 10) ")" + } + /^[[:space:]]*\{"name":/ { + rows[++n] = sprintf("| %s | %s | %s | %s | %s | %s | %s |", \ + unquote(field($0, "name")), \ + cell(unquote(field($0, "owner"))), \ + cell(unquote(field($0, "target_framework"))), \ + cell(gensub_commas(unquote(field($0, "runtime")))), \ + deplist(field($0, "dependencies")), \ + cell(gensub_commas(unquote(field($0, "tooling")))), \ + cell(unquote(field($0, "last_touched")))) + keys[n] = unquote(field($0, "name")) + } + function gensub_commas(v) { gsub(/,/, ", ", v); return v } + END { + for (i = 1; i <= n; i++) + for (j = i + 1; j <= n; j++) + if (keys[j] < keys[i]) { + t = keys[i]; keys[i] = keys[j]; keys[j] = t + t = rows[i]; rows[i] = rows[j]; rows[j] = t + } + for (i = 1; i <= n; i++) print rows[i] + } + ' "$record" + # Development-scope dependencies stay out of the table: they are the longest + # list in the record and would push every other column off the page. + dev_lines="$(awk "$SPLIT_AWK"' + function deplist(v, n, list, parts, i, out) { + n = arraycount(v) + if (n == 0) return "" + list = arraylist(v, "`, `") + if (n <= 10) return "`" list "`" + split(list, parts, "`, `") + out = "" + for (i = 1; i <= 10; i++) out = out (i > 1 ? ", " : "") "`" parts[i] "`" + gsub(/`+/, "`", out) + return out " (+" (n - 10) ")" + } + /^[[:space:]]*\{"name":/ { + d = deplist(field($0, "dev_dependencies")) + if (d != "") rows[++n] = sprintf("- %s: %s", unquote(field($0, "name")), d) + } + END { for (i = 1; i <= n; i++) print rows[i] } + ' "$record")" + if [[ -n "$dev_lines" ]]; then + printf '\n## Development-scope dependencies\n\n' + printf 'Truncated to ten per repository; the record carries the full list.\n\n' + printf '%s\n' "$dev_lines" + fi + printf '\n## Evidence\n\n' + printf '| Repository | Fact | Source |\n|---|---|---|\n' + awk "$SPLIT_AWK"' + /^[[:space:]]*\{"name":/ { + name = unquote(field($0, "name")) + ev = field($0, "evidence") + n = split_object(ev, k, v) + for (i = 1; i <= n; i++) printf "| %s | %s | %s |\n", name, k[i], unquote(v[i]) + } + ' "$record" +} >"$outdir/portfolio.md" + +exit 0 diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh new file mode 100644 index 0000000000..e71264b2a8 --- /dev/null +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# Self-contained tests for render-landscape.sh (skill-script shape, per +# docs/conventions/shell-test-helpers/README.md: per-plugin assertion +# primitives are duplicated on purpose, never shared across plugins). +# +# The fixture record is written here rather than collected, so the renderer is +# tested against a fixed input and every assertion is about rendering alone. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT="$SCRIPT_DIR/render-landscape.sh" +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +FAILED=0 +CASE_NUM=0 + +pass() { + CASE_NUM=$((CASE_NUM + 1)) + printf 'PASS: %s\n' "$1" +} +fail() { + CASE_NUM=$((CASE_NUM + 1)) + FAILED=$((FAILED + 1)) + printf 'FAIL: %s\n detail: %s\n' "$1" "$2" >&2 +} +assert_contains() { + case "$2" in + *"$3"*) pass "$1" ;; + *) fail "$1" "expected to contain: $3 + actual: $2" ;; + esac +} +assert_not_contains() { + case "$2" in + *"$3"*) fail "$1" "unexpected substring: $3 + actual: $2" ;; + *) pass "$1" ;; + esac +} +assert_equals() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected [$3], got [$2]"; fi +} + +render() { + # $1 output subdirectory, rest passed through + local dir="$TEST_TMPDIR/$1" + shift + rm -rf "$dir" + mkdir -p "$dir" + bash "$SCRIPT" --out "$dir" "$@" +} + +# --- The fixture record ----------------------------------------------------- +# +# Two checked-out repositories under one owner, an edge to a third repository +# under the same owner that is NOT checked out, and two external targets whose +# reference counts differ. Hyphens and dots in names exercise alias sanitising. +cat >"$TEST_TMPDIR/record.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "current repository plus reference graph", + "remote": "not used", + "repositories": [ + {"name":"web-ui","path":"/srv/web-ui","remote":"https://github.com/acme/web-ui","owner":"acme","runtime":"node","tooling":"node","target_framework":">=22","dependencies":["left-pad","undici"],"dev_dependencies":["vitest"],"last_touched":"2026-01-01T00:00:00+00:00","evidence":{"owner":"origin remote URL","runtime":"node: package.json"}}, + {"name":"billing.api","path":"/srv/billing.api","remote":"https://github.com/acme/billing.api","owner":"acme","runtime":"dotnet,shell","tooling":"unknown","target_framework":"net9.0","dependencies":["a","b","c","d","e","f","g","h","i","j","k","l"],"dev_dependencies":[],"last_touched":"2026-01-02T00:00:00+00:00","evidence":{"owner":"CODEOWNERS default rule","runtime":"dotnet: Billing.csproj"}} + ], + "edges": [ + {"from":"web-ui","to":"acme/billing.api","type":"depends-on","relation":"internal","count":3,"files":["package.json"]}, + {"from":"web-ui","to":"acme/design-tokens","type":"cites","relation":"internal","count":2,"files":["README.md"]}, + {"from":"web-ui","to":"actions/checkout","type":"uses-workflow","relation":"external","count":9,"files":[".github/workflows/ci.yml"]}, + {"from":"web-ui","to":"vitest-dev/vitest","type":"cites","relation":"external","count":1,"files":["README.md"]} + ] +} +JSON + +# --- Case group 1: the mermaid dialect -------------------------------------- +render mermaid --record "$TEST_TMPDIR/record.json" +rc=$? +assert_equals "mermaid: rendering exits 0" "$rc" "0" +md="$(cat "$TEST_TMPDIR/mermaid/landscape.md")" +assert_contains "mermaid: the diagram is a focal-system-free C4Context" "$md" 'C4Context' +assert_contains "mermaid: with the landscape title" "$md" 'title System Landscape' +assert_contains "mermaid: the owner becomes an enterprise boundary" "$md" 'Enterprise_Boundary(b0, "acme")' +assert_contains "mermaid: a dotted name is sanitised into a valid alias" "$md" 'System(acme_billing_api, "billing.api"' +assert_contains "mermaid: a hyphenated one too" "$md" 'System(acme_web_ui, "web-ui"' +assert_contains "mermaid: the node label is the primary runtime plus the framework" "$md" '"dotnet, net9.0"' +assert_contains "mermaid: a single runtime with a framework reads the same way" "$md" '"node, >=22"' +assert_contains "mermaid: a referenced repository with no checkout is still a system" "$md" 'System(acme_design_tokens, "design-tokens", "not checked out here")' +assert_contains "mermaid: an other-owner target is external" "$md" 'System_Ext(actions_checkout, "actions/checkout"' +assert_contains "mermaid: an external keeps its owner prefix, having no boundary to sit in" "$md" '"actions/checkout"' +assert_contains "mermaid: the edge label is the type and the count" "$md" 'Rel(acme_web_ui, acme_billing_api, "depends-on (3)")' +assert_contains "mermaid: the provenance line names the discovery source" "$md" 'from current repository plus reference graph' +assert_contains "mermaid: and whether remote facts were used" "$md" 'Remote facts: not used' +assert_contains "mermaid: the generated-on date comes from the record, never the clock" "$md" '2026-01-02' + +# The second checked-out repository cites nothing, so it must still be a node. +assert_contains "mermaid: a repository with no outgoing edge is still drawn" "$md" 'System(acme_billing_api' + +# --- Case group 2: determinism ---------------------------------------------- +render mermaid2 --record "$TEST_TMPDIR/record.json" +if diff -q "$TEST_TMPDIR/mermaid/landscape.md" "$TEST_TMPDIR/mermaid2/landscape.md" >/dev/null && + diff -q "$TEST_TMPDIR/mermaid/portfolio.md" "$TEST_TMPDIR/mermaid2/portfolio.md" >/dev/null; then + pass "determinism: the same record renders byte-identical artifacts" +else + fail "determinism: the same record renders byte-identical artifacts" \ + "$(diff "$TEST_TMPDIR/mermaid/landscape.md" "$TEST_TMPDIR/mermaid2/landscape.md")" +fi + +# --- Case group 3: the structurizr dialect ---------------------------------- +render dsl --record "$TEST_TMPDIR/record.json" --dialect structurizr +assert_equals "structurizr: rendering exits 0" "$?" "0" +if [[ -f "$TEST_TMPDIR/dsl/landscape.dsl" && ! -f "$TEST_TMPDIR/dsl/landscape.md" ]]; then + pass "structurizr: it writes landscape.dsl and no landscape.md" +else + fail "structurizr: it writes landscape.dsl and no landscape.md" "$(ls "$TEST_TMPDIR/dsl")" +fi +dsl="$(cat "$TEST_TMPDIR/dsl/landscape.dsl")" +assert_contains "structurizr: the model is wrapped in a workspace" "$dsl" 'workspace {' +assert_contains "structurizr: it uses the dedicated landscape view" "$dsl" 'systemLandscape "landscape" {' +assert_contains "structurizr: with include and autoLayout" "$dsl" 'include *' +assert_contains "structurizr: owners become groups" "$dsl" 'group "acme" {' +assert_contains "structurizr: a system carries its label as the description" "$dsl" 'softwareSystem "billing.api" "dotnet, net9.0"' +assert_contains "structurizr: an other-owner system is tagged External" "$dsl" '"actions/checkout" "not checked out here" "External"' +assert_contains "structurizr: relationships carry the same type-and-count label" "$dsl" 'acme_web_ui -> acme_billing_api "depends-on (3)"' + +# The two dialects must agree on which systems exist. +for sys in acme_web_ui acme_billing_api acme_design_tokens actions_checkout; do + case "$md$dsl" in + *"$sys"*) pass "dialects: $sys appears in both" ;; + *) fail "dialects: $sys appears in both" "missing" ;; + esac +done + +# --- Case group 4: the portfolio table -------------------------------------- +pf="$(cat "$TEST_TMPDIR/mermaid/portfolio.md")" +assert_contains "portfolio: the header carries all seven columns" "$pf" \ + '| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched |' +assert_contains "portfolio: a multi-runtime repository lists every runtime" "$pf" '| dotnet, shell |' +assert_contains "portfolio: dependencies past ten are truncated with a count" "$pf" 'a, b, c, d, e, f, g, h, i, j (+2)' +assert_contains "portfolio: an underived fact stays unknown" "$pf" '| unknown |' +# shellcheck disable=SC2016 # backticks are markdown code spans in the rendered output. +assert_contains "portfolio: development-scope dependencies sit below the table" "$pf" '- web-ui: `vitest`' +assert_not_contains "portfolio: a repository with none gets no empty line" "$pf" '- billing.api:' +assert_contains "portfolio: every fact names the file it came from" "$pf" '| web-ui | runtime | node: package.json |' +assert_contains "portfolio: including a non-default owner ladder rung" "$pf" 'CODEOWNERS default rule' + +# Rows sort by name, so the record's order (web-ui first) must not survive. +row_order="$(printf '%s\n' "$pf" | sed -n 's/^| \([^|]*\) | acme |.*/\1/p' | tr -d ' ' | tr '\n' ' ')" +assert_equals "portfolio: rows sort by name, not by record order" "$row_order" "billing.api web-ui " + +# --- Case group 5: --top-external ------------------------------------------- +render capped --record "$TEST_TMPDIR/record.json" --top-external 1 +capped="$(cat "$TEST_TMPDIR/capped/landscape.md")" +assert_contains "cap: the most referenced external survives the cap" "$capped" 'actions/checkout' +assert_not_contains "cap: the least referenced one is dropped from the diagram" "$capped" 'vitest-dev' +assert_contains "cap: and the remainder is counted, not silently lost" "$capped" '1 external repositories are referenced but not drawn' +assert_not_contains "cap: an edge to an undrawn system is dropped with it" "$capped" 'vitest_dev_vitest' +assert_contains "cap: every internal system is drawn whatever the cap" "$capped" 'System(acme_design_tokens' + +render uncapped --record "$TEST_TMPDIR/record.json" --top-external 0 +uncapped="$(cat "$TEST_TMPDIR/uncapped/landscape.md")" +assert_not_contains "cap: zero draws no external at all" "$uncapped" 'System_Ext' +assert_contains "cap: and says how many were left out" "$uncapped" '2 external repositories' +assert_not_contains "cap: a cap wide enough for every external adds no leftover line" \ + "$md" 'external repositories are referenced but not drawn' + +# --- Case group 6: annotations ---------------------------------------------- +printf '## Notes\n\nBilling owns the ledger.\n' >"$TEST_TMPDIR/notes.md" +render noted --record "$TEST_TMPDIR/record.json" --notes "$TEST_TMPDIR/notes.md" +noted="$(cat "$TEST_TMPDIR/noted/landscape.md")" +assert_contains "notes: the annotations are appended verbatim" "$noted" 'Billing owns the ledger.' +assert_contains "notes: under their own heading" "$noted" '## Notes' +assert_contains "notes: and the diagram is unchanged" "$noted" 'Rel(acme_web_ui, acme_billing_api, "depends-on (3)")' +assert_not_contains "notes: the portfolio does not receive them" \ + "$(cat "$TEST_TMPDIR/noted/portfolio.md")" 'Billing owns the ledger.' + +# --- Case group 7: a record with nothing in it ------------------------------ +cat >"$TEST_TMPDIR/empty.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "not used", + "repositories": [ + {"name":"solo","path":"/srv/solo","remote":"","owner":"unknown","runtime":"unknown","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{"owner":"no CODEOWNERS and no origin remote"}} + ], + "edges": [] +} +JSON +render solo --record "$TEST_TMPDIR/empty.json" +assert_equals "empty: an edgeless record still renders" "$?" "0" +solo="$(cat "$TEST_TMPDIR/solo/landscape.md")" +assert_contains "empty: the one system is drawn" "$solo" 'System(solo, "solo"' +assert_contains "empty: an unknown owner does not fabricate a boundary name" "$solo" 'Enterprise_Boundary(b0, "unknown")' +assert_contains "empty: a repository with no probed runtime says so" "$solo" 'no probed runtime' +assert_contains "empty: an empty dependency list reads as none, not blank" \ + "$(cat "$TEST_TMPDIR/solo/portfolio.md")" '| (none) |' +assert_not_contains "empty: and no relationship is invented" "$solo" 'Rel(' +assert_not_contains "empty: nor is a leftover-externals line" "$solo" 'not drawn' + +# --- Case group 8: several owners ------------------------------------------- +cat >"$TEST_TMPDIR/two-owners.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "not used", + "repositories": [ + {"name":"web-ui","path":"/srv/web-ui","remote":"","owner":"acme","runtime":"node","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}}, + {"name":"tooling","path":"/srv/tooling","remote":"","owner":"zeta","runtime":"shell","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}} + ], + "edges": [] +} +JSON +render owners --record "$TEST_TMPDIR/two-owners.json" +owners="$(cat "$TEST_TMPDIR/owners/landscape.md")" +assert_contains "owners: the first owner gets a boundary" "$owners" 'Enterprise_Boundary(b0, "acme")' +assert_contains "owners: the second gets its own, not a shared one" "$owners" 'Enterprise_Boundary(b1, "zeta")' + +# --- Case group 9: markdown that lints -------------------------------------- +if command -v npx >/dev/null 2>&1 && [[ -z "${SKIP_MARKDOWNLINT:-}" ]]; then + npx --no-install markdownlint-cli2 "$TEST_TMPDIR/mermaid/landscape.md" \ + "$TEST_TMPDIR/mermaid/portfolio.md" >/dev/null 2>&1 + assert_equals "lint: the rendered markdown passes markdownlint" "$?" "0" +else + pass "lint: markdownlint skipped, npx or the package is unavailable" +fi + +# --- Case group 10: usage --------------------------------------------------- +bash "$SCRIPT" >/dev/null 2>&1 +assert_equals "usage: no arguments exits 2" "$?" "2" + +bash "$SCRIPT" --record "$TEST_TMPDIR/record.json" >/dev/null 2>&1 +assert_equals "usage: a record with no --out exits 2" "$?" "2" + +bash "$SCRIPT" --record "$TEST_TMPDIR/record.json" --out "$TEST_TMPDIR" --dialect plantuml >/dev/null 2>&1 +assert_equals "usage: an unsupported dialect exits 2" "$?" "2" + +bash "$SCRIPT" --record "$TEST_TMPDIR/record.json" --out "$TEST_TMPDIR" --top-external many >/dev/null 2>&1 +assert_equals "usage: a non-numeric cap exits 2" "$?" "2" + +bash "$SCRIPT" --record "$TEST_TMPDIR/absent.json" --out "$TEST_TMPDIR" >/dev/null 2>&1 +assert_equals "usage: an unreadable record exits 1" "$?" "1" + +bash "$SCRIPT" --record "$TEST_TMPDIR/record.json" --out "$TEST_TMPDIR/nowhere" >/dev/null 2>&1 +assert_equals "usage: an output directory that is not there exits 1" "$?" "1" + +printf '{"schema_version": 2, "repositories": [], "edges": []}\n' >"$TEST_TMPDIR/v2.json" +bad="$(bash "$SCRIPT" --record "$TEST_TMPDIR/v2.json" --out "$TEST_TMPDIR" 2>&1)" +assert_equals "usage: an unknown schema version exits 1" "$?" "1" +assert_contains "usage: and says which version it wanted" "$bad" "schema_version 1" + +help_out="$(bash "$SCRIPT" --help 2>&1)" +assert_equals "usage: --help exits 0" "$?" "0" +assert_contains "usage: and states the determinism contract" "$help_out" "byte-identical" + +printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED" +[[ "$FAILED" -eq 0 ]] || exit 1 +exit 0 From b1c125d0206c5b694e06803eacf2133b2e6e7cfe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:51:43 +0000 Subject: [PATCH 09/15] feat(architecture): default map-landscape to this repository and its references A bare invocation stopped and asked for a scope argument, and even an explicit one drew a single node, because relationships required the other repository to be a local checkout. A repository already names its neighbours in its workflows, marketplace sources, module paths and docs; the skill now reads them. The default scope is the current repository plus its reference graph, one hop out. --repos and --root stay as explicit overrides, --out redirects one run, and the working directory is still never walked for nested repositories. The record is committed and every re-run compares against it before writing, with --check as the read-only CI shape. --remote is opt-in, presence-gated, and loses to a local checkout for any fact both can supply. The --root collaborator protocol and the --remote contract move to a scope-modes reference spoke: both are rare paths whose detail does not belong in context on a default run. This repository's own artifacts are regenerated through the new pipeline, with the annotations it cannot derive in landscape-notes.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape-notes.md | 23 ++ docs/architecture/landscape.json | 88 ++++++ docs/architecture/landscape.md | 93 +++--- docs/architecture/portfolio.md | 45 +-- .../architecture/.claude-plugin/plugin.json | 4 +- plugins/architecture/CHANGELOG.md | 36 +++ plugins/architecture/README.md | 45 ++- plugins/architecture/reference/config.md | 12 +- .../skills/map-landscape/SKILL.md | 265 ++++++++++-------- .../skills/map-landscape/evals/evals.json | 145 ++++++++-- .../map-landscape/reference/scope-modes.md | 62 ++++ .../map-landscape/scripts/landscape-record.sh | 43 ++- .../scripts/landscape-record.test.sh | 13 +- 13 files changed, 637 insertions(+), 237 deletions(-) create mode 100644 docs/architecture/landscape-notes.md create mode 100644 docs/architecture/landscape.json create mode 100644 plugins/architecture/skills/map-landscape/reference/scope-modes.md diff --git a/docs/architecture/landscape-notes.md b/docs/architecture/landscape-notes.md new file mode 100644 index 0000000000..ca2b6dc11d --- /dev/null +++ b/docs/architecture/landscape-notes.md @@ -0,0 +1,23 @@ +## Annotations + +Everything above is extracted. Everything here is annotation: the extractor reports +that a repository is referenced and how, never what it is for. Edit this file +freely; the renderer appends it and never overwrites it. + +| System | What it is for | +|---|---| +| `claude-code-plugins` | This repository. The plugin marketplace and its skills. | +| `ci-workflows` | The reusable workflows and composite actions every lane here calls. | +| `standards` | The engineering conventions this repository restates and defers to. | +| `github-iac` | GitHub organisation and repository configuration as code. | +| `medley` | An application repository that consumes these plugins. | +| `provisioning` | Machine provisioning. | +| `dotfiles` | Developer environment setup. | +| `knowledge-corpus` | Source material the writing and research skills draw on. | +| `anthropics/claude-code` | The CLI these plugins target. External, read-only. | +| `actions/checkout` | The GitHub Action every workflow here starts with. External, read-only. | + +The `cites` counts dwarf every other edge type because this repository is mostly +prose about tooling. A high `cites` count means the two repositories talk about +each other, not that one runs on the other. `uses-workflow` is the edge that +carries a real runtime dependency. diff --git a/docs/architecture/landscape.json b/docs/architecture/landscape.json new file mode 100644 index 0000000000..038fd1f92b --- /dev/null +++ b/docs/architecture/landscape.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "generated_on": "2026-09-10", + "discovery_source": "current repository plus reference graph", + "remote": "not used", + "repositories": [ + {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-10T18:21:45+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} + ], + "edges": [ + {"from":"claude-code-plugins","to":"DavidAnson/markdownlint","type":"cites","relation":"external","count":2,"files":[".markdownlint-cli2.jsonc","plugins/docs-hygiene/skills/audit-noise/SKILL.md"]}, + {"from":"claude-code-plugins","to":"DavidAnson/markdownlint-cli2","type":"cites","relation":"external","count":3,"files":["plugins/markdown-format/CHANGELOG.md","plugins/markdown-format/README.md"]}, + {"from":"claude-code-plugins","to":"Dometrain/mcp","type":"cites","relation":"external","count":8,"files":["plugins/dometrain/README.md","plugins/dometrain/skills/grounding/SKILL.md","plugins/dometrain/skills/setup/SKILL.md","plugins/dometrain/skills/sync/SKILL.md","plugins/dometrain/skills/sync/vendor/SKILL.md"]}, + {"from":"claude-code-plugins","to":"GSA/plainlanguage.gov","type":"cites","relation":"external","count":1,"files":["plugins/writing/skills/be-concise/reference/sources.md"]}, + {"from":"claude-code-plugins","to":"JuliusBrussee/caveman","type":"cites","relation":"external","count":1,"files":["plugins/adhd/README.md"]}, + {"from":"claude-code-plugins","to":"Satsuoni/DeDRM_tools","type":"cites","relation":"external","count":5,"files":["plugins/kindle-dedrm/skills/manage/SKILL.md","plugins/kindle-dedrm/skills/manage/reference/sources.md","plugins/kindle-dedrm/skills/manage/reference/versions.md","plugins/kindle-dedrm/skills/manage/reference/workflow.md"]}, + {"from":"claude-code-plugins","to":"SonarSource/eslint-plugin-sonarjs","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/sonarjs.py"]}, + {"from":"claude-code-plugins","to":"anthropics/claude-code","type":"cites","relation":"external","count":37,"files":["docs/MIGRATION-PLAYBOOK.md","docs/PLUGIN-PHILOSOPHY.md","docs/conventions/hook-config-delivery/README.md","docs/conventions/permission-rule-hygiene/README.md","docs/conventions/topic-docs/README.md"]}, + {"from":"claude-code-plugins","to":"anthropics/claude-code-action","type":"cites","relation":"external","count":1,"files":["plugins/improvement/README.md"]}, + {"from":"claude-code-plugins","to":"anthropics/claude-plugins-official","type":"cites","relation":"external","count":1,"files":["plugins/playgrounds/README.md"]}, + {"from":"claude-code-plugins","to":"anthropics/skills","type":"cites","relation":"external","count":1,"files":["plugins/docs-hygiene/skills/audit-progressive-disclosure/SKILL.md"]}, + {"from":"claude-code-plugins","to":"ayghri/i-have-adhd","type":"cites","relation":"external","count":4,"files":["plugins/adhd/CHANGELOG.md","plugins/adhd/LICENSE","plugins/adhd/README.md","plugins/adhd/skills/shape/SKILL.md"]}, + {"from":"claude-code-plugins","to":"bash-lsp/bash-language-server","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-lsp-viability.md"]}, + {"from":"claude-code-plugins","to":"bitinn/node-fetch","type":"cites","relation":"external","count":1,"files":["plugins/miro/dist/index.min.js"]}, + {"from":"claude-code-plugins","to":"bnjbvr/cargo-machete","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-detector-landscape.md"]}, + {"from":"claude-code-plugins","to":"boyter/scc","type":"cites","relation":"external","count":6,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/scc.py","plugins/code-tidying/skills/dissolve-comments/reference/tooling.md"]}, + {"from":"claude-code-plugins","to":"cli/cli","type":"cites","relation":"external","count":1,"files":["docs/CLOUD-FLEET-SETUP.md"]}, + {"from":"claude-code-plugins","to":"crate-ci/typos","type":"cites","relation":"external","count":9,"files":[".claude/cloud-bootstrap.sh","_typos.toml","plugins/typos-format/README.md","plugins/typos-format/hooks/typos-format.sh","plugins/typos-format/skills/setup/SKILL.md"]}, + {"from":"claude-code-plugins","to":"cursor/plugins","type":"cites","relation":"external","count":5,"files":["docs/upstream/cursor-pstack.md","plugins/ai-slop/CHANGELOG.md","plugins/ai-slop/README.md","plugins/ai-slop/skills/audit/reference/catalog.md","plugins/ai-slop/skills/audit/reference/rewrite-guide.md"]}, + {"from":"claude-code-plugins","to":"depcheck/depcheck","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-detector-landscape.md"]}, + {"from":"claude-code-plugins","to":"dotnet/roslyn","type":"cites","relation":"external","count":1,"files":["docs/specs/dead-code-detector-landscape.md"]}, + {"from":"claude-code-plugins","to":"editorconfig-checker/editorconfig-checker","type":"cites","relation":"external","count":1,"files":[".claude/cloud-bootstrap.sh"]}, + {"from":"claude-code-plugins","to":"ericbuess/claude-code-docs","type":"cites","relation":"external","count":1,"files":["plugins/discipline/CHANGELOG.md"]}, + {"from":"claude-code-plugins","to":"fzipp/gocyclo","type":"cites","relation":"external","count":4,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/gocyclo.py"]}, + {"from":"claude-code-plugins","to":"gitleaks/gitleaks","type":"cites","relation":"external","count":3,"files":[".claude/cloud-bootstrap.sh",".gitleaks.toml"]}, + {"from":"claude-code-plugins","to":"golang/go","type":"cites","relation":"external","count":1,"files":["docs/specs/dead-code-detector-landscape.md"]}, + {"from":"claude-code-plugins","to":"golang/tools","type":"cites","relation":"external","count":1,"files":["plugins/code-tidying/skills/audit-dead-code/context/adjudication.md"]}, + {"from":"claude-code-plugins","to":"humanlayer/skills","type":"cites","relation":"external","count":1,"files":["docs/upstream/humanlayer-skills.md"]}, + {"from":"claude-code-plugins","to":"jendrikseipp/vulture","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-detector-landscape.md"]}, + {"from":"claude-code-plugins","to":"joelparkerhenderson/architecture-decision-record","type":"cites","relation":"external","count":1,"files":["plugins/architecture/skills/record-decision/SKILL.md"]}, + {"from":"claude-code-plugins","to":"johnousterhout/aposd-vs-clean-code","type":"cites","relation":"external","count":2,"files":["plugins/code-tidying/skills/audit-comment-residue/SKILL.md","plugins/code-tidying/skills/dissolve-comments/reference/sources.md"]}, + {"from":"claude-code-plugins","to":"koalaman/shellcheck","type":"cites","relation":"external","count":7,"files":[".claude/cloud-bootstrap.sh",".shellcheckrc","docs/specs/dead-code-detector-landscape.md","plugins/bash-format/hooks/bash-format.sh","plugins/bash-format/skills/setup/SKILL.md"]}, + {"from":"claude-code-plugins","to":"kucherenko/jscpd","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/jscpd.py"]}, + {"from":"claude-code-plugins","to":"lycheeverse/lychee","type":"cites","relation":"external","count":1,"files":["lychee.toml"]}, + {"from":"claude-code-plugins","to":"mattpocock/skills","type":"cites","relation":"external","count":5,"files":["docs/upstream/aihero-course.md","docs/upstream/aihero-shipping-course.md","docs/upstream/mattpocock-skills.md","plugins/wizard/CHANGELOG.md"]}, + {"from":"claude-code-plugins","to":"melodic-software/ci-workflows","type":"cites","relation":"internal","count":142,"files":[".claude/source-control.md",".github/actionlint.yaml",".github/dependabot.yml",".github/standards/runner-policy/policy.json",".github/standards/runner-policy/runner-policy.mjs"]}, + {"from":"claude-code-plugins","to":"melodic-software/claude-code-plugins-ci","type":"cites","relation":"internal","count":1,"files":["package.json"]}, + {"from":"claude-code-plugins","to":"melodic-software/dotfiles","type":"cites","relation":"internal","count":4,"files":["docs/CI-RUNNER-ROUTING.md","docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md","plugins/machine-health/CHANGELOG.md","plugins/source-control/CHANGELOG.md"]}, + {"from":"claude-code-plugins","to":"melodic-software/github-iac","type":"cites","relation":"internal","count":18,"files":[".github/actionlint.yaml",".github/dependabot.yml",".github/workflows/ci.yml",".github/workflows/silent-revert-canary.yml","docs/CI-RUNNER-ROUTING.md"]}, + {"from":"claude-code-plugins","to":"melodic-software/knowledge-corpus","type":"cites","relation":"internal","count":2,"files":["docs/adr/0022-consume-the-knowledge-corpus-from-a-separate-repository.md","docs/knowledge-integration-design.md"]}, + {"from":"claude-code-plugins","to":"melodic-software/medley","type":"cites","relation":"internal","count":17,"files":["docs/MIGRATION-PLAYBOOK.md","docs/adr/0020-defer-three-medley-surfaces-with-explicit-recheck-triggers.md","docs/ai-briefing-design.md","docs/conventions/ecosystem-commands/CHANGELOG.md","docs/hook-migration-audit.md"]}, + {"from":"claude-code-plugins","to":"melodic-software/miro-mcp","type":"cites","relation":"internal","count":1,"files":["plugins/miro/package.json"]}, + {"from":"claude-code-plugins","to":"melodic-software/provisioning","type":"cites","relation":"internal","count":5,"files":["plugins/claude-ops/skills/observability/context/operator-setup-collector-daemon.md","plugins/claude-ops/skills/observability/context/operator-setup.md","plugins/claude-ops/skills/observability/context/otel-pipeline.md"]}, + {"from":"claude-code-plugins","to":"melodic-software/runner-policy-runtime","type":"cites","relation":"internal","count":1,"files":[".github/standards/runner-policy/package.json"]}, + {"from":"claude-code-plugins","to":"melodic-software/standards","type":"cites","relation":"internal","count":53,"files":[".claude/rules/pr-body-contract.md",".github/workflows/managed-files-guard.yml",".markdownlint-cli2.jsonc","docs/CI-RUNNER-ROUTING.md","docs/CLOUD-FLEET-SETUP.md"]}, + {"from":"claude-code-plugins","to":"mibk/dupl","type":"cites","relation":"external","count":4,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/dupl.py"]}, + {"from":"claude-code-plugins","to":"microsoft/TypeScript","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-lsp-viability.md"]}, + {"from":"claude-code-plugins","to":"microsoft/mcp","type":"cites","relation":"external","count":1,"files":["plugins/claude-ops/skills/known-issues/context/action-scan.md"]}, + {"from":"claude-code-plugins","to":"microsoft/multilspy","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-lsp-viability.md"]}, + {"from":"claude-code-plugins","to":"microsoft/playwright-cli","type":"cites","relation":"external","count":4,"files":["plugins/playwright/CHANGELOG.md","plugins/playwright/README.md","plugins/playwright/skills/playwright/SKILL.md"]}, + {"from":"claude-code-plugins","to":"miroapp/api-clients","type":"cites","relation":"external","count":1,"files":["plugins/miro/dist/index.min.js"]}, + {"from":"claude-code-plugins","to":"modelcontextprotocol/modelcontextprotocol","type":"cites","relation":"external","count":1,"files":["plugins/miro/dist/index.min.js"]}, + {"from":"claude-code-plugins","to":"modelcontextprotocol/python-sdk","type":"cites","relation":"external","count":1,"files":["plugins/mcp-tools/CHANGELOG.md"]}, + {"from":"claude-code-plugins","to":"modelcontextprotocol/typescript-sdk","type":"cites","relation":"external","count":1,"files":["plugins/miro/README.md"]}, + {"from":"claude-code-plugins","to":"mvdan/sh","type":"cites","relation":"external","count":4,"files":[".claude/cloud-bootstrap.sh","plugins/bash-format/README.md","plugins/bash-format/hooks/bash-format.sh","plugins/bash-format/skills/setup/SKILL.md"]}, + {"from":"claude-code-plugins","to":"nadeesha/ts-prune","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-detector-landscape.md"]}, + {"from":"claude-code-plugins","to":"plantain-00/type-coverage","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/type-coverage.py"]}, + {"from":"claude-code-plugins","to":"priv-kweihmann/multimetric","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/multimetric.py"]}, + {"from":"claude-code-plugins","to":"python/tzdata","type":"cites","relation":"external","count":1,"files":["plugins/session-flow/skills/keep-going/scripts/vendor/README.md"]}, + {"from":"claude-code-plugins","to":"rhysd/actionlint","type":"cites","relation":"external","count":6,"files":[".claude/cloud-bootstrap.sh",".github/actionlint.yaml","plugins/actionlint/README.md","plugins/actionlint/hooks/actionlint-check.sh","plugins/actionlint/skills/setup/SKILL.md"]}, + {"from":"claude-code-plugins","to":"rubik/radon","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/radon.py"]}, + {"from":"claude-code-plugins","to":"shellspec/shellmetrics","type":"cites","relation":"external","count":3,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/shellmetrics.py"]}, + {"from":"claude-code-plugins","to":"sverweij/dependency-cruiser","type":"cites","relation":"external","count":2,"files":["plugins/review/skills/audit-enforceability/SKILL.md","plugins/review/skills/audit-enforceability/context/crosswalk.md"]}, + {"from":"claude-code-plugins","to":"terryyin/lizard","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/lizard.py"]}, + {"from":"claude-code-plugins","to":"typescript-language-server/typescript-language-server","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-lsp-viability.md"]}, + {"from":"claude-code-plugins","to":"uber/piranha","type":"cites","relation":"external","count":1,"files":["plugins/overengineering/context/scrutiny-method.md"]}, + {"from":"claude-code-plugins","to":"uudashr/gocognit","type":"cites","relation":"external","count":4,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/gocognit.py"]}, + {"from":"claude-code-plugins","to":"x-motemen/ghq","type":"cites","relation":"external","count":1,"files":["plugins/repo-hygiene/skills/setup/SKILL.md"]}, + {"from":"claude-code-plugins","to":"anthropics/claude-plugins-community","type":"installs-plugin","relation":"external","count":2,"files":["plugins/education/README.md","plugins/education/skills/eli5/SKILL.md"]}, + {"from":"claude-code-plugins","to":"anthropics/claude-plugins-official","type":"installs-plugin","relation":"external","count":1,"files":["plugins/playgrounds/skills/use/SKILL.md"]}, + {"from":"claude-code-plugins","to":"dometrain/mcp","type":"installs-plugin","relation":"external","count":1,"files":["plugins/dometrain/README.md"]}, + {"from":"claude-code-plugins","to":"trailofbits/skills","type":"installs-plugin","relation":"external","count":1,"files":["plugins/review/skills/audit-enforceability/SKILL.md"]}, + {"from":"claude-code-plugins","to":"actions/checkout","type":"uses-workflow","relation":"external","count":15,"files":[".github/workflows/ci.yml",".github/workflows/claude-review.yml",".github/workflows/claude-security-review.yml",".github/workflows/dependabot-miro-bundle.yml",".github/workflows/hook-utils-timing.yml"]}, + {"from":"claude-code-plugins","to":"actions/setup-node","type":"uses-workflow","relation":"external","count":5,"files":[".github/workflows/ci.yml",".github/workflows/dependabot-miro-bundle.yml",".github/workflows/video-digest-source-liveness.yml"]}, + {"from":"claude-code-plugins","to":"actions/setup-python","type":"uses-workflow","relation":"external","count":3,"files":[".github/workflows/ci.yml"]}, + {"from":"claude-code-plugins","to":"actions/upload-artifact","type":"uses-workflow","relation":"external","count":1,"files":[".github/workflows/video-digest-source-liveness.yml"]}, + {"from":"claude-code-plugins","to":"melodic-software/ci-workflows","type":"uses-workflow","relation":"internal","count":24,"files":[".github/workflows/ci.yml",".github/workflows/claude-review.yml",".github/workflows/claude-security-review.yml",".github/workflows/issue-triage-label.yml",".github/workflows/link-check.yml"]} + ] +} diff --git a/docs/architecture/landscape.md b/docs/architecture/landscape.md index 899c416c8b..26e517ed74 100644 --- a/docs/architecture/landscape.md +++ b/docs/architecture/landscape.md @@ -1,52 +1,69 @@ # System Landscape -Generated on 2026-09-10 from this repository plus its reference graph, one hop -out. Facts come from `portfolio-facts.sh`; edges come from `reference-edges.sh`, -which reads tracked files only. Nothing was fetched, so a stale checkout reports -a stale date and a repository this one never names does not appear at all. +Generated on 2026-09-10 from current repository plus reference graph. Remote facts: not used. -Every edge is typed by the syntax that carries it and labelled with how many -references support it. `uses-workflow` is a workflow step this repository runs. -`cites` is the weakest type: a mention in prose, configuration, or a rule. +Every fact traces to the file the probe named. Every edge is typed by the +syntax that carries it and labelled with how many references support it. +A system with no probed runtime is one this checkout names but does not +contain. ```mermaid C4Context title System Landscape Enterprise_Boundary(b0, "melodic-software") { - System(claude_code_plugins, "claude-code-plugins", "shell; tooling: node, python") - System(ci_workflows, "ci-workflows", "reusable CI workflows") - System(standards, "standards", "engineering conventions") - System(github_iac, "github-iac", "GitHub configuration as code") - System(medley, "medley", "application") - System(provisioning, "provisioning", "machine provisioning") - System(dotfiles, "dotfiles", "developer environment") + System(melodic_software_ci_workflows, "ci-workflows", "not checked out here") + System(melodic_software_claude_code_plugins, "claude-code-plugins", "shell") + System(melodic_software_claude_code_plugins_ci, "claude-code-plugins-ci", "not checked out here") + System(melodic_software_dotfiles, "dotfiles", "not checked out here") + System(melodic_software_github_iac, "github-iac", "not checked out here") + System(melodic_software_knowledge_corpus, "knowledge-corpus", "not checked out here") + System(melodic_software_medley, "medley", "not checked out here") + System(melodic_software_miro_mcp, "miro-mcp", "not checked out here") + System(melodic_software_provisioning, "provisioning", "not checked out here") + System(melodic_software_runner_policy_runtime, "runner-policy-runtime", "not checked out here") + System(melodic_software_standards, "standards", "not checked out here") } - System_Ext(anthropics_claude_code, "anthropics/claude-code", "the CLI these plugins target") - System_Ext(actions_checkout, "actions/checkout", "GitHub Action") - - Rel(claude_code_plugins, ci_workflows, "uses-workflow (24)") - Rel(claude_code_plugins, ci_workflows, "cites (142)") - Rel(claude_code_plugins, standards, "cites (53)") - Rel(claude_code_plugins, github_iac, "cites (18)") - Rel(claude_code_plugins, medley, "cites (17)") - Rel(claude_code_plugins, provisioning, "cites (5)") - Rel(claude_code_plugins, dotfiles, "cites (4)") - Rel(claude_code_plugins, anthropics_claude_code, "cites (37)") - Rel(claude_code_plugins, actions_checkout, "uses-workflow (15)") + System_Ext(anthropics_claude_code, "anthropics/claude-code", "not checked out here") + System_Ext(actions_checkout, "actions/checkout", "not checked out here") + + Rel(melodic_software_claude_code_plugins, anthropics_claude_code, "cites (37)") + Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "cites (142)") + Rel(melodic_software_claude_code_plugins, melodic_software_claude_code_plugins_ci, "cites (1)") + Rel(melodic_software_claude_code_plugins, melodic_software_dotfiles, "cites (4)") + Rel(melodic_software_claude_code_plugins, melodic_software_github_iac, "cites (18)") + Rel(melodic_software_claude_code_plugins, melodic_software_knowledge_corpus, "cites (2)") + Rel(melodic_software_claude_code_plugins, melodic_software_medley, "cites (17)") + Rel(melodic_software_claude_code_plugins, melodic_software_miro_mcp, "cites (1)") + Rel(melodic_software_claude_code_plugins, melodic_software_provisioning, "cites (5)") + Rel(melodic_software_claude_code_plugins, melodic_software_runner_policy_runtime, "cites (1)") + Rel(melodic_software_claude_code_plugins, melodic_software_standards, "cites (53)") + Rel(melodic_software_claude_code_plugins, actions_checkout, "uses-workflow (15)") + Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "uses-workflow (24)") ``` -## What the diagram leaves out +63 external repositories are referenced but not drawn; the record carries +every one of them. + +## Annotations -Five further same-owner repositories are referenced once or twice each and are -omitted to keep the diagram readable: `knowledge-corpus` (2), plus -`runner-policy-runtime`, `miro-mcp`, and `claude-code-plugins-ci` (1 each). +Everything above is extracted. Everything here is annotation: the extractor reports +that a repository is referenced and how, never what it is for. Edit this file +freely; the renderer appends it and never overwrites it. -Sixty-five external repositories are referenced in total. Only the two most -referenced are drawn. The rest are tool and documentation citations rather than -systems this repository relates to, and they belong in the record instead of the -diagram. +| System | What it is for | +|---|---| +| `claude-code-plugins` | This repository. The plugin marketplace and its skills. | +| `ci-workflows` | The reusable workflows and composite actions every lane here calls. | +| `standards` | The engineering conventions this repository restates and defers to. | +| `github-iac` | GitHub organisation and repository configuration as code. | +| `medley` | An application repository that consumes these plugins. | +| `provisioning` | Machine provisioning. | +| `dotfiles` | Developer environment setup. | +| `knowledge-corpus` | Source material the writing and research skills draw on. | +| `anthropics/claude-code` | The CLI these plugins target. External, read-only. | +| `actions/checkout` | The GitHub Action every workflow here starts with. External, read-only. | -Node descriptions above are annotations, not extracted facts: the extractor -reports that a repository is referenced and how, never what it is for. Only the -`claude-code-plugins` node carries probe-derived runtime and tooling, because it -is the only repository checked out here. +The `cites` counts dwarf every other edge type because this repository is mostly +prose about tooling. A high `cites` count means the two repositories talk about +each other, not that one runs on the other. `uses-workflow` is the edge that +carries a real runtime dependency. diff --git a/docs/architecture/portfolio.md b/docs/architecture/portfolio.md index af0a50b1ad..d4ac5928cf 100644 --- a/docs/architecture/portfolio.md +++ b/docs/architecture/portfolio.md @@ -1,32 +1,33 @@ # Application portfolio -Generated on 2026-09-10 from an explicit repository list (`--repos`). Last -touched is the local HEAD of each checkout; nothing was fetched, so a stale -checkout reports a stale date. Every value comes from the file the -`portfolio-facts.sh` probe named; `unknown` means no probe could derive it. +Generated on 2026-09-10 from current repository plus reference graph. Remote facts: not used. + +Last touched is the local HEAD of each checkout unless a remote fact says +otherwise, so a stale checkout reports a stale date. `unknown` means no probe +could derive the value. `Runtime` and `Dependencies` are runtime scope, what the repository runs on. -`Tooling` is development scope, what it is built with: npm `devDependencies`, -CI requirement pins, anything under a dot-directory. This repository is shell -and markdown that lints with Node and Python tooling, which is why it has no -runtime dependencies at all. +`Tooling` and the development-scope dependencies below the table are what it +is built with. -| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched (local HEAD) | +| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched | |---|---|---|---|---|---|---| -| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-10T16:50:21+00:00 | +| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-10T18:21:45+00:00 | + +## Development-scope dependencies + +Truncated to ten per repository; the record carries the full list. -Development-scope dependencies, truncated to ten: `@anthropic-ai/claude-code`, -`@biomejs/biome`, `htmlhint`, `iniconfig`, `markdownlint-cli2`, `packaging`, -`pluggy`, `pygments`, `pytest`, `pyyaml` (+10). +- claude-code-plugins: `@anthropic-ai/claude-code`, `@biomejs/biome`, `htmlhint`, `iniconfig`, `markdownlint-cli2`, `packaging`, `pluggy`, `pygments`, `pytest`, `pyyaml` (+10) ## Evidence -| Fact | Source | -|---|---| -| Owner | origin remote URL (no CODEOWNERS default rule) | -| Runtime | shell: `lib/hook-utils.sh` | -| Tooling | node: `package.json` (development scope); python: `.github/requirements-ci.txt` (development scope) | -| Target framework | no framework declaration for runtime shell | -| Dependencies | no runtime-scope dependency manifest | -| Development dependencies | `package.json` (devDependencies), `.github/requirements-ci.txt` | -| Last touched | `git log -1 --format=%cI` (local HEAD) | +| Repository | Fact | Source | +|---|---|---| +| claude-code-plugins | owner | origin remote URL | +| claude-code-plugins | runtime | shell: lib/hook-utils.sh | +| claude-code-plugins | tooling | node: package.json (development scope), python: .github/requirements-ci.txt (development scope) | +| claude-code-plugins | target_framework | no framework declaration for runtime shell | +| claude-code-plugins | dependencies | no runtime-scope dependency manifest | +| claude-code-plugins | dev_dependencies | package.json (devDependencies), .github/requirements-ci.txt (development scope) | +| claude-code-plugins | last_touched | git log -1 --format=%cI (local HEAD) | diff --git a/plugins/architecture/.claude-plugin/plugin.json b/plugins/architecture/.claude-plugin/plugin.json index 57603d0e78..03b66235ba 100644 --- a/plugins/architecture/.claude-plugin/plugin.json +++ b/plugins/architecture/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "architecture", - "version": "0.8.6", - "description": "Scans an existing codebase for module-level architecture friction — shallow modules, seam leaks, and locality gaps — using Ousterhout's deep-module lens, presents candidates as a self-contained HTML report, and runs an interview loop on the selected candidate before handing off for planning. Also charts a discovered set of repositories as a C4 system landscape plus an application-portfolio table, and records an architecture decision into the repository's existing ADR convention.", + "version": "0.9.0", + "description": "Scans an existing codebase for module-level architecture friction — shallow modules, seam leaks, and locality gaps — using Ousterhout's deep-module lens, presents candidates as a self-contained HTML report, and runs an interview loop on the selected candidate before handing off for planning. Also charts a repository and the systems it references as a C4 system landscape plus an application-portfolio table, committing the result as a record that later runs check for drift, and records an architecture decision into the repository's existing ADR convention.", "author": { "name": "Melodic Software", "email": "info@melodicsoftware.com" diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index 0fbdac5b51..c6b0953fbb 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -3,6 +3,42 @@ 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.9.0] + +### Added + +- **`map-landscape`:** a bare invocation now charts the current repository plus every repository its + tracked files reference, one hop out. `--repos` and `--root` stay as explicit overrides, and the + working directory is still never walked for nested repositories. +- **`map-landscape`:** `reference-edges.sh` extracts typed, counted edges from one repository's + tracked files. Each type trusts one syntax: `uses-workflow` a workflow `uses:` step, + `installs-plugin` a marketplace source, `depends-on` a module path, and `cites` a github.com URL + or a bare `owner/repo` whose owner matches the subject's own. Every edge carries the files that + support it. +- **`map-landscape`:** `landscape-record.sh` assembles both collectors into a committed + `landscape.json` (schema_version 1) and compares a fresh collection against it. The drift report + names repositories and edges added or removed, facts whose value changed, and cited evidence files + that no longer exist; `--check` runs the comparison, writes nothing, and exits non-zero on drift. +- **`map-landscape`:** `render-landscape.sh` renders both dialects and the portfolio table from the + record, so the same record and flags produce byte-identical artifacts. It does only work the + record decides; prose lives in a `landscape-notes.md` the script appends and never overwrites. +- **`map-landscape`:** `--out ` overrides the declared architecture home for one run, and + `--remote` / `--remote=all` opt in to facts for referenced repositories that are not checked out + locally. Both are off by default, and an unflagged run makes no network call. +- **`map-landscape`:** a fixed closing report: artifacts, repositories charted, edges by type, + unknown count, discovery source, remote state, and drift. + +### Changed + +- **`map-landscape`:** relationships are the extractor's output rather than the model's judgment. + An edge is drawn because a script matched a string in a tracked file, and its label is the edge + type and reference count. +- **`map-landscape`:** an other-owner repository renders as an external system and is read-only in + every mode. Nothing is written to it, and nothing is fetched from it unless `--remote=all`. +- **`map-landscape`:** the description leads with single-repository-plus-references, and routes + module-level questions, fleet hygiene, organisation settings, and in-repo doc drift to the skills + that own them by name. + ## [0.8.6] ### Changed diff --git a/plugins/architecture/README.md b/plugins/architecture/README.md index e1136e7d99..470065e0dd 100644 --- a/plugins/architecture/README.md +++ b/plugins/architecture/README.md @@ -37,19 +37,38 @@ module's purpose without traversing the whole import graph. ## Across repositories A second lens works one altitude up, over a *set* of repositories rather than -inside one codebase. `map-landscape` discovers the set, collects facts from a -tested script (owner, runtime, target framework, dependencies, last touched), -draws only the relationships a cited fact supports, and writes two artifacts -into the architecture directory your repository declares: a C4 System Landscape -view (Structurizr `systemLandscape`, or a mermaid `C4Context` block) and an -application-portfolio table. Anything no probe could derive stays `unknown` -rather than becoming a plausible guess. - -Discovery is selected by argument. `--repos` charts exactly the repositories you -list. `--root` discovers, delegating to the `repo-fleet-hygiene` plugin when it -is installed and falling back to an announced bundled walk when it is not. -Neither argument stops and names both forms; the session's working directory is -never scanned. +inside one codebase. Run `/architecture:map-landscape` with no arguments and it +charts the repository you are in plus every repository its tracked files name, +one hop out. Your workflows, marketplace sources, module paths, and docs already +say which systems you build against; the skill reads them rather than requiring +every neighbour to be checked out beside you. + +Two tested scripts do the collecting. `portfolio-facts.sh` derives owner, +runtime, target framework, dependencies, tooling, and last touched, each with the +file it came from. `reference-edges.sh` extracts typed, counted edges, and each +type trusts exactly one syntax: a workflow `uses:` step, a marketplace source, a +module path, or a plain citation. Anything no probe could derive stays `unknown` +rather than becoming a plausible guess, and a repository nobody names produces no +edge. + +The answer is committed, not just printed. `landscape.json` holds the facts and +edges; `landscape.md` (mermaid `C4Context`) or `landscape.dsl` (Structurizr +`systemLandscape`) and `portfolio.md` are rendered from it, so two runs on the +same facts produce byte-identical files. A later run compares before it writes +and reports what moved: systems added or removed, edges gained or lost, facts +changed, evidence files gone. `--check` runs that comparison, writes nothing, and +exits non-zero, which is the shape a CI lane wants. + +Scope is overridable. `--repos` charts exactly the repositories you list. +`--root` discovers, delegating to the `repo-fleet-hygiene` plugin when it is +installed and falling back to an announced bundled walk when it is not. `--out` +redirects one run's output without touching your declared home. The working +directory is never walked for nested repositories under any of them. + +Nothing reaches the network unless you pass `--remote`, which fills facts for +referenced repositories that are not checked out here. A repository outside your +own owner is read-only reference in every mode: it is drawn and recorded, never +written to. ## Record a decision diff --git a/plugins/architecture/reference/config.md b/plugins/architecture/reference/config.md index 8f5f7d9105..322a088472 100644 --- a/plugins/architecture/reference/config.md +++ b/plugins/architecture/reference/config.md @@ -25,13 +25,15 @@ into it and no dual-read window exists. ## Resolution order, per key -1. The convention home resolves (resolver exit 0) and `/architecture/README.md` declares the +1. `--out ` on the invocation overrides `architecture_dir` for that run alone. It is a + redirect, not a declaration: it never writes the topic doc and never changes the dialect. +2. The convention home resolves (resolver exit 0) and `/architecture/README.md` declares the key, so that value wins. -2. Otherwise the skill INFERS a proposal from repository evidence: an existing `*.dsl` proposes +3. Otherwise the skill INFERS a proposal from repository evidence: an existing `*.dsl` proposes `landscape_dialect: structurizr`; an existing `docs/architecture/` or `architecture/` proposes that directory as `architecture_dir`. Inference proposes; only the operator's confirmation binds. -3. Otherwise the skill asks once. -4. Unanswered: `landscape_dialect` falls back to its documented default, `mermaid`. +4. Otherwise the skill asks once. +5. Unanswered: `landscape_dialect` falls back to its documented default, `mermaid`. `architecture_dir` has no fallback. Undeclared and unconfirmed, including every non-interactive run, `map-landscape` stops and points at `/architecture:setup`. @@ -52,7 +54,7 @@ landscape_dialect: mermaid # structurizr | mermaid | Key | Values | Default | Meaning | |---|---|---|---| -| `architecture_dir` | repo-relative directory path | **none** | Where `map-landscape` writes `landscape.dsl` / `landscape.md` and `portfolio.md`. No default: an undeclared, unconfirmed value stops the skill rather than picking a directory. | +| `architecture_dir` | repo-relative directory path | **none** | Where `map-landscape` writes `landscape.json`, `landscape.dsl` / `landscape.md`, and `portfolio.md`, and where it reads `landscape-notes.md`. No default: an undeclared, unconfirmed value stops the skill rather than picking a directory. `--out ` overrides it for one run. | | `landscape_dialect` | `structurizr` \| `mermaid` | `mermaid` | Which landscape artifact `map-landscape` emits. `structurizr` emits `landscape.dsl` with a `systemLandscape` view; `mermaid` emits `landscape.md` with a `C4Context` block. | An unknown key, or a `landscape_dialect` value outside the two above, is reported by diff --git a/plugins/architecture/skills/map-landscape/SKILL.md b/plugins/architecture/skills/map-landscape/SKILL.md index 93adad819a..ab8da1943c 100644 --- a/plugins/architecture/skills/map-landscape/SKILL.md +++ b/plugins/architecture/skills/map-landscape/SKILL.md @@ -1,19 +1,18 @@ --- -description: "Chart a set of repositories as a C4 System Landscape plus an application-portfolio table: discover the repositories, collect owner/runtime/framework/dependency/last-touched facts from a tested script, draw only relationships a cited fact supports, and write both artifacts into the consumer's declared architecture home. Use when: 'map our landscape', 'system landscape', 'what systems do we have', 'application portfolio', 'who owns which repo', 'what runtimes are we on', 'chart our repositories', 'C4 context across repos', 'inventory our systems'. Skip when: the question is inside ONE codebase (module friction, shallow modules, seam placement) which is /architecture:improve, or the goal is fleet cleanup (stale branches, orphaned worktrees, moved remotes) which is /repo-fleet-hygiene:audit." -argument-hint: "[--repos [,...]] [--root ]..." +description: "Chart a repository and the systems it references as a C4 System Landscape plus an application-portfolio table: extract typed reference edges and portfolio facts from tested scripts, commit the result as a landscape record, report drift against it, and render both artifacts into the declared architecture home. Use when: 'map our landscape', 'system landscape', 'what systems do we have', 'what does this repo depend on', 'application portfolio', 'who owns which repo', 'what runtimes are we on', 'chart our repositories', 'C4 context across repos', 'inventory our systems', 'has our landscape drifted'. Skip when: the question is module-level structure inside one codebase (shallow modules, seam placement) which is /architecture:improve, fleet cleanup (stale branches, orphaned worktrees) which is /repo-fleet-hygiene:audit, org settings which is /github:audit, or doc-versus-code drift inside one repository which is /codebase-health:audit." +argument-hint: "[--repos [,...]] [--root ] [--out ] [--check] [--remote[=all]]" user-invocable: true disable-model-invocation: false shell: bash metadata: workflow-stage: explore - summary: Chart discovered repositories as a C4 system landscape plus an application-portfolio table + summary: Chart a repository and the systems it references as a C4 system landscape and portfolio table --- ## Repository context -The repository context here is the CONSUMER repository, the one whose convention home declares where -artifacts land. It is never a discovery scope: the set of repositories to chart comes from arguments -only, and the session's working directory is never scanned. +The current repository is both the CONSUMER, whose convention home declares where artifacts land, +and the DEFAULT SUBJECT, the repository whose tracked files name the rest of the landscape. Collect with an **individual** Bash call, one command per call: the project root, `git rev-parse --show-toplevel`. Treat a failure (not a repository, git unavailable) as an unknown @@ -22,9 +21,10 @@ value and carry on; `${CLAUDE_PROJECT_DIR}` is the resolver's `--root` either wa ## Purpose Answer "what systems does this organization have, who owns them, what do they run on, and how do -they relate" with two artifacts written into the consumer's declared architecture home: a C4 System -Landscape view, and an application-portfolio table. Every fact traces to a named file; every -relationship traces to a matched string. +they relate" from what a repository already says about its neighbours. Every fact traces to a named +file; every edge traces to a matched string in a tracked file; both are collected by scripts, never +derived by hand. The committed record makes the answer re-runnable, so the second run reports what +moved instead of quietly replacing the first. ## Resolve home and dialect @@ -32,136 +32,149 @@ Read `${CLAUDE_PLUGIN_ROOT}/reference/config.md` first; it owns the keys, the to and the resolution order. This skill reports against that contract rather than restating it. Run `bash "${CLAUDE_PLUGIN_ROOT}/lib/resolve-convention-home.sh" --root "${CLAUDE_PROJECT_DIR}"` and -follow the exit code. Never parse the root instruction file yourself. - -- **Exit 0**: read `/architecture/README.md` for `architecture_dir` and `landscape_dialect`. -- **Exit 1** (no pointer line), **exit 3** (FAIL, surface the resolver's own message), **exit 2** - (usage): there is no declared home to read. - -Then, per key: - -1. **Topic doc.** A declared value wins. -2. **Infer, and propose.** An existing `*.dsl` in the repository proposes `structurizr`; an existing - `docs/architecture/` or `architecture/` proposes that directory. Inference PROPOSES; only the - operator's confirmation binds. -3. **Ask once.** -4. **Default.** `landscape_dialect` falls back to `mermaid`. `architecture_dir` has no default: - undeclared and unconfirmed, including every non-interactive run, STOP and point at - `/architecture:setup`. - -This skill never writes the consumer's root instruction file. `/architecture:setup apply` owns that. - -## Discover repositories - -The argument selects the mode. - -1. **`--repos [,...]`**: exactly those repositories. No discovery runs at all. -2. **`--root `** (repeatable): discovery. - - **When the `repo-fleet-hygiene` plugin is installed**, it owns bounded fleet discovery and - canonical-checkout resolution. Resolve the memory slice per - `${CLAUDE_PLUGIN_ROOT}/reference/topic-docs.md`, `mkdir -p` it, then invoke via the Skill tool: - `/repo-fleet-hygiene:audit ... --plan-file //fleet-plan.json`. - Confirm the plan's `schema_version` is `1`, read `repositories[]`, and **keep only entries - whose `discovered` path lies under a requested root**: that collaborator's config-supplied - scope is additive, so an unfiltered read would chart the operator's whole configured fleet. - Use each kept entry's `canonical` and `remote`. Announce that the collaborator's audit collects - GitHub evidence and may need `gh` authentication. - - **When the plugin is absent, or the plan file is missing or carries another `schema_version`**: - fall back to the bundled walk, and ANNOUNCE the fallback. Recurse from each root to depth 5; a - `.git` entry marks a repository and is not descended into; skip `node_modules`, `vendor`, and - `.venv`; the canonical checkout is the first record of `git worktree list --porcelain`. -3. **Neither argument**: STOP and name both forms. Never scan the session's working directory. - -## Collect facts - -Facts come from the helper script, never derived by hand: +follow the exit code. Never parse the root instruction file yourself. Exit 0 means read +`/architecture/README.md` for `architecture_dir` and `landscape_dialect`; exit 1 (no pointer +line), 2 (usage), and 3 (FAIL, surface the resolver's own message) all mean there is no declared +home to read. + +Per key, in order: `--out ` wins for this run alone, then a declared topic-doc value, then an +inference PROPOSED from repository evidence and confirmed by the operator, then one question. Two +outcomes are non-negotiable when nothing answers: `landscape_dialect` falls back to `mermaid`, and +`architecture_dir` has NO default, so an undeclared and unconfirmed home, including every +non-interactive run, STOPS and points at `/architecture:setup`. + +This skill never writes the consumer's root instruction file or its topic doc. `/architecture:setup +apply` owns both. + +## Choose the subject repositories + +1. **No scope argument**: the current repository, plus every repository its tracked files reference, + one hop out. This is the default and the primary use. Facts are collected from the current + checkout; the referenced repositories are nodes with edges and no probed facts unless they are + also checked out locally or `--remote` is passed. +2. **`--repos [,...]`**: exactly those repositories, facts and edges both. No discovery + runs at all. +3. **`--root `** (repeatable): discovery, delegated to the `repo-fleet-hygiene` plugin when it + is installed and an announced bundled walk when it is not. Read + [scope-modes.md](${CLAUDE_PLUGIN_ROOT}/skills/map-landscape/reference/scope-modes.md) before + running this mode; the collaborator's plan is wider than the roots you asked for, and charting it + unfiltered grows the landscape to the operator's whole configured fleet. + +The session's working directory is never WALKED for nested repositories under any mode. Reading the +current repository as the default subject is not a walk: it is one path, resolved from +`git rev-parse --show-toplevel`. + +## Build the record + +One call assembles both collectors into the committed record: ```bash -"${CLAUDE_SKILL_DIR}/scripts/portfolio-facts.sh" ... +"${CLAUDE_SKILL_DIR}/scripts/landscape-record.sh" \ + --source "" --remote "" \ + --edges-from ... ``` The `${CLAUDE_SKILL_DIR}` anchor matters. A bare relative path resolves against the session's working directory, which is not where the script lives. -It emits one JSON object per repository: `name`, `path`, `remote`, `owner`, `runtime`, `tooling`, -`target_framework`, `dependencies[]`, `dev_dependencies[]`, `last_touched`, `evidence{}`. Anything no -probe could derive is the literal `unknown`. Carry `unknown` through to the artifacts as-is; never -replace it with a guess, and never fill it from a commit author, a directory name, or ecosystem -memory. +`--source` and `--remote` are recorded verbatim, so a later reader can tell an explicit list from a +fleet plan, and a local-only run from one that fetched. `--edges-from` is the repository whose +tracked files supply the edges: the current repository under the default and `--root` modes, the +first path under `--repos` unless the operator names another. + +The record carries `repositories[]` from `portfolio-facts.sh` (`name`, `remote`, `owner`, `runtime`, +`tooling`, `target_framework`, `dependencies[]`, `dev_dependencies[]`, `last_touched`, `evidence{}`) +and `edges[]` from `reference-edges.sh` (`from`, `to` as `owner/repo`, `type`, `relation`, `count`, +`files[]`). + +Anything no probe could derive is the literal `unknown`. Carry it through to the artifacts as-is; +never replace it with a guess, and never fill it from a commit author, a directory name, or +ecosystem memory. `runtime` and `dependencies` are runtime scope, what the repository RUNS ON. `tooling` and `dev_dependencies` are development scope, what it is BUILT WITH: npm `devDependencies`, PEP 735 dependency groups, a `requirements-ci.txt`, anything under a dot-directory. Report them as separate facts; a linter is not a runtime. -## Draw relationships +Edge `relation` is `internal` when the target owner matches the subject's own and `external` +otherwise. An external repository is reference material: drawn and recorded, never written to, never +fetched from unless `--remote=all`. Edges are the script's output, not your judgment: do not add one +the script did not extract, and do not delete one for looking incidental, because a low `cites` +count IS the signal that the reference is weak. + +## Report drift, and honour --check + +When `/landscape.json` already exists, compare before writing anything: + +```bash +"${CLAUDE_SKILL_DIR}/scripts/landscape-record.sh" \ + --drift-against "/landscape.json" --edges-from ... +``` -Relationships are your judgment, but every edge cites evidence. An edge A to B exists ONLY when a -fact in A names B: +Exit 0 means the committed record still matches. Exit 3 means it drifted; the report names +repositories and edges added or removed, facts whose value changed, and cited evidence files that no +longer exist. A `last_touched` that moved is reported as `moved on ` and does NOT set the exit +code, because the subject repository advances its own HEAD on every commit. Surface the whole +report, gating and non-gating lines alike, before the artifacts. -- a dependency name equals B's repository name or package id; -- a `ProjectReference` or workspace path resolves into B; -- B's remote URL appears in A's tracked config or README. +`--check` stops there: run the comparison, print the report, write NOTHING, and end the run with the +comparison's own exit code. That is the CI shape, and it is the only mode in which this skill writes +no file at all when a home is declared. -The edge description is the matched string. No cited match, no edge. Do not infer an edge from -naming similarity, shared owner, or adjacent directories. +## Remote facts, only when asked + +Without `--remote`, no network call is made at all and the record says `remote: not used`. That is +the default and it is not negotiable by a referenced repository looking empty. + +With `--remote` (or `--remote=all` for externals too), read +[scope-modes.md](${CLAUDE_PLUGIN_ROOT}/skills/map-landscape/reference/scope-modes.md) for the +presence gate, the fact list, the evidence shape, and the rule that a local checkout always wins. ## Emit artifacts -Both land in ``. - -**Artifact one, the landscape.** With `landscape_dialect: structurizr`, write `landscape.dsl`: - -```text -workspace { - model { - billing = softwareSystem "billing" - web = softwareSystem "web" - web -> billing "depends on package billing" - } - views { - systemLandscape "landscape" { - include * - autoLayout - } - } -} -``` +Render, then annotate. The renderer does the mechanical work; you write only prose. -With `landscape_dialect: mermaid`, write `landscape.md`: a `C4Context` block titled "System -Landscape" with no focal system, every repository a `System`, grouped by `Enterprise_Boundary` per -remote owner when more than one owner is present, and `Rel` lines per the evidence rule above: - -````markdown -```mermaid -C4Context - title System Landscape - Enterprise_Boundary(b0, "acme") { - System(billing, "billing", "dotnet, net9.0") - System(web, "web", "node, >=22") - } - Rel(web, billing, "depends on package billing") +```bash +"${CLAUDE_SKILL_DIR}/scripts/render-landscape.sh" \ + --record "/landscape.json" --out "" \ + --dialect "" --notes "/landscape-notes.md" ``` -```` -**Artifact two, `portfolio.md`.** A heading, a generated-on line carrying the date AND the discovery -source (explicit list / fleet-hygiene plan / bundled walk), then one table: +Write `landscape.json` first, then render from it. The script writes `landscape.md` (mermaid) or +`landscape.dsl` (structurizr) plus `portfolio.md`, and appends `landscape-notes.md` verbatim when it +exists. `--top-external ` sets how many external systems the diagram draws, most referenced +first; every internal system is always drawn and the remainder is counted under the diagram. + +`landscape-notes.md` is the ONLY file in the architecture directory you author, and the only one you +never overwrite: read it, extend it, leave what a person wrote alone, and mark an annotation as an +annotation. Annotations say what a system is FOR, which the extractor cannot know. + +## Close with the report -`Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched (local HEAD)` +End every run with this block, in this order, filled from the record and the script exits: -One row per repository, sorted by name. Render `unknown` as-is. Comma-join dependencies, truncating -to 10 with a trailing `(+N)`. `Tooling` is the record's `tooling` field, the development-scope -families; the development-scope dependency names stay in the record rather than the table. +- **Artifacts**: each path written, or `none written (--check)`. +- **Repositories charted**: internal count, external count. +- **Edges by type**: `uses-workflow`, `installs-plugin`, `depends-on`, `cites`, each with its count. +- **Unknown facts**: how many fields across the record are the literal `unknown`. +- **Discovery source**: default (current repository plus reference graph), explicit list, fleet + plan, or bundled walk. +- **Remote**: not used, used for owned repositories, used for all, or requested and unavailable with + the missing backend named. +- **Drift**: none, the drift summary, or no committed record to compare against. ## What this skill does NOT do - Baseline-versus-target gap analysis, capability maps, or work-breakdown structures. - Container-level or component-level C4 views. This is the landscape altitude only. -- Modify any discovered repository. Every read is local; nothing fetches, checks out, or writes - outside ``. +- Transitive hops beyond one. A repository named by a repository this one names is not charted. +- Modify any repository other than the consumer, or write outside `` within it. + External repositories are read-only reference in every mode. +- Reach the network without `--remote`. - Write the consumer's root instruction file, inside or outside the marked region. That is `/architecture:setup apply`. -- Invent a home. No declared and no confirmed `architecture_dir` is a stop, not a default. +- Invent a home. No declared, no `--out`, and no confirmed `architecture_dir` is a stop, not a + default. ## Next @@ -176,22 +189,28 @@ families; the development-scope dependency names stay in the record rather than experimental and warns the syntax may change (source: , verified 2026-09-06). Recheck this entry when a mermaid release adds a landscape type or drops the experimental notice. +- **A referenced repository is not a checked-out one, and the portfolio says so.** Under the default + scope only the subject repository has probed facts. Everything else is a node with edges and an + `unknown` row. That is the honest answer without `--remote`, not a gap to fill by guessing. +- **`cites` is the weakest type and the loudest one.** It means a name appears in tracked text, + nothing more, and on a documentation-heavy repository it outnumbers every other type several times + over. Read a high `cites` count as "this repository talks about that one", never as a dependency. - **A dot-directory manifest is tooling, never a runtime.** Cache and build directories (`.venv`, `.mypy_cache`, `.tox`) are pruned outright; the CI and container config directories (`.github`, `.gitlab`, `.circleci`, `.devcontainer`) are kept, and every manifest under one is pinned to development scope whatever its content says. So a repository of shell and markdown whose CI - installs `ruff` reports `runtime: shell` with `tooling: python`, not a Python runtime. The same - rule makes a `package.json` carrying only `devDependencies` report tooling rather than a runtime, - which is why `target_framework` can be `unknown` while a `Tooling` entry is present. -- **`owner` is a ladder, and commit authors are not on it.** `CODEOWNERS` (root, `.github/`, or - `docs/`) default `*` rule's first owner, then the owner segment of the `origin` remote URL, then - `unknown`. Who edits a repository most is not who owns it, so the script never looks at git - authorship and neither should the write-up. -- **`last_touched` is local HEAD, and nothing fetches.** A stale checkout reports a stale date. Say - so in the generated-on line rather than implying the fleet was queried live. -- **The fleet plan is a temp artifact.** `fleet-plan.json` lives in the memory slice of the - topic-docs convention, which self-ignores. It is never committed and never treated as a durable - record of the fleet. -- **The plan's repository list is wider than the roots you asked for.** The collaborator's - config-supplied scope is additive. Filter `repositories[]` on `discovered` before charting, or the - landscape quietly grows to the operator's whole configured fleet. + installs `ruff` reports `runtime: shell` with `tooling: python`. The same rule makes a + `package.json` carrying only `devDependencies` report tooling, which is why `target_framework` can + be `unknown` while a `Tooling` entry is present. +- **`owner` is a ladder, and commit authors are not on it.** The `CODEOWNERS` default `*` rule's + first owner, then the owner segment of the `origin` remote, then `unknown`. Who edits a repository + most is not who owns it, so neither the script nor the write-up looks at git authorship. +- **`last_touched` is local HEAD unless a remote fact replaced it, and nothing fetches by default.** + A stale checkout reports a stale date. The generated-on line carries the remote state so a reader + can tell which it is. +- **The record holds no filesystem paths.** The fact collector reports where each checkout sits and + the record drops it, because a committed artifact naming one machine's directory layout differs on + every other machine that regenerates it. Read the collector directly when a run needs the path. +- **The fleet plan is a temp artifact, and wider than the roots you asked for.** Both traps belong + to `--root` alone and are stated in full in + [scope-modes.md](${CLAUDE_PLUGIN_ROOT}/skills/map-landscape/reference/scope-modes.md). diff --git a/plugins/architecture/skills/map-landscape/evals/evals.json b/plugins/architecture/skills/map-landscape/evals/evals.json index fe49165411..7c8dc48406 100644 --- a/plugins/architecture/skills/map-landscape/evals/evals.json +++ b/plugins/architecture/skills/map-landscape/evals/evals.json @@ -3,21 +3,81 @@ "evals": [ { "id": 1, + "name": "bare-invocation-charts-this-repo-and-its-references", + "prompt": "/architecture:map-landscape\n\nThe convention home resolves and its architecture topic doc is the mermaid fixture below. The current repository is a git checkout whose tracked workflows carry `uses: acme/ci-workflows/...` lines and whose docs name `acme/standards`. No landscape.json exists yet.", + "expected_output": "Charts the current repository plus the repositories its tracked files reference, one hop out, without any scope argument. Builds landscape.json from the two collector scripts, renders landscape.md and portfolio.md into docs/architecture, and closes with the fixed report.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], + "expectations": [ + "Does NOT stop asking for a scope argument: a bare invocation is the primary use, charting the current repository plus its reference graph", + "Resolves the subject from `git rev-parse --show-toplevel` rather than walking the working directory for nested repositories", + "Runs `${CLAUDE_SKILL_DIR}/scripts/landscape-record.sh` to build the record rather than calling the two collectors separately and stitching JSON by hand", + "Writes `docs/architecture/landscape.json` and renders from it with `${CLAUDE_SKILL_DIR}/scripts/render-landscape.sh`, rather than composing landscape.md or portfolio.md by hand", + "Records the discovery source as the default (current repository plus reference graph), not as an explicit list", + "acme/ci-workflows appears as a node with a `uses-workflow` edge, and acme/standards with a `cites` edge", + "Closes with the fixed report: artifacts, repositories charted, edges by type, unknown count, discovery source, remote state, drift" + ] + }, + { + "id": 2, + "name": "referenced-repos-are-nodes-without-invented-facts", + "prompt": "/architecture:map-landscape\n\nThe convention home resolves to the mermaid fixture below. The current repository references acme/ci-workflows and anthropics/claude-code. Neither is checked out anywhere on this machine, and --remote was not passed.", + "expected_output": "Draws both as systems: acme/ci-workflows inside the acme boundary because it shares the subject's owner, anthropics/claude-code as an external system. Both carry no probed facts, and the portfolio row for each is unknown rather than guessed.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], + "narration": true, + "expectations": [ + "Both referenced repositories are nodes even though neither is a local checkout", + "acme/ci-workflows is internal (its owner matches the subject's origin owner) and anthropics/claude-code is external", + "Neither gains a runtime, framework, or dependency list: an uncollected fact stays `unknown` and is NEVER filled from the repository name, ecosystem memory, or what the reference looks like", + "Makes no network call, because --remote was not passed; the record and the closing report both say remote was not used", + "Does not write to, fetch from, or otherwise touch either referenced repository" + ] + }, + { + "id": 3, + "name": "drift-against-the-committed-record", + "prompt": "/architecture:map-landscape\n\nThe convention home resolves to the mermaid fixture below. `docs/architecture/landscape.json` already exists from an earlier run. Since then a workflow gained a `uses: acme/new-service/...` line and a doc that cited acme/medley was deleted.", + "expected_output": "Compares the fresh collection against the committed record BEFORE writing, surfaces the drift report naming the added acme/new-service edge and the removed acme/medley one, then writes the refreshed record and re-renders.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], + "narration": true, + "expectations": [ + "Runs the comparison with `--drift-against docs/architecture/landscape.json` BEFORE writing anything, rather than overwriting the record and losing what changed", + "Surfaces the drift report to the operator: the added acme/new-service edge and the removed acme/medley edge are both named", + "Then writes the refreshed landscape.json and re-renders the artifacts from it, because this is not a --check run", + "Reports the drift in the closing block rather than only in passing", + "Does not treat a drifting record as an error to stop on: drift is the answer the operator asked for" + ] + }, + { + "id": 4, + "name": "check-writes-nothing-and-fails-on-drift", + "prompt": "/architecture:map-landscape --check\n\nThe convention home resolves to the mermaid fixture below. `docs/architecture/landscape.json` exists and the repository has since gained a workflow `uses:` line naming a repository the record does not carry. This is a non-interactive CI run.", + "expected_output": "Runs the comparison, prints the drift report, writes no file at all, and ends the run with the comparison's non-zero exit code.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], + "narration": true, + "expectations": [ + "Writes NOTHING: no landscape.json, no landscape.md, no portfolio.md, no temp or fallback file anywhere", + "Ends the run with the comparison's non-zero exit code so a CI lane fails on drift", + "Prints the drift report naming the new edge, so the failure explains itself", + "Does not fall back to writing the refreshed record 'so the next run is clean': --check is read-only by contract" + ] + }, + { + "id": 5, "name": "explicit-list-bypasses-discovery", "prompt": "/architecture:map-landscape --repos /srv/code/platform/billing,/srv/code/platform/web\n\nThe repo-fleet-hygiene plugin is NOT installed. The convention home resolves and its architecture topic doc is the mermaid fixture below.", - "expected_output": "Charts exactly the two listed repositories with no discovery of any kind. Runs the bundled facts script over both paths, writes landscape.md (mermaid C4Context) and portfolio.md into docs/architecture, and never mentions a fallback walk because no discovery ran.", + "expected_output": "Charts exactly the two listed repositories with no discovery of any kind. Collects facts for both through the record script, renders landscape.md (mermaid C4Context) and portfolio.md into docs/architecture, and never mentions a fallback walk because no discovery ran.", "files": ["evals/fixtures/topic-doc-mermaid.md"], "expectations": [ "Charts EXACTLY the two paths given; runs no discovery walk and no fleet-hygiene invocation, because an explicit list bypasses discovery outright", + "Does NOT chart the current repository as well: an explicit list replaces the default subject, it does not extend it", "Does NOT announce a bundled-walk fallback: the fallback belongs to the --root mode, and announcing it here would misreport how the set was chosen", - "Runs `${CLAUDE_SKILL_DIR}/scripts/portfolio-facts.sh` with both paths rather than reading manifests by hand", - "Writes landscape.md (mermaid dialect from the topic doc) and portfolio.md into docs/architecture, the declared architecture_dir", - "The portfolio table carries all six columns (Repository, Owner, Target framework, Runtime, Dependencies, Last touched) with one row per repository, sorted by name", - "Never scans the session's working directory" + "Records the discovery source as the explicit list, so a later reader can tell it from a default run", + "The portfolio table carries all seven columns (Repository, Owner, Target framework, Runtime, Dependencies, Tooling, Last touched) with one row per repository, sorted by name", + "Never walks the session's working directory for nested repositories" ] }, { - "id": 2, + "id": 6, "name": "root-with-collaborator-installed", "prompt": "/architecture:map-landscape --root /srv/code/platform\n\nThe repo-fleet-hygiene plugin IS installed. Its audit writes the plan fixture below, whose repositories[] includes an entry outside the requested root.", "expected_output": "Invokes /repo-fleet-hygiene:audit with --plan-file pointing into the topic-docs memory slice, confirms schema_version is 1, filters repositories[] to entries whose discovered path lies under /srv/code/platform, and charts billing and web from their canonical paths. The operator's personal scratchpad, present in the plan, is excluded.", @@ -28,14 +88,15 @@ "expectations": [ "Invokes /repo-fleet-hygiene:audit via the Skill tool with `--plan-file //fleet-plan.json`, creating the memory slice first", "Confirms the plan's `schema_version` is `1` before reading `repositories[]`", - "FILTERS `repositories[]` to entries whose `discovered` path lies under the requested root: /srv/personal/scratchpad is present in the plan and MUST NOT appear in either artifact", + "FILTERS `repositories[]` to entries whose `discovered` path lies under the requested root: /srv/personal/scratchpad is present in the plan and MUST NOT appear in any artifact", "Uses each kept entry's `canonical` path (so the web worktree charts as /srv/code/platform/web) and its `remote`", "Announces that the collaborator's audit collects GitHub evidence and may need `gh` authentication", + "Records the discovery source as the fleet plan, not as a bundled walk or an explicit list", "Treats fleet-plan.json as a temp artifact in the self-ignoring memory slice; never commits it or copies it into architecture_dir" ] }, { - "id": 3, + "id": 7, "name": "root-with-collaborator-absent-falls-back", "prompt": "/architecture:map-landscape --root /srv/code/platform\n\nThe repo-fleet-hygiene plugin is NOT installed.", "expected_output": "Announces the fallback, then runs the bundled walk: recurse from the root to depth 5, treat a .git entry as a repository without descending into it, skip node_modules, vendor and .venv, and resolve each canonical checkout from the first record of `git worktree list --porcelain`.", @@ -45,28 +106,43 @@ "Recurses from the root to depth 5, treats a `.git` entry as a repository and does NOT descend into it", "Skips `node_modules`, `vendor`, and `.venv`", "Resolves each canonical checkout from the first record of `git worktree list --porcelain`, not from `git rev-parse --show-toplevel`", - "Falls back the same way when the plan file is missing or carries a schema_version other than 1, rather than parsing an unrecognized plan" + "Falls back the same way when the plan file is missing or carries a schema_version other than 1, rather than parsing an unrecognized plan", + "Records the discovery source as the bundled walk" ] }, { - "id": 4, + "id": 8, "name": "structurizr-dialect-from-topic-doc", "prompt": "/architecture:map-landscape --repos /srv/code/platform/billing,/srv/code/platform/web\n\nThe convention home resolves and its architecture topic doc is the structurizr fixture below.", - "expected_output": "Emits landscape.dsl (not landscape.md) into the declared architecture directory: a workspace with a model of softwareSystem elements and -> relationships, and views { systemLandscape \"landscape\" { include * autoLayout } }. Relationships appear only where a collected fact names the target.", + "expected_output": "Passes --dialect structurizr to the render script, which emits landscape.dsl (not landscape.md) into the declared architecture directory: a workspace with a model of softwareSystem elements grouped by owner, -> relationships labelled by edge type and count, and views { systemLandscape \"landscape\" { include * autoLayout } }.", "files": ["evals/fixtures/topic-doc-structurizr.md"], "expectations": [ "Emits `landscape.dsl`, NOT `landscape.md`, because the topic doc declares landscape_dialect: structurizr", + "Reaches that output by passing --dialect to the render script, rather than writing DSL by hand", "The DSL carries a `workspace` with a `model` of `softwareSystem` elements and `->` relationships", "The DSL carries `views { systemLandscape \"landscape\" { include * autoLayout } }`", "Writes into `architecture/`, the declared architecture_dir, not a guessed docs/architecture", - "Draws an edge ONLY where a collected fact in the source names the target (dependency name equals the target's repository name or package id, a ProjectReference or workspace path resolves into it, or its remote URL appears in tracked config or README), and states the matched string as the edge description", - "Does not infer an edge from naming similarity, a shared owner, or directory adjacency" + "Relationship labels are the edge type and count the extractor produced, not a re-description of what the relationship means" ] }, { - "id": 5, + "id": 9, + "name": "out-overrides-the-declared-home-for-one-run", + "prompt": "/architecture:map-landscape --out /tmp/scratch-landscape\n\nThe convention home resolves and its architecture topic doc declares architecture_dir: docs/architecture (the mermaid fixture below).", + "expected_output": "Writes the record and both artifacts into /tmp/scratch-landscape for this run only. docs/architecture is untouched, and the topic doc is not rewritten to point at the override.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], + "narration": true, + "expectations": [ + "Writes into /tmp/scratch-landscape, the override, rather than the declared docs/architecture", + "Leaves docs/architecture untouched: an override is one run, not a change of home", + "Does NOT rewrite the topic doc or the root instruction file to record the override", + "Still emits the dialect the topic doc declares: --out overrides the directory, not the dialect" + ] + }, + { + "id": 10, "name": "no-topic-doc-and-no-inference-evidence", - "prompt": "/architecture:map-landscape --repos /srv/code/platform/billing\n\nThe resolver exits 1 (no pointer line anywhere). The repository contains no *.dsl file, no docs/architecture/ and no architecture/ directory. This is a non-interactive run.", + "prompt": "/architecture:map-landscape\n\nThe resolver exits 1 (no pointer line anywhere). The repository contains no *.dsl file, no docs/architecture/ and no architecture/ directory. No --out was passed. This is a non-interactive run.", "expected_output": "Stops and points at /architecture:setup. architecture_dir has no default, inference found no evidence to propose, and a non-interactive run cannot confirm one, so no artifact is written anywhere.", "files": [], "narration": true, @@ -74,23 +150,38 @@ "STOPS and points at `/architecture:setup`; does not pick a directory", "Writes NO artifact anywhere, including no temp or fallback location", "Names architecture_dir specifically as the key with no default, distinguishing it from landscape_dialect which does default to mermaid", - "Does not write the consumer's root instruction file, inside or outside the marked convention-home region", - "Does not treat the absent pointer line as permission to infer a home" + "Does not treat the bare invocation's default SUBJECT scope as permission to default the output HOME: the two are separate decisions", + "Does not write the consumer's root instruction file, inside or outside the marked convention-home region" ] }, { - "id": 6, - "name": "no-scope-names-both-forms", - "prompt": "/architecture:map-landscape", - "expected_output": "Stops and names both scope forms, --repos [,...] and --root . Does not scan the session's working directory, and does not treat the current repository as an implied single-repository scope.", - "files": [], + "id": 11, + "name": "remote-requested-but-unavailable", + "prompt": "/architecture:map-landscape --remote\n\nThe convention home resolves to the mermaid fixture below. Neither the GitHub MCP tools nor an authenticated `gh` is available in this session.", + "expected_output": "Presence-gates, finds no backend, names the missing backend in the closing report, and completes the run local-only rather than failing. Referenced repositories keep their unknown rows.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], + "narration": true, + "expectations": [ + "Checks for the GitHub MCP tools and then for an authenticated `gh` BEFORE attempting any fetch", + "Completes the run local-only rather than aborting: an unavailable backend degrades the facts, it does not invalidate the landscape", + "Names the missing backend in the closing report so the operator knows why the rows are unknown", + "NEVER prompts for a token, a password, or any other credential", + "Referenced repositories keep `unknown` facts rather than gaining plausible ones" + ] + }, + { + "id": 12, + "name": "remote-facts-lose-to-a-local-checkout", + "prompt": "/architecture:map-landscape --repos /srv/code/platform/billing --remote\n\nThe convention home resolves to the mermaid fixture below. The GitHub MCP tools are available. /srv/code/platform/billing is a local checkout whose probe finds runtime dotnet and last_touched 2026-01-01; the remote reports primary language C# and pushed_at 2026-06-01.", + "expected_output": "Keeps the local probe's facts for billing and does not overwrite them with the remote's. Remote facts fill only what no local checkout could supply, and each one's evidence names the call that produced it.", + "files": ["evals/fixtures/topic-doc-mermaid.md"], "narration": true, "expectations": [ - "STOPS rather than choosing a scope", - "Names BOTH forms explicitly: `--repos [,...]` and `--root `", - "Does NOT scan the session's working directory", - "Does NOT treat the current repository as an implied one-repository landscape", - "Runs no facts collection and writes no artifact" + "billing keeps runtime dotnet and last_touched 2026-01-01 from the local probe: a local checkout always wins for a fact both sources carry", + "Does NOT silently replace last_touched with pushed_at for a repository that is checked out here", + "Any fact that DID come from the remote names the API call or `gh` command in its evidence entry, not a bare 'remote'", + "A remote-sourced last_touched is labelled as pushed_at rather than local HEAD, so the two are never conflated", + "The record and the closing report both say remote was used, so a later local-only run can explain a moving date" ] } ] diff --git a/plugins/architecture/skills/map-landscape/reference/scope-modes.md b/plugins/architecture/skills/map-landscape/reference/scope-modes.md new file mode 100644 index 0000000000..6fa7b5f661 --- /dev/null +++ b/plugins/architecture/skills/map-landscape/reference/scope-modes.md @@ -0,0 +1,62 @@ +# Scope modes beyond the default + +The default scope, the current repository plus its reference graph, needs nothing from this file. +Read this when the invocation carries `--root` or `--remote`. + +## `--root ` discovery + +Repeatable. Discovers repositories under each root, then charts them. + +### When the `repo-fleet-hygiene` plugin is installed + +That plugin owns bounded fleet discovery and canonical-checkout resolution, so delegate rather than +walking. Resolve the memory slice per `${CLAUDE_PLUGIN_ROOT}/reference/topic-docs.md`, `mkdir -p` it, +then invoke via the Skill tool: + +```text +/repo-fleet-hygiene:audit ... --plan-file //fleet-plan.json +``` + +Confirm the plan's `schema_version` is `1` before reading `repositories[]`. Use each entry's +`canonical` path and its `remote`. Announce that the collaborator's audit collects GitHub evidence +and may need `gh` authentication. + +**Filter `repositories[]` on `discovered` before charting anything.** That collaborator's scope is +additive: it merges the roots you asked for with the roots its own config declares. Keep only +entries whose `discovered` path lies under a root this invocation named. An unfiltered read charts +the operator's whole configured fleet, including personal directories nobody asked about. + +`fleet-plan.json` is a temp artifact. It lives in the memory slice of the topic-docs convention, +which self-ignores. Never commit it, and never copy it into the architecture directory as a record +of the fleet. + +### When the plugin is absent + +Fall back to the bundled walk, and ANNOUNCE the fallback: the operator has to know the collaborator +did not run. Fall back the same way when the plan file is missing or carries a `schema_version` +other than `1`, rather than parsing a plan shape nothing verified. + +The walk: recurse from each root to depth 5; a `.git` entry marks a repository and is not descended +into; skip `node_modules`, `vendor`, and `.venv`; the canonical checkout is the first record of +`git worktree list --porcelain`, not `git rev-parse --show-toplevel`. + +## `--remote` facts + +`--remote` collects facts for referenced repositories that are not checked out locally. +`--remote=all` extends that to external ones. Without the flag no network call is made at all, and +the record says `remote: not used`. + +- **Presence-gate first.** Use the GitHub MCP tools when they resolve, otherwise an authenticated + `gh`. When neither is available, name the missing backend in the closing report and continue + local-only rather than failing the run. Never prompt for credentials. +- **A local checkout always wins.** A repository probed from a local checkout keeps those facts; + remote facts fill only what no local checkout could supply. +- **Fetch these and nothing else**: primary language, default branch, `pushed_at` (recorded as + `last_touched`), archived, visibility, and the CODEOWNERS default rule when readable. Manifests + only through the contents API, only at the repository root, and only the manifest names the + runtime probe already knows. +- **Every remote fact names its call.** Its `evidence` entry is the API call or `gh` command that + supplied it, and it reads `pushed_at (remote)` rather than the local-HEAD wording, so a later + local-only run can explain why `last_touched` moved backwards. +- **Externals stay read-only.** `--remote=all` reads facts about an external repository. It never + writes to one, and it does not extend the graph a second hop. diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh index 88bb481db6..5ce1bc450a 100755 --- a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh @@ -35,13 +35,17 @@ # } # # One object per line is deliberate: it keeps the record diffable in review and -# parseable here without a JSON library. +# parseable here without a JSON library. The collector's `path` field is dropped +# on the way in: it records where a checkout happens to sit on one machine, which +# is not a fact about the architecture and would make the committed record differ +# on every machine that regenerates it. # # Output with --drift-against: a plain-text report naming repositories added or # removed, edges added or removed, facts whose value changed, and cited evidence -# files that no longer exist. `path` is excluded from fact comparison because it -# records where a checkout happens to sit on one machine, which is not a fact -# about the architecture. +# files that no longer exist. `path` is excluded from fact comparison too, so a +# record written before it was dropped still compares clean. A `last_touched` +# that moved is reported but never gated on: the subject repository advances its +# own HEAD on every commit, and a check lane that went red for that gets muted. # # Nothing here fetches and nothing is written: the record goes to stdout, and the # caller decides where it lands. @@ -258,7 +262,22 @@ if [[ -z "$compare_to" ]]; then printf ' "generated_on": "%s",\n' "$(date -u +%Y-%m-%d)" printf ' "discovery_source": "%s",\n' "$(json_escape "$source_text")" printf ' "remote": "%s",\n' "$(json_escape "$remote_text")" - emit_array repositories "$facts_out" "," + # `path` is dropped on the way in. It records where a checkout happened to sit + # on one machine at one moment, which is not a fact about the architecture and + # would make the committed record differ on every machine that regenerates it. + emit_array repositories "$(printf '%s\n' "$facts_out" | awk "$SPLIT_AWK"' + NF { + n = split_object($0, k, v) + out = "{" + first = 1 + for (i = 1; i <= n; i++) { + if (k[i] == "path") continue + out = out (first ? "" : ",") "\"" k[i] "\":" v[i] + first = 0 + } + print out "}" + } + ')" "," emit_array edges "$edges_out" "" printf '}\n' exit 0 @@ -277,10 +296,17 @@ old_edges="$(sed -n 's/^[[:space:]]*\({"from":.*}\),\{0,1\}$/\1/p' "$compare_to" drift=0 report="" +notes="" say() { report="$report$1"$'\n' drift=1 } +# A timestamp moving forward is expected of any repository anyone is working in, +# so it is reported and never gated on: a `--check` lane that went red on every +# commit to the subject repository would be turned off within a week. +note() { + notes="$notes$1"$'\n' +} compare_set() { # $1 label, $2 key expression, $3 old lines, $4 new lines @@ -341,7 +367,10 @@ compare_fields() { ')" while IFS= read -r c; do [[ -n "$c" ]] || continue - say " changed $label on $id: $c" + case "$c" in + last_touched:*) note " moved on $id: $c" ;; + *) say " changed $label on $id: $c" ;; + esac done <<<"$changed" done <<<"$4" } @@ -379,8 +408,10 @@ done <<<"$missing" if [[ "$drift" -eq 0 ]]; then printf 'Landscape drift: none. The committed record matches a fresh collection.\n' + [[ -z "$notes" ]] || printf '%s' "$notes" exit 0 fi printf '%s' "$report" +[[ -z "$notes" ]] || printf '%s' "$notes" exit 3 diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh index 8f2c280cef..1eb27da1a1 100644 --- a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh @@ -92,6 +92,7 @@ assert_contains "record: it carries a generated-on date" "$out" '"generated_on": assert_contains "record: the discovery source defaults to the explicit list" "$out" '"discovery_source": "explicit list"' assert_contains "record: remote is off unless asked for" "$out" '"remote": "not used"' assert_contains "record: the repository's facts are embedded whole" "$out" '{"name":"hub",' +assert_not_contains "record: minus the local checkout path, which is not architecture" "$out" '"path":' assert_contains "record: so are the edges" "$out" '"to":"fixture-owner/ci-workflows"' assert_contains "record: including the weaker cites edge" "$out" '"to":"fixture-owner/standards"' @@ -157,11 +158,21 @@ out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/fact-drift.json")" assert_equals "drift: a changed fact exits 3 too" "$?" "3" assert_contains "drift: it names the field, the old value and the new" "$out" 'changed fact on hub: runtime: "rust" -> "shell"' -sed 's|"path":"[^"]*"|"path":"/somewhere/else"|' \ +# A record written by an earlier version still carries `path`; comparing against +# one must not report the local checkout location as architecture drift. +sed 's|{"name":"hub",|{"name":"hub","path":"/somewhere/else",|' \ "$TEST_TMPDIR/committed.json" >"$TEST_TMPDIR/path-drift.json" out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/path-drift.json")" assert_equals "drift: a moved checkout is not architecture drift" "$?" "0" +# A repository anyone is working in moves its HEAD constantly, so the timestamp +# is reported and never gated on: a check lane red on every commit gets muted. +sed 's|"last_touched":"[^"]*"|"last_touched":"2020-01-01T00:00:00+00:00"|' \ + "$TEST_TMPDIR/committed.json" >"$TEST_TMPDIR/time-drift.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/time-drift.json")" +assert_equals "drift: a newer HEAD does not fail the check" "$?" "0" +assert_contains "drift: but it is still reported" "$out" "moved on hub: last_touched" + # A repository that left the record, and one that joined it. out="$(bash "$SCRIPT" "$repo" "$quiet" --edges-from "$repo" \ --drift-against "$TEST_TMPDIR/committed.json")" From 4ce96d028a8f520ed722becd3befccefe7192b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 18:52:37 +0000 Subject: [PATCH 10/15] docs(architecture): mark archived repositories, and hold --check read-only A landscape that quietly drops archived repositories hides exactly the dependencies worth acting on, so a --remote run charts and marks them. --check gains the instruction it was missing: report the comparison's exit code as the run's outcome and do not refresh the record to make the next run clean. A check lane that fixes what it is checking has stopped being a check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- plugins/architecture/skills/map-landscape/SKILL.md | 7 ++++--- .../skills/map-landscape/reference/scope-modes.md | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/architecture/skills/map-landscape/SKILL.md b/plugins/architecture/skills/map-landscape/SKILL.md index ab8da1943c..301e002137 100644 --- a/plugins/architecture/skills/map-landscape/SKILL.md +++ b/plugins/architecture/skills/map-landscape/SKILL.md @@ -117,9 +117,10 @@ longer exist. A `last_touched` that moved is reported as `moved on ` and d code, because the subject repository advances its own HEAD on every commit. Surface the whole report, gating and non-gating lines alike, before the artifacts. -`--check` stops there: run the comparison, print the report, write NOTHING, and end the run with the -comparison's own exit code. That is the CI shape, and it is the only mode in which this skill writes -no file at all when a home is declared. +`--check` stops there: run the comparison, print the report, write NOTHING, and report the +comparison's exit code as the run's outcome, so a lane invoking the script directly fails on drift. +That is the CI shape, and the only mode in which this skill writes no file at all when a home is +declared. Do not "helpfully" refresh the record so the next run is clean. ## Remote facts, only when asked diff --git a/plugins/architecture/skills/map-landscape/reference/scope-modes.md b/plugins/architecture/skills/map-landscape/reference/scope-modes.md index 6fa7b5f661..e53ade9c8f 100644 --- a/plugins/architecture/skills/map-landscape/reference/scope-modes.md +++ b/plugins/architecture/skills/map-landscape/reference/scope-modes.md @@ -58,5 +58,8 @@ the record says `remote: not used`. - **Every remote fact names its call.** Its `evidence` entry is the API call or `gh` command that supplied it, and it reads `pushed_at (remote)` rather than the local-HEAD wording, so a later local-only run can explain why `last_touched` moved backwards. +- **An archived repository is charted and marked.** Archiving is a fact about the system, not a + reason to hide it: a landscape that quietly drops archived repositories hides exactly the + dependencies worth acting on. Mark it in the node annotation and in the portfolio row. - **Externals stay read-only.** `--remote=all` reads facts about an external repository. It never writes to one, and it does not extend the graph a second hop. From 9e3f926dc6bf01aa0789c54a8e6fa5fa7c37a66e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 03:35:07 +0000 Subject: [PATCH 11/15] fix(architecture): clear the three hygiene gates this branch tripped exec-bit: the two new test scripts carry a shebang without the executable bit, which the repo-wide gate rejects. changed-skills: the render suite read `npx --no-install` exiting non-zero as "the renderer emits bad markdown", when on a host without the package installed it only means the tool is absent. The two are indistinguishable at the exit code, so the check now probes on a file known to be clean first and skips explicitly when that probe fails. contract-slice-diff: docs/topics/ is contract tier, committed on a task branch and pruned before merge. The Brief's durable decisions graduate to docs/adr/0032: charting from a repository's own references, the one-syntax-per edge type rule, runtime versus development as a scope axis, the committed record the artifacts derive from, and other-owner repositories as read-only reference. Its Evidence section carries the standards survey and the concrete false matches that ruled out a naive reference regex. The slice itself is gone; the PR body names the pre-prune commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- ...om-references-and-commit-it-as-a-record.md | 88 ++++++++++++++++++ docs/topics/map-landscape-redesign/PLAN.md | 90 ------------------- .../scripts/landscape-record.test.sh | 0 .../scripts/render-landscape.test.sh | 15 +++- 4 files changed, 101 insertions(+), 92 deletions(-) create mode 100644 docs/adr/0032-chart-a-landscape-from-references-and-commit-it-as-a-record.md delete mode 100644 docs/topics/map-landscape-redesign/PLAN.md mode change 100644 => 100755 plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh mode change 100644 => 100755 plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh diff --git a/docs/adr/0032-chart-a-landscape-from-references-and-commit-it-as-a-record.md b/docs/adr/0032-chart-a-landscape-from-references-and-commit-it-as-a-record.md new file mode 100644 index 0000000000..b29e109860 --- /dev/null +++ b/docs/adr/0032-chart-a-landscape-from-references-and-commit-it-as-a-record.md @@ -0,0 +1,88 @@ +# Chart a landscape from references and commit it as a record + +- Status: accepted +- Date: 2026-09-11 + +## Context + +`/architecture:map-landscape` charted only repositories that were local +checkouts, and it drew a relationship only where a collected fact in one +checkout named another. In a cloud session, or on any machine holding one +repository rather than a fleet, that yields a single box: this checkout names +nineteen other organisation repositories in its workflows, marketplace sources +and docs, and the skill saw none of them. + +Two further defects came out of the same pass. The facts probe mixed runtime +and development dependencies, so a repository of shell and markdown whose CI +installs `ruff` reported a Python runtime. And the artifacts were rendered by +hand from collected JSON, so two runs over identical facts produced different +files and nothing recorded what had changed between them. + +## Decision + +**A repository's own references are the landscape.** A bare invocation charts +the current repository plus every repository its tracked files name, one hop +out. Edges are extracted by a script, typed by the syntax that carries them, +and counted; `--repos` and `--root` remain as explicit overrides. The working +directory is still never walked for nested repositories. + +**Each edge type trusts exactly one syntax.** `uses-workflow` a workflow +`uses:` step, `installs-plugin` a marketplace source, `depends-on` a module +path, `cites` a github.com URL or a bare `owner/repo` whose owner matches the +subject's own. A single `owner/repo` regex over all tracked text is rejected. + +**Runtime versus development is a scope axis, not a path bucket.** A manifest's +scope is read from the manifest, with one override: anything under a +dot-directory is development scope whatever its content says. + +**The answer is committed as a record, and the artifacts derive from it.** +`landscape.json` holds facts and edges; both rendered artifacts are produced +from it by a script doing only reasoning-free work. A later run compares before +it writes and reports what moved, and `--check` makes that comparison a CI gate. + +**A repository outside the subject's own owner is read-only reference.** It is +drawn and recorded, never written to, and never fetched from unless the +explicitly opt-in `--remote=all` is passed. + +## Evidence + +**Every surveyed dependency standard separates runtime from development on +scope, and none on path.** CycloneDX carries a `scope` field +(`required`/`optional`/`excluded`); SPDX types the relationship +(`RUNTIME_DEPENDENCY_OF`, `DEV_DEPENDENCY_OF`, `BUILD_TOOL_OF`, +`TEST_TOOL_OF`); npm splits `dependencies` from `devDependencies`; PEP 735 +adds `[dependency-groups]`; the GitHub dependency-submission API takes +`scope: runtime|development`. A separate `ci_tooling` bucket keyed on path was +considered and rejected against this evidence. + +**The dot-directory override has its own precedent.** GitHub Linguist vendors +`(^|/)\.github/` out of a repository's language statistics, on the same +reasoning: what a repository's automation installs is not what the repository +runs on. + +**A naive reference regex is not merely imprecise, it is wrong on this +repository.** Over all tracked text, `owner/repo` matches `sponsors/…` from a +funding URL, `en/…` from a documentation path, and every `acme/billing` in +every test fixture. Each surviving edge type anchors on a syntax that only ever +names a repository, or requires the owner to match the subject's own. + +**Committing the record is what makes drift reportable.** Without it each run +is a snapshot with no memory, and nothing distinguishes a system that was +removed from one that was never charted. Two fields are deliberately excluded +from the comparison: the collector's `path`, because a committed artifact +naming one machine's directory layout differs on every other machine that +regenerates it, and `last_touched`, because the subject repository advances its +own HEAD on every commit and a check lane red for that gets turned off. + +## Consequences + +A referenced repository that is not checked out is a node with edges and no +probed facts. That is the honest answer without `--remote`, and the portfolio +reports it as `unknown` rather than filling it in. + +`cites` outnumbers every other edge type several times over on a +documentation-heavy repository. It means a name appears in tracked text and +nothing more; a high count is not a dependency. + +The landscape is bounded at one hop. A repository named by a repository this +one names is not charted. diff --git a/docs/topics/map-landscape-redesign/PLAN.md b/docs/topics/map-landscape-redesign/PLAN.md deleted file mode 100644 index bf7573c2e4..0000000000 --- a/docs/topics/map-landscape-redesign/PLAN.md +++ /dev/null @@ -1,90 +0,0 @@ -# map-landscape-redesign - -## Brief - -### TLDR - -- A bare `/architecture:map-landscape` charts the current repository plus its reference graph, one - hop out; `--repos` and `--root` stay as explicit overrides. -- References become typed, counted edges extracted per source type by a tested script; other-owner - repositories render as external systems, kept read-only. -- The committed record is `docs/architecture/landscape.json`; both rendered artifacts derive from it - through a reasoning-free render script, and every re-run reports drift, with `--check` for CI. -- The facts script gains a runtime-versus-development scope axis, prunes `.github/` and other - dot-directories from runtime probes, and adds a Tooling column. -- `--remote` facts are opt-in and presence-gated; a fixed closing report and a rewritten description - make single-repository-plus-references the primary use. - -### Goal - -Rework the `map-landscape` skill so that one run in one repository yields a useful, committed, -re-runnable landscape of that repository and everything it references. Today the skill only reads -local checkouts and stops on a bare invocation, so on a cloud checkout of this repository it drew one -node with no edges even though the tracked files name nineteen other organisation repositories. - -### Constraints - -- Facts and edges come from tested scripts; the model adds prose annotations only. Rendering scripts - do only reasoning-free work (tables, alias sanitising, boundary grouping, labels). -- The declared-home doctrine in `plugins/architecture/reference/config.md` stays: an interactive run - with no home proposes `docs/architecture`, `--out ` overrides one run, and a non-interactive run - with neither still stops. -- No network call is made unless `--remote` is passed. Externals are never fetched from unless - `--remote=all`, and nothing ever writes to another repository. -- Scripts stay bash plus POSIX awk/grep/sed, matching `portfolio-facts.sh`; no jq, no python. -- The working directory is never walked for nested repositories. -- Skill bodies follow `.claude/rules/skill-bodies-state-current-rules.md`; validation runs through - `scripts/affected-tests.sh --run`. - -### Acceptance criteria - -- A bare run in this checkout produces a landscape with `ci-workflows` and `standards` as nodes and - typed edges to them, without any argument. -- Every edge cites its source file and type; every fact cites its evidence. -- IF no home is declared and the run is non-interactive, THEN nothing is written. -- IF `--remote` is absent, THEN no network call is made. -- WHILE a committed `docs/architecture/landscape.json` exists, a re-run reports drift instead of - silently overwriting, and `--check` exits non-zero on drift. -- This checkout no longer reports Python as a runtime; its CI pins appear under Tooling. -- Rendering a fixed facts-plus-edges fixture twice yields byte-identical artifacts. -- Existing evals are updated, and the `no-scope-names-both-forms` eval is replaced by one asserting - the bare default. - -### Captured assumptions - -- The GitHub MCP or an authenticated `gh` is available when `--remote` is used; otherwise the flag - reports unavailability and continues local-only. Revisit if a consumer needs remote facts with - neither. -- Mermaid stays the default dialect. Revisit if Structurizr becomes the organisation default. -- Same-owner is decided from the current repository's `origin` owner segment. Revisit if a consumer - has repositories split across owners it considers one enterprise. -- A separate `ci_tooling` bucket was rejected in favour of a scope axis because every surveyed - standard (CycloneDX scope, SPDX dependency relationships, npm devDependencies, PEP 735 dependency - groups, GitHub dependency-graph scope) separates runtime from development on scope, and GitHub - Linguist vendors `.github/` out of language statistics. Revisit if a manifest family appears whose - scope cannot be read from the file. - -### Out-of-scope - -- Transitive hops beyond one without `--remote`. -- Branch or worktree hygiene (`/repo-fleet-hygiene:audit`), GitHub organisation settings - (`/github:audit`), doc-versus-code drift inside one repository (`/codebase-health:audit`), and - module-level structure (`/architecture:improve`). -- Container-level or component-level C4 views. -- Writing the consumer's root instruction file; `/architecture:setup apply` owns that. - -### Deferred questions - -None. All twelve interview questions were answered; see the memory-tier ledger. - -### Sequencing - -One tracking issue with four children, in order, each shipped as its own draft PR: - -1. Manifest scope hygiene in `portfolio-facts.sh` (scope axis, dot-directory pruning, Tooling). -2. Core reshape: bare default scope, reference-edge script, `landscape.json` record with drift and - `--check`, closing report, description and README rewrite, evals. -3. Render script for both dialects. -4. `--remote` facts. - -## Plan diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh old mode 100644 new mode 100755 diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh old mode 100644 new mode 100755 index e71264b2a8..54af4ef3a1 --- a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh @@ -220,12 +220,23 @@ assert_contains "owners: the first owner gets a boundary" "$owners" 'Enterprise_ assert_contains "owners: the second gets its own, not a shared one" "$owners" 'Enterprise_Boundary(b1, "zeta")' # --- Case group 9: markdown that lints -------------------------------------- -if command -v npx >/dev/null 2>&1 && [[ -z "${SKIP_MARKDOWNLINT:-}" ]]; then +# +# Probed on a file known to be clean first. `npx --no-install` exits non-zero +# when the package is simply absent, which is indistinguishable at the exit code +# from a lint failure, and reading "tool missing" as "the renderer emits bad +# markdown" would fail this suite on any host without the dependency installed. +markdownlint_usable() { + [[ -z "${SKIP_MARKDOWNLINT:-}" ]] || return 1 + command -v npx >/dev/null 2>&1 || return 1 + printf '# Probe\n\nOne clean paragraph.\n' >"$TEST_TMPDIR/probe.md" + npx --no-install markdownlint-cli2 "$TEST_TMPDIR/probe.md" >/dev/null 2>&1 +} +if markdownlint_usable; then npx --no-install markdownlint-cli2 "$TEST_TMPDIR/mermaid/landscape.md" \ "$TEST_TMPDIR/mermaid/portfolio.md" >/dev/null 2>&1 assert_equals "lint: the rendered markdown passes markdownlint" "$?" "0" else - pass "lint: markdownlint skipped, npx or the package is unavailable" + pass "lint: markdownlint skipped, the package is not installed here" fi # --- Case group 10: usage --------------------------------------------------- From c971100c26f8fd14e1b305519e65d1765d98ff11 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 04:41:40 +0000 Subject: [PATCH 12/15] fix(architecture): stop the landscape record feeding itself, and four more Adjudicating this branch's open decisions against official sources surfaced six defects. Every design decision was confirmed; these are the bugs found on the way. The serious one: once landscape.json was tracked, the edge extractor read it back as evidence. The record names every repository it charted, so each run raised every citation count by one and listed the record among its own sources. It hid because git grep scans only tracked files, so the first build (with the record still untracked) looked clean and committing it armed the loop. A drift gate that can never report clean is not a gate. This skill's own artifact names are fixed by its contract while only their directory varies, so the extractor now skips them by basename, and regenerating twice is byte-identical. A record built with remote facts carries repositories no local-only run can produce, and every one of them reported as removed. The comparison now checks the recorded posture against the run's own and says when the two sides are not the same kind of thing. Two checkouts sharing a basename collapsed onto one identity and silently matched the wrong row; that is now reported rather than guessed at. A clean comparison carrying non-gating differences no longer says the record "matches" and then lists what moved. A repository with no resolvable owner is drawn outside every boundary in both dialects, because a boundary is captioned with an organisation and `unknown` is the absence of one. The Structurizr External tag gains the styles block that makes it render, since `location` was removed upstream and the tag is the only carrier left for that fact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape.json | 12 ++++--- docs/architecture/landscape.md | 6 ++-- docs/architecture/portfolio.md | 4 +-- plugins/architecture/CHANGELOG.md | 20 +++++++++++ .../map-landscape/scripts/landscape-record.sh | 36 +++++++++++++++++-- .../scripts/landscape-record.test.sh | 32 +++++++++++++++++ .../map-landscape/scripts/reference-edges.sh | 15 ++++++++ .../scripts/reference-edges.test.sh | 18 ++++++++++ .../map-landscape/scripts/render-landscape.sh | 24 ++++++++++++- .../scripts/render-landscape.test.sh | 12 ++++++- 10 files changed, 166 insertions(+), 13 deletions(-) diff --git a/docs/architecture/landscape.json b/docs/architecture/landscape.json index 038fd1f92b..1bd8f9b9a4 100644 --- a/docs/architecture/landscape.json +++ b/docs/architecture/landscape.json @@ -1,10 +1,10 @@ { "schema_version": 1, - "generated_on": "2026-09-10", + "generated_on": "2026-09-11", "discovery_source": "current repository plus reference graph", "remote": "not used", "repositories": [ - {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-10T18:21:45+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} + {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-11T03:35:07+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} ], "edges": [ {"from":"claude-code-plugins","to":"DavidAnson/markdownlint","type":"cites","relation":"external","count":2,"files":[".markdownlint-cli2.jsonc","plugins/docs-hygiene/skills/audit-noise/SKILL.md"]}, @@ -26,11 +26,14 @@ {"from":"claude-code-plugins","to":"cli/cli","type":"cites","relation":"external","count":1,"files":["docs/CLOUD-FLEET-SETUP.md"]}, {"from":"claude-code-plugins","to":"crate-ci/typos","type":"cites","relation":"external","count":9,"files":[".claude/cloud-bootstrap.sh","_typos.toml","plugins/typos-format/README.md","plugins/typos-format/hooks/typos-format.sh","plugins/typos-format/skills/setup/SKILL.md"]}, {"from":"claude-code-plugins","to":"cursor/plugins","type":"cites","relation":"external","count":5,"files":["docs/upstream/cursor-pstack.md","plugins/ai-slop/CHANGELOG.md","plugins/ai-slop/README.md","plugins/ai-slop/skills/audit/reference/catalog.md","plugins/ai-slop/skills/audit/reference/rewrite-guide.md"]}, + {"from":"claude-code-plugins","to":"dandavison/delta","type":"cites","relation":"external","count":1,"files":["docs/adr/0031-name-the-worktree-root-git-config-key-as-a-capability-section.md"]}, {"from":"claude-code-plugins","to":"depcheck/depcheck","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-detector-landscape.md"]}, {"from":"claude-code-plugins","to":"dotnet/roslyn","type":"cites","relation":"external","count":1,"files":["docs/specs/dead-code-detector-landscape.md"]}, {"from":"claude-code-plugins","to":"editorconfig-checker/editorconfig-checker","type":"cites","relation":"external","count":1,"files":[".claude/cloud-bootstrap.sh"]}, {"from":"claude-code-plugins","to":"ericbuess/claude-code-docs","type":"cites","relation":"external","count":1,"files":["plugins/discipline/CHANGELOG.md"]}, {"from":"claude-code-plugins","to":"fzipp/gocyclo","type":"cites","relation":"external","count":4,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/gocyclo.py"]}, + {"from":"claude-code-plugins","to":"git-lfs/git-lfs","type":"cites","relation":"external","count":1,"files":["docs/adr/0031-name-the-worktree-root-git-config-key-as-a-capability-section.md"]}, + {"from":"claude-code-plugins","to":"github/hub","type":"cites","relation":"external","count":1,"files":["docs/adr/0031-name-the-worktree-root-git-config-key-as-a-capability-section.md"]}, {"from":"claude-code-plugins","to":"gitleaks/gitleaks","type":"cites","relation":"external","count":3,"files":[".claude/cloud-bootstrap.sh",".gitleaks.toml"]}, {"from":"claude-code-plugins","to":"golang/go","type":"cites","relation":"external","count":1,"files":["docs/specs/dead-code-detector-landscape.md"]}, {"from":"claude-code-plugins","to":"golang/tools","type":"cites","relation":"external","count":1,"files":["plugins/code-tidying/skills/audit-dead-code/context/adjudication.md"]}, @@ -38,11 +41,12 @@ {"from":"claude-code-plugins","to":"jendrikseipp/vulture","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-detector-landscape.md"]}, {"from":"claude-code-plugins","to":"joelparkerhenderson/architecture-decision-record","type":"cites","relation":"external","count":1,"files":["plugins/architecture/skills/record-decision/SKILL.md"]}, {"from":"claude-code-plugins","to":"johnousterhout/aposd-vs-clean-code","type":"cites","relation":"external","count":2,"files":["plugins/code-tidying/skills/audit-comment-residue/SKILL.md","plugins/code-tidying/skills/dissolve-comments/reference/sources.md"]}, + {"from":"claude-code-plugins","to":"k1LoW/git-wt","type":"cites","relation":"external","count":1,"files":["docs/adr/0031-name-the-worktree-root-git-config-key-as-a-capability-section.md"]}, {"from":"claude-code-plugins","to":"koalaman/shellcheck","type":"cites","relation":"external","count":7,"files":[".claude/cloud-bootstrap.sh",".shellcheckrc","docs/specs/dead-code-detector-landscape.md","plugins/bash-format/hooks/bash-format.sh","plugins/bash-format/skills/setup/SKILL.md"]}, {"from":"claude-code-plugins","to":"kucherenko/jscpd","type":"cites","relation":"external","count":2,"files":["plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/jscpd.py"]}, {"from":"claude-code-plugins","to":"lycheeverse/lychee","type":"cites","relation":"external","count":1,"files":["lychee.toml"]}, {"from":"claude-code-plugins","to":"mattpocock/skills","type":"cites","relation":"external","count":5,"files":["docs/upstream/aihero-course.md","docs/upstream/aihero-shipping-course.md","docs/upstream/mattpocock-skills.md","plugins/wizard/CHANGELOG.md"]}, - {"from":"claude-code-plugins","to":"melodic-software/ci-workflows","type":"cites","relation":"internal","count":142,"files":[".claude/source-control.md",".github/actionlint.yaml",".github/dependabot.yml",".github/standards/runner-policy/policy.json",".github/standards/runner-policy/runner-policy.mjs"]}, + {"from":"claude-code-plugins","to":"melodic-software/ci-workflows","type":"cites","relation":"internal","count":145,"files":[".claude/source-control.md",".github/actionlint.yaml",".github/dependabot.yml",".github/standards/runner-policy/policy.json",".github/standards/runner-policy/runner-policy.mjs"]}, {"from":"claude-code-plugins","to":"melodic-software/claude-code-plugins-ci","type":"cites","relation":"internal","count":1,"files":["package.json"]}, {"from":"claude-code-plugins","to":"melodic-software/dotfiles","type":"cites","relation":"internal","count":4,"files":["docs/CI-RUNNER-ROUTING.md","docs/adr/0004-rightsize-instruction-surfaces-by-incumbent-first-arbitration.md","plugins/machine-health/CHANGELOG.md","plugins/source-control/CHANGELOG.md"]}, {"from":"claude-code-plugins","to":"melodic-software/github-iac","type":"cites","relation":"internal","count":18,"files":[".github/actionlint.yaml",".github/dependabot.yml",".github/workflows/ci.yml",".github/workflows/silent-revert-canary.yml","docs/CI-RUNNER-ROUTING.md"]}, @@ -74,7 +78,7 @@ {"from":"claude-code-plugins","to":"typescript-language-server/typescript-language-server","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-lsp-viability.md"]}, {"from":"claude-code-plugins","to":"uber/piranha","type":"cites","relation":"external","count":1,"files":["plugins/overengineering/context/scrutiny-method.md"]}, {"from":"claude-code-plugins","to":"uudashr/gocognit","type":"cites","relation":"external","count":4,"files":["plugins/code-metrics/README.md","plugins/code-metrics/reference/collectors.md","plugins/code-metrics/scripts/collectors/gocognit.py"]}, - {"from":"claude-code-plugins","to":"x-motemen/ghq","type":"cites","relation":"external","count":1,"files":["plugins/repo-hygiene/skills/setup/SKILL.md"]}, + {"from":"claude-code-plugins","to":"x-motemen/ghq","type":"cites","relation":"external","count":2,"files":["docs/adr/0031-name-the-worktree-root-git-config-key-as-a-capability-section.md","plugins/repo-hygiene/skills/setup/SKILL.md"]}, {"from":"claude-code-plugins","to":"anthropics/claude-plugins-community","type":"installs-plugin","relation":"external","count":2,"files":["plugins/education/README.md","plugins/education/skills/eli5/SKILL.md"]}, {"from":"claude-code-plugins","to":"anthropics/claude-plugins-official","type":"installs-plugin","relation":"external","count":1,"files":["plugins/playgrounds/skills/use/SKILL.md"]}, {"from":"claude-code-plugins","to":"dometrain/mcp","type":"installs-plugin","relation":"external","count":1,"files":["plugins/dometrain/README.md"]}, diff --git a/docs/architecture/landscape.md b/docs/architecture/landscape.md index 26e517ed74..a60e970ddc 100644 --- a/docs/architecture/landscape.md +++ b/docs/architecture/landscape.md @@ -1,6 +1,6 @@ # System Landscape -Generated on 2026-09-10 from current repository plus reference graph. Remote facts: not used. +Generated on 2026-09-11 from current repository plus reference graph. Remote facts: not used. Every fact traces to the file the probe named. Every edge is typed by the syntax that carries it and labelled with how many references support it. @@ -27,7 +27,7 @@ C4Context System_Ext(actions_checkout, "actions/checkout", "not checked out here") Rel(melodic_software_claude_code_plugins, anthropics_claude_code, "cites (37)") - Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "cites (142)") + Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "cites (145)") Rel(melodic_software_claude_code_plugins, melodic_software_claude_code_plugins_ci, "cites (1)") Rel(melodic_software_claude_code_plugins, melodic_software_dotfiles, "cites (4)") Rel(melodic_software_claude_code_plugins, melodic_software_github_iac, "cites (18)") @@ -41,7 +41,7 @@ C4Context Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "uses-workflow (24)") ``` -63 external repositories are referenced but not drawn; the record carries +67 external repositories are referenced but not drawn; the record carries every one of them. ## Annotations diff --git a/docs/architecture/portfolio.md b/docs/architecture/portfolio.md index d4ac5928cf..b3160cfcec 100644 --- a/docs/architecture/portfolio.md +++ b/docs/architecture/portfolio.md @@ -1,6 +1,6 @@ # Application portfolio -Generated on 2026-09-10 from current repository plus reference graph. Remote facts: not used. +Generated on 2026-09-11 from current repository plus reference graph. Remote facts: not used. Last touched is the local HEAD of each checkout unless a remote fact says otherwise, so a stale checkout reports a stale date. `unknown` means no probe @@ -12,7 +12,7 @@ is built with. | Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched | |---|---|---|---|---|---|---| -| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-10T18:21:45+00:00 | +| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-11T03:35:07+00:00 | ## Development-scope dependencies diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index 69d9c546e4..cf08f58a3a 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -28,6 +28,26 @@ All notable changes to the `architecture` plugin are documented here. Format fol - **`map-landscape`:** a fixed closing report: artifacts, repositories charted, edges by type, unknown count, discovery source, remote state, and drift. +### Fixed + +- **`map-landscape`:** the extractor no longer reads this skill's own committed artifacts as evidence. + Once `landscape.json` was tracked it named every repository it charted, so each run raised every + citation count by one and listed the record among its own sources, and the drift gate could never + report clean. The record is now a fixed point: regenerating it twice produces byte-identical output. +- **`map-landscape`:** a drift comparison against a record built with a different remote posture says + so. A record carrying remote-sourced repositories would otherwise report every one of them as + removed on a local-only run, because no local collection can produce them. +- **`map-landscape`:** two checkouts sharing a directory basename are reported as an ambiguous + identity rather than silently matched onto one row. +- **`map-landscape`:** a clean comparison that carries non-gating differences no longer claims the + record "matches" and then lists what moved. +- **`map-landscape`:** a repository with no resolvable owner is drawn outside every boundary in both + dialects. An enterprise boundary or group is captioned with an organisation, and `unknown` is the + absence of one. +- **`map-landscape`:** the Structurizr artifact carries a `styles` block for its `External` tag. + Structurizr removed the internal/external `location` property, so the tag is the only carrier left + for that fact, and without a style it rendered nothing. + ### Changed - **`map-landscape`:** relationships are the extractor's output rather than the model's judgment. diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh index 5ce1bc450a..02900224a5 100755 --- a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh @@ -338,6 +338,31 @@ compare_set() { report="$report"'Landscape drift, fresh collection versus '"$compare_to"$'\n' +# A repository is identified by `name`, its directory basename. Two checkouts +# sharing a basename would collapse onto one key here and silently match the +# wrong row below, so an ambiguous identity is reported rather than guessed at. +dup_names() { + printf '%s\n' "$1" | awk "$SPLIT_AWK"' + NF { n = unquote(field($0, "name")); if (n != "") seen[n]++ } + END { for (k in seen) if (seen[k] > 1) print k } + ' | sort +} +while IFS= read -r dup; do + [[ -n "$dup" ]] || continue + say " ambiguous repository identity: several checkouts are named $dup, so this comparison cannot tell them apart" +done < <( + printf '%s\n%s\n' "$(dup_names "$old_repos")" "$(dup_names "$facts_out")" | sort -u +) + +# The committed record and this collection must describe the same kind of +# thing. A record built with remote facts carries repositories no local-only +# run can produce, and every one of them would otherwise report as removed. +old_remote="$(sed -n 's/^[[:space:]]*"remote"[[:space:]]*:[[:space:]]*"\(.*\)".*$/\1/p' "$compare_to" | head -1)" +if [[ "$old_remote" != "$remote_text" ]]; then + report="$report"' NOT COMPARABLE: the committed record was built with remote "'"$old_remote"'" and this run declares "'"$remote_text"'".'$'\n' + report="$report"' Repositories present only in the record are reported below, but a posture mismatch, not their removal, may explain them.'$'\n' +fi + compare_set repository "name" "$old_repos" "$facts_out" compare_set edge "to,type" "$old_edges" "$edges_out" @@ -407,8 +432,15 @@ while IFS=$'\t' read -r to f; do done <<<"$missing" if [[ "$drift" -eq 0 ]]; then - printf 'Landscape drift: none. The committed record matches a fresh collection.\n' - [[ -z "$notes" ]] || printf '%s' "$notes" + if [[ -z "$notes" ]]; then + printf 'Landscape drift: none. The committed record matches a fresh collection.\n' + else + # Saying the record "matches" and then listing what moved contradicts + # itself. Non-gating lines are still differences; only their consequence + # differs. + printf 'Landscape drift: none that gates. Non-gating differences follow.\n' + printf '%s' "$notes" + fi exit 0 fi diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh index 1eb27da1a1..8adffff5f4 100755 --- a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh @@ -204,6 +204,38 @@ out="$(bash "$SCRIPT" "$ev_repo" --drift-against "$TEST_TMPDIR/ev.json")" assert_not_contains "evidence: an edge still cited by one file survives" "$out" "removed edge: fixture-owner/toolkit" assert_contains "evidence: but the count change is reported" "$out" "changed" +# --- Case group 8a: comparisons this script cannot honestly make ------------ +# +# Two checkouts sharing a basename collapse onto one identity, so the field pass +# would silently match the wrong row. Saying so beats guessing. +dup_a="$(make_repo dupname)" +printf 'echo a\n' >"$dup_a/run.sh" +commit_repo "$dup_a" +mkdir -p "$TEST_TMPDIR/nested" +dup_b="$TEST_TMPDIR/nested/dupname" +mkdir -p "$dup_b" +git -C "$dup_b" init --quiet 2>/dev/null +git -C "$dup_b" config user.email "fixture@example.invalid" +git -C "$dup_b" config user.name "Fixture" +git -C "$dup_b" config commit.gpgsign false +git -C "$dup_b" remote add origin "https://github.com/other-owner/dupname.git" +printf 'echo b\n' >"$dup_b/run.sh" +commit_repo "$dup_b" +bash "$SCRIPT" "$dup_a" "$dup_b" --edges-from "$dup_a" >"$TEST_TMPDIR/dup.json" +out="$(bash "$SCRIPT" "$dup_a" "$dup_b" --edges-from "$dup_a" --drift-against "$TEST_TMPDIR/dup.json")" +assert_equals "ambiguity: a colliding basename is drift, not a silent wrong match" "$?" "3" +assert_contains "ambiguity: and the collision is named" "$out" "ambiguous repository identity: several checkouts are named dupname" + +# A record built with remote facts carries repositories no local-only run can +# produce; without this guard every one of them reports as removed. +sed 's/"remote": "not used"/"remote": "used, owned only"/' \ + "$TEST_TMPDIR/committed.json" >"$TEST_TMPDIR/remote-posture.json" +out="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/remote-posture.json")" +assert_contains "posture: a mismatched remote posture is called out" "$out" "NOT COMPARABLE" +assert_contains "posture: naming both sides" "$out" 'built with remote "used, owned only"' +out="$(bash "$SCRIPT" "$repo" --remote "used, owned only" --drift-against "$TEST_TMPDIR/remote-posture.json")" +assert_not_contains "posture: matching postures compare normally" "$out" "NOT COMPARABLE" + # --- Case group 9: a record this script will not compare against ------------ printf '{"schema_version": 2, "repositories": [], "edges": []}\n' >"$TEST_TMPDIR/v2.json" bad="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/v2.json" 2>&1)" diff --git a/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh index 90b3220c98..c3697435c1 100755 --- a/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh +++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh @@ -210,6 +210,21 @@ is_fixture_file() { */evals/* | evals/*) return 0 ;; */fixtures/* | fixtures/*) return 0 ;; */testdata/* | testdata/*) return 0 ;; + *) is_own_artifact "$1" ;; + esac +} + +# This skill's own committed output names every repository it charted, so once +# those artifacts are tracked the extractor would read them back as fresh +# evidence: each run would raise every count by one and cite the record as its +# own source, and a drift gate could never report clean again. The artifact +# names are fixed by this skill's contract while only their directory varies, +# so matching the basename is enough to keep derived output out of the input. +is_own_artifact() { + case "${1##*/}" in + landscape.json | landscape.md | landscape.dsl | landscape-notes.md | portfolio.md) + return 0 + ;; *) return 1 ;; esac } diff --git a/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh index 2de39683ab..5c47f16f8a 100755 --- a/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh @@ -202,6 +202,24 @@ assert_not_contains "noise: a .test.sh fixture name is not a system" "$out" 'Gon assert_not_contains "noise: a test-file same-owner token is not a system" "$out" 'fixture-owner/ghost' assert_not_contains "noise: an evals fixture is not a system" "$out" 'fixture-owner/eval-only' assert_not_contains "noise: the repository does not cite itself" "$out" '"to":"fixture-owner/charted"' + +# This skill's own committed output names every repository it charted. Reading +# it back would raise every count on each run and cite the record as its own +# evidence, so a drift gate could never report clean. +mkdir -p "$noise_repo/docs/architecture" +cat >"$noise_repo/docs/architecture/landscape.json" <<'JSON' +{"repositories":[],"edges":[{"to":"fixture-owner/from-the-record"}]} +JSON +printf 'System(x, "fixture-owner/also-from-the-record")\n' \ + >"$noise_repo/docs/architecture/landscape.md" +printf '| fixture-owner/portfolio-row | owner |\n' \ + >"$noise_repo/docs/architecture/portfolio.md" +commit_repo "$noise_repo" +out="$(bash "$SCRIPT" "$noise_repo")" +assert_not_contains "artifact: the record is not evidence for its own edges" "$out" 'from-the-record' +assert_not_contains "artifact: nor is the rendered diagram" "$out" 'also-from-the-record' +assert_not_contains "artifact: nor the portfolio table" "$out" 'portfolio-row' +assert_contains "artifact: a real doc reference still survives alongside them" "$out" '"to":"fixture-owner/ci-workflows"' assert_not_contains "noise: nor in another case" "$out" '"to":"Fixture-Owner/Charted"' # --- Case group 6: the .git suffix ------------------------------------------ diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh index c4b59652db..6395e7a566 100755 --- a/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh @@ -379,6 +379,14 @@ if [[ "$dialect" == "mermaid" ]]; then b = 0 for (i = 1; i <= n; i++) { split(io[i], f, "\t") + # An enterprise boundary is captioned with an organisation. "unknown" + # is the absence of one, so a repository with no resolvable owner is + # drawn at the top level rather than inside a boundary naming nothing. + if (f[7] == "unknown") { + if (cur != "") { print " }"; cur = "" } + printf " System(%s, \"%s\", \"%s\")\n", f[2], f[4], f[6] + continue + } if (f[7] != cur) { if (cur != "") print " }" printf " Enterprise_Boundary(b%d, \"%s\") {\n", b++, f[7] @@ -410,6 +418,13 @@ else END { for (i = 1; i <= n; i++) { split(io[i], f, "\t") + # A group is captioned with an organisation. "unknown" is the absence + # of one, so an ownerless repository sits outside every group. + if (f[7] == "unknown") { + if (cur != "") { print " }"; cur = "" } + printf " %s = softwareSystem \"%s\" \"%s\"\n", f[2], f[4], f[6] + continue + } if (f[7] != cur) { if (cur != "") print " }" printf " group \"%s\" {\n", f[7] @@ -426,7 +441,14 @@ else ' printf '%s\n' "$model" | awk -F'\t' '$1 == "edge" { printf " %s -> %s \"%s\"\n", $2, $3, $4 }' printf ' }\n views {\n systemLandscape "landscape" {\n' - printf ' include *\n autoLayout\n }\n }\n}\n' + printf ' include *\n autoLayout\n }\n' + # Structurizr removed the internal/external `location` property, so a tag is + # the only carrier left for that fact. Without a style to read it the tag + # renders nothing, and the DSL artifact would lose a distinction the mermaid + # one keeps through System versus System_Ext. + printf ' styles {\n element "External" {\n' + printf ' background #999999\n color #ffffff\n }\n }\n' + printf ' }\n}\n' } >"$target" fi diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh index 54af4ef3a1..e87517f1b2 100755 --- a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh @@ -124,6 +124,9 @@ assert_contains "structurizr: owners become groups" "$dsl" 'group "acme" {' assert_contains "structurizr: a system carries its label as the description" "$dsl" 'softwareSystem "billing.api" "dotnet, net9.0"' assert_contains "structurizr: an other-owner system is tagged External" "$dsl" '"actions/checkout" "not checked out here" "External"' assert_contains "structurizr: relationships carry the same type-and-count label" "$dsl" 'acme_web_ui -> acme_billing_api "depends-on (3)"' +# Structurizr dropped the internal/external `location` property, so the tag is +# the only carrier for that fact — and a tag with no style renders nothing. +assert_contains "structurizr: the External tag has a style to render through" "$dsl" 'element "External" {' # The two dialects must agree on which systems exist. for sys in acme_web_ui acme_billing_api acme_design_tokens actions_checkout; do @@ -193,7 +196,14 @@ render solo --record "$TEST_TMPDIR/empty.json" assert_equals "empty: an edgeless record still renders" "$?" "0" solo="$(cat "$TEST_TMPDIR/solo/landscape.md")" assert_contains "empty: the one system is drawn" "$solo" 'System(solo, "solo"' -assert_contains "empty: an unknown owner does not fabricate a boundary name" "$solo" 'Enterprise_Boundary(b0, "unknown")' +# An enterprise boundary is captioned with an organisation, so the absence of +# one is drawn as no boundary rather than as a boundary named "unknown". +assert_not_contains "empty: an unknown owner does not become a boundary caption" "$solo" 'Enterprise_Boundary' +assert_contains "empty: the ownerless system is drawn at the top level instead" "$solo" 'System(solo, "solo"' +render dsl_solo --record "$TEST_TMPDIR/empty.json" --dialect structurizr +solo_dsl="$(cat "$TEST_TMPDIR/dsl_solo/landscape.dsl")" +assert_not_contains "empty: nor a group caption in the other dialect" "$solo_dsl" 'group "unknown"' +assert_contains "empty: where it sits outside every group" "$solo_dsl" 'solo = softwareSystem "solo"' assert_contains "empty: a repository with no probed runtime says so" "$solo" 'no probed runtime' assert_contains "empty: an empty dependency list reads as none, not blank" \ "$(cat "$TEST_TMPDIR/solo/portfolio.md")" '| (none) |' From 9315a8154d5a6fba2109276ce65268cf92697de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 05:36:33 +0000 Subject: [PATCH 13/15] fix(architecture): regenerate the catalog and cheat sheet for the new descriptions Both are generated from the plugin manifest and the skill frontmatter, and this branch rewrote the plugin description and the skill summary without rerunning the generators. The drift check that catches it runs only in the full test lanes, which a draft pull request skips, so it stayed invisible until the flip to ready armed those lanes. The catalog was the reported failure on both test-linux shards. Fixing it surfaced the cheat sheet behind it: the validator stops at the first drift, so one stale generated file masks the next. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/CATALOG.md | 2 +- docs/SKILL-CHEAT-SHEET.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CATALOG.md b/docs/CATALOG.md index b53e7a5a65..32502f8da3 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -17,7 +17,7 @@ plugin manifests and kept in sync by CI — never hand-edit it; the category voc ## Design -- [`architecture`](../plugins/architecture) — Scans an existing codebase for module-level architecture friction — shallow modules, seam leaks, and locality gaps — using Ousterhout's deep-module lens, presents candidates as a self-contained HTML report, and runs an interview loop on the selected candidate before handing off for planning. Also charts a discovered set of repositories as a C4 system landscape plus an application-portfolio table, and records an architecture decision into the repository's existing ADR convention. +- [`architecture`](../plugins/architecture) — Scans an existing codebase for module-level architecture friction — shallow modules, seam leaks, and locality gaps — using Ousterhout's deep-module lens, presents candidates as a self-contained HTML report, and runs an interview loop on the selected candidate before handing off for planning. Also charts a repository and the systems it references as a C4 system landscape plus an application-portfolio table, committing the result as a record that later runs check for drift, and records an architecture decision into the repository's existing ADR convention. - [`prototype`](../plugins/prototype) — Builds throwaway code to answer a design question before committing to architecture — a logic facet (an interactive terminal app over a portable state model) and a UI facet (radically different visual variants on one route). - [`planning`](../plugins/planning) — Pre-implementation planning pipeline: chart a too-big, foggy effort as a decision map, diverge on candidate approaches, lock product intent and the engineering contract, route resolved domain language to the domain-driven-design glossary steward, explore the design space, stress-test adversarially, and produce a structured implementation plan with an approval gate. - [`domain-driven-design`](../plugins/domain-driven-design) — Domain-driven-design practice skills. Today: actively maintains a consuming project's ubiquitous-language glossary — resolves ambiguous or overloaded terms, records canonical language and rejected synonyms, sharpens what-it-IS definitions, and routes entries to already-known bounded contexts without discovering boundaries. diff --git a/docs/SKILL-CHEAT-SHEET.md b/docs/SKILL-CHEAT-SHEET.md index 3223c7db3e..b836abda27 100644 --- a/docs/SKILL-CHEAT-SHEET.md +++ b/docs/SKILL-CHEAT-SHEET.md @@ -40,7 +40,7 @@ owned by [docs/CATALOG-TAXONOMY.md](CATALOG-TAXONOMY.md). | Skill | Plugin | What it does | | --- | --- | --- | -| [`/architecture:map-landscape`](../plugins/architecture/skills/map-landscape/SKILL.md) | `architecture` | Chart discovered repositories as a C4 system landscape plus an application-portfolio table | +| [`/architecture:map-landscape`](../plugins/architecture/skills/map-landscape/SKILL.md) | `architecture` | Chart a repository and the systems it references as a C4 system landscape and portfolio table | | [`/discovery:blindspot`](../plugins/discovery/skills/blindspot/SKILL.md) | `discovery` | Surface your unknown-unknowns and sharpen the prompt before unfamiliar work | | [`/discovery:explore`](../plugins/discovery/skills/explore/SKILL.md) | `discovery` | Explore code, history, tests, and config before changing anything | | [`/discovery:trace-intent`](../plugins/discovery/skills/trace-intent/SKILL.md) | `discovery` | Reconstruct why a thing was built this way, from evidence outside the code | From 7cbb9b2d5ee6befaa18848d162ea70211a25073e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 06:23:14 +0000 Subject: [PATCH 14/15] fix(architecture): close the five review findings on map-landscape Repository-controlled text could break out of the string literal it was written into. A target framework is read out of a manifest with only XML tags stripped, and a raw quote is legal there, so a crafted value could splice arbitrary syntax into a committed diagram. Values are now decoded out of the record and their delimiters replaced for the target grammar, and a pipe is escaped before it reaches a portfolio-table cell. Two names differing only in punctuation collapsed onto one diagram identifier, so both dialects declared the system twice and pointed every relationship at whichever declaration won. Aliases are handed out once, in emission order, and a repeat gets a counted suffix. A quoted `uses:` scalar left its opening quote on the owner segment, which failed the character check and dropped the edge without a word. A checkout on disk was read as a claim of ownership: every locally collected repository was internal whatever its owner, so a third-party clone sat inside an enterprise boundary while the edges to it said external. The record now names its `subject_owner`, resolved by the edge extractor so the nodes and the edges cannot disagree, and a cross-owner checkout renders external with its probed facts intact. It is drawn whatever `--top-external` says, because that cap trims the tail of repositories a run only read about, not the set someone asked to chart. `--remote` recorded only a status string, so fetched facts had nowhere to land and every referenced repository stayed factless however much was fetched. `--remote-facts ` merges them, a local checkout winning outright over an entry of the same name, and an archived repository is marked in the node description and the portfolio row as the remote-facts contract already promised. The committed landscape.md was also stale against its own record: it drew two external systems where the record has five. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape.json | 3 +- docs/architecture/landscape.md | 8 +- docs/architecture/portfolio.md | 2 +- plugins/architecture/CHANGELOG.md | 23 +++ plugins/architecture/README.md | 9 +- .../skills/map-landscape/SKILL.md | 20 +++ .../map-landscape/reference/scope-modes.md | 36 +++- .../map-landscape/scripts/landscape-record.sh | 62 +++++++ .../scripts/landscape-record.test.sh | 69 +++++++- .../map-landscape/scripts/reference-edges.sh | 30 ++++ .../scripts/reference-edges.test.sh | 50 +++++- .../map-landscape/scripts/render-landscape.sh | 128 ++++++++++++--- .../scripts/render-landscape.test.sh | 155 ++++++++++++++++++ 13 files changed, 565 insertions(+), 30 deletions(-) diff --git a/docs/architecture/landscape.json b/docs/architecture/landscape.json index 1bd8f9b9a4..f47184aa05 100644 --- a/docs/architecture/landscape.json +++ b/docs/architecture/landscape.json @@ -3,8 +3,9 @@ "generated_on": "2026-09-11", "discovery_source": "current repository plus reference graph", "remote": "not used", + "subject_owner": "melodic-software", "repositories": [ - {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-11T03:35:07+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} + {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-11T05:36:33+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} ], "edges": [ {"from":"claude-code-plugins","to":"DavidAnson/markdownlint","type":"cites","relation":"external","count":2,"files":[".markdownlint-cli2.jsonc","plugins/docs-hygiene/skills/audit-noise/SKILL.md"]}, diff --git a/docs/architecture/landscape.md b/docs/architecture/landscape.md index a60e970ddc..35c0d8d417 100644 --- a/docs/architecture/landscape.md +++ b/docs/architecture/landscape.md @@ -25,8 +25,14 @@ C4Context } System_Ext(anthropics_claude_code, "anthropics/claude-code", "not checked out here") System_Ext(actions_checkout, "actions/checkout", "not checked out here") + System_Ext(crate_ci_typos, "crate-ci/typos", "not checked out here") + System_Ext(Dometrain_mcp, "Dometrain/mcp", "not checked out here") + System_Ext(koalaman_shellcheck, "koalaman/shellcheck", "not checked out here") + Rel(melodic_software_claude_code_plugins, Dometrain_mcp, "cites (8)") Rel(melodic_software_claude_code_plugins, anthropics_claude_code, "cites (37)") + Rel(melodic_software_claude_code_plugins, crate_ci_typos, "cites (9)") + Rel(melodic_software_claude_code_plugins, koalaman_shellcheck, "cites (7)") Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "cites (145)") Rel(melodic_software_claude_code_plugins, melodic_software_claude_code_plugins_ci, "cites (1)") Rel(melodic_software_claude_code_plugins, melodic_software_dotfiles, "cites (4)") @@ -41,7 +47,7 @@ C4Context Rel(melodic_software_claude_code_plugins, melodic_software_ci_workflows, "uses-workflow (24)") ``` -67 external repositories are referenced but not drawn; the record carries +64 external repositories are referenced but not drawn; the record carries every one of them. ## Annotations diff --git a/docs/architecture/portfolio.md b/docs/architecture/portfolio.md index b3160cfcec..04ee06402c 100644 --- a/docs/architecture/portfolio.md +++ b/docs/architecture/portfolio.md @@ -12,7 +12,7 @@ is built with. | Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched | |---|---|---|---|---|---|---| -| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-11T03:35:07+00:00 | +| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-11T05:36:33+00:00 | ## Development-scope dependencies diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index cf08f58a3a..e94769fd2b 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -47,6 +47,29 @@ All notable changes to the `architecture` plugin are documented here. Format fol - **`map-landscape`:** the Structurizr artifact carries a `styles` block for its `External` tag. Structurizr removed the internal/external `location` property, so the tag is the only carrier left for that fact, and without a style it rendered nothing. +- **`map-landscape`:** repository-controlled text can no longer break out of the string literal it + is written into. A target framework is read out of a manifest with only XML tags stripped, and a + raw quote is legal there; the renderer wrote it straight into a quoted `System(...)` or + `softwareSystem` string, so a crafted value could splice arbitrary diagram syntax into a committed + artifact. Values are now decoded out of the record and their delimiters replaced for the target + grammar, and a pipe is escaped before it lands in a portfolio-table cell. +- **`map-landscape`:** two repository names differing only in punctuation, `a-b` and `a_b`, no + longer collapse onto one diagram identifier. Both dialects declared the system twice and pointed + every relationship at whichever declaration won; an alias already handed out is now suffixed. +- **`map-landscape`:** a quoted `uses:` scalar is read. `uses: "owner/repo/.github/workflows/x.yml@v1"` + left the opening quote on the owner segment, which failed the character check and dropped the + edge without a word, so a repository writing ordinary quoted YAML charted an incomplete graph. +- **`map-landscape`:** a checkout on disk is no longer a claim of ownership. Every locally collected + repository was marked internal whatever its owner, so a third-party checkout was drawn inside an + enterprise boundary while the edges to it said external. The record now names its `subject_owner`, + resolved by the edge extractor so the nodes and the edges cannot disagree, and a cross-owner + checkout renders as an external system with its probed facts intact. It is drawn whatever + `--top-external` says: that cap trims the tail of repositories a run only read about. +- **`map-landscape`:** `--remote` reaches the record. It recorded only a status string, so fetched + facts had nowhere to land and every referenced repository stayed factless however much was + fetched. `landscape-record.sh --remote-facts ` merges them, a local checkout winning + outright over an entry of the same name, and an `archived` repository is marked in the node + description and the portfolio row as the remote-facts contract already promised. ### Changed diff --git a/plugins/architecture/README.md b/plugins/architecture/README.md index 470065e0dd..dfb9c55f4a 100644 --- a/plugins/architecture/README.md +++ b/plugins/architecture/README.md @@ -66,9 +66,12 @@ redirects one run's output without touching your declared home. The working directory is never walked for nested repositories under any of them. Nothing reaches the network unless you pass `--remote`, which fills facts for -referenced repositories that are not checked out here. A repository outside your -own owner is read-only reference in every mode: it is drawn and recorded, never -written to. +referenced repositories that are not checked out here. An archived one is +charted and marked rather than dropped, because a landscape that hides archived +repositories hides exactly the dependencies worth acting on. A repository +outside your own owner is read-only reference in every mode: it is drawn and +recorded, never written to, and having a clone of it on disk does not move it +inside your enterprise boundary. ## Record a decision diff --git a/plugins/architecture/skills/map-landscape/SKILL.md b/plugins/architecture/skills/map-landscape/SKILL.md index 9ff0c49386..271c8e02cb 100644 --- a/plugins/architecture/skills/map-landscape/SKILL.md +++ b/plugins/architecture/skills/map-landscape/SKILL.md @@ -87,6 +87,11 @@ The record carries `repositories[]` from `portfolio-facts.sh` (`name`, `remote`, and `edges[]` from `reference-edges.sh` (`from`, `to` as `owner/repo`, `type`, `relation`, `count`, `files[]`). +It also records `subject_owner`, the organisation the graph was drawn from, resolved by the edge +extractor so the nodes and the edges cannot disagree about it. That is what makes a checkout +internal: having a repository on disk says where someone works, not who owns the system, so a +cross-owner checkout is the same external system the edges to it already call external. + Anything no probe could derive is the literal `unknown`. Carry it through to the artifacts as-is; never replace it with a guess, and never fill it from a commit author, a directory name, or ecosystem memory. @@ -131,6 +136,11 @@ With `--remote` (or `--remote=all` for externals too), read [scope-modes.md](${CLAUDE_PLUGIN_ROOT}/skills/map-landscape/reference/scope-modes.md) for the presence gate, the fact list, the evidence shape, and the rule that a local checkout always wins. +Fetched facts reach the record through `--remote-facts `, one JSON object per line in the +shape `portfolio-facts.sh` emits. Without it the flag records only that a fetch happened, and every +referenced repository stays factless. Pass the same file to the drift run: a merged record compared +against a local-only collection reads every fetched repository as removed. + ## Emit artifacts Render, then annotate. The renderer does the mechanical work; you write only prose. @@ -203,6 +213,16 @@ End every run with this block, in this order, filled from the record and the scr installs `ruff` reports `runtime: shell` with `tooling: python`. The same rule makes a `package.json` carrying only `devDependencies` report tooling, which is why `target_framework` can be `unknown` while a `Tooling` entry is present. +- **A checkout on disk is not a claim of ownership.** A repository whose owner differs from + `subject_owner` renders as an external system with its probed facts intact, outside every + enterprise boundary, even though this run read its files. It is drawn whatever `--top-external` + says: that cap trims the tail of repositories the run only read about, never the set someone + asked it to chart. +- **A quote or a pipe read out of a manifest is replaced, not preserved.** A target framework, an + owner from `CODEOWNERS` and a repository name are all repository-controlled text that lands + inside a quoted string in both dialects and inside a cell in the portfolio table. Neither diagram + grammar has a portable escape for its own delimiter, so the delimiter is swapped for one that + cannot close the literal. A value that comes out altered is a value that was never a fact. - **`owner` is a ladder, and commit authors are not on it.** The `CODEOWNERS` default `*` rule's first owner, then the owner segment of the `origin` remote, then `unknown`. Who edits a repository most is not who owns it, so neither the script nor the write-up looks at git authorship. diff --git a/plugins/architecture/skills/map-landscape/reference/scope-modes.md b/plugins/architecture/skills/map-landscape/reference/scope-modes.md index e53ade9c8f..a1a1573804 100644 --- a/plugins/architecture/skills/map-landscape/reference/scope-modes.md +++ b/plugins/architecture/skills/map-landscape/reference/scope-modes.md @@ -58,8 +58,38 @@ the record says `remote: not used`. - **Every remote fact names its call.** Its `evidence` entry is the API call or `gh` command that supplied it, and it reads `pushed_at (remote)` rather than the local-HEAD wording, so a later local-only run can explain why `last_touched` moved backwards. -- **An archived repository is charted and marked.** Archiving is a fact about the system, not a - reason to hide it: a landscape that quietly drops archived repositories hides exactly the - dependencies worth acting on. Mark it in the node annotation and in the portfolio row. - **Externals stay read-only.** `--remote=all` reads facts about an external repository. It never writes to one, and it does not extend the graph a second hop. + +### Getting the fetched facts into the record + +Assemble what you fetched into a file of one JSON object per line, in the shape +`portfolio-facts.sh` emits, and hand it to the record: + +```bash +"${CLAUDE_SKILL_DIR}/scripts/landscape-record.sh" \ + --source "…" --remote "used, owned only" \ + --remote-facts "//remote-facts.jsonl" \ + --edges-from ... +``` + +Each line opens with `"name"` and carries the same keys a probed repository does, plus the ones +only a fetch can supply: `archived`, `default_branch`, `visibility`. A malformed line fails the run +rather than dropping out of the record in silence. + +A local checkout wins outright: an entry whose name a collector already produced is discarded, not +merged field by field. A probe that read the files is a better witness than an API summary of them, +and a row assembled from both is one no single source stands behind. + +Pass the same file to the `--drift-against` run. A merged record compared against a local-only +collection reports every fetched repository as removed, which is why the comparison refuses a run +whose `--remote` posture differs from the record's. + +The file is a temp artifact and lives in the memory slice, next to `fleet-plan.json`. Never commit +it; the record it produced is the committed thing. + +**An archived repository is charted and marked.** Archiving is a fact about the system, not a +reason to hide it: a landscape that quietly drops archived repositories hides exactly the +dependencies worth acting on. `archived: true` leads the node description and marks the portfolio +row on its own. `default_branch` and `visibility` are carried in the record and read from there; +neither changes what the diagram means, so neither is drawn. diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh index 02900224a5..7ed2359ee7 100755 --- a/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh @@ -20,6 +20,11 @@ # external. Defaults to the edges-from origin owner. # --source Discovery source recorded verbatim in the record. # --remote Remote-facts status recorded verbatim in the record. +# --remote-facts Merge fetched facts for repositories with no local +# checkout. One JSON object per line, each opening with +# "name", in the shape portfolio-facts.sh emits. A local +# checkout wins: an entry whose name a collector already +# produced is discarded, not merged field by field. # --drift-against Compare the fresh collection with committed record . # Prints a drift report instead of the record. # @@ -30,10 +35,16 @@ # "generated_on": "YYYY-MM-DD", # "discovery_source": "…", # "remote": "…", +# "subject_owner": "…", # "repositories": [ ], # "edges": [ ] # } # +# `subject_owner` is the organisation the graph was drawn from, resolved by the +# edge extractor so the nodes and the edges cannot disagree about it. It is what +# makes a checkout internal: having a repository on disk says where someone +# works, not who owns the system. +# # One object per line is deliberate: it keeps the record diffable in review and # parseable here without a JSON library. The collector's `path` field is dropped # on the way in: it records where a checkout happens to sit on one machine, which @@ -77,6 +88,7 @@ edges_from="" owner="" source_text="explicit list" remote_text="not used" +remote_facts="" compare_to="" while [[ $# -gt 0 ]]; do @@ -121,6 +133,15 @@ while [[ $# -gt 0 ]]; do remote_text="${1#--remote=}" shift ;; + --remote-facts) + [[ $# -ge 2 ]] || die "--remote-facts needs a path" 2 + remote_facts="$2" + shift 2 + ;; + --remote-facts=*) + remote_facts="${1#--remote-facts=}" + shift + ;; --drift-against) [[ $# -ge 2 ]] || die "--drift-against needs a path" 2 compare_to="$2" @@ -157,6 +178,46 @@ facts_out="$(bash "$FACTS" "${repos[@]}")" || die "fact collection failed" 1 edge_args=("$edges_from") [[ -n "$owner" ]] && edge_args+=(--owner "$owner") edges_out="$(bash "$EDGES" "${edge_args[@]}")" || die "edge extraction failed" 1 +subject_owner="$(bash "$EDGES" "${edge_args[@]}" --print-owner)" || die "owner resolution failed" 1 +[[ -n "$subject_owner" ]] || subject_owner="unknown" + +# Fetched facts for repositories nobody has checked out. They arrive already +# assembled, because fetching them is model work against an API and this script +# reaches no network. A local checkout wins outright rather than field by field: +# a probe that read the files is a better witness than an API summary of them, +# and merging the two would produce a repository row no single source stands +# behind. +if [[ -n "$remote_facts" ]]; then + [[ -r "$remote_facts" ]] || die "cannot read remote facts: $remote_facts" 1 + merged="$(printf '%s\n' "$facts_out" | awk ' + NR == FNR { if (NF) { local[++l] = $0; name[objname($0)] = 1 } ; next } + NF { + if ($0 !~ /^[[:space:]]*\{"name":/) + { printf "line %d is not a repository object\n", FNR > "/dev/stderr"; bad = 1; next } + n = objname($0) + if (n in name) next + name[n] = 1 + remote[++r] = n "\t" $0 + } + function objname(s, t) { + t = s + sub(/^[^{]*\{"name":[[:space:]]*"/, "", t) + sub(/".*$/, "", t) + return t + } + END { + if (bad) exit 1 + for (i = 1; i <= l; i++) print local[i] + # Sorted, so the record does not depend on the order the fetches + # happened to come back in. + for (i = 1; i <= r; i++) + for (j = i + 1; j <= r; j++) + if (remote[j] < remote[i]) { t = remote[i]; remote[i] = remote[j]; remote[j] = t } + for (i = 1; i <= r; i++) { sub(/^[^\t]*\t/, "", remote[i]); print remote[i] } + } + ' - "$remote_facts")" || die "malformed remote facts: $remote_facts" 1 + facts_out="$merged" +fi # --- Emit ------------------------------------------------------------------- @@ -262,6 +323,7 @@ if [[ -z "$compare_to" ]]; then printf ' "generated_on": "%s",\n' "$(date -u +%Y-%m-%d)" printf ' "discovery_source": "%s",\n' "$(json_escape "$source_text")" printf ' "remote": "%s",\n' "$(json_escape "$remote_text")" + printf ' "subject_owner": "%s",\n' "$(json_escape "$subject_owner")" # `path` is dropped on the way in. It records where a checkout happened to sit # on one machine at one moment, which is not a fact about the architecture and # would make the committed record differ on every machine that regenerates it. diff --git a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh index 8adffff5f4..ef054a0f2a 100755 --- a/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh @@ -245,7 +245,71 @@ assert_contains "schema: and says which version it wanted" "$bad" "schema_versio bad="$(bash "$SCRIPT" "$repo" --drift-against "$TEST_TMPDIR/absent.json" 2>&1)" assert_equals "schema: an unreadable record exits 1" "$?" "1" -# --- Case group 10: usage --------------------------------------------------- +# --- Case group 10: the subject owner is recorded --------------------------- +# +# Whether a checkout is internal turns on who owns it, not on someone having it +# on disk, so the record has to name the organisation it was drawn from. +out="$(bash "$SCRIPT" "$repo")" +assert_contains "subject: the origin owner is recorded" "$out" '"subject_owner": "fixture-owner"' +out="$(bash "$SCRIPT" "$repo" --owner other-org)" +assert_contains "subject: an override is recorded instead" "$out" '"subject_owner": "other-org"' +noremote="$TEST_TMPDIR/ownerless" +mkdir -p "$noremote" +git -C "$noremote" init --quiet 2>/dev/null +git -C "$noremote" config user.email "fixture@example.invalid" +git -C "$noremote" config user.name "Fixture" +git -C "$noremote" config commit.gpgsign false +printf 'nothing\n' >"$noremote/README.md" +commit_repo "$noremote" +out="$(bash "$SCRIPT" "$noremote")" +assert_contains "subject: an unresolvable owner reads unknown, not empty" "$out" '"subject_owner": "unknown"' + +# --- Case group 11: fetched remote facts reach the record ------------------- +# +# Fetching is model work against an API, so the facts arrive assembled. Without +# somewhere to put them the flag would only record that it ran. +cat >"$TEST_TMPDIR/remote.jsonl" <<'JSONL' +{"name":"standards","remote":"https://github.com/fixture-owner/standards","owner":"fixture-owner","runtime":"unknown","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"2026-02-01T00:00:00+00:00","archived":true,"default_branch":"main","visibility":"public","evidence":{"last_touched":"pushed_at (remote)"}} +{"name":"ci-workflows","remote":"https://github.com/fixture-owner/ci-workflows","owner":"fixture-owner","runtime":"unknown","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"2026-02-02T00:00:00+00:00","evidence":{"owner":"repos API"}} +JSONL +out="$(bash "$SCRIPT" "$repo" --remote "used, owned only" --remote-facts "$TEST_TMPDIR/remote.jsonl")" +assert_equals "remote-facts: a merged build exits 0" "$?" "0" +assert_contains "remote-facts: a repository with no checkout gains facts" "$out" '{"name":"standards",' +assert_contains "remote-facts: and the second one too" "$out" '{"name":"ci-workflows",' +assert_contains "remote-facts: the archived flag survives into the record" "$out" '"archived":true' +assert_contains "remote-facts: so does a field only a fetch can supply" "$out" '"visibility":"public"' +assert_contains "remote-facts: the local checkout is still there" "$out" '{"name":"hub",' +# Sorted, so the record does not encode the order the fetches came back in. +merged_order="$(printf '%s\n' "$out" | sed -n 's/^ {"name":"\([^"]*\)".*$/\1/p' | tr '\n' ' ')" +assert_equals "remote-facts: locals first, then fetched entries in name order" \ + "$merged_order" "hub ci-workflows standards " + +# A probe that read the files beats an API summary of them, so a local checkout +# wins outright rather than being merged field by field. +printf '{"name":"hub","owner":"impostor","runtime":"cobol","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}}\n' \ + >"$TEST_TMPDIR/clash.jsonl" +out="$(bash "$SCRIPT" "$repo" --remote-facts "$TEST_TMPDIR/clash.jsonl")" +assert_not_contains "remote-facts: a fetch never overwrites a probed checkout" "$out" '"runtime":"cobol"' +hub_rows="$(printf '%s\n' "$out" | grep -c '{"name":"hub",')" +assert_equals "remote-facts: nor does it add a second row for it" "$hub_rows" "1" + +# A merged record is compared as one, or the fetched rows read as removals. +bash "$SCRIPT" "$repo" --remote "used, owned only" --remote-facts "$TEST_TMPDIR/remote.jsonl" \ + >"$TEST_TMPDIR/merged.json" +bash "$SCRIPT" "$repo" --remote "used, owned only" --remote-facts "$TEST_TMPDIR/remote.jsonl" \ + --drift-against "$TEST_TMPDIR/merged.json" >/dev/null 2>&1 +assert_equals "remote-facts: the same merge compares clean" "$?" "0" + +bad="$(bash "$SCRIPT" "$repo" --remote-facts "$TEST_TMPDIR/nowhere.jsonl" 2>&1)" +assert_equals "remote-facts: an unreadable path exits 1" "$?" "1" +assert_contains "remote-facts: and says which one" "$bad" "nowhere.jsonl" + +printf 'not an object\n' >"$TEST_TMPDIR/junk.jsonl" +bad="$(bash "$SCRIPT" "$repo" --remote-facts "$TEST_TMPDIR/junk.jsonl" 2>&1)" +assert_equals "remote-facts: a malformed line exits 1 rather than dropping quietly" "$?" "1" +assert_contains "remote-facts: naming the line" "$bad" "line 1" + +# --- Case group 12: usage --------------------------------------------------- bash "$SCRIPT" >/dev/null 2>&1 assert_equals "usage: no repository path exits 2" "$?" "2" @@ -258,6 +322,9 @@ assert_equals "usage: an unknown option exits 2" "$?" "2" bash "$SCRIPT" "$repo" --edges-from "$TEST_TMPDIR/nowhere" >/dev/null 2>&1 assert_equals "usage: an --edges-from path that is not there exits 1" "$?" "1" +bash "$SCRIPT" "$repo" --remote-facts >/dev/null 2>&1 +assert_equals "usage: --remote-facts without a value exits 2" "$?" "2" + help_out="$(bash "$SCRIPT" --help 2>&1)" assert_equals "usage: --help exits 0" "$?" "0" assert_contains "usage: and describes the record" "$help_out" "schema_version" diff --git a/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh index c3697435c1..f683174828 100755 --- a/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh +++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh @@ -17,12 +17,17 @@ # # Usage: # reference-edges.sh [--owner ] +# reference-edges.sh [--owner ] --print-owner # reference-edges.sh --help # # --owner overrides the owner segment taken from the `origin` remote. It decides # which references are `internal` (same owner) and which are `external`, and it # is the only way a bare `owner/repo` token is trusted at all. # +# --print-owner prints the owner this run resolved and extracts nothing, so a +# caller can record which organisation the graph was drawn from without +# reimplementing the resolution. `unknown` when none resolves. +# # Output: JSON Lines on stdout, one object per (target, type) pair, sorted: # # {"from":…,"to":…,"type":…,"relation":…,"count":N,"files":[…]} @@ -72,6 +77,7 @@ fi repo_arg="" owner_override="" +print_owner=0 while [[ $# -gt 0 ]]; do case "$1" in --owner) @@ -83,6 +89,7 @@ while [[ $# -gt 0 ]]; do owner_override="$1" ;; --owner=*) owner_override="${1#--owner=}" ;; + --print-owner) print_owner=1 ;; -*) printf 'reference-edges.sh: unknown option: %s\n' "$1" >&2 exit 2 @@ -148,6 +155,15 @@ remote_owner_segment() { owner="$owner_override" [[ -n "$owner" ]] || owner="$(remote_owner_segment)" || owner="" +# The owner this run resolved, for a caller that has to record which +# organisation the graph was drawn from. Reading it back from here keeps one +# resolution: a second implementation elsewhere would drift from this one about +# what counts as the subject, and then the edges and the nodes would disagree. +if [[ "$print_owner" -eq 1 ]]; then + printf '%s\n' "${owner:-unknown}" + exit 0 +fi + # --------------------------------------------------------------------------- # Reserved GitHub path prefixes # --------------------------------------------------------------------------- @@ -299,6 +315,20 @@ while IFS= read -r line; do match="${line#*:}" ref="${match#*uses:}" ref="${ref#"${ref%%[![:space:]]*}"}" + # A YAML scalar may be quoted either way. The quotes belong to the syntax and + # not to the repository, and the owner segment carrying one fails the segment + # check, so an ordinary quoted `uses:` would drop out of the graph in silence. + case "$ref" in + \"?*\") + ref="${ref#\"}" + ref="${ref%\"}" + ;; + \'?*\') + ref="${ref#\'}" + ref="${ref%\'}" + ;; + *) ;; + esac case "$ref" in ./* | docker://*) continue ;; *) ;; diff --git a/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh index 5c47f16f8a..a12aec8ea4 100755 --- a/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh @@ -249,7 +249,55 @@ rc=$? assert_equals "quiet: no edges is exit 0, not an error" "$rc" "0" assert_equals "quiet: and emits nothing" "$out" "" -# --- Case group 9: usage and bad paths -------------------------------------- +# --- Case group 9: quoted YAML scalars -------------------------------------- +# +# `uses:` takes an ordinary YAML scalar, which may be quoted either way. The +# quote belongs to the syntax, and an owner segment still carrying one fails the +# character check and drops the edge without saying so. +quoted_repo="$(make_repo quoted)" +mkdir -p "$quoted_repo/.github/workflows" +{ + printf 'jobs:\n build:\n' + printf ' uses: "fixture-owner/ci-workflows/.github/workflows/build.yml@v1"\n' + printf ' steps:\n' + printf " - uses: 'actions/checkout@v4'\n" + printf ' - uses: actions/setup-node@v4\n' +} >"$quoted_repo/.github/workflows/ci.yml" +commit_repo "$quoted_repo" +quoted_out="$(bash "$SCRIPT" "$quoted_repo")" +# By TYPE, not just by target. A same-owner bare token is also a `cites` hit, so +# asserting the target alone would pass on the citation while the workflow edge +# this case exists for stayed missing. +uses_line() { printf '%s\n' "$quoted_out" | grep -F '"type":"uses-workflow"' | grep -F "\"to\":\"$1\""; } +assert_contains "quoted: a double-quoted reusable workflow is still a workflow edge" \ + "$(uses_line fixture-owner/ci-workflows)" '"type":"uses-workflow"' +assert_contains "quoted: a single-quoted action is too" \ + "$(uses_line actions/checkout)" '"type":"uses-workflow"' +assert_contains "quoted: the unquoted form is unaffected" \ + "$(uses_line actions/setup-node)" '"type":"uses-workflow"' +assert_not_contains "quoted: no quote survives into a repository name" \ + "$quoted_out" '"to":"\"' + +# --- Case group 10: --print-owner ------------------------------------------- +# +# The record has to name the organisation the graph was drawn from, and a second +# implementation of that resolution would eventually disagree with this one. +owner_out="$(bash "$SCRIPT" "$quoted_repo" --print-owner)" +assert_equals "print-owner: the origin owner, and no edges" "$owner_out" "fixture-owner" +override_out="$(bash "$SCRIPT" "$quoted_repo" --owner other-co --print-owner)" +assert_equals "print-owner: the override wins" "$override_out" "other-co" +noremote_repo="$TEST_TMPDIR/no-remote" +mkdir -p "$noremote_repo" +git -C "$noremote_repo" init --quiet 2>/dev/null +git -C "$noremote_repo" config user.email "fixture@example.invalid" +git -C "$noremote_repo" config user.name "Fixture" +git -C "$noremote_repo" config commit.gpgsign false +printf 'no origin here\n' >"$noremote_repo/README.md" +commit_repo "$noremote_repo" +assert_equals "print-owner: no resolvable owner reads unknown" \ + "$(bash "$SCRIPT" "$noremote_repo" --print-owner)" "unknown" + +# --- Case group 11: usage and bad paths -------------------------------------- bash "$SCRIPT" >/dev/null 2>&1 assert_equals "usage: no arguments exits 2" "$?" "2" diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh index 6395e7a566..1887cfe2e3 100755 --- a/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh @@ -133,6 +133,13 @@ esac grep -q '"schema_version"[[:space:]]*:[[:space:]]*1' "$record" || die "not a schema_version 1 record: $record" 1 +# Which organisation the landscape is drawn from. A checkout is internal because +# its owner matches this one, not because someone happened to have it on disk. +# A record that names no subject owner cannot make that call, so every checkout +# in it stays internal and the drawing is the same as it was. +subject_owner="$(sed -n 's/^[[:space:]]*"subject_owner"[[:space:]]*:[[:space:]]*"\(.*\)".*$/\1/p' "$record" | head -1)" +[[ -n "$subject_owner" ]] || subject_owner="unknown" + # The top-level key/value split, shared with landscape-record.sh: it walks the # object rather than matching a pattern, so a value carrying a brace, a comma or # an escaped quote does not split the record in the wrong place. @@ -202,9 +209,22 @@ function field(line, want, keys, vals, n, i) { for (i = 1; i <= n; i++) if (keys[i] == want) return vals[i] return "" } -function unquote(v) { - if (substr(v, 1, 1) == "\"") return substr(v, 2, length(v) - 2) - return v +# The record is JSON, so a quote or a backslash inside a value arrives escaped. +# Stripping the delimiters without undoing the escapes hands the next stage a +# stray backslash and a quote it will read as its own, so the value is decoded +# here and neutralised for the target grammar where it is written out. +function unquote(v, out, i, c, last) { + if (substr(v, 1, 1) != "\"") return v + v = substr(v, 2, length(v) - 2) + if (index(v, "\\") == 0) return v + out = "" + last = length(v) + for (i = 1; i <= last; i++) { + c = substr(v, i, 1) + if (c == "\\" && i < last) { i++; c = substr(v, i, 1) } + out = out c + } + return out } # A JSON string array to a plain list, "" when empty. function arraylist(v, sep, inner, parts, n, i, out) { @@ -235,13 +255,38 @@ AWK # edgefrom-aliasto-aliaslabelrelation # omitexternal-systems-not-drawn -model="$(awk -v top="$top_external" "$SPLIT_AWK"' +model="$(awk -v top="$top_external" -v subject_owner="$subject_owner" "$SPLIT_AWK"' function alias(s, a) { a = s gsub(/[^A-Za-z0-9]/, "_", a) if (a ~ /^[0-9]/) a = "n_" a return a } +# Every character outside the alphabet folds to the same underscore, so two +# repository names that differ only in punctuation, `a-b` and `a_b`, arrive at +# one identifier. Both dialects would then declare the system twice and point +# every relationship at whichever declaration won, so an alias already handed +# out is never handed out again. +function uniq(a, c, cand) { + cand = a + c = 1 + while (cand in taken) { c++; cand = a "_" c } + taken[cand] = 1 + return cand +} +# A repository name, an owner read out of CODEOWNERS and a target framework +# read out of a manifest are all repository-controlled text, and both dialects +# carry them inside a double-quoted string literal. Neither grammar offers a +# portable escape for its own delimiter, so the delimiter is replaced rather +# than escaped: a quote in any of these values is corrupt data or an attempt to +# splice diagram syntax, never a fact worth carrying through verbatim. A tab +# would split the model row itself, one field early. +function safe(s) { + gsub(/\\/, "/", s) + gsub(/"/, "\047", s) + gsub(/\t/, " ", s) + return s +} function primary(list, parts) { split(list, parts, ",") return parts[1] @@ -255,6 +300,10 @@ function primary(list, parts) { desc = primary(run) if (fw != "unknown" && fw != "") desc = desc ", " fw if (desc == "unknown" || desc == "") desc = "no probed runtime" + # Archiving is a fact about the system, and the most consequential one a + # reader of the diagram can learn about it, so it leads the description + # rather than trailing a runtime nobody will read that far for. + if (unquote(field($0, "archived")) == "true") desc = "archived, " desc order[++ln] = key ldesc[key] = desc ldisp[key] = name @@ -278,13 +327,22 @@ function primary(list, parts) { next } END { - # Every locally collected repository is a node, keyed by owner/name. + # Every locally collected repository is a node, keyed by owner/name. Having a + # checkout on disk says where someone works, not who owns the system: a + # third-party repository charted from a local clone is the same external + # system the edges to it already call external, so the owner decides. An + # ownerless repository has nothing to compare and stays internal, which is + # where it was already drawn. for (i = 1; i <= ln; i++) { k = order[i] + split(k, oseg, "/") isnode[k] = 1 - nrel[k] = "internal" + if (subject_owner != "unknown" && oseg[2] != "" && oseg[1] != subject_owner) + nrel[k] = "external" + else + nrel[k] = "internal" ndesc[k] = ldesc[k] - ndisp[k] = ldisp[k] + ndisp[k] = (nrel[k] == "external" ? k : ldisp[k]) } # Edge targets that are not local checkouts become nodes with no probed facts. for (i = 1; i <= tn; i++) { @@ -324,18 +382,31 @@ END { a = exts[i]; b = exts[j] if (weight[b] > weight[a] || (weight[b] == weight[a] && b < a)) { exts[i] = b; exts[j] = a } } + # Aliases are handed out once, in the order the two sorted lists will be + # emitted in, so the same record always yields the same identifiers and a + # collision is broken the same way every time. + for (i = 1; i <= ni; i++) aliasof[ints[i]] = uniq(alias(ints[i])) + for (i = 1; i <= ne; i++) aliasof[exts[i]] = uniq(alias(exts[i])) for (i = 1; i <= ni; i++) drawn[ints[i]] = 1 - shown_ext = (top < ne ? top : ne) - for (i = 1; i <= shown_ext; i++) drawn[exts[i]] = 1 + shown_ext = 0 + for (i = 1; i <= ne; i++) { + # --top-external trims the long tail of repositories this run only read + # about. A repository it actually probed was named by the operator or found + # in the subject checkout, so it is drawn whatever its reference weight. + if (i > top && !(exts[i] in ldesc)) continue + drawn[exts[i]] = 1 + shown_ext++ + } for (i = 1; i <= ni; i++) { k = ints[i] split(k, seg, "/") - printf "node\t%s\t%s\t%s\t%s\t%s\t%s\n", alias(k), k, ndisp[k], "internal", ndesc[k], (seg[2] == "" ? "unknown" : seg[1]) + printf "node\t%s\t%s\t%s\t%s\t%s\t%s\n", aliasof[k], k, safe(ndisp[k]), "internal", safe(ndesc[k]), safe(seg[2] == "" ? "unknown" : seg[1]) } - for (i = 1; i <= shown_ext; i++) { + for (i = 1; i <= ne; i++) { k = exts[i] + if (!(k in drawn)) continue split(k, seg, "/") - printf "node\t%s\t%s\t%s\t%s\t%s\t%s\n", alias(k), k, ndisp[k], "external", ndesc[k], (seg[2] == "" ? "unknown" : seg[1]) + printf "node\t%s\t%s\t%s\t%s\t%s\t%s\n", aliasof[k], k, safe(ndisp[k]), "external", safe(ndesc[k]), safe(seg[2] == "" ? "unknown" : seg[1]) } for (i = 1; i <= en; i++) { if (!(etarget[i] in drawn)) continue @@ -344,7 +415,7 @@ END { # the answer at the mercy of the unspecified array order in awk. fk = localkey[efrom[i]] if (fk == "" || !(fk in isnode)) continue - printf "edge\t%s\t%s\t%s\t%s\n", alias(fk), alias(etarget[i]), elabel[i], erel[i] + printf "edge\t%s\t%s\t%s\t%s\n", aliasof[fk], aliasof[etarget[i]], elabel[i], erel[i] } printf "omit\t%d\n", ne - shown_ext } @@ -477,20 +548,31 @@ fi printf '| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched |\n' printf '|---|---|---|---|---|---|---|\n' awk "$SPLIT_AWK"' - function cell(v) { return (v == "" ? "unknown" : v) } + # A pipe read out of a manifest ends the cell it lands in and shifts every + # column after it, so it is escaped to the pipe GFM renders as text. + # Joined rather than substituted: a backslash in a gsub replacement is + # underspecified, and mawk and gawk disagree on how many survive it. + function md(v, n, parts, i, out) { + n = split(v, parts, "|") + out = parts[1] + for (i = 2; i <= n; i++) out = out "\\|" parts[i] + return out + } + function cell(v) { return (v == "" ? "unknown" : md(v)) } function deplist(v, n, list, parts, i, out) { n = arraycount(v) if (n == 0) return "(none)" list = arraylist(v, ", ") - if (n <= 10) return list + if (n <= 10) return md(list) split(list, parts, ", ") out = "" for (i = 1; i <= 10; i++) out = out (i > 1 ? ", " : "") parts[i] - return out " (+" (n - 10) ")" + return md(out) " (+" (n - 10) ")" } /^[[:space:]]*\{"name":/ { rows[++n] = sprintf("| %s | %s | %s | %s | %s | %s | %s |", \ - unquote(field($0, "name")), \ + md(unquote(field($0, "name"))) \ + (unquote(field($0, "archived")) == "true" ? " (archived)" : ""), \ cell(unquote(field($0, "owner"))), \ cell(unquote(field($0, "target_framework"))), \ cell(gensub_commas(unquote(field($0, "runtime")))), \ @@ -538,11 +620,19 @@ fi printf '\n## Evidence\n\n' printf '| Repository | Fact | Source |\n|---|---|---|\n' awk "$SPLIT_AWK"' + # Joined rather than substituted: a backslash in a gsub replacement is + # underspecified, and mawk and gawk disagree on how many survive it. + function md(v, n, parts, i, out) { + n = split(v, parts, "|") + out = parts[1] + for (i = 2; i <= n; i++) out = out "\\|" parts[i] + return out + } /^[[:space:]]*\{"name":/ { - name = unquote(field($0, "name")) + name = md(unquote(field($0, "name"))) ev = field($0, "evidence") n = split_object(ev, k, v) - for (i = 1; i <= n; i++) printf "| %s | %s | %s |\n", name, k[i], unquote(v[i]) + for (i = 1; i <= n; i++) printf "| %s | %s | %s |\n", name, md(k[i]), md(unquote(v[i])) } ' "$record" } >"$outdir/portfolio.md" diff --git a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh index e87517f1b2..0ef0add097 100755 --- a/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh +++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh @@ -229,6 +229,161 @@ owners="$(cat "$TEST_TMPDIR/owners/landscape.md")" assert_contains "owners: the first owner gets a boundary" "$owners" 'Enterprise_Boundary(b0, "acme")' assert_contains "owners: the second gets its own, not a shared one" "$owners" 'Enterprise_Boundary(b1, "zeta")' +# --- Case group 8a: repository content stays inside its string literal ------ +# +# A target framework is read out of a manifest with only XML tags stripped, and +# a raw quote is legal there. Both dialects then carry it inside a quoted string +# literal, and neither offers a portable escape for its own delimiter, so the +# delimiter is replaced rather than escaped: what arrives here is corrupt data +# or an attempt to splice diagram syntax, never a fact worth keeping verbatim. +cat >"$TEST_TMPDIR/hostile.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "not used", + "subject_owner": "acme", + "repositories": [ + {"name":"payments","path":"/srv/payments","remote":"","owner":"acme","runtime":"dotnet","tooling":"unknown","target_framework":"net9.0\" } click n1 \"javascript:alert(1)\" \"pwn","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{"runtime":"a | b"}} + ], + "edges": [] +} +JSON +render hostile --record "$TEST_TMPDIR/hostile.json" +hostile="$(cat "$TEST_TMPDIR/hostile/landscape.md")" +assert_not_contains "injection: the payload cannot close the mermaid literal" "$hostile" '"pwn' +assert_contains "injection: the system is still drawn, with the value neutralised" \ + "$hostile" 'System(acme_payments, "payments", "dotnet, net9.0'"'"' } click n1' +quote_count="$(printf '%s\n' "$hostile" | grep -c '^ System(acme_payments, "payments", "[^"]*")$')" +assert_equals "injection: the mermaid call has exactly its own four quotes" "$quote_count" "1" +render hostile_dsl --record "$TEST_TMPDIR/hostile.json" --dialect structurizr +hostile_dsl="$(cat "$TEST_TMPDIR/hostile_dsl/landscape.dsl")" +assert_not_contains "injection: nor the structurizr one" "$hostile_dsl" '"pwn' +dsl_count="$(printf '%s\n' "$hostile_dsl" | grep -c '^ acme_payments = softwareSystem "payments" "[^"]*"$')" +assert_equals "injection: whose string closes where it should" "$dsl_count" "1" +# A pipe read out of a manifest ends the cell it lands in and shifts every +# column after it, which corrupts the table the same way. +hostile_portfolio="$(cat "$TEST_TMPDIR/hostile/portfolio.md")" +assert_contains "injection: a pipe in a fact is escaped, not a new column" \ + "$hostile_portfolio" '| a \| b |' +ev_row="$(printf '%s\n' "$hostile_portfolio" | grep -F '| payments | runtime |')" +assert_equals "injection: so the evidence row stays three cells wide" \ + "$ev_row" '| payments | runtime | a \| b |' + +# --- Case group 8b: one alias per system ------------------------------------ +# +# Every character outside the alphabet folds to the same underscore, so two +# names differing only in punctuation arrive at one identifier and take each +# other's relationships with them. +cat >"$TEST_TMPDIR/collide.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "not used", + "subject_owner": "acme", + "repositories": [ + {"name":"a-b","path":"/srv/a-b","remote":"","owner":"acme","runtime":"shell","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}}, + {"name":"a_b","path":"/srv/a_b","remote":"","owner":"acme","runtime":"shell","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}} + ], + "edges": [ + {"from":"a-b","to":"acme/a_b","type":"depends-on","relation":"internal","count":1,"files":["go.mod"]} + ] +} +JSON +render collide --record "$TEST_TMPDIR/collide.json" +collide="$(cat "$TEST_TMPDIR/collide/landscape.md")" +decls="$(printf '%s\n' "$collide" | grep -c '^ System(')" +assert_equals "alias: two systems, two declarations" "$decls" "2" +uniq_aliases="$(printf '%s\n' "$collide" | sed -n 's/^ System(\([^,]*\),.*$/\1/p' | sort -u | wc -l | tr -d ' ')" +assert_equals "alias: and two distinct identifiers" "$uniq_aliases" "2" +assert_contains "alias: the collision is broken with a counted suffix" "$collide" 'acme_a_b_2' +assert_contains "alias: the relationship points at one of them, not at both" \ + "$collide" 'Rel(acme_a_b, acme_a_b_2' +render collide2 --record "$TEST_TMPDIR/collide.json" +if diff -q "$TEST_TMPDIR/collide/landscape.md" "$TEST_TMPDIR/collide2/landscape.md" >/dev/null; then + pass "alias: the same record breaks the collision the same way twice" +else + fail "alias: the same record breaks the collision the same way twice" \ + "$(diff "$TEST_TMPDIR/collide/landscape.md" "$TEST_TMPDIR/collide2/landscape.md")" +fi + +# --- Case group 8c: a checkout is not a claim of ownership ------------------ +# +# Having a repository on disk says where someone works, not who owns the system. +# A third-party checkout is the same external system the edges to it call +# external, so the recorded subject owner decides and the local facts stay. +cat >"$TEST_TMPDIR/foreign.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "not used", + "subject_owner": "acme", + "repositories": [ + {"name":"web-ui","path":"/srv/web-ui","remote":"","owner":"acme","runtime":"node","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}}, + {"name":"vendor-sdk","path":"/srv/vendor-sdk","remote":"","owner":"thirdparty","runtime":"go","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}} + ], + "edges": [] +} +JSON +render foreign --record "$TEST_TMPDIR/foreign.json" +foreign="$(cat "$TEST_TMPDIR/foreign/landscape.md")" +assert_contains "foreign: the cross-owner checkout is drawn as external" \ + "$foreign" 'System_Ext(thirdparty_vendor_sdk, "thirdparty/vendor-sdk"' +assert_not_contains "foreign: and not inside an enterprise boundary for its owner" \ + "$foreign" 'Enterprise_Boundary(b1' +assert_contains "foreign: the subject owner still gets one" "$foreign" 'Enterprise_Boundary(b0, "acme")' +assert_contains "foreign: the facts the probe read are kept" "$foreign" '"go"' +# --top-external trims the tail of repositories this run only read about, never +# the set someone asked it to chart. +render foreign_capped --record "$TEST_TMPDIR/foreign.json" --top-external 0 +assert_contains "foreign: a probed external survives the external cap" \ + "$(cat "$TEST_TMPDIR/foreign_capped/landscape.md")" 'System_Ext(thirdparty_vendor_sdk' +# A record that names no subject owner cannot make the call, so nothing moves. +cat >"$TEST_TMPDIR/nosubject.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "not used", + "repositories": [ + {"name":"vendor-sdk","path":"/srv/vendor-sdk","remote":"","owner":"thirdparty","runtime":"go","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"unknown","evidence":{}} + ], + "edges": [] +} +JSON +render nosubject --record "$TEST_TMPDIR/nosubject.json" +assert_contains "foreign: with no subject owner recorded, the drawing is unchanged" \ + "$(cat "$TEST_TMPDIR/nosubject/landscape.md")" 'System(thirdparty_vendor_sdk, "vendor-sdk"' + +# --- Case group 8d: an archived system is charted and marked ---------------- +# +# Archiving is a fact about the system, and the most consequential one a reader +# can learn about it: a landscape that quietly drops archived repositories hides +# exactly the dependencies worth acting on. +cat >"$TEST_TMPDIR/archived.json" <<'JSON' +{ + "schema_version": 1, + "generated_on": "2026-01-02", + "discovery_source": "explicit list", + "remote": "used, owned only", + "subject_owner": "acme", + "repositories": [ + {"name":"legacy-api","remote":"https://github.com/acme/legacy-api","owner":"acme","runtime":"ruby","tooling":"unknown","target_framework":"unknown","dependencies":[],"dev_dependencies":[],"last_touched":"2024-05-05T00:00:00+00:00","archived":true,"evidence":{"last_touched":"pushed_at (remote)"}} + ], + "edges": [] +} +JSON +render archived --record "$TEST_TMPDIR/archived.json" +assert_contains "archived: the node says so before it says anything else" \ + "$(cat "$TEST_TMPDIR/archived/landscape.md")" 'System(acme_legacy_api, "legacy-api", "archived, ruby")' +assert_contains "archived: and the portfolio row is marked" \ + "$(cat "$TEST_TMPDIR/archived/portfolio.md")" '| legacy-api (archived) |' +assert_not_contains "archived: it is charted, not hidden" \ + "$(cat "$TEST_TMPDIR/archived/portfolio.md")" '| (none) | unknown | unknown | +' + # --- Case group 9: markdown that lints -------------------------------------- # # Probed on a file known to be clean first. `npx --no-install` exits non-zero From 1a69a263475db0d44f7e8ec745b40646d947ab3e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 06:30:11 +0000 Subject: [PATCH 15/15] docs(architecture): refresh the landscape record for the merged base Integrating main brought in tracked files that the reference graph reads, so `anthropics/skills` gains two citations. The `stale-base-overlap` gate refused the branch for the same reason it refuses any stale base: both sides had touched docs/CATALOG.md, and a squash cut from there can revert what landed on the base after the branch point. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012Drcna7MoR2BX9KrLqRy5i --- docs/architecture/landscape.json | 4 ++-- docs/architecture/portfolio.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/landscape.json b/docs/architecture/landscape.json index f47184aa05..48e472ea46 100644 --- a/docs/architecture/landscape.json +++ b/docs/architecture/landscape.json @@ -5,7 +5,7 @@ "remote": "not used", "subject_owner": "melodic-software", "repositories": [ - {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-11T05:36:33+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} + {"name":"claude-code-plugins","remote":"https://github.com/melodic-software/claude-code-plugins","owner":"melodic-software","runtime":"shell","tooling":"node,python","target_framework":"unknown","dependencies":[],"dev_dependencies":["@anthropic-ai/claude-code","@biomejs/biome","htmlhint","iniconfig","markdownlint-cli2","packaging","pluggy","pygments","pytest","pyyaml","ruff","tree-sitter","tree-sitter-bash","tree-sitter-c-sharp","tree-sitter-javascript","tree-sitter-python","tree-sitter-toml","tree-sitter-typescript","tree-sitter-yaml","zizmor"],"last_touched":"2026-09-11T06:27:34+00:00","evidence":{"owner":"origin remote URL","runtime":"shell: lib/hook-utils.sh","tooling":"node: package.json (development scope), python: .github/requirements-ci.txt (development scope)","target_framework":"no framework declaration for runtime shell","dependencies":"no runtime-scope dependency manifest","dev_dependencies":"package.json (devDependencies), .github/requirements-ci.txt (development scope)","last_touched":"git log -1 --format=%cI (local HEAD)"}} ], "edges": [ {"from":"claude-code-plugins","to":"DavidAnson/markdownlint","type":"cites","relation":"external","count":2,"files":[".markdownlint-cli2.jsonc","plugins/docs-hygiene/skills/audit-noise/SKILL.md"]}, @@ -18,7 +18,7 @@ {"from":"claude-code-plugins","to":"anthropics/claude-code","type":"cites","relation":"external","count":37,"files":["docs/MIGRATION-PLAYBOOK.md","docs/PLUGIN-PHILOSOPHY.md","docs/conventions/hook-config-delivery/README.md","docs/conventions/permission-rule-hygiene/README.md","docs/conventions/topic-docs/README.md"]}, {"from":"claude-code-plugins","to":"anthropics/claude-code-action","type":"cites","relation":"external","count":1,"files":["plugins/improvement/README.md"]}, {"from":"claude-code-plugins","to":"anthropics/claude-plugins-official","type":"cites","relation":"external","count":1,"files":["plugins/playgrounds/README.md"]}, - {"from":"claude-code-plugins","to":"anthropics/skills","type":"cites","relation":"external","count":1,"files":["plugins/docs-hygiene/skills/audit-progressive-disclosure/SKILL.md"]}, + {"from":"claude-code-plugins","to":"anthropics/skills","type":"cites","relation":"external","count":3,"files":["plugins/docs-hygiene/skills/audit-progressive-disclosure/SKILL.md","plugins/playbooks/skills/skill-authoring/reference/authoring-guidance.md","plugins/skill-quality/scripts/check-skill.sh"]}, {"from":"claude-code-plugins","to":"ayghri/i-have-adhd","type":"cites","relation":"external","count":4,"files":["plugins/adhd/CHANGELOG.md","plugins/adhd/LICENSE","plugins/adhd/README.md","plugins/adhd/skills/shape/SKILL.md"]}, {"from":"claude-code-plugins","to":"bash-lsp/bash-language-server","type":"cites","relation":"external","count":2,"files":["docs/specs/dead-code-lsp-viability.md"]}, {"from":"claude-code-plugins","to":"bitinn/node-fetch","type":"cites","relation":"external","count":1,"files":["plugins/miro/dist/index.min.js"]}, diff --git a/docs/architecture/portfolio.md b/docs/architecture/portfolio.md index 04ee06402c..268d2eaaa8 100644 --- a/docs/architecture/portfolio.md +++ b/docs/architecture/portfolio.md @@ -12,7 +12,7 @@ is built with. | Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched | |---|---|---|---|---|---|---| -| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-11T05:36:33+00:00 | +| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-11T06:27:34+00:00 | ## Development-scope dependencies