/**
* `harn graph` ported to .harn — see harn#2311 (W11).
*
* **Pragmatic partial port.** The module-graph extraction itself lives
* in `crates/harn-cli/src/commands/graph.rs` — it walks the directory
* tree, runs `crate::commands::check::collect_harn_targets` +
* `build_module_graph`, parses each module, walks the IR analyser to
* derive capabilities / effects / host-call surface, and renders public
* symbols with stdlib-metadata frontmatter. None of that is reachable
* from script-land today without a new
* `harness.modules.compile_view(path)` host capability the W11 ticket
* spec calls out as future scope.
*
* What this script owns: the **rendering layer** — the human-readable
* text tree and the `JsonEnvelope`-shaped `--json` payload. The Rust
* shim pre-builds a `GraphReport` and serialises it as JSON into
* `HARN_GRAPH_VIEW_JSON` so the script just parses + formats.
*
* Note: the W11 ticket spec mentions a `--mermaid` mode, but the legacy
* graph handler today exposes only `--json` and the human text tree.
* Mermaid output is generated by `harn viz` (see `commands/viz.rs`) — a
* separate subcommand that already has its own renderer. The mermaid
* acceptance criterion is therefore N/A for the graph port.
*
* Inputs (from the dispatch shim in crates/harn-cli/src/commands/graph.rs):
* HARN_GRAPH_VIEW_JSON — serialised `GraphReport` with shape:
* {
* "modules": [{
* "path": "...",
* "public_symbols": [{
* "name": "...",
* "kind": "fn" | "tool" | "pipeline" | ...,
* "signature": "...",
* "metadata": {...} | absent,
* }, ...],
* "imports": ["..."],
* "requires_capabilities": ["..."],
* "effects": ["..."],
* "host_calls": ["..."],
* }, ...],
* "graph": {
* "nodes": ["..."],
* "edges": [{"from": "...", "to": "..."}, ...],
* }
* }
* HARN_OUTPUT_JSON — "1" iff the host saw `--json`, else
* human-readable text tree.
*
* Error envelopes stay in the Rust shim — extraction failures take the
* legacy direct render path.
*/
fn __safe_string(value, fallback: string) -> string {
if type_of(value) == "string" {
return value
}
return fallback
}
fn __safe_list(value) -> list {
if type_of(value) == "list" {
return value
}
return []
}
fn __safe_dict(value) -> dict {
if type_of(value) == "dict" {
return value
}
return {}
}
/**
* Join a list of strings with `sep`. Mirrors `join(items, sep)` for
* homogeneous string lists; non-string entries are coerced via
* `to_string`.
*/
fn __join(items: list, sep: string) -> string {
var out = ""
var first = true
for item in items {
let part = if type_of(item) == "string" {
item
} else {
to_string(item)
}
if first {
out = part
first = false
} else {
out = out + sep + part
}
}
return out
}
/**
* Render the human-readable tree. Mirrors `print_text_report` in
* `crates/harn-cli/src/commands/graph.rs`:
*
* <module-path>
* <kind> <signature>
* ...
* import <path>
* ...
* requires <cap1>, <cap2>
*/
fn __render_text(report: dict) -> string {
var out = ""
let modules = __safe_list(report["modules"])
for module in modules {
let m = __safe_dict(module)
out = out + __safe_string(m["path"], "") + "\n"
let symbols = __safe_list(m["public_symbols"])
for symbol in symbols {
let s = __safe_dict(symbol)
out = out + " " + __safe_string(s["kind"], "")
+ " "
+ __safe_string(s["signature"], "")
+ "\n"
}
let imports = __safe_list(m["imports"])
for import_path in imports {
out = out + " import " + __safe_string(import_path, "") + "\n"
}
let caps = __safe_list(m["requires_capabilities"])
if len(caps) > 0 {
out = out + " requires " + __join(caps, ", ") + "\n"
}
}
return out
}
/**
* Re-clean a single `public_symbols` entry: drop the `metadata` key
* when absent so the JSON envelope structurally matches the Rust
* `#[serde(skip_serializing_if = "Option::is_none")]` shape, and pass
* the nested metadata dict through unchanged when present.
*/
fn __clean_symbol(symbol: dict) -> dict {
var entry = {
name: __safe_string(symbol["name"], ""),
kind: __safe_string(symbol["kind"], ""),
signature: __safe_string(symbol["signature"], ""),
}
let metadata = symbol["metadata"]
if metadata != nil {
entry = entry + {metadata: metadata}
}
return entry
}
/**
* Re-clean modules + graph for the JSON envelope. Mirrors the
* `#[serde(skip_serializing_if = ...)]` projections the Rust
* `GraphReport`/`GraphModule`/`GraphSymbol` types apply on the way out.
*/
fn __strip_absent(report: dict) -> dict {
let modules = __safe_list(report["modules"])
var cleaned_modules = []
for module in modules {
let m = __safe_dict(module)
let symbols = __safe_list(m["public_symbols"])
var cleaned_symbols = []
for symbol in symbols {
cleaned_symbols = cleaned_symbols.push(__clean_symbol(__safe_dict(symbol)))
}
let entry = {
path: __safe_string(m["path"], ""),
public_symbols: cleaned_symbols,
imports: __safe_list(m["imports"]),
requires_capabilities: __safe_list(m["requires_capabilities"]),
effects: __safe_list(m["effects"]),
host_calls: __safe_list(m["host_calls"]),
}
cleaned_modules = cleaned_modules.push(entry)
}
let graph = __safe_dict(report["graph"])
let edges = __safe_list(graph["edges"])
var cleaned_edges = []
for edge in edges {
let e = __safe_dict(edge)
let edge_entry = {from: __safe_string(e["from"], ""), to: __safe_string(e["to"], "")}
cleaned_edges = cleaned_edges.push(edge_entry)
}
return {modules: cleaned_modules, graph: {nodes: __safe_list(graph["nodes"]), edges: cleaned_edges}}
}
fn __render_envelope(report: dict) -> string {
let cleaned = __strip_absent(report)
let envelope = {schemaVersion: 1, ok: true, data: cleaned, error: nil, warnings: []}
return json_stringify_pretty(envelope)
}
fn main(harness: Harness) -> int {
let raw = harness.env.get_or("HARN_GRAPH_VIEW_JSON", "")
if raw == "" {
harness.stdio
.eprintln("internal error: HARN_GRAPH_VIEW_JSON not set by dispatch shim")
return 70
}
let report = try {
json_parse(raw)
} catch (e) {
harness.stdio
.eprintln("internal error: failed to parse HARN_GRAPH_VIEW_JSON: " + to_string(e))
return 70
}
if type_of(report) != "dict" {
harness.stdio.eprintln("internal error: HARN_GRAPH_VIEW_JSON must be a JSON object")
return 70
}
let json_mode = harness.env.get_or("HARN_OUTPUT_JSON", "0") == "1"
if json_mode {
harness.stdio.println(__render_envelope(report))
return 0
}
// Strip the trailing newline so `println` re-adds exactly one — the
// legacy `print_text_report` ends every row with `println!()` so the
// stream ends with one terminating `\n`.
let text = __render_text(report)
let trimmed = if len(text) > 0 && text[len(text) - 1] == "\n" {
text[0:len(text) - 1]
} else {
text
}
harness.stdio.println(trimmed)
return 0
}