Skip to content

feat(api): let a profile say what its source files are called - #45

Merged
endrix merged 2 commits into
mainfrom
feat/profile-source-extensions
Sep 4, 2026
Merged

feat(api): let a profile say what its source files are called#45
endrix merged 2 commits into
mainfrom
feat/profile-source-extensions

Conversation

@endrix

@endrix endrix commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The problem

The core decided whether a URI was a diagram's source by testing the path against one product's file extension, written out as a literal. Seven sites did it — both open-diagram commands, the rename command's active-file lookup, the editor provider's save handler and its on-disk change handler, and a file-system watcher glob.

For a consumer whose files end in anything else, all of that silently refuses:

  • the open commands resolve nothing and warn the user to open a kind of file the product does not have — naming an extension it does not use, in text it cannot change;
  • rename says the same thing in different words;
  • the watcher fires for files the product does not own and never for the ones it does, so an edit made outside the editor (the chat agent through the edit backend, git, a formatter) never reaches the open diagram.

The neutrality gate did not catch it. The existing gates look for words, and an extension is not a word — it reads as punctuation, which is exactly why it survived four gates for as long as it existed.

The fix

DiagramProfile.sourceExtensions?: string[] — lower-case, leading dot, e.g. ['.foo', '.bar']. Every one of those sites asks the list instead, through a new source-extensions.ts helper that also builds the noun the user-facing messages use. Messages are derived from the declaration rather than written out, because a message that names an extension is a message the core is not in a position to write.

Two things came along with it:

  • watch.globs had been on the profile since the v2 contract and nothing read it. It does now, and takes precedence over the globs derived from sourceExtensions — a product may want to watch more than its own sources (a manifest, a generated index), and it is the more specific statement.
  • The toolkit's profile builder already knew the extension and now hands it to the platform, so the existing consumer keeps exactly the filtering it had.

Gate 5 closes the class: it bans deciding, from a literal extension, whether a path is one of a product's sources. It deliberately does not ban naming an extension inside a directory layout the platform itself defines (its own skills and agents directories) — that layout is the platform's to name. Verified to fail on a reintroduced literal path test and on a reintroduced literal watcher glob.

The trade-off

Declaring nothing filters nothing. That is the deliberate half of the design, and it is a real trade: with no declaration the platform accepts any file where it used to accept one kind, and watches every file where it used to watch one kind.

The alternative — refusing what the core does not recognise — fails in the worse direction. The core cannot know how a product names its files, and a wrong guess makes every command refuse every file, which from the outside is indistinguishable from a workspace that has no sources in it. An unrecognised file instead reaches the profile's own canOpenSource, which can refuse it for a reason the product actually knows.

The watcher cost is the visible half of that trade. It is bounded by the artifact-directory filter that already existed (build trees, dist, out, node_modules, VCS and virtual-env directories are ignored), and any profile that cares replaces it by declaring either field.

Compatibility

Purely additive and optional — no API version bump, following the precedent of the last three profile fields (entityPaletteItems, paletteIcons, nodeFamilies). Every existing consumer is unaffected; the shipped one keeps its filter through the toolkit builder.

Verification

  • npm run build — succeeded
  • npm test — 874 tests, 152 files, all passing
  • npm run check:neutrality — 5/5 gates
  • npm run typecheck — 5/5 packages

The core decided whether a URI was a diagram's source by testing the path
against one product's file extension, written out as a literal. Six places
did it — both open-diagram commands, the rename command's active-file
lookup, the editor provider's save handler and its on-disk change handler —
plus a file-system watcher glob pinned to the same extension.

For any consumer whose files end in something else, all of that is dead
weight that silently refuses. The open commands resolve nothing and show a
warning telling the user to open a kind of file the product does not have,
naming an extension it does not use, in text it has no way to change. Rename
says the same thing in different words. The watcher fires for files the
product does not own and never for the ones it does, so an edit made outside
the editor — the chat agent through the edit backend, git, a formatter —
never reaches the open diagram.

A profile declares `sourceExtensions` instead and every site asks it.
Messages are built from the list rather than written out, because a message
that names an extension is a message the core is not in a position to write.

Declaring nothing filters nothing. That is the deliberate half. The core
cannot know how a product names its files, and the failure mode of a wrong
guess is the worst one available: commands that refuse every file, which
looks from the outside like a workspace with no sources in it. An
unrecognised file reaches `canOpenSource` instead, where the product can
refuse it for a reason it actually knows. The cost is a watcher on every
file for a profile that declares neither `sourceExtensions` nor
`watch.globs`; the artifact-directory filter that already existed is what
keeps that affordable, and either declaration replaces it.

`watch.globs` had been on the profile since the v2 contract and nothing read
it. It does now, and wins over the derived globs — a product may want to
watch more than its own sources, a manifest or a generated index, and it is
the more specific statement of intent. The toolkit's profile builder already
knew the extension and now hands it over, so the shipped consumer keeps the
filtering it had.

Gate 5 catches the class. The four existing gates were green for as long as
this defect existed, because they look for WORDS and an extension is not a
word — it reads as punctuation, which is exactly why it survived. The new
gate is narrow on purpose: it bans deciding from a literal extension whether
a PATH is one of a product's sources. It does not ban naming an extension
inside a directory layout the platform itself defines, which is the
platform's to name.
@endrix
endrix force-pushed the feat/profile-source-extensions branch from 976d37f to 9e7df8a Compare September 4, 2026 07:54
Three conflicts, all of them two additions landing in one place.

The import needed merging rather than concatenating: each side wanted a
different symbol from the same statement, so keeping both lines compiled
to a duplicate identifier — which the typecheck caught and a textual
"keep both sides" would have shipped.
@endrix
endrix merged commit 0e18a99 into main Sep 4, 2026
1 check passed
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