Repository navigation
Rule Packs
Rule packs are named, shareable sets of rules that encode the rules of the outside world: App Store Connect limits, Open Graph sizes, privacy disclosure law, basic graph hygiene. STARCHART ships four built-in packs: core, appstore, privacy and seo. This page lists every rule in each pack: its id, severity, what it checks (read from the source), a real violation message and how to fix it. It also explains how to write your own pack and load it from the CLI with plugins:, and where the community registry stands (not built yet).
List pack ids under packs: in .starchart/config.yaml. The default is [core].
# .starchart/config.yaml
packs: [core, appstore, privacy, seo]loadPacks accepts three spellings for the same pack. Duplicates are ignored and order is preserved.
| You write | Loads |
|---|---|
appstore |
the appstore pack |
pack-appstore |
the appstore pack |
@starchart/pack-appstore |
the appstore pack |
The prefixed forms are stripped before lookup, so they resolve built-in packs and packs registered by plugins. A plugin pack is also found by its exact id as written. Nothing is fetched from npm. An unknown id makes starchart rules and starchart privacy exit 2:
starchart: unknown rule pack(s): @starchart/pack-gdpr
The starchart_rules MCP tool doesn't fail on unknown packs. It returns them in an unknownPacks array. packs: [] disables every pack, including core.
Pack rules run alongside your own YAML rules (see Rules Engine), and their violations carry pack: "<id>". The example messages below come from real runs against temp copies of examples/pro-universe. Some copies were modified on purpose to trip rules the demo passes, for example a retired offer, an oversized OG image or a mismatched Swift constant.
Graph hygiene. Source: rules/packs/core.ts.
| Rule | Severity | Checks |
|---|---|---|
artifact-bound |
warn | Every artifact has a binding. |
promo-not-expired |
error | Every artifact that isn't status: retired has a validThrough that hasn't passed. |
promotes-active |
error | No live artifact promotes a retired or deprecated entity. |
fact-used |
info | Leaf facts that nothing depends on. |
anchor-matches |
error | Hardcoded code literals that anchor a fact still equal the fact value. |
no-dangling-edge |
error | Every edge points at nodes that exist. |
owners |
info | Once any node declares owners, every artifact has one. |
Declarative: select: { kind: artifact }, require: { bound: true }. Without a binding, STARCHART can't audit or update the artifact.
warn artifact-bound privacy:appstore-label has no binding (.starchart/artifacts/marketing.yaml)
Fix: add binding: { adapter: …, … } (see Adapters Overview). If there's nothing to bind to, like an App Store privacy label, which has no API, accept the warning or leave core out of packs: and write your own version with a whereNot (see the bound-except-labels recipe on Rules Engine).
Declarative: select: { kind: artifact, whereNot: { status: retired } }, require: { notExpired: true }. The name says promo, but it applies to every artifact with a validThrough. A bare date is valid through the end of that day (UTC).
error promo-not-expired reel:spring-2026 expired on 2026-06-30 (.starchart/artifacts/marketing.yaml)
Fix: take the promo down and set status: retired, or extend validThrough.
For every promotes edge whose source isn't retired, it fails if the target's status is retired or deprecated.
error promotes-active web:blog-launch promotes addon:legacy, which is retired (.starchart/artifacts/extra.yaml)
Fix: retire the promoting artifact, or point promotes: at the replacement offer.
Reports leaf facts (facts with no child facts) that nothing depends on. A fact counts as used when a non-partOf edge points at it, or at any of its containers or its entity, because a change to a fact propagates up through partOf. The compiler-generated <entity>.status fact is skipped.
info fact-used app:nebula.appStoreId is not used by any code or artifact (.starchart/entities/app.yaml)
Fix: bind the fact (an artifact embeds it, code anchors it) or delete it. The same check appears as fact-unused in Orphans.
For every anchors edge from a code-layer node to a fact, it compares the literal value in code with the fact value, numerically for numbers. Generated constants (meta.generated, from Codegen) are skipped. The violation carries the source file.
error anchor-matches addon:pro.price.usd: code says 3.99, fact says 4.99 (apps/ios/Sources/Core/Pricing.swift:5) (apps/ios/Sources/Core/Pricing.swift)
Fix: decide which side is right. If the fact is right, fix the literal, or better, replace it with a generated constant. If the code is right, change the fact in .starchart/ and run starchart plan. See Code Authority Facts.
Every edge's from and to must exist. The loader warns about unknown targets at build time, and this rule turns that into a failing check.
error no-dangling-edge web:blog-launch --describes--> addon:ghost: "addon:ghost" does not exist (.starchart/artifacts/extra.yaml)
Fix: correct the id (typos are the usual cause; see Node IDs) or remove the edge.
Does nothing until at least one node in the graph has owners. After that, every artifact without owners is reported. This is the gentle, info-level version of an ownership policy. For a hard gate, use the artifact-has-owner recipe on Rules Engine.
info owners web:landing-hero has no owners (.starchart/artifacts/web.yaml)
Fix: add owners: ["@team"].
App Store Connect rules. Source: rules/packs/appstore.ts.
Character limits (APPSTORE_LIMITS), counted in Unicode code points:
| Field | Limit |
|---|---|
name |
30 |
subtitle |
30 |
promotionalText |
170 |
description |
4000 |
keywords |
100 |
whatsNew |
4000 |
| Rule | Severity | Checks |
|---|---|---|
appstore-listing-limits |
error | Listing entity facts fit the limits, in every locale. |
appstore-field-limits |
error | Field artifacts (binding.field) fit the limits. |
appstore-keywords-spacing |
warn | Keywords have no spaces after commas. |
appstore-screenshot-sets |
error | Required iPhone and iPad screenshot sizes are present, per locale. |
appstore-subscription-mirrored |
warn | Recurring offers are mirrored by a store product when the app sells in-app. |
Declarative. It selects entities typed sc:AppStoreListing and applies maxLength to the facts name, subtitle, promotionalText, description, keywords and whatsNew. Missing facts are skipped. Locale maps are checked per locale.
error appstore-listing-limits listing:nebula name [en-US] "Nebula: Galaxy Planner & Focus Timer" is 36 chars (max 30) (.starchart/entities/extra.yaml)
Fix: shorten that locale's copy in the entity YAML.
Applies to artifacts bound to the appstore adapter with a binding.field. Accepted field names are case-insensitive: name/title, subtitle, promotionalText/promotional_text, description, keywords, whatsNew/whats_new/releaseNotes/release_notes. The text comes from meta.text, or from the single fact the artifact embeds, mirrors or renders when that fact holds text or a locale map. When binding.locale is set, only that locale is checked. If the artifact links to several text facts, it is skipped.
error appstore-field-limits appstore:listing/subtitle-de subtitle "Plane deine Galaxie, einen Orbit nach dem anderen" is 49 chars (max 30) (.starchart/artifacts/extra.yaml)
Fix: shorten the text, or the fact it carries.
Checks keywords facts on listing entities, plus appstore artifacts whose field is keywords. Every space after a comma is a wasted character from the 100-character budget. Each locale is reported separately.
warn appstore-keywords-spacing listing:nebula keywords [en-US] has spaces after commas (3 of 100 chars wasted) (.starchart/entities/extra.yaml)
Fix: planner,focus,timer,galaxy.
Looks at appstore artifacts with a binding.set and groups them by binding.locale. Sets are normalized from "6.9", 6.9, '6.9"', "6.9in" or display types like APP_IPHONE_69. Sizes under 8 inches count as iPhone, 8 and up as iPad. For each locale:
- If there are iPhone sets, one must be 6.9", 6.7" or 6.5". App Store Connect accepts 6.7" in the 6.9" slot.
- If there are iPad sets, one must be 13" or 12.9".
The violation is attached to the first app entity (typed sc:App, schema:MobileApplication or schema:SoftwareApplication), or to a screenshot if there is none.
error appstore-screenshot-sets missing required iPhone 6.9" (or 6.5") screenshot set for de-DE; have 5.5" (.starchart/entities/app.yaml)
Fix: add a 6.9" (or 6.7"/6.5") screenshot artifact for that locale.
Only runs when the app sells in-app: a RevenueCat package node exists (pkg:swift/purchases-ios, pkg:npm/react-native-purchases, pkg:gradle/com.revenuecat.purchases:purchases), or any artifact is bound to appstore. For every schema:Offer entity that isn't retired or deprecated and has a billing fact of weekly, monthly, bimonthly, quarterly, semiannual, yearly, annual or annually, it needs an incoming mirrors edge from an artifact bound to appstore or playstore, on the offer or any of its facts. There is no playstore adapter yet, but the binding name is honored.
warn appstore-subscription-mirrored addon:team is a yearly subscription but no App Store or Play Store product mirrors it (.starchart/entities/extra.yaml)
Fix: add an artifact such as appstore:iap/team-yearly with binding: { adapter: appstore, product: … } and mirrors: [addon:team.price].
Privacy drift: what your SDKs collect versus what your disclosures declare. Source: rules/packs/privacy.ts. The detection model, the SDK catalog and the fix-it walkthrough have their own page, Privacy Drift.
| Rule | Severity | Checks |
|---|---|---|
privacy-disclosed |
error | Every default data type of every detected SDK is declared in every disclosure for that platform. Also reports manifests that can't be parsed. |
privacy-tracking |
error | SDKs that track (ATT sense) need NSPrivacyTracking = true in manifests, and labels or policies must not say tracking: false. |
privacy-disclosure-exists |
warn | Each platform where SDKs collect data has at least one disclosure. The message says "collects" for one package, "collect" for several. |
error privacy-disclosed pkg:swift/sentry-cocoa collects CrashData but PrivacyInfo.xcprivacy (apps/ios/Resources/PrivacyInfo.xcprivacy) does not declare NSPrivacyCollectedDataTypeCrashData (apps/ios/Resources/PrivacyInfo.xcprivacy)
error privacy-disclosed pkg:swift/sentry-cocoa collects CrashData but privacy label privacy:appstore-label does not declare CrashData (.starchart/artifacts/marketing.yaml)
error privacy-disclosed cannot parse PrivacyInfo.xcprivacy (apps/ios/Widget/PrivacyInfo.xcprivacy): unknown element <maybe/> at offset 41 (apps/ios/Widget/PrivacyInfo.xcprivacy)
error privacy-tracking pkg:npm/react-native-fbsdk-next tracks users but PrivacyInfo.xcprivacy (apps/ios/Resources/PrivacyInfo.xcprivacy) sets NSPrivacyTracking to false (apps/ios/Resources/PrivacyInfo.xcprivacy)
error privacy-tracking pkg:npm/react-native-fbsdk-next tracks users but privacy label privacy:appstore-label declares no tracking (.starchart/artifacts/marketing.yaml)
warn privacy-disclosure-exists pkg:npm/@sentry/nextjs, pkg:npm/posthog-js collect CrashData, DeviceID, OtherDiagnosticData, PerformanceData, ProductInteraction on web but no disclosure exists; add a privacy policy (sc:PrivacyPolicy with meta.declares)
Fix: declare the missing data type in the named disclosure (the message gives the exact manifest key), set the tracking flags to the truth, or add the missing disclosure. If the catalog is wrong for your setup, override it per package. All of this is covered in Privacy Drift.
Source: rules/packs/seo.ts.
| Rule | Severity | Checks |
|---|---|---|
seo-og-image-size |
warn | Open Graph images are 1200x630. |
seo-jsonld-renders-entity |
info | JSON-LD artifacts render an entity, so structured data can't drift. |
seo-meta-length |
warn | Web page titles fit 60 chars and meta descriptions 160 chars. |
Applies to artifacts typed schema:ImageObject that are tagged og or have og as a standalone word in their id (web:og-pro). It reads meta.width and meta.height, skips the artifact if both are missing, and requires exactly 1200 by 630.
warn seo-og-image-size web:og-team is 1024x512, Open Graph images should be 1200x630 (.starchart/artifacts/extra.yaml)
Fix: re-render at 1200x630 and update meta.
Applies to artifacts typed sc:JsonLd, sc:JSONLD or sc:StructuredData, or tagged jsonld / json-ld. Each needs an outgoing renders edge to an entity, or to a fact that belongs to an entity.
info seo-jsonld-renders-entity web:jsonld-pro is JSON-LD but does not render any entity; add renders: <entity> (.starchart/artifacts/extra.yaml)
Fix: renders: addon:pro. starchart emit jsonld --entity addon:pro produces the markup (see JSON-LD and SEO).
Applies to artifacts typed schema:WebPage. It checks meta.title (max 60) and meta.description (max 160) when they are strings.
warn seo-meta-length web:blog-launch title "Nebula Pro is here: themes, widgets, sync and everything you asked for" is 70 chars (max 60) (.starchart/artifacts/extra.yaml)
Fix: shorten the copy.
A pack is a plain object:
interface RulePack {
id: string;
description: string;
rules: Rule[]; // declarative rules (defineRule) and/or custom rules ({ id, severity, check })
}Built-in packs set pack: "<id>" on each rule so violations are labelled. A small example:
import { defineRule, type RulePack } from "@space-pirate-zero/starchart";
const PACK = "growth";
export const pack: RulePack = {
id: PACK,
description: "Growth team conventions",
rules: [
defineRule({
id: "growth-promo-owned",
pack: PACK,
severity: "warn",
select: { kind: "artifact", tag: "promo" },
require: { owners: true, notExpired: true },
}),
{
id: "growth-one-hero",
pack: PACK,
severity: "error",
check(graph) {
const heroes = graph.nodes({ kind: "artifact" }).filter((n) => n.tags?.includes("hero"));
return heroes.length > 1 ? heroes.map((n) => ({ node: n.id, message: `${n.id}: more than one hero artifact` })) : [];
},
},
],
};Custom rules get a RuleContext with now, lock, root and a per-evaluation cache. The privacy pack uses the cache to parse manifests once for all three rules. See Rules Engine.
Rules in a pack don't get pack stamped for you. Set it on each rule, or violations come out unlabelled.
Export the pack from an ES module, list the module under plugins:, and add the pack id to packs::
# .starchart/config.yaml
packs: [core, appstore, privacy, seo, house]
plugins: [./plugins/example.mjs]// plugins/example.mjs (the rule part; the full file also ships an adapter)
const house = {
id: "house",
description: "House conventions",
rules: [
{
id: "house-json-owned",
pack: "house",
severity: "warn",
check(graph) {
return graph
.nodes({ kind: "artifact" })
.filter((n) => n.binding?.adapter === "jsonfile" && !n.owners?.length)
.map((n) => ({ node: n.id, message: `${n.id} has no owners` }));
},
},
],
};
export const packs = [house];Real run on a copy of the demo with that plugin:
$ starchart rules
…
warn artifact-bound privacy:appstore-label has no binding (.starchart/artifacts/marketing.yaml)
warn house-json-owned json:pricing-pro has no owners (.starchart/artifacts/json.yaml)
…
9 error · 3 warn · 5 info
The rules:
- Relative paths resolve from the project root. Bare names (
starchart-pack-house) resolve from the project'snode_modules, so publishing a pack is publishing an npm package that exportspacks. -
packscan be a named export or live on the default export. - The loader calls
registerPackfor each pack. A plugin pack can't take the id of a built-in (core,appstore,privacy,seo): loading fails withrule pack "core" is built in and cannot be replacedand exit code 2. - A registered pack does nothing until its id is in
packs:. - To import
defineRulein a pack module, the project needs@space-pirate-zero/starchartinstalled where the plugin can resolve it. A plain object withcheckneeds no imports at all. - Duplicate rule ids across packs and your YAML rules are still not detected. Both run. Prefix pack rule ids with the pack id.
Plugins load in buildProject, so the pack also runs in the starchart_rules MCP tool and the Claude Code Hook. Details on the loader and its error messages: Writing an Adapter.
From a script you can still skip the config: registerPack(pack) and then loadPacks([...config.packs, pack.id]), or pass pack.rules straight to evaluateRules (see Library API).
Contributing a pack upstream (a new file in rules/packs/ plus an entry in PACKS) makes it available to everyone without a plugin. See Contributing.
The plan is a registry of community-maintained packs that encode the outside world, so that when Apple adds a required screenshot size, one pack update tells every STARCHART user which sets they're missing. Planned packs include pack-playstore, pack-stripe and pack-gdpr, plus per-SDK packs that declare what each SDK collects, with vendors able to publish their own. The @starchart/pack-<id> naming that loadPacks already accepts is reserved for this.
Status: not built. There is no hosted registry and no remote loading. The @starchart/pack-<id> names map to built-in packs or to packs a plugin has already registered; STARCHART never installs anything itself. See Roadmap.
★ 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