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