Repository navigation
Lockfile and Drift
starchart.lock pins every artifact to the exact fact values and code hashes it was last produced against. When a pinned dependency moves, the artifact is stale: drift, caught at PR time instead of by a customer. This page documents the lock format, the hashing, how dependencies are computed, how lock, apply and ack rewrite the lock (including partial relocks that keep old values), how check and plan read it and why they agree, and how to wire it into CI. Source: core/lock.ts and engine/apply.ts.
starchart.lock is JSON at the project root (next to .starchart/), written with two-space indentation and a trailing newline.
{
"version": 1,
"facts": {
"addon:pro.price": { "hash": "64a7596467e5e883", "value": { "usd": 4.99, "eur": 4.99 } },
"addon:pro.price.usd": { "hash": "71b62336ac91dd67", "value": 4.99 }
},
"code": {
"i18n:ios/paywall.title": "4c3f514ba283e814",
"route:web/pricing": "1f061885d61c7365",
"screen:ios/Paywall": "3298c1c584b4d89b"
},
"artifacts": {
"stripe:price/pro-monthly": {
"deps": {
"addon:pro.name": "7458c2007d89f182",
"addon:pro.price.usd": "71b62336ac91dd67",
"symbol:ios/Pricing.proUSD": "20bc1f157ce5a99f",
"symbol:web/lib/stripe#PRICE_PRO_MONTHLY": "c3d580064eebc169"
}
}
},
"maxCodeDepth": 2
}| Key | Contents |
|---|---|
version |
Always 1. Any other value is a ConfigError (unsupported lock version). |
maxCodeDepth |
Optional. The code-hop limit used to compute deps (from config code.maxCodeDepth). Absent means the default, 4. Written after the other keys, so it sits at the end of the file. |
facts |
Every fact in the graph: { hash, value }. The value is kept so plans can show before → after and adapters can find the old text to replace. |
code |
Code node id → hash, but only for code nodes that at least one artifact depends on. |
artifacts |
Artifact id → { deps }, where deps maps each dependency (fact or code node) to the hash it had when the artifact was last synced. |
A missing lock file reads as an empty lock (version: 1, all maps empty).
-
Facts:
hashValue(value)= first 16 hex characters of SHA-1 overstableStringify(value). -
Code nodes: the node's own
hash, set by the ingestor (content hash of the file, symbol or route source; the version for packages; the translations for i18n keys). -
Artifacts have no hash of their own; they're tracked through their
deps.
stableStringify is JSON with object keys sorted and undefined properties dropped, so key order in YAML never changes a hash. 4.99 hashes to 71b62336ac91dd67; {usd: 4.99, eur: 4.99} is serialised as {"eur":4.99,"usd":4.99} and hashes to 64a7596467e5e883. An undefined value (say, an unresolved code-authority fact) hashes like null.
Code nodes without a hash never take part in drift tracking: env vars, flags, events, and packages with no version.
dependencies(graph, artifactId) answers "which facts and code nodes, if changed, would impact this artifact?" It's impact analysis run backwards:
- Start at the artifact.
- Collect upstream nodes:
toof every outgoing edge whose propagation isreverseorboth;fromof every incoming edge whose propagation isforwardorboth; and, for files, every symbol theycontains. - Track consecutive code hops. Skip anything past
maxCodeDepth(default 4). A node is revisited only if it's reached with a smaller code depth. - Every upstream node with a current hash (a fact or a hashed code node) is a dependency. Keep walking.
So web:landing-hero, which describes addon:pro, depends on every fact under addon:pro (through incoming forward partOf) and on every symbol anchored to those facts. web:og-pro, which renders addon:pro.price.usd, depends on that leaf and its anchors, but not on the container addon:pro.price.
Set code.maxCodeDepth in .starchart/config.yaml and impact analysis uses it right away. The lock picks it up the next time it's written (lock, apply, ack), records it as maxCodeDepth, and walks dependencies with it. Staleness checks use the value recorded in the lock, not the config, so changing the config alone never makes check fail; relock once to apply it. If you later remove the config key, relocks keep the recorded value.
On the demo, maxCodeDepth: 2 followed by starchart lock drops two deep code pins:
$ starchart lock
✓ starchart.lock: 12 artifacts, 12 facts, 27 code pins
(29 code pins at the default depth.) i18n:ios/paywall.title and symbol:ios/Pricing.display fall out, and appstore:screenshots/6.9/03 no longer depends on addon:pro.features through the paywall code.
buildLock(graph, previous, artifactIds?, { maxCodeDepth? }):
- Rewrites
factsfrom the current graph (every fact, current value). RecordsmaxCodeDepth(from the option, else the previous lock's value). - Starts
artifactsfrom the previous lock, then re-pins each target artifact (default: all) to its current dependencies. - Rebuilds
codefrom the code deps of every artifact in the lock, re-synced or not, at their current hashes. - Drops artifacts that no longer exist in the graph.
starchart lock pins everything with buildLock. starchart lock <ids…> pins only those artifacts with relockArtifacts (below); the others keep their old deps.
starchart lock✓ starchart.lock: 12 artifacts, 12 facts, 29 code pins
Step 1 is the catch. After pinning only some artifacts, facts would hold the new value of every fact, including facts that other, still-stale artifacts depend on. check would still flag those artifacts (their deps hashes are old), but plan would lose the fact change, and writable adapters would lose the old text they search for.
relockArtifacts(graph, previous, ids, { maxCodeDepth }) fixes that. It runs buildLock for ids, then for every locked artifact that is still stale, puts the previous lock entry back for each changed fact it depends on. apply, ack and starchart lock <ids…> all use it. The fact only moves to its new value once nothing stale depends on it.
In a copy of the demo after changing addon:pro.price.usd to 5.99:
starchart lock web:og-pro
starchart check | tail -3
starchart plan✓ starchart.lock: 12 artifacts, 12 facts, 29 code pins
changed: addon:pro.price.usd
Check: 10 stale
Change: addon:pro.price {"usd":4.99,"eur":4.99} → {"usd":5.99,"eur":4.99}
Change: addon:pro.price.usd 4.99 → 5.99
! manual appstore:iap/pro-monthly mirrors update in appstore (adapter is read-only)
…
~ auto web:messages-en embeds replace embedded value
~ auto web:pricing-page embeds replace embedded value
…
Plan: 5 manual · 5 auto · 1 review · 2 code · 1 retire · 1 tests
(4 informational items hidden; use --verbose)
web:og-pro is pinned to 5.99 and gone from the plan (5 auto instead of 6); the ten still-stale artifacts keep 4.99 in the lock and stay in both check and plan. Save the bare starchart lock for when everything is in sync: it resets every pin and every fact to the current graph.
staleArtifacts(graph, lock) checks every artifact in the graph:
-
Unlocked: no entry in
lock.artifacts. Reported withunlocked: true. -
Changed: some pinned dep is gone from the graph or has a different hash, or the artifact now has a dependency the lock doesn't know about (you added an
embedsedge). Current dependencies are walked with the lock's recordedmaxCodeDepth.
✗ stale appstore:iap/pro-monthly
changed: addon:pro.price
changed: addon:pro.price.usd
✗ stale web:og-pro
changed: addon:pro.price.usd
…
Check: 11 stale
? unlocked brand:nebula never synced; run `starchart lock` once it is correct
? unlocked web:team-page never synced; run `starchart lock` once it is correct
Check: 2 unlocked
starchart check -f json prints [{ id, changed, unlocked }]; -f markdown prints a table for PR comments.
starchart plan (and apply, preview) build their plan with planFromLock in api.ts. The seeds are changedSince(graph, lock):
- every fact whose current hash differs from
lock.facts[id].hash(with before/after values). Facts that aren't in the lock at all are skipped; nothing was synced against them yet; - every
lock.codeentry whose node is gone (after = undefined) or has a different hash;
plus every changed dependency of an artifact that staleArtifacts reports. That matters because every relock rebuilds code at current hashes: a code node that moved (say, a regenerated constant) is then only "changed" from the point of view of the stale artifacts that pinned its old hash. After impact analysis, locked artifacts that are not stale are dropped. That's why plan and check always agree: after an apply or ack, plan lists exactly the stale artifacts (plus the code items and tests they imply) instead of going quiet.
Change: addon:pro.features ["Themes","iCloud sync"] → ["Themes","iCloud sync","Widgets"]
Change: symbol:ios/Entitlements.proFeatures "67931739280ac34c" → "3043a3a49728dca7"
Code changes show hashes; fact changes show values. Seeds that come from stale dependencies take their before value from lock.facts, so a code node seeded that way shows ∅ → <current value>, as the regenerated constants do after an apply (Change: symbol:ios/StarchartFacts.AddonPro.Price.usd ∅ → 5.99). A deleted fact is not a changedSince seed, but every artifact that pinned it is stale in check.
| Command | Effect |
|---|---|
starchart lock |
Pin every artifact and every fact to the current graph (buildLock). Records code.maxCodeDepth. |
starchart lock <ids…> |
Pin only these, keeping old fact values that still-stale artifacts need (relockArtifacts). |
starchart apply |
After successful writes (including codegen), rebuilds the project so edits to bound files are seen, then relockArtifacts for the applied artifacts. |
starchart ack <ids…> |
relockArtifacts for manual/review artifacts you've handled by hand. Errors on non-artifacts (exit 2). |
starchart revert <journal> |
Restores the lock saved in the journal (Apply, Revert and Journals). |
Commit starchart.lock. It's the record of what the world was last synced to, and:
-
checkin CI needs it; -
starchart history <fact>reads fact values from the lock in past commits (Time Machine); - journals and
revertrestore it.
Lock diffs are readable: a price change shows up as new hashes under the artifacts you synced, and, once nothing stale depends on the old price any more, a one-line value change under facts. So history sees the new value when the last dependent artifact is synced or acked.
starchart check is the drift gate:
| Exit code | Meaning |
|---|---|
0 |
Every artifact is in sync (✓ every artifact is in sync). |
1 |
At least one artifact is stale or unlocked. |
2 |
Error: bad YAML, schema violation, unknown edge type, bad lock version. |
- run: npx --yes @space-pirate-zero/starchart check -f markdown >> "$GITHUB_STEP_SUMMARY"The GitHub Action runs impact --diff for a sticky PR comment and can run check with fail-on-stale: true.
★ 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