bc-atlas 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/capabilities.js +46 -0
- package/src/cli/help.js +1 -0
- package/src/cli.js +8 -0
- package/src/commands/graph.js +13 -0
- package/src/graph/history.js +13 -3
- package/src/graph/platform.js +217 -0
- package/src/mcp.js +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to BC Atlas will be documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
|
|
5
5
|
follows [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.15.0 - 2026-11-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Saved graphs are a reusable platform: analyze once, query repeatedly, and
|
|
12
|
+
render for a specific use case without re-analyzing AL.
|
|
13
|
+
- `bca graph query` and the MCP tool `bc_atlas_graph_query` select roots,
|
|
14
|
+
named root groups, traversal depth, dependency or caller direction,
|
|
15
|
+
relation kinds, graph levels, and temporal presence frames. Native Atlas
|
|
16
|
+
graphs and version 1 nodes/edges snapshots retain their original IDs,
|
|
17
|
+
evidence, unresolved boundaries, and metadata in reusable JSON slices.
|
|
18
|
+
- `bca graph render` and `bc_atlas_graph_render` write SVG, self-contained
|
|
19
|
+
static HTML, Graphviz DOT, or JSON. SVG and HTML use the existing local
|
|
20
|
+
renderer without requiring Graphviz; roots are highlighted and every
|
|
21
|
+
selected node and edge is retained.
|
|
22
|
+
- Graph validation rejects unsupported versions, invalid roots, and missing
|
|
23
|
+
endpoints, prevents overwriting the input, and rejects oversized visual
|
|
24
|
+
selections explicitly rather than truncating dependencies.
|
|
25
|
+
|
|
26
|
+
## 0.14.0 - 2026-11-05
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- A root `.mcp.json` starts BC Atlas from the local checkout for direct MCP
|
|
31
|
+
tool calls in Copilot-compatible hosts. The MCP integration test uses the
|
|
32
|
+
same configuration, and the agent guide explains setup and server approval.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Git-history analysis waits for its `git cat-file` reader to exit before
|
|
37
|
+
removing temporary snapshots, avoiding file-lock failures on Windows.
|
|
38
|
+
|
|
7
39
|
## 0.13.0 - 2026-11-04
|
|
8
40
|
|
|
9
41
|
### Added
|
package/README.md
CHANGED
|
@@ -41,10 +41,12 @@ In VS Code, install the **BC Atlas** extension and open an AL project:
|
|
|
41
41
|
| **Get started** | `bca setup` | Guided setup in the terminal or through your AI agent (Claude Code, Copilot, Cursor): a starter `.bca.json`, features, Husky or Git hooks, GitHub Actions or Azure Pipelines, cron schedule, and sync. |
|
|
42
42
|
| **Analyze** | `bca check` | Health findings - cycles, forbidden dependencies, architecture violations, hot spots, diagnostics, and AL compiler and CodeCop/UICop/AppSourceCop findings - with a CI exit code, also as SARIF for code scanning. |
|
|
43
43
|
| | `bca architecture` | Find a target architecture (components, layers), list every dependency that blocks it with how to resolve it, and enforce it with a shrinking baseline. [Guide](./docs/clean-architecture.md) |
|
|
44
|
+
| | `bca graph query` | Reuse a saved graph for impact analysis or message migrations without re-analyzing AL; keep IDs, directions, evidence, and metadata. [Guide](./docs/dependency-explorer.md#query-and-render-saved-graphs) |
|
|
44
45
|
| **Export** | `bca export` | Everything the extension shows, as files: the BC Atlas pages as one offline HTML file to share (default), the Markdown report ([example](./examples/report/README.md)), the 3D explorer, one Markdown page per object, any of ten diagram views as D2, SVG, PNG, PDF, or JSON, with `--watch`, and the project as a knowledge graph in RDF with an OWL ontology ([Turtle, RDF/XML, OWL, N-Triples, JSON-LD](./docs/knowledge-graph.md)). |
|
|
45
46
|
| | `bca docs` | User guides and test cases from AL UI tests (`[SCENARIO]`, `[GIVEN]`, `[WHEN]`, `[THEN]`) as Markdown, HTML, DITA 1.3, or Azure DevOps CSV, with real captions and translations, a flow diagram per scenario, and BPMN 2.0 processes per use case. [Example](./examples/docs/index.md) |
|
|
47
|
+
| | `bca graph render` | Render saved graph selections as SVG, static HTML, DOT, or JSON through the CLI or MCP, locally and without Graphviz. |
|
|
46
48
|
| **Integrate** | `bca run` | One entry point for Git hooks, schedules, and pipelines; verifies, stages, or publishes generated files. Also available as a [GitHub Action](./docs/automation-and-ci.md#github-actions). |
|
|
47
|
-
| | `bca update` | "BC Atlas 0.
|
|
49
|
+
| | `bca update` | "BC Atlas 0.15.0 is available": release notes, one-step update, and regenerated hooks, tasks, and pipelines. Dependabot pull requests on request. |
|
|
48
50
|
| | `bca mcp` | A Model Context Protocol server for AI agents; `bca help --format json` is the versioned command contract. |
|
|
49
51
|
|
|
50
52
|
Run `bca` for the same overview in your terminal and `bca help <command>` for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bc-atlas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "BC Atlas maps Microsoft Dynamics 365 Business Central AL projects: architecture diagrams, health checks, reports, and documentation generated from source.",
|
|
5
5
|
"homepage": "https://github.com/SchulzOli/BC-Atlas#readme",
|
|
6
6
|
"bugs": {
|
package/src/capabilities.js
CHANGED
|
@@ -140,6 +140,16 @@ const pathsOption = string("Only analyze AL files in this folder or matching thi
|
|
|
140
140
|
value: "path"
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
const graphOptions = {
|
|
144
|
+
root: string("Exact node ID, label, or Atlas key; repeat for multiple roots.", { group: FILTERS, repeatable: true, value: "selector" }),
|
|
145
|
+
rootGroup: string("Named root group in the saved graph, e.g. legacy.", { group: FILTERS, value: "name" }),
|
|
146
|
+
depth: string("Traversal depth; 0 selects only roots. Without roots the entire graph is returned.", { group: FILTERS, value: "n", default: "1" }),
|
|
147
|
+
direction: string("Follow dependencies, callers, or both; edge directions remain unchanged.", { group: FILTERS, enum: ["outgoing", "incoming", "both"], default: "outgoing" }),
|
|
148
|
+
kind: string("Traverse edges containing this relation kind; aggregated evidence stays intact.", { group: FILTERS, repeatable: true, value: "kind" }),
|
|
149
|
+
level: string("Temporal graph level: object, app, module, or component.", { group: FILTERS, value: "name", default: "object" }),
|
|
150
|
+
frame: string("Temporal presence frame index (default: latest); labels/evidence reflect the saved document.", { group: FILTERS, value: "n" })
|
|
151
|
+
};
|
|
152
|
+
|
|
143
153
|
const exportOptions = {
|
|
144
154
|
view: string("What to write: app, report, explorer, catalog, or a diagram view.", {
|
|
145
155
|
group: OUTPUT,
|
|
@@ -345,6 +355,42 @@ export function createCapabilities(version) {
|
|
|
345
355
|
"bca check ./app --format markdown -o health.md"
|
|
346
356
|
]
|
|
347
357
|
}),
|
|
358
|
+
command({
|
|
359
|
+
id: "graph.query",
|
|
360
|
+
area: "analyze",
|
|
361
|
+
argv: ["bca", "graph", "query", "<graph.json>"],
|
|
362
|
+
purpose: "Query a saved Atlas graph or nodes/edges snapshot without analyzing AL; retain IDs and evidence as reusable JSON.",
|
|
363
|
+
options: {
|
|
364
|
+
...graphOptions,
|
|
365
|
+
output: string("Write the reusable graph slice to this file; otherwise JSON goes to stdout.", { group: OUTPUT, value: "path", short: "o" })
|
|
366
|
+
},
|
|
367
|
+
output: { type: "json", schema: "bc-atlas.graph.slice" },
|
|
368
|
+
rules: ["Input must be a version 1 graph with valid nodes and edges; output never overwrites the input.", "Depth limits traversal, not an induced subgraph: only edges visited from expanded roots are retained."],
|
|
369
|
+
examples: [
|
|
370
|
+
"bca graph query graph.json --root-group legacy --depth 2 -o orders.json",
|
|
371
|
+
"bca graph query graph.json --root \"Sales-Post\" --direction incoming"
|
|
372
|
+
]
|
|
373
|
+
}),
|
|
374
|
+
command({
|
|
375
|
+
id: "graph.render",
|
|
376
|
+
area: "export",
|
|
377
|
+
argv: ["bca", "graph", "render", "<graph.json>"],
|
|
378
|
+
purpose: "Render a saved graph selection as local SVG/HTML, Graphviz DOT, or reusable JSON without analyzing AL.",
|
|
379
|
+
options: {
|
|
380
|
+
...graphOptions,
|
|
381
|
+
output: string("Output file; its extension selects the format when --format is omitted.", { group: OUTPUT, value: "path", short: "o", default: "bc-atlas-graph.svg" }),
|
|
382
|
+
format: string("Output format.", { group: OUTPUT, enum: ["json", "dot", "svg", "html"], short: "f" }),
|
|
383
|
+
title: string("Graph title.", { group: OUTPUT, value: "text" }),
|
|
384
|
+
maxNodes: string("SVG/HTML node limit; oversized selections fail explicitly instead of being truncated.", { group: RENDERING, value: "n", default: "1500" }),
|
|
385
|
+
maxEdges: string("SVG/HTML edge limit.", { group: RENDERING, value: "n", default: "5000" })
|
|
386
|
+
},
|
|
387
|
+
output: { type: "file", pathOption: "output" },
|
|
388
|
+
rules: ["SVG and HTML use the built-in local renderer; Graphviz is not required.", "Roots are blue, unresolved boundaries orange; every selected edge keeps its original direction."],
|
|
389
|
+
examples: [
|
|
390
|
+
"bca graph render graph.json --root-group legacy -o orders.svg",
|
|
391
|
+
"bca graph render orders.json -o orders.html"
|
|
392
|
+
]
|
|
393
|
+
}),
|
|
348
394
|
command({
|
|
349
395
|
id: "architecture",
|
|
350
396
|
area: "analyze",
|
package/src/cli/help.js
CHANGED
|
@@ -33,6 +33,7 @@ const SHORT = {
|
|
|
33
33
|
architecture: "Find a target architecture, resolve what stands in its way, and enforce it with a baseline",
|
|
34
34
|
refactor: "Move objects between components, lock a component's public surface (move, surface)",
|
|
35
35
|
export: "Write files: the pages as one offline HTML file, report, explorer, catalog, or one diagram",
|
|
36
|
+
graph: "Query and render saved graphs without analyzing AL (query, render)",
|
|
36
37
|
run: "Run the features configured for a Git hook, pipeline, or schedule",
|
|
37
38
|
update: "Update BC Atlas together with the generated setup files",
|
|
38
39
|
mcp: "Start the Model Context Protocol server for AI agents"
|
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { checkCommand } from "./commands/check.js";
|
|
|
9
9
|
import { architectureCommand } from "./commands/blueprint.js";
|
|
10
10
|
import { refactorMoveCommand, refactorSurfaceCommand } from "./commands/refactor.js";
|
|
11
11
|
import { exportCommand } from "./commands/export.js";
|
|
12
|
+
import { graphQueryCommand, graphRenderCommand } from "./commands/graph.js";
|
|
12
13
|
import { runCommand } from "./commands/run.js";
|
|
13
14
|
import { updateCommand } from "./commands/update.js";
|
|
14
15
|
import { maybeNotifyUpdate } from "./update.js";
|
|
@@ -33,6 +34,8 @@ const HANDLERS = {
|
|
|
33
34
|
"refactor.move": refactorMoveCommand,
|
|
34
35
|
"refactor.surface": refactorSurfaceCommand,
|
|
35
36
|
export: exportCommand,
|
|
37
|
+
"graph.query": graphQueryCommand,
|
|
38
|
+
"graph.render": graphRenderCommand,
|
|
36
39
|
run: runCommand,
|
|
37
40
|
update: updateCommand,
|
|
38
41
|
mcp: mcpCommand,
|
|
@@ -61,6 +64,11 @@ function resolveCommand(args) {
|
|
|
61
64
|
return { id, args: rest };
|
|
62
65
|
}
|
|
63
66
|
if (first === "setup" && findCommand(capabilities, `setup.${second}`)) return { id: `setup.${second}`, args: rest };
|
|
67
|
+
if (first === "graph") {
|
|
68
|
+
if (findCommand(capabilities, `graph.${second}`)) return { id: `graph.${second}`, args: rest };
|
|
69
|
+
if (!second || ["-h", "--help", "help"].includes(second)) return { id: "graph.query", args: ["--help"] };
|
|
70
|
+
throw new UsageError(`unknown graph command "${second}"; use "bca graph query" or "bca graph render"`);
|
|
71
|
+
}
|
|
64
72
|
if (first === "refactor") {
|
|
65
73
|
if (findCommand(capabilities, `refactor.${second}`)) return { id: `refactor.${second}`, args: rest };
|
|
66
74
|
if (["-h", "--help", "help"].includes(second)) return { id: "refactor.move", args: ["--help"] };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { renderGraph, writeGraphQuery } from "../graph/platform.js";
|
|
2
|
+
import { displayPath } from "../output.js";
|
|
3
|
+
|
|
4
|
+
export async function graphQueryCommand(input, values) {
|
|
5
|
+
const { graph, file } = await writeGraphQuery(input, values);
|
|
6
|
+
if (file) console.error(`Wrote ${displayPath(file)} (${graph.nodes.length} nodes, ${graph.edges.length} edges)`);
|
|
7
|
+
else process.stdout.write(`${JSON.stringify(graph, null, 2)}\n`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function graphRenderCommand(input, values) {
|
|
11
|
+
const result = await renderGraph(input, values);
|
|
12
|
+
console.log(`Wrote ${displayPath(result.output)} (${result.nodes} nodes, ${result.edges} edges)`);
|
|
13
|
+
}
|
package/src/graph/history.js
CHANGED
|
@@ -76,7 +76,14 @@ function blobReader(cwd) {
|
|
|
76
76
|
});
|
|
77
77
|
},
|
|
78
78
|
close() {
|
|
79
|
-
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
child.once("error", reject);
|
|
81
|
+
child.once("close", (code) => {
|
|
82
|
+
if (code === 0) resolve();
|
|
83
|
+
else reject(new Error(`git cat-file --batch exited with code ${code}`));
|
|
84
|
+
});
|
|
85
|
+
child.stdin.end();
|
|
86
|
+
});
|
|
80
87
|
}
|
|
81
88
|
};
|
|
82
89
|
}
|
|
@@ -273,8 +280,11 @@ export async function replayHistory(projectRoot, { by = "commits", maxCommits =
|
|
|
273
280
|
previous = commit;
|
|
274
281
|
}
|
|
275
282
|
} finally {
|
|
276
|
-
|
|
277
|
-
|
|
283
|
+
try {
|
|
284
|
+
await reader.close();
|
|
285
|
+
} finally {
|
|
286
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
287
|
+
}
|
|
278
288
|
}
|
|
279
289
|
|
|
280
290
|
const dirty = git(repository, ["status", "--porcelain", "-z", "--untracked-files=all"])
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MAX_DIAGRAM_NODES, renderScene } from "../diagram/index.js";
|
|
4
|
+
|
|
5
|
+
export const GRAPH_RENDER_FORMATS = Object.freeze(["json", "dot", "svg", "html"]);
|
|
6
|
+
const SLICE_SCHEMA = "bc-atlas.graph.slice";
|
|
7
|
+
|
|
8
|
+
function integer(value, name, fallback) {
|
|
9
|
+
const number = value === undefined ? fallback : Number(value);
|
|
10
|
+
if (!Number.isSafeInteger(number) || number < 0 || value === "") {
|
|
11
|
+
throw new Error(`${name} must be a nonnegative integer`);
|
|
12
|
+
}
|
|
13
|
+
return number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validateGraph(graph) {
|
|
17
|
+
if (!Array.isArray(graph?.nodes) || !Array.isArray(graph?.edges) || !graph.nodes.length) {
|
|
18
|
+
throw new Error("graph must contain a nonempty nodes array and an edges array");
|
|
19
|
+
}
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
for (const node of graph.nodes) {
|
|
22
|
+
if (!node || typeof node.id !== "string" || !node.id || typeof node.label !== "string" || ids.has(node.id)) {
|
|
23
|
+
throw new Error(`invalid or duplicate graph node: ${node?.id}`);
|
|
24
|
+
}
|
|
25
|
+
ids.add(node.id);
|
|
26
|
+
}
|
|
27
|
+
const edgeIds = new Set();
|
|
28
|
+
for (const edge of graph.edges) {
|
|
29
|
+
if (!edge || typeof edge.id !== "string" || !edge.id || edgeIds.has(edge.id) || !ids.has(edge.source) || !ids.has(edge.target)) {
|
|
30
|
+
throw new Error(`invalid graph edge or missing endpoint: ${edge?.id}`);
|
|
31
|
+
}
|
|
32
|
+
edgeIds.add(edge.id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Loads a saved Atlas graph or a nodes/edges snapshot, without reading AL. */
|
|
37
|
+
export async function queryGraph(input, values = {}) {
|
|
38
|
+
const document = JSON.parse(await fs.readFile(input, "utf8"));
|
|
39
|
+
if (!document || document.schemaVersion !== 1 || (document.schema && !["bc-atlas.graph", SLICE_SCHEMA].includes(document.schema))) {
|
|
40
|
+
throw new Error("unsupported graph schema; expected version 1 of bc-atlas.graph or a nodes/edges snapshot");
|
|
41
|
+
}
|
|
42
|
+
const level = values.level ?? "object";
|
|
43
|
+
let graph = document;
|
|
44
|
+
let frame;
|
|
45
|
+
if (document.schema === "bc-atlas.graph") {
|
|
46
|
+
graph = document.levels?.[level];
|
|
47
|
+
if (!graph) throw new Error(`unknown graph level: ${level}`);
|
|
48
|
+
const frames = document.timeline?.frames;
|
|
49
|
+
if (!Array.isArray(frames) || !frames.length) throw new Error("graph timeline must contain frames");
|
|
50
|
+
frame = integer(values.frame, "frame", frames.length - 1);
|
|
51
|
+
if (frame >= frames.length) throw new Error(`frame must be between 0 and ${frames.length - 1}`);
|
|
52
|
+
validateGraph(graph);
|
|
53
|
+
const present = (item) => {
|
|
54
|
+
if (!Array.isArray(item.presence) || item.presence.some((span) =>
|
|
55
|
+
!Array.isArray(span) || span.length !== 2 || !Number.isInteger(span[0]) || span[0] < 0 ||
|
|
56
|
+
(span[1] !== null && (!Number.isInteger(span[1]) || span[1] <= span[0]))
|
|
57
|
+
)) throw new Error(`invalid presence spans for ${item.id}`);
|
|
58
|
+
return item.presence.some(([start, end]) => start <= frame && (end === null || frame < end));
|
|
59
|
+
};
|
|
60
|
+
graph = { nodes: graph.nodes.filter(present), edges: graph.edges.filter(present) };
|
|
61
|
+
} else if (values.frame !== undefined || (values.level !== undefined && values.level !== "object")) {
|
|
62
|
+
throw new Error("frame and non-object level selections require a temporal bc-atlas.graph document");
|
|
63
|
+
}
|
|
64
|
+
validateGraph(graph);
|
|
65
|
+
const roots = new Set();
|
|
66
|
+
const selectors = values.root === undefined ? [] : [values.root].flat();
|
|
67
|
+
const rootGroups = document.roots ?? {};
|
|
68
|
+
if (typeof rootGroups !== "object" || Array.isArray(rootGroups) || Object.values(rootGroups).some((ids) =>
|
|
69
|
+
!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !id)
|
|
70
|
+
)) throw new Error("invalid graph root groups; expected named arrays of node IDs");
|
|
71
|
+
if (document.schema === SLICE_SCHEMA && (!Array.isArray(document.selection?.roots) ||
|
|
72
|
+
document.selection.roots.some((id) => !graph.nodes.some((node) => node.id === id))
|
|
73
|
+
)) throw new Error("invalid graph slice selection roots");
|
|
74
|
+
if (values.rootGroup !== undefined) {
|
|
75
|
+
const group = rootGroups[values.rootGroup];
|
|
76
|
+
if (!Array.isArray(group) || !group.length) throw new Error(`unknown or empty root group: ${values.rootGroup}`);
|
|
77
|
+
selectors.push(...group);
|
|
78
|
+
}
|
|
79
|
+
for (const selector of selectors) {
|
|
80
|
+
const exact = graph.nodes.find(({ id }) => id === selector);
|
|
81
|
+
const matches = exact ? [exact] : graph.nodes.filter(({ label, atlasKey }) => label === selector || atlasKey === selector);
|
|
82
|
+
if (matches.length !== 1) throw new Error(`root "${selector}" is ${matches.length ? "ambiguous; use a node ID" : "not present in the selected graph"}`);
|
|
83
|
+
roots.add(matches[0].id);
|
|
84
|
+
}
|
|
85
|
+
const depth = integer(values.depth, "depth", 1);
|
|
86
|
+
const direction = values.direction ?? "outgoing";
|
|
87
|
+
if (!["outgoing", "incoming", "both"].includes(direction)) throw new Error(`unsupported graph direction: ${direction}`);
|
|
88
|
+
const kinds = values.kind === undefined ? [] : [values.kind].flat();
|
|
89
|
+
const edges = graph.edges.filter((edge) => !kinds.length || kinds.some((kind) =>
|
|
90
|
+
Object.hasOwn(edge.kinds ?? {}, kind) || edge.kind === kind
|
|
91
|
+
));
|
|
92
|
+
const selected = new Set(roots);
|
|
93
|
+
const selectedEdges = new Set();
|
|
94
|
+
if (roots.size) {
|
|
95
|
+
const adjacency = new Map(graph.nodes.map(({ id }) => [id, []]));
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
if (direction !== "incoming") adjacency.get(edge.source).push([edge, edge.target]);
|
|
98
|
+
if (direction !== "outgoing") adjacency.get(edge.target).push([edge, edge.source]);
|
|
99
|
+
}
|
|
100
|
+
let frontier = [...roots];
|
|
101
|
+
for (let step = 0; step < depth && frontier.length; step++) {
|
|
102
|
+
const next = [];
|
|
103
|
+
for (const id of frontier) {
|
|
104
|
+
for (const [edge, neighbor] of adjacency.get(id)) {
|
|
105
|
+
selectedEdges.add(edge.id);
|
|
106
|
+
if (!selected.has(neighbor)) {
|
|
107
|
+
selected.add(neighbor);
|
|
108
|
+
next.push(neighbor);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
frontier = next;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const nodes = roots.size ? graph.nodes.filter(({ id }) => selected.has(id)) : graph.nodes;
|
|
116
|
+
const nodeIds = new Set(nodes.map(({ id }) => id));
|
|
117
|
+
const { nodes: _nodes, edges: _edges, levels: _levels, roots: _roots, selection: _selection, ...metadata } = document;
|
|
118
|
+
return {
|
|
119
|
+
schema: SLICE_SCHEMA,
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
metadata: document.schema === SLICE_SCHEMA ? document.metadata : metadata,
|
|
122
|
+
roots: Object.fromEntries(Object.entries(rootGroups).map(([name, ids]) => [name, ids.filter((id) => nodeIds.has(id))])),
|
|
123
|
+
selection: document.schema === SLICE_SCHEMA && !roots.size
|
|
124
|
+
? { ...document.selection, ...(values.kind === undefined ? {} : { kinds }) }
|
|
125
|
+
: { level, ...(frame === undefined ? {} : { frame }), roots: [...roots], depth, direction, kinds },
|
|
126
|
+
nodes,
|
|
127
|
+
edges: roots.size ? edges.filter(({ id }) => selectedEdges.has(id)) : edges
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function writeGraphFile(input, output, content) {
|
|
132
|
+
const source = await fs.realpath(input);
|
|
133
|
+
let destination = path.resolve(output);
|
|
134
|
+
try {
|
|
135
|
+
destination = await fs.realpath(destination);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error.code !== "ENOENT") throw error;
|
|
138
|
+
}
|
|
139
|
+
if (process.platform === "win32" ? source.toLowerCase() === destination.toLowerCase() : source === destination) {
|
|
140
|
+
throw new Error("graph output must not overwrite the input graph");
|
|
141
|
+
}
|
|
142
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
143
|
+
await fs.writeFile(destination, content);
|
|
144
|
+
return destination;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function writeGraphQuery(input, values = {}) {
|
|
148
|
+
const graph = await queryGraph(input, values);
|
|
149
|
+
const file = values.output ? await writeGraphFile(input, values.output, `${JSON.stringify(graph, null, 2)}\n`) : undefined;
|
|
150
|
+
return { graph, file };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const quote = (value) => JSON.stringify(String(value));
|
|
154
|
+
const edgeLabel = (edge) => Object.keys(edge.kinds ?? {}).join(", ") || edge.kind || "dependency";
|
|
155
|
+
const nodeLabel = (node) => [node.type && `${node.type}${node.objectId ? ` ${node.objectId}` : ""}`, node.label].filter(Boolean).join("\n");
|
|
156
|
+
const fill = (node, roots) => roots.has(node.id) ? "#dbeafe" : node.origin === "unresolved" ? "#ffedd5" : "#f1f5f9";
|
|
157
|
+
|
|
158
|
+
function graphDot(graph, title) {
|
|
159
|
+
const roots = new Set(graph.selection.roots);
|
|
160
|
+
return [
|
|
161
|
+
"digraph BCAtlas {",
|
|
162
|
+
`graph [rankdir=LR, label=${quote(title)}, labelloc=t];`,
|
|
163
|
+
'node [shape=box, style="rounded,filled"];',
|
|
164
|
+
...graph.nodes.map((node) => `${quote(node.id)} [label=${quote(nodeLabel(node))}, fillcolor=${quote(fill(node, roots))}];`),
|
|
165
|
+
...graph.edges.map((edge) => `${quote(edge.source)} -> ${quote(edge.target)} [label=${quote(edgeLabel(edge))}];`),
|
|
166
|
+
"}", ""
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function graphScene(graph, title) {
|
|
171
|
+
const roots = new Set(graph.selection.roots);
|
|
172
|
+
const paths = new Map(graph.nodes.map(({ id }, index) => [id, `g0.n${index}`]));
|
|
173
|
+
return {
|
|
174
|
+
title, direction: "right", omitted: 0, maxEdges: graph.edges.length,
|
|
175
|
+
groups: [{
|
|
176
|
+
key: "g0", label: "Roots: blue | Unresolved boundaries: orange", dash: 3,
|
|
177
|
+
nodes: graph.nodes.map((node, index) => ({
|
|
178
|
+
key: `n${index}`, path: paths.get(node.id), object: node.id, type: node.type,
|
|
179
|
+
label: nodeLabel(node), fill: fill(node, roots), stroke: "#64748b",
|
|
180
|
+
tooltip: [node.id, node.file, node.line, node.origin].filter((value) => value !== undefined).join("\n")
|
|
181
|
+
}))
|
|
182
|
+
}],
|
|
183
|
+
edges: graph.edges.map((edge) => ({
|
|
184
|
+
from: paths.get(edge.source), to: paths.get(edge.target), kind: edgeLabel(edge),
|
|
185
|
+
arrow: "->", label: edgeLabel(edge), color: edge.inCycle ? "#b91c1c" : "#64748b",
|
|
186
|
+
tooltip: [edge.id, edge.evidenceSampled ? "Evidence sampled" : "", JSON.stringify(edge.evidence ?? [])].filter(Boolean).join("\n")
|
|
187
|
+
}))
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Renders a query result using Atlas's local SVG renderer, or portable JSON/DOT. */
|
|
192
|
+
export async function renderGraph(input, values = {}) {
|
|
193
|
+
const requested = path.resolve(values.output ?? "bc-atlas-graph.svg");
|
|
194
|
+
const extension = path.extname(requested).slice(1).toLowerCase();
|
|
195
|
+
const format = values.format ?? (extension || "svg");
|
|
196
|
+
if (!GRAPH_RENDER_FORMATS.includes(format)) throw new Error(`unsupported graph format: ${format}`);
|
|
197
|
+
const output = extension === format ? requested
|
|
198
|
+
: `${GRAPH_RENDER_FORMATS.includes(extension) ? requested.slice(0, -extension.length - 1) : requested}.${format}`;
|
|
199
|
+
const graph = await queryGraph(input, values);
|
|
200
|
+
const title = values.title ?? "BC Atlas graph";
|
|
201
|
+
let content;
|
|
202
|
+
if (format === "json") content = `${JSON.stringify(graph, null, 2)}\n`;
|
|
203
|
+
else if (format === "dot") content = graphDot(graph, title);
|
|
204
|
+
else {
|
|
205
|
+
const maxNodes = integer(values.maxNodes, "max-nodes", MAX_DIAGRAM_NODES);
|
|
206
|
+
const maxEdges = integer(values.maxEdges, "max-edges", 5000);
|
|
207
|
+
if (graph.nodes.length > maxNodes || graph.edges.length > maxEdges) {
|
|
208
|
+
throw new Error(`graph has ${graph.nodes.length} nodes and ${graph.edges.length} edges; focus it with graph query/root/depth or raise max-nodes/max-edges`);
|
|
209
|
+
}
|
|
210
|
+
const svg = await renderScene(graphScene(graph, title), "svg");
|
|
211
|
+
const escape = (value) => String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
212
|
+
content = format === "svg" ? svg
|
|
213
|
+
: `<!doctype html>\n<html lang="en"><meta charset="utf-8"><title>${escape(title)}</title><body>${svg}</body></html>\n`;
|
|
214
|
+
}
|
|
215
|
+
const file = await writeGraphFile(input, output, content);
|
|
216
|
+
return { output: file, format, nodes: graph.nodes.length, edges: graph.edges.length, roots: graph.selection.roots.length };
|
|
217
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -17,6 +17,7 @@ import { runAutomation } from "./automation/run.js";
|
|
|
17
17
|
import { applySetup, createPlan } from "./automation/setup.js";
|
|
18
18
|
import { checkForUpdate } from "./commands/update.js";
|
|
19
19
|
import { writeExplorer } from "./commands/explore.js";
|
|
20
|
+
import { queryGraph, renderGraph } from "./graph/platform.js";
|
|
20
21
|
import { assessArchitecture } from "./blueprint/index.js";
|
|
21
22
|
import { architectureJson } from "./blueprint/report.js";
|
|
22
23
|
import { planExtraction } from "./blueprint/extract.js";
|
|
@@ -125,6 +126,17 @@ const projectFields = {
|
|
|
125
126
|
compilerLog: z.string().optional().describe("Import diagnostics from an AL compiler error log file or folder")
|
|
126
127
|
};
|
|
127
128
|
|
|
129
|
+
const graphFields = {
|
|
130
|
+
path: z.string().min(1).describe("Saved version 1 Atlas graph or nodes/edges JSON file; no AL analysis is performed"),
|
|
131
|
+
root: z.array(z.string().min(1)).optional().describe("Exact node IDs, labels, or Atlas keys"),
|
|
132
|
+
rootGroup: z.string().min(1).optional().describe("Named root group in the saved graph"),
|
|
133
|
+
depth: z.number().int().nonnegative().optional(),
|
|
134
|
+
direction: z.enum(["outgoing", "incoming", "both"]).optional(),
|
|
135
|
+
kind: z.array(z.string().min(1)).optional(),
|
|
136
|
+
level: z.string().min(1).optional(),
|
|
137
|
+
frame: z.number().int().nonnegative().optional()
|
|
138
|
+
};
|
|
139
|
+
|
|
128
140
|
export function createMcpServer(version = pkg.version) {
|
|
129
141
|
const server = new McpServer({ name: "bc-atlas", version });
|
|
130
142
|
|
|
@@ -140,6 +152,25 @@ export function createMcpServer(version = pkg.version) {
|
|
|
140
152
|
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
141
153
|
}, tool(async (args) => (await createArchitectureModel(args.path, architectureOptions(args))).model));
|
|
142
154
|
|
|
155
|
+
server.registerTool("bc_atlas_graph_query", {
|
|
156
|
+
description: "Query a saved graph without analyzing AL. Return a reusable graph slice with original IDs, directions, unresolved boundaries and evidence; select roots, relation kinds, traversal depth and temporal presence.",
|
|
157
|
+
inputSchema: z.object(graphFields),
|
|
158
|
+
annotations: { readOnlyHint: true, idempotentHint: true }
|
|
159
|
+
}, tool(async ({ path: input, ...values }) => queryGraph(input, values)));
|
|
160
|
+
|
|
161
|
+
server.registerTool("bc_atlas_graph_render", {
|
|
162
|
+
description: "Render a saved graph query as self-contained SVG/HTML, DOT, or JSON using the local Atlas renderer. No Graphviz installation or AL analysis is required.",
|
|
163
|
+
inputSchema: z.object({
|
|
164
|
+
...graphFields,
|
|
165
|
+
output: z.string().min(1),
|
|
166
|
+
format: z.enum(["json", "dot", "svg", "html"]).optional(),
|
|
167
|
+
title: z.string().optional(),
|
|
168
|
+
maxNodes: z.number().int().nonnegative().optional(),
|
|
169
|
+
maxEdges: z.number().int().nonnegative().optional()
|
|
170
|
+
}),
|
|
171
|
+
annotations: { idempotentHint: true }
|
|
172
|
+
}, tool(async ({ path: input, ...values }) => renderGraph(input, values)));
|
|
173
|
+
|
|
143
174
|
server.registerTool("bc_atlas_generate_diagram", {
|
|
144
175
|
description: "Analyze AL source and write a D2, JSON, or SVG architecture file.",
|
|
145
176
|
inputSchema: z.object({
|