the-custodian/docs/evidence/2026-10-03-dependency-graph-triage/scan_workplan_files.py
codex 84f2e1899c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Python Tests / pytest (push) Successful in 24s
CUST-WP-0075: add repeatable dependency monitor and handoff evidence
2026-10-03 23:10:54 +02:00

141 lines
7.1 KiB
Python

import json,re,os,glob,collections,datetime,sys
import yaml
import os; S=os.environ.get('TRIAGE_SCRATCH','.')
repos=json.load(open(f'{S}/repos.json')); repos=repos if isinstance(repos,list) else repos['items']
FM=re.compile(r'^---\n(.*?)\n---',re.S); TB=re.compile(r'```task\n(.*?)```',re.S)
parse_warnings=[]
def kv(block):
try:
d=yaml.safe_load(block)
except yaml.YAMLError:
# Historic records mix prose into task fences. Preserve their fields,
# but expose the degraded parse rather than silently certify the scan.
parse_warnings.append(f)
d={}
for match in re.finditer(r'^([A-Za-z_]+):\s*([^\n]*)$',block,re.M):
key,value=match.groups()
if not value.strip():
tail=block[match.end():]
items=re.match(r'\n((?:[ \t]+-[ \t]+[^\n]*\n?)+)',tail)
value='\n'+items.group(1) if items else ''
try: d[key]=yaml.safe_load(value)
except yaml.YAMLError: d[key]=value.strip().strip('"')
if not isinstance(d,dict): raise ValueError('work record must be a YAML mapping')
return d
def lst(v):
if not v: return []
if isinstance(v,list): return [str(x) for x in v]
v=str(v)
v=v.strip()
if v.startswith('['): return [x.strip().strip('"\'') for x in v[1:-1].split(',') if x.strip()]
return [v]
WP={}; T={}
for r in repos:
p=r.get('local_path')
if not p or not os.path.isdir(p): continue
for f in glob.glob(f'{p}/workplans/*.md')+glob.glob(f'{p}/workplans/archived/*.md'):
txt=open(f,errors='ignore').read(); m=FM.match(txt)
if not m: continue
fm=kv(m.group(1))
# multi-line depends_on in frontmatter
dm=re.search(r'^depends_on:\s*\n((?:\s+-\s+.*\n)+)',m.group(1)+'\n',re.M)
wdeps=[x.strip()[2:].strip().strip('"') for x in dm.group(1).splitlines()] if dm else lst(fm.get('depends_on'))
wid=fm.get('id')
if not wid or fm.get('type') not in (None,'workplan'): continue
WP[wid]={'repo':r['slug'],'file':f,'status':fm.get('status'),'flavor':fm.get('flavor'),'depends_on':wdeps,'title':fm.get('title')}
for tb in TB.findall(txt):
d=kv(tb); tid=d.get('id')
if not tid: continue
T[tid]={'wp':wid,'repo':r['slug'],'status':d.get('status'),'depends_on':lst(d.get('depends_on')),'needs_human':str(d.get('needs_human','')).lower()=='true','blocked_on':d.get('blocked_on') or None,'wait_until':str(d['wait_until']) if d.get('wait_until') else None,'decision_id':d.get('decision_id') or None,'reason':d.get('blocking_reason') or '', 'hub_id':d.get('state_hub_task_id')}
json.dump({'WP':WP,'T':T,'parse_warnings':sorted(set(parse_warnings))},open(f'{S}/files.json','w'))
TW={'finished','archived'}; TT={'done','cancel'}
def term(x):
if x in T: return T[x]['status'] in TT
if x in WP: return WP[x]['status'] in TW
return None
openwp={k:v for k,v in WP.items() if v['status'] in ('blocked','active','ready','proposed','backlog')}
print('files: workplans',len(WP),'open',len(openwp),collections.Counter(v['status'] for v in openwp.values()))
print('open tasks',collections.Counter(t['status'] for t in T.values() if t['wp'] in openwp))
today=datetime.date.today()
print('\n=== A. satisfied waits in open workplans (file view) ===')
for tid,t in sorted(T.items()):
if t['status']!='wait' or t['wp'] not in openwp: continue
tg=t['depends_on']; rs=[]
if tg and all(term(x) for x in tg): rs.append('deps terminal: '+','.join(tg))
if t['wait_until'] and datetime.date.fromisoformat(t['wait_until'][:10])<today: rs.append('wait_until passed '+t['wait_until'])
unres=[x for x in tg if term(x) is None]
if unres: rs.append('UNRESOLVED '+','.join(unres))
if rs: print(f"{tid:28} {t['repo']:22} human={t['needs_human']} on={t['blocked_on']} | {'; '.join(rs)}")
print('\n=== B. blocked workplans without any wait task ===')
bywp=collections.defaultdict(list)
for tid,t in T.items(): bywp[t['wp']].append((tid,t['status']))
for k,v in sorted(openwp.items()):
if v['status']=='blocked' and not any(s=='wait' for _,s in bywp[k]):
print(k, v['repo'], v['flavor'], [x for x in bywp[k] if x[1] not in TT])
print('\n=== B2. non-blocked open workplans whose only open tasks are wait ===')
for k,v in sorted(openwp.items()):
op=[s for _,s in bywp[k] if s not in TT]
if v['status']!='blocked' and op and all(s=='wait' for s in op): print(k,v['repo'],v['status'],len(op))
print('\n=== B3. open workplans with zero open tasks ===')
for k,v in sorted(openwp.items()):
op=[s for _,s in bywp[k] if s not in TT]
if not op: print(k,v['repo'],v['status'],v['flavor'],len(bywp[k]))
print('\n=== C. unqualified waits in open non-residual workplans ===')
for tid,t in sorted(T.items()):
if t['status']=='wait' and t['wp'] in openwp and openwp[t['wp']]['flavor']!='residual' and not t['depends_on'] and not t['needs_human'] and not t['blocked_on'] and not t['wait_until']:
print(tid,t['repo'],'|',t['reason'][:90])
# graph
dep=collections.defaultdict(list)
for tid,t in T.items():
if t['wp'] in openwp and t['status'] not in TT:
for x in t['depends_on']: dep[tid].append(x)
for k,v in openwp.items():
for x in v['depends_on']: dep[k].append(x)
rev=collections.defaultdict(set)
for a,bs in dep.items():
for b in bs: rev[b].add(a)
def wpof(x): return T[x]['wp'] if x in T else x
def fanin(x):
seen=set(); st=[x]; out=set()
while st:
n=st.pop()
nxt=set(rev.get(n,()))
if n in T: nxt.add(T[n]['wp']) # a task gates its workplan
if n in WP: nxt|= {tid for tid,t in T.items() if t['wp']==n and t['status'] not in TT and False}
for p in nxt:
if p in seen: continue
seen.add(p); st.append(p)
w=wpof(p)
if w!=wpof(x) and w in openwp: out.add(w)
return out
print('\n=== E. roots by transitive open-workplan fan-in (file view) ===')
nodes=set(dep)|{b for bs in dep.values() for b in bs}
rows=[]
for n in nodes:
if term(n): continue
if any(not term(b) for b in dep.get(n,[])): continue
f=fanin(n)
if f: rows.append((len(f),n,sorted(f)))
rows.sort(key=lambda r:(-r[0],r[1]))
for n,k,f in rows[:45]:
t=T.get(k);
desc=(f"wait h={t['needs_human']} on={t['blocked_on']} dec={t['decision_id']} | {t['reason'][:70]}" if t and t['status']=='wait' else (f"{t['status']}" if t else f"WP {WP[k]['status']} {WP[k]['repo']}" if k in WP else 'UNKNOWN'))
print(f"{n:3} {k:28} {desc}\n -> {', '.join(f[:12])}{' ...' if len(f)>12 else ''}")
print('\n=== F. cycles (file view) ===')
color={}
def dfs(n,path):
color[n]=1; path.append(n)
for d in dep.get(n,[]):
if term(d): continue
if color.get(d)==1: print(' -> '.join(path[path.index(d):]+[d]))
elif d not in color: dfs(d,path)
path.pop(); color[n]=2
for n in list(dep):
if n not in color and not term(n): dfs(n,[])
print('\n=== G. human gates by fan-in ===')
rows=[]
for tid,t in T.items():
if t['status']=='wait' and t['wp'] in openwp and t['needs_human']: rows.append((len(fanin(tid)),tid,t))
rows.sort(key=lambda r:-r[0])
for n,tid,t in rows: print(f"{n:3} {tid:28} {t['repo']:22} dec={t['decision_id']} | {t['reason'][:110]}")
print(len(rows),'human gates')