Rule Evaluation
Rule evaluation runs after lowering. runSemanticChecks in packages/shp-checker/src/checker/rules.ts calls each entry of SEMANTIC_CHECKS in order, and checkLoweredShapeModel then runs checkBindings unless enforceBindings is false. Every check is a TypeScript function that reads the typed indexes of the lowered Model and returns SemanticDiagnostic[]. No check stops a later one, and no rule module reads model.facts. Checker Pipeline covers the phases around this one and how the combined result is sorted. Checker module paths below are relative to packages/shp-checker/src/checker/; other paths are relative to the repository root.
Inputs
Section titled “Inputs”Each check receives the Model and the NormalizedCheckOptions:
- The typed indexes described in Fact Lowering:
resources,traits,components,hypergraph,candidateEffects,implementations,bindings,rules,rationales,memories,reevaluations,roles,policies,attestations,shapeUpdatePaths, andchangeEvents. - The options. Only six checks read them:
checkFreshnessreadsfreshnessDate;checkGuardedSources,checkCoverage, andcheckBindingsreadchangedFiles,repoRoot, and the base model derived frombaseModules;checkStaleAttestationsreads its attestation keys; andcheckCitedPathsreadsrepositoryFilesandrepoRoot.allowUnknownEffectsandincludeFactsapply after the rules, incheckLoweredShapeModel.
Shared queries that more than one layer needs, such as deriveFinalForbidsForResource, requirementsForTarget, and reevaluationValidationReasons, live in derivations.ts.
Registry
Section titled “Registry”| Order | Function | Module | Diagnostic kinds | Reads options |
|---|---|---|---|---|
| 1 | checkResolvedNames | rules/names.ts | unknown_name, invalid_relation, invalid_rule | no |
| 2 | checkRules | rules/declarations.ts | invalid_rule | no |
| 3 | checkFingerprintExpectations | rules/relations.ts | invalid_relation, fingerprint_mismatch | no |
| 4 | checkCandidateEffectFingerprints | rules/functions.ts | candidate_pin_fingerprint_mismatch | no |
| 5 | checkContextTargets | rules/context.ts | invalid_context_target, context_target_mismatch | no |
| 6 | checkRequiredContext | rules/context.ts | missing_required_context | no |
| 7 | checkRequiredDescriptions | rules/context.ts | missing_required_description | no |
| 8 | checkReevaluations | rules/context.ts | invalid_reevaluation | no |
| 9 | checkGuardedChanges | rules/guards.ts | guarded_shape_changed | no |
| 10 | checkGuardedSources | rules/guards.ts | guarded_source_changed | changedFiles, repoRoot, baseModules |
| 11 | checkFreshness | rules/guards.ts | stale_memory | freshnessDate; runs only when it is set |
| 12 | checkFunctions | rules/functions.ts | unsafe_effects, unknown_effects, final_forbidden_effect, missing_grant | no |
| 13 | checkProvidesRules | rules/relations.ts | forbidden_provides | no |
| 14 | checkForbiddenPaths | rules/relations.ts | invalid_rule, forbidden_path | no |
| 15 | checkHypercycles | rules/relations.ts | forbidden_hypercycle | no |
| 16 | checkCoverage | rules/coverage.ts | missing_shape_update | changedFiles, repoRoot, baseModules |
| 17 | checkStaleAttestations | rules/coverage.ts | stale_attestation (a warning) | baseModules; runs only when it is set |
| 18 | checkCitedPaths | rules/paths.ts | missing_cited_path | repositoryFiles, repoRoot; runs only when repositoryFiles is set |
| after the registry | checkBindings | rules/coverage.ts | missing_bound_docs_change | changedFiles, repoRoot, baseModules; skipped when enforceBindings is false |
With checkLoosening and baseModules set (shp check --check-loosening), checkShapeModules then calls checkRuleLoosening in loosening.ts, outside the registry, which reports rule_loosening. It compares the rule-layer declarations and trait lists of the authored modules with the base by module, declaration kind, and name, ignoring layout and comments. For each one the change removed or edited, it builds a copy of the input modules with that declaration or trait list put back, lowers and checks the copy with the same options, and keeps the rule-violation diagnostics absent from the first result. It never mutates the parsed modules; the copies share their unchanged declarations. Rule loosening lists which diagnostic kinds count.
Lowering, not the registry, reports duplicate_declaration, duplicate_fingerprint, ambiguous_name, invalid_candidate_effect, invalid_require_context, and invalid_implementation, as well as some invalid_relation and unknown_name diagnostics.
Diagnostic order does not depend on the SEMANTIC_CHECKS order: checkLoweredShapeModel sorts every result by kind, then by rendered text. The printed form of each kind, and how to fix it, is in Diagnostics.
Interactions
Section titled “Interactions”These behaviours span more than one check, or a check and the result assembly. The user-facing rules are taught in Effect Model, Design Memory, and Keep the Model Current.
- Final-forbid precedence. For each entry of a complete effect summary,
checkFunctionsfirst looks at the target. When the target is a declared resource, it checks final forbids withfindFinalForbidden. A match emitsfinal_forbidden_effectand skips the grant check for that entry, so nomissing_grantis reported for it. When the target is not a declared resource, the final-forbid check is skipped, the grant check still runs, andcheckResolvedNamesalso reportsunknown_name. An entry with no<Resource>target skips both checks. Rationale, memory, reevaluations, and attestations never suppress either diagnostic, and a grant never suppressesfinal_forbidden_effect. - Memory is not a waiver. Memory can satisfy required design context and create review obligations, but it does not suppress final forbids, missing grants, or other hard model failures.
checkFunctions, the relation checks, and coverage never readrationales,memories, orreevaluations, and attestations are read only bycheckCoverage,checkBindings, andcheckStaleAttestations. - Unknown-effects severity.
checkFunctionsreports each function witheffects unknownasunknown_effectswith severityerror, and skips that function’s final-forbid and grant checks. It skips functions from generated-AST modules (shouldIgnoreUnknownEffectsDiagnostic, which readsFunctionInfo.generatedAstCandidate). UnderallowUnknownEffects,checkLoweredShapeModeldowngrades everyunknown_effectsdiagnostic towarning; no other kind changes, and a result that holds only these warnings passes. - The current-file filter. With an empty changed-file list,
checkCoverageandcheckBindingsreport nothing. OtherwisechangedFileContextnormalizes the list againstrepoRoot, and a Shape-update ref counts only whenprovenanceFileChangedfinds its declaring.shapefile in that list. An attestation follows the same rule unlessbaseModulesis set; thenisCurrentAttestationcounts it only when itsattestationKey(kind, path, and reason) is absent from the lowered base model. With a base,checkBindingsalso skips a triggering.shapefile whose text without attestations (model.attestationFreeTexts, kept per file and absent for a module built in code) equals the base version’s, so a change that only adds or deletes attestations, such asshp attest prune, never demands a bound docs change. The incremental checker keys cached results on the base model’s attestation keys and attestation-free texts, so a different base re-runs the checks. The attestation, governed-path, and binding rules applied after this filter are in Keep the Model Current. - Cited paths. When the caller passes
repositoryFiles(shp check --check-cited-paths),checkCitedPathscollects every function’ssourcepath and complete-effectevidencepaths, readingmodel.componentsdirectly becauseshapeUpdatePathsleaves out generated AST functions, every candidate effect’ssource, everyevidencepath of rationale, memory, and reevaluation, and every memoryobservedpath, and reports each one missing from the list once, with every citing declaration as its cause. Attestation sources are exempt, since attesting a deletion names a removed file. The checker reads no files; the list is part of the incremental checker’s cache key. - Guards read change events and changed files.
checkGuardedChangespasses the contexts frombuildGuardContextsandmodel.changeEventstoevaluateGuardsinpackages/shp-checker/src/memory-guards.ts. Change events come only fromchangedeclarations, so editing a guarded declaration in place never raisesguarded_shape_changed, and any valid reevaluation (hasValidReevaluationForGuard) satisfies every guard of the context it names.checkGuardedSourcescovers the code side: for eachon_change require ReEvaluationguard on a function whoseprotectsblock is empty or has an opaque entry, it reportsguarded_source_changedwhen the function’ssourcepath, without its#anchor, is in the normalized changed-file list. Only a current reevaluation clears it: one whosereevaluationKeyis absent from the base model’sreevaluationKeys, or, without a base, one declared in a changed.shapefile. It skips a function that achangedeclaration modifies while no valid reevaluation exists, sinceguarded_shape_changedalready reports it. The base model’s reevaluation keys are part of the incremental checker’s cache key. - Rule-derived final forbids.
deriveFinalForbidsForResourceadds forbids fromruledeclarations that contain aforbid finalmember:- The subject name from
when T has TraitNameis the rule’s generic binder.checkRulesreportsinvalid_rulewhen a rule with aforbid finalmember has nowhensubject or more than one distinct subject. - Every
whenclause for the subject must match: the resource must carry each listed trait. - Each condition trait must be a marker trait with no type parameters, or have exactly one parameter explicitly bound to
Resource, such asAppendOnly<T: Resource>.resourceRuleConditionCompatibilityrejects unbound, non-Resource, and multiple parameters asinvalid_rule, because the rule syntax cannot bind them to the subject. - An invalid or unresolved condition contributes no forbids.
- A generic target such as
HardDelete<T>binds to the matching resource. A concrete target such asHardDelete<history::Revision>stays that exact resource after module and import resolution. - Plain
forbidmembers in a rule derive nothing. - A
final_forbidden_effectfrom a rule names the firstwhentrait as its trait.
- The subject name from
Graph witnesses
Section titled “Graph witnesses”forbid path and forbid hypercycle share one step-graph builder, buildRelationTraversalGraph in rules/relations.ts, and report one shortest, canonical witness each.
Step graph. Each hyperedge contributes directed steps according to the traversal its kind declares in PRELUDE_RELATION_KINDS (packages/shp-checker/src/prelude.ts). directed_pairs kinds (calls, callbacks, provides) give one step, members[0] → members[1]. The ordered_path kind (coordinated_call) gives one step per consecutive pair of members. Other kinds give no steps. Only hyperedges whose kind is in the rule’s kind list contribute; a forbid hypercycle with no over clause uses every kind. Each vertex’s outgoing steps are sorted by target, then relation kind, then relation name, and the vertex list is sorted; every comparison is by codepoint.
Paths. checkForbiddenPaths reports invalid_rule for a forbid path whose endpoints are equal or whose kinds include one without traversal. It skips a rule whose endpoints are unresolved or ambiguous, which checkResolvedNames reports. findShortestPath builds the step graph from validPathHyperedges only: it leaves out hyperedges with an unresolved or ambiguous endpoint, and provides hyperedges whose provider is not a component or whose target is not a resource. A breadth-first search from the source, with a visited set, returns the first path that reaches the target. That path has the fewest hops, with ties broken by the sorted step order, and the visited set makes the search terminate on cyclic graphs. Each forbid path member with a witness produces one forbidden_path diagnostic that lists every step.
Hypercycles. findHypercycle builds the step graph from all hyperedges, without endpoint filtering. It finds strongly connected components with Tarjan’s algorithm, visiting vertices in sorted order, and keeps the components that contain a cycle: more than one vertex, or a step from a vertex to itself. Within each such component, a breadth-first search from every vertex finds the shortest cycle back to that vertex. Candidates are compared by length, then by their vertex sequence, then by each step’s relation kind and name, all by codepoint, and the smallest across all components wins. The walk therefore starts at the codepoint-smallest vertex that lies on a shortest cycle. The witness lists the vertices as a closed walk and the relations in walk order, each relation once. checkHypercycles reports at most one diagnostic per forbid hypercycle member, and drops a repeat of the same set of relations within one rule.
Examples of both witnesses, and how a reviewer reads them, are in Relations and Graph Rules.
Adding a rule
Section titled “Adding a rule”A rule belongs in Shape when it rejects an incoherent or incomplete claim and can explain itself through lowered model data and provenance. It must not adjudicate taste. Before writing one, identify which index it reads, which declaration creates that data, which diagnostic a reviewer should see, and whether a reviewer can fix the problem without knowing checker internals.
- If the rule needs data the model lacks, add the field to
Modelinmodel.tsand fill it in the matching lowerer underlowering/. Add aFactvariant as well only ifincludeFactsconsumers should see the data. - Add the diagnostic variant to
SemanticDiagnosticinmodel.ts, withfilePathand acausedBylist built withdescribeProvenance. Add its case toformatDiagnosticindiagnostics.ts;bun run typecheckfails until the switch covers the new kind. - Write the check in the matching module under
rules/, as a function that takes theModel(plus any option values it needs) and returnsSemanticDiagnostic[]. Read typed indexes andderivations.tshelpers, nevermodel.facts. - Add an entry to
SEMANTIC_CHECKSinrules.tsthat passes it the model and those option values. - Add a passing model under
fixtures/pass/<name>/and a failing one underfixtures/fail/<name>/, and cases inpackages/shp-checker/src/checker.test.tsthat load them.checker.test.tsloads each fixture by path, so a fixture that no test names is never checked. The formatter round-trip test andbun run format:checkdo scan every fixture, so runbun run format:shapeon new fixtures. - Document the printed form, cause, and fix in
docs-site/src/content/docs/reference/diagnostics.md. TheRuleEngineDocsbinding inshape/delivery.shaperequires a docs change whenrules.ts,rules/**,diagnostics.ts, orshape/checker.shapechanges. - Model the new function on
ShapeRuleEngineinshape/checker.shape, with itssourceand complete effects.packages/shp-checker/src/checker/**/*.tsis governed by theCheckerSourceimplementation, so coverage fails unless a current Shape update or attestation names the changed rule module. - Run the contributor checks listed in
CONTRIBUTING.md.
Why rules are direct TypeScript checks
Section titled “Why rules are direct TypeScript checks”Rules are TypeScript functions over the typed indexes (decided 2026-07-26, issue #39). The credible alternative was a Datalog-style engine over model.facts. The unexported spike in packages/shp-checker/src/experiments/datalog-rule-engine-spike.ts reproduces the production missing_grant diagnostic and its provenance with one anti-join rule:
missing_grant(Component, Function, Effect, Target) :- effect(Component, Function, Effect, Target), component(Component), not grants(Component, Effect, Target).It consumes the real Fact[] stream, carries structured provenance through positive matches, and selects output deterministically. Its tests compare the result field for field with the direct checker for passing, failing, targetless-effect, exact-join, provenance, and input-fact-order cases. A separate test confirms that the spike does not apply final-forbid precedence: for a HardDelete on an AppendOnly resource it reports a missing_grant that production suppresses.
The prototype shows that provenance can survive a relational join, but it does not show a clear maintenance, correctness, or performance benefit over the current rule modules. Direct checks keep one authoritative semantic implementation instead of two semantic representations of the same model. Each rule uses the model index that naturally represents its domain, TypeScript exhaustiveness and the existing domain types keep invalid states visible, and diagnostic precedence and witness selection remain explicit. A full migration would also need a richer fact contract and engine extensions: the fact stream is not a complete rule database, and some rule semantics depend on more than tuple membership:
- A
rulefact records the rule name, not its conditions or forbidden actions. - A
context_requiredfact omitssatisfiedByandrequiresDescription. - Rationale and memory facts omit effective
applies_to, freshness dates, and policy metadata. - Change events used by guarded-change evaluation are not facts.
- Diagnostic precedence, such as final forbids suppressing a missing-grant diagnostic for the same effect, lives in direct control flow.
- Path and hypercycle checks depend on canonical graph traversal and witness selection, not only tuple membership.
- A
trait_final_forbidfact is emitted for plainforbidmembers too and carries nofinalflag, and per-resource and rule-derived forbids are not facts. - Declaration-level
modifyandremovechange entries leave the replaced declaration’s facts in the stream.
An in-house engine would also make Shape own rule safety, stratification, indexing, proof selection, and debugging. An external engine would still need adapters for provenance, diagnostic ordering, graph witnesses, Bun packaging, and the browser boundary, and no current maintenance or performance result justifies another production dependency. The accepted cost is that new rules may repeat simple lookup and anti-join code.
Reopen the decision only when at least one primary trigger holds and every parity requirement is met.
Primary triggers:
- Several new rules duplicate the same multi-relation join or recursive derivation logic, and a shared relational form materially reduces the implementation.
- Representative profiling identifies semantic rule evaluation, rather than parsing or lowering, as a meaningful bottleneck.
- An incremental-checking design needs dependency-tracked derived facts that direct indexes cannot provide cleanly.
Parity requirements:
- The fact contract represents every input used by the candidate rules without consulting side indexes.
- Differential tests cover the complete fixture corpus and compare diagnostic kind, fields, ordering, and causal witnesses.
- Final-forbid precedence, required-context satisfaction modes, changed-file rules, and guarded changes retain their current semantics.
- Path and hypercycle rules either preserve canonical shortest witnesses through a documented extension or remain specialized direct checks.
- Any external dependency passes offline release, binary packaging, browser, and supported-platform checks.
- Benchmarks use representative large models and show a benefit large enough to justify the added concepts.
Until then, new rules extend the direct modules under rules/, and facts remain inspection output. CI compiles the spike and runs its differential tests through bun run typecheck and bun test. Code review confirms that packages/shp-checker/src/index.ts does not export the spike and that SEMANTIC_CHECKS does not call it.
experiments/semantic-kernel/ is a separate, isolated Rust and browser-WASM prototype that evaluates one rule over projected facts. The Shape rule ExperimentalSemanticKernelIsolation in shape/runtime.shape forbids a modeled calls path from ShapeChecker, ShapeEditorServices, or ShpCli to ExperimentalSemanticKernel. Its protocol, measurements, and adoption criteria are in the README of experiments/semantic-kernel/.