From 4ca8f14a0061c3c1bb1b1312a9e54c92a14df02d Mon Sep 17 00:00:00 2001 From: KageBinary Date: Sat, 24 Oct 2026 11:12:45 -0700 Subject: [PATCH] feat(tools): say when the Ix architecture map is stale Since Ix #841 automatic maps may defer the architecture map after an edit, and region readers answer from the last map while a background refresh runs. Ix #880 reports this in machine output (JSON `map_stale` / `regions_as_of`, llm `diagnostic code=map_stale`). ix-subsystems (llm and JSON paths) and ix-map now turn it into one line under the heading, and the llm path drops the raw record so it is not said twice. Without the signal (fresh map, older CLI) output is unchanged. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + ARCHITECTURE.md | 1 + TOOL_CONTRACT.md | 5 +- runtime/map-stale.ts | 88 +++++++++++++++ tests/fixtures/ix-v0.12.0/README.md | 8 ++ .../synthetic/subsystems-list-stale.json | 1 + .../synthetic/subsystems-scope-stale.json | 1 + .../synthetic/subsystems-stale-no-asof.json | 1 + .../synthetic/subsystems-stale-no-asof.txt | 13 +++ .../synthetic/subsystems-stale.json | 1 + .../ix-v0.12.0/synthetic/subsystems-stale.txt | 13 +++ tests/map-stale.test.ts | 70 ++++++++++++ tests/success-fixtures.test.ts | 104 ++++++++++++++++++ tools/ix-map.ts | 50 +++++---- tools/ix-subsystems.ts | 16 ++- 15 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 runtime/map-stale.ts create mode 100644 tests/fixtures/ix-v0.12.0/synthetic/subsystems-list-stale.json create mode 100644 tests/fixtures/ix-v0.12.0/synthetic/subsystems-scope-stale.json create mode 100644 tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.json create mode 100644 tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.txt create mode 100644 tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.json create mode 100644 tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.txt create mode 100644 tests/map-stale.test.ts diff --git a/AGENTS.md b/AGENTS.md index 170df91..8f74992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ runtime/ cli.ts — runs `ix` with a timeout, keeping stdout on a non-zero exit automap.ts — the guarded, debounced post-edit `ix map ` llm.ts — version-gated `--format llm` fast path + map-stale.ts — reads Ix's stale-architecture-map signal (Ix#880) for ix-map / ix-subsystems secrets.ts — secret detection and redaction for tool output commands/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 27f9fd7..705b296 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,6 +38,7 @@ ix-opencode-plugin/ cli.ts # runs `ix` with a deadline automap.ts # guarded, debounced post-edit `ix map` llm.ts # version-gated `--format llm` fast path + map-stale.ts # Ix's stale-architecture-map signal (Ix#880) secrets.ts # secret redaction for tool output commands/ ix-understand.md # /ix-understand — architectural mental model diff --git a/TOOL_CONTRACT.md b/TOOL_CONTRACT.md index d677f6d..379bc96 100644 --- a/TOOL_CONTRACT.md +++ b/TOOL_CONTRACT.md @@ -172,6 +172,7 @@ Risk levels: `low` → `SAFE TO PROCEED`, `medium` → `REVIEW CALLERS FIRST`, ` - Lists `scores[]` from `ix subsystems --list` as subsystem health - Takes the file count from `stats.nodes.byKind` (kind `file`) and totals from `nodes.total` / `edges.total` - Flags multi-file subsystems with low cohesion (< 0.4) with ⚠. Ix's `coupling` is an edge-weight count, not a 0–1 ratio, so it is shown but not judged +- When either `ix subsystems` call reports the architecture map stale (`map_stale: true`, Ix#880), adds one line under the heading: `_Map is stale (regions as of map rev 7, 42 files); recent edits may not be reflected — a background refresh is running._` (the parenthetical only when Ix gives `regions_as_of`). Without the field (fresh map, older CLI) the output is unchanged **Example output:** ``` @@ -382,7 +383,9 @@ _55 files · 870 nodes_ **Parameters:** none -**CLI:** `ix subsystems --format json` +**CLI:** `ix subsystems --format llm` (CLI ≥ 0.7.0), else `ix subsystems --format json` + +When Ix reports the architecture map stale (llm `diagnostic code=map_stale` record, or JSON `map_stale: true`; Ix#880), the same stale line as `ix-map` goes under the heading and the raw diagnostic record is not repeated. Without it the output is unchanged. --- diff --git a/runtime/map-stale.ts b/runtime/map-stale.ts new file mode 100644 index 0000000..9d38c5a --- /dev/null +++ b/runtime/map-stale.ts @@ -0,0 +1,88 @@ +// Copyright 2026 Ix Infrastructure Inc. + +/** + * Saying when the Ix architecture map is stale. + * + * Since Ix#841 the automatic `ix map` the hooks run after an edit may defer + * recomputing the architecture map: region readers (`ix subsystems`, + * `overview`, `explain`, `impact`, `locate`) answer from the last computed map + * and start a background refresh. Since Ix#880 their machine output says so: + * + * --format json top-level `map_stale: true`, and `regions_as_of` + * (`{map_rev, file_count}` of the last map) when known + * --format llm one `diagnostic code=map_stale message="..."` record, + * just before the answer's first record + * + * A fresh map adds neither, and an older CLI never emits them, so a tool that + * finds nothing here renders exactly what it did before. Nothing in this + * module throws on unexpected input: a malformed field reads as "not stale" or + * "no regions_as_of". + */ + +import { parseLlmRecord } from "./ix-error.ts"; + +/** The map the regions come from, as Ix names it. */ +export interface RegionsAsOf { + map_rev: number; + file_count: number; +} + +/** What a region reader's output said about the map, when it said it is stale. */ +export interface MapStale { + regionsAsOf?: RegionsAsOf; +} + +function regionsAsOfFrom(value: unknown): RegionsAsOf | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const v = value as Record; + const rev = v["map_rev"]; + const files = v["file_count"]; + if (typeof rev !== "number" || !Number.isFinite(rev)) return undefined; + if (typeof files !== "number" || !Number.isFinite(files)) return undefined; + return { map_rev: rev, file_count: files }; +} + +/** From a parsed `--format json` payload: stale only when `map_stale === true`. */ +export function mapStaleFromJson(value: unknown): MapStale | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const v = value as Record; + if (v["map_stale"] !== true) return null; + const asOf = regionsAsOfFrom(v["regions_as_of"]); + return asOf ? { regionsAsOf: asOf } : {}; +} + +const MAP_STALE_RECORD = /^diagnostic\s+code=map_stale(\s|$)/; + +/** + * From `--format llm` text: whether it carries a `diagnostic code=map_stale` + * record, and the text without it (so the tool does not print it twice). + * Text without the record comes back unchanged. The record's message names + * the last map as `(map rev N, M files)`; that is read when present. + */ +export function splitLlmMapStale(text: string): { stale: MapStale | null; body: string } { + const lines = text.split("\n"); + let stale: MapStale | null = null; + const kept: string[] = []; + for (const line of lines) { + if (MAP_STALE_RECORD.test(line.trim())) { + if (!stale) { + stale = {}; + const message = parseLlmRecord(line).fields["message"] ?? ""; + const m = message.match(/\(map rev (\d+), (\d+) files?\)/); + if (m) stale.regionsAsOf = { map_rev: Number(m[1]), file_count: Number(m[2]) }; + } + continue; + } + kept.push(line); + } + if (!stale) return { stale: null, body: text }; + return { stale, body: kept.join("\n").trim() }; +} + +/** The one line a tool puts under its heading while the map is stale. */ +export function mapStaleLine(stale: MapStale): string { + const asOf = stale.regionsAsOf + ? ` (regions as of map rev ${stale.regionsAsOf.map_rev}, ${stale.regionsAsOf.file_count} file${stale.regionsAsOf.file_count === 1 ? "" : "s"})` + : ""; + return `_Map is stale${asOf}; recent edits may not be reflected — a background refresh is running._`; +} diff --git a/tests/fixtures/ix-v0.12.0/README.md b/tests/fixtures/ix-v0.12.0/README.md index aecdf21..7814458 100644 --- a/tests/fixtures/ix-v0.12.0/README.md +++ b/tests/fixtures/ix-v0.12.0/README.md @@ -112,6 +112,14 @@ file, `summary.{callers,callees}` for a function, `propagationBuckets[].region`, and for `impact-degraded.json` the `graph` block with `riskLevel: "unknown"` that Ix emits on a hollow graph. +`synthetic/subsystems-stale*.{json,txt}`, `synthetic/subsystems-list-stale.json` +and `synthetic/subsystems-scope-stale.json` are the `success/subsystems*` +captures as a CLI with Ix#880 prints them while the architecture map is stale: +JSON gains top-level `map_stale: true` and (except `-no-asof`) `regions_as_of` +(`{map_rev, file_count}`), llm gains one `diagnostic code=map_stale` record +first, worded as Ix's `mapStaleNotice`. Not v0.12.0 output; kept here beside +the captures they extend. + `synthetic/smells-run*.{json,txt}` are `ix smells` detection runs (no `--list`), built from `renderSmellsRunLlm` and the compact JSON in Ix's `ix-cli/src/cli/commands/smells.ts`. A real capture needs a detection run, which diff --git a/tests/fixtures/ix-v0.12.0/synthetic/subsystems-list-stale.json b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-list-stale.json new file mode 100644 index 0000000..ef9574d --- /dev/null +++ b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-list-stale.json @@ -0,0 +1 @@ +{"scores":[{"name":"Tools","level":5,"health":0.63,"files":24,"chunks_per_file":0,"smell_files":0},{"name":"Agents","level":5,"health":0.59,"files":5,"chunks_per_file":0,"smell_files":0},{"name":".github","level":5,"health":0.57,"files":3,"chunks_per_file":0,"smell_files":0},{"name":"Workflows","level":5,"health":0.57,"files":3,"chunks_per_file":0,"smell_files":0},{"name":"Runtime","level":1,"health":0.52,"files":8,"chunks_per_file":0,"smell_files":0},{"name":"Explain","level":1,"health":0.51,"files":7,"chunks_per_file":0,"smell_files":0},{"name":"Impact","level":1,"health":0.5,"files":5,"chunks_per_file":0,"smell_files":0},{"name":"Plugins","level":1,"health":0.49,"files":4,"chunks_per_file":0,"smell_files":0},{"name":"Scripts","level":1,"health":0.41,"files":1,"chunks_per_file":0,"smell_files":0},{"name":"Opencode","level":1,"health":0.41,"files":1,"chunks_per_file":0,"smell_files":0},{"name":"Package","level":1,"health":0.41,"files":1,"chunks_per_file":0,"smell_files":0}],"map_stale":true,"regions_as_of":{"map_rev":1,"file_count":38}} diff --git a/tests/fixtures/ix-v0.12.0/synthetic/subsystems-scope-stale.json b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-scope-stale.json new file mode 100644 index 0000000..dc11139 --- /dev/null +++ b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-scope-stale.json @@ -0,0 +1 @@ +{"target":{"label":"Runtime","kind":"module","level":1,"files":8,"confidence":0.4,"signals":["calls","path","imports"]},"parent":{"label":"Tools","kind":"system"},"summary":{"well_defined":0,"moderate":0,"fuzzy":1,"cross_cutting":0},"hierarchy":{"label":"Runtime","kind":"module","files":8,"confidence":0.4},"children":[],"map_stale":true,"regions_as_of":{"map_rev":1,"file_count":38}} diff --git a/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.json b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.json new file mode 100644 index 0000000..887fce1 --- /dev/null +++ b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.json @@ -0,0 +1 @@ +{"file_count":38,"region_count":11,"levels":2,"map_rev":1,"outcome":"full_local_completed","regions":[{"label":"Explain","label_kind":"module","level":1,"files":7,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.45,"coupling":28.98,"boundary":0.32,"confidence":0.38,"signals":["calls","imports","path"],"interfaces":7,"crosscut":0.04},{"label":"Impact","label_kind":"module","level":1,"files":5,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.67,"coupling":33.74,"boundary":0.2,"confidence":0.34,"signals":["calls","imports","path"],"interfaces":5,"crosscut":0.14},{"label":"Opencode","label_kind":"module","level":1,"files":1,"children":0,"parent_id":null,"cohesion":0,"coupling":0.2,"boundary":0,"confidence":0.04,"signals":["path"],"interfaces":1},{"label":"Package","label_kind":"module","level":1,"files":1,"children":0,"parent_id":null,"cohesion":0,"coupling":0.2,"boundary":0,"confidence":0.04,"signals":["path"],"interfaces":1},{"label":"Plugins","label_kind":"module","level":1,"files":4,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.49,"coupling":21.79,"boundary":0.14,"confidence":0.31,"signals":["imports","calls","path"],"interfaces":4},{"label":"Runtime","label_kind":"module","level":1,"files":8,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.4,"coupling":29.16,"boundary":0.38,"confidence":0.4,"signals":["calls","path","imports"],"interfaces":8,"crosscut":0.01},{"label":"Scripts","label_kind":"module","level":1,"files":1,"children":0,"parent_id":null,"cohesion":0,"coupling":0,"boundary":0,"confidence":0.04,"signals":["path"],"interfaces":0},{"label":".github","label_kind":"system","level":5,"files":3,"children":0,"parent_id":null,"cohesion":0.2,"coupling":0,"boundary":100,"confidence":0.57,"signals":["path"],"interfaces":0},{"label":"Agents","label_kind":"system","level":5,"files":5,"children":0,"parent_id":null,"cohesion":0.2,"coupling":0,"boundary":100,"confidence":0.63,"signals":["path"],"interfaces":0},{"label":"Tools","label_kind":"system","level":5,"files":24,"children":4,"parent_id":null,"cohesion":0.31,"coupling":0,"boundary":100,"confidence":0.78,"signals":["calls","path","imports"],"interfaces":0,"crosscut":0.04},{"label":"Workflows","label_kind":"system","level":5,"files":3,"children":0,"parent_id":null,"cohesion":0.2,"coupling":0,"boundary":100,"confidence":0.57,"signals":["path"],"interfaces":0}],"hierarchy":{"confidence":null},"map_stale":true} diff --git a/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.txt b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.txt new file mode 100644 index 0000000..d257bcb --- /dev/null +++ b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.txt @@ -0,0 +1,13 @@ +diagnostic code=map_stale message="Stale map: not recomputed after this edit. Every count and region here is from the last map. Run ix map --remap to recompute it now; region views refresh it in the background." +map files=38 regions=11 levels=2 rev=1 outcome=full_local_completed +region id=d1d2d840 kind=module label=Explain level=1 files=7 parent=9e37e2f3 cohesion=0.45 coupling=28.98 crosscut=0.04 confidence=0.38 signals=calls,imports,path +region id=a12802ed kind=module label=Impact level=1 files=5 parent=9e37e2f3 cohesion=0.67 coupling=33.74 crosscut=0.14 confidence=0.34 signals=calls,imports,path +region id=d4a36832 kind=module label=Opencode level=1 files=1 cohesion=0 coupling=0.2 confidence=0.04 signals=path +region id=f93dc886 kind=module label=Package level=1 files=1 cohesion=0 coupling=0.2 confidence=0.04 signals=path +region id=f762eb93 kind=module label=Plugins level=1 files=4 parent=9e37e2f3 cohesion=0.49 coupling=21.79 confidence=0.31 signals=imports,calls,path +region id=7010867e kind=module label=Runtime level=1 files=8 parent=9e37e2f3 cohesion=0.4 coupling=29.16 crosscut=0.01 confidence=0.4 signals=calls,path,imports +region id=3ece01a7 kind=module label=Scripts level=1 files=1 cohesion=0 coupling=0 confidence=0.04 signals=path +region id=1dcc96f8 kind=system label=.github level=5 files=3 cohesion=0.2 coupling=0 confidence=0.57 signals=path +region id=9fd8cfcf kind=system label=Agents level=5 files=5 cohesion=0.2 coupling=0 confidence=0.63 signals=path +region id=9e37e2f3 kind=system label=Tools level=5 files=24 children=4 cohesion=0.31 coupling=0 crosscut=0.04 confidence=0.78 signals=calls,path,imports +region id=c1962d16 kind=system label=Workflows level=5 files=3 cohesion=0.2 coupling=0 confidence=0.57 signals=path diff --git a/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.json b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.json new file mode 100644 index 0000000..ba2423b --- /dev/null +++ b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.json @@ -0,0 +1 @@ +{"file_count":38,"region_count":11,"levels":2,"map_rev":1,"outcome":"full_local_completed","regions":[{"label":"Explain","label_kind":"module","level":1,"files":7,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.45,"coupling":28.98,"boundary":0.32,"confidence":0.38,"signals":["calls","imports","path"],"interfaces":7,"crosscut":0.04},{"label":"Impact","label_kind":"module","level":1,"files":5,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.67,"coupling":33.74,"boundary":0.2,"confidence":0.34,"signals":["calls","imports","path"],"interfaces":5,"crosscut":0.14},{"label":"Opencode","label_kind":"module","level":1,"files":1,"children":0,"parent_id":null,"cohesion":0,"coupling":0.2,"boundary":0,"confidence":0.04,"signals":["path"],"interfaces":1},{"label":"Package","label_kind":"module","level":1,"files":1,"children":0,"parent_id":null,"cohesion":0,"coupling":0.2,"boundary":0,"confidence":0.04,"signals":["path"],"interfaces":1},{"label":"Plugins","label_kind":"module","level":1,"files":4,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.49,"coupling":21.79,"boundary":0.14,"confidence":0.31,"signals":["imports","calls","path"],"interfaces":4},{"label":"Runtime","label_kind":"module","level":1,"files":8,"children":0,"parent_id":"9e37e2f3-9c09-3dd2-bd79-33b5957199b8","cohesion":0.4,"coupling":29.16,"boundary":0.38,"confidence":0.4,"signals":["calls","path","imports"],"interfaces":8,"crosscut":0.01},{"label":"Scripts","label_kind":"module","level":1,"files":1,"children":0,"parent_id":null,"cohesion":0,"coupling":0,"boundary":0,"confidence":0.04,"signals":["path"],"interfaces":0},{"label":".github","label_kind":"system","level":5,"files":3,"children":0,"parent_id":null,"cohesion":0.2,"coupling":0,"boundary":100,"confidence":0.57,"signals":["path"],"interfaces":0},{"label":"Agents","label_kind":"system","level":5,"files":5,"children":0,"parent_id":null,"cohesion":0.2,"coupling":0,"boundary":100,"confidence":0.63,"signals":["path"],"interfaces":0},{"label":"Tools","label_kind":"system","level":5,"files":24,"children":4,"parent_id":null,"cohesion":0.31,"coupling":0,"boundary":100,"confidence":0.78,"signals":["calls","path","imports"],"interfaces":0,"crosscut":0.04},{"label":"Workflows","label_kind":"system","level":5,"files":3,"children":0,"parent_id":null,"cohesion":0.2,"coupling":0,"boundary":100,"confidence":0.57,"signals":["path"],"interfaces":0}],"hierarchy":{"confidence":null},"map_stale":true,"regions_as_of":{"map_rev":1,"file_count":38}} diff --git a/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.txt b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.txt new file mode 100644 index 0000000..fe8b53f --- /dev/null +++ b/tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.txt @@ -0,0 +1,13 @@ +diagnostic code=map_stale message="Stale map: not recomputed after this edit. Every count and region here is from the last map (map rev 1, 38 files). Run ix map --remap to recompute it now; region views refresh it in the background." +map files=38 regions=11 levels=2 rev=1 outcome=full_local_completed +region id=d1d2d840 kind=module label=Explain level=1 files=7 parent=9e37e2f3 cohesion=0.45 coupling=28.98 crosscut=0.04 confidence=0.38 signals=calls,imports,path +region id=a12802ed kind=module label=Impact level=1 files=5 parent=9e37e2f3 cohesion=0.67 coupling=33.74 crosscut=0.14 confidence=0.34 signals=calls,imports,path +region id=d4a36832 kind=module label=Opencode level=1 files=1 cohesion=0 coupling=0.2 confidence=0.04 signals=path +region id=f93dc886 kind=module label=Package level=1 files=1 cohesion=0 coupling=0.2 confidence=0.04 signals=path +region id=f762eb93 kind=module label=Plugins level=1 files=4 parent=9e37e2f3 cohesion=0.49 coupling=21.79 confidence=0.31 signals=imports,calls,path +region id=7010867e kind=module label=Runtime level=1 files=8 parent=9e37e2f3 cohesion=0.4 coupling=29.16 crosscut=0.01 confidence=0.4 signals=calls,path,imports +region id=3ece01a7 kind=module label=Scripts level=1 files=1 cohesion=0 coupling=0 confidence=0.04 signals=path +region id=1dcc96f8 kind=system label=.github level=5 files=3 cohesion=0.2 coupling=0 confidence=0.57 signals=path +region id=9fd8cfcf kind=system label=Agents level=5 files=5 cohesion=0.2 coupling=0 confidence=0.63 signals=path +region id=9e37e2f3 kind=system label=Tools level=5 files=24 children=4 cohesion=0.31 coupling=0 crosscut=0.04 confidence=0.78 signals=calls,path,imports +region id=c1962d16 kind=system label=Workflows level=5 files=3 cohesion=0.2 coupling=0 confidence=0.57 signals=path diff --git a/tests/map-stale.test.ts b/tests/map-stale.test.ts new file mode 100644 index 0000000..b77a86a --- /dev/null +++ b/tests/map-stale.test.ts @@ -0,0 +1,70 @@ +// Copyright 2026 Ix Infrastructure Inc. + +/** + * Reading Ix's stale-map signal (Ix#880) without ever throwing on what an + * older or newer CLI prints instead. + * + * Run with: bun test + */ + +import { describe, expect, test } from "bun:test"; + +import { mapStaleFromJson, mapStaleLine, splitLlmMapStale } from "../runtime/map-stale.ts"; +import { parseIxError } from "../runtime/ix-error.ts"; + +const RECORD = + 'diagnostic code=map_stale message="Stale map: not recomputed after this edit. Every count and region here is from the last map (map rev 7, 42 files). Run ix map --remap to recompute it now; region views refresh it in the background."'; + +describe("mapStaleFromJson", () => { + test("map_stale true, with regions_as_of", () => { + expect(mapStaleFromJson({ map_stale: true, regions_as_of: { map_rev: 7, file_count: 42 } })).toEqual({ + regionsAsOf: { map_rev: 7, file_count: 42 }, + }); + }); + + test("map_stale true, regions_as_of missing, null or malformed", () => { + expect(mapStaleFromJson({ map_stale: true })).toEqual({}); + expect(mapStaleFromJson({ map_stale: true, regions_as_of: null })).toEqual({}); + expect(mapStaleFromJson({ map_stale: true, regions_as_of: { map_rev: "7" } })).toEqual({}); + }); + + test("anything but map_stale === true is not stale", () => { + for (const v of [{}, { map_stale: false }, { map_stale: "true" }, { map_stale: 1 }, null, undefined, [], "x", 3]) { + expect(mapStaleFromJson(v)).toBeNull(); + } + }); +}); + +describe("splitLlmMapStale", () => { + test("takes the record out and reads the map it names", () => { + const { stale, body } = splitLlmMapStale(`${RECORD}\nmap files=42 regions=3\nregion id=a label=X`); + expect(stale).toEqual({ regionsAsOf: { map_rev: 7, file_count: 42 } }); + expect(body).toBe("map files=42 regions=3\nregion id=a label=X"); + }); + + test("a message without the map is still stale", () => { + const { stale, body } = splitLlmMapStale('diagnostic code=map_stale message="Stale map."\nmap files=1'); + expect(stale).toEqual({}); + expect(body).toBe("map files=1"); + }); + + test("text without the record comes back unchanged", () => { + const text = "map files=1 regions=1\ndiagnostic code=other message=x"; + expect(splitLlmMapStale(text)).toEqual({ stale: null, body: text }); + }); + + test("the record is not an Ix error", () => { + expect(parseIxError(`${RECORD}\nmap files=1`)).toBeNull(); + }); +}); + +describe("mapStaleLine", () => { + test("names the map when known", () => { + expect(mapStaleLine({ regionsAsOf: { map_rev: 7, file_count: 42 } })).toBe( + "_Map is stale (regions as of map rev 7, 42 files); recent edits may not be reflected — a background refresh is running._", + ); + expect(mapStaleLine({})).toBe( + "_Map is stale; recent edits may not be reflected — a background refresh is running._", + ); + }); +}); diff --git a/tests/success-fixtures.test.ts b/tests/success-fixtures.test.ts index 82be54b..4b526e4 100644 --- a/tests/success-fixtures.test.ts +++ b/tests/success-fixtures.test.ts @@ -582,6 +582,110 @@ describe("ix-smells on real output", () => { }); }); +// ─── stale architecture map (Ix#880) ───────────────────────────────────────── + +/** + * Since Ix#880 a region reader answering from a deferred map says so: JSON + * `map_stale: true` (+ `regions_as_of`), llm one `diagnostic code=map_stale` + * record. The tools turn that into one line under the heading; without it + * (fresh map, or a CLI before Ix#880) their output is exactly as before. + * `synthetic/*-stale*` are the success captures with those keys/that record + * added, as Ix's `mapStaleJson` / `mapStaleLlm` add them. + */ +describe("a stale architecture map is said once, under the heading", () => { + const STALE = "_Map is stale (regions as of map rev 1, 38 files); recent edits may not be reflected — a background refresh is running._"; + const STALE_NO_ASOF = "_Map is stale; recent edits may not be reflected — a background refresh is running._"; + const withoutLine = (output: string, line: string) => output.replace(`${line}\n\n`, ""); + + for (const llm of [false, true]) { + const path_ = llm ? "llm" : "JSON"; + + test(`ix-subsystems (${path_}): stale map, with regions_as_of`, async () => { + const fresh = await runTool("ix-subsystems.ts", {}, [["'subsystems '*", "success/subsystems"]], { llm }); + const stale = await runTool("ix-subsystems.ts", {}, [["'subsystems '*", "synthetic/subsystems-stale"]], { llm }); + expect(stale.output.startsWith(`## ix-subsystems\n\n${STALE}\n\n`)).toBe(true); + expect(stale.output.split(STALE).length).toBe(2); + // The raw record is not passed through as well. + expect(stale.output).not.toContain("diagnostic code=map_stale"); + expect(stale.output).not.toContain("Stale map:"); + expect(withoutLine(stale.output, STALE)).toBe(fresh.output); + expectNoMissedFields(stale.output); + }); + + test(`ix-subsystems (${path_}): stale map, no regions_as_of`, async () => { + const fresh = await runTool("ix-subsystems.ts", {}, [["'subsystems '*", "success/subsystems"]], { llm }); + const stale = await runTool("ix-subsystems.ts", {}, [["'subsystems '*", "synthetic/subsystems-stale-no-asof"]], { llm }); + expect(stale.output.startsWith(`## ix-subsystems\n\n${STALE_NO_ASOF}\n\n`)).toBe(true); + expect(stale.output).not.toContain("diagnostic code=map_stale"); + expect(withoutLine(stale.output, STALE_NO_ASOF)).toBe(fresh.output); + }); + + test(`ix-subsystems (${path_}): fresh map, no stale line`, async () => { + const { output } = await runTool("ix-subsystems.ts", {}, [["'subsystems '*", "success/subsystems"]], { llm }); + expect(output).not.toContain("Map is stale"); + expect(output.startsWith(llm ? "## ix-subsystems\n\nmap files=38" : "## ix-subsystems\n\n**11 subsystems**")).toBe(true); + }); + } + + // ix-map runs only `--format json` (no llm path), so the llm case is that + // turning the llm fast path on changes nothing. + for (const llm of [false, true]) { + const label = llm ? " with the llm fast path allowed" : ""; + + test(`ix-map${label}: stale map, with regions_as_of`, async () => { + const fresh = await runTool("ix-map.ts", {}, MAP_ROUTES, { llm }); + const stale = await runTool("ix-map.ts", {}, [ + ["'subsystems --list '*", "synthetic/subsystems-list-stale"], + ["'subsystems --format '*", "synthetic/subsystems-stale"], + ["'stats '*", "success/stats"], + ], { llm }); + expect(stale.output.startsWith(`## ix-map\n\n${STALE}\n\n**Codebase:**`)).toBe(true); + // Both region reads said it; the tool says it once. + expect(stale.output.split(STALE).length).toBe(2); + expect(withoutLine(stale.output, STALE)).toBe(fresh.output); + expectNoMissedFields(stale.output); + }); + + test(`ix-map${label}: fresh map, no stale line`, async () => { + const { output } = await runTool("ix-map.ts", {}, MAP_ROUTES, { llm }); + expect(output).not.toContain("Map is stale"); + expect(output.startsWith("## ix-map\n\n**Codebase:**")).toBe(true); + }); + } + + test("ix-map: prefers the read that names the map", async () => { + const { output } = await runTool("ix-map.ts", {}, [ + ["'subsystems --list '*", "synthetic/subsystems-list-stale"], + ["'subsystems --format '*", "synthetic/subsystems-stale-no-asof"], + ["'stats '*", "success/stats"], + ]); + expect(output.startsWith(`## ix-map\n\n${STALE}\n\n`)).toBe(true); + expect(output).not.toContain(STALE_NO_ASOF); + }); + + test("ix-map: only the health list says it", async () => { + const fresh = await runTool("ix-map.ts", {}, MAP_ROUTES); + const { output } = await runTool("ix-map.ts", {}, [ + ["'subsystems --list '*", "synthetic/subsystems-list-stale"], + ["'subsystems --format '*", "success/subsystems"], + ["'stats '*", "success/stats"], + ]); + expect(withoutLine(output, STALE)).toBe(fresh.output); + expect(output).toContain(STALE); + }); + + test("ix-map with a scope, stats off: the line goes under the heading", async () => { + const routes: Route[] = [ + ["'subsystems --list '*", "success/subsystems-list"], + ["'subsystems Runtime '*", "synthetic/subsystems-scope-stale"], + ]; + const fresh = await runTool("ix-map.ts", { scope: "Runtime", include_stats: false }, MAP_ROUTES); + const stale = await runTool("ix-map.ts", { scope: "Runtime", include_stats: false }, routes); + expect(stale.output.startsWith(`## ix-map: Runtime\n\n${STALE}\n\n**Subsystem Runtime**`)).toBe(true); + expect(withoutLine(stale.output, STALE)).toBe(fresh.output); + }); +}); + // ─── cwd ───────────────────────────────────────────────────────────────────── describe("tools run ix in the session directory", () => { diff --git a/tools/ix-map.ts b/tools/ix-map.ts index fdc595c..5979341 100644 --- a/tools/ix-map.ts +++ b/tools/ix-map.ts @@ -10,6 +10,7 @@ import { safeRun } from "../runtime/cli.ts"; import { ixErrorLines, parseIxError } from "../runtime/ix-error.ts"; import { toolCwd } from "../runtime/paths.ts"; +import { mapStaleFromJson, mapStaleLine, type MapStale } from "../runtime/map-stale.ts"; export const name = "ix-map"; export const description = @@ -49,28 +50,31 @@ export async function execute( const dir = toolCwd(context); const includeStats = params.include_stats !== false; - const fetches: Promise[] = [ + const [subsystems, subsystemList, stats] = await Promise.all([ fetchSubsystems(dir, params.scope), fetchSubsystemList(dir), - ]; - if (includeStats) fetches.push(fetchStats(dir)); - - const [subsystems, subsystemList, stats] = await Promise.all( - fetches.length === 3 - ? fetches - : [...fetches, Promise.resolve("")] - ); + includeStats ? fetchStats(dir) : Promise.resolve(""), + ]); const sections = [`## ix-map${params.scope ? `: ${params.scope}` : ""}`, ""]; + // Both `ix subsystems` calls read the map's regions; either may say it is + // stale (Ix#880). One line under the heading, preferring the one that names + // the map the regions are from. Absent from both: output as before. + const stale = [subsystems.stale, subsystemList.stale].find((s) => s?.regionsAsOf) ?? subsystems.stale ?? subsystemList.stale; + if (stale) sections.push(mapStaleLine(stale), ""); + if (stats) sections.push(stats, ""); - sections.push(subsystems); - if (subsystemList) sections.push("", subsystemList); + sections.push(subsystems.text); + if (subsystemList.text) sections.push("", subsystemList.text); return sections.join("\n"); } -async function fetchSubsystems(dir: string, scope?: string): Promise { +/** A section's text, and whether the ix output it came from said the map is stale. */ +type Section = { text: string; stale?: MapStale | null }; + +async function fetchSubsystems(dir: string, scope?: string): Promise
{ try { const args = scope ? ["subsystems", scope, "--format", "json"] @@ -82,17 +86,18 @@ async function fetchSubsystems(dir: string, scope?: string): Promise { if (output === null) throw new Error("ix subsystems produced no output"); // An error record has no regions; reading it as a result said "none found". const ixErr = parseIxError(output); - if (ixErr) return ["**Subsystems:**", ...ixErrorLines(ixErr)].join("\n"); + if (ixErr) return { text: ["**Subsystems:**", ...ixErrorLines(ixErr)].join("\n") }; const parsed = JSON.parse(output) as SubsystemsRecord; + const stale = mapStaleFromJson(parsed); // `ix subsystems ` answers with that region (`target`) and its // `children`, not with a `regions` list. - if (parsed.target) return formatScoped(parsed); + if (parsed.target) return { text: formatScoped(parsed), stale }; const systems = parsed.regions ?? []; if (systems.length === 0) { - return "**Subsystems:** none found. Run `ix map` to build the graph."; + return { text: "**Subsystems:** none found. Run `ix map` to build the graph.", stale }; } const lines = [ @@ -114,26 +119,27 @@ async function fetchSubsystems(dir: string, scope?: string): Promise { ); } - return lines.join("\n"); + return { text: lines.join("\n"), stale }; } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - return `**Subsystems:** unavailable — ${msg}`; + return { text: `**Subsystems:** unavailable — ${msg}` }; } } -async function fetchSubsystemList(dir: string): Promise { +async function fetchSubsystemList(dir: string): Promise
{ try { const output = await safeRun(["subsystems", "--list", "--format", "json"], dir); if (output === null) throw new Error("no output"); - if (parseIxError(output)) return ""; + if (parseIxError(output)) return { text: "" }; // `{"scores":[{"name","level","health","files",...}]}`, healthiest first. const parsed = JSON.parse(output) as { scores?: { name?: string; health?: number }[] }; + const stale = mapStaleFromJson(parsed); const scores = (parsed.scores ?? []).filter((s) => typeof s.name === "string"); - if (scores.length === 0) return ""; + if (scores.length === 0) return { text: "", stale }; const named = scores.map((s) => (s.health !== undefined ? `${s.name} (${s.health.toFixed(2)})` : s.name)); - return `**Subsystem health:** ${named.join(", ")}`; + return { text: `**Subsystem health:** ${named.join(", ")}`, stale }; } catch { - return ""; + return { text: "" }; } } diff --git a/tools/ix-subsystems.ts b/tools/ix-subsystems.ts index f2f478b..7953154 100644 --- a/tools/ix-subsystems.ts +++ b/tools/ix-subsystems.ts @@ -12,6 +12,7 @@ import { runIx, failureDetail } from "../runtime/cli.ts"; import { tryLlm } from "../runtime/llm.ts"; import { formatIxError, parseIxError } from "../runtime/ix-error.ts"; import { toolCwd } from "../runtime/paths.ts"; +import { mapStaleFromJson, mapStaleLine, splitLlmMapStale, type MapStale } from "../runtime/map-stale.ts"; export const name = "ix-subsystems"; export const description = @@ -30,7 +31,12 @@ export async function execute(_params: Params, context: Context): Promise