diff --git a/docs/CATALOG.md b/docs/CATALOG.md
index 13652a2e97..ef045125d8 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 |
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/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..48e472ea46
--- /dev/null
+++ b/docs/architecture/landscape.json
@@ -0,0 +1,93 @@
+{
+ "schema_version": 1,
+ "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-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"]},
+ {"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":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"]},
+ {"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":"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"]},
+ {"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":"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":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"]},
+ {"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":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"]},
+ {"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
new file mode 100644
index 0000000000..35c0d8d417
--- /dev/null
+++ b/docs/architecture/landscape.md
@@ -0,0 +1,75 @@
+# System Landscape
+
+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.
+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(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", "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)")
+ 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)")
+```
+
+64 external repositories are referenced but not drawn; the record carries
+every one of them.
+
+## 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/portfolio.md b/docs/architecture/portfolio.md
new file mode 100644
index 0000000000..268d2eaaa8
--- /dev/null
+++ b/docs/architecture/portfolio.md
@@ -0,0 +1,33 @@
+# Application portfolio
+
+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
+could derive the value.
+
+`Runtime` and `Dependencies` are runtime scope, what the repository runs on.
+`Tooling` and the development-scope dependencies below the table are what it
+is built with.
+
+| Repository | Owner | Target framework | Runtime | Dependencies | Tooling | Last touched |
+|---|---|---|---|---|---|---|
+| claude-code-plugins | melodic-software | unknown | shell | (none) | node, python | 2026-09-11T06:27:34+00:00 |
+
+## Development-scope dependencies
+
+Truncated to ten per repository; the record carries the full list.
+
+- claude-code-plugins: `@anthropic-ai/claude-code`, `@biomejs/biome`, `htmlhint`, `iniconfig`, `markdownlint-cli2`, `packaging`, `pluggy`, `pygments`, `pytest`, `pyyaml` (+10)
+
+## Evidence
+
+| 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 a244547051..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.7",
- "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 49ad9bc3b3..e94769fd2b 100644
--- a/plugins/architecture/CHANGELOG.md
+++ b/plugins/architecture/CHANGELOG.md
@@ -3,6 +3,93 @@
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.
+
+### 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.
+- **`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
+
+- **`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.
+- **`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.7]
### Fixed
diff --git a/plugins/architecture/README.md b/plugins/architecture/README.md
index e1136e7d99..dfb9c55f4a 100644
--- a/plugins/architecture/README.md
+++ b/plugins/architecture/README.md
@@ -37,19 +37,41 @@ 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. 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/reference/config.md b/plugins/architecture/reference/config.md
index ca3ae33cbe..02c73cb311 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 5e8032efe5..271c8e02cb 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,129 +32,160 @@ 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`,
-`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.
+`--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.
-## Draw relationships
+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[]`).
-Relationships are your judgment, but every edge cites evidence. An edge A to B exists ONLY when a
-fact in A names B:
+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.
-- 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.
+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.
-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.
+`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.
-## Emit artifacts
+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
-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
- }
- }
-}
+When `/landscape.json` already exists, compare before writing anything:
+
+```bash
+"${CLAUDE_SKILL_DIR}/scripts/landscape-record.sh" \
+ --drift-against "/landscape.json" --edges-from ...
```
-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")
+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.
+
+`--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
+
+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.
+
+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.
+
+```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 | 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)`.
+- **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
@@ -169,15 +200,38 @@ to 10 with a trailing `(+N)`.
the recheck trigger (experimental banner drops, or mermaid documents a dedicated landscape
type) live in `${CLAUDE_PLUGIN_ROOT}/reference/config.md` under C4 dialect surfaces. This
skill does not carry a second stamp.
-- **`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.
+- **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`. 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.
+- **`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..a1a1573804
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/reference/scope-modes.md
@@ -0,0 +1,95 @@
+# 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.
+
+### 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
new file mode 100755
index 0000000000..7ed2359ee7
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.sh
@@ -0,0 +1,511 @@
+#!/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.
+# --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.
+#
+# Output without --drift-against: the record on stdout.
+#
+# {
+# "schema_version": 1,
+# "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
+# 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 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.
+#
+# 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"
+remote_facts=""
+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
+ ;;
+ --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"
+ 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
+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 -------------------------------------------------------------------
+
+# 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")"
+ 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.
+ 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
+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=""
+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
+ 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'
+
+# 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"
+
+# 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
+ case "$c" in
+ last_touched:*) note " moved on $id: $c" ;;
+ *) say " changed $label on $id: $c" ;;
+ esac
+ 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
+ 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
+
+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
new file mode 100755
index 0000000000..ef054a0f2a
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/scripts/landscape-record.test.sh
@@ -0,0 +1,334 @@
+#!/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_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"'
+
+# 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"'
+
+# 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")"
+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 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)"
+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: 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"
+
+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"
+
+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"
+
+printf '\n%d cases, %d failed\n' "$CASE_NUM" "$FAILED"
+[[ "$FAILED" -eq 0 ]] || exit 1
+exit 0
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 '^{')"
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..f683174828
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.sh
@@ -0,0 +1,435 @@
+#!/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 [--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":[…]}
+#
+# 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=""
+print_owner=0
+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=}" ;;
+ --print-owner) print_owner=1 ;;
+ -*)
+ 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=""
+
+# 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
+# ---------------------------------------------------------------------------
+#
+# `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 ;;
+ *) 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
+}
+
+# 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:]]*}"}"
+ # 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 ;;
+ *) ;;
+ 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..a12aec8ea4
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/scripts/reference-edges.test.sh
@@ -0,0 +1,321 @@
+#!/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"'
+
+# 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 ------------------------------------------
+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: 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"
+
+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
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..1887cfe2e3
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.sh
@@ -0,0 +1,640 @@
+#!/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
+
+# 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.
+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 ""
+}
+# 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) {
+ 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" -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]
+}
+/^[[: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"
+ # 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
+ 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. 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
+ if (subject_owner != "unknown" && oseg[2] != "" && oseg[1] != subject_owner)
+ nrel[k] = "external"
+ else
+ nrel[k] = "internal"
+ ndesc[k] = ldesc[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++) {
+ 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 }
+ }
+ # 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 = 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", aliasof[k], k, safe(ndisp[k]), "internal", safe(ndesc[k]), safe(seg[2] == "" ? "unknown" : seg[1])
+ }
+ 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", 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
+ # 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", aliasof[fk], aliasof[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")
+ # 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]
+ 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")
+ # 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]
+ 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'
+ # 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
+
+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"'
+ # 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 md(list)
+ split(list, parts, ", ")
+ out = ""
+ for (i = 1; i <= 10; i++) out = out (i > 1 ? ", " : "") parts[i]
+ return md(out) " (+" (n - 10) ")"
+ }
+ /^[[:space:]]*\{"name":/ {
+ rows[++n] = sprintf("| %s | %s | %s | %s | %s | %s | %s |", \
+ 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")))), \
+ 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"'
+ # 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 = 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, md(k[i]), md(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 100755
index 0000000000..0ef0add097
--- /dev/null
+++ b/plugins/architecture/skills/map-landscape/scripts/render-landscape.test.sh
@@ -0,0 +1,437 @@
+#!/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)"'
+# 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
+ 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"'
+# 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) |'
+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 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
+# 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, the package is not installed here"
+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