diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 5ef408b..e89b685 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "source": "./" } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5ef408b..e89b685 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "source": "./" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6f079cc..6510a4d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "homepage": "https://github.com/programmersio-ibmi/skills", "license": "MIT", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 02f7b3d..9da910c 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis for coding agents.", "homepage": "https://github.com/programmersio-ibmi/skills", "license": "MIT", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index be5a415..48e5451 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ia", "displayName": "iA — IBM i Impact Analysis", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "publisher": "programmersio", "homepage": "https://github.com/programmersio-ibmi/skills", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 5ef408b..e89b685 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "source": "./" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5677a1b..c232199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] — 2026-06-27 + +### Added + +- **3D application map generation** (`references/app-map.md`): produce an interactive 3D map of a whole library or application area — menus, programs, data files, display/printer files, and external calls as nodes, with read/write/call/submit relationships as links. Authored as a single JSON data file (the source of truth) and built into a self-contained HTML viewer by `scripts/build_app_map.py` from `templates/app-map-template.html`. Ships with a node-budget recipe, a closed node/link vocabulary the builder validates, and a guided-tour walkthrough. + +### Changed + +- `SKILL.md` references table and `references/index.md` routing updated to surface the new app-map capability ("app map of library/area X", "3D map"). + ## [1.1.0] — 2026-06-11 ### Added diff --git a/gemini-extension.json b/gemini-extension.json index ae4b9ff..5032408 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "contextFileName": "GEMINI.md" } diff --git a/package.json b/package.json index cd8c42e..d932fdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ia-tools-skill", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "license": "MIT", "type": "module", diff --git a/plugin.json b/plugin.json index 10610b0..3c15476 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.1.0", + "version": "1.2.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "publisher": "programmersio", "homepage": "https://github.com/programmersio-ibmi/skills", diff --git a/skills/ia/SKILL.md b/skills/ia/SKILL.md index c55fc23..f2c96f1 100644 --- a/skills/ia/SKILL.md +++ b/skills/ia/SKILL.md @@ -143,3 +143,4 @@ Do not attempt to diagnose server-side issues or retry indefinitely. | Program documentation | [program-documentation.md](references/program-documentation.md) | | Test case document for a program (QA/UAT scripts) | [test-case-generation.md](references/test-case-generation.md) | | Visual flowchart of a program (single-page HTML) | [flowchart.md](references/flowchart.md) | +| 3D app map of a library or application area (interactive HTML) | [app-map.md](references/app-map.md) | diff --git a/skills/ia/references/app-map.md b/skills/ia/references/app-map.md new file mode 100644 index 0000000..204a020 --- /dev/null +++ b/skills/ia/references/app-map.md @@ -0,0 +1,143 @@ +# 3D Application Map Generation + +Use this when a user asks for an **application map** of a library or application area ("app map of CASELIB", "3D map of the order area", "bird's-eye view of the application", "show me the whole application"). The deliverable is **two files**: + +``` +docs/app-maps/{SCOPE}/{SCOPE}_AppMap_Data.json ← you write this (single source of truth) +docs/app-maps/{SCOPE}/{SCOPE}_AppMap.html ← built BY SCRIPT from the JSON +``` + +`{SCOPE}` = the library name (`CASELIB`) or `{LIBRARY}_{AREA}` (`CASELIB_ORDERS`). + +> **You only ever author the JSON.** The HTML is produced by `python scripts/build_app_map.py ` (path relative to this skill's folder), which validates the JSON and injects it into `templates/app-map-template.html`. **Never write or edit the HTML by hand, never touch the template.** The node/link vocabularies below are closed — never invent a new kind. + +--- + +## 1. Resolve the scope + +- **Whole library** → `ia_object_list(library=L)` is the inventory. +- **Application area** → `ia_application_area(area_name=A)` lists the area's objects. If the user's area name doesn't match, run `ia_application_area(area_name='*LIST')` and ask the user to pick — never substitute (Rule Two). +- Record `repository` from `ia_repo_config` (repository/library configuration) for the meta block. + +## 2. Inventory and budget — target ≤ 75 in-scope nodes + +Run once: `ia_code_complexity(library=L, limit=5000)` — this gives every member's total/executable lines and IF/SQL/subroutine/procedure counts. You'll use it for ranking AND for node stats. + +**Fill the map in this order and STOP adding when you reach 75 in-scope nodes:** + +1. Every `*MENU` (if more than 5, keep the 5 that launch the most programs). +2. Every program a kept menu launches — `ia_call_hierarchy(program_name=MENU, direction='CALLEES')`. +3. Remaining `*PGM`s ranked by **executable lines (descending)** from the complexity call, until programs total ~40. +4. Data physical files used by ≥2 kept programs, then by 1 (most-shared first) — from step 3 of the link rules below. **Data PFs only** (`object_attribute='PF-DATA'`); never map source physical files (QRPGLESRC, QCLSRC, …). +5. Display files (DSPF) and printer files (PRTF) used by kept programs. +6. Up to **5** uncompiled source members ≥ 500 lines (`ia_uncompiled_sources`, keep only rows in the scoped library) — these are the modernization-candidate "floating giants". +7. Up to **10** EXT nodes (out-of-scope programs called by kept programs — see link rules). + +`meta.scope.totalObjects` = the full inventory count before trimming; `meta.scope.mappedObjects` = **exactly the number of non-EXT nodes in your file** (the builder fails on any mismatch). The viewer shows "X of Y objects mapped" automatically when they differ. + +## 3. Nodes — classification is mechanical + +| Object | `kind` | `attr` | +|--------|--------|--------| +| `*MENU` | `MENU` | MNUDDS | +| `*PGM` attribute RPGLE / SQLRPGLE / RPG / SQLRPG | `PGM_RPG` | the attribute | +| `*PGM` attribute CLLE / CLP / CL | `PGM_CL` | the attribute | +| `*FILE` attribute DSPF | `DSPF` | DSPF | +| `*FILE` data PF | `PF` | PF | +| `*FILE` attribute PRTF | `PRTF` | PRTF | +| Source member with no compiled object | `SOURCE` | member type | +| Called program outside the scope | `EXT` | *PGM if known | + +Each node: `{ "id", "kind", "attr", "lines", "sourceFile", "desc", "stats" }`. + +- **One node per name.** A `*MENU` usually has a same-named `*FILE` (DSPF) and `*MSGF` — map **only the MENU node**, skip the same-named siblings (duplicate ids fail the build). Likewise skip `*MODULE` rows when a `*PGM` of the same name exists. +- `attr` = the **member type** from the complexity call when present (catches SQLRPGLE — the compiled object often just says RPGLE), else the object attribute. +- `id` = the UPPERCASE object/member name. `lines` = total source lines (integer or `null` if unknown). `sourceFile` = source physical file name (QRPGLESRC, QDDSSRC, …). +- `stats` (programs and SOURCE only, from the complexity call): `{ "execLines": N, "subroutines": N, "procedures": N, "sql": N }` — include only non-zero values. If a node has no stats, **omit the key** (never write `"stats": {}`). +- **SOURCE means "no compiled object exists".** A member only becomes a SOURCE node if it appeared in `ia_uncompiled_sources`. If a name is in the `*PGM` inventory, it is a program — even when its source looks legacy. Never decide this from the source's size or style. +- `desc` — **one plain-language sentence, ≤ 160 chars**, business role first. Patterns: program → "*Customer maintenance — display, add and F4-lookup of customers.*"; PF → "*Customer master file.*"; DSPF → "*Customer maintenance screen.*"; PRTF → "*Order report layout (spool file).*"; SOURCE → "*Legacy source member — N lines, never compiled into any object.*"; EXT → "*External program — called from this application, outside the map's scope.*". For programs, derive the role from `ia_program_summary` and the object's text description — do not guess. + +## 4. Links — one rule per kind + +| `kind` | Direction | Source of truth | +|--------|-----------|-----------------| +| `MENU` | menu → program | `ia_call_hierarchy(MENU, 'CALLEES')` | +| `CALL` | caller → callee | `ia_call_hierarchy(PGM, 'CALLEES')` for each kept program | +| `SBMJOB` | CL → submitted pgm (self-loop allowed) | `ia_cl_jobs(member_name='*ALL')` once, keep rows whose CL member is in scope; carry `"job"` and `"jobQueue"` | +| `INPUT` | **file → program** | `ia_find_object_usages(object_name=PF)` once per kept PF — rows with `USING_TYPE='*PGM'` whose usage is only `I` | +| `UPDATE` | **program → file** | same call — rows whose usage contains `U` or `O` (e.g. `U`, `I/O/U`) | +| `DISPLAY` | program → DSPF | `ia_program_files(member_name=PGM)` rows whose file is a DSPF | +| `PRINT` | program → PRTF | `ia_program_files` rows whose file is a PRTF | + +Each link: `{ "source", "target", "kind", "label" }` — keep labels short ("reads", "reads + writes", "menu option", "CALL in batch"). + +- **PF links come from `ia_find_object_usages`, not `ia_program_files`** — the file map has no read/write flag and misses tables reached only through embedded SQL (`REFERENCE_SOURCE = 'S'` rows). Per (program, PF) pair: any row with `U` or `O` in `REFERENCE_USAGE` → one UPDATE link; otherwise → one INPUT link. Ignore rows whose `USING_TYPE` is not `*PGM` (skips `*MODULE` duplicates and DSPF field references). +- **Note the INPUT direction:** data flows *from* the file *into* the program, so the file is the link `source`. UPDATE flows program → file. +- **One SBMJOB beats a self-CALL.** A CL that re-submits itself shows up both in `ia_cl_jobs` (SBMJOB) and as a self-reference in `ia_call_hierarchy` — emit only the SBMJOB self-link (the validator rejects a CALL self-link anyway). +- **Logical files are never nodes.** When a usage row's file is an LF, re-point the link to its based-on physical file: call `ia_file_dependencies(file_name=PF)` once per kept PF and collect its dependent LFs into a lookup; an LF whose parent PF isn't mapped → drop the link. After substitution, de-duplicate identical (source, target, kind) links. +- **EXT rule:** a callee that is not in the scope inventory becomes an EXT node and the link must be `CALL` into it. EXT nodes are never link sources and never get file/screen links. +- **SOURCE nodes get no links** — an uncompiled member has no compiled object, so iA records no relationships for it; the floating, disconnected node IS the message. The builder rejects any link touching a SOURCE node. +- A CL program that re-submits itself produces a SBMJOB **self-loop** — that's correct and renders as a loop; the validator allows self-links for SBMJOB only. Take the `job`/`jobQueue` values verbatim from the `ia_cl_jobs` row — the job name is usually NOT the CL's own name. + +## 5. Guided tour — 5–7 steps, fixed storyline + +Write `meta.tour` as an array of `{ "title", "text", "focus" }` (focus = a node id; omit it for a wrap-up step). Follow this storyline, **skipping any step whose subject doesn't exist**: + +1. **The front door** — the menu (or most-called program if no menu). +2. **The workhorse** — the `PGM_RPG`/`PGM_CL` node with the highest `stats.execLines` **in your own nodes array**. A SOURCE node is never the workhorse — it doesn't run. +3. **The batch pattern** — the CL driver, if any SBMJOB link exists. +4. **Where the report lands** — the PRTF at the end of that batch chain. +5. **Shared data = shared risk** — the PF with the most program links **in your own links array**. +6. **The modernization candidate** — a SOURCE node, if any ("compiled into nothing"). +7. **Wrap-up** (no focus) — restate the exact `meta.scope` numbers and that iA can drill into any node. + +Titles ≤ 5 words; text 1–3 short sentences. **Every claim must be checkable against your own JSON** — names, counts and line numbers come from the nodes/links you wrote, nothing else. Never describe scheduling or frequency ("nightly", "overnight", "daily") — a SBMJOB is user-triggered unless a job-scheduler entry proves otherwise. + +## 6. The meta block + +```json +"meta": { + "title": "CASELIB Application Map", + "library": "CASELIB", + "area": "ORDERS", ← only for area-scoped maps; omit otherwise + "repository": "{REPOSITORY}", + "generated": "2026-06-12", + "author": "iA by programmers.io", + "scope": { "totalObjects": 30, "mappedObjects": 26 }, + "tour": [ { "title": "…", "text": "…", "focus": "CASEMNU" } ] +} +``` + +## 7. Build — the only way to produce the HTML + +``` +python scripts/build_app_map.py docs/app-maps/{SCOPE}/{SCOPE}_AppMap_Data.json +``` + +Exit 0 → `{SCOPE}_AppMap.html` is written next to the JSON. Exit 1 → read each `ERROR:` line, fix the **JSON**, re-run. Never work around an error by editing HTML. Warnings (`WARN:`) don't block but read them — they catch over-budget maps and orphan nodes. + +## 8. Browser gate — open the HTML and confirm + +- [ ] Graph renders (not a black screen); needs internet for the CDN modules. +- [ ] Header shows the right library/scope and the stats chips match the JSON counts. +- [ ] Every node kind present appears in the filter list; toggling a filter hides those nodes. +- [ ] Search for one program by name — it flies to the node and opens the info panel with desc + connections. +- [ ] If SBMJOB links exist, the red batch loop is visible. +- [ ] EXT nodes (grey wireframe pyramids) sit at the edge with only incoming CALL links. +- [ ] Guided tour steps through and focuses the right nodes. + +## Common errors + +| Symptom | Fix | +|---------|-----| +| Builder: `kind 'X' is not one of …` | You invented a kind. Map the object using the §3 table — or leave it out. | +| Builder: `INPUT must be file → program …` (or similar direction error) | The link is inverted — swap `source` and `target`. INPUT is the only file→program kind; everything else flows out of the program. | +| Builder: `SOURCE … nodes are isolated by design` | You linked an uncompiled member, or misclassified a program as SOURCE / a SOURCE as a program. Re-check `ia_uncompiled_sources`. | +| Builder: `mappedObjects is N but the file has M` | Count your non-EXT nodes and set `meta.scope.mappedObjects` to that number. | +| Builder: `source/target … is not a node id` | A link references an evicted or misspelled node. Drop the link or add the node. | +| Builder warns `zero CALL links` | You almost certainly missed the program→driver→print chains — run `ia_call_hierarchy(CALLEES)` for every kept program. | +| Builder: `exceeds the hard cap` | Re-apply §2 fill order — evict lowest-ranked programs' files first, then programs. | +| Builder: `EXT node … cannot be a link source` | EXT is a CALL target only. If you know the external program's own links, it belongs in scope instead. | +| Map is a hairball in the browser | Too many nodes — trim toward 75; drop single-use PFs and PRTFs of minor programs. | +| A file shows no links | It was reached only through logical files whose parent PF you didn't map — apply the LF substitution rule in §4. | +| Tour step flies nowhere | Its `focus` id isn't a node — the builder catches this; re-run it. | diff --git a/skills/ia/references/index.md b/skills/ia/references/index.md index 4261e49..e1c6041 100644 --- a/skills/ia/references/index.md +++ b/skills/ia/references/index.md @@ -12,6 +12,7 @@ This index guides progressive loading of skill references. Load only what you ne | **Complex analysis** (field impact, call chains) | + [query-flows.md](query-flows.md) | Optimal tool sequences | | **Troubleshooting / edge cases** | + [playbook.md](playbook.md) | Analysis playbooks | | **Program documentation** | + [program-documentation.md](program-documentation.md) | 8-step workflow | +| **App map** ("app map of library/area", "3D map") | + [app-map.md](app-map.md) | JSON contract + build script | ## Reference Files @@ -22,6 +23,7 @@ This index guides progressive loading of skill references. Load only what you ne | [query-flows.md](query-flows.md) | Optimal tool chains | Complex multi-step analysis | | [playbook.md](playbook.md) | Playbooks + chaining rules | Edge cases, troubleshooting | | [program-documentation.md](program-documentation.md) | Spec generation workflow | "Document program X" | +| [app-map.md](app-map.md) | 3D application map workflow | "App map of library/area X" | | [templates/](templates/) | 4 audience-specific templates | Spec generation | ## Quick Decision diff --git a/skills/ia/scripts/build_app_map.py b/skills/ia/scripts/build_app_map.py new file mode 100644 index 0000000..dcf7038 --- /dev/null +++ b/skills/ia/scripts/build_app_map.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Validate an iA app-map data JSON and build the 3D viewer HTML from the bundled template. + +Usage: + python build_app_map.py [--template ] + +The JSON is the single source of truth. This script: + 1. Validates it (schema, closed kind enums, link integrity, node budget, tour refs). + 2. Injects it into templates/app-map-template.html (single __APP_MAP_DATA__ token). + 3. Writes {SCOPE}_AppMap.html next to the JSON. + +Exit 0 = built. Exit 1 = validation errors (HTML not written). Warnings never block. +""" +import argparse +import json +import re +import sys +from pathlib import Path + +# Windows pipes default to cp1252-strict; arrows/dashes in messages must not crash the run. +for _stream in (sys.stdout, sys.stderr): + if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") + +NODE_KINDS = {"MENU", "PGM_RPG", "PGM_CL", "DSPF", "PF", "PRTF", "SOURCE", "EXT"} +LINK_KINDS = {"MENU", "CALL", "SBMJOB", "INPUT", "UPDATE", "DISPLAY", "PRINT"} + +# Allowed (source-kind, target-kind) per link kind. Catches inverted directions +# (e.g. program -> PF INPUT), fabricated menu targets, and links on SOURCE nodes. +PGM = {"PGM_RPG", "PGM_CL"} +LINK_RULES = { + "MENU": (({"MENU"}), PGM | {"EXT"}, "menu → program"), + "CALL": ((PGM), PGM | {"EXT"}, "program → program (EXT allowed as target)"), + "SBMJOB": ((PGM), PGM, "CL/RPG → submitted program (self-loop allowed)"), + "INPUT": (({"PF"}), PGM, "file → program (data flows INTO the program)"), + "UPDATE": ((PGM), {"PF"}, "program → file"), + "DISPLAY": ((PGM), {"DSPF"}, "program → display file"), + "PRINT": ((PGM), {"PRTF"}, "program → printer file"), +} +META_REQUIRED = ["title", "library", "repository", "generated", "author"] +ID_RE = re.compile(r"^[A-Z0-9_#$@.]+$") +BUDGET_WARN = 75 # recipe target +BUDGET_FAIL = 90 # hard cap +EXT_WARN = 15 +DESC_WARN = 160 +DESC_FAIL = 200 + +errors, warnings = [], [] +err = errors.append +warn = warnings.append + + +def validate(data: dict) -> None: + if not isinstance(data, dict): + err("Top level must be a JSON object with 'meta', 'nodes', 'links'.") + return + + # ---- meta ---- + meta = data.get("meta") + if not isinstance(meta, dict): + err("Missing 'meta' object.") + meta = {} + for f in META_REQUIRED: + if not meta.get(f): + err(f"meta.{f} is required and must be non-empty.") + scope = meta.get("scope") + if not isinstance(scope, dict): + err("meta.scope is required: {\"totalObjects\": N, \"mappedObjects\": N}.") + else: + tot, mapped = scope.get("totalObjects"), scope.get("mappedObjects") + if not isinstance(tot, int) or not isinstance(mapped, int): + err("meta.scope.totalObjects and meta.scope.mappedObjects must be integers.") + elif mapped > tot: + err(f"meta.scope.mappedObjects ({mapped}) cannot exceed totalObjects ({tot}).") + + # ---- nodes ---- + nodes = data.get("nodes") + if not isinstance(nodes, list) or not nodes: + err("'nodes' must be a non-empty array.") + nodes = [] + ids = set() + empty_stats = [] + for i, n in enumerate(nodes): + where = f"nodes[{i}]" + (f" ({n.get('id')})" if isinstance(n, dict) and n.get("id") else "") + if not isinstance(n, dict): + err(f"{where}: each node must be an object.") + continue + nid = n.get("id") + if not nid: + err(f"{where}: 'id' is required.") + continue + if nid in ids: + err(f"{where}: duplicate node id '{nid}'.") + ids.add(nid) + if not ID_RE.match(nid): + err(f"{where}: id '{nid}' must be UPPERCASE alphanumeric (A-Z 0-9 _ # $ @ .).") + kind = n.get("kind") + if kind not in NODE_KINDS: + err(f"{where}: kind '{kind}' is not one of {sorted(NODE_KINDS)}.") + desc = n.get("desc", "") + if not desc or not str(desc).strip(): + err(f"{where}: 'desc' is required — one plain-language sentence.") + elif len(desc) > DESC_FAIL: + err(f"{where}: desc is {len(desc)} chars (max {DESC_FAIL}). Shorten it.") + elif len(desc) > DESC_WARN: + warn(f"{where}: desc is {len(desc)} chars (target ≤{DESC_WARN}).") + lines = n.get("lines") + if lines is not None and not isinstance(lines, int): + err(f"{where}: 'lines' must be an integer or null.") + if kind == "MENU" and n.get("attr") == "DSPF": + warn(f"{where}: kind MENU with attr DSPF — did you pick the menu's same-named display file? " + f"A *MENU's attr is usually MNUDDS.") + if n.get("stats") == {}: + empty_stats.append(nid) + + if empty_stats: + warn(f"{len(empty_stats)} node(s) have empty stats {{}} ({', '.join(empty_stats[:5])}" + f"{', …' if len(empty_stats) > 5 else ''}) — omit the 'stats' key instead.") + + # ---- budget ---- + in_scope = sum(1 for n in nodes if isinstance(n, dict) and n.get("kind") != "EXT") + ext_count = sum(1 for n in nodes if isinstance(n, dict) and n.get("kind") == "EXT") + if isinstance(scope, dict) and isinstance(scope.get("mappedObjects"), int) \ + and scope["mappedObjects"] != in_scope: + err(f"meta.scope.mappedObjects is {scope['mappedObjects']} but the file has {in_scope} " + f"in-scope (non-EXT) nodes — set mappedObjects to the actual node count.") + if in_scope > BUDGET_FAIL: + err(f"{in_scope} in-scope nodes exceeds the hard cap of {BUDGET_FAIL}. " + f"Apply the eviction rules in app-map.md (rank by reference count) or map a smaller area.") + elif in_scope > BUDGET_WARN: + warn(f"{in_scope} in-scope nodes exceeds the target of {BUDGET_WARN} — consider trimming.") + if ext_count > EXT_WARN: + warn(f"{ext_count} EXT nodes — boundary noise; consider keeping only the most-called externals.") + + # ---- links ---- + links = data.get("links") + if not isinstance(links, list): + err("'links' must be an array.") + links = [] + ext_ids = {n.get("id") for n in nodes if isinstance(n, dict) and n.get("kind") == "EXT"} + kind_of = {n.get("id"): n.get("kind") for n in nodes if isinstance(n, dict)} + linked = set() # ids with at least one non-self link + seen_links = set() + call_count = 0 + for i, l in enumerate(links): + where = f"links[{i}]" + if not isinstance(l, dict): + err(f"{where}: each link must be an object.") + continue + s, t, kind = l.get("source"), l.get("target"), l.get("kind") + where = f"links[{i}] ({s} → {t})" + if s not in ids: + err(f"{where}: source '{s}' is not a node id.") + if t not in ids: + err(f"{where}: target '{t}' is not a node id.") + if kind not in LINK_KINDS: + err(f"{where}: kind '{kind}' is not one of {sorted(LINK_KINDS)}.") + elif s in ids and t in ids: + src_ok, tgt_ok, shape = LINK_RULES[kind] + sk, tk = kind_of.get(s), kind_of.get(t) + if sk == "SOURCE" or tk == "SOURCE": + err(f"{where}: SOURCE (uncompiled) nodes are isolated by design — they have no compiled " + f"object, so iA records no links for them. Remove the link or reclassify the node.") + elif sk not in src_ok or tk not in tgt_ok: + err(f"{where}: {kind} must be {shape}, but source is {sk} and target is {tk}. " + f"If the direction is inverted, swap source and target.") + if s == t and kind != "SBMJOB": + err(f"{where}: self-link only allowed for SBMJOB (CL re-submitting itself).") + if s in ext_ids: + err(f"{where}: EXT node '{s}' cannot be a link source — externals are CALL targets only.") + if t in ext_ids and kind != "CALL": + err(f"{where}: link into EXT node '{t}' must be kind CALL.") + if kind == "SBMJOB" and not l.get("job"): + warn(f"{where}: SBMJOB link without 'job' name.") + if kind == "CALL": + call_count += 1 + key = (s, t, kind) + if key in seen_links: + warn(f"{where}: duplicate link (same source, target, kind).") + seen_links.add(key) + if s != t: + linked.update([s, t]) + + for n in nodes: + if isinstance(n, dict) and n.get("id") in ids - linked and n.get("kind") != "SOURCE": + warn(f"node '{n['id']}' has no links to other nodes (self-loops don't count) — " + f"only SOURCE (uncompiled) nodes are expected to be isolated.") + n_programs = sum(1 for n in nodes if isinstance(n, dict) and n.get("kind") in PGM) + if call_count == 0 and n_programs >= 2: + warn(f"{n_programs} programs but zero CALL links — verify ia_call_hierarchy(direction='CALLEES') " + f"was run for every kept program (interactive→driver and driver→print chains are easy to miss).") + + # ---- tour ---- + tour = (meta or {}).get("tour") + if tour is not None: + if not isinstance(tour, list): + err("meta.tour must be an array of steps.") + else: + if not 3 <= len(tour) <= 9: + warn(f"meta.tour has {len(tour)} steps (recommended 5–7).") + for i, step in enumerate(tour): + if not isinstance(step, dict) or not step.get("title") or not step.get("text"): + err(f"meta.tour[{i}]: each step needs non-empty 'title' and 'text'.") + continue + focus = step.get("focus") + if focus and focus not in ids: + err(f"meta.tour[{i}]: focus '{focus}' is not a node id.") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("json_path", help="Path to {SCOPE}_AppMap_Data.json") + ap.add_argument("--template", default=None, help="Override template HTML path") + args = ap.parse_args() + + json_path = Path(args.json_path) + if not json_path.is_file(): + print(f"ERROR: {json_path} not found.") + return 1 + try: + data = json.loads(json_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + print(f"ERROR: invalid JSON — {e}") + return 1 + + validate(data) + + for w in warnings: + print(f"WARN: {w}") + if errors: + for e in errors: + print(f"ERROR: {e}") + print(f"\nFAILED — {len(errors)} error(s). Fix the JSON and re-run; the HTML was NOT built.") + return 1 + + template_path = Path(args.template) if args.template else \ + Path(__file__).resolve().parent.parent / "templates" / "app-map-template.html" + if not template_path.is_file(): + print(f"ERROR: template not found at {template_path}") + return 1 + template = template_path.read_text(encoding="utf-8") + if template.count("__APP_MAP_DATA__") != 1: + print("ERROR: template must contain the __APP_MAP_DATA__ token exactly once.") + return 1 + + # "") would terminate the inline + # + + + +
+ +
+

+
+
+
+ +
+ + +
+ + +
+
+ + +
+
+
+ +
+

CONNECTIONS

+
+
+ +
+
+

+
+
+
+
+
+ +
+

+

+ +
+
+ + + + + +