check

package
v0.28.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

Documentation

Overview

Package check validates an in-memory aiwf tree and returns findings.

Each check is a small pure function from the tree (and its load errors) to a slice of findings. Findings carry a code, a severity, a message, and optional context (path / entity id / subcode). Run composes all eight checks plus per-file load-errors-as-findings into a single slice.

"Errors are findings, not parse failures": Run never returns an error. A load error becomes a load-error finding; a malformed entity becomes a frontmatter-shape finding; the tree is loaded and validated as far as it can go.

Index

Constants

View Source
const (
	CodeACsShape                        = "acs-shape"
	CodeACsTitleProse                   = "acs-title-prose"
	CodeACsTDDAudit                     = "acs-tdd-audit"
	CodeMilestoneDoneIncompleteACs      = "milestone-done-incomplete-acs"
	CodeMilestoneCancelledIncompleteACs = "milestone-cancelled-incomplete-acs"
	CodeACsBodyCoherence                = "acs-body-coherence"
	CodeMilestoneDoneZeroACs            = "milestone-done-zero-acs"
	CodeACsEmptyBodyOnStart             = "acs-empty-body"
)

Finding codes emitted by this file. Typed per G-0129 so the compiler closes on rename / retire across emit sites and tests.

View Source
const (
	CodeTerminalEntityNotArchived = "terminal-entity-not-archived"
	CodeArchiveSweepPending       = "archive-sweep-pending"
	CodeArchivedEntityNotTerminal = "archived-entity-not-terminal"
)

Finding codes emitted by this file. Typed per G-0129.

View Source
const (
	CodeLoadError            = "load-error"
	CodeCasePaths            = "case-paths"
	CodeIDsUnique            = "ids-unique"
	CodeRoadmapCaseCollision = "roadmap-case-collision"
	CodeFrontmatterShape     = "frontmatter-shape"
	CodeIDPathConsistent     = "id-path-consistent"
	CodeStatusValid          = "status-valid"
	// CodePriorityValid is the G-0078/E-0066 closed-set check for the
	// `priority` field: a present value outside {urgent, high, medium,
	// low} fires this code. See internal/check/priority_valid.go.
	CodePriorityValid = "priority-valid"
	// CodePriorityNotApplicable is the G-0078/E-0066 scope check for the
	// `priority` field: a present value on a kind that does not carry
	// its own priority (entity.CarriesOwnPriority) fires this code. See
	// internal/check/priority_not_applicable.go.
	CodePriorityNotApplicable   = "priority-not-applicable"
	CodeRefsResolve             = "refs-resolve"
	CodeNoCycles                = "no-cycles"
	CodeTitlesNonempty          = "titles-nonempty"
	CodeADRSupersessionMutual   = "adr-supersession-mutual"
	CodeGapAddressedHasResolver = "gap-addressed-has-resolver"
	// CodeBodyProseID is the G-0184 body-prose id-shape rule. Subcodes:
	// malformed-shape, unresolved, unresolved-milestone, unresolved-ac.
	// See internal/check/body_prose_id.go for the classifier shape.
	CodeBodyProseID = "body-prose-id"
	// CodeSkillBodyID is the G-0299 skill-body id-reference rule: a shipped
	// SKILL.md body must cite no real (digit-bearing) entity id. The mirror
	// image of body-prose-id; inert in a consumer repo (no skill-source
	// tree). See internal/check/skill_body_id.go.
	CodeSkillBodyID = "skill-body-id"
)

Finding codes emitted by this file. Typed per G-0129 so the compiler closes on rename / retire across emit sites and tests.

View Source
const (
	CodeProvenanceTrailerIncoherent     = "provenance-trailer-incoherent"
	CodeProvenanceForceNonHuman         = "provenance-force-non-human"
	CodeProvenanceActorMalformed        = "provenance-actor-malformed"
	CodeProvenancePrincipalNonHuman     = "provenance-principal-non-human"
	CodeProvenanceOnBehalfOfNonHuman    = "provenance-on-behalf-of-non-human"
	CodeProvenanceAuthorizedByMalformed = "provenance-authorized-by-malformed"
	CodeProvenanceAuthorizationMissing  = "provenance-authorization-missing"
	CodeProvenanceAuthorizationEnded    = "provenance-authorization-ended"
	CodeProvenanceNoActiveScope         = "provenance-no-active-scope"
	CodeProvenanceAuditOnlyNonHuman     = "provenance-audit-only-non-human"

	// I2.5 step 7b: pre-push trailer audit (G24). Surfaces the
	// audit-trail hole when a manual `git commit` lands on entity
	// files without an aiwf-verb: trailer. Warning, not error: the
	// user's intended response is `aiwf <verb> --audit-only --reason
	// "..."` which fills the hole without rewriting history.
	CodeProvenanceUntrailedEntityCommit = "provenance-untrailered-entity-commit"

	// Companion to the above: emitted once when the untrailered-
	// audit scope cannot be determined (the branch has no upstream
	// and the operator passed no `--since <ref>`). The audit is
	// skipped — scanning all of HEAD on a long-lived branch with
	// many merges from trunk produces a flood of warnings against
	// commits that are someone else's responsibility. The hint
	// names the two opt-in paths (configure upstream, or pass
	// --since) so the operator can re-enable a deliberate scan.
	CodeProvenanceUntrailedScopeUndefined = "provenance-untrailered-scope-undefined"
)

Provenance finding codes — the I2.5 standing rules from docs/design/provenance-model.md §"`aiwf check` rules". Each fires on commit-history audit, not on tree state.

View Source
const CodeAreaCoverageNoPaths = "area-coverage-no-paths"

CodeAreaCoverageNoPaths is emitted by AreaCoverage when coverage_roots is declared but no area declares any `paths:` — the operator opted into coverage but the path oracle is dormant, so the check is inert. Surfaced rather than silently no-op'd (M-0185/AC-8).

View Source
const CodeAreaCoverageRootMissing = "area-coverage-root-missing"

CodeAreaCoverageRootMissing is emitted by AreaCoverage for a declared coverage root that resolves to no directory (the path does not exist, or names a file) — dead config, the coverage analogue of area-dead-glob. A silently-skipped dead root gives the operator false confidence that coverage is active (M-0185/AC-8).

View Source
const CodeAreaDeadGlob = "area-dead-glob"

CodeAreaDeadGlob is the finding code emitted by AreaDeadGlob. Typed per G-0129 so the compiler closes on rename / retire across the emit site and tests.

View Source
const CodeAreaMistag = "area-mistag"

CodeAreaMistag is the finding code emitted by AreaMistag. Typed per G-0129 so the compiler closes on rename / retire across the emit site and tests.

View Source
const CodeAreaOverlap = "area-overlap"

CodeAreaOverlap is the finding code emitted by AreaOverlap. Typed per G-0129 so the compiler closes on rename / retire across the emit site and tests.

View Source
const CodeAreaRequired = "area-required"

CodeAreaRequired is the finding code emitted by AreaRequired. Typed (like CodeAreaUnknown, per G-0129) so the compiler closes on rename / retire across the emit site and tests.

View Source
const CodeAreaUnknown = "area-unknown"

CodeAreaUnknown is the finding code emitted by AreaUnknown. Typed per G-0129 so the compiler closes on rename / retire across the emit site and tests.

View Source
const CodeAreaUnslotted = "area-unslotted"

CodeAreaUnslotted is the finding code emitted by AreaCoverage for an unslotted project directory. Typed per G-0129 so the compiler closes on rename / retire across the emit site and tests.

View Source
const CodeDependsOnCancelled = "depends-on-cancelled"

CodeDependsOnCancelled is the finding code emitted by dependsOnCancelled. Typed per G-0129.

View Source
const CodeEntityBodyEmpty = "entity-body-empty"

CodeEntityBodyEmpty is the finding code emitted by entityBodyEmpty. Typed per G-0129.

View Source
const CodeEntityIDNarrowWidth = "entity-id-narrow-width"

CodeEntityIDNarrowWidth is the finding code emitted by entityIDNarrowWidth. Typed per G-0129.

View Source
const CodeEpicActiveNoDraftedMilestones = "epic-active-no-drafted-milestones"

CodeEpicActiveNoDraftedMilestones is the finding code emitted by epicActiveNoDraftedMilestones. Typed per G-0129.

View Source
const CodeEpicTerminalNonTerminalChildren = "epic-terminal-non-terminal-children"

CodeEpicTerminalNonTerminalChildren is the finding code emitted by epicTerminalNonTerminalChildren. Typed per G-0129.

View Source
const CodeFSMHistoryConsistent = "fsm-history-consistent"

CodeFSMHistoryConsistent is the finding code emitted by FSMHistoryConsistent (and fsm_history_walker.go's historyWalkErrorFindings). Typed per G-0129.

View Source
const CodeMilestoneTDDUndeclared = "milestone-tdd-undeclared"

CodeMilestoneTDDUndeclared is the finding code emitted by milestoneTDDUndeclared. Typed per G-0129 so the compiler closes on rename / retire across the emit site, the strict bumper, and tests.

View Source
const CodeTrailerVerbUnknown = "trailer-verb-unknown"

CodeTrailerVerbUnknown fires when a commit carries `aiwf-verb: <value>` whose value is not in the closed set of verb paths registered in the running binary's Cobra command tree.

The kernel principle "framework correctness must not depend on the LLM's behavior" assumes trailer values are mechanically validated. Before G-0150 they were not — an LLM-driven session fabricated `aiwf-verb: implement` on a hand-rolled Conventional- Commits code commit; every gate (pre-commit `aiwf check --shape-only`, pre-push `aiwf check`, golangci-lint, `go test`) passed. The fabricated trailer would have polluted `aiwf history` projections by misrepresenting a hand-rolled code commit as a kernel-verb invocation.

Severity was warning at G-0150 landing time so the rule introduced without retroactive breakage of existing fabricated trailers in history. G-0218 Patch 1 closed the composition-time gap with a `commit-msg` git hook (HookInstallSHA below); G-0218 Patch 2 (this file) tightens severity to error for commits whose ancestry includes the hook-install SHA. Pre-hook history stays at warning — rewriting those SHAs would invalidate addressed_by_commit refs.

Closes G-0150.

View Source
const CodeUnexpectedTreeFile = "unexpected-tree-file"

CodeUnexpectedTreeFile is the finding code emitted by TreeDiscipline for files under work/ that don't match a recognized entity shape. Typed per G-0129.

View Source
const HookInstallSHA = "0baed90b951f3d6e755a44ca427b7e01e90c2f5c"

HookInstallSHA is the full SHA of the commit that installed the commit-msg hook materialization in internal/initrepo (G-0218 Patch 1). Commits descending from this SHA either pre-date the hook in the operator's local clone (acceptable — operator hadn't run `aiwf update` yet) or bypassed the hook via `--no-verify` / git plumbing (the policy violation this rule's post-cutoff severity tightening targets).

The CLI gather layer at internal/cli/check/provenance.go walks `git rev-list HookInstallSHA..HEAD` once per check invocation to build the post-cutoff SHA set. When HookInstallSHA is unreachable from HEAD (shallow clone, fork that diverged before the hook landed), the walk yields an empty set and every finding stays at warning — the fallback preserves the G-0150 baseline so divergent histories aren't retroactively broken.

G-0218 Patch 2.

Variables

View Source
var CodeIDRenameUntrailered = codespkg.Code{
	ID:    "id-rename-untrailered",
	Class: codespkg.ClassBranchChoreography,
}

CodeIDRenameUntrailered is the typed kernel-code descriptor for the id-rename-untrailered finding (M-0160/AC-4). The finding fires when a commit between merge-base(HEAD, trunk) and HEAD renames an id-bearing entity file AND lacks an `aiwf-verb` trailer in the rename-class closed set (retitle / rename / reallocate / archive / move per `renameClassVerbs` below, mirrored from internal/gitops/refs.go::renameVerbs).

Catches the operator-discipline gap CLAUDE.md §"Id-collision resolution at merge time" documents: an operator resolves a trunk-collision via inline `git mv` instead of `aiwf reallocate <id-or-path>`. The immediate trunk-collision finding clears (git's rename detection paired the move via G-0167's trailer-driven path or G-0109's cumulative-similarity fallback), but the kernel trailer history misses the renumber event: `aiwf history G-old` doesn't bridge to the new id, cross-references in body prose aren't rewritten, and any future check rule keyed on `aiwf-verb: reallocate` doesn't see the rename.

The code carries [codes.ClassBranchChoreography] — the layer-4 kernel carve-out per ADR-0011 — so the rule joins the branch-policing finding set alongside isolation-escape, which polices the AI-actor-on-wrong-branch shape of the same trunk-collision-resolution discipline surface.

Severity is warning at first land (per the M-0106 / G-0150 chokepoint-rule precedent); a future decision (recorded as a D-NNN) may tighten to error after one epic of usage. This milestone does not pre-commit the tightening timing.

Closes the CLAUDE.md §Id-collision chokepoint hint.

View Source
var CodeIsolationEscape = codespkg.Code{ID: "isolation-escape", Class: codespkg.ClassBranchChoreography}

CodeIsolationEscape is the typed kernel-code descriptor for the isolation-escape finding (M-0106, closes G-0099). The finding fires when an AI-actor's commit lands on a branch that doesn't match its active scope's recorded aiwf-branch: trailer — i.e., the commit "escaped" its assigned ritual branch.

The code carries [codes.ClassBranchChoreography] — the layer-4 kernel carve-out per ADR-0011 — so the branch-policing finding set is enumerable independently of structural / legality codes.

Severity is warning at first land (per M-0106 spec); a future decision (recorded as a D-NNN) may tighten to error after one epic of usage. This milestone does not pre-commit the tightening timing.

The finding is the post-hoc complement of M-0103's verb-time preflight: the preflight refuses bad-dispatch up front; the finding catches drift after dispatch (subagent escaped via `git checkout main`, `cd ..`, `git -C <other-path>`, or a manual cherry-pick that violates the scope-branch coupling). Together the two surfaces give defense in depth against G-0099's "commits ended up on the wrong branch" failure mode.

View Source
var CodeIsolationEscapeOracleFailure = codespkg.Code{ID: "isolation-escape-oracle-failure", Class: codespkg.ClassBranchChoreography}

CodeIsolationEscapeOracleFailure is the advisory finding code surfacing oracle partial-coverage states (M-0161/AC-3 / G-0203 / D-0019). One finding fires per per-ref failure accumulated during BranchOracle construction, naming the ref and the underlying failure mode (ref-resolution-failed today; shallow-clone and reflog-disabled added by AC-4 and AC-5).

Severity is warning at first land per the M-0125 ratchet pattern: surface advisory at first, tighten to error after one epic of usage if the false-positive rate stays low. The contract is fail-shut on rule correctness — the [isolation-escape] rule does NOT fire on commits whose branch resolution was lost to a failed ref, so the advisory exists for operator visibility, not as a blocker. See D-0019 for the fail-shut-on-correctness / fail-open-on-coverage contract.

View Source
var CodeIsolationEscapeOrphanedAICommit = codespkg.Code{ID: "isolation-escape-orphaned-ai-commit", Class: codespkg.ClassBranchChoreography}

CodeIsolationEscapeOrphanedAICommit is the warning finding code surfacing AI-actor commits orphaned by non-fast-forward updates ("force-push") on ritual branches (M-0161/AC-5 / G-0205). One finding fires per orphaned AI commit detected via reflog walk; the kernel cannot determine from the orphan alone whether it was on the correct branch at force-time (the rewrite removed the audit trail), so the rule surfaces the orphan for operator review with hint text pointing at `aiwf acknowledge illegal` for deliberate sovereign cleanup.

Severity is warning per the M-0125 ratchet pattern; future D-NNN may tighten to error after one full epic of usage.

Composes with AC-3: when core.logAllRefUpdates=false the reflog is absent and detection cannot run — that case rides AC-3's isolation-escape-oracle-failure advisory with Capability "reflog-disabled" rather than introducing a separate finding code, per the D-0019 fail-shut-on-correctness /fail-open-on-coverage contract.

View Source
var CodeIsolationEscapeShallowClone = codespkg.Code{ID: "isolation-escape-shallow-clone", Class: codespkg.ClassBranchChoreography}

CodeIsolationEscapeShallowClone is the warning finding code surfacing shallow-clone-induced total-coverage failure (M-0161/AC-4 / G-0204). One finding fires when the repository is shallow per `git rev-parse --is-shallow-repository`, naming the remediation directly: unshallow with `git fetch --unshallow` (or in CI, `actions/checkout@vN` with `fetch-depth: 0`).

Severity is warning, NOT advisory — a shallow clone is a total-coverage failure (not a per-ref partial failure as AC-3's isolation-escape-oracle-failure tracks), so the operator-visibility weight is higher. The deliberate exception to D-0019 Alternative D's "ride the typed slice" rule per the AC-4 body line 292.

Per the AC-4 fail-shut-on-correctness contract: on shallow, the per-SHA branch map is left EMPTY (no false positives from half-walked first-parent indexes), so the isolation-escape rule stays silent regardless of what the shallow window would otherwise expose. The new code's job is to make that coverage gap mechanically visible.

View Source
var CodePromoteOnWrongBranch = codespkg.Code{ID: "promote-on-wrong-branch", Class: codespkg.ClassBranchChoreography}

CodePromoteOnWrongBranch is the warning finding code that fires when an activating-promote commit lands on a branch other than the entity's expected parent branch (M-0161/AC-8 / G-0209). One finding per violating commit; per-SHA dedup is not applied because each commit is a distinct activation event.

Severity is warning per the M-0125 ratchet pattern. The AC-8 body's "future D-NNN may tighten to error after one epic of usage" path is consistent with M-0106's same trajectory.

Composes with AC-3 fail-shut: if the parent-branch expectation can't be computed (parent lookup failed, tree truncated, non-ritual entity kind), the rule stays silent rather than firing a false positive.

Override paths (shared with AC-5 and AC-6):

  • `aiwf acknowledge illegal <sha> --reason "..."` silences post-hoc via the shared ackedSHAs map.
  • `aiwf-force: <reason>` trailer on the promote commit itself silences per-commit (existing override pattern).
View Source
var CodeProvenanceAuthorizationOutOfScope = codespkg.Code{ID: "provenance-authorization-out-of-scope", Class: codespkg.ClassLegality}

CodeProvenanceAuthorizationOutOfScope is the typed kernel-code descriptor for the out-of-scope refusal: an authorized agent's verb is refused when the target is not reachable from the active scope-entity via D-0006's three edges. It declares [codes.ClassLegality] (D-0011 / ADR-0012) — the marker the legality set is enumerated from — because the refusal is a verb-time legality violation named by the global scope-reach spec rule (ADR-0013, E-0037).

The code is dual-emitted: the verb-time gate ([verb.ScopeOutOfReachError]) and this package's check-time audit (below) raise the same code for the same violation at two surfaces. Consumers read the [codes.Code.ID] string; the .ID suffix at use sites is the only change from the prior bare-string-const era.

Functions

func AnyAreaHasPaths added in v0.18.0

func AnyAreaHasPaths(areas []AreaPaths) bool

AnyAreaHasPaths reports whether at least one declared area carries a `paths:` glob. The CLI seam uses it to gate the (full-history, multi-second) gather + AreaMistag entirely: with no paths-carrying area, mistag can produce nothing (AreaMistag early-returns nil), so walking history is pure waste. Gating here keeps `aiwf check` from eating a git-log walk for a feature the consumer hasn't opted into — the common default.

func ApplyArchiveSweepThreshold added in v0.8.0

func ApplyArchiveSweepThreshold(findings []Finding, threshold int, set bool, count int)

ApplyArchiveSweepThreshold bumps the aggregate `archive-sweep-pending` finding from warning to error when the consumer has set `archive.sweep_threshold` in aiwf.yaml and the pending-sweep count **strictly exceeds** that threshold (M-0088 AC-2). Mutates the findings slice in place; no-op when set=false (default-permissive) or when count ≤ threshold (consumer's declared ceiling not breached).

The escalation rewrites the aggregate's Message so the human reading `aiwf check` output sees both the count and their declared threshold cited explicitly. Per-file `terminal-entity-not-archived` leaf findings are NOT escalated — the aggregate is the single actionable signal; escalating leaves would flood the operator with duplicate "this gap is pending" warnings once they have already seen the aggregate.

Per ADR-0004 §"Drift control" (layer 2): "Configurable hard threshold. aiwf.yaml's archive.sweep_threshold (default unset) flips the advisory finding to blocking past the named count."

Callers run this AFTER Run() (or after appending the rule's findings to their own slice) so the rule's emission stays config- agnostic and the strictness bump is a separate, testable transform. The threshold is read via config.Config.ArchiveSweepThreshold; the count is the number of pending sweeps (i.e. the same value the aggregate's Message already names).

func ApplyAreaRequiredStrict added in v0.18.0

func ApplyAreaRequiredStrict(findings []Finding, required bool)

ApplyAreaRequiredStrict bumps the severity of the area-axis findings from warning to error when required=true. Mutates the findings slice in place. The escalation mirrors ApplyTDDStrict: the rules stay config-agnostic (always emit at warning), and the strictness bump is a separate, testable post-pass composed at the CLI layer where `areas.required` is in scope.

Two axes escalate together under `areas.required: true`:

  • the entity-tag axis (M-0178/AC-7): present-but-undeclared `area` fires area-unknown — escalated here so the pre-push hook blocks it too. (Empty area is the separate area-required error.)
  • the path-claim axis (M-0180): a dead path glob fires area-dead-glob and two areas claiming one directory fire area-overlap — both escalated here so a monorepo that opted into strictness cannot push an area pointing at nothing or an ambiguous path oracle.
  • the coverage axis (M-0185): an unslotted project directory fires area-unslotted, a declared root that resolves to no directory fires area-coverage-root-missing, and coverage declared with no paths fires area-coverage-no-paths — all escalated here so a monorepo that opted into strictness cannot push a project that no area claims, nor a dead/dormant coverage configuration.

With required off, all stay warnings (byte-for-byte the pre-knob behavior). The bumper is intentionally scoped: codes outside the escalated area set (area-unknown, area-dead-glob, area-overlap, area-unslotted, area-coverage-root-missing, area-coverage-no-paths) pass through unchanged regardless of the flag.

func ApplyTDDStrict

func ApplyTDDStrict(findings []Finding, strict bool)

ApplyTDDStrict bumps the severity of the TDD-strictness finding set from warning to error when strict=true (M-066/AC-2, G-0268). Mutates the findings slice in place. The function is the single source of truth for which codes are covered by `aiwf.yaml: tdd.strict`: `entity-body-empty` (M-066) and `milestone-tdd- undeclared` (G-0268). The bumper is intentionally narrow: codes outside this set pass through unchanged regardless of the flag.

entity-body-empty findings against a born-complete kind (entity.IsBornComplete — gap/decision/adr/contract) are already SeverityError as emitted by entityBodyEmpty, independent of strict (G-0326); this bumper re-applying SeverityError to them under strict=true is a harmless no-op. Only epic/milestone (and the AC subcode) findings are actually escalated by this function.

Callers run this AFTER `Run` (or after appending the rule's findings to their own slice) so the rule's emission stays config-agnostic and the strictness bump is a separate, testable transformation.

func CountPendingSweep added in v0.8.0

func CountPendingSweep(t *tree.Tree) int

CountPendingSweep returns the number of terminal-status entities still in the active tree (i.e. the count of pending sweeps). Uses the shared `isPendingSweep` predicate so it cannot drift from `terminalEntityNotArchived`'s definition.

Exported so callers outside the check package can read the value; it is the same number the aggregate finding's Message names.

func DisputedTrunkIDs added in v0.26.0

func DisputedTrunkIDs(t *tree.Tree) []trunk.ID

DisputedTrunkIDs returns the subset of t.TrunkIDs that name an id also present in the working tree (t.Entities/t.Stubs) at a different, non-archive-sweep-equivalent path — the candidate set idsUnique's trunk-collision rule might need a rename exemption for.

Computed entirely in memory, at zero git-subprocess cost, so a cmd dispatcher can gate an expensive trunk-side git rename-detection call behind "is there anything to even investigate" (ADR-0031): empty result skips the git work entirely; the common case, every push. idsUnique itself consumes this same predicate for its final exemption check, so the two can never drift apart — a diverging dispatcher-side gate could otherwise skip the git call in a case the rule would have needed it for.

func EmptyRequiredSections added in v0.24.0

func EmptyRequiredSections(k entity.Kind, body []byte) []string

EmptyRequiredSections returns the names of kind's load-bearing top-level body sections (per requiredSectionsByKind) that ARE PRESENT in body but empty per isAllWhitespaceOrHeadings. A section whose heading is missing outright is not "empty" in this rule's sense — the same stance entityBodyEmpty has always taken (a body using an unrecognized heading shape is a different problem, not this one). Returns nil when kind has no required-sections entry, or when every present required section carries content.

Shared by the `aiwf add` verb-time gate for born-complete kinds (internal/verb/add.go, G-0326) so the verb-time refusal and this file's entityBodyEmpty check rule can never drift out of agreement on what "empty" means — both read the same body-parsing helpers.

func GatherEntityPaths added in v0.18.0

func GatherEntityPaths(ctx context.Context, root string) map[string]map[string]bool

GatherEntityPaths walks HEAD-reachable history and returns, per canonical root entity id, the set of repo-relative paths that entity's commits touched — gathered via the `aiwf-entity:` commit trailer. A commit contributes its full touched-file set to every entity it is trailered to; composite acceptance-criterion trailers (M-NNNN/AC-N) roll up to the parent milestone so an AC's code lands in its milestone's set.

The gather is deliberately UNFILTERED: it unions every touched path, planning files and project code alike. The area-glob filtering that decides mistag lives downstream in AreaMistag (M-0181/AC-2); keeping gather and filter separate makes each a single testable unit.

Returns nil for a non-git root or empty history. Trailer ids are canonicalized at ingest so a narrow-legacy trailer (`aiwf-entity: G-123`, the pre-ADR-0008 gap width) and a canonical-width lookup (`G-0123`) agree.

func HasErrors

func HasErrors(fs []Finding) bool

HasErrors reports whether the slice contains any error-severity finding.

func HintFor

func HintFor(code, subcode string) string

HintFor returns the canonical action hint for a given code+subcode. Returns "" when no hint is registered. Verb-side findings (e.g., reallocate-body-reference) call this so the human-facing suggestion stays in one place.

func SortFindings

func SortFindings(fs []Finding)

SortFindings orders findings by severity (errors first), then code, then path. Stable so callers that pre-sort within a code group keep their order. Exported for callers that merge findings from multiple sources (e.g. the CLI's `aiwf check` after appending contract findings to the entity-tree slice).

func WalkAcknowledgedMistags added in v0.18.0

func WalkAcknowledgedMistags(head []HeadCommit) map[string]bool

WalkAcknowledgedMistags returns the set of canonical entity ids acknowledged by an `aiwf-verb: acknowledge-mistag` commit (via that commit's `aiwf-entity:` trailer) among head's commits. AreaMistag exempts these entities — the sovereign-traced escape valve for legitimate cross-cutting work (M-0181/AC-6, written by `aiwf acknowledge mistag`).

Returns nil for an empty head. Entity ids are rolled up to their root and canonicalized at ingest, so a narrow-legacy trailer and a canonical-width entity lookup agree (the WalkAcknowledgedSHAEntities ingest convention). The verb-value match is the contract this shares with the verb that emits it; the M-0181/AC-6 end-to-end test (ack → suppressed) is the drift guard tying the two literals together.

G-0372 Fix 2: derives from the shared HEAD walk (head) instead of spawning its own `git log HEAD` — the CLI gather layer at internal/cli/check/check.go::Run computes WalkHeadCommits once (M-0216/AC-5) and now threads it here too, mirroring WalkCherryPicks / the other FromHead-converted gathers. A nil/empty head yields nil, the same "no commits / no acks" signal the prior git-walk returned.

func WalkAcknowledgedSHAEntities added in v0.12.0

func WalkAcknowledgedSHAEntities(ctx context.Context, root string, head []HeadCommit) map[string]map[string]bool

WalkAcknowledgedSHAEntities is the per-(SHA, entity) variant of WalkAcknowledgedSHAs, added by G-0231 item 3 to feed RunUntrailedAudit's per-(commit, entity) finding shape with a matching per-(commit, entity) ack shape. Returns map[fullSHA]map[canonicalEntityID]bool.

Only ack commits carrying BOTH `aiwf-force-for: <sha>` AND `aiwf-entity: <id>` count. SHA-only acks (the legacy seven rules' blanket shape via WalkAcknowledgedSHAs) do NOT suppress findings here — the per-(commit, entity) shape requires both sides. The verb's `git diff-tree` write-time check is what gives the (SHA, entity) pair its kernel-attested binding.

Returns nil for non-git directories and empty histories; the consumer treats nil and an empty map identically (no exemptions).

M-0216/AC-5: derives from the shared HEAD walk (head) — see WalkAcknowledgedSHAs for the retained-name / single-compute rationale.

func WalkAcknowledgedSHAs added in v0.12.0

func WalkAcknowledgedSHAs(ctx context.Context, root string, head []HeadCommit) map[string]bool

WalkAcknowledgedSHAs walks HEAD's reachable history for commits carrying an `aiwf-force-for: <sha>` trailer (per M-0136) and returns the set of target SHAs. The set is consumed by illegalTransitionFindings, RunIsolationEscape, RunTrailerVerbUnknown, and RunIDRenameUntrailered (M-0160/AC-4) to exempt commits that have been retroactively acknowledged via `aiwf acknowledge illegal`.

Returns nil for non-git directories and empty histories; the consumers treat nil and an empty map identically (no exemptions). Per-SHA scoping is the closed-set guarantee — an acknowledgment for one SHA does NOT exempt findings against other commits.

The walk is HEAD-reachable (not --all) because the exemption is DAG-scoped: a cherry-picked acknowledgment on a branch that doesn't include the original violation must not exempt findings on this branch. HEAD's reachable set is precisely the set of commits this branch sees, so the exemption only applies when the acknowledgment's history actually contains the offending commit.

Reads via one `git log` subprocess + the gitops.ParseTrailers helper. Performance: O(reachable-commits) once per check invocation; for kernel-tree-sized repos under a second.

AC-3 caller convention: the CLI gather layer at internal/cli/check/check.go::Run calls this exactly once and passes the result to all four downstream rules through a uniformly-named ackedSHAs parameter (id-rename-untrailered added at M-0160/AC-4 as the fourth consumer). Rule-internal recomputes are forbidden by PolicyAcksHelperLift (violation class 3c).

M-0216/AC-5: derives from the shared HEAD walk (head) instead of spawning its own `git log HEAD` — the CLI gather layer computes WalkHeadCommits once and threads it in. The "Walk" name is retained because the acks_helper_lift policy (M-0159/AC-3) pins this exported symbol as the single ackedSHAs source; it now derives rather than walks. resolveFullSHA stays a git call (it resolves against the full object DB, which the in-memory HEAD set can't replicate). A nil/empty head yields nil — the same "no commits / no acks" signal the prior git-walk returned.

func WalkCherryPicks added in v0.12.0

func WalkCherryPicks(head []HeadCommit) map[string]bool

WalkCherryPicks walks HEAD's reachable history for commits that are sovereign-human cherry-pick re-authors per the both-signals contract pinned in the RunIsolationEscape docstring at internal/check/isolation_escape.go:67-89 (the canonical contract; keep edits there, not duplicated here). This helper is the gather-side derivation that produces the cherryPicked map the rule consumes; the contract is rule-side.

Derivation steps (one `git log` subprocess + an in-memory filter):

  1. `git log --pretty=format:%H<US>%ae<US>%ce<US>%B<RS> HEAD` emits one record per HEAD-reachable commit (SHA, author email, committer email, full body), null-byte-free, separated by ASCII unit (US) and record (RS) control chars.
  2. For each record, the commit qualifies iff author email AND committer email AND author email != committer email (the identity gap) AND the body matches cherryPickedMarkerRE (the `(cherry picked from commit <sha>)` marker line). Both signals required — see rule docstring for the rationale.
  3. Qualifying SHAs land in the returned map.

Returns nil for non-git directories and empty histories; the rule treats nil and an empty map identically (no exemptions). Performance: one git subprocess, O(reachable-commits) parse; for kernel-tree-sized repos under a second.

AC-6 caller convention: the CLI gather layer at internal/cli/check/provenance.go::RunProvenanceCheck calls this exactly once per check invocation and passes the resulting map to RunIsolationEscape's cherryPicked parameter (replacing the G-0202 nil-placeholder).

M-0216/AC-5: derives from the shared HEAD walk (head) — the author/committer emails and body it needs are carried on each HeadCommit, so the dedicated `git log HEAD` it used to spawn is gone. A nil/empty head yields nil (the prior "no commits" signal).

func WalkPostCutoffSHAs added in v0.12.0

func WalkPostCutoffSHAs(ctx context.Context, root string) map[string]bool

WalkPostCutoffSHAs walks `git rev-list HookInstallSHA..HEAD` once and returns the resulting SHA set. See file-level docstring above for failure-mode contracts. The caller is the CLI gather layer at internal/cli/check/provenance.go; the result threads through RunTrailerVerbUnknown's postCutoffSHAs parameter.

The production wrapper delegates to walkPostCutoffSHAsFromCutoff so tests can exercise the walker against fixture cutoff SHAs; pinning the live constant in production keeps the gather layer's call site terse and the cutoff knowledge concentrated in trailer_verb_unknown.go.

Types

type AreaPaths added in v0.18.0

type AreaPaths struct {
	Name  string
	Paths []string
}

AreaPaths is the config-agnostic projection of a declared area's name and its path globs. The path-axis checks take it from the CLI seam so the check package stays config-agnostic (the M-0171/AC-4 boundary): the pure check.Run never reads aiwf.yaml.

type BodyProseIndex added in v0.14.0

type BodyProseIndex struct {
	ByID        map[string]*entity.Entity
	Trunk       map[string]bool
	CrossBranch map[string][]trunk.RefHit
	// Collisions is t.CrossBranchCollisions verbatim (M-0259/AC-3): the
	// canonicalized-id set whose cross-branch hits diverge in content.
	// classifyBodyToken escalates a CrossBranch hit here to the
	// distinct cross-branch-collision subcode instead of the ordinary
	// cross-branch-pending one — both are non-blocking warnings (D-0036).
	Collisions map[string]bool
}

BodyProseIndex is the two-tier id-resolution view ScanBodyProseID consults.

ByID is the primary tier: canonicalized id → entity for every working-tree entity (active + archive) and stub. Stubs are included so a body referencing an entity whose file failed to parse resolves silently (the parse failure is already reported as a load-error finding; re-reporting via body-prose-id would be noise).

Trunk is the second tier (G-0241): the canonicalized id set observed on the configured trunk ref. A strict-form token that misses ByID but hits Trunk is silent — the id IS allocated, just not visible in this branch's working tree (typical case: an entity filed on trunk in another session while this branch is in flight). Resolution is thereby symmetric with allocation, where AllocateID already treats trunk ids as authoritative. Nil Trunk (in-memory test trees, no-remote repos, dispatchers that load without a trunk read) degrades to primary-tier-only behavior — the pre-G-0241 default.

CrossBranch is the third tier (M-0259/AC-2): canonicalized id → the cross-branch hits carrying it (see crossBranchIndex). Consulted only after both ByID and Trunk miss. Unlike Trunk, a hit here is VISIBLE — it fires the non-blocking cross-branch-pending subcode — because a sibling branch is provisional (it can be rebased, renamed, or abandoned before merging), unlike trunk which is authoritative.

func BodyProseIDIndex added in v0.12.0

func BodyProseIDIndex(t *tree.Tree) BodyProseIndex

BodyProseIDIndex builds the id-resolution index that ScanBodyProseID consumes from the tree's entities, stubs, and trunk-id set.

Exposed so verbs that scan planned-write body content at verb time (G-0184 verb-time scan) share the index with the tree-walking bodyProseID rule. Verbs should build the index once before the loop over planned files, then pass it to ScanBodyProseID per file.

type BranchOracle added in v0.12.0

type BranchOracle interface {
	FirstParentBranches(sha string) []string
	OracleErrors() []OracleErr
	// BranchOfSHA returns the current ritual-shape branch (or
	// "main") whose first-parent index contains sha. An empty
	// return means the SHA is not on any branch the oracle
	// knows about — either the branch was deleted, the SHA is
	// orphaned, or the SHA was never on a ritual ref.
	//
	// Used by M-0161/AC-6's rename-survival path:
	// `aiwf-branch-sha:` trailer values from authorize commits
	// resolve to whatever branch currently reaches that SHA,
	// regardless of how the branch has been renamed. A returned
	// empty string composes with AC-3's fail-shut-on-correctness
	// contract — the rule does not fire (no false positive when
	// binding is lost); the partial-coverage state surfaces via
	// the existing isolation-escape-oracle-failure advisory
	// when configured.
	//
	// Resolution is deterministic but order-dependent on the
	// underlying branchesBySHA index: when the SHA appears on
	// multiple branches, the first-listed entry wins. The
	// production gitBranchOracle iterates ritual refs in
	// for-each-ref order; tests using fakeOracle should not
	// rely on a particular branch being first when the SHA
	// has multiple owners (use single-owner fixtures or
	// extend the fake).
	BranchOfSHA(sha string) string
}

BranchOracle answers per-commit branch-reachability questions the isolation-escape rule needs but that scope.Commit does not carry. Implementations are supplied by the CLI gather layer (which has the git context); the check rule itself stays pure.

FirstParentBranches returns the set of ritual-shape branches the commit is reachable from along first-parent paths. The set MAY include "main" when a commit landed directly on the trunk. An empty/nil return means the commit is not on any branch the oracle knows about (treat as "unknown" — the rule does not fire on unknown-branch commits, since the kernel cannot confidently classify them as escaped).

OracleErrors returns per-ref construction failures accumulated at gather time (M-0161/AC-3 / G-0203). Empty slice ↔ every enumerated ref's first-parent index built cleanly; non-empty slice ↔ at least one ref failed and the rule's coverage is incomplete for those refs. Consumers (RunProvenanceCheck) surface one CodeIsolationEscapeOracleFailure finding per entry, naming the ref and the underlying error in the hint — see D-0019 for the fail-shut-on-correctness / fail-open-on-coverage contract that orders OracleErrors with FirstParentBranches.

type CommitDAG added in v0.21.0

type CommitDAG struct {
	// contains filtered or unexported fields
}

CommitDAG is an in-memory parent map of the repository's commit graph, including reflog-only (force-pushed-away) commits, built from a single `git rev-list --all --reflog --parents` subprocess. It answers ancestry queries in memory, replacing the per-pair `git merge-base --is-ancestor` fan-out the orphaned-AI-commit walk previously spawned (683 subprocesses on the kernel's own repo at the M-0215 baseline), and — via CommitDAG.FirstParentChain — the per-branch `git rev-list --first-parent` fan-out the isolation-escape oracle spawned (M-0216 AC-6). Built ONCE per check invocation and shared across both consumers (E-0053 / M-0216).

func BuildCommitDAG added in v0.21.0

func BuildCommitDAG(ctx context.Context, root string) (*CommitDAG, error)

BuildCommitDAG runs one `git rev-list --all --reflog --parents` and parses it into a SHA→parents map. The `--reflog` flag is load-bearing: the orphaned-AI-commit walk asks about commits that were force-pushed away and are no longer reachable from any ref, so a plain `--all` DAG would omit exactly the commits the walk inspects (`--all` is a superset of what the oracle's first-parent index needs, so the one DAG serves both). Returns an error only on a genuine git failure; callers treat that as "cannot determine ancestry / first-parent" and fall back to their per-call git path, matching the prior behavior.

func (*CommitDAG) FirstParentChain added in v0.21.0

func (d *CommitDAG) FirstParentChain(tip string) []string

FirstParentChain returns the SHAs along the first-parent path from tip back to a root — tip, then tip's first parent, then that commit's first parent, and so on — matching `git rev-list --first-parent <tip>` (newest-first). First parent is parents[0] because `git rev-list --parents` lists parents first-parent-first. A commit absent from the map (or a root) ends the chain. The seen-guard is defensive against a hypothetical cycle; a real git DAG is acyclic. Empty tip yields nil.

type Finding

type Finding struct {
	Code     string   `json:"code"`
	Severity Severity `json:"severity"`
	Message  string   `json:"message"`
	Path     string   `json:"path,omitempty"`
	Line     int      `json:"line,omitempty"`
	EntityID string   `json:"entity_id,omitempty"`
	Subcode  string   `json:"subcode,omitempty"`
	Hint     string   `json:"hint,omitempty"`
	Field    string   `json:"-"`
}

Finding is one structured report from a check. The finder fills in Code, Severity, and Message; Path and EntityID provide locator context where they apply; Subcode distinguishes variants of the same finding (e.g., "unresolved" vs "wrong-kind" within refs-resolve).

Line is a 1-based line number in the file at Path. It is filled in post-hoc by Run() based on the Field annotation each check sets; when the field cannot be located in the file (or no field applies), Line is 1 so editors still receive a clickable file:line link.

Hint is a one-line suggestion for what to change to clear the finding. It is set by Run() from a Code+Subcode → hint table; checks don't populate it directly, so the wording stays consistent.

Field is an internal annotation naming the YAML key the finding is "about" (e.g., "parent", "status"). It is not part of the JSON envelope; it exists so Run() can resolve a useful Line.

func AreaCoverage added in v0.18.0

func AreaCoverage(t *tree.Tree, areas []AreaPaths, coverageRoots []string) []Finding

AreaCoverage (warning) reports any immediate child directory of an operator-declared coverage root that is claimed by no declared area's `paths:` glob — an unslotted project (E-0044, M-0185). It is the *covering* law of the area↔directory partition: where AreaDeadGlob asserts no area column is empty and AreaOverlap asserts no directory row is claimed twice, coverage asserts every in-scope directory is claimed by some area. The monorepo-specific catch for a newly-added project that nobody slotted.

The universe is scoped and opt-in: the operator names the coverage root(s) (aiwf.yaml: areas.coverage_roots), and only the immediate child directories of those roots must tile. Directories outside any declared root are unscoped and never flagged, so the single-project / semantic-section repo — which declares no coverage root — is never flagged wholesale. The model is deliberately not total-partition: the filesystem remainder (README, docs/, top-level config) is legitimately uncovered.

Activation and the opted-in-but-undeliverable diagnostics:

  • no coverage root declared — fully inert; the knob's presence is the activation signal, so absence means the law does not apply (M-0185/AC-4);
  • coverage root declared but no area declares any `paths:` — the path oracle is dormant, so instead of silently doing nothing the check emits one area-coverage-no-paths finding: the operator opted in but the prerequisite is missing (M-0185/AC-8);
  • a declared root that resolves to no directory (does not exist, or names a file) emits area-coverage-root-missing — dead config, the coverage analogue of area-dead-glob; a silently-skipped dead root would give false confidence that coverage is active (M-0185/AC-8).

Enumeration is single-level (one os.ReadDir per declared root) and reads the filesystem read-only; a transient/permission IO error yields no findings rather than failing (the roadmapCaseCollision precedent). Dot-prefixed immediate children (.git / .github / .claude / …) are skipped — hidden directories are tooling / VCS artifacts, never projects (the Unix dotfile convention), and .claude is materialized by aiwf itself, so flagging them would be a never-actionable false positive. The skip covers only HIDDEN dirs: a "." root still enumerates non-hidden top-level dirs (docs/, work/, node_modules/), so point coverage at a dedicated project-parent root (projects/, apps/) rather than "." unless every non-hidden top-level dir is genuinely a project. The "is this directory claimed?" test routes through the areamatch SSOT (M-0180/AC-1), the same matcher the path-axis checks use — no second matcher.

Composed at the CLI layer (internal/cli/check) with the declared areas and coverage roots sourced from config — the same seam AreaDeadGlob and AreaOverlap use — so the pure check.Run stays config-agnostic. Severity is warning, escalated to error under areas.required by ApplyAreaRequiredStrict.

func AreaDeadGlob added in v0.18.0

func AreaDeadGlob(t *tree.Tree, areas []AreaPaths) []Finding

AreaDeadGlob (warning) reports any declared area path glob that matches no real file or directory under the repo root — dead config: a renamed, deleted, or typo'd project path leaving that area's path oracle empty. The check is per-glob: each declared glob must locate at least one path, and each dead glob fires its own finding naming the area and the glob.

Reads the filesystem read-only through the area-glob matcher (areamatch, the SSOT introduced in M-0180/AC-1) and never fails on IO: an empty or unreadable root, or a glob that errors during the walk, is silently skipped (the roadmapCaseCollision precedent). Malformed globs are owned by config-load validation (Tier 1), not re-reported here.

Composed at the CLI layer (internal/cli/check) with the declared areas sourced from config — the same seam AreaUnknown uses — so the pure check.Run stays config-agnostic. Inert when no area declares a `paths:` glob (label-only / legacy string-form config), so a paths-less areas block fires nothing on the path axis (M-0180/AC-5). Severity is warning, escalated to error under areas.required by ApplyAreaRequiredStrict.

func AreaMistag added in v0.18.0

func AreaMistag(t *tree.Tree, areas []AreaPaths, touchedByEntity map[string]map[string]bool, ackedMistags map[string]bool) []Finding

AreaMistag (warning) reports an entity whose linked commits' work landed in a DIFFERENT area's path territory than the one the entity is tagged to. It is the path-vs-tag consistency check: with `paths:` (M-0179) declared and the entity ↔ commit linkage aiwf records via the `aiwf-entity:` trailer (gathered by GatherEntityPaths), the touched-files-vs-glob comparison catches "filed against the wrong area, flew under the radar" — the failure label-only areas are blind to.

The entity's effective area comes from t.ResolvedArea (so a milestone is judged against its parent epic's area), matched against the declared globs via the areamatch SSOT. Several guards make the rule inert where it can't speak:

  • no area declares `paths:` → nothing to check against;
  • the entity is untagged, or carries the reserved `global` sentinel (inherently cross-cutting, ADR-0021) → skipped;
  • the entity's own area declares no `paths:` → can't locate "inside";
  • the entity has no linked commits / no touched paths → nothing to judge;
  • archived entities are out of scope (ADR-0004 §"check shape rules").

Severity is warning and NEVER escalates — unlike dead-glob / overlap, it is deliberately absent from ApplyAreaRequiredStrict, because legitimate cross-cutting work exists and the acknowledge path (M-0181/AC-6) is the sanctioned escape valve, not a strictness bump. Composed at the CLI layer with the gathered paths and declared areas, like the other path-axis rules.

ackedMistags is the set of canonical entity ids that have a sovereign `aiwf acknowledge mistag` commit in history (gathered by WalkAcknowledgedMistags, M-0181/AC-6); those entities are exempted — the operator has affirmed the cross-cutting work is intentional. A nil set exempts nothing.

func AreaOverlap added in v0.18.0

func AreaOverlap(t *tree.Tree, areas []AreaPaths) []Finding

AreaOverlap (warning) reports any directory claimed by more than one declared area — ambiguous attribution: two areas whose `paths:` globs both match a common path. The ambiguity would make the entity-touching checks (mistag M-0181, auto-derive M-0182) behave non-deterministically there, so the path oracle must be a partition (each directory claimed by at most one area). One finding per overlapping area-pair, naming both areas and a representative shared path.

Reads the filesystem read-only through the area-glob matcher (areamatch, M-0180/AC-1) and never fails on IO: an empty or unreadable root, or a glob that errors during the walk, is silently skipped (the roadmapCaseCollision precedent). Malformed globs are owned by config-load validation (Tier 1, areamatch.Validate), not re-reported here.

Composed at the CLI layer (internal/cli/check) with the declared areas sourced from config — the same seam AreaUnknown and AreaDeadGlob use — so the pure check.Run stays config-agnostic. Inert with fewer than two areas carrying paths. Severity is warning, escalated to error under areas.required by ApplyAreaRequiredStrict.

func AreaRequired added in v0.18.0

func AreaRequired(t *tree.Tree, declared []string, required bool) []Finding

AreaRequired (error) reports any non-archived entity of a self-tagging root kind (epic, ADR, gap, decision, contract) whose `area` frontmatter is empty, when the consumer has opted into strictness via `aiwf.yaml: areas.required: true` (M-0178). It is the present-at-all chokepoint for the 1:1 monorepo where every entity belongs to exactly one project — orthogonal to AreaUnknown, which polices present-⇒-declared.

The knob is the gate: with required false the rule emits nothing (no warning→error bump — it simply does not fire). The remediation is `aiwf set-area <id> <member>` (M-0183).

Three guards, in order:

  • Inert when required is false OR declared is empty (no `areas` block). The empty-declared guard is defensive — config.Load rejects required:true with zero members, so it is unreachable through the normal path, but the rule stays self-contained.
  • Milestone skip (load-bearing): a milestone's `area` is blanked at load and derived from its parent epic (tree.go, E-0043 / M-0171/AC-3), so an untagged milestone always reads area=="". Skipping KindMilestone is what makes an untagged epic fire exactly once rather than once per untagged milestone underneath it.
  • Archive scoping (ADR-0004 §"check shape rules"): archived entities are out of scope for active linting, matching the other shape-and-health rules.

Composed at the CLI layer (internal/cli/check) with the declared set and the required bool sourced from config — the same seam AreaUnknown uses — so the pure check.Run stays config-agnostic.

func AreaUnknown added in v0.17.0

func AreaUnknown(t *tree.Tree, declared []string) []Finding

AreaUnknown (warning) reports any non-archived entity whose `area` frontmatter value is present and non-empty but not a member of the declared set (`aiwf.yaml: areas.members`). It is the present-⇒-declared chokepoint for E-0043 — typo protection for the optional grouping tag, the authoritative surface a creation-time flag alone can't cover (a hand-edit or an `aiwf import` can introduce an undeclared area without passing through `aiwf add --area`).

Three behaviors fall out of the guards, in order:

  • Inert when declared is empty (no `areas` block): present `area` values parse but nothing validates, per M-0171's "the field is inert until a block is declared" contract.
  • Absence (empty `area`) is never evaluated: absent / explicit-null / empty all deserialize to "" and only a present, non-empty value can be "unknown".
  • Archive scoping (ADR-0004 §"check shape rules"): archived entities are out of scope for active linting, matching the other shape-and-health rules.

The rule reads the *stored* area, so only root kinds that carry their own `area` can fire; a milestone (area blanked at load, derived from its parent epic) never double-reports under a bad-area epic.

Composed at the CLI layer (internal/cli/check) with the declared set sourced from config — the same seam TreeDiscipline, the contract checks, and the tests-metrics check use — so the pure check.Run stays config-agnostic (the boundary M-0171/AC-4's metamorphic guard pins). Severity is warning with no strictness knob (E-0043 / M-0172 decision).

func FSMHistoryConsistent added in v0.9.0

func FSMHistoryConsistent(ctx context.Context, root string, t *tree.Tree, ackedSHAs map[string]bool, head []HeadCommit) []Finding

FSMHistoryConsistent is the kernel chokepoint that makes the per- entity status FSM a tree-invariant rather than just a verb- precondition (closes G-0132 when all of M-0130 lands). The rule walks every entity's commit history in DAG order, observes every status-change commit, and — once AC-2/3/4 land the per-subcode predicates — emits findings per violation.

M-0130 lands the rule incrementally:

  • AC-1 (this file): walker scaffolding. walkStatusChanges enumerates status-change observations across every entity in the tree via DAG-aware per-parent comparison. FSMHistoryConsistent returns no findings yet — the per-subcode predicates land in AC-2/3/4. The walker's correctness (per-parent comparison, rename tracking via --follow, single-commit and no-change short-circuits, multi-entity independence, branched-history phantom-transition avoidance) is pinned by the test suite in fsm_history_consistent_test.go.
  • AC-2: illegal-transition subcode — observation's (Prior, Next) is outside entity.AllowedTransitions and Trailers lacks aiwf-force.
  • AC-3: forced-untrailered subcode — sovereign-act-shape transition (per entity.IsSovereignActShape) without aiwf-force trailer.
  • AC-4: manual-edit subcode — catch-all: legal-in-FSM AND not sovereign-act-shape AND no aiwf-verb trailer. Includes audit- only suppression.
  • AC-5: hint table entries + SKILL.md rows (already landed).
  • AC-6: audit catalog update (legal-workflows-audit.md).

The rule is wired in internal/cli/check/check.go's Run() alongside RunProvenanceCheck, NOT in this package's Run(). The per-entity git-walk is too expensive for the per-commit pre-commit hook's policy-test path; pre-push and explicit `aiwf check` invocations get the full audit.

Walker design contract (the load-bearing correctness pin for AC-1):

The walker is DAG-aware, not linearization-aware. For each commit C that touched an entity's file, the walker compares C's status against the status at each of C's actual git parents (not against the linearization-neighbor commit in `git log --follow` output). The original AC-1 design walked linearization adjacency, which silently produced phantom transitions across branch boundaries — e.g., a retitle commit on a feature branch with status=proposed followed in `git log` order by a promote-to-active on a parallel branch would emit an "active → proposed" observation that corresponds to no real edit. Per-parent comparison eliminates the phantom by structurally restricting comparisons to actual parent- child edges in the DAG.

M-0159/AC-3: ackedSHAs is the gather-layer-computed map of retroactively-acknowledged commit SHAs (per WalkAcknowledgedSHAs in acks.go). The rule consumes it directly rather than recomputing internally — the CLI gather layer computes it once per check invocation and passes the result to all three rules that consume it. A nil/empty map is "no acknowledgments" (the rule polices normally).

func Run

func Run(t *tree.Tree, loadErrs []tree.LoadError) []Finding

Run executes every check against the tree and returns all findings, ordered first by severity (errors first), then by code, then by path. Per-file load errors from the tree loader are surfaced as load-error findings ahead of the regular checks.

Run also fills in Line (1-based) and Hint on every finding. Line is derived from the field name the check annotated; Hint is looked up from a Code+Subcode → hint table.

func RunIDRenameUntrailered added in v0.12.0

func RunIDRenameUntrailered(renames []UntrailedIDRename, ackedSHAs map[string]bool) []Finding

RunIDRenameUntrailered emits one warning finding per record in renames, minus those whose SHA appears in ackedSHAs.

The rule is pure: the gather layer (WalkUntrailedIDRenames) does all git work and filtering; the rule itself just maps records to findings. Same shape as the M-0159/AC-3 ack-helper-lift pattern: `ackedSHAs map[string]bool` carries the set of commits retroactively acknowledged via `aiwf acknowledge illegal`; per- SHA closed-set scoping; nil or empty map is "no acknowledgments."

Each record produces its own finding (no per-entity aggregation, mirrors M-0106/AC-10's per-commit firing). The finding's Path is the new (post-rename) path so the operator's editor can jump to the file that needs aiwf-reallocate retroactively; EntityID is the new id when known.

func RunIsolationEscape added in v0.12.0

func RunIsolationEscape(commits []scope.Commit, oracle BranchOracle, cherryPicked, ackedSHAs map[string]bool) []Finding

RunIsolationEscape applies the M-0106 branch-choreography rule against a commit history. The rule scans commits for those carrying both aiwf-actor: ai/... and aiwf-entity: <id> trailers, finds each candidate's active scope at the commit's time, and fires isolation-escape when the commit's branch does not match the scope's recorded aiwf-branch:.

commits must be ordered oldest-first (matching the RunProvenance convention). oracle supplies per-commit branch info; a nil oracle is treated as "no branch info available" and the rule returns silently — a graceful degradation for environments where the gather layer cannot determine commit branches (e.g., a bare repo fragment in a test fixture without ref history).

cherryPicked is the set of commit SHAs the gather layer identified as `git cherry-pick -x` re-authors of upstream commits: both (a) the commit body carries the `(cherry picked from commit <sha>)` marker line that `git cherry-pick -x` writes by default AND (b) the commit's git committer email differs from its git author email (the identity gap that `git cherry-pick` produces by default when a different identity re-authors the original — committer becomes the current user, author preserved from source). When a commit's SHA is in this set the rule treats it as a sovereign human re-author (corner case 8 / AC-6) and suppresses any isolation-escape finding against it; the audit trail lives in the committer-vs-author identity gap and the marker itself. A nil/empty map means "no cherry-pick info available"; the rule then polices as usual (no false negatives — only known-cherry-picks are suppressed).

The both-signals contract is the gather-side's per-commit derivation, implemented by check.WalkCherryPicks in internal/check/cherry_picks.go (M-0159/AC-6 / G-0202). Either signal alone is insufficient: a fabricated marker (no real cherry-pick) lacks the gap; an amended commit (gap without -x) lacks the marker. Real-git E2E coverage lives in internal/cli/integration/branch_scenarios_ac6_test.go.

Per-commit firing: each violating commit produces its own finding. No aggregation, no per-entity summary — the user wants the cardinality so each escaped commit is individually addressable (AC-10).

Algorithm (per commit, in chronological order):

  1. Skip if the commit is not an AI commit on an entity (must carry both aiwf-actor: ai/... and aiwf-entity: <id>).
  2. Find the most recent opened-scope commit on the same entity in the preceding commits. If none, skip (AC-9 — no scope, no policing). Cycle 3 will further filter on the scope's current state (paused → silent per AC-5).
  3. Read the scope's aiwf-branch: trailer. If absent — legacy pre-M-0102 scope — skip (non-retroactive per epic §"Out of scope").
  4. Ask the oracle for the commit's branch set. If empty — "unknown branch" — skip (do not fire on commits the kernel cannot confidently classify).
  5. If the bound branch is in the commit's branch set, silent (AC-4 — commit rides bound branch).
  6. Otherwise fire isolation-escape with the commit's SHA, the entity id, the bound branch, and the actual branch list as evidence.

M-0159/AC-3: ackedSHAs carries the set of commit SHAs that have been retroactively acknowledged via `aiwf acknowledge illegal`. The CLI gather layer computes the map once per check invocation (via WalkAcknowledgedSHAs in acks.go) and passes it to all three rules that consume it; rule-internal recompute is forbidden. A commit whose SHA appears in ackedSHAs is exempted from this rule's finding even when it would otherwise fire (same per-SHA closed-set scoping the M-0136/AC-2 illegal-transition exemption uses). Nil or empty map means "no acknowledgments" and the rule polices as usual.

func RunOrphanedAICommits added in v0.12.0

func RunOrphanedAICommits(orphans []OrphanedAICommit, ackedSHAs map[string]bool) []Finding

RunOrphanedAICommits emits one CodeIsolationEscapeOrphanedAICommit warning per orphaned AI-actor commit, honoring per-SHA acks.

orphans is the typed gather output from WalkOrphanedAICommits. ackedSHAs is the M-0159/AC-3 acknowledgment set; an entry in the map exempts that SHA from this rule the same way it exempts isolation-escape, id-rename-untrailered, etc.

Per-SHA dedup: an orphan that surfaced on multiple branches (uncommon; would require the commit to have been the tip on more than one branch over time) fires once. The first (branch, date) pair seen names the finding.

func RunPromoteOnWrongBranch added in v0.12.0

func RunPromoteOnWrongBranch(commits []scope.Commit, expectedBranches map[string]string, dag *CommitDAG, branchTips map[string]string, trunkShort string, ackedSHAs map[string]bool, t *tree.Tree) []Finding

RunPromoteOnWrongBranch applies the AC-8 rule to a commit history. expectedBranches maps entity ids to the expected parent-branch short name; empty/missing values silence the rule for that entity (fail-shut on correctness).

commits must be ordered oldest-first (matches the RunProvenance convention). Unlike the original M-0161/AC-8 design, this rule no longer asks a BranchOracle "what branches is this commit reachable from" — that question requires enumerating and name-filtering local branches, which is exactly what silently missed G-0270's incident (the activation commit landed on a non-ritual-shaped branch, so the oracle's branch enumeration never indexed it, and the rule stayed silent on an "unknown branch"). Instead it asks the narrower, branch-name-independent question "is this commit correctly reachable from the expected branch," via dag, branchTips, and trunkShort. Only a nil dag fail-shuts the whole rule (no ancestry data at all to test against).

The correctness test is NOT a single ancestor check — plain full-ancestry (any path) can't distinguish two very different milestone histories once the epic's ritual branch has been merged and deleted (its normal end-of-life, per aiwfx-wrap-epic): a milestone activation commit correctly made on that branch becomes, after the merge, a full ancestor of trunk's tip too — arriving via the merge commit's non-first parent, never itself on trunk's own lineage. A milestone activation that instead skipped the ritual branch and landed directly on trunk is ALSO a full ancestor of trunk's tip, but IS on trunk's own first-parent chain. The same fixture-verified pattern occurs for epics too (aiwfx-wrap-epic's own "promote E-NN active" step, run from the epic's own branch just before it is merged and deleted — confirmed against this repo's own history). One check handles both kinds uniformly:

  • Correct if the commit is an ancestor of expected's own tip (branchTips[expected]) — for an epic this already IS trunk's tip (expected == trunkShort), so this alone covers both "made directly on trunk" and "made on a branch since merged into trunk"; for a milestone this covers the still-live, in-flight epic branch.
  • Otherwise, for a milestone whose epic branch has since been merged and deleted (absent from branchTips), still correct if the commit is an ancestor of trunk's tip while NOT itself on trunk's own first-parent chain — i.e. it arrived via a merge, not by landing directly on trunk (which would mean the milestone skipped its epic branch entirely).

dag is the shared in-memory commit DAG (check.BuildCommitDAG), built once per check invocation. branchTips maps a branch's short name (as expectedBranches' values name them — the configured trunk short name for epics, "epic/<slug>" for milestones) to that branch's current LOCAL tip SHA; an absent entry (the branch doesn't exist locally — never cut, or cut and since deleted) is not ambiguous on its own — see the two-step check above for how it resolves. Comparing against the local branch (not a remote-tracking ref) is deliberate: a legitimate, correct, not-yet-pushed activation commit is an ancestor of local trunk immediately, but would not yet be an ancestor of a remote-tracking ref. trunkShort is the configured trunk short name, used to resolve trunk's own tip from branchTips independently of expected (which for a milestone names the epic branch, not trunk).

ackedSHAs honors M-0159/AC-3 acknowledgments via the shared per-SHA exemption.

t is the current entity tree, consulted to resolve a commit's aiwf-entity: trailer forward through any reallocation before the expectedBranches lookup (G-0308). expectedBranches is keyed by the *current* tree's ids; a commit that predates a reallocate carries the freed id verbatim in its trailer, and that id may since have been reclaimed by an unrelated entity. Resolving the trailer id through the rename chain (in-window aiwf-prior-entity: trailers) and then prior_ids frontmatter (out-of-window reallocations) — the same two-step RunProvenance uses for authorization-out-of-scope — finds the expectation for the commit's *actual* entity instead of the id's current claimant. A nil t skips only the prior_ids fallback; in-window rename-chain resolution (walkRenameChain) still applies since it reads solely from commits.

func RunProvenance

func RunProvenance(commits []scope.Commit, t *tree.Tree) []Finding

RunProvenance returns provenance findings for the given commit history. commits must be ordered oldest-first (`git log --reverse`) and should already be filtered to those carrying any `aiwf-*` trailer; pre-aiwf commits are silently skipped by the per-rule checks anyway, but pre-filtering keeps the work proportional.

t is the current entity tree, consulted by the authorization-out-of-scope rule for reference reachability.

The function is pure (no I/O, no git subprocess); the caller (cmd/aiwf) gathers commits via gitops and hands them in.

func RunTrailerVerbUnknown added in v0.10.0

func RunTrailerVerbUnknown(commits []scope.Commit, registeredVerbs, ritualVerbs map[string]struct{}, ackedSHAs, postCutoffSHAs map[string]bool) []Finding

RunTrailerVerbUnknown returns one finding per commit in commits whose `aiwf-verb:` trailer value is neither in registeredVerbs (the kernel Cobra command tree) nor in ritualVerbs (the non-kernel verbs stamped by embedded ritual skills). Commits without an `aiwf-verb:` trailer, with an empty value, or whose value resolves to either closed set are silent.

An empty registeredVerbs set short-circuits to no findings — the verb enumeration runs at RunE time and could in principle return empty (cobra tree wiring failure); we'd rather skip than flood every commit as "unknown."

Per G-0190 the caller is expected to derive ritualVerbs from the embedded ritual snapshot (typically via skills.RitualTrailerVerbs) so the allowlist stays in lock-step with what the rituals actually stamp. A nil ritualVerbs is treated as the empty set; the kernel `add`/`promote`/etc. verbs still resolve via registeredVerbs.

M-0159/AC-3: ackedSHAs carries the set of commit SHAs that have been retroactively acknowledged via `aiwf acknowledge illegal`. The CLI gather layer computes the map once per check invocation (via WalkAcknowledgedSHAs in acks.go) and passes it here so historical stray commits with `aiwf-verb: <fabricated>` trailers can be quieted without rewriting history. Per-SHA closed-set scoping; nil or empty map is "no acknowledgments." Ack is checked before the cutoff decision so an explicit acknowledgment overrides every severity transition.

G-0218 Patch 2: postCutoffSHAs carries the set of commit SHAs that descend from HookInstallSHA (computed once per check invocation by the CLI gather layer via `git rev-list HookInstallSHA..HEAD`). Findings on these commits emit at SeverityError with a remediation hint — the commit-msg hook would have refused them at composition time, so a post-cutoff fabricated trailer means the commit bypassed the hook (`--no-verify` or git plumbing). Pre-cutoff commits (SHA absent from the map) stay at SeverityWarning per the G-0150 baseline so existing trunk history isn't retroactively broken. nil or empty postCutoffSHAs degrades to "all warning" — the safe fallback for shallow clones, forks that diverged before the hook landed, or any future state where the cutoff SHA is unreachable from HEAD.

Closes G-0150; G-0218 Patch 2 tightens severity for post-cutoff.

func RunUntrailedAudit

func RunUntrailedAudit(commits []UntrailedCommit, ackedSHAEntities map[string]map[string]bool) []Finding

RunUntrailedAudit returns `provenance-untrailered-entity-commit` findings — one per (commit, entity) pair — for every untrailered commit in the supplied slice that touched an entity file. The caller is expected to scope `commits` to the unpushed range (typically `@{u}..HEAD`) so already-pushed pre-aiwf history is silently ignored.

One finding per entity, not per commit: a manual commit that touches three entity files emits three findings, each tagged with the entity id. This is what makes per-entity audit-only suppression work — `aiwf <verb> M-001 --audit-only` clears the M-001 finding without affecting the others on the same commit. Each finding's message names exactly one entity, which keeps individual lines short even when commits touch many entity files (matters for squash, merge, and bulk-import commits).

The finding is an ERROR (G-0231 item 3, bumped from WARNING after the merge-commit carveout cleared the historical noise and the remaining direct-edit findings were SHA-verified-acked via `aiwf acknowledge illegal --for-entity`).

Two coverage mechanisms suppress findings:

  1. audit-only — when a later commit in the same range carries `aiwf-audit-only:` and its `aiwf-entity:` matches the entity id, the finding for that (commit, entity) pair is suppressed. Composite ids on audit-only commits roll up to the parent milestone for matching, mirroring how composite ids on manual commits resolve to the parent file. Per-entity blanket — clears ALL prior untrailered findings for the entity. Operator-attested reason; no SHA binding. Intended for entity status flips that bypassed `aiwf promote`.

  2. acknowledge-illegal --for-entity (G-0231 item 3) — when an ack commit carries BOTH `aiwf-force-for: <sha>` AND `aiwf-entity: <id>` trailers, the finding for that exact (sha, id) pair is suppressed. Per-(SHA, entity) precision; the verb verifies at WRITE time that <sha>'s diff actually touches <id>'s file (kernel walks git diff-tree; operator-attested bindings are refused without mechanical evidence). Intended for historical body edits where the SHA is real but the kernel should still be able to verify the binding.

Mechanism 2 is the tighter mechanism. The audit-only path remains for the per-entity blanket case (status flips); the acknowledge-illegal path is the SHA-verified case (body edits).

Defensive fallback: if a commit touched paths that PathKind recognizes but IDFromPath cannot parse to an id (a bug in the path scheme would be the only realistic cause), one path-tagged finding fires per such path. EntityID is empty in that branch.

func ScanBodyProseID added in v0.12.0

func ScanBodyProseID(body []byte, entityID, path string, idx BodyProseIndex) []Finding

ScanBodyProseID classifies every id-shaped token in body (the bytes after the YAML frontmatter delimiter) and returns one finding per unique (token, subcode) pair, deduped within this body. Path and entityID are used only to populate the Finding's locator fields — the scanner is otherwise stateless, so it can run against on-disk content (the tree-walking bodyProseID rule) or against planned- write bytes that don't yet exist on disk (verb-time pre-flight).

Non-prose content is masked (not stripped) via proseMask before scanning, so byte offsets in the input remain stable across the masking step. Finding.Line is set to the 1-based line number within body where the matched token starts; callers that want file-relative Line (the bodyProseID tree-walk rule) add the body's start-of-file line offset themselves.

The idx parameter is the resolution index from BodyProseIDIndex; callers that scan multiple bodies should build it once and reuse.

func ScanSkillBodyID added in v0.21.0

func ScanSkillBodyID(body []byte, path string) []Finding

ScanSkillBodyID classifies every id-shaped token in the given content (a whole shipped *.md file, frontmatter included, or a bare body) and returns one finding per unique real-id token, deduped within this content. A token fires only when it matches a kind's strict, digit-bearing id pattern (bare or composite); canonical letter-N placeholders and malformed shapes are not this rule's concern (placeholder normalization is policed separately).

Non-prose content is masked (not stripped) via proseMask before scanning, so byte offsets stay stable and tokens inside code constructs or non-prose link carriers are exempt by construction. Finding.Line is 1-based within the given content; when the caller passes the whole file (skillBodyIDReference does), that line is already file-relative.

Path populates the finding locator only; the scanner is otherwise stateless, so it runs against on-disk content (skillBodyIDReference) or against literal test bytes.

func TreeDiscipline

func TreeDiscipline(t *tree.Tree, allow []string, strict bool) []Finding

TreeDiscipline reports any file under work/{epics,gaps,decisions, contracts}/ that the loader walked but could not classify as a recognized entity file. This is the G40 mechanical guarantee for the entity-bearing subtrees: the LLM must not write directly inside them — tree-shape changes go through verbs, body-prose edits stay inside existing entity files, and stray hand-written files in those subtrees are flagged at validation time.

Scope is the four entity-bearing subdirs, not all of work/. Files at the work/ root or under non-entity sibling dirs (work/migration/, work/scratch/, etc.) are outside the loader's walk roots, never registered as strays, and never flagged here. The threat model is the LLM mistaking the entity tree as a scratch space, not the operator parking a transient file alongside it.

Filtering, in order:

  1. Files inside a recognized contract's directory (work/contracts/C-NNN-<slug>/) are auto-exempt — contracts legitimately carry schema/fixture artifacts alongside contract.md, and the binding lives in aiwf.yaml.
  2. Files matching any pattern in `allow` (filepath.Match, forward-slash, repo-relative) are exempt. Consumers configure these via `aiwf.yaml: tree.allow_paths`.
  3. Everything else surfaces as a finding.

Severity is warning by default. When `strict` is true (consumer opts in via `aiwf.yaml: tree.strict: true`), the severity is promoted to error so the pre-push hook blocks the push.

Run does *not* call this — it lives outside the standard rule chain so render/status callers don't get tree-discipline noise on every read. `runCheck` invokes it at the validation chokepoint.

type HeadCommit added in v0.21.0

type HeadCommit struct {
	SHA            string
	Trailers       []gitops.Trailer
	AuthorEmail    string
	CommitterEmail string
	AuthorDate     string
	Subject        string
	Body           string
}

HeadCommit is one HEAD-reachable commit captured by WalkHeadCommits: the union of fields the five trailer-reading gather rules need, plus the author date and subject the render single pass (M-0221) reads.

Trailers is parsed once (from `%(trailers:unfold=true)`) and shared; AuthorEmail / CommitterEmail feed the cherry-pick identity-gap check; Body feeds the cherry-pick marker match and the provenance aiwf-trailer grep.

AuthorDate (%aI) and Subject (%s) are additive (M-0221): no check consumer reads them, so extending the record leaves every check gather byte-identical. render's single pass uses them to reproduce entityview.HistoryEvent (Date + Detail) and to date scope open/end events without the per-SHA `git show` the scope views used. Body stays %B (the full raw message) so the two check body-greps are unchanged; the render bucketer derives the prose body from it.

Trailer-shape assumption (M-0216 AC-5): the provenance gather replaced `%(trailers:only=true,unfold=true)` with this `%(trailers:unfold=true)` block, which is byte-identical ONLY while no commit's trailer block carries a non-trailer, colon-bearing line (e.g. `Note: see http://…`) that `gitops.ParseTrailers` would read as a trailer. That holds for every aiwf-produced commit and was verified across the whole kernel tree; a consumer commit that violated it could synthesize a trailer the `only=true` path dropped. A latent constraint, not a structural invariant.

func WalkHeadCommits added in v0.21.0

func WalkHeadCommits(ctx context.Context, root string) ([]HeadCommit, error)

WalkHeadCommits walks HEAD's reachable history once, oldest-first, and returns one HeadCommit per commit. Oldest-first matches the `--reverse` order readProvenanceCommits depends on; the map-building consumers (acks, audit-only, cherry-picks) are order-insensitive.

Returns (nil, nil) for an empty root, a non-git directory, or a repo with no commits yet — the consumers treat a nil slice as "no commits / no exemptions".

Fail-loud contract (M-0216 Finding 1): a catastrophic `git log HEAD` failure — a repo whose HEAD resolves (so hasGitCommits is true) but whose reachable history cannot be read, e.g. a corrupt object store or a partial/blobless clone missing a reachable commit — returns a non-nil error. The sole production caller (internal/cli/check) fails the whole check with ExitInternal on that error rather than reading the unreadable history as "no commits". This restores the fail-loud behavior the pre-refactor readProvenanceCommits had: a shared walk that silently returned nil here would disable the provenance / isolation-escape / promote-on-wrong-branch integrity checks on a degraded repo with no signal at all. The error path is outside the byte-identical claim's domain — a healthy tree never triggers it.

One `git log --reverse HEAD` subprocess replaces the five the gather rules used to each spawn (E-0053 / M-0216 AC-5).

type OracleErr added in v0.12.0

type OracleErr struct {
	Ref        string
	Capability string
	Err        error
}

OracleErr is a per-ref or repo-wide oracle-construction failure surfaced by BranchOracle.OracleErrors. The Capability tag names what coverage was lost; the underlying Err is preserved for diagnostic surface in the isolation-escape-oracle-failure advisory's hint text.

Ref is the ritual ref the failure pertains to (e.g. "epic/E-0001-engine"). Ref MAY be empty for repo-wide failures (corrupted packed-refs at enumeration time); per D-0019 point 4, that case is handled at the construction-error path rather than via OracleErrors, but the field shape leaves room for a future repo-wide kind.

Capability classes (M-0161/AC-3..AC-5):

  • "ref-resolution-failed" (AC-3) — `git rev-list --first-parent <ref>` failed on a single ritual ref while `for-each-ref` enumerated cleanly. The ref's first-parent index could not be built.
  • "shallow-clone" (AC-4) — the ref's first-parent walk hit the shallow-depth horizon. Reserved.
  • "reflog-disabled" (AC-5) — `core.logAllRefUpdates=false` detected at gather time; AC-5's reflog-walk extension cannot fire on the affected ref. Reserved.

type OrphanedAICommit added in v0.12.0

type OrphanedAICommit struct {
	SHA        string
	Branch     string
	ReflogDate string
	EntityID   string
	Actor      string
}

OrphanedAICommit is one AI-actor commit that was orphaned by a non-fast-forward update on a ritual branch.

SHA is the orphan's commit SHA (preserved in the local object store regardless of the ref's current value). Branch is the ritual branch whose reflog records the orphaning update. ReflogDate is the human-readable date of the reflog entry recording the orphaning update. EntityID and Actor are the aiwf-entity: and aiwf-actor: trailer values from the orphan's commit body.

func WalkOrphanedAICommits added in v0.12.0

func WalkOrphanedAICommits(ctx context.Context, root string, dag *CommitDAG, trunkShort string) []OrphanedAICommit

WalkOrphanedAICommits walks the reflog of every ritual branch under refs/heads/, identifies non-fast-forward updates (the previous tip was not an ancestor of the new tip — the canonical force-push shape), reads trailers from each orphaned tip, and returns one OrphanedAICommit per orphan that carries aiwf-actor: ai/... + aiwf-entity: <id>.

Algorithm (per ritual ref):

  1. `git reflog show <ref> --pretty=format:%H %gd` lists the reflog entries newest-first.
  2. Walk consecutive pairs (newer, older). older was the ref's tip BEFORE the update that landed at newer.
  3. If older is NOT an ancestor of newer (`git merge-base --is-ancestor older newer` exits non-zero), the update was a non-fast-forward and older was orphaned.
  4. Read older's trailers via `git log -1 --pretty=%B older`. If aiwf-actor starts with "ai/" AND aiwf-entity is non-empty, surface the orphan.

Returns nil on non-git directories, empty repos, and repos with no ritual refs. Reflog absence (e.g. core.logAllRefUpdates =false) is handled at the oracle layer (M-0161/AC-3 typed- error contract emits OracleErr with Capability "reflog- disabled" → isolation-escape-oracle-failure advisory); this walker simply finds no entries to inspect.

Per-SHA deduplication: an orphan that appears across multiple ref reflogs (rare; would require the orphan tip to have been the tip on multiple refs at different times) surfaces once per (SHA, Branch) pair; the rule consumer deduplicates by SHA when emitting findings.

type Severity

type Severity string

Severity classifies a finding. Errors block `aiwf check` (exit 1); warnings are surfaced but don't change the exit code unless errors are also present.

const (
	SeverityError   Severity = "error"
	SeverityWarning Severity = "warning"
)

Severity values used by every check.

type UntrailedCommit

type UntrailedCommit struct {
	SHA        string
	Subject    string
	Trailers   []gitops.Trailer
	Paths      []string
	ParentSHAs []string
}

UntrailedCommit is the input shape for RunUntrailedAudit: the commit's SHA, its subject (first line of the message), its trailer set, the relative paths it touched (as reported by `git diff-tree`), and its parent SHAs (from `git log %P`).

The Subject is consulted to specialize the warning when the commit looks like a GitHub squash-merge ("…(#NNN)" suffix) — see G31. It can be left empty by callers that don't need that specialization; the bare warning still fires.

ParentSHAs distinguishes ordinary `--no-ff` merges (multi-parent, no squash-merge subject) from direct edits and squash merges (G-0231 item 3). `len(ParentSHAs) > 1` means a real merge whose content traces back to feature-branch commits already reachable via the second parent — those commits carry their own `aiwf-verb:` trailers, so the merge commit's first-parent diff is redundant audit material and is skipped by RunUntrailedAudit. Callers that don't populate ParentSHAs (zero-length) get the pre-G-0231 behavior — every commit is audited regardless of merge shape.

type UntrailedIDRename added in v0.12.0

type UntrailedIDRename struct {
	SHA     string
	OldPath string
	NewPath string
	OldID   string
	NewID   string
}

UntrailedIDRename describes a single id-bearing file rename occurring on a commit between merge-base(HEAD, trunk) and HEAD whose `aiwf-verb:` trailer is NOT in the rename-class closed set. Pre-computed by the CLI gather layer via WalkUntrailedIDRenames; consumed by RunIDRenameUntrailered.

OldID and NewID are extracted from the respective paths via entity.PathKind + entity.IDFromPath. For the typical slug-rename shape (retitle/rename done outside the verb path) OldID == NewID; for the rare hand-edited-frontmatter shape (operator manually changed `id:` AND did `git mv`) they may differ. The rule fires the same way either way; carrying both fields preserves the information for future tightening (e.g., a future rule that distinguishes "untrailered slug rename" from "untrailered renumber").

func WalkUntrailedIDRenames added in v0.12.0

func WalkUntrailedIDRenames(ctx context.Context, root, ref string) []UntrailedIDRename

WalkUntrailedIDRenames walks merge-base(HEAD, ref)..HEAD for commits that rename id-bearing entity files WITHOUT an aiwf-verb trailer in the rename-class closed set. Returns one record per qualifying rename; multiple renames in one commit produce multiple records. Closes the gather-side seam for AC-4.

Returns nil for all error conditions — ref empty, ref unresolved, no commits on HEAD, no common ancestor, transient git subprocess failure. Matches WalkCherryPicks's "benign fail-shut" precedent at internal/check/cherry_picks.go: the rule degrades to "no records" rather than blocking `aiwf check` on a git hiccup, since the chokepoint is one rule among many and a transient git failure should surface other findings rather than aborting the pass.

Performance: one `git log` to enumerate the range with trailers, then one `git show -M --diff-filter=R` per untrailered commit to extract renames. Same shape as gitops.renamesFromAiwfVerbTrailers at the gather layer; for kernel-tree-sized branches the cost is well under a second.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL