File size: 5,135 Bytes
6858873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#!/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 <file> <kind> — 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 <ref> — 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