Repository navigation
Adapter fs
The fs adapter owns every file inside your repo: source code with a hardcoded price, a JSON string table, a Markdown email, an OG image rendered from an SVG template. It is the only adapter that writes by default. This page covers binding fields and selectors, the exact text-matching rules, what audit reports, how apply replaces values (and when it refuses), template renders including SVG→PNG, revert, and worked examples.
Source: adapters/fs.ts and the shared matcher adapters/text.ts.
binding: { adapter: fs, path: apps/web/messages/en.json, selector: "json:$.pricing.pro" }| Field | Required | Meaning |
|---|---|---|
adapter |
yes | fs |
path |
yes (unless the artifact has a template with out) |
Root-relative file path. Must resolve inside the project root, symlinks followed (see Path containment). |
selector |
no | Narrows where values are searched and replaced. json:<path> or regex:<pattern>. |
If path is missing but the artifact declares renders: { template, out }, the out path is used. With neither, you get <id>: fs binding needs a "path".
| Selector | Example | Scope |
|---|---|---|
| none | The whole file | |
json: |
json:$.plans.pro.price, json:$.plans[0].price, json:$["pro plan"].price
|
Only the scalars under that JSON path. The file must parse as JSON. |
regex: |
regex:price:\s*"([^"]+)" |
Only the regions the regex matches. If the pattern has a capture group, only group 1 counts. Compiled with flags gmd. Unsafe patterns are refused (see Regex screening). |
JSON paths accept $, .key, [0] and ["quoted key"]. A leading $ is optional. Anything else is invalid JSON path.
JavaScript regexes have no timeout, so a selector with a nested quantifier can hang STARCHART on a crafted file. Since 0.1.1 a regex: selector is screened before it compiles and refused when it:
- is longer than 500 characters, or
- quantifies a group that already contains a quantifier:
(a+)+,(\d+)+,(.*)*,(x{2,})+.
The artifact fails with an adapter error instead of hanging. Real audit output for selector: 'regex:(\d+)+\.99':
error web:pricing-page [fs] unsafe regex selector: nested quantifier (e.g. (a+)+) can backtrack catastrophically
The check is a heuristic that catches the common catastrophic shapes. It is not a proof that a pattern runs in linear time, so keep selectors simple. The same screen applies to rule value.pattern (Rules Engine).
Use a selector when the same number means two things in one file. That is exactly the situation where STARCHART refuses to guess (see Safety refusals).
Every path the adapter touches must land inside the project root: binding paths, render out paths and templates, on audit, apply and revert alike. Since 0.1.1 the check follows symlinks, so a committed link such as page.tsx -> ../../outside/page.tsx can't smuggle a write out of the repo. For a path that doesn't exist yet, its nearest existing parent directory is checked instead. The two refusals:
| Case | Message |
|---|---|
The path itself climbs out (../x, an absolute path elsewhere) |
path "../x" is outside the project root |
| The path looks local but a symlink on the way points outside | path "apps/web/app/pricing/page.tsx" resolves outside the project root (symlink) |
Real output with apps/web/app/pricing/page.tsx symlinked to a file outside the project (the outside file is left untouched):
$ starchart audit
error web:pricing-page [fs] path "apps/web/app/pricing/page.tsx" resolves outside the project root (symlink)
…
$ starchart apply --yes
✗ web:pricing-page [fs] path "apps/web/app/pricing/page.tsx" resolves outside the project root (symlink)
Symlinks that stay inside the project are fine.
The adapter collects leaf facts reached through embeds, mirrors and renders edges. A container fact expands to its leaves: embedding addon:pro.price ({usd: 4.99, eur: 4.99}) means checking both addon:pro.price.usd and addon:pro.price.eur. Array facts contribute each string or number element. describes and promotes edges are semantic and never text-checked.
All text adapters (fs, url, appstore) share text.ts. The rules, verified against the code:
Token boundaries. A value only matches as a whole token.
| Value | Text | Match? | Why |
|---|---|---|---|
4.99 |
$4.99/mo |
yes |
$ is not a word character |
4.99 |
costs $4.99. |
yes | a trailing sentence period is fine |
4.99 |
14.99 |
no | preceded by a digit |
4.99 |
v1.4.99 |
no | preceded by digit + .
|
4.99 |
4.995 |
no | followed by a digit |
5 |
5.50 |
no | followed by . + digit |
Pro |
Proton |
no | followed by a letter |
Pro |
Pro+ |
yes |
+ is not a word character |
Word characters are Unicode letters, digits and _. For non-numeric strings, a boundary is only enforced on a side where the value itself starts or ends with a word character.
Number forms. A number matches its String(value) form, plus a two-decimal money form:
| Fact value | Forms searched |
|---|---|
5 |
5, 5.00
|
4.5 |
4.5, 4.50
|
4.99 |
4.99 |
Currency. Currency symbols are not part of the value. $4.99, €4.99 and 4.99 USD all contain the token 4.99. Locale formats are not recognized: 4,99 € does not match 4.99. A YAML value of 5.00 is stored as the number 5, so both $5 and $5.00 match it. Other renderings (five dollars, localized digits, thousands separators) do not.
Strings. Strings match literally and case-sensitively. Empty strings never match. Booleans and objects have no text form and are ignored.
Replacement preserves style. When the matched text was two-decimal (5.00) and the new value is a number, the replacement is formatted with two decimals too (6.00, not 6).
For a plain (non-template) artifact, audit reads the file and, for each leaf fact:
-
Stale. If the lock's previous value differs from the current value and any form of the old value is still in the file (inside the selector), report
stalewith the first occurrence's line:still shows old value 4.99; expected 5.99. Multiple hits are counted:(3 occurrences).- An old value that is the current value of another fact on the same artifact is not reported as stale. If
usdmoved from 4.99 to 5.99 buteuris still 4.99, the remaining4.99belongs toeur.
- An old value that is the current value of another fact on the same artifact is not reported as stale. If
-
Missing. Otherwise, if any current value is absent, report
missing:does not contain 5.99.
Other outcomes:
| Situation | Diff |
|---|---|
| File doesn't exist |
missing: file not found: <path>
|
| JSON selector path not found |
missing: JSON path $.x not found
|
File isn't valid JSON (with a json: selector) |
thrown, reported under audit errors |
Real output from the demo after changing addon:pro.price.usd from 4.99 to 5.99 (other lines trimmed):
! stale email:onboarding-day-3 marketing/emails/onboarding-day-3.md:3 still shows old value 4.99; expected 5.99
! stale web:messages-en apps/web/messages/en.json:4 still shows old value 4.99; expected 5.99
? missing web:og-pro apps/web/public/og/pro.png rendered output not found: apps/web/public/og/pro.png
! stale web:pricing-page apps/web/app/pricing/page.tsx:8 still shows old value 4.99; expected 5.99
apply computes the new content with computeFsUpdate() (the same function audit and Future Universe Preview use), then writes it.
- Build a replacement plan from the lock's previous values to the current values (
planReplacements):- Scalar → scalar: one
old → newpair. - List → list: elements removed from the old list are paired, in order, with elements added to the new list.
- Scalar → scalar: one
- Apply every pair in one pass (
applyReplacements). A chain like4.99 → 5.99and5.99 → 6.99never cascades:was 4.99 now 5.99becomeswas 5.99 now 6.99. Overlapping matches keep the earliest, longest hit. - With a
json:selector, the file is parsed, only scalars under the path are rewritten (numbers stay numbers), and the file is re-serialized with its detected indent and trailing newline. Withregex:, only the matched regions are touched. - Verify: every changed fact must now be present. If an old value was never found, the apply fails rather than claim success.
If nothing changed, apply returns ok with no changes and writes nothing. With --dry-run, changes come back prefixed with would.
Files produced by Codegen (// @starchart generated symbols) are not written through fs. apply runs one codegen pass for all of them and reports each symbol under the codegen adapter:
✓ symbol:web/lib/starchart-facts#ADDON_PRO_PRICE_USD [codegen] regenerated apps/web/lib/starchart-facts.ts; regenerated apps/ios/Sources/Core/StarchartFacts.swift
✓ web:pricing-page [fs] addon:pro.price.usd: 4.99 → 5.99
Codegen output is not journaled, so revert leaves it alone. Generated files are reproducible; git undoes them. With no codegen targets configured the step fails: generated constants found but no codegen targets are configured.
fs refuses rather than corrupt a file. Each refusal fails the step (and stops the apply run):
| Refusal | Message |
|---|---|
| Ambiguous old value. The old value is also the current value of another fact on this artifact. | ambiguous: old value 4.99 of addon:pro.price.usd is also the current value of addon:pro.price.eur; add a selector to the binding |
| Unplaceable list items. A list grew and the new items have no old counterpart to replace. | cannot place new item(s) "c" (fact:x) by find-and-replace; edit <path> manually, then "starchart ack <id>" |
| Old value not found. |
could not find old value 4.99 of addon:pro.price.usd in <path> (plus (within selector) when one is set) |
| File missing, bad selector, bad JSON, JSON path not found | the corresponding message |
The ambiguity case is real. The demo's addon:pro.price is {usd: 4.99, eur: 4.99}. An artifact that embeds the whole addon:pro.price container and then sees usd move to 5.99 cannot know which 4.99 is the dollar price. Bind it with a selector, or embed addon:pro.price.usd only (which is what the demo does).
An artifact with an object-form renders is regenerated from a template instead of patched:
- id: web:og-pro
binding: { adapter: fs, path: apps/web/public/og/pro.png }
renders: { template: apps/web/og/pro.svg, with: [addon:pro.name, addon:pro.price.usd] }The compiler stores template and out in meta.template / meta.templateOut and adds a renders edge to each with fact. renders edges always classify as auto ("regenerate from template").
| Output extension | What happens |
|---|---|
.png |
The template must be .svg. It is filled with facts (XML-escaped) and rasterized with resvg at the SVG's intrinsic width (from width or viewBox), with system fonts loaded. A non-SVG template fails: only .svg templates rasterize. |
.svg, .html, .htm, .xml
|
Rendered as text with every substituted value XML-escaped |
| anything else | Rendered as text, no escaping |
Template syntax is {{ addon:pro.price.usd | money:USD }}, with filters money, upper, lower, join and default. An unknown fact without default is an error. Full reference: JSON-LD and SEO and render/template.ts.
Audit for template artifacts:
| Situation | Diff |
|---|---|
| Template file missing or render throws |
break with the error |
| Output file doesn't exist |
missing: rendered output not found: <path>
|
| Output differs from a fresh render |
mismatch: out of date with template <template>
|
PNGs are compared pixel by pixel with pixelmatch (threshold 0.1), so a re-encode that looks identical doesn't count as drift. Text outputs are compared byte for byte.
Every real write returns an undo record holding the file's previous bytes (base64) or the fact that it didn't exist:
{
"adapter": "fs",
"artifact": "web:og-pro",
"data": { "path": "apps/web/public/og/pro.png", "existed": false, "encoding": "base64", "content": null }
}revert restores the old bytes exactly, or deletes a file that the apply created (removed generated apps/web/public/og/pro.png). The undo record's path goes through the same path containment check, so a hand-edited journal can't point a revert outside the project. Revert restores a snapshot. It does not merge: edits made to the file after the apply are overwritten.
A JSON string table where only one key should change:
- id: web:messages-en
binding: { adapter: fs, path: apps/web/messages/en.json, selector: "json:$.pricing" }
embeds: [addon:pro.name, addon:pro.price.usd]A Swift file where the price appears in a comment you don't want touched:
- id: ios:pricing-constant
binding: { adapter: fs, path: apps/ios/Sources/Core/Pricing.swift, selector: 'regex:proUSD\s*=\s*([\d.]+)' }
embeds: [addon:pro.price.usd]Make fs read-only for a cautious first week:
adapters:
fs: { write: false }Every fs step then plans as manual with the reason adapter "fs" cannot write.
★ STARCHART
☠ a Space Pirate Zero joint
STARCHART Every dependency. Code to cosmos.
Guides
Concepts
- Three-Layer Model
- Facts and Entities
- Artifacts and Bindings
- Edge Types
- Node IDs
- Impact Analysis
- Lockfile and Drift
- Rollout Ordering
Authoring
Code layer
Commands & API
Adapters & engine
- Adapters Overview
- Adapter: fs
- Adapter: url
- Adapter: Stripe
- Adapter: App Store Connect
- Writing an Adapter
- Audit and Break Detection
- Apply, Revert and Journals
- Future Universe Preview
- Viewer and Serve
- Reality X-Ray
Rules & analysis
Integrations
Project