Skip to content

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.

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, and changeEvents.
  • The options. Only six checks read them: checkFreshness reads freshnessDate; checkGuardedSources, checkCoverage, and checkBindings read changedFiles, repoRoot, and the base model derived from baseModules; checkStaleAttestations reads its attestation keys; and checkCitedPaths reads repositoryFiles and repoRoot. allowUnknownEffects and includeFacts apply after the rules, in checkLoweredShapeModel.

Shared queries that more than one layer needs, such as deriveFinalForbidsForResource, requirementsForTarget, and reevaluationValidationReasons, live in derivations.ts.

OrderFunctionModuleDiagnostic kindsReads options
1checkResolvedNamesrules/names.tsunknown_name, invalid_relation, invalid_ruleno
2checkRulesrules/declarations.tsinvalid_ruleno
3checkFingerprintExpectationsrules/relations.tsinvalid_relation, fingerprint_mismatchno
4checkCandidateEffectFingerprintsrules/functions.tscandidate_pin_fingerprint_mismatchno
5checkContextTargetsrules/context.tsinvalid_context_target, context_target_mismatchno
6checkRequiredContextrules/context.tsmissing_required_contextno
7checkRequiredDescriptionsrules/context.tsmissing_required_descriptionno
8checkReevaluationsrules/context.tsinvalid_reevaluationno
9checkGuardedChangesrules/guards.tsguarded_shape_changedno
10checkGuardedSourcesrules/guards.tsguarded_source_changedchangedFiles, repoRoot, baseModules
11checkFreshnessrules/guards.tsstale_memoryfreshnessDate; runs only when it is set
12checkFunctionsrules/functions.tsunsafe_effects, unknown_effects, final_forbidden_effect, missing_grantno
13checkProvidesRulesrules/relations.tsforbidden_providesno
14checkForbiddenPathsrules/relations.tsinvalid_rule, forbidden_pathno
15checkHypercyclesrules/relations.tsforbidden_hypercycleno
16checkCoveragerules/coverage.tsmissing_shape_updatechangedFiles, repoRoot, baseModules
17checkStaleAttestationsrules/coverage.tsstale_attestation (a warning)baseModules; runs only when it is set
18checkCitedPathsrules/paths.tsmissing_cited_pathrepositoryFiles, repoRoot; runs only when repositoryFiles is set
after the registrycheckBindingsrules/coverage.tsmissing_bound_docs_changechangedFiles, 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.

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, checkFunctions first looks at the target. When the target is a declared resource, it checks final forbids with findFinalForbidden. A match emits final_forbidden_effect and skips the grant check for that entry, so no missing_grant is reported for it. When the target is not a declared resource, the final-forbid check is skipped, the grant check still runs, and checkResolvedNames also reports unknown_name. An entry with no <Resource> target skips both checks. Rationale, memory, reevaluations, and attestations never suppress either diagnostic, and a grant never suppresses final_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 read rationales, memories, or reevaluations, and attestations are read only by checkCoverage, checkBindings, and checkStaleAttestations.
  • Unknown-effects severity. checkFunctions reports each function with effects unknown as unknown_effects with severity error, and skips that function’s final-forbid and grant checks. It skips functions from generated-AST modules (shouldIgnoreUnknownEffectsDiagnostic, which reads FunctionInfo.generatedAstCandidate). Under allowUnknownEffects, checkLoweredShapeModel downgrades every unknown_effects diagnostic to warning; no other kind changes, and a result that holds only these warnings passes.
  • The current-file filter. With an empty changed-file list, checkCoverage and checkBindings report nothing. Otherwise changedFileContext normalizes the list against repoRoot, and a Shape-update ref counts only when provenanceFileChanged finds its declaring .shape file in that list. An attestation follows the same rule unless baseModules is set; then isCurrentAttestation counts it only when its attestationKey (kind, path, and reason) is absent from the lowered base model. With a base, checkBindings also skips a triggering .shape file 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 as shp 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), checkCitedPaths collects every function’s source path and complete-effect evidence paths, reading model.components directly because shapeUpdatePaths leaves out generated AST functions, every candidate effect’s source, every evidence path of rationale, memory, and reevaluation, and every memory observed path, 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. checkGuardedChanges passes the contexts from buildGuardContexts and model.changeEvents to evaluateGuards in packages/shp-checker/src/memory-guards.ts. Change events come only from change declarations, so editing a guarded declaration in place never raises guarded_shape_changed, and any valid reevaluation (hasValidReevaluationForGuard) satisfies every guard of the context it names. checkGuardedSources covers the code side: for each on_change require ReEvaluation guard on a function whose protects block is empty or has an opaque entry, it reports guarded_source_changed when the function’s source path, without its # anchor, is in the normalized changed-file list. Only a current reevaluation clears it: one whose reevaluationKey is absent from the base model’s reevaluationKeys, or, without a base, one declared in a changed .shape file. It skips a function that a change declaration modifies while no valid reevaluation exists, since guarded_shape_changed already reports it. The base model’s reevaluation keys are part of the incremental checker’s cache key.
  • Rule-derived final forbids. deriveFinalForbidsForResource adds forbids from rule declarations that contain a forbid final member:
    • The subject name from when T has TraitName is the rule’s generic binder. checkRules reports invalid_rule when a rule with a forbid final member has no when subject or more than one distinct subject.
    • Every when clause 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 as AppendOnly<T: Resource>. resourceRuleConditionCompatibility rejects unbound, non-Resource, and multiple parameters as invalid_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 as HardDelete<history::Revision> stays that exact resource after module and import resolution.
    • Plain forbid members in a rule derive nothing.
    • A final_forbidden_effect from a rule names the first when trait as its trait.

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.

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.

  1. If the rule needs data the model lacks, add the field to Model in model.ts and fill it in the matching lowerer under lowering/. Add a Fact variant as well only if includeFacts consumers should see the data.
  2. Add the diagnostic variant to SemanticDiagnostic in model.ts, with filePath and a causedBy list built with describeProvenance. Add its case to formatDiagnostic in diagnostics.ts; bun run typecheck fails until the switch covers the new kind.
  3. Write the check in the matching module under rules/, as a function that takes the Model (plus any option values it needs) and returns SemanticDiagnostic[]. Read typed indexes and derivations.ts helpers, never model.facts.
  4. Add an entry to SEMANTIC_CHECKS in rules.ts that passes it the model and those option values.
  5. Add a passing model under fixtures/pass/<name>/ and a failing one under fixtures/fail/<name>/, and cases in packages/shp-checker/src/checker.test.ts that load them. checker.test.ts loads each fixture by path, so a fixture that no test names is never checked. The formatter round-trip test and bun run format:check do scan every fixture, so run bun run format:shape on new fixtures.
  6. Document the printed form, cause, and fix in docs-site/src/content/docs/reference/diagnostics.md. The RuleEngineDocs binding in shape/delivery.shape requires a docs change when rules.ts, rules/**, diagnostics.ts, or shape/checker.shape changes.
  7. Model the new function on ShapeRuleEngine in shape/checker.shape, with its source and complete effects. packages/shp-checker/src/checker/**/*.ts is governed by the CheckerSource implementation, so coverage fails unless a current Shape update or attestation names the changed rule module.
  8. Run the contributor checks listed in CONTRIBUTING.md.

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 rule fact records the rule name, not its conditions or forbidden actions.
  • A context_required fact omits satisfiedBy and requiresDescription.
  • 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_forbid fact is emitted for plain forbid members too and carries no final flag, and per-resource and rule-derived forbids are not facts.
  • Declaration-level modify and remove change 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/.