Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


id: refactor kind: intent checked_against_core: clear statement: “WHEN a proposed changeset would edit rows owned by a node with high, unrelated fan-in in [[graph]], THE refactor advisor SHALL surface a non-gating suggestion to split that node first, and SHALL never block or delay any transition in [[specodelic]]’s or [[orchestrate]]’s lifecycle.”

Refactor Advisor

STATUS.md §2 already names the pathology this mechanizes — “God object / cyclic dependency” — and specodelic.md Revision 2 already fixed one real instance of it by hand (splitting the flat 7-clause lint guard into the Checker Ownership table) before anyone had a tool that would have flagged it proactively. This file is that tool’s spec: Kent Beck’s tidy-first rule (“separate the structural change from the behavioral one”) read as a graph query — a node with fan-in from unrelated namespaces, or a changeset that only needs a fraction of what a node owns, is a candidate for splitting before the behavioral edit lands, not after.

Purpose

STATUS.md §2 already names the pathology this mechanizes — “God object / cyclic dependency” — and specodelic.md Revision 2 already fixed one real instance of it by hand (splitting the flat 7-clause lint guard into the Checker Ownership table) before anyone had a tool that would have flagged it proactively. This file is that tool’s spec: Kent Beck’s tidy-first rule (“separate the structural change from the behavioral one”) read as a graph query — a node with fan-in from unrelated namespaces, or a changeset that only needs a fraction of what a node owns, is a candidate for splitting before the behavioral edit lands, not after.

Constraints

idkindexprtraces_to
fan_in_read_from_graphinvariantthe advisor's dependent-count for node N is [[graph]]'s blast_radius(N) restricted to incoming edges — never independently recomputed by walking markdown[[refactor]]
unrelated_fan_in_definedinvarianta dependent is "unrelated" to N when N and the dependent share no namespace segment below the repo root — i.e. their nearest common ancestor in the id namespace is the root itself[[refactor]]
narrow_diff_heuristicinvarianta changeset naming a strict subset of node N's owned Constraint/Property rows as its actual target, while depending on none of N's other owned rows, is flagged regardless of N's fan-in count[[refactor]]
threshold_is_per_repo_settinginvariantthe fan-in count that counts as "high" is a configured value, not a number fixed in this file — "high" is relative to a given repo's typical fan-in[[refactor]]
advisory_finding_emittedeffectoutput == {node_id, dependent_count, unrelated_namespace_count, suggested_split: bool}[[refactor]]

Model

States

  • idle
  • analyzing
  • clean
  • found emits: [[refactor.advisory_finding_emitted]]

Transitions

idfromtoguard
analyzeidleanalyzing[[graph.queryable]] — graph’s artifact is published and queryable (see graph.md’s own lifecycle)
flaganalyzingfound[[refactor.unrelated_fan_in_defined]] ∨ [[refactor.narrow_diff_heuristic]]
clearanalyzingclean¬flag.guard

Properties

idkindderives_fromgeneratorpredicate
fan_in_graph_sourcedunit[[refactor.fan_in_read_from_graph]]node_with_precomputed_blast_radius_in_graph()advisor_source_calls(markdown_walker) == 0 — every count comes from graph’s artifact
unrelated_fan_in_flaggedunit[[refactor.unrelated_fan_in_defined]]node_referenced_by_dependents_in_three_disjoint_top_level_namespaces()check(node) == found
related_fan_in_not_flaggedunit[[refactor.unrelated_fan_in_defined]]node_referenced_only_by_dependents_within_its_own_namespace_subtree()check(node) == clean
narrow_diff_flagged_low_fan_inunit[[refactor.narrow_diff_heuristic]]changeset_touching_one_of_five_constraints_owned_by_a_node_with_fan_in(1)check(node) == found — flagged even though fan-in alone wouldn’t trigger it
emitted_finding_shapeunit[[refactor.advisory_finding_emitted]]found state reached for node N with dependent_count 4, 2 unrelatedemits(found) == {node_id: N, dependent_count: 4, unrelated_namespace_count: 2, suggested_split: true}
threshold_read_from_configunit[[refactor.threshold_is_per_repo_setting]]same_node_same_changeset_under_two_repo_configs_with_different_high_fan_in_values()flagged(config_A) != flagged(config_B) — the decision follows the repo’s configured threshold, never a constant embedded in this file

Notes

checked_against_core: clear (see AGENTS.md’s convention). Nothing new was needed to make the finding non-gating: advisory_finding_emitted is kind == effect, and [[specodelic.ref_kind_compatible]]’s own typing already restricts every transition’s guard field to kind == invariant Constraints only, tested generically by [[specodelic.advisory_cannot_gate]]. An effect-kind Constraint is excluded from guard-eligibility by that same rule, for the same reason an advisory-kind one is — so this file needed no invariant of its own asserting “the finding can’t gate anything”; that was already true, globally, before this file existed. (An earlier draft of this file carried two such invariants plus a redundant unit test — removed as an instance of the exact “one rule, restated three ways” pattern specodelic.md Revision 6 already caught once, for append_only_variants.) advisory_finding_emitted’s emits reuses the Moore-machine mechanism Revision 6 built for State; this file adds no sixth 𝒦 object and no new kind of Constraint — a new consumer of an existing mechanism, not a new one.

This file never decides how to split a node. It only detects the fan-in/narrow-diff shape and emits a finding; the actual split — a new file, an updated traces_to, an updated Checker Ownership-style table if the node is itself a dependency hub — is an ordinary edit, checked the normal way by the six existing linters. Deciding how to split is a design judgment this file deliberately doesn’t automate.

Depends on graph.md, not on re-deriving reachability: analyze’s guard cites graph’s own queryable state by typed citation ([[graph.queryable]] — a State, which Revision 10’s guard typing admits) instead of bare prose (the same “cite another file’s state fact when it isn’t itself a Constraint row” pattern orchestrate.md already uses for model_check.md/verify.md), and every fan-in count is a graph.blast_radius query, never an independent markdown walk.

Decision of record (2026-09-30, user-approved; advised by a typed Jev evaluation, jev-1.13.0, conf 0.54): “unrelated fan-in” keys off the id namespace — the “shares no namespace segment below root” test stands as written. Physical directory placement is rejected as the keying: ids are the format’s primary identity (id_matches_file ties them to filenames, the Reference Typing table types them as foreign keys), so relatedness judged by namespace is consistent with how every other checker reads the corpus, while directory placement would make the advisory’s meaning depend on filesystem layout the format doesn’t own. The flat-namespace failure mode is real but contained: the finding is kind == advisory-shaped (non-gating by typing), and the threshold is already per-repo configurable — a flat-namespace repo raises the threshold or lives with noise. A configurable keying mode (namespace or directory) stays a possible future widening under kind_field_extensible-style governance if a real flat-namespace corpus ever adopts the format; nothing in this file’s constraint text changes.

Open question, Needs Human Review: threshold_is_per_repo_setting deliberately leaves the actual number unset here — this file specifies that the threshold is configurable, not what a reasonable default is.

Requirements

Requirement: Property coverage mirror

Every property row SHALL be verified by exactly one dedicated scenario; ah sync SHALL derive one contract per VERIFIES link.

Scenario: fan_in_graph_sourced

  • WHEN node_with_precomputed_blast_radius_in_graph()
  • THEN advisor_source_calls(markdown_walker) == 0 — every count comes from graph’s artifact
  • VERIFIES [[refactor.fan_in_graph_sourced]]

Scenario: unrelated_fan_in_flagged

  • WHEN node_referenced_by_dependents_in_three_disjoint_top_level_namespaces()
  • THEN check(node) == found
  • VERIFIES [[refactor.unrelated_fan_in_flagged]]
  • WHEN node_referenced_only_by_dependents_within_its_own_namespace_subtree()
  • THEN check(node) == clean
  • VERIFIES [[refactor.related_fan_in_not_flagged]]

Scenario: narrow_diff_flagged_low_fan_in

  • WHEN changeset_touching_one_of_five_constraints_owned_by_a_node_with_fan_in(1)
  • THEN check(node) == found — flagged even though fan-in alone wouldn’t trigger it
  • VERIFIES [[refactor.narrow_diff_flagged_low_fan_in]]

Scenario: emitted_finding_shape

  • WHEN found state reached for node N with dependent_count 4, 2 unrelated
  • THEN emits(found) == {node_id: N, dependent_count: 4, unrelated_namespace_count: 2, suggested_split: true}
  • VERIFIES [[refactor.emitted_finding_shape]]

Scenario: threshold_read_from_config

  • WHEN same_node_same_changeset_under_two_repo_configs_with_different_high_fan_in_values()
  • THEN flagged(config_A) != flagged(config_B) — the decision follows the repo’s configured threshold, never a constant embedded in this file
  • VERIFIES [[refactor.threshold_read_from_config]]