#!/usr/bin/env nu # reflection/modules/sync.nu — ontology↔code synchronization. # Detects drift between .ontology/ declarations and actual project artifacts. # Scans: crates, scenarios, agents, CI, forms, modes, justfiles, .claude/. # Operates on $env.ONTOREF_PROJECT_ROOT (set by onref wrapper). use store.nu [daemon-export, daemon-export-safe] # Resolve project root: ONTOREF_PROJECT_ROOT if set and different from ONTOREF_ROOT, else ONTOREF_ROOT. def project-root []: nothing -> string { let pr = ($env.ONTOREF_PROJECT_ROOT? | default "") if ($pr | is-not-empty) and ($pr != $env.ONTOREF_ROOT) { $pr } else { $env.ONTOREF_ROOT } } # ── scan ────────────────────────────────────────────────────────────────────── # Analyze a project's real structure: crates, scenarios, agents, CI, forms, modes, justfiles, claude. export def "sync scan" [ --level: string = "auto", # Analysis level: structural | full | auto (detect nightly) ]: nothing -> record { let root = (project-root) let crates = (scan-crates $root $level) let scenarios = (scan-scenarios $root) let agents = (scan-agents $root) let ci = (scan-ci $root) let forms = (scan-forms $root) let modes = (scan-modes $root) let justfiles = (scan-justfiles $root) let claude = (scan-claude $root) let api_level = if ($crates | any { |c| ($c.pub_api | length) > 0 }) { "full" } else { "structural" } { crates: $crates, scenarios: $scenarios, agents: $agents, ci: $ci, forms: $forms, modes: $modes, justfiles: $justfiles, claude: $claude, api_level: $api_level, } } # ── diff ────────────────────────────────────────────────────────────────────── # Compare scan against ontology, producing drift report. export def "sync diff" [ --quick, # Skip nickel exports; parse NCL text directly for speed --level: string = "", # Extra checks: "full" adds ADR violations, content assets, connection health --docs, # Check for drift between crate //! doc comments and ontology node descriptions --fail-on-drift, # Exit 1 if any DRIFT items are found (for pre-commit use) ]: nothing -> table { let root = (project-root) let scan = (sync scan --level structural) let ontology = if $quick { load-ontology-quick $root } else { load-ontology $root } if ($ontology | is-empty) { print " No .ontology/core.ncl found — nothing to diff." return [] } let nodes = ($ontology.nodes? | default []) let edges = ($ontology.edges? | default []) let node_ids = ($nodes | each { |n| $n | get -o id | default "" } | where { $in | is-not-empty }) # Build set of all existing paths from scan let existing_paths = (collect-existing-paths $scan $root) mut report = [] # Check each node: does at least one artifact_path exist on disk? for node in $nodes { let id = $node.id let paths = ($node.artifact_paths? | default []) if ($paths | is-empty) { # Tensions and abstract concepts have no artifact_paths — always OK $report = ($report | append { status: "OK", id: $id, artifact_path: "", detail: "No artifact_paths declared (abstract node)" }) } else { # Check if at least one declared path exists in the project let found = ($paths | any { |p| let full = $"($root)/($p)" ($full | path exists) or ($existing_paths | any { |ep| $ep starts-with $p or $p starts-with $ep }) }) if $found { $report = ($report | append { status: "OK", id: $id, artifact_path: ($paths | first), detail: "" }) } else { $report = ($report | append { status: "STALE", id: $id, artifact_path: ($paths | str join ", "), detail: $"Declared paths not found: ($paths | str join ', ')", }) } } } # Check scan artifacts that no node claims via artifact_paths let all_node_paths = ($nodes | each { |n| $n.artifact_paths? | default [] } | flatten) let unclaimed = (find-unclaimed-artifacts $scan $root $all_node_paths) for art in $unclaimed { $report = ($report | append { status: "MISSING", id: $art.id, artifact_path: $art.path, detail: $"Artifact exists but no ontology node claims it: ($art.kind) at ($art.path)", }) } # Check edge integrity for edge in $edges { if not ($edge.from in $node_ids) { $report = ($report | append { status: "BROKEN", id: $"edge:($edge.from)->($edge.to)", artifact_path: "", detail: $"Edge source '($edge.from)' not found in nodes", }) } if not ($edge.to in $node_ids) { $report = ($report | append { status: "BROKEN", id: $"edge:($edge.from)->($edge.to)", artifact_path: "", detail: $"Edge target '($edge.to)' not found in nodes", }) } } # ── Full level: ADR violations, content assets, connection health ──────────── if $level == "full" { # ADR violations: run validate check-all and capture failures let adr_result = do { nu --no-config-file -c "use reflection/modules/validate.nu *; validate check-all --fmt json" } | complete if $adr_result.exit_code != 0 { let violations = do { $adr_result.stdout | from json } | complete if $violations.exit_code == 0 { let failed = ($violations.stdout | where { |r| ($r.passed? | default true) == false }) for v in $failed { $report = ($report | append { status: "BROKEN", id: ($v.constraint_id? | default "adr-constraint"), artifact_path: "", detail: $"ADR constraint failed: ($v.description? | default '')", }) } } } # Content assets: verify source_path exists on disk let manifest_file = $"($root)/.ontology/manifest.ncl" if ($manifest_file | path exists) { let manifest_result = do { nickel export --format json $manifest_file } | complete if $manifest_result.exit_code == 0 { let manifest = ($manifest_result.stdout | from json) let assets = ($manifest.content_assets? | default []) for asset in $assets { let src = ($asset.source_path? | default "") if ($src | is-not-empty) and not ($"($root)/($src)" | path exists) { $report = ($report | append { status: "MISSING", id: ($asset.id? | default $src), artifact_path: $src, detail: $"content_asset source_path not found on disk: ($src)", }) } } } } # Connection health: check declared project slugs exist in daemon registry let conn_file = $"($root)/.ontology/connections.ncl" if ($conn_file | path exists) { let conn_result = do { nickel export --format json $conn_file } | complete if $conn_result.exit_code == 0 { let connections = ($conn_result.stdout | from json) let daemon_url = ($env.ONTOREF_DAEMON_URL? | default "http://127.0.0.1:7891") let projects_result = do { http get $"($daemon_url)/projects" } | complete if $projects_result.exit_code == 0 { let registered = ($projects_result.stdout | from json | get slugs? | default []) for direction in ["upstream", "downstream", "peers"] { let conns = ($connections | get -o $direction | default []) for conn in $conns { let target = ($conn.project? | default "") if ($target | is-not-empty) and not ($target in $registered) { $report = ($report | append { status: "BROKEN", id: $"connection:($target)", artifact_path: "", detail: $"connection ($direction) references unregistered project: ($target)", }) } } } } } } } if $docs { let doc_scan = (sync scan --level structural) let doc_drifts = (check-crate-doc-drift $root $nodes $doc_scan) $report = ($report | append $doc_drifts) } let sorted = ($report | sort-by status id) if $fail_on_drift { let drifts = ($sorted | where status == "DRIFT") if ($drifts | is-not-empty) { print $"(ansi red)✗ ($drifts | length) crate\(s\) have doc drift:(ansi reset)" for d in $drifts { print $" ($d.id) ($d.artifact_path)" print $" ($d.detail)" } exit 1 } } $sorted } # ── propose ─────────────────────────────────────────────────────────────────── # Generate NCL patches for drift items. export def "sync propose" []: nothing -> string { let diff = (sync diff) let missing = ($diff | where status == "MISSING") let stale = ($diff | where status == "STALE") let broken = ($diff | where status == "BROKEN") mut lines = [] if ($missing | is-not-empty) { $lines = ($lines | append "# ── New nodes (MISSING artifacts found) ──") $lines = ($lines | append "") for item in $missing { let id = $item.id let path = $item.artifact_path let kind = (detect-artifact-kind $path) $lines = ($lines | append (generate-node-ncl $id $path $kind)) $lines = ($lines | append "") } } if ($stale | is-not-empty) { $lines = ($lines | append "# ── Stale nodes (no artifact found) — remove or update ──") $lines = ($lines | append "") for item in $stale { $lines = ($lines | append $"# REMOVE: ($item.id) — ($item.detail)") } $lines = ($lines | append "") } if ($broken | is-not-empty) { $lines = ($lines | append "# ── Broken edges — remove ──") $lines = ($lines | append "") for item in $broken { $lines = ($lines | append $"# REMOVE EDGE: ($item.id) — ($item.detail)") } $lines = ($lines | append "") } if ($lines | is-empty) { "# No drift detected — ontology is in sync." } else { $lines | str join "\n" } } # ── apply ───────────────────────────────────────────────────────────────────── # Apply proposed changes: insert new nodes, remove stale nodes from core.ncl. export def "sync apply" []: nothing -> nothing { let diff = (sync diff) let missing = ($diff | where status == "MISSING") let stale = ($diff | where status == "STALE") let broken = ($diff | where status == "BROKEN") if ($missing | is-empty) and ($stale | is-empty) and ($broken | is-empty) { print " Ontology is in sync — nothing to apply." return } let root = (project-root) let core_ncl = $"($root)/.ontology/core.ncl" if not ($core_ncl | path exists) { print $" Error: ($core_ncl) not found." return } print "" print "Proposed changes:" print "──────────────────────────────────────────────────────────────────" if ($missing | is-not-empty) { print $" ADD: ($missing | length) new nodes" for item in $missing { print $" + ($item.id) ← ($item.artifact_path)" } } if ($stale | is-not-empty) { print $" REMOVE: ($stale | length) stale nodes" for item in $stale { print $" - ($item.id)" } } if ($broken | is-not-empty) { print $" FIX: ($broken | length) broken edges" for item in $broken { print $" ! ($item.id)" } } print "" let confirm = (input "Apply changes? [y/N] " | str trim | str downcase) if $confirm != "y" { print " Aborted." return } mut lines = (open $core_ncl --raw | lines | each { |l| $l }) # Insert new nodes before the '],\n\n edges' boundary if ($missing | is-not-empty) { # Find the line index of ' edges = [' let edges_match = ($lines | enumerate | where { |row| ($row.item | str trim) == "edges = [" }) if ($edges_match | is-empty) { print " Cannot find 'edges = [' in core.ncl — file format not recognized. Skipping node insertion." } else { let edges_idx = ($edges_match | first | get index) # Find the top-level '],' closing the nodes array before edges. # Top-level = exactly 2 leading spaces (same indent as 'edges = ['). mut insert_idx = $edges_idx - 1 while $insert_idx > 0 { let line = ($lines | get $insert_idx) let trimmed = ($line | str trim) # Top-level array close: line is " ]," (2-space indent, not nested deeper) if $trimmed == "]," and ($line | str starts-with " ") and (not ($line | str starts-with " ")) { break } $insert_idx = $insert_idx - 1 } if $insert_idx == 0 { print " Cannot find nodes array closing bracket in core.ncl. Skipping node insertion." } else { mut new_lines = [] for item in $missing { let kind = (detect-artifact-kind $item.artifact_path) let block = (generate-node-ncl $item.id $item.artifact_path $kind) $new_lines = ($new_lines | append "") $new_lines = ($new_lines | append ($block | lines)) } let before = ($lines | first $insert_idx) let after = ($lines | skip $insert_idx) $lines = ($before | append $new_lines | append $after) print $" Inserted ($missing | length) new nodes." } # insert_idx guard } # edges_match guard } # Remove stale nodes for item in $stale { let id = $item.id let id_pattern = $"\"($id)\"" let id_matches = ($lines | enumerate | where { |row| ($row.item | str contains $id_pattern) and ($row.item | str contains "id ") }) if ($id_matches | is-not-empty) { let id_line_idx = ($id_matches | first | get index) # Walk back to find 'd.make_node' mut start_idx = $id_line_idx while $start_idx > 0 { if ($lines | get $start_idx | str contains "d.make_node") { break } $start_idx = $start_idx - 1 } # Walk forward to find '},' or lone '}' (last node in array has no trailing comma) mut end_idx = $id_line_idx let total = ($lines | length) while $end_idx < ($total - 1) { let trimmed_line = ($lines | get $end_idx | str trim) if ($trimmed_line | str starts-with "},") or ($trimmed_line == "}") { break } $end_idx = $end_idx + 1 } # Guard: only remove if we actually found the closing brace let end_line_trimmed = ($lines | get $end_idx | str trim) if not (($end_line_trimmed | str starts-with "},") or ($end_line_trimmed == "}")) { print $" warn: could not find closing brace for node '($id)' — skipping removal" continue } # Remove lines [start_idx..end_idx] inclusive let before = ($lines | first $start_idx) let after = ($lines | skip ($end_idx + 1)) # Skip leading blank line in after let trimmed_after = if ($after | is-not-empty) and (($after | first) | str trim | is-empty) { $after | skip 1 } else { $after } $lines = ($before | append $trimmed_after) print $" Removed node '($id)'." } } # Remove broken edges (handles both single-line and multi-line make_edge blocks) # Deduplicate: an edge with both from+to broken produces two BROKEN entries with the same id. let broken = ($broken | uniq-by id) for item in $broken { let edge_id = $item.id let parts = ($edge_id | str replace "edge:" "" | split row "->") if ($parts | length) == 2 { let src = ($parts | first) let tgt = ($parts | last) let from_pattern = $"from = \"($src)\"" let to_pattern = $"to = \"($tgt)\"" # Try single-line match first (from and to on same line) let single_matches = ($lines | enumerate | where { |row| ($row.item | str contains $from_pattern) and ($row.item | str contains $to_pattern) }) if ($single_matches | is-not-empty) { let edge_idx = ($single_matches | first | get index) let before = ($lines | first $edge_idx) let after = ($lines | skip ($edge_idx + 1)) $lines = ($before | append $after) print $" Removed edge ($src) → ($tgt)." } else { # Multi-line: find the make_edge block containing both from and to let from_lines = ($lines | enumerate | where { |row| $row.item | str contains $from_pattern }) if ($from_lines | is-not-empty) { let from_idx = ($from_lines | first | get index) # Search for matching to within ±5 lines let search_start = ([$from_idx - 5, 0] | math max) let search_end = ([($from_idx + 5), (($lines | length) - 1)] | math min) let nearby = ($lines | skip $search_start | first ($search_end - $search_start + 1)) let has_to = ($nearby | any { |l| $l | str contains $to_pattern }) if $has_to { # Walk back to find d.make_edge mut start = $from_idx while $start > 0 and (not ($lines | get $start | str contains "d.make_edge")) { $start = $start - 1 } # Walk forward to find the closing } mut end = $from_idx while $end < (($lines | length) - 1) { let l = ($lines | get $end | str trim) if ($l == "}," or $l == "}") and $end > $from_idx { break } $end = $end + 1 } let before = ($lines | first $start) let after = ($lines | skip ($end + 1)) $lines = ($before | append $after) print $" Removed multi-line edge ($src) → ($tgt)." } } } } } $lines | str join "\n" | $in + "\n" | save --force $core_ncl print "" print " Changes written to .ontology/core.ncl" print " Run 'sync diff' to verify." } # ── state ───────────────────────────────────────────────────────────────────── # Compare state.ncl dimensions against project reality. export def "sync state" []: nothing -> table { let root = (project-root) let state_ncl = $"($root)/.ontology/state.ncl" if not ($state_ncl | path exists) { print " No .ontology/state.ncl found." return [] } let state = (daemon-export-safe $state_ncl) if $state == null { print " Failed to export state.ncl" return [] } let dims = ($state.dimensions? | default []) mut report = [] for dim in $dims { let current = ($dim.current_state? | default "unknown") let desired = ($dim.desired_state? | default "unknown") let status = if $current == $desired { "REACHED" } else { "ACTIVE" } $report = ($report | append { id: $dim.id, current: $current, desired: $desired, status: $status, horizon: ($dim.horizon? | default ""), }) } $report } # ── audit ───────────────────────────────────────────────────────────────────── # Full audit: scan + diff + ADR constraints + gate conditions + NCL integrity. export def "sync audit" [ --fmt: string = "", # Output format: table* | json | silent --strict, # Exit with error on MISSING/STALE/BROKEN --quick, # Skip expensive operations (no nickel exports for API surface) ]: nothing -> record { let root = (project-root) let actor = ($env.ONTOREF_ACTOR? | default "developer") let f = if ($fmt | is-not-empty) { $fmt } else if $actor == "agent" { "json" } else { "table" } # Node drift let diff = if $quick { sync diff --quick } else { sync diff } let ok_count = ($diff | where status == "OK" | length) let missing_count = ($diff | where status == "MISSING" | length) let stale_count = ($diff | where status == "STALE" | length) let broken_count = ($diff | where status == "BROKEN" | length) let total_nodes = ($ok_count + $missing_count + $stale_count + $broken_count) # ADR constraint checks let adr_results = if $quick { [] } else { audit-adr-constraints $root } # Gate status (pass diff so breach detection can check protected nodes) let gate_results = if $quick { [] } else { audit-gates $root $diff } # State dimensions let state_report = if $quick { [] } else { sync state } # Justfile + .claude + tools audits (manifest-driven when available) let scan = if $quick { { justfiles: { exists: false }, claude: { exists: false } } } else { sync scan --level structural } let manifest = (load-manifest-safe $root) let justfile_results = if $quick { [] } else { audit-justfiles ($scan.justfiles? | default { exists: false }) $manifest } let claude_results = if $quick { [] } else { audit-claude ($scan.claude? | default { exists: false }) $manifest } let tools_results = if $quick { [] } else { audit-tools $manifest } let manifest_cov_results = if $quick { [] } else { audit-manifest-coverage $root } # Health score (0-100) — pooled weighted model # # Each check earns points proportional to its architectural weight: # node OK = 1pt (ontology completeness) # ADR Hard PASS = 3pt (architectural guardrail — violation is severe) # ADR Soft PASS = 1pt (advisory constraint) # infra PASS = 1pt (tooling / .claude / justfiles) # Penalties (subtracted from earned points): # broken edge = -2pt (graph integrity) # gate BREACH = -5pt (active membrana violated) # # health = earned / max_possible * 100, floored at 0 let adr_pass = ($adr_results | where status == "PASS" | length) let adr_total = ($adr_results | length) let all_infra = ($justfile_results | append $claude_results | append $tools_results | append $manifest_cov_results) let jc_pass = ($all_infra | where status == "PASS" | length) let jc_total = ($all_infra | length) let node_earned = ($ok_count | into float) let node_max = ($total_nodes | into float) # ADR severity weighting: Hard = 3pt, Soft = 1pt let adr_earned_list = ($adr_results | where status == "PASS" | each { |r| if ($r.severity? | default "Hard") == "Hard" { 3 } else { 1 } }) let adr_earned = if ($adr_earned_list | is-empty) { 0.0 } else { $adr_earned_list | math sum | into float } let adr_max_list = ($adr_results | each { |r| if ($r.severity? | default "Hard") == "Hard" { 3 } else { 1 } }) let adr_max = if ($adr_max_list | is-empty) { 0.0 } else { $adr_max_list | math sum | into float } let infra_earned = ($jc_pass | into float) let infra_max = ($jc_total | into float) let breach_count = ($gate_results | where status == "BREACH" | length) let penalty = (($broken_count * 2 + $breach_count * 5) | into float) let max_possible = ($node_max + $adr_max + $infra_max) let earned = ($node_earned + $adr_earned + $infra_earned - $penalty) let health = if $max_possible == 0.0 { 100.0 } else { let raw = ($earned / $max_possible * 100.0) if $raw < 0.0 { 0.0 } else if $raw > 100.0 { 100.0 } else { $raw } } | math round --precision 1 let report = { nodes: { ok: $ok_count, missing: $missing_count, stale: $stale_count, broken_edges: $broken_count, details: $diff, }, adrs: $adr_results, gates: $gate_results, state: $state_report, justfiles: $justfile_results, claude: $claude_results, tools: $tools_results, health: $health, } if $f == "json" { print ($report | to json) } else if $f == "silent" { # No output — caller uses return value only } else { print "" print $"Ontology Audit — ($root | path basename)" print "──────────────────────────────────────────────────────────────────" print $" Nodes: ($ok_count) OK / ($missing_count) MISSING / ($stale_count) STALE" print $" Edges: ($broken_count) broken" if ($adr_results | is-not-empty) { let adr_fail = ($adr_results | where status == "FAIL" | length) let hard_fail = ($adr_results | where status == "FAIL" and severity == "Hard" | length) let soft_fail = ($adr_results | where status == "FAIL" and severity == "Soft" | length) let severity_detail = if ($hard_fail + $soft_fail) > 0 { $" \(($hard_fail)H ($soft_fail)S\)" } else { "" } print $" ADR constraints: ($adr_pass) PASS / ($adr_fail) FAIL($severity_detail)" } if ($gate_results | is-not-empty) { let active_gates = ($gate_results | where status == "ACTIVE" | length) let breach_gates = ($gate_results | where status == "BREACH" | length) print $" Gates: ($active_gates) ACTIVE / ($breach_gates) BREACH" } if ($state_report | is-not-empty) { let reached = ($state_report | where status == "REACHED" | length) let active_dims = ($state_report | where status == "ACTIVE" | length) print $" State: ($reached) REACHED / ($active_dims) ACTIVE" } if ($justfile_results | is-not-empty) { let jf_pass = ($justfile_results | where status == "PASS" | length) let jf_miss = ($justfile_results | where status == "MISSING" | length) print $" Justfiles: ($jf_pass) PASS / ($jf_miss) MISSING" } if ($claude_results | is-not-empty) { let cl_pass = ($claude_results | where status == "PASS" | length) let cl_miss = ($claude_results | where status == "MISSING" | length) print $" .claude/: ($cl_pass) PASS / ($cl_miss) MISSING" } if ($tools_results | is-not-empty) { let tl_pass = ($tools_results | where status == "PASS" | length) let tl_miss = ($tools_results | where status == "MISSING" | length) let tl_opt = ($tools_results | where status == "OPTIONAL" | length) print $" Tools: ($tl_pass) PASS / ($tl_miss) MISSING / ($tl_opt) optional" } print $" Health: ($health)%" print "" # Show problem items let problems = ($diff | where status != "OK") if ($problems | is-not-empty) { print " Issues:" for p in $problems { print $" [($p.status)] ($p.id) ($p.detail)" } print "" } # Show justfile/claude/tools issues let jc_issues = ($justfile_results | append $claude_results | append $tools_results | where status == "MISSING") if ($jc_issues | is-not-empty) { print " Infrastructure gaps:" for p in $jc_issues { print $" [MISSING] ($p.check): ($p.detail)" } print "" } # Show manifest capability coverage issues let manifest_issues = ($manifest_cov_results | where { |r| $r.status != "PASS" }) if ($manifest_issues | is-not-empty) { print " Manifest capability gaps:" for p in $manifest_issues { print $" [($p.status)] ($p.check): ($p.detail)" } print "" } } if $strict and (($missing_count + $stale_count + $broken_count) > 0) { error make { msg: $"Audit failed: ($missing_count) MISSING, ($stale_count) STALE, ($broken_count) BROKEN" } } $report } # ── manifest-check ─────────────────────────────────────────────────────────── # Quick manifest capability coverage check — used by pre-commit hooks and CI. # Exits 0 if no Hard failures. Prints warnings but does not block on Soft issues. export def "sync manifest-check" [ --strict, # Also fail on WARN (Soft) issues ]: nothing -> nothing { let root = (project-root) let results = (audit-manifest-coverage $root) let fails = ($results | where status == "FAIL") let warns = ($results | where status == "WARN") if ($fails | is-not-empty) { for f in $fails { print $"(ansi red)✗ ($f.detail)(ansi reset)" } error make { msg: $"Manifest coverage: ($fails | length) Hard failure\(s\)" } } if $strict and ($warns | is-not-empty) { for w in $warns { print $"(ansi yellow)⚠ ($w.check): ($w.detail)(ansi reset)" } error make { msg: $"Manifest coverage: ($warns | length) warnings \(strict mode\)" } } if ($warns | is-not-empty) { print $"(ansi yellow)⚠ ($warns | length) manifest coverage warnings — run `ontoref sync audit` for details(ansi reset)" } let pass = ($results | where status == "PASS") if ($pass | is-not-empty) { print $"(ansi green)✓ ($pass | first | get detail)(ansi reset)" } } # ── watch ───────────────────────────────────────────────────────────────────── # Launch bacon in headless mode with ontology-watch job, monitor drift file. export def "sync watch" []: nothing -> nothing { let root = (project-root) let bacon_toml = $"($root)/bacon.toml" if not ($bacon_toml | path exists) { print " No bacon.toml found — cannot watch." print " Run setup_reflection.nu to install bacon configuration." return } # Verify ontology-watch job exists in bacon.toml let bacon_content = (open $bacon_toml --raw) if not ($bacon_content | str contains "[jobs.ontology-watch]") { print " bacon.toml found but missing [jobs.ontology-watch] job." print " Re-run setup_reflection.nu to enable the ontology-reflection layer." return } let has_bacon = (which bacon | is-not-empty) if not $has_bacon { print " bacon not found — install with: cargo install bacon" return } print " Starting ontology drift watch via bacon..." print " Press Ctrl+C to stop." ^bacon --headless -j ontology-watch } # ── Internal helpers ────────────────────────────────────────────────────────── def scan-crates [root: string, level: string]: nothing -> list { let cargo_toml = $"($root)/Cargo.toml" if not ($cargo_toml | path exists) { return [] } let cargo = (open $cargo_toml) let workspace_members = ($cargo | get -o workspace.members | default []) let crate_paths = if ($workspace_members | is-empty) { ["."] } else { $workspace_members } mut crates = [] for member in $crate_paths { let crate_dir = $"($root)/($member)" let crate_toml_path = $"($crate_dir)/Cargo.toml" if ($crate_toml_path | path exists) { $crates = ($crates | append (parse-single-crate $crate_dir $level $root)) } else { # Try globbing (e.g., "crates/*") let expanded = (glob $"($root)/($member)/Cargo.toml") for ct in $expanded { $crates = ($crates | append (parse-single-crate ($ct | path dirname) $level $root)) } } } $crates } def parse-single-crate [crate_dir: string, level: string, root: string]: nothing -> record { let crate_toml = $"($crate_dir)/Cargo.toml" let cargo = (open $crate_toml) let name = ($cargo | get -o package.name | default ($crate_dir | path basename)) let features = ($cargo | get -o features | default {} | columns) let deps_count = ($cargo | get -o dependencies | default {} | columns | length) let src_dir = $"($crate_dir)/src" let src_modules = if ($src_dir | path exists) { glob $"($src_dir)/**/*.rs" | each { |f| $f | path relative-to $crate_dir } } else { [] } let test_dir = $"($crate_dir)/tests" let test_count = if ($test_dir | path exists) { glob $"($test_dir)/**/*.rs" | length } else { 0 } let do_full = match $level { "full" => true, "structural" => false, _ => { (which "cargo" | is-not-empty) and ((do { ^cargo +nightly --version } | complete).exit_code == 0) }, } let pub_api = if $do_full { extract-pub-api $crate_dir $name } else { [] } let crate_doc = (extract-crate-doc $crate_dir) { name: $name, path: ($crate_dir | path relative-to $root), features: $features, deps_count: $deps_count, src_modules: $src_modules, test_count: $test_count, pub_api: $pub_api, crate_doc: $crate_doc, } } def extract-pub-api [crate_dir: string, crate_name: string]: nothing -> list { let result = do { cd $crate_dir ^cargo +nightly rustdoc -p $crate_name -- -Z unstable-options --output-format json } | complete if $result.exit_code != 0 { return [] } # Find the generated JSON doc let doc_name = ($crate_name | str replace "-" "_") let doc_json = $"($crate_dir)/target/doc/($doc_name).json" if not ($doc_json | path exists) { return [] } let doc = (open $doc_json) let index = ($doc | get -o index | default {}) $index | transpose k v | where {|item| let vis = ($item.v | get -o visibility | default "") $vis == "public" } | each { |item| let inner = ($item.v | get -o inner | default {}) let kind = ($inner | columns | first | default "unknown") { kind: $kind, path: ($item.v | get -o name | default ""), signature: ($item.v | get -o docs | default "" | str substring 0..120), } } } def extract-crate-doc [crate_dir: string]: nothing -> string { let candidates = [$"($crate_dir)/src/lib.rs", $"($crate_dir)/src/main.rs"] let existing = ($candidates | where { |f| $f | path exists }) if ($existing | is-empty) { return "" } open --raw ($existing | first) | lines | skip while { |l| let t = ($l | str trim) ($t | is-empty) or ($t | str starts-with "#![") } | take while { |l| let t = ($l | str trim) ($t | is-empty) or ($t | str starts-with "//!") } | where { |l| $l | str starts-with "//!" } | each { |l| $l | str replace --regex '^//!\s?' "" } | str join "\n" | str trim } def word-overlap [a: string, b: string]: nothing -> float { let stop = ["that", "this", "with", "from", "have", "will", "been", "when", "they", "each"] let words_a = ($a | str downcase | split row --regex '\W+' | where { |w| ($w | str length) > 3 and not ($w in $stop) } | sort | uniq) let words_b = ($b | str downcase | split row --regex '\W+' | where { |w| ($w | str length) > 3 and not ($w in $stop) } | sort | uniq) if ($words_a | is-empty) or ($words_b | is-empty) { return 0.0 } let intersection = ($words_a | where { |w| $w in $words_b } | length) let union_size = ($words_a | append $words_b | sort | uniq | length) ($intersection | into float) / ($union_size | into float) } def check-crate-doc-drift [root: string, nodes: list, scan: record]: nothing -> list { let crates = ($scan.crates? | default []) if ($crates | is-empty) { return [] } mut drifts = [] for node in $nodes { let paths = ($node.artifact_paths? | default []) if ($paths | is-empty) { continue } let node_desc = ($node.description? | default "" | str trim) # First sentence of description for summary-level comparison. # Split on ". " (period + space) to avoid breaking on path fragments like ".ontology/". let node_summary = ($node_desc | split row ". " | where { |s| not ($s | str trim | is-empty) } | if ($in | is-not-empty) { first | str trim } else { "" }) # Only check the FIRST artifact_path for crate-doc drift. # A crate appearing mid-list means "this crate implements the concept" — # different abstraction level from the concept description itself. let first_path = ($paths | if ($in | is-not-empty) { first } else { "" }) let p_norm = ($first_path | str replace --regex '/$' "") let matched = ($crates | where { |c| $c.path == $p_norm }) if ($matched | is-not-empty) { let cr = ($matched | first) let crate_doc = ($cr.crate_doc? | default "" | str trim) if ($crate_doc | is-not-empty) { let crate_first = ($crate_doc | lines | where { |l| $l | is-not-empty } | if ($in | is-not-empty) { first } else { "" }) if ($node_summary | is-empty) { $drifts = ($drifts | append { status: "DRIFT", id: $node.id, artifact_path: $cr.path, detail: $"Node has no description. Crate //! doc: \"($crate_first | str substring 0..120)\"", }) } else { # Compare first sentences: both are "summary" granularity regardless of total length. let overlap = (word-overlap $node_summary $crate_first) if $overlap < 0.20 { $drifts = ($drifts | append { status: "DRIFT", id: $node.id, artifact_path: $cr.path, detail: $"Doc mismatch \(overlap: ($overlap * 100 | math round)%\). Node: \"($node_summary | str substring 0..80)\" | Crate: \"($crate_first | str substring 0..80)\"", }) } } } } } $drifts } def scan-scenarios [root: string]: nothing -> list { let scenarios_dir = $"($root)/reflection/scenarios" if not ($scenarios_dir | path exists) { # Fallback: check examples/ let examples_dir = $"($root)/examples" if not ($examples_dir | path exists) { return [] } ls $examples_dir | where type == "dir" | each { |d| { category: ($d.name | path basename), path: ($d.name | path relative-to $root), description: "", files: (glob $"($d.name)/*" | length), actor: "developer", } } } else { ls $scenarios_dir | where type == "dir" | each { |d| let meta_file = $"($d.name)/scenario.ncl" let meta = if ($meta_file | path exists) { daemon-export-safe $meta_file | default {} } else { {} } { category: ($d.name | path basename), path: ($d.name | path relative-to $root), description: ($meta | get -o purpose | default ""), files: (glob $"($d.name)/*" | length), actor: ($meta | get -o actor | default "developer"), validates: ($meta | get -o validates | default []), } } } } def scan-agents [root: string]: nothing -> list { let agent_files = (glob $"($root)/**/*.agent.mdx") $agent_files | each { |f| { name: ($f | path basename | str replace ".agent.mdx" ""), path: ($f | path relative-to $root), format: "mdx", } } } def scan-ci [root: string]: nothing -> list { mut providers = [] if ($"($root)/.github/workflows" | path exists) { let wf = (glob $"($root)/.github/workflows/*.yml" | append (glob $"($root)/.github/workflows/*.yaml")) for f in $wf { $providers = ($providers | append { provider: "github-actions", path: ($f | path relative-to $root) }) } } if ($"($root)/.woodpecker" | path exists) { let wf = (glob $"($root)/.woodpecker/*.yml" | append (glob $"($root)/.woodpecker/*.yaml")) for f in $wf { $providers = ($providers | append { provider: "woodpecker", path: ($f | path relative-to $root) }) } } $providers } def scan-forms [root: string]: nothing -> list { let forms_dir = $"($root)/reflection/forms" if not ($forms_dir | path exists) { return [] } glob $"($forms_dir)/*.ncl" | each { |f| { name: ($f | path basename | str replace ".ncl" ""), path: ($f | path relative-to $root) } } } def scan-modes [root: string]: nothing -> list { # Collect modes from both ONTOREF_ROOT and project root let ontoref_modes = (glob $"($env.ONTOREF_ROOT)/reflection/modes/*.ncl") let project_modes = if ($root != $env.ONTOREF_ROOT) { glob $"($root)/reflection/modes/*.ncl" } else { [] } let all = ($ontoref_modes | append $project_modes | uniq) $all | each { |f| { name: ($f | path basename | str replace ".ncl" ""), path: $f } } } # ── Justfile scanner ──────────────────────────────────────────────────────── # Detects justfile modules, recipes, module system (import/mod), and variables. def scan-justfiles [root: string]: nothing -> record { let justfile_path = $"($root)/justfile" if not ($justfile_path | path exists) { return { exists: false, system: "", modules: [], recipes: [], variables: [] } } let raw = (open $justfile_path --raw) let lines = ($raw | lines) # Detect module system: import vs mod let has_import = ($lines | any { |l| ($l | str trim | str starts-with "import ") or ($l | str trim | str starts-with "import?") }) let has_mod = ($lines | any { |l| ($l | str trim | str starts-with "mod ") or ($l | str trim | str starts-with "mod?") }) let system = if $has_import and $has_mod { "hybrid" } else if $has_import { "import" } else if $has_mod { "mod" } else { "flat" } # Extract module declarations let mod_lines = ($lines | where { |l| let t = ($l | str trim) ($t | str starts-with "import ") or ($t | str starts-with "import? ") or ($t | str starts-with "mod ") or ($t | str starts-with "mod? ") }) let modules = ($mod_lines | each { |l| let t = ($l | str trim) let optional = ($t | str starts-with "import?") or ($t | str starts-with "mod?") let path_match = ($t | parse --regex "'([^']+)'|\"([^\"]+)\"") let mod_path = if ($path_match | is-not-empty) { let m = ($path_match | first) let c0 = ($m | get -o capture0 | default "") let c1 = ($m | get -o capture1 | default "") if ($c0 | is-not-empty) { $c0 } else { $c1 } } else { "" } let name_match = ($t | parse --regex '(?:mod\??\s+)(\w+)') let name = if ($name_match | is-not-empty) { $name_match | first | get capture0 } else { $mod_path | path basename | str replace ".just" "" } { name: $name, path: $mod_path, optional: $optional } }) # Scan justfiles/ directory for .just files let justfiles_dir = $"($root)/justfiles" let just_files = if ($justfiles_dir | path exists) { glob $"($justfiles_dir)/*.just" | each { |f| $f | path relative-to $root } } else { [] } # Extract top-level variables (VAR := "value" or VAR := `command`) let variables = ($lines | where { |l| let t = ($l | str trim) ($t =~ '^\w+\s*:=') and (not ($t | str starts-with "#")) } | each { |l| let parts = ($l | str trim | split row ":=" | each { |p| $p | str trim }) if ($parts | length) >= 2 { $parts | first } else { "" } } | where { |v| $v | is-not-empty }) # Extract recipe names (lines matching "name:", "@name:", "name PARAM:", "name PARAM="val":") let recipes = ($lines | where { |l| let t = ($l | str trim) ($t =~ '^@?\w[\w-]*(\s.*)?:') and (not ($t | str starts-with "#")) and (not ($t =~ '^\w+\s*:=')) } | each { |l| let t = ($l | str trim | str replace "@" "") $t | split row " " | first | str replace ":" "" }) { exists: true, system: $system, modules: $modules, module_files: $just_files, recipes: $recipes, variables: $variables, } } # ── .claude/ scanner ─────────────────────────────────────────────────────── # Inventories .claude/ capabilities: guidelines, commands, hooks, agents, skills. def scan-claude [root: string]: nothing -> record { let claude_dir = $"($root)/.claude" if not ($claude_dir | path exists) { return { exists: false, has_claude_md: false, guidelines: [], commands: [], hooks: [], settings: [] } } let has_claude_md = ($"($claude_dir)/CLAUDE.md" | path exists) # Guidelines: check each language subdirectory let guidelines_dir = $"($claude_dir)/guidelines" let guidelines = if ($guidelines_dir | path exists) { ls $guidelines_dir | where type == "dir" | each { |d| let lang = ($d.name | path basename) let files = (glob $"($d.name)/*.md" | each { |f| $f | path basename }) { language: $lang, files: $files } } } else { [] } # Commands let commands_dir = $"($claude_dir)/commands" let commands = if ($commands_dir | path exists) { glob $"($commands_dir)/*.md" | each { |f| let name = ($f | path basename | str replace ".md" "") let is_symlink = ((do { ^test -L $f } | complete).exit_code == 0) { name: $name, symlink: $is_symlink } } } else { [] } # Hooks (check settings.json for hook definitions) let settings_path = $"($claude_dir)/settings.json" let hooks = if ($settings_path | path exists) { let settings = (open $settings_path) let hook_events = ($settings | get -o hooks | default {} | columns) $hook_events } else { [] } # Settings files let settings = (glob $"($claude_dir)/settings*.json" | each { |f| $f | path basename }) # Layout conventions let has_layout = ($"($claude_dir)/layout_conventions.md" | path exists) # Session hook — check both legacy and current locations let has_session_hook = ( ($"($claude_dir)/hooks/session-context.sh" | path exists) or ($"($claude_dir)/ontoref-session-start.sh" | path exists) ) { exists: true, has_claude_md: $has_claude_md, has_layout_conventions: $has_layout, has_session_hook: $has_session_hook, guidelines: $guidelines, commands: $commands, hooks: $hooks, settings: $settings, } } # Load ontology via daemon-export (full fidelity, includes all fields). def load-ontology [root: string]: nothing -> record { let core_ncl = $"($root)/.ontology/core.ncl" if not ($core_ncl | path exists) { return {} } let data = (daemon-export-safe $core_ncl) if $data == null { print " Warning: failed to export core.ncl" return {} } $data } # Load ontology by parsing NCL text directly — no nickel binary needed. # Extracts id and artifact_paths from node blocks, and from/to from edge blocks. # Intentionally lossy: sufficient for drift detection in pre-commit/quick mode. def load-ontology-quick [root: string]: nothing -> record { let core_ncl = $"($root)/.ontology/core.ncl" if not ($core_ncl | path exists) { return {} } let raw = (open $core_ncl --raw) let lines = ($raw | lines) mut nodes = [] mut edges = [] mut in_node = false mut in_paths = false mut in_edge_section = false mut in_edge = false mut edge_from = "" mut edge_to = "" mut current_id = "" mut current_paths = [] for line in $lines { let trimmed = ($line | str trim) # Detect section boundaries if ($trimmed | str starts-with "nodes = [") { $in_edge_section = false } else if ($trimmed | str starts-with "edges = [") { $in_edge_section = true } # Parse node blocks if not $in_edge_section { if ($trimmed | str contains "d.make_node") { $in_node = true $in_paths = false $current_id = "" $current_paths = [] } else if $in_node and ($trimmed | str starts-with "},") { if ($current_id | is-not-empty) { $nodes = ($nodes | append { id: $current_id, artifact_paths: $current_paths }) } $in_node = false $in_paths = false } else if $in_node { # Extract id if ($trimmed | str starts-with "id ") { let id_match = ($trimmed | parse --regex 'id\s+=\s+"([^"]+)"') if ($id_match | is-not-empty) { $current_id = ($id_match | first | get capture0) } } # Extract artifact_paths (handles multiline arrays) if ($trimmed | str starts-with "artifact_paths") { $in_paths = true let paths_match = ($trimmed | parse --regex '"([^"]+)"') if ($paths_match | is-not-empty) { $current_paths = ($current_paths | append ($paths_match | get capture0)) } } else if $in_paths { if ($trimmed | str starts-with "],") or ($trimmed == "]") { $in_paths = false } else { let paths_match = ($trimmed | parse --regex '"([^"]+)"') if ($paths_match | is-not-empty) { $current_paths = ($current_paths | append ($paths_match | get capture0)) } } } } } # Parse edge blocks (single-line or multi-line d.make_edge) if $in_edge_section and ($trimmed | str contains "d.make_edge") { $in_edge = true $edge_from = "" $edge_to = "" # Check if single-line (from + to on same line as d.make_edge) let from_match = ($trimmed | parse --regex 'from\s*=\s*"([^"]+)"') let to_match = ($trimmed | parse --regex 'to\s*=\s*"([^"]+)"') if ($from_match | is-not-empty) { $edge_from = ($from_match | first | get capture0) } if ($to_match | is-not-empty) { $edge_to = ($to_match | first | get capture0) } if ($edge_from | is-not-empty) and ($edge_to | is-not-empty) { $edges = ($edges | append { from: $edge_from, to: $edge_to }) $in_edge = false } } else if $in_edge { # Multi-line: accumulate from/to across lines let from_match = ($trimmed | parse --regex 'from\s*=\s*"([^"]+)"') let to_match = ($trimmed | parse --regex 'to\s*=\s*"([^"]+)"') if ($from_match | is-not-empty) { $edge_from = ($from_match | first | get capture0) } if ($to_match | is-not-empty) { $edge_to = ($to_match | first | get capture0) } if ($trimmed == "}," or $trimmed == "}") { if ($edge_from | is-not-empty) and ($edge_to | is-not-empty) { $edges = ($edges | append { from: $edge_from, to: $edge_to }) } $in_edge = false } } } { nodes: $nodes, edges: $edges } } # Collect all paths that exist in the scan output (relative to root). def collect-existing-paths [scan: record, root: string]: nothing -> list { mut paths = [] for c in $scan.crates { $paths = ($paths | append $c.path) } for s in $scan.scenarios { $paths = ($paths | append $s.path) } for a in $scan.agents { $paths = ($paths | append $a.path) } for ci in $scan.ci { $paths = ($paths | append $ci.path) } # Forms for f in ($scan.forms? | default []) { $paths = ($paths | append $f.path) } # Modes for m in ($scan.modes? | default []) { $paths = ($paths | append $m.path) } # Justfile module files if ($scan.justfiles?.module_files? | default [] | is-not-empty) { for jf in $scan.justfiles.module_files { $paths = ($paths | append $jf) } } # Entry points if ($"($root)/onref" | path exists) { $paths = ($paths | append "onref") } if ($"($root)/onref" | path exists) { $paths = ($paths | append "onref") } $paths } # Find scan artifacts not claimed by any node's artifact_paths. def find-unclaimed-artifacts [scan: record, root: string, node_paths: list]: nothing -> list { mut unclaimed = [] for c in $scan.crates { let crate_rel = $c.path let claimed = ($node_paths | any { |np| $crate_rel starts-with $np or $np starts-with $crate_rel }) if not $claimed { $unclaimed = ($unclaimed | append { id: $c.name, path: $crate_rel, kind: "crate" }) } } for s in $scan.scenarios { let scen_rel = $s.path let claimed = ($node_paths | any { |np| $scen_rel starts-with $np or $np starts-with $scen_rel }) if not $claimed { $unclaimed = ($unclaimed | append { id: $"scenario-($s.category)", path: $scen_rel, kind: "scenario" }) } } for a in $scan.agents { let agent_rel = $a.path let claimed = ($node_paths | any { |np| $agent_rel starts-with $np or $np starts-with $agent_rel }) if not $claimed { $unclaimed = ($unclaimed | append { id: $"agent-($a.name)", path: $agent_rel, kind: "agent" }) } } for ci in $scan.ci { let ci_rel = $ci.path let claimed = ($node_paths | any { |np| $ci_rel starts-with $np or $np starts-with $ci_rel }) if not $claimed { $unclaimed = ($unclaimed | append { id: $"ci-($ci.provider)", path: $ci_rel, kind: "ci" }) } } for f in ($scan.forms? | default []) { # form.path may be absolute — normalize to relative for comparison let form_rel = if ($f.path | str starts-with $root) { $f.path | path relative-to $root } else { $f.path } let claimed = ($node_paths | any { |np| $form_rel starts-with $np or $np starts-with $form_rel }) if not $claimed { $unclaimed = ($unclaimed | append { id: $"form-($f.name)", path: $form_rel, kind: "form" }) } } for m in ($scan.modes? | default []) { # mode.path may be absolute — normalize to relative for comparison let mode_rel = if ($m.path | str starts-with $root) { $m.path | path relative-to $root } else { $m.path } let claimed = ($node_paths | any { |np| $mode_rel starts-with $np or $np starts-with $mode_rel }) if not $claimed { $unclaimed = ($unclaimed | append { id: $"mode-($m.name)", path: $mode_rel, kind: "mode" }) } } $unclaimed } def detect-artifact-kind [path: string]: nothing -> string { if ($path | str contains "crates/") { "crate" } else if ($path | str contains "scenarios/") or ($path | str contains "examples/") { "scenario" } else if ($path | str contains ".agent.mdx") { "agent" } else if ($path | str contains ".github/") or ($path | str contains ".woodpecker/") { "ci" } else if ($path | str contains "justfiles/") or ($path == "justfile") { "justfile" } else if ($path | str contains ".claude/") { "claude" } else if ($path | str contains "reflection/modules/") { "module" } else if ($path | str contains "reflection/modes/") { "mode" } else { "unknown" } } def generate-node-ncl [id: string, path: string, kind: string]: nothing -> string { let level = match $kind { "crate" => "'Project", "scenario" => "'Moment", "agent" => "'Practice", _ => "'Project", } [ $" d.make_node {", $" id = \"($id)\",", $" name = \"($id | str replace -a '-' ' ' | split words | each { |w| ($w | str capitalize) } | str join ' ')\",", $" pole = 'Yang,", $" level = ($level),", $" description = \"Detected from ($kind) at ($path)\",", $" invariant = false,", $" artifact_paths = [\"($path)\"],", $" },", ] | str join "\n" } def audit-adr-constraints [root: string]: nothing -> list { let adr_files = (glob $"($root)/adrs/adr-*.ncl") mut results = [] for f in $adr_files { let adr = (daemon-export-safe $f) if $adr == null { continue } if ($adr.status? | default "") != "Accepted" { continue } let constraints = ($adr.constraints? | default []) for c in $constraints { let severity = ($c.severity? | default "") if $severity not-in ["Hard", "Soft"] { continue } let hint = ($c.check_hint? | default "") if ($hint | is-empty) { continue } # Guard: only execute check_hints that start with a known safe command prefix. # Prevents arbitrary shell injection from ADR constraint fields. # Only read-only commands that do NOT accept code execution flags. # Excluded: nu (accepts -c), cargo (accepts run), nickel (accepts eval), # cat/jq (can be piped into shell via bash -c). let hint_trimmed = ($hint | str trim) let parts = ($hint_trimmed | split row " ") let cmd = ($parts | first) let args = ($parts | skip 1) let safe_commands = ["rg", "grep", "test", "[", "ls", "wc", "file", "stat"] if $cmd not-in $safe_commands { $results = ($results | append { adr: ($adr.id? | default ""), constraint: ($c.id? | default ""), severity: $severity, status: "SKIP", detail: $"check_hint command not in whitelist: ($cmd)", }) continue } # Block flags that allow code execution via whitelisted commands # (e.g., rg --pre