T-7EJO-extract-corpus-depgraph-module
Status: closed/done · Impact: medium · Complexity: medium
The cross-entity corpus loader, depends_on target resolution, per-entity-type
“satisfied” predicate, and the dependency-graph (edge-build + cycle detection)
live inside plugin/lib/model/entities/task/ops/next.ts and are duplicated
— with a tasks-only resolution gap — inside plugin/lib/model/ops/audit.ts.
Two independent cycle detectors and two corpus walks drift apart and each must
be fixed twice. Extract this machinery into one model-level module both ops
import, killing the duplication and closing audit’s cross-entity resolution gap
— the sibling follow-up T-0015 explicitly deferred (“audit.ts
cross-entity / bare-id depends_on resolution. Same gap, sibling follow-up”).
| Location | Role today |
|---|---|
plugin/lib/model/entities/task/ops/next.ts | The sdlc task next op. Defines loadCorpus/DIR_TYPE (cross-entity {basename → {type,status}} index), SATISFIED_BY_TYPE/isSatisfied (per-type satisfied bands), resolveTarget (bare-id ↔ basename), buildDependencyIndex (forward/reverse edges, skipping closed targets), and detectCycle (3-color DFS, returns one cycle’s sorted members). All cross-entity, not task-specific — only the sort-key/liftSortKeys policy is genuinely task-local. |
plugin/lib/model/ops/audit.ts | The entities audit op. Carries its own duplicate corpus walk, adjacency build, and findCycles (all elementary cycles, rendered as a -> b -> a paths), and resolves depends_on targets only against docs/planning/tasks/ — a cross-entity target is wrongly reported as a broken edge (the does not resolve to a file under docs/planning/tasks/ finding). This is the gap T-0015 deferred. |
plugin/lib/model/entities/task/ops/resolve.ts | Task-only file resolver (resolveTaskFile); shares the bare-id/basename matching convention but does not satisfy the cross-entity case. |
plugin/lib/services/orchestrator/ops/watch.ts | In-process consumer: imports nextOp and calls nextOp.handler for the dispatchable set. Depends transitively on the machinery being moved; must keep working unchanged. |
plugin/lib/util/wikilinks.ts | unwrapWikilink — used by both ops to read depends_on entries. Stays a leaf util. |
plugin/lib/model/entities/task/ops/tests/next-golden.test.ts | Golden tests pinning corpus loading, the satisfied-band ⊆ status-enum assertion, cycle detection, and lift. |
plugin/skills/entities-audit/tests/ | Audit fixtures (cycle-of-two, cycle-of-three, linear-chain, …) that exercise audit’s dependency-graph + cycle reporting end to end. |
Proposed
Section titled “Proposed”One new module — plugin/lib/model/corpus/ — owns the corpus + dependency-graph
substrate:
loader.ts—loadCorpus,DIR_TYPE,CorpusEntry, and the sharedreadFrontmatterhelper (moved out ofnext.ts).resolve.ts—resolveTarget(target, basenames)(exact basename, then unique<id>-prefix; ambiguous/none →null).satisfied.ts—SATISFIED_BY_TYPE,SatisfiedBand,isSatisfied.graph.ts— a generic edge-builderbuildEdges({ nodes, targetsOf, resolve, skip? }) → { forwardEdges, reverseEdges, unresolved }and a single cycle primitivefindCycles(nodes, forwardEdges) → string[][](all elementary cycles, deterministic order).next.tsconsumesfindCycles(...)[0](sorted members) for its single-cycle contract;audit.tsconsumes the full list for its multi-cycle report.index.ts— re-exports;tests/— unit tests for each file.
next.ts keeps only the task-sort policy (SortKey, sortKeyFromFm,
comparators, liftSortKeys, unsatisfiedTargets wiring) and its op descriptor;
it imports everything else from the module. audit.ts resolves depends_on
cross-entity through the module and detects cycles via findCycles, retiring its
local copies. No task next output changes; audit gains correct cross-entity
resolution.
Approach
Section titled “Approach”- Create
plugin/lib/model/corpus/and moveloadCorpus/DIR_TYPE/CorpusEntry/readFrontmatter(→loader.ts),resolveTarget(→resolve.ts), andSATISFIED_BY_TYPE/SatisfiedBand/isSatisfied(→satisfied.ts) verbatim fromnext.ts. Re-export fromindex.ts. - Generalize the graph code into
graph.ts:buildEdgestakes the node set, atargetsOf(node)(rawdepends_onreader), aresolve(raw)(the module’sresolveTargetbound to the corpus basenames), and an optionalskip(target)predicate (nextpasses “status starts withclosed/”;auditpasses none). ImplementfindCyclesas the all-elementary-cycles finder (reuse audit’s canonicalize/dedup so audit output is preserved); deterministic iteration over sorted nodes. - Rewrite
next.tsto import from the module: replacebuildDependencyIndexwithbuildEdges, anddetectCyclewithfindCycles(...)[0] → sorted members(ornull). LeaveliftSortKeys, sort keys,unsatisfiedTargets, and the op descriptor in place. Target:next-golden.test.tspasses with only import-path edits. - Rewrite audit’s
depends_onresolution (the cross-file graph section ending in the tasks-onlydoes not resolve…finding) andfindCyclescall site to use the module: resolve targets against the full corpus, flag only genuinely-absent targets as broken, and detect cycles via the sharedfindCycles. Keep audit’s finding-render contract (a -> b -> a) intact. - Relocate the satisfied-band ⊆
status-enum assertion tocorpus/tests/satisfied.test.ts(or re-point it at the moved constant) so an enum change that breaks a band still fails. Addcorpus/tests/coverage forloader/resolve/graph. - Add an
entities-auditfixture proving cross-entity resolution (a task whosedepends_onnames a decision) no longer flags a broken edge. Update any audit golden that asserted the old tasks-only behavior. - Sweep doc-comments /
definition.mdthat namenext.tsas the home of the corpus loader or satisfied bands; re-point them atplugin/lib/model/corpus/.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/model/corpus/loader.ts | new | loadCorpus, DIR_TYPE, CorpusEntry, readFrontmatter moved from next.ts |
plugin/lib/model/corpus/resolve.ts | new | resolveTarget moved from next.ts |
plugin/lib/model/corpus/satisfied.ts | new | SATISFIED_BY_TYPE, SatisfiedBand, isSatisfied moved from next.ts |
plugin/lib/model/corpus/graph.ts | new | generic buildEdges + all-cycles findCycles (unifies next.detectCycle + audit.findCycles) |
plugin/lib/model/corpus/index.ts | new | re-exports for the module |
plugin/lib/model/corpus/tests/ | new | unit tests for loader/resolve/satisfied/graph, incl. the band ⊆ enum assertion |
plugin/lib/model/entities/task/ops/next.ts#loadCorpus | modify | delete moved fns; import from the corpus module; keep sort/lift policy + op descriptor |
plugin/lib/model/ops/audit.ts#findCycles | modify | adopt cross-entity resolver + shared findCycles; retire local copies; preserve render contract |
plugin/lib/model/entities/task/ops/tests/next-golden.test.ts | modify | import-path updates; move/re-point the band ⊆ enum assertion |
plugin/skills/entities-audit/tests/ | modify | add a cross-entity-edge fixture; refresh any golden asserting tasks-only resolution |
plugin/lib/model/entities/task/definition.md | modify | re-point any pointer naming next.ts as the corpus/satisfied-band home |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
plugin/lib/model/corpus/exportsloadCorpus,resolveTarget,SATISFIED_BY_TYPE/isSatisfied,buildEdges, andfindCycles; neithernext.tsnoraudit.tsdefines its own corpus loader, target resolver, or cycle detector — both import from the module. - AC-2: running
bun testagainstplugin/lib/model/entities/task/ops/tests/next-golden.test.tspasses with only import-path edits; every non-cycletask nextfixture yields byte-identicalorderedandskipped_blockedoutput to pre-change. - AC-3: a fixture with a task whose
depends_onnames a non-task entity (e.g. a decision) produces no broken-edge finding fromentities audit; only a genuinely-absent target is flagged. - AC-4: the
cycle-of-two,cycle-of-three, andlinear-chainfixtures underplugin/skills/entities-audit/tests/produce the same cycle / no-cycle verdicts and the samea -> b -> arender as pre-change. - AC-5: the satisfied-band ⊆ live-
status-enum assertion runs against the movedSATISFIED_BY_TYPEand still fails if any band escapes its schema enum. - AC-6:
bun test plugin/is green and the typecheck (bun runtypecheck path) is clean. - AC-7:
git grep -nE 'loadCorpus|SATISFIED_BY_TYPE|DIR_TYPE' -- plugin docs siteshows these symbols defined only underplugin/lib/model/corpus/; all other hits are imports or prose pointing at the module, none namingnext.tsas their home.
Out of scope
Section titled “Out of scope”- The
liftSortKeys/ sort-key policy. The dependency-lift and the five-key sort tuple stay innext.ts; only the corpus + graph substrate moves. Extracting a generic schedule kernel (lift + order, generic over key type) is a separate, later follow-up gated on a second algorithmic consumer. - Changing cycle-finding semantics. Cycles are still detected and reported by both ops; this unifies the implementation, not the behavior.
- Time-based / recurring task scheduling (B-1NN4-scheduled-tasks) — a different sense of “schedule”; unrelated.
- Broader
audit.tsrefactor beyond thedepends_onresolution + cycle call sites (the schema/frontmatter/prose checks are untouched). - Migrating
resolve.ts’sresolveTaskFileinto the module; it is a task-file-on-disk resolver with a different contract.
Dependencies
Section titled “Dependencies”- none — built entirely on shipped code (T-0015 merged in PR #484).
Discovery context
Section titled “Discovery context”Surfaced 2026-06-27 reviewing PR #484 / T-0015 for a possible “graph
schedule library” extraction. T-0015 shipped the cross-entity corpus loader +
satisfied-band predicate under task next but explicitly deferred audit’s
matching cross-entity/bare-id depends_on resolution as a sibling follow-up.
The review found audit independently re-implements the corpus walk and cycle
detection (findCycles) with a tasks-only resolution gap — making the
extraction a concrete de-duplication with a ready second consumer, not
speculative generality.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-06-28. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
git grepconfirmsloadCorpus/resolveTarget/SATISFIED_BY_TYPE/isSatisfied/buildEdges/findCyclesdefined only underplugin/lib/model/corpus/(re-exported viaindex.ts);next.tsandaudit.tsimport them and define none locally. - AC-2: auto —
bun test next-golden.test.ts(11 pass / 0 fail); byte-exactordered/skipped_blockedgoldens untouched, only the import paths and the AC-5-mandated band-assertion relocation changed. - AC-3: auto — new fixture
plugin/skills/entities-audit/tests/fixtures/cross-entity-edge/(a task whosedepends_onnames a decision, plus a genuinely-absent ghost target) drivesrun_evals.test.ts; the cross-entity edge is not flagged, only the ghost is. - AC-4: auto — the
cycle-of-two/cycle-of-three/linear-chaincases inrun_evals.test.tspass unchanged, incl. the exacta -> b -> arender (10 pass / 0 fail). - AC-5: auto — the band ⊆ live-
status-enum invariant relocated tocorpus/tests/satisfied.test.ts, importing the movedSATISFIED_BY_TYPEandstatusEnumFor;bun test plugin/lib/model/corpus(26 pass / 0 fail). - AC-6: auto —
bunx tsc --noEmitclean; targeted suites green. Fullbun test plugin/is green except one pre-existing environmental failure (atask-auto-definetest that does a realgit fetch originand fails on sandbox auth) that is in the origin/main baseline and unrelated to this change. - AC-7: auto —
git grep -nE 'loadCorpus|SATISFIED_BY_TYPE|DIR_TYPE' -- plugin docs site: definitions only undercorpus/; every other hit is an import/use or the T-7EJO spec prose and its generatedsite/mirror, none namingnext.tsas the home.
What worked
Section titled “What worked”- The deterministic readiness gate (
task gap-report) and the baseline-then-diff quality flow ran end to end without intervention; the sub-agent landed the extraction in six focused commits and left the worktree clean. - The golden-test contract (
next-golden.test.ts) made AC-2 a binary check — byte-exactorderedoutput proved the refactor was behavior-preserving with no manual diffing.
Friction and automation gaps
Section titled “Friction and automation gaps”bun test’sdashboard listtable-row prints a raw PID in the path column that the quality-baseline normalizer does not scrub (it scrubs<PID>/<PORT>/<TMPDIR>elsewhere) — so that row differs every run (10737 / 18165 / 25129) and always shows asnew-drift:under baseline-gating, defeating subtraction. Extend the normalizer to scrub the PID column ofdashboard listoutput (T-BQRU territory). → T-BQRU-quality-normalize-ports-pids-timings- The rumdl aggregate summary lines (
Issues: Found N issues in M files,Run rumdl fmt to fix N) are captured as findings, so any change in the total count — including a legitimate reduction — diffs asnew-drift:even when no individual finding is introduced. The baseline diff should drop rumdl’s aggregate/summary lines and gate only on per-file finding lines. → T-BCNP-quality-gate-ignores-summary-and-corpus-lines start_task.ts’s appended## Post-mortemstub uses underscore emphasis, which flips a task file’s MD049 majority style and retroactively flags any pre-existing asterisk emphasis in the spec prose (hereschedule kernel) as new drift. Either emit the stub with no emphasis or have task creation pre-normalize emphasis style so the stub cannot strand author prose. → T-AVRD-post-mortem-stub-md049-safe-emphasis
Spawned follow-up tasks
Section titled “Spawned follow-up tasks”- T-BQRU-quality-normalize-ports-pids-timings — linked (existing tracker for
normalizeFindingephemeral-token masking; enriched with thedashboard listpath-column bare-PID data point — a bare PID with nopidkeyword that the keyword-anchored mask would miss). - T-BCNP-quality-gate-ignores-summary-and-corpus-lines — linked (existing tracker for baseline-shifting summary/corpus lines; fifth independent observation, adds the legitimate-count-reduction false-flag datum).
- T-AVRD-post-mortem-stub-md049-safe-emphasis
(https://github.com/sksizer/dev/pull/499) — spawned (Upstream-plugin,
sdlc-meta; emit the post-mortem stub with MD049-safe emphasis).