Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo-root>`
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/
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion TOOL_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
```
Expand Down Expand Up @@ -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.

---

Expand Down
88 changes: 88 additions & 0 deletions runtime/map-stale.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
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._`;
}
8 changes: 8 additions & 0 deletions tests/fixtures/ix-v0.12.0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
@@ -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}}
Original file line number Diff line number Diff line change
@@ -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}
13 changes: 13 additions & 0 deletions tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale-no-asof.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.json
Original file line number Diff line number Diff line change
@@ -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}}
13 changes: 13 additions & 0 deletions tests/fixtures/ix-v0.12.0/synthetic/subsystems-stale.txt
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions tests/map-stale.test.ts
Original file line number Diff line number Diff line change
@@ -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._",
);
});
});
Loading
Loading