Skip to content

fix: close the three open cross-port source-resolution items (print-only writes, C# symlink cycle, strict-config ruling) - #319

Merged
dmealing merged 5 commits into
mainfrom
fix/init-print-only-and-csharp-symlink-cycle
Aug 20, 2026
Merged

fix: close the three open cross-port source-resolution items (print-only writes, C# symlink cycle, strict-config ruling)#319
dmealing merged 5 commits into
mainfrom
fix/init-print-only-and-csharp-symlink-cycle

Conversation

@dmealing

Copy link
Copy Markdown
Member

Closes the three items the cross-port sources work (#315 / #316) left open and documented. Non-breaking, PATCH-shaped: no registry vocabulary, no metamodel change, no adopter-visible default flip. expected-registry.json is untouched.

1. --print-only wrote for real on both agent-context paths

--config-only --print-only was fixed by moving that branch below the printOnly guard. --docs-only and --refresh-docs return from init() above the same guard and were left, so both documented dry runs silently scaffolded: the docs, every stack-scoped skill reference, the manifest, and (with --wire-root) a created root CLAUDE.md.

It was left because the write set is dynamic. It didn't need to be — writeAgentContext already computes the complete plan before writing, so guarding the I/O rather than the report makes the dry run report exactly the real write set, with no second list to drift. --refresh-docs was a second door the original note didn't mention.

2. C# admitted phantom sources on a symlink cycle

Worse than recorded, and measured rather than inferred. Directory.EnumerateFiles(dir, "*", AllDirectories) follows links but has no loop guard, and its EnumerationOptions default of IgnoreInaccessible swallows the kernel's ELOOP. So a self-referential directory symlink did not hang and did not throw — it completed normally, returning 41 copies of one real file at ever-deeper phantom paths. Nothing downstream could recover: SourceResolver de-duplicates on the LEXICAL full path and every phantom is lexically distinct, so each was admitted as its own source and the same metadata loaded once per level. TypeScript, Java and Python all raise; C# alone reported success.

Expand now walks itself with a per-branch real-ancestor set. RealPath resolves component-by-component because Directory.ResolveLinkTarget canonicalizes only the final segment — using it alone leaves a symlinked ancestor unresolved and misses the cycle it exists to catch.

Adding the corpus case exposed a second, wider bug. The corpus README documents expectError: true as leaving the error unpinned, but three of the four runners (C#, Python, Java) asserted a narrow coded type anyway — each would have scored a correct port as failing. Latent only because no prior case exercised the looseness.

3. ConfigSchema.strict() — ruled INTENDED

A genuinely unknown top-level config key throws in TypeScript and resolves fine in the other three. Reviewed and ruled: neither side moves. TypeScript owns the file and models its whole vocabulary, so it is the only port that can tell a typo from a key a sibling owns — and the hazard is concrete (scopes for scope silently means "everything in scope"). The others model the neutral subset and could imitate strictness only by carrying TS's key list in lockstep, at which point a port one release behind would reject a config a newer meta had just written.

Stays off the shared corpus permanently rather than deferred again: a shared case asserts ONE outcome and the correct outcome differs by port by design. Both halves are pinned where they belong instead.

Review + simplify

A review pass over the first three commits found three defects in them, all fixed in 514f7072:

  • The expectError: true widening was too broad — it relaxed six pre-existing malformed-config cases from "a coded parse failure" to "anything at all", so a raw NPE/NullReferenceException/TypeError would have scored as a correct rejection. Scoped to the one case that needs it via a new optional errorIsNative.
  • The cycle guard compared StringComparer.Ordinal while RealPath necessarily mixes lexical and canonical spellings, so on a case-insensitive volume MODEL/loop -> model walked past the guard — back to the ELOOP floor that this port swallows.
  • --print-only suppressed the writes but not the past-tense reporting, announcing wired … into CLAUDE.md for edits it hadn't made.

Then a simplifier pass (f3ba9650) cut RealPath from 13 lines to 2 after verifying — with the process CWD set to / — that ResolveLinkTarget(…, returnFinalTarget: true).FullName already returns fully-qualified resolved paths for relative one-hop targets, relative chains and . self-links, so the removed fallback was dead code.

Verification

Every fix was proven by reverting it and watching the test go red, not by its accompanying green:

  • C# guard reverted → both tests fail with "No exception was thrown", the exact silent-success mode.
  • errorIsNative removed → the cycle case fails in all three coded ports, proving the restored type requirement is live rather than decorative.
  • Both --print-only doors reverted → docs land on disk; the --refresh-docs arm is red on its own.

Full local CI green — all 20 gates, including five-port conformance, the Java reactor and the docker integration suite.

🤖 Generated with Claude Code

dmealing and others added 5 commits August 19, 2026 22:11
`--config-only --print-only` was fixed by moving the config branch below the
printOnly guard. The two agent-context branches — `--docs-only` and
`--refresh-docs` — return from `init()` above that same guard and were left,
so both documented dry runs silently scaffolded for real: the docs, every
stack-scoped skill reference, the manifest, and (with --wire-root) a created
root CLAUDE.md.

The reason it was left is that this write set is DYNAMIC — it depends on the
resolved stack, so it cannot be the hardcoded path list the full-scaffold dry
run uses. It does not need to be. `writeAgentContext` already computes the
complete plan (`planScaffold`) before performing a single write, so the guard
goes at the I/O, not at the report: `result.created` is populated from the same
plan a real run executes, which makes the dry run's output exactly the real
write set with no second list to drift.

`.agent-context.json` joins that report. A real run has always written it while
omitting it from `created`, and the full-scaffold dry run has always named it —
reporting it here settles both against what actually happens on disk.

Proven by reverting: both new tests go red (the docs land on disk), and the
`--refresh-docs` arm is a genuinely separate door, red on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng the type

Following symlinked directories became a cross-port contract in #315, which is
what makes a symlink CYCLE reachable. TypeScript, Java and Python each added a
guard when they picked it up; C# did not, and the failure mode is worse than a
hang. `Directory.EnumerateFiles(dir, "*", AllDirectories)` follows links but has
no loop guard, and its `EnumerationOptions` default of `IgnoreInaccessible`
swallows the kernel's own ELOOP refusal — so a self-referential directory symlink
did not hang and did not throw. It COMPLETED NORMALLY, returning 41 copies of one
real file at ever-deeper phantom paths (measured, not inferred). Nothing
downstream could recover: `SourceResolver` de-duplicates on the LEXICAL full path
and every phantom is lexically distinct, so each was admitted as its own source
and the same metadata loaded once per level.

`Expand` now walks itself, carrying the real ancestor directories on the current
branch and raising on revisit. Resolution is component-by-component from the
root, because `Directory.ResolveLinkTarget` canonicalizes only the FINAL segment:
using it alone leaves a symlinked ancestor unresolved, and the guard then
compares a half-resolved path against a real one and misses the loop — recursing
forever on exactly the input it exists to catch. Ancestors extend only on the
recursive call, never in place, so a diamond still resolves.

The corpus gains `a-symlink-cycle-is-an-error`. Adding it exposed that THREE of
the four runners had quietly re-pinned what the corpus refuses to pin: the
README says `expectError: true` means "raises, type deliberately unpinned", but
C# asserted MetaModelException, Python caught ParseError and Java caught
MetaDataException. Each scored a correct port as a failure — the guards raise
IOException, SymlinkLoopError and FileSystemLoopException respectively. All three
now assert on the base type for the `true` form, and still demand the coded type
for the string form, which is the arm that actually needs it.

The case is documented as a FLOOR. On Linux the kernel's ELOOP makes an unguarded
walk raise eventually anyway, so `expectError: true` cannot tell a real guard from
a late accident; what it DOES discriminate is a port that swallows that error and
reports success, which is precisely what C# did. Immediacy and the diamond
distinction are pinned per-port instead — C# gains the two tests the other three
already had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d pin both halves

A genuinely unknown top-level key in `.metaobjects/config.json` throws in
TypeScript and resolves fine in Java, C# and Python. The corpus README recorded
this as a confirmed cross-port divergence and left it as "an open,
human-reviewable follow-up". Reviewed: neither side moves.

It is not a defect that leaked — it falls out of ownership. The file is
TypeScript's, and TypeScript is the only port that models its whole vocabulary,
so it is the only one that CAN tell a typo from a key a sibling owns. `.strict()`
is what turns that knowledge into a diagnostic, and the hazard it catches is
specific and already pinned: a stripped `scopes` (for `scope`) silently means
"everything in scope", and a stripped `migrate.scopee` silently governs the whole
database. The other three model the neutral subset, for which every other key is
indistinguishable from a TS-owned one; they could imitate strictness only by
carrying TypeScript's key list in lockstep, and would then REJECT a config a
newer `meta` had just written. Tolerance is the only coherent behaviour for a
partial reader, strictness the only coherent behaviour for the owner.

So this stays off the shared corpus permanently rather than being deferred again.
A shared case asserts ONE outcome and the correct outcome differs by port by
design, so adding one could only be done by making some port wrong. Each half is
now pinned where it belongs instead — TypeScript's in `config.test.ts` beside the
`scopes`/`scopee` cases it shares a rationale with, and the tolerant half in the
C#, Python and Java resolver tests, none of which previously covered it. The
forward-compatibility cost is stated and accepted: a config written by a newer
`meta` hard-fails an older one, which is what `schema_version` is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e, a tense

Review of the preceding two commits found three defects in them.

1. The `expectError: true` widening was too broad, and weakened six cases to buy
one. `true` had a stronger meaning than the README's wording admitted, and all
three coded ports implemented it identically: "raises the port's coded metadata
error; WHICH code is unpinned". The type was pinned. Relaxing the whole arm to
bare Exception/`Throwable` so the symlink cycle could raise a native
IOException also relaxed `sources-must-be-an-array-not-an-object` and its five
siblings, which would now PASS on a raw NullReferenceException / NPE /
TypeError — a crash scoring as a correct rejection. The allowance is now scoped
to the single case that needs it via a new optional `errorIsNative`, the coded
type is required everywhere else, and the README says which of the two `true`
pins (the type) and which it does not (the code). TypeScript is unaffected and
stays loose: it propagates the raw parser error, which is why the code is
unpinned in the first place.

2. The cycle guard's ancestor set compared `StringComparer.Ordinal` while
`RealPath` necessarily mixes spellings — a non-link segment keeps the caller's
casing via `GetFullPath`, a resolved link comes back in the filesystem's
canonical casing. On a case-insensitive volume `MODEL/loop -> model` therefore
walked straight past the guard, back down to the kernel ELOOP floor that the new
immediacy test claims to have removed — and this is the one port whose
enumeration SWALLOWS ELOOP, so there is no backstop under it. Now Ordinal on
Linux (where `Model` and `model` are genuinely different directories and folding
would reject a valid tree) and case-insensitive elsewhere.

3. `--print-only` suppressed the writes but not the past-tense reporting, so a
dry run announced `wired @.metaobjects/AGENTS.md into CLAUDE.md` and `refreshed
version written to <path>.new` for edits it had not made. Worse than the silent
write it replaced: it names a side effect on a file the user owns, which they can
go look for and will not find. All three messages are future-tense under a dry
run, pinned by a test asserting no past-tense form survives.

That test caught its own fix at first: `\bcreated with\b` also matches the
CORRECT "(would be created with …)". Anchored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…write sites

Post-review simplification pass. No behaviour change; all five constraints the
review pass established are intact and still proven by their tests.

`DirectorySource.RealPath` drops the `File.ResolveLinkTarget` fallback and the
manual rooted/relative branch. Both were dead code, verified rather than assumed:
`Directory.ResolveLinkTarget(path, returnFinalTarget: true).FullName` already
returns a fully-qualified, symlink-resolved absolute path for a relative one-hop
target, a relative chain, an absolute target and a `.` self-link — checked with
the process CWD set to `/`, which is exactly where a relative target resolved
against the wrong base would have shown itself. Since `Collect` only ever calls
`RealPath` on a path it has already confirmed is a directory, `File.`- and
`Directory.ResolveLinkTarget` could never disagree either. Thirteen lines become
two; the component-by-component LOOP is untouched, which is the part that
carries the correctness (resolving only the final segment misses a cycle reached
through a symlinked ancestor).

Also fixes a doc-comment bug introduced by the previous commit: the new
`PathComparer` block was inserted between `Expand()`'s existing `<summary>` and
`Expand()` itself, so two adjacent `///` blocks merged into one and XML-doc
tooling attached the pair to `PathComparer`, orphaning `Expand()`'s own summary.

`init.ts` factors the four-times-repeated `if (!dryRun) { mkdir; writeFile }` into
`writeUnlessDryRun`, and the three past/future tense ternaries into
`verbed(dryRun, pastParticiple)` — each call site now states only its own past
participle rather than spelling out both tenses of a whole sentence. This
normalises one message from active "would wire" to passive "would be wired", to
match the two that already read that way; the non-dry-run wording is unchanged,
which is what the tests pin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dmealing
dmealing merged commit 5a1250e into main Aug 20, 2026
1 check passed
@dmealing
dmealing deleted the fix/init-print-only-and-csharp-symlink-cycle branch August 20, 2026 13:35
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