Skip to content

Semantic Rule Checking

import { Aside, Code, Tabs, TabItem } from ‘@astrojs/starlight/components’;

CARI’s layers-check and boundary-violations commands detect violations in the import graph — which module imports which. Semantic rules go one level deeper: they detect violations in the AST of each file — what a module does inside its own code.

Import-graph violations (layers-check):
File A ──imports──► File B ← edge in the import graph
Semantic violations (rules-check):
File A ──accesses──► item.resource.path ← property access chain
File A ──calls─────► parseRef() ← function invocation
File A ──defines───► itemsById Map ← forbidden symbol pattern
File A ──imports───► packages/data/** ← import path pattern

This matters because import boundaries can be clean while the code still violates architectural intent. A UI component that accesses item.resource.path and applies domain parsing logic may have legal imports — but it’s doing resolver work that belongs in the adapter layer.

Terminal window
# Extract rules from an ADR
iw intent extract docs/ADR-003.md --provider openai --output .iw/rules.yaml
# Check all three domains
iw intent check
iw intent check --domain structural # import + AST rules only
iw intent check --domain behavioral # Mermaid sequence/flow rules
iw intent check --domain documentary # coverage + stale docs + terminology
iw intent check --domain all # explicit all-domains pass
# CI: only changed files, only high severity
iw intent check --changed src/auth.ts --severity high --format json
# Regression gating: fail only if violations increased
iw intent check --baseline .iw/baseline.json

Rules live in .iw/rules.yaml (or the path given to --config):

version: 1
# Optional: explicit allowed layer flows for the prescriptive diagram (§17.2)
allowed:
- from_layer: apps/ui
to_layer: apps/api
description: "UI may call the API gateway"
rules:
- id: no-internal-field-access-in-ui
description: "UI components must not access internal resource fields directly"
adr: ADR-001
severity: high
forbidden:
- type: property_access
chain: "**.resource.path"
in: "apps/ui/src/components/**"

When present, the allowed: list declares which layer-to-layer flows are explicitly sanctioned. This powers the green arrows in the Prescriptive Architecture Diagram. When absent, the diagram derives permitted flows from layer order automatically.

FieldRequiredDescription
from_layeryesSource layer name (must match a layer in .iw/layers.yaml or inferred)
to_layeryesDestination layer name
descriptionnoRationale shown in the CLI and diagram hover panel

Each rule has one or more forbidden clauses. Every clause has a type:

Flags any import whose resolved path matches the given glob.

- id: no-ui-to-db
severity: high
forbidden:
- type: import_pattern
pattern: "packages/data/**" # glob matched against the imported file path
in: "apps/ui/**" # only check these files

This is the only type that works without full AST extraction — it uses the existing import graph. Use it when the ADR says “layer X must never import layer Y”.

FieldDescription
patternGlob matched against the imported file path
inRestrict to files matching this glob
exceptWhitelist files that are allowed to break the rule
target_layerOnly flag if the imported file belongs to this named layer (prevents false positives when path patterns overlap across packages)

property_access — Forbidden Property Chains

Section titled “property_access — Forbidden Property Chains”

Flags any access to a property chain matching the pattern (e.g. item.resource.path).

- id: no-internal-field-access-in-ui
severity: high
forbidden:
- type: property_access
chain: "**.resource.path" # glob-style chain matcher; ** matches any prefix
in: "apps/ui/src/**"
except: "apps/ui/src/adapters/**"

Chains are extracted from the AST: member_expression nodes deeper than 2 levels are recorded in the property_accesses table with their full chain.

FieldDescription
chainGlob pattern matched against the full property chain (e.g. **.source.path)
inRestrict to files matching this glob
exceptWhitelist files or directories
except_patternWhitelist accesses whose enclosing symbol name matches this regex
taint_propagationtrue to track variables assigned from this chain (§16.1)

Flags any call to a function whose name matches the pattern.

- id: no-parse-path-in-ui
severity: high
forbidden:
- type: call
callee: "parseCategory|parsePath|normalizeRef" # pipe-separated names or regex
in: "apps/ui/src/**"

Combine with context_access to only flag calls that co-occur with a forbidden property access:

- id: no-regex-on-source-metadata
severity: high
forbidden:
- type: call
callee: "match|exec"
in: "apps/**"
context_access: "**.resource.path" # only flag when .resource.path is accessed nearby
FieldDescription
calleePipe-separated name list or regex
inRestrict to files matching this glob
exceptWhitelist files
context_accessOnly flag when co-located with an access chain matching this pattern
taint_propagationtrue to track calls on variables assigned from a tainted source (§16.1)

symbol_name — Forbidden Symbol Declarations

Section titled “symbol_name — Forbidden Symbol Declarations”

Flags any symbol (variable, constant, function, class) whose name matches the pattern.

- id: no-entity-map-in-views
severity: medium
forbidden:
- type: symbol_name
pattern: "itemsById" # exact name or regex
in: "apps/ui/src/components/**"

Use scope to restrict to only exported or top-level symbols:

- id: no-exported-helpers-in-views
severity: low
forbidden:
- type: symbol_name
pattern: ".*Helper$"
in: "apps/ui/src/**"
scope: exported # exported | top-level | any (default: any)
FieldDescription
patternName pattern (exact or regex)
inRestrict to files matching this glob
scopeexported, top-level, or any (default)

cypher — Custom Graph Queries (CypherLite)

Section titled “cypher — Custom Graph Queries (CypherLite)”

Run a custom Cypher query against the CARI SQLite graph. Returns one row per violation. The query must return columns file, line, and detail.

- id: no-untested-public-api
description: "Exported functions in packages/ must have a corresponding test"
severity: medium
forbidden:
- type: cypher
query: >
MATCH (s:Symbol {kind: 'function', exported: true})
WHERE s.file STARTS WITH 'packages/'
AND NOT EXISTS {
MATCH (t:Symbol) WHERE t.file CONTAINS '/test/' AND t.name CONTAINS s.name
}
RETURN s.file AS file, s.line AS line, s.name AS detail

CypherLite supports a subset of Cypher syntax directly against the SQLite schema — no Neo4j required. Use this for custom cross-table checks that the built-in types cannot express.


variable_assignment — Forbidden Assignment Patterns

Section titled “variable_assignment — Forbidden Assignment Patterns”

Flags variable assignments where the right-hand side matches a pattern.

- id: no-hardcoded-base-url
severity: medium
forbidden:
- type: variable_assignment
rhs_pattern: "https?://[^/]+\\.(prod|staging)\\.example\\.com"
in: "src/**"
except: "src/config/**"

property_chain_length — Overly Deep Property Access

Section titled “property_chain_length — Overly Deep Property Access”

Flags any property chain access exceeding a maximum depth.

- id: no-deep-access-in-ui
description: "Warn when UI accesses deeply nested properties (often a layering smell)"
severity: low
forbidden:
- type: property_chain_length
max_length: 4 # flag chains with more than 4 segments
in: "apps/ui/src/components/**"

taint_propagation — Track Values Through Variable Assignments

Section titled “taint_propagation — Track Values Through Variable Assignments”

When a forbidden property_access or call result is stored in a local variable, the violation can silently escape detection if only the original expression is checked. Enable taint_propagation: true to have CARI follow intra-function def-use chains and flag any subsequent access or call through tainted variables.

- id: no-internal-field-access-in-ui
severity: high
forbidden:
- type: property_access
chain: "**.resource.path"
in: "apps/ui/src/**"
taint_propagation: true # also flag uses of vars assigned from .resource.path

Without taint propagation, this code is not flagged:

apps/ui/src/components/ItemCard.tsx
const path = item.resource.path; // direct access — flagged ✓
const label = path.split("/").pop(); // indirect use — NOT flagged without taint_propagation

With taint_propagation: true, the assignment const path = item.resource.path taints the variable path, and any subsequent property_access or call on path within the same function is also reported as a violation.

FAIL no-internal-field-access-in-ui [high]
apps/ui/src/components/ItemCard.tsx:4 item.resource.path (direct)
apps/ui/src/components/ItemCard.tsx:5 path.split (taint: path ← item.resource.path)

Taint tracking is intra-function only — it does not follow values across function boundaries or module exports. For inter-procedural analysis, combine with a call rule that targets the function that consumes the tainted value.

FieldTypeDefaultDescription
taint_propagationbooleanfalseFollow intra-function def-use chains from the forbidden expression

By default, each occurrence in a file counts as a separate violation. Use count_mode: per_file to report at most one violation per file:

- id: no-any-cast-in-services
severity: low
count_mode: per_file # at most one violation per file (not per occurrence)
forbidden:
- type: symbol_name
pattern: "as any"
in: "packages/services/**"

Attach a human-readable fix hint that appears in rules-check output and JSON reports:

- id: no-source-path-in-ui
severity: high
autofix:
suggestion: "Use entity.properties.displayPath instead of entity.source.path"
docs_url: "https://example.com/adr/ADR-003#resolver-layer"
forbidden:
- type: property_access
chain: "**.source.path"
in: "apps/ui/**"

A rule may carry an expresses block to define named components and their flows for the Prescriptive Architecture Diagram. These are visualization-only rules — they produce no CI violations.

- id: adr003-provider-adapter-flow
description: "Intended ADR-003 pipeline: Providers → Adapters → Pipeline Workers"
adr: ADR-003
severity: low
expresses:
elements:
- name: SourceProvider
kind: component # component | interface | service | store
layer: "packages/providers"
- name: AdapterParser
kind: component
layer: "packages/adapters"
- name: PipelineWorker
kind: component
layer: "packages/pipeline"
flows:
- from: SourceProvider
to: AdapterParser
policy: allowed
kind: data
- from: AdapterParser
to: PipelineWorker
policy: allowed
kind: control
forbidden: [] # explicit empty — no CI enforcement

Combine expresses with a real forbidden clause to both visualize intent and enforce it:

- id: no-ui-to-db
expresses:
elements:
- name: UILayer
kind: component
layer: "apps/ui"
- name: DatabaseLayer
kind: component
layer: "packages/data"
flows:
- from: UILayer
to: DatabaseLayer
policy: forbidden
forbidden:
- type: import_pattern
pattern: "packages/data/**"
in: "apps/ui/**"
Terminal window
# Check all rules, all files
iw index rules-check
# ASCII conformance diagram (default) — omit with --no-diagram
iw index rules-check --no-diagram
# CI: only changed files, only high severity, JSON output
iw index rules-check --changed src/auth.ts,src/data/repo.ts \
--severity high --format json
# Check a single rule by ID
iw index rules-check --rule-id no-source-path-in-ui
# Use a custom config path
iw index rules-check --config infra/rules.yaml

By default rules-check prints an ASCII conformance diagram summarising the layer topology:

Architecture Conformance
[OK] apps/ui ──✓──▶ apps/api UI may call the API gateway
[OK] apps/api ──✓──▶ packages/services API may use domain services
[HIGH] apps/ui ─────▶ packages/data no-ui-to-db: 3 violation(s)
● no-service-names-in-ui [MED]
15/17 rules clean · 3 total violation(s)

Suppress the diagram with --no-diagram for minimal CI output.

FlagDefaultDescription
--config <path>.iw/rules.yamlPath to rules.yaml
--changed <files>(all files)Comma-separated changed file list for incremental CI
--severity <level>lowMinimum severity: high, medium, or low
--rule-id <id>(all rules)Check only a specific rule
--limit <n>100Maximum violations to report
--format <fmt>textOutput format: text or json
--no-diagram(off)Suppress the ASCII conformance diagram
--db <path>(auto)Path to index.db

Use rules-extract to have an LLM read your ADRs and draft a rules.yaml:

Terminal window
# Basic extraction — forbidden rules only
iw index rules-extract docs/ADR-003.md --provider openai --output .iw/rules.yaml
# Also synthesize allowed: entries from ADR prose (§17.3)
iw index rules-extract docs/ADR-003.md docs/ADR-005.md \
--provider openai \
--output .iw/rules.yaml \
--with-allowed
# Also extract architectural layer hints
iw index rules-extract docs/ADR-003.md \
--provider openai \
--output .iw/rules.yaml \
--with-allowed \
--with-layer-hints \
--layers-output .iw/layers.hints.yaml
# Append newly found rules without overwriting existing ones
iw index rules-extract docs/ADR-006.md --provider openai \
--output .iw/rules.yaml --append
FlagDefaultDescription
--provider <name>openaiLLM provider: openai or smart-mock
--model <name>gpt-4oModel name
--api-key <key>$OPENAI_API_KEYAPI key override
--output <path>(stdout)Output path for the rules.yaml draft
--append(off)Append new rules, skipping duplicate IDs
--with-allowed(off)Also extract explicit allowed: permission entries
--with-layer-hints(off)Also extract architectural layer hints
--layers-output <path>.iw/layers.hints.yamlOutput path for layer hints YAML
-v, --verbose(off)Show prompt and response details

The extracted YAML is a draft — always review before committing. The LLM only emits rules for constraints that are explicitly stated in the ADR.

.github/workflows/arch.yml
- name: Check architectural rules
run: |
iw intent check \
--changed "${{ steps.changed.outputs.files }}" \
--severity high \
--format json > violations.json
jq -e '.violations | length == 0' violations.json

See GitHub Actions / CI for a full workflow example.

Projects that use TypeScript path aliases (@app/*), Webpack aliases, or Docusaurus @site get automatic alias resolution after every iw index build. CARI reads compilerOptions.paths from tsconfig.json and tsconfig.base.json (following extends chains) and rewrites aliased import specifiers in the index before any rule checks run.

Without this, a rule like no-cross-package-imports would see @site/src/foo as an unresolved external import and raise false positives.

For aliases not defined in tsconfig.json, add them to .iw/config.yaml:

.iw/config.yaml
aliases:
"@site": "microsite" # Docusaurus — maps @site/… → microsite/…
"@app": "packages/app/src"

Manual entries win on conflict with auto-detected aliases.

intent_check and cari_rules_check are available as Copilot tools:

MCP ToolPurpose
intent_checkCheck all domains (structural / behavioral / documentary)
cari_rules_checkStructural domain only (direct)
@workspace Does this change violate any ADRs?
version: 1
allowed:
- from_layer: apps/ui
to_layer: apps/api
description: "UI may call the API gateway"
- from_layer: apps/api
to_layer: packages/services
description: "API handlers use domain service interfaces"
- from_layer: packages/services
to_layer: packages/data
description: "Services access repositories"
rules:
- id: no-ui-to-db
description: "UI must never import the data layer directly"
adr: ADR-003
severity: high
forbidden:
- type: import_pattern
pattern: "packages/data/**"
in: "apps/ui/**"
- id: no-source-path-in-ui
description: "UI components must not access entity.source.path"
adr: ADR-003
severity: high
autofix:
suggestion: "Use entity.properties.displayPath instead"
forbidden:
- type: property_access
chain: "**.source.path"
in: "apps/ui/src/components/**"
- id: no-ref-resolution-in-ui
description: "UI components must not resolve $ref strings"
adr: ADR-003
severity: medium
forbidden:
- type: call
callee: "refToId|idToName"
in: "apps/ui/src/components/**"
- type: property_access
chain: "**.$ref"
in: "apps/ui/src/components/**"
except: "apps/ui/src/components/resolver/**"
- id: no-entitybyid-in-views
description: "entityById maps must not be built in view components"
adr: ADR-003
severity: medium
forbidden:
- type: symbol_name
pattern: "entityById"
in: "apps/ui/src/components/views/**"
- id: adr003-pipeline-flow
description: "Intended pipeline: Providers → Adapters → Workers (viz only)"
adr: ADR-003
severity: low
expresses:
elements:
- { name: SourceProvider, kind: component, layer: "packages/providers" }
- { name: AdapterParser, kind: component, layer: "packages/adapters" }
- { name: PipelineWorker, kind: component, layer: "packages/pipeline" }
flows:
- { from: SourceProvider, to: AdapterParser, policy: allowed, kind: data }
- { from: AdapterParser, to: PipelineWorker, policy: allowed, kind: control }
forbidden: []