#!/bin/bash # OMN graph — canon link-graph emit + structural lint. # Nodes: facts/, decisions/, projects/ + watch/learnings/backlog/inbox.md. # Edges: [[wiki-links]] (resolved by basename anywhere in core, same rule as # check.sh) + supersedes: frontmatter. Report-only (exit 0) — a close.sh hook # may promote the warns to a gate later (the human's call). set -u CORE="$(cd "$(dirname "$0")/.." && pwd)" OUT="$CORE/graph.json" FAIL=0 warn() { echo " !! $*"; FAIL=$((FAIL+1)); } note() { echo " - $*"; } meta() { awk -v f="$2" 'BEGIN{n=0} /^---$/ {n++; next} n==1 && $1==f":" {sub(/^[^:]+: /,""); print; exit}' "$1" } days_since() { local s s=$(date -d "$1" +%s 2>/dev/null) || { echo 0; return; } echo $(( ($(date +%s) - s) / 86400 )) } # stale_p — kind: facts|decisions|projects|top (top-level working # files are never stale here; check.sh gates them via mtime, not frontmatter) stale_p() { case "$2" in facts|decisions|projects) local iv; iv=$(meta "$1" check); iv=${iv%d}; [ -n "$iv" ] || iv=7 [ "$(days_since "$(meta "$1" updated)")" -gt "$iv" ] ;; *) return 1 ;; esac } # resolve — path relative to core, or "" (card.md never a target) resolve() { local hit hit=$(find "$CORE" -name "${1##*/}.md" ! -name card.md 2>/dev/null | sort | head -1) [ -n "$hit" ] && printf '%s' "${hit#"$CORE"/}" } TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT NODES="$TMP/nodes"; EDGES="$TMP/edges"; LINKS="$TMP/links" touch "$NODES" "$EDGES" "$LINKS" # 1. Nodes for f in "$CORE"/facts/*.md "$CORE"/decisions/*.md "$CORE"/projects/*.md \ "$CORE"/watch.md "$CORE"/learnings.md "$CORE"/backlog.md "$CORE"/inbox.md \ "$CORE"/README.md; do [ -f "$f" ] || continue id="${f#"$CORE"/}"; dir=${id%%/*}; [ "$dir" = "$id" ] && dir=top case "$(meta "$f" status)" in accepted|live|active|superseded|"") ;; *) continue ;; esac st=$(meta "$f" status); [ -n "$st" ] || st="-" ti=$(meta "$f" title); [ -n "$ti" ] || ti=$(grep -m1 '^# ' "$f" | sed 's/^# //') esc=$(printf '%s' "$ti" | sed 's/\\/\\\\/g; s/"/\\"/g') if stale_p "$f" "$dir"; then stale=true; else stale=false; fi printf '%s\t%s\t%s\t%s\n' "$id" "$st" "$stale" "$esc" >> "$NODES" done # 2. Edges — [[wiki-links]] anywhere in node files + supersedes: frontmatter while IFS=$'\t' read -r id _; do f="$CORE/$id" grep -oE '\[\[[^]]+\]\]' "$f" 2>/dev/null | sed -E 's/^\[\[//; s/\]\]$//' | sort -u | while read -r ref; do [ -z "$ref" ] && continue tgt=$(resolve "$ref") if [ -z "$tgt" ]; then printf 'DEAD\t%s\t%s\n' "$id" "$ref" >> "$LINKS" elif [ "$tgt" = "$id" ]; then continue else printf '%s\t%s\twikilink\n' "$id" "$tgt" >> "$EDGES"; fi done sup=$(meta "$f" supersedes) [ -z "$sup" ] && continue printf '%s\n' "$sup" | grep -oE '\[\[[^]]+\]\]' | sed -E 's/^\[\[//; s/\]\]$//' | while read -r ref; do tgt=$(resolve "$ref") if [ -z "$tgt" ]; then printf 'DEAD\t%s\t%s\n' "$id" "$ref" >> "$LINKS" else printf '%s\t%s\tsupersedes\n' "$id" "$tgt" >> "$EDGES"; fi done done < "$NODES" # dedup edges per (from,to), supersedes wins over wikilink sort -t$'\t' -k1,1 -k2,2 -k3,3 "$EDGES" | awk -F'\t' '!seen[$1 FS $2]++' > "$TMP/edges.u" # 3. Lint echo "=== OMN graph ($(date +%F)) ===" while IFS=$'\t' read -r _ src ref; do warn "dead link [[$ref]] in $src"; done < "$LINKS" # stale neighbors: live source -> stale target while IFS=$'\t' read -r from to kind; do tf="$CORE/$to"; tdir=${to%%/*}; [ "$tdir" = "$to" ] && tdir=top if stale_p "$tf" "$tdir"; then sf="$CORE/$from"; sdir=${from%%/*}; [ "$sdir" = "$from" ] && sdir=top stale_p "$sf" "$sdir" || warn "stale neighbor: $from ($kind) -> $to is STALE — update or tombstone $to" fi done < "$TMP/edges.u" # orphans + god nodes (in-canon degree) awk -F'\t' '{d[$1]++; d[$2]++} END{for (k in d) print k}' "$TMP/edges.u" > "$TMP/linked" awk -F'\t' '{c[$2]++} END{for (k in c) print c[k], k}' "$TMP/edges.u" | sort -rn | head -3 > "$TMP/gods" comm -23 <(cut -f1 "$NODES" | sort) <(sort "$TMP/linked") > "$TMP/orphans" if [ -s "$TMP/orphans" ]; then note "orphans (no canon edges): $(paste -sd' ' "$TMP/orphans")" else note "orphans: none" fi if [ -s "$TMP/gods" ]; then note "god nodes (most-linked): $(awk '{printf "%s (%d)%s", $2, $1, (NR<3?", ":"")}' "$TMP/gods")" fi printf ' - graph.json written (%d nodes, %d edges)\n' \ "$(wc -l < "$NODES")" "$(wc -l < "$TMP/edges.u")" # 4. Emit graph.json { printf '{\n "generated": "%s",\n' "$(date '+%F %T')" printf ' "nodes": [\n' first=1 while IFS=$'\t' read -r id st stale ti; do [ $first -eq 1 ] || printf ',\n'; first=0 printf ' {"id": "%s", "status": "%s", "stale": %s, "title": "%s"}' "$id" "$st" "$stale" "$ti" done < "$NODES" printf '\n ],\n "edges": [\n' first=1 while IFS=$'\t' read -r from to kind; do [ $first -eq 1 ] || printf ',\n'; first=0 printf ' {"from": "%s", "to": "%s", "kind": "%s"}' "$from" "$to" "$kind" done < "$TMP/edges.u" printf '\n ]\n}\n' } > "$OUT" [ "$FAIL" -eq 0 ] && echo "PASS" || echo "REPORT: $FAIL finding(s) — report-only, exit 0" exit 0