Skip to content

feat: cross-model imports, slice 1 (local imports) - #34

Merged
jbeda merged 22 commits into
mainfrom
feat/imports-slice1
Jul 27, 2026
Merged

feat: cross-model imports, slice 1 (local imports)#34
jbeda merged 22 commits into
mainfrom
feat/imports-slice1

Conversation

@jbeda

@jbeda jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Implements slice 1 of #25: a model can reference an item defined in another model in the same repository. Design is ADR-0010, amended by ADR-0012 (included here).

Not in this change: provenance headers, surface digests, modelith deps commands, anything touching the network, and qualified references in relationship.entity/subtypeOf. Those are slices 2 and 3.

The shape

imports:
  - ./payments.modelith.yaml          # binds scope "payments" from the stem
  - scope: billing                    # explicit, for the rare mismatch
    path: ./legacy/pay-v2.modelith.yaml

entities:
  Ticket:
    attributes:
      - name: paidWith
        type: payments.PaymentMethod

The importer binds the scope. There is no scope: field in the format. This reversed a decision mid-implementation: the original design had each file declare its own slug, which the renderer then could not resolve without opening the file — and the renderer is forbidden from opening imported files, because its output must stay a pure function of the model being rendered. ADR-0012 records the reversal; ADR-0010 and ADR-0011 are untouched, per the never-edit-in-place rule.

The string-or-object union mirrors actions, which is already a bare string or an object.

Behaviour

Errors: a qualified type whose scope is not bound, or which names no enum in that file, or which resolves to a non-enum; two imports binding the same scope; a bare-form import whose stem is not a valid slug (the message says to use the explicit form); an import path that is absolute, unreadable, or not a model.

Completeness finding (so --completeness error promotes it, like unused enums): an import that is never referenced.

The existing unqualified behaviour is unchanged — an unqualified PascalCase type that matches no enum is still a warning, because it might be a primitive the author invented. A qualified name has no such excuse. The asymmetry is deliberate and documented.

Notable

  • lint.Run now takes a path and a FileReader seam. It previously took bytes only, with no way to locate or read a sibling. fs.FS was rejected because fs.ValidPath forbids .., which the peer-directory layout requires. Tests use a map-backed fake, per .claude/rules/testing.md.
  • The renderer never opens imported files. It emits an Imports section and deep links (./payments.modelith.md#paymentmethod) purely from what the importing model declares.
  • Mermaid is untouched — the ER does not render attribute types, so a dotted type never reaches it.
  • Worked example lives in docs/05-parking-garage/, which is globbed by path in Taskfile.yml and ci.yml, so no glob edits were needed. The imports material is an advanced section at the end of index.md.
  • TestADR_0009_NoSelfModel's allowlist grew by one, as designed.

Review status

Draft. task check green and hack/mermaid-check.sh clean (17 blocks), but the review loop has not run yet.

Flagged by the implementer for a reviewer's eye: Import.ScopeFromPath (json:"-") exists so the invalid-slug error fires only on the bare form. It is the one piece of state on a format struct that the schema does not describe.

Refs #25

🤖 Generated with Claude Code

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Review round 1 — adversarial pass (approximation, not /code-review)

Per .claude/rules/agent-workflow.md as amended in #33: /code-review is user-invoked, so this round is the approximation — the adversarial binary run against hostile fixtures. It is not a substitute for /code-review high, which has not yet run on this branch.

Full matrix: .scratch/reviews/imports-slice1-adversarial.md. 63 fixtures at .scratch/reviews/imports-slice1-fixtures/, rerunnable via .scratch/adv/run.sh.

2 High, 5 Medium, 4 Low. No panic, hang, or recursion. Non-transitivity, mutual and self imports, render determinism, and base-corpus regression all came back clean.

High — both confirmed by hand, both fixed

H-1/H-2 — injection through imports[].path. The renderer interpolated the raw path into link labels and targets unescaped, while every other string in markdown.go went through mdCell/oneLine. Fixture 61-clean-injection lint-passed at --completeness error (exit 0) and rendered a live <img src=x onerror=alert(1)>; a newline injected a real ## heading.

I reproduced both before the fix and re-verified after: labels are now backslash-escaped plain text, targets percent-encoded, and codeSpan fences past backtick runs. Lint additionally rejects control characters in a path. The adversarial agent's summary of the root cause is the useful part — the linter's path checks and the renderer's link construction were wholly independent, so "lints clean" gave no protection to the published .md.

Medium and Low — all fixed in 45443af

M-1 mdCell ran after link assembly, escaping | inside the URL → dead link
M-2 a.b.C, .C, payments. were silent; the plausible typo payments.v2.PaymentMethod passed
M-3 runImports ignored structuralOK, then advised impossible syntax ("reference … as .Name")
M-4 an imported model's version was never checked; v99 was silently mined
L-1 raw newline broke line-wise diagnostic output
L-2 three scope regexes disagreed on minimum length; a.modelith.yaml was unimportable
L-3 one unbound scope produced 201 errors; now one finding with a count
L-4 a duplicate scope was masked by an unreadable second binder

L-2's fix is worth a reviewer's eye: model.ScopeSlug is now the single definition, guarded by TestInvariant_ScopeSlugMatchesSchema.

M-5 — held back as a design decision, then implemented (9f4d4c9, ADR-0013)

Import paths could walk anywhere, and the four distinct outcomes formed an existence-and-permission oracle. Resolution is now confined to the enclosing repository — the nearest ancestor with a .git entry — falling back to the model's own directory when there is none.

Verified by hand: an import escaping the repo is rejected, and so is a prefix-sharing sibling (repo-evil beside root repo), which is exactly what a strings.HasPrefix containment check would have admitted. The oracle's three distinct answers now collapse to one diagnostic.

Three traps are covered by tests, each verified by mutating the implementation until the specific test failed: .git is a file in a linked worktree; symlinks resolve before containment; containment uses filepath.Rel.

The agent also caught an error in my spec — I had written that a prefix-sharing sibling should "resolve", which describes the bug rather than the fix. It implemented the rejection and flagged the discrepancy instead of following the spec off a cliff.

Filed separately

#35 — a backtick in attributes[].name, attributes[].type, an action name or a role breaks out of its code span. Pre-existing on main, not from this PR, so it is not fixed here. Sibling of #29.

Still needed

task check green, hack/mermaid-check.sh clean (17 blocks), goldens regenerated and diffs read. Staying in draft until /code-review high has run — this touches schema/struct sync, lint rules and renderer determinism, and an approximated round is not grounds to flip a correctness-critical change to ready.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 1 record

Source: real /code-review high #34, run by @jbeda. Not an approximation. The reviewer built the branch binary and ran it against adversarial fixtures; go test ./... and render-check were clean in a normal checkout.

Seven findings. F1–F6 carry executable repros (CONFIRMED); F7 is a UX nit.

# Location Finding
F1 internal/lint/root.go:24 resolutionRoot walks the unresolved ancestor chain but compares candidates after realPath(). A model reached through a symlinked directory gets a root from a different tree, rejecting a same-directory import — and the error names the wrong repo.
F2 internal/lint/imports.go:89,144 The FileReader seam doesn't cover containment; resolutionRoot/realPath/absolute hit the OS directly. TestImports_Resolution/parent-relative import resolves passes only because internal/lint sits under the repo's real .git. Fails in any non-git checkout.
F3 internal/schema/v1/modelith.schema.json:250 checkQualifiedTypes runs unconditionally, so any dot in an attribute type is a hard error even with no imports:decimal(10.2), google.protobuf.Timestamp break previously-clean models. The schema's own type description doesn't mention the reservation.
F4 internal/lint/lint.go (runCompleteness) A pure vocabulary model fails --completeness error on unused enums, with no escape hatch — exactly the factoring the new docs recommend.
F5 internal/model/model.go:92 ScopeFromPath mishandles .modelith.yml, yielding payments.modelith. The bare import form is unusable for an extension RenderedPath/defaultOut otherwise treat as first-class.
F6 internal/lint/lint.go:116 reportQualifiedEntityRefs forces structuralOK false, silently suppressing every import diagnostic until an unrelated mistake is fixed.
F7 internal/render/markdown/markdown.go:88 Imports list labels links with the .yaml path but targets the .md.

Design calls made before the fix round

  • F4 → explicit shared semantics. A top-level shared: true marks a model as having expected incoming external references, relaxing the completeness checks that flag locally-unreferenced definitions. Schema change: new property + struct field (TestSchemaStructSync guards the pair) + schema-reference docs. Staying on v1.
  • F6 → report both layers. The qualified-ref finding stops gating the imports layer, so everything surfaces in one run.

Checked and clean

Containment against .. escape, absolute paths, in-repo symlinks pointing out, dangling symlinks, and symlink loops (bounded at 40 hops); read and check use the same lexically-cleaned path, so no lexical/OS divergence. Markdown escaping against ] ( ) | < > \`` injection. Self-import and mutual imports terminate; no nil deref on imp.model`. CI globs already cover the new parking-garage example; no render drift.

Fix round in progress. Staying in draft until it clears.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 1 fixes, independently verified

All seven findings fixed across 8 signed-off commits (a71337f..6ddbeb2), +499/−94 over 15 files. task check green: vet, staticcheck, golangci-lint (0 issues), go test -race, lint-models, render-check, validate-plugin.

Fix Commit
F1 Walk seeded with the resolved dir a71337f
F2 FileReaderFiles (+ResolutionRoot/Resolve); the fake answers both 164bbca
F3 Check kept; JSON Schema type description + docs now state the reservation 20cef93
F4 Top-level shared: true — schema + struct + lint + docs, still v1 c53237a, 6ddbeb2
F5 Both .modelith.yaml and .modelith.yml stripped whole 72d2ec2
F6 Both layers report in one run c717ed3
F7 Path as code span, link labelled [rendered]; golden regenerated fedfb3d

Verification — repros run against base and HEAD binaries, not taken on trust

F1home/ is a git repo, home/work → realrepo/models, realrepo is a separate git repo. Linting home/work/garage.modelith.yaml, whose import is ./payments.modelith.yaml:

  • base: error [semantic] /imports/0: ... outside ".../f1/home" — that directory is the repository holding this model (a same-directory import rejected, and home named as the repo when it isn't)
  • HEAD: ok

F2git archive extraction with no .git anywhere:

  • base go test ./internal/lint/: FAIL TestImports_Resolution/parent-relative_import_resolves
  • HEAD, same extraction: ok

Containment was not loosened by the F1 fix — both escapes still rejected on HEAD, and now naming realrepo (the correct root) rather than home:

  • ../../outside/payments.modelith.yaml → rejected
  • in-repo symlink sneaky.modelith.yaml → outside/payments.modelith.yaml → rejected

That second case also demonstrates F6: the qualified-ref error and the import error both appear in one run.

F4 — real payments.modelith.yaml with its method attribute removed, so PaymentMethod is genuinely unused locally:

  • without shared: true: warning [completeness] /enums/PaymentMethod: enum "PaymentMethod" is defined but no attribute uses it
  • with shared: true: ok

F5imports: [./payments.modelith.yml]: base gives an invalid-slug error plus an unbound-scope error; HEAD is ok.

F7 — renders as - **`payments`** — `./payments.modelith.yaml` ([rendered](./payments.modelith.md)). Source path is a code span, not a mislabelled link.

Judgment calls recorded rather than guessed (both reversible)

  1. shared: true relaxes the unused-enum and unused-glossary checks only — not "no scenario exercises entity X" (a content gap) and not the unreferenced-import finding.
  2. docs/05-parking-garage/payments.modelith.yaml is marked shared: true so the field has a worked example, with a paragraph added to the walkthrough so the surrounding "needs no changes at all" prose isn't left contradicted.

Still open

The fix round added a new schema field and refactored the containment seam — a new surface that round 1 never saw. Staying in draft pending a real /code-review on the delta. The branch is also 2 commits behind main and wants a rebase before merge.

@jbeda

jbeda commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 2 record

Source: real /code-review high #34, run by @jbeda. Second full pass — the built-in has no incremental mode, so it re-reviewed origin/main...origin/feat/imports-slice1 from scratch rather than just the round-1 fix delta. Method: hunk-by-hunk read, then the branch binary built in a throwaway worktree and run against ~20 hostile fixtures (escapes via .. and symlinks, dangling links, duplicate scopes, malformed dotted types, unreadable/directory/non-model/future-version imports, control chars in paths, no-repo confinement, mutual and self imports, non-map entities, non-list relationships, cwd variation), compared against a main-built binary where it mattered.

No regressions from the round-1 fixes

The reviewer independently validated the containment logic it had flagged in round 1: the lexical filepath.Join cleaning happens before both the containment check and the read, so the two cannot disagree; the dangling-symlink hand-follow is correct; and maxSymlinkHops cannot be used to bypass the boundary because the OS's own ELOOP limit is lower. The suppression bookkeeping (qualifiedcollectLeaves, claimed → reference sites, used → unused-import) dedupes correctly across the cases it could construct.

Neither of the round-1 design calls (F3's unconditional dotted-type check, F4's shared semantics) was re-raised as an objection.

New findings

Location Severity Finding
R2-1 cmd/modelith/main.go:232 medium render -o <dir>/x.md and render --stdout emit import links relative to the source directory, so they dangle. renderImports/typeCell build links from model.RenderedPath(imp.Path) with no knowledge of the output location. The new comment above defaultOut claims the two "cannot disagree about where a model's .md lives" — true only when -o/--stdout are absent.
R2-2 plugin/skills/domain-model-author/SKILL.md:134 medium The shipped authoring skill was never updated for the breaking change to attribute type. It still describes only primitives and PascalCase enum names — nothing about a dot being reserved, nothing about imports/shared. An agent following the skill can emit decimal(10.2), money.Amount, or google.protobuf.Timestamp and hit an error the skill gave it no way to anticipate. CLAUDE.md states the plugin ships next to the binary so the two stay in lockstep.
R2-3 internal/lint/imports.go:74 low An import referenced only from an entity position gets contradictory advice: both the correct "cross-model reference not supported in an entity position" error and import ... is never referenced — drop it. Dropping it is the wrong fix. checkQualifiedTypes marks used only from attribute types; reportQualifiedEntityRefs already knows the scope was reached for.
R2-4 internal/lint/lint.go:318 low Cross-model refs in entity positions go unreported when the schema version is unsupported. reportQualifiedEntityRefs runs inside runStructural after the version early-return, but runSemantic/runSubtypes skip qualified values assuming it ran. You get an error either way, but the skip is guarded by a reporter not guaranteed to have run.
R2-5 docs/06-schema-reference.md:448 low The semantic-error list omits three classes the linter actually emits: resolving outside the repository root, a control character in the path, and an imported model declaring an unsupported schema version.

R2-2 is the notable one: it is a downstream consequence of the F3 call from round 1. Keeping the dotted-type check unconditional was right, but the shipped skill is what agents actually read, and it was left describing the old contract.

Fix round 2 to follow. Staying in draft.

@jbeda

jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 2 fixes, independently verified

All five round-2 findings fixed across 3 signed-off commits (1a4fcfc..e07eebd), +413/−66 over 12 files. task check green: vet, staticcheck, golangci-lint, go test -race, lint-models, render-check, validate-plugin.

Fix Commit
R2-1 markdown.Render takes sourceDir, outDir; main.go computes both via filepath.Abs; --stdout keeps source-relative 1a4fcfc
R2-2 All three plugin/skills/*/SKILL.md updated for the dot reservation, imports:, shared: e07eebd
R2-3 runImports treats scopes reached from entity positions as used 24572a4
R2-4 runStructural calls reportQualifiedEntityRefs even on the unsupported-version early return 24572a4
R2-5 Semantic-error list names the boundary, control-character, and import-version cases 1a4fcfc

Verification — repros run against the built branch binary

R2-1 — rendered src/garage.modelith.yaml to a sibling out/main.md:

- **`payments`** — `./payments.modelith.yaml` ([rendered](../src/payments.modelith.md))
| `paidWith` | [payments.PaymentMethod](../src/payments.modelith.md#paymentmethod) | ... |

Both link kinds relativized, not just the Imports list. The target resolves to a real file once the import is rendered in place. --stdout still emits ./payments.modelith.md, as specified. Default output (no -o) is byte-identical to the committed golden — confirmed by diff, independent of render-check.

Semantics implemented, since the CLI cannot know what filename an imported model was rendered under: target = the imported model's default rendered path, expressed relative to the output file's directory. It resolves whenever the import was rendered in place, which is the documented convention. It does not assume the import was rendered into the output directory.

R2-3 — model importing payments whose only use is subtypeOf: payments.Payment: the correct entity-position error fires, and the contradictory never referenced — drop it warning is gone (grep count 0).

R2-4 — same model at version: v99: now reports both the version error and the qualified-ref error. Previously only the version error.

R2-2 — five type forms checked against the binary, each matching what the skills now claim:

type Result
decimal(10.2) error — malformed cross-model reference, scope decimal(10 not kebab-case
payments.Payment error — resolves to an entity; only an enum can be referenced across models
payments.Nonexistent error — names no such enum in the imported model
money.Amount error — scope money bound by no import
payments.PaymentMethod clean

Loop status

Two review rounds done, both real user-invoked /code-review high. Round 1: 7 findings, 2 blocking. Round 2: 5 findings, 0 blocking, and no regressions from the round-1 fixes. Converging.

Round 2's fixes changed a renderer signature and rewrote three shipped skills — a surface no review has seen. Under .claude/rules/agent-workflow.md the loop allows one more round. Remaining before merge: the third review (or a decision to skip it), and a rebase — the branch is 2 commits behind main.

jbeda added 22 commits July 26, 2026 17:34
A model may declare a kebab-case `scope:` slug and list `imports:` — paths to
peer model files, relative to itself — so an item defined elsewhere can be
referenced as `slug.Name` (ADR-0010). Both are optional; the slug is declared
by the imported model and never restated by the importer.

Schema and structs move together, per TestSchemaStructSync.

Signed-off-by: Joe Beda <joe@stacklok.com>
lint.Run now takes the model's path and a one-method FileReader seam so it can
read the peer models an `imports:` list names. fs.FS was rejected: fs.ValidPath
forbids "..", which peer models in a sibling directory require.

Resolution is non-recursive (ADR-0010): only items defined directly in a listed
file are reachable, so mutual imports terminate with no cycle detection. An
unresolvable qualified type is an error, where an unqualified PascalCase type
that names no enum stays a warning — it may be a primitive the author invented,
while scope.Name can only be a cross-model reference.

A qualified value in an entity position (relationship.entity, subtypeOf) gets a
friendly error ahead of the schema's opaque pattern violation, which is then
suppressed so one mistake reads as one finding.

Signed-off-by: Joe Beda <joe@stacklok.com>
Adds an Imports section to the schema reference covering the two new fields,
the non-recursive resolution rule, the entity-position deferral, and why an
unresolvable qualified type is an error while an unqualified PascalCase one is
only a warning. Amends the bounded-contexts omission, which claimed the format
had no cross-context reference at all.

Signed-off-by: Joe Beda <joe@stacklok.com>
…ed file

Supersedes the binding introduced in 04d34be. A model no longer declares a
top-level `scope:`; each `imports:` entry binds one instead, defaulting to the
file's basename with .modelith.yaml stripped, with an explicit {scope, path}
object for a filename that is not a usable slug or is already bound. The union
follows the precedent `actions` set.

The cause was the renderer. Its links into an imported model's Markdown need to
pair a scope with a path, and it may not open imported files to learn that
pairing, so a scope declared by the imported file was unresolvable where it was
needed most. Moving the binding to the importer makes it local, and the renderer
needs no filename convention and no guessing.

Falls out of it: the imported-file-declares-no-scope error is gone, and the
unreferenced-import finding becomes a completeness one, alongside the unused
enum and glossary term whose promotion under --completeness error it shares.

Recorded as ADR-0012.

Signed-off-by: Joe Beda <joe@stacklok.com>
ADR-0012 supersedes ADR-0010's identity decision and the part of its resolution
decision that made the imported file the source of truth for the slug; both
originals are left untouched. The schema reference now documents the union form,
the filename default, and the local nature of the binding.

Signed-off-by: Joe Beda <joe@stacklok.com>
Adds docs/05-parking-garage/payments.modelith.yaml — a small payments context
owning the PaymentMethod enum — and has the garage import it for Ticket.paidWith
rather than keeping a second copy of the same list. Both lint clean under
--completeness error and both .md files are committed; the directory is globbed
by path, so CI picks the new model up with no change to Taskfile.yml or ci.yml.

The imports material sits at the end of index.md, marked advanced: most readers
will not need it for a long time and the teaching flow above it is the point of
the page.

Signed-off-by: Joe Beda <joe@stacklok.com>
An import path reaches a published Markdown page unescaped, in both the
link label and the link destination, while every other string in the
renderer goes through mdCell/oneLine. A path closing the destination and
opening a tag renders a live element and a second link from a model that
lints clean; a path holding a newline injects a heading into the Imports
list and splits a lint diagnostic across two lines.

Escape each value for the position it lands in. A link destination is
percent-encoded outside the URL unreserved set, so parentheses,
whitespace, angle brackets and control characters cannot end it. A link
label is backslash-escaped plain text rather than a code span: a code
span cannot escape the "]" that ends the label, which would leave its
inertness resting on the rule that code spans bind more tightly than
link brackets — the spec says so and widely used renderers disagree. A
code span, where one is still used, is fenced past the longest backtick
run inside it. oneLine now replaces every control character, not only
the line breaks. The parts are escaped before the link is assembled;
escaping the finished link is what left a pipe inside a URL as "\|".

Alongside, the lint layer:

- an import path holding a control character is an error, and every
  import diagnostic quotes the path with %q so an embedded newline
  survives as text instead of a line break;
- an attribute type containing a dot must be exactly scope.Name, and a
  malformed one says which way it is malformed rather than passing as a
  primitive;
- imports resolve only against a document the schema accepted, so a
  rejected scope no longer binds and then advises impossible syntax;
- an imported model declaring an unsupported schema version is an error
  naming the file and the version;
- references to one unbound scope are reported once with a count, and a
  scope whose import failed to load is not blamed at every reference
  site — a single typo was producing 201 errors;
- a duplicate scope binding is settled before the file is opened, so an
  unreadable second binder no longer masks it.

model.ScopeSlug becomes the one definition of a scope slug, consumed by
the linter, the renderer and the schema, which disagreed on the minimum
length: a single-character scope is now legal.

The only golden change is the imports bullet, whose path label loses its
backticks.

Signed-off-by: Joe Beda <joe@stacklok.com>
An import path could walk anywhere. "../../../../etc/passwd" was read,
and the four outcomes a read produces — resolves, missing, unreadable,
holds no model — are distinguishable, so they form an existence and
permission oracle over the whole filesystem. modelith ships an Action
that lints models in CI, so a *.modelith.yaml from a fork could probe
the runner and report what it found through diagnostic text landing in
a pull-request check.

Resolution is now bounded by the nearest ancestor of the linted model
holding a .git entry, or by the model's own directory when there is no
repository above it, since outside one the tool cannot know how far the
project extends. Containment is decided before the file is opened,
because opening it is the leak. The three previously distinct answers
on the oracle fixtures collapse to one diagnostic whose only variable
is the path the author wrote.

Three details decide whether this works at all, each pinned by a case
that fails when the behaviour is removed:

- .git is tested for existence, not for being a directory. In a linked
  worktree and in a submodule it is a regular file holding a gitdir
  pointer, and requiring a directory would collapse the root to a
  single directory in every such checkout — including the worktree this
  was written in.
- Symlinks resolve before the containment test, including a dangling
  one, which is followed via its recorded target. A link is judged by
  where it points, not by where the link file sits. Leaving a dangling
  link unresolved would keep the oracle alive, since "unreadable" is
  one of the four answers.
- Containment is filepath.Rel, not strings.HasPrefix, which would place
  "/repo-evil" inside "/repo".

No flag names the root, so no diagnostic advises one; a test asserts
the string never appears. Recorded as ADR-0013, with the boundary and
the no-repository fallback documented in the schema reference. The
oracle is not closed within the repository, and that is stated rather
than engineered against: anyone who can commit a file to your
repository has better options than a lint diagnostic.

Signed-off-by: Joe Beda <joe@stacklok.com>
resolutionRoot climbed the ancestor chain of the path as written while every
candidate import was judged after symlink resolution, so a model reached
through a symlinked directory was assigned a root from a different tree: a
peer in the same directory was refused, and the diagnostic named a repository
that does not hold the model.

Seed the walk with the resolved directory instead. The two cases added to
TestADR_0013_ImportsConfinedToTheRepository — a peer import through a link
into another repository, and an escape from that same location — both fail
without the change.

Signed-off-by: Joe Beda <joe@stacklok.com>
The FileReader seam covered reads only: resolutionRoot, realPath and absolute
called os.Lstat, filepath.EvalSymlinks and os.Getwd directly, so a caller that
supplied its own reader still had containment judged against the local disk.
The test suite inherited that. TestImports_Resolution/parent-relative_import_resolves
passed only because internal/lint happens to sit under the repository's real
.git; the same tests fail in a copy of the tree without one — a source tarball,
a vendored module, a checkout CI exported rather than cloned.

Widen the seam to the three questions import resolution asks: read the file,
where is resolution confined, and where does this path land. FileReader becomes
Files; OSFiles answers all three from the local filesystem, and the test fake
answers them from its own map, with a ".git" key marking a repository just as
the entry does on disk.

TestImports_ContainmentUsesTheFileSeam pins the behaviour that used to depend
on the ambient tree: with a marker a sibling directory is reachable, without one
resolution stops at the model's own directory. `go test ./...` now passes in a
copy of the tree with .git removed.

Signed-off-by: Joe Beda <joe@stacklok.com>
…rence

reportQualifiedEntityRefs writes its findings before schema validation, and
runStructural counted them when deciding whether the document was structurally
valid. A model carrying both `subtypeOf: payments.Payment` and a broken import
therefore reported only the former: the nonexistent import, and every
unreferenced-import finding with it, stayed hidden until the unrelated mistake
was fixed, at which point a second round of errors appeared unannounced.

Judge structural validity by what the schema said, and let the cross-model
entity reference — a supported-feature limit, not a shape the imports list
depends on — stand as its own finding. Both layers now surface in one run.

Signed-off-by: Joe Beda <joe@stacklok.com>
ScopeFromPath stripped ".modelith.yaml" whole but fell back to trimming only
the final extension, so "./payments.modelith.yml" bound the scope
"payments.modelith" — not a valid slug. RenderedPath and the render command's
default output path both treat .yml as a model file, so the bare import form
was the one place the spelling was not first-class, and it failed twice: an
invalid-slug error on the import, then an unbound-scope error at every
reference site, since an invalid slug never claims its scope.

Strip both spellings whole. Covered at the unit level in TestScopeFromPath and
end to end by a resolution case importing a .modelith.yml peer.

Signed-off-by: Joe Beda <joe@stacklok.com>
…ally

A model that exists to be imported cannot pass --completeness error. The
references that justify its enums and glossary terms live in the files that
import it, which it cannot see, so every enum it publishes collects "defined
but no attribute uses it" and CI fails. The docs recommend factoring exactly
such a model out; docs/05-parking-garage/payments.modelith.yaml escaped only
because Payment.method happens to use the enum locally.

Add a top-level `shared: true`. It is the counterpart of `imports` — one model
declares what it reaches for, the other declares that it is reached for — and
relaxes exactly the checks about a definition nothing in this file uses: the
unused enum and the unused glossary term. It relaxes nothing about content: an
entity with no invariants, or one no scenario exercises, is a gap in the model
itself, which being imported does not fill.

Schema property, struct field (TestSchemaStructSync guards the pair) and the
schema reference land together, and the parking-garage payments model now
declares what it already is.

Signed-off-by: Joe Beda <joe@stacklok.com>
checkQualifiedTypes runs whether or not a model has `imports:`, so any dot in
an attribute type is now an error — `decimal(10.2)` and
`google.protobuf.Timestamp` included. That is deliberate: an unconditional rule
is what makes a typo in a cross-model reference reportable instead of readable
as an exotic primitive. It was documented in the schema reference and nowhere
in the JSON Schema, which is the contract an editor fetches through the
$schema header, and which still described `type` as a primitive or an enum
name.

Say it in both, and pin the unconditional part with two resolution cases in a
model that imports nothing.

Signed-off-by: Joe Beda <joe@stacklok.com>
The Imports bullet rendered as [./payments.modelith.yaml](./payments.modelith.md):
a link that says .yaml and lands on Markdown. Show the source path as a code
span and label the link for what it leads to, so the two files are two things
on the page.

mdLinkText was the imports label's escaper and had no other caller, so it goes
with it; the code span carries the same author text with the fence-widening the
rest of the renderer already relies on. The hostile-path test now checks the
stronger property directly — strip the code spans from a bullet and the fixed
skeleton is all that is left.

Signed-off-by: Joe Beda <joe@stacklok.com>
The walkthrough tells the reader the imported model "needs no changes at all",
which is true of the reference itself and now sits next to a payments model
carrying shared: true. Name the one thing an imported model may want to say,
and why this one says it.

Signed-off-by: Joe Beda <joe@stacklok.com>
render -o <dir>/x.md and --stdout emitted import links relative to the
source directory regardless of where the output landed, so a link built
for outdir/main.md pointed at outdir/payments.modelith.md, which
modelith never wrote. The link target is now the imported model's
default rendered path (RenderedPath resolved against the source
directory) expressed relative to the actual output directory; this
resolves whenever the imported model was itself rendered to its
default location. --stdout has no output file to relativize against,
so its links stay source-relative, as they would from a default
render.

The common case (no -o, both directories equal) is left byte-identical
to avoid gratuitous golden churn; only render -o and --stdout change.
Narrows defaultOut's comment, which previously claimed the renderer's
links and defaultOut "cannot disagree" unconditionally.

Round 2 finding R2-1: .scratch/reviews/imports-slice1.md

Signed-off-by: Joe Beda <joe@stacklok.com>
…rts layer

Two related findings, one fix: reportQualifiedEntityRefs is the sole
authority on cross-model references in an entity position
(relationship.entity, subtypeOf) — unsupported there, but a real
reference to the scope all the same.

R2-3: runImports didn't know that, so an import referenced only from
an entity position got both the "not supported in an entity position"
error and a contradictory "never referenced — drop it" completeness
warning. reportQualifiedEntityRefs now also returns the scopes it
found, and runImports treats them as used, same as an attribute type
would.

R2-4: runSemantic and runSubtypes independently skip a value matching
the qualified-reference pattern, trusting reportQualifiedEntityRefs
already reported it — but runStructural only called it after the
unsupported-schema-version early return, so on an unsupported version
the reference went unreported by anyone: neither the skipped structural
finding nor the semantic/subtype checks that deferred to it. It now
runs before that return too, so the trust the skip relies on always
holds.

Round 2 findings R2-3, R2-4: .scratch/reviews/imports-slice1.md

Signed-off-by: Joe Beda <joe@stacklok.com>
domain-model-author still described the pre-imports attribute `type`
rule ("a primitive in lowercase, or the PascalCase name of a defined
enum") with no mention of the dot reservation, `imports:`, or
`shared:`. An agent following it could emit `decimal(10.2)` or
`money.Amount` and hit an error the skill gave it no way to
anticipate. Verified against a build of this branch: `type:
"decimal(10.2)"` lints clean on main and errors here.

Adds `shared:` and `imports:` conventions to domain-model-author,
narrows the attribute-type bullet to state the dot reservation and the
cross-model-reference form, and updates domain-model-lint's structural/
semantic/completeness breakdown to include the entity-position
cross-model-reference error, the import-resolution errors, the
malformed-type error, and how `shared: true` narrows the completeness
checks it relaxes. domain-model-context gets one line: an `imports:`
entry means the vocabulary a `scope.Name` type names lives in another
file, worth loading too.

Round 2 finding R2-2: .scratch/reviews/imports-slice1.md

Signed-off-by: Joe Beda <joe@stacklok.com>
domain-model-lint/SKILL.md's semantic-error list for imports omitted
the control-character and unsupported-schema-version cases that
docs/06-schema-reference.md gained in the previous round. Verified
both error strings against the built binary rather than paraphrasing
from the docs.

Round 3 finding: .scratch/reviews/r3-docs.md

Signed-off-by: Joe Beda <joe@stacklok.com>
internal/lint/lint.go:195 declared a new, block-scoped entityScopes
with := on the unsupported-version early-return path, shadowing
runStructural's named return instead of assigning it. The explicit
return false, entityScopes right after still returned the correct
(shadowed) value, so this was latent, not live -- but a bare return
relying on the named return would have silently returned nil.

Added TestRunStructural_UnsupportedVersionReturnsEntityScopes, a
white-box test calling runStructural directly (Run() never reaches
this path with structuralOK true, so nothing downstream currently
observes the value). Confirmed empirically that this one-line hunk
alone is not observable by reverting it: the test still passes,
matching the round-3 finding's own characterization. It does fail
against the documented risk (shadow + relying on the named return),
verified separately and not left in the tree.

Round 3 finding: .scratch/reviews/r3-lint.md

Signed-off-by: Joe Beda <joe@stacklok.com>
importLinkTarget joined an import path against sourceDir without
checking filepath.IsAbs, so an absolute import path (a lint error,
but render only runs structural validation, so still reachable)
produced two different link shapes for the same model: unchanged
under the default output location, silently re-rooted under
sourceDir when -o pointed elsewhere. Return the rendered path
unchanged when it's absolute, alongside the existing outDir ==
sourceDir shortcut.

Added TestRenderImports_AbsolutePathIsNotRelativized, confirmed to
fail against the pre-fix code (reverted the guard, verified the
sibling-output-dir case relativizes to a bogus ../models/etc/...
link, then restored).

Round 3 finding: .scratch/reviews/r3-render.md

Signed-off-by: Joe Beda <joe@stacklok.com>
@jbeda
jbeda force-pushed the feat/imports-slice1 branch from 2c8f3aa to e93ab27 Compare July 27, 2026 00:35
@jbeda

jbeda commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Review loop — round 3 record

Source: an APPROXIMATION of /code-review, not the real thing. /code-review is user-invoked and an agent cannot launch it, so per .claude/rules/agent-workflow.md this round used independent reviewer subagents plus an adversarial binary pass. Rounds 1 and 2 were real user-invoked /code-review high runs; this one was not. Scope was the round-2 fix delta (6ddbeb2..e07eebd pre-rebase) only — the earlier surface had already had two full passes.

Three finder angles, read-and-run only:

Angle Model Rationale
Render path relativization, reviewed and attacked Opus Signature change plus new path math — the only genuinely risky code in the delta
Lint bookkeeping (reportQualifiedEntityRefs scopes) Sonnet Small, well-specified; probing seams rather than hunting
Docs/skills lifecycle walkthrough Sonnet Comparing prose to CLI behavior needs judgment, not depth

Findings — no blockers

Finding Disposition
R3-1 plugin/skills/domain-model-lint/SKILL.md import error list had 6 entries where docs/06-schema-reference.md has 8 — missing control-character and unsupported-import-version Fixed, 8de4124
R3-2 internal/lint/lint.go:195 shadowed the named return instead of assigning Fixed, bf84a8b
R3-3 importLinkTarget lacked an IsAbs guard, so an absolute import path rendered two link shapes with and without -o Fixed, e93ab27
R3-4 -o /dev/stdout relativizes against /dev Not fixed, deliberate — --stdout is the sanctioned idiom and is mutually exclusive with -o; handling a character-device output target is complexity for a pathological case
R3-5 Lexical path math is approximate under symlinked directories Not fixed — pre-existing, base behaved identically, unchanged by this PR

R3-2 is hygiene, not a live bug, and the fix agent said so rather than claiming a regression test it didn't have. Verified: the bare-return hazard is compile-blocked today either way, because ok is shadowed by the if v, ok := obj["version"] on line 180.

internal/lint/lint.go:197:5: result parameter ok not in scope at return
	internal/lint/lint.go:180:9: inner declaration of var ok bool

Kept anyway — it is more correct, and the protection matters if that ok shadow is refactored away.

Adversarial pass

21 attack cases, base vs HEAD binaries built outside the repo: sibling/nested/no-shared-ancestor output dirs, relative and ..-containing -o, bare filenames, hostile and non-ASCII path components. Percent-encoding still routes through linkTarget on both call sites. Output determinism is cwd-invariant (four working directories, one md5). Goldens and the no-imports case byte-identical to base.

The lint finder ran positive controls, so its clean result has teeth: a genuinely-unused import alongside an entity-position-referenced one is flagged only for the unused one; malformed qualified values never reach the scopes map; three runs byte-identical.

One out-of-scope note it raised — a shared: true model still flagged for a genuinely unreferenced import — is correct behavior, matching the round-1 decision that shared relaxes unused-enum and unused-glossary only. An unused import is dead weight regardless.

Verified independently before flipping

R3-3: absolute import path now renders [rendered](/etc/payments.modelith.md) both by default and under -o, one shape. R3-1: both new classes present in the skill list. task check green before and after the rebase.

Rebased onto main

The branch was 2 commits behind and has been rebased (--force-with-lease), so all 22 commits have new SHAs. Earlier round records cite the old ones; mapping below.

Old → new SHA mapping
Before rebase After rebase Subject
04d34be edc4732 feat(schema): add scope and imports to the v1 format
f62effb 2546612 feat(lint): resolve local imports and qualified attribute types
c3feb8f 840a9e1 docs: document scope, imports, and cross-model references
eea7699 9399bdd feat(schema)!: bind an import's scope in the importer, not the imported file
d31bfae 3dfba31 docs(adr): record ADR-0012 and align the schema reference
61e7e5f e779cb1 docs(example): reference a peer payments model from the parking garage
45443af df2616e fix(imports): escape import paths and tighten the reference diagnostics
9f4d4c9 83cfc2c feat(lint)!: confine import resolution to the enclosing repository
a71337f 8f62920 fix(lint): take the resolution root from the model's real directory
164bbca 8c5e729 refactor(lint): put import containment behind the file seam
c717ed3 ffcd1fd fix(lint): report the imports layer alongside a qualified entity reference
72d2ec2 7c42c45 fix(model): derive an import scope from a .modelith.yml file too
c53237a 4935a3a feat(schema): mark a model shared so its vocabulary may be unused locally
20cef93 44571aa docs(schema): say a dot in an attribute type is reserved
fedfb3d 1318c66 fix(render): stop labelling the imports link with the source path
6ddbeb2 f9cd4f6 docs(parking-garage): say what shared: true is for where it comes up
1a4fcfc 6fdbd62 fix(render): relativize import links against the output directory
24572a4 01c3899 fix(lint): make entity-position cross-model refs honest with the imports layer
e07eebd 7cff4cf docs(plugin): bring the skills up to the imports/shared contract
eedbb36 8de4124 docs(plugin): add two import error classes to domain-model-lint's list
e7b3611 bf84a8b fix(lint): assign the named return instead of shadowing it
2c8f3aa e93ab27 fix(render): don't relativize an absolute import path

Loop closed

Three rounds, the cap. Round 1: 7 findings, 2 blocking. Round 2: 5 findings, 0 blocking, no regressions from round 1. Round 3: 5 findings, 0 blocking, 3 fixed and 2 deliberately declined. No blocking issues remain open. Marking ready.

Note for whoever merges: rounds 1 and 2 were real reviews; round 3 was not. If you want a real /code-review high #34 over the final rebased state, that has not been run.

@jbeda
jbeda marked this pull request as ready for review July 27, 2026 00:35
@jbeda
jbeda merged commit 6960db1 into main Jul 27, 2026
2 checks passed
@jbeda
jbeda deleted the feat/imports-slice1 branch July 27, 2026 00:48
jbeda added a commit that referenced this pull request Jul 27, 2026
…#35)

Two gaps in the same class, both reachable from any unconstrained string.

A backtick in a code-spanned field closed its span early and dropped the
remainder into the document as live Markdown/HTML (#35). The `codeSpan`
helper from #34 already fences past backtick runs, so this applies it at the
call sites that wrap a schema-unconstrained value: attribute names, enum
value names, action names, and an action's actor. Entity, enum and glossary
keys, `subtypeOf`, a relationship's target and an invariant id are pinned by
schema patterns that exclude a backtick, and `render` validates structurally
before it renders, so they stay as they are.

Table cells route through a new `codeCell`, since a pipe in a name ended the
cell. GFM recognizes a backslash-escaped pipe inside a code span.

Separately, prose fields (`definition`, `description`, a role, a note, an
invariant statement, a scenario step) emitted raw HTML. They stay Markdown --
models backtick their entity names throughout -- but an angle bracket in one
is now a character reference, so a tag renders as the text the author typed.
The pass is code-span aware: inside a span `<x>` is already literal and
escaping it would put a visible "&lt;x&gt;" on the page. An ampersand is
escaped only where it introduces a character reference. ADR-0014 records the
contract and the reasoning.

No committed golden changed: no example holds a backtick or an angle bracket
in any affected field.

Fixes #35

Signed-off-by: Joe Beda <joe@stacklok.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant