lint-codes — numable check error codes (generated)
checkhas two profiles: personal (default, for your own use) only enforces rules that would break the package; publish (--profile publish) adds store requirements. Codes marked "publish only" can be ignored for personal packages. E = must fix, W = should fix. The chapter column points to thenumable docs <id>that explains how to write it.
Overview
| Code | What it guards | Profile | Chapter |
|---|---|---|---|
| G0 | Package structure and JSON syntax | personal too | numable docs layout |
| G1 | No files that are never loaded, no test fixtures | personal too | numable docs layout |
| G2 | Required manifest fields and identity | personal too | numable docs layout |
| G3 | The network allowlist must match exactly | personal too | numable docs layout |
| G4 | Cross-platform-safe idioms in a data flow | personal too | numable docs df |
| G6 | Complete set of widget sizes | publish only | numable docs xwidget |
| G7 | Colors and node structure | personal too | numable docs rcn |
| G8 | Localization table structure and references | publish only | numable docs i18n |
| G11 | Package icon | publish only | numable docs layout |
| G12 | Routes, flow references, and the parameter-editing entry point | personal too | numable docs page |
| G13 | Package size limit | personal too | numable docs layout |
| G14 | Sealed package format (enforced when sealing) | personal too | numable docs layout |
| G15 | Input font size in H5 pages | personal too | numable docs page |
| G16 | External links and schemes | personal too | numable docs layout |
| G17 | XPage top inset | personal too | numable docs page |
| G18 | Credential declarations | personal too | numable docs layout |
| G19 | Store front-of-house copy | publish only | numable docs layout |
| G20 | Localized widget titles | publish only | numable docs xwidget |
| G21 | Package name length and the metadata table | publish only | numable docs layout |
| G22 | A tap needs immediate feedback | personal too | numable docs rcn |
| G23 | First-paint cache | publish only | numable docs df |
| G24 | Only one entry point for adding widgets | publish only | numable docs page |
| G25 | Credential binding needs a direct entry point | publish only | numable docs page |
| G26 | A failed fetch must fail the flow | personal too | numable docs df |
| G27 | One layout for the add-widget button | publish only | numable docs page |
| G28 | Expression typos and the render scope | personal too | numable docs rcn |
| G29 | Do not write a tap as a click string | personal too | numable docs rcn |
| G30 | Renderable glyphs and the condition key | personal too | numable docs df |
| G31 | Method names must be in the VParser inventory | personal too | numable docs df |
| G32 | No $[method] inside size fields | personal too | numable docs rcn |
| G33 | Layout anchors must not enter method arguments | personal too | numable docs rcn |
| G34 | resultFilter must not expose the reserved keys lang / theme | personal too | numable docs df |
| G35 | Node-level ${@i18n.k} in XPage must resolve in the page table | personal too | numable docs page |
| G36 | Event values must not start with ${ | personal too | numable docs xwidget |
| G37 | An .af that writes to storage must refresh afterwards | personal too | numable docs af |
| G38 | Rich-text spans must not be separated by a plain space | personal too | numable docs rcn |
| G39 | Secrets must never be written into the data.* sandbox | personal too | numable docs credentials |
| G40 | Declared credentials must actually be used | personal too | numable docs credentials |
| G41 | A loop body's first node reads a value that accumulates across rounds | personal too | numable docs df |
| G42 | Bridge methods must actually exist | personal too | numable docs bridge |
| G43 | banner.xbanner must use the v2 canvas shape | personal too | numable docs layout |
| G44 | A .df should end with a resultFilter that limits its output | personal too | numable docs df |
| G45 | A .xjob must be one of the three shapes | personal too | numable docs alerts |
| G46 | A widget's jobs declaration must resolve and map cleanly | personal too | numable docs xwidget |
| G47 | A .xjob's i18n has four slots, and translations must keep every variable | personal too | numable docs i18n |
| G48 | File references must point at files that exist in the package | personal too | numable docs layout |
| G49 | A call-type bridge method returns an envelope | personal too | numable docs bridge |
| G50 | Numeric inputs: no type="number", and normalize full-width characters | personal too | numable docs page |
| G51 | Tabular data needs minEngine 3 | personal too | numable docs df |
| G52 | request / sleep parameters must be strings | personal too | numable docs df |
| G53 | No Chinese text in .xwidget default params | personal too | numable docs xwidget |
| G54 | Check for Chinese, not for English | personal too | numable docs i18n |
| G55 | Sticky elements in H5 pages sit under the collapsed title bar | personal too | numable docs page |
G0 Package structure and JSON syntax
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The package root must contain a manifest.json that parses | E | manifest.json is missing or fails to parse; the package will not install on a device | Generate the skeleton with numable init, or put manifest.json back | |
| Every .json .rcn .df .af .xwidget .xbanner .xpage .xform .xmenu must be valid JSON | E | The whole widget renders nothing on the device, with a clean log and no error at all | Fix the syntax error (usually a trailing comma, a comment, or single quotes) |
G1 No files that are never loaded, no test fixtures
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| No page.json, no xWidget/template/ or **/actionFlow/ folders, no .flow.json suffix | E | These files and folders are never loaded, so what you wrote looks like it had no effect | Put RCN in rc/, flows in flow/, and use the suffixes .af (action flow) / .df (data flow) | |
| G1b | The package must not contain test fixtures (*.params.json, fixtures/ folders) | E | Fixtures get sealed into the signed package — neither their size nor their content should ship | Move fixtures to .numable/params/, which never goes into the package |
| G1c | An .xpage node must not set loading / error fields | W | It has no effect: the app never reads these fields; loading and error states are always rendered by the app | Delete both fields |
G2 Required manifest fields and identity
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| id / version / title / category / domain / minEngine are required | E | The manifest is missing a field | Fill in all six fields | |
| The retired field manifest.scheme must not appear | E | Writing it has no effect | Delete the field; write deep links directly as numable:// |
|
| id should be a 26-character ULID (system packages with system:true are exempt) | W | manifest.id is not a ULID | Use the id generated by numable init |
G3 The network allowlist must match exactly
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Every host a request uses must already be listed in manifest.network | E | On the device the request is silently blocked by the network guard and the widget stays empty | Add the domains you actually request to network | |
| Every host declared in manifest.network must actually be used | E(W in the personal profile) | Over-permissioned: the install panel lists domains you never call, which alarms users | Delete the unused domains |
G4 Cross-platform-safe idioms in a data flow
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| G4b | The step immediately after concurrent must not reference the id of a concurrent branch | E | Concurrent results only become visible one step later, so you read an empty value | Insert an op:set barrier step between the concurrent step and its consumer |
| G4c | Once the time tokens are removed, a parseDate pattern must have no letters left | E | On Android/iOS the whole parse fails and returns null, so times display wrong | Cut a clean time string out with subString first, then parseDate |
| A .df may only use capabilities on the data-flow allowlist (enforced when sealing) | E | Rejected at packaging time | Move UI capabilities (ui.* / nav.* / xpage.*) into an .af |
G6 Complete set of widget sizes
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Each package needs at least 3 .xwidget files, must include a 22 size, and must include either a 42 or a 44 size | E | Only N widgets / no 22 size / no 42 or 44 size | Add the small square widget and the large widget; this is not checked for personal use |
G7 Colors and node structure
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Every *color field in a .rcn that carries a hex value must be written as a light|dark pair (pure transparent and pure black scrims excepted) | E | A hard-coded single color becomes unreadable, or disappears entirely, under the other theme | Write it in the form "#FFFFFF|#15171A" | |
| G7b | img.scaleType may only be fitXY / fitStart / fitEnd / fitCenter / centerCrop | E | A typo silently falls back to centerCrop and the image gets cropped | Use one of the allowed spellings |
| G7c | Every RCN cell (including those inside cells / react / children) must have a non-empty type | E | The whole widget fails to render and the editor only says "wasm not ready" | Give every cell a type; write op shorthands out in full; move comments into a _note field |
G8 Localization table structure and references
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| manifest.lang must be a BCP-47 language tag | E · personal too | Language resolution fails | Write it as zh-CN / en-US | |
| A content table must be a flat object, without an extra values wrapper | E · personal too | Lookups always come back empty and the literal key shows on screen | Remove the outer wrapper | |
| Every referenced key must exist in each required language | E | That language shows the raw key instead of the text | Add the translation; if a key is deliberately empty, declare it in i18nEmptyOk | |
| The top-level table in router.json is retired; routes[].title must not reference ${@i18n.} — use the per-language fields alongside it | E · personal too | The page title renders as the raw expression | Put the title into the per-locale fields of routes[].i18n | |
| routes[].title must not contain an expression (${key} / $[method::…]) | E · personal too | Titles are not evaluated, so the title bar shows the raw expression | Write plain text; for other languages use the route's side-by-side i18n table | |
| Table keys must be BCP-47, key counts should match across languages, no stale or unused keys | W | A language is missing entries, or dead entries are left behind | Add or delete as the message says; this group is not checked for personal use | |
| G8b | Chinese text is hard-coded in a user-visible key | W | Chinese shows up in an English environment | Extract it into ${@i18n.key} |
G11 Package icon
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The package should contain a logo.png | W | The store and the install panel fall back to an initial-letter icon | Add a 512×512 logo.png | |
| G11b | The logo must be square and decodable; 512 is recommended; do not bake your own rounded corners | E | Transparent or opaque rounded corners get cropped again by the host at 0.2237 of the edge, leaving pale or dark fringes | Let the artwork bleed to the square edges and leave the rounding to the host |
G12 Routes, flow references, and the parameter-editing entry point
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The target of numable://self/page/x must exist in router.json, and the id in numable://self/widget/ |
E | The device shows "page not found", or opens an empty add panel | Add the route; to open the home page write numable://self | |
| G12b | A flow called from an H5 page must exist, and its suffix must match how it is called | E | The device returns -6 flow not found | Use .df + runDataFlow to fetch data, and .af + runFlow for side effects |
| G12c | A bare-string binding in depends must not swallow the input parameters | E | The inputs go empty, the flow still reports success, and the widget silently renders -- | Write it as {"flow":"x","params":{"k":"${k}"}} |
| G12d | The af form of onEdit only accepts an in-package .af, forbids .., and the file must exist; the route form must be a bare path that exists in router.json | E | Nothing happens on tap, or a blank page opens without an error | Paths are relative to xWidget/; write the actual file name |
| G12e | An onEdit .af must contain widget.updateParams, and a page target must actually be able to write back | E | The edit page opens, but pressing anything changes nothing | Add widget.updateParams to the af; add onSubmit to an xform, or events to an xpage |
G13 Package size limit
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The source form must not exceed 3MB in total bytes | E | The source form is over the 3MB limit | Compress or drop assets such as images; serve large images from a network address | |
| No single file should exceed 256KB | W | The larger a flow, the slower every fetch | Split it into several flows, or turn repeated expanded expressions into a lookup table; serve large images from a network address |
G14 Sealed package format (enforced when sealing)
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The folder structure, manifest, and signature format of the build output must follow the spec (enforced when sealing, not by numable check) | E | Rejected while sealing, or the client fails signature verification and cannot install it | Never edit the build output by hand; re-seal with the release toolchain |
G15 Input font size in H5 pages
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Input controls on an H5 page must set font-size explicitly, and it must be at least 16px | E | On iOS, focusing an input zooms the whole page | Give input / textarea / select a font-size of 16px or more |
G16 External links and schemes
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| manifest.schemes is retired; its presence is an error | E | Writing it has no effect | Delete the whole field; the runtime shows a first-time confirmation for every jump to an external app | |
| Dangerous schemes such as javascript / file / data / intent / about / blob must not be used | E | They are blocked | Use https or numable:// instead | |
| In a third-party scheme URL (nav.open url and events binding strings), every interpolated ${} should be wrapped in urlEncode | W | The jump fails when a parameter contains special characters | Write it as $[urlEncode::(${v})] | |
| The fallback of nav.open may only be an http(s) address | E | There is nothing to fall back to when the target app is not installed | Set fallback to a web address |
G17 XPage top inset
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The paddingTop of an .xpage root node must reference ${@contentInset.top} | E | The top of the page sits under the floating capsule, and no platform reports an error | Write "paddingTop": "${@contentInset.top}" on the root node; if you truly need an exemption, explain it in _notePad | |
| The root node layout must not be pager | E | There is nowhere to put the top inset | Wrap it in a list as the root |
G18 Credential declarations
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Each credentials entry must be an object, must have an id, and ids must not repeat | E | Credential injection does not happen | Give every credential a unique id | |
| type may only be bearer / token / header / query / jwt-assertion / oauth2 | E | The mechanism is not on the allowlist | Switch to a mechanism on the allowlist | |
| jwt-assertion and oauth2 must carry a preset, and that preset must exist in the built-in table | E | These two mechanisms only work with a built-in configuration | To add a new site, submit it through feedback | |
| type=header requires headerName; type=query requires paramName; injecting through query prompts you to use a request header instead | E | The credential never reaches the request, or ends up in the address bar | Use a request header wherever you can | |
| hosts must be written explicitly, and must be a subset of manifest.network | E | A credential is only sent to the declared domains, so a missing declaration means it is never sent | List the domains that actually use the credential in hosts, and make sure they are all in network | |
| label must be a non-empty string per language, in both Chinese and English; help must be an https address | E | The binding panel has no explanatory text | Provide both the zh-CN and the en-US label | |
| Keys in .xwidget.params must not look like credentials (token / secret / password / api_key, or their equivalents in any language) | E | A published package may not put secrets into user-editable parameters | Move it to manifest.credentials | |
| G18b | request.credential must be a literal declaration id, never a ${}, and that id must already be declared | E | At runtime the request silently goes out anonymously | Write the id string from the declaration directly |
| G18c | credential.state's id must be a literal already declared in manifest.credentials; a fixture exemption needs a _note reason | E | It queries a credential that does not exist, always gets unbound, and the page keeps asking the user to bind | Use a declared id as a literal |
G19 Store front-of-house copy
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| manifest.subtitle is required | E | The store widget has no one-line introduction | Write an introduction of at most 22 characters | |
| subtitle should not exceed 22 characters | W | It gets truncated in the store | Shorten it | |
| i18n["en-US"] must carry title and subtitle (exempt if the base language is already English) | E | The store shows Chinese in an English environment | Add the English title and introduction | |
| A free-text category needs English coverage; an English subtitle over 42 characters prompts you to shorten it | W | It does not fit in an English environment | Use a platform enum value for category; shorten the English introduction |
G20 Localized widget titles
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| .xwidget.i18n must be an object | E · personal too | The widget title cannot find its translation | Write it as {"en-US": {"title": "…", "sub": "…"}} | |
| English title and sub should be covered; keys must be BCP-47; only the fields title and sub are read | W | In an English environment the widget picker shows Chinese widget titles | Add the two English fields and delete the others |
G21 Package name length and the metadata table
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| In each language the package name must not exceed a display width of 24 (full-width counts 2, half-width counts 1) | E | The name is too wide to fit even on two lines in the grid | Shorten title | |
| The package name should not exceed 16 | W | It is easily truncated on narrow screens | Shorten it a bit more | |
| Keys of manifest.i18n must be BCP-47 (W), and each locale's value must be an object (E, checked in personal too) | E | The whole metadata translation stops working | Use the shape {"en-US": {…}} | |
| The metadata table only reads title / subtitle / category / description; category should use a platform enum value | W | Extra fields are never read | Delete the other fields |
G22 A tap needs immediate feedback
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| In an .af bound to a widget cell event, the first action should be ui.haptic, and it must come first | W | The widget has no tap animation, so there is no feedback at all for the ~250ms before the screen changes and the user taps again | Put {"action":"ui.haptic"} at actions[0]; you may skip it where a haptic would be wrong |
G23 First-paint cache
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| G23-C1 | If a .df consumed by a page makes a request, it should also read a cache with data.get (system packages are exempt) | W | The first paint waits out a full network round trip every time | Make it a three-state flow: render the cache first, revalidate in the background; if it truly must not be cached, write _lintCache:"exempt" with a _note |
| A cache exemption must state its reason | E | There is no way to tell an oversight from a deliberate choice | Write one sentence in _note explaining why | |
| G23-C2 | In a .df consumed by a widget, the cache write-back must be gated by the ${_cache} switch, and default to off | E · personal too | The whole widget refresh path spins with no effect and the data never updates | Wrap the write-back step in a _cache condition; the page passes 1, the widget passes nothing |
| G23-C3 | An .xpage whose root depends is cached must have events.onRefresh (data.remove first, then reloadPage) | E · personal too | Pull to refresh changes nothing — you get the same cache back | Add onRefresh, deleting the cache before reloading the page |
| G23-C4 | Values written to the cache must not contain credentials | E · personal too | A secret lands in the local cache | Strip the credential fields before caching |
| G23-C5 | A cache key must not contain an index; a c. prefix is recommended | W | As soon as the order changes, A's cache is rendered for B | Key on the object's own stable identifier, not on its position |
G24 Only one entry point for adding widgets
This group hangs off the store front-of-house section and does not run in the personal profile.
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| A single file should not contain more than 2 add-widget calls (pickWidgets( / "widget.pick") | W | It reads as a hand-copied widget catalog | Collapse it into one button and let the system panel list the widgets | |
| Do not use addWidget — it no longer exists on the bridge | W · personal too | It throws a TypeError, and calls like this are almost always swallowed by the page's own try/catch | Use pickWidgets(items) instead | |
| The add-widget page must not say "added" | W | The interface cannot confirm whether the user actually added it, so the copy would be lying | Say "submitted" instead, or show nothing at all |
G25 Credential binding needs a direct entry point
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| When a credential has required:true, page/ must offer a direct entry point to numable://app/mine?section=credentials | E | The user is told to "add the widget first and then find the entry point yourself" | Write numbered steps on the onboarding page and add a direct button | |
| page/ must not steer users the long way round ("add any widget below and follow its prompt") | E | Users add a widget that is bound to stay empty, then hunt for the entry on it — two extra steps versus a direct link | Replace it with a button that opens numable://app/mine?section=credentials directly |
G26 A failed fetch must fail the flow
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| A .df under xWidget that contains a request or a concurrent must have an action:"error" exit | E | The flow still reports success when the fetch fails, and the empty data overwrites the last good data | Throw error after checking the main fields for emptiness; an empty collection is a normal success, not a failure |
G27 One layout for the add-widget button
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Wherever there is an add-widget call there must also be a .pickb button carrying the shared xb:addbtn baseline CSS block | W | The layout drifts from the baseline and every package's button looks different | Generate the baseline style with the accompanying script and paste it in verbatim | |
| The layout of p-addbtn*.rcn must match the baseline, and the button node in an XPage must be 56pt tall | W | Buttons come out at different heights | Regenerate them from the baseline |
G28 Expression typos and the render scope
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| No duplicated unit suffixes, such as 14.0ptpt | E | The whole widget fails to render, without an error | Give a number exactly one pt | |
| A $[...] must not nest another $[ inside it | E | The whole widget fails to render | Write the inner call as a bare method name, such as if::(...) | |
| A ${x} in a .rcn must come from the output of this widget's depends .df (a key of resultFilter); shell parameters are not in the render scope | E | That spot renders empty, or always takes the fallback value | Pass the parameter into the .df through depends.params, land it in the flow, expose it through resultFilter, and only then read it on the widget |
G29 Do not write a tap as a click string
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| A click string in a .rcn must not contain @[file:// | E | The tap behavior is out of your control | Use events.* bindings instead |
G30 Renderable glyphs and the condition key
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The condition key of op:if in a flow must be props.val | E | The condition is always false and the branch body silently never runs, while the flow still reports success | Put the condition in props.val | |
| Emoji (code points ≥ U+1F000) are forbidden in text that RCN can render | E | The glyph pipeline cannot render emoji and leaves a silent gap at that spot | Use an in-package image asset, or a Basic Multilingual Plane symbol such as ★ ✓ ✕ › |
G31 Method names must be in the VParser inventory
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Every name::(…) in .rcn/.df/.af/.xpage/.xform/.xbanner must be one of VParser's 43 methods | E | An unknown method evaluates to empty, silently: no error, nothing in the logs, and the slot renders blank or always takes the fallback branch (abs:: and push:: have both bitten) | Check the table in numable docs methods; for absolute value write if::(ge::(v,0),v,calc::(0-v)) — there is no abs/avg/filter/groupBy/indexOf/push |
G32 No $[method] inside size fields
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The values of x/y/w/h/fontSize/lineWidth/maxWidth/maxHeight in .rcn must not contain $[ | E | A method is not guaranteed to be evaluated there, and once it yields empty the string becomes a bare "pt" → StaticCanvas layout fails = the whole widget renders nothing, with no error | Land it with op:set first (wrap the value in findNotEmpty), then write "y":"${_v}pt" |
G33 Layout anchors must not enter method arguments
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| A $[…] argument string must not contain layout anchors like {parent.x} / { |
E | Layout anchors and method evaluation are separate scopes, so the method cannot see it → the node is silently not drawn, with no error | Rewrite it as a pure layout expression (layout expressions may interpolate ${}): "w": "({parent.w}-134pt)*${pct}/100" |
G34 resultFilter must not expose the reserved keys lang / theme
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| .df resultFilter.keys must not contain lang or theme | E | The render layer injects {...data, theme, lang} and the later write wins: RCN gets the host's language code / light-dark setting instead of your value, while the flow still reports success and the widget still renders | Rename the key (langCode / themeName, say) |
G35 Node-level ${@i18n.k} in XPage must resolve in the page table
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Any @i18n key referenced in a .xpage node's params / props.text / menu[].label must exist in that .xpage's top-level i18n table | E | Node-level @i18n only looks in this page's own table; a miss evaluates to an empty string — no error, just blank on screen, as if the author forgot the copy | Add the string to the .xpage top-level i18n table (one entry per locale), or move it into the matching .rcn's rc.i18n (this check does not cover the inside of canvas.source) |
G36 Event values must not start with ${
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The events.* / click / onClick strings in .xwidget/.rcn/.xpage must not start with ${ | E | The dispatcher classifies by first character before interpolating (/ = route, numable:// = deeplink, @[file:// = flow), so a string starting with an interpolation matches nothing → it is looked up as a flow name, the tap does nothing, and no error is raised | Keep the known prefix outside the interpolation: "/zone?id=${id}" / "https://${host}/x" |
G37 An .af that writes to storage must refresh afterwards
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| If an .af contains data.set / data.merge / data.remove and a widget's data flow reads one of the written keys with data.get, it should also call widget.refresh or widget.updateParams (page-level refresh chains carrying xpage.reloadPage are exempt) | W | After the write, the dashboard widget's reload starts with cacheGet and hits the bitmap rendered from the old data — the user finishes the settings, comes back, and the widget has not moved; it only heals on the next interval | Add a widget.refresh after all the write actions (pick scope self / widget / bundle by blast radius); if not refreshing is deliberate, write _lintRefresh:"exempt" with a _note giving the reason | |
| An exemption from the post-write refresh must state its reason | E | There is no way to tell an oversight from a deliberate choice | Write one sentence in _note explaining why (same rule as _lintCache) |
G38 Rich-text spans must not be separated by a plain space
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Rich-text spans must not be separated by a plain space — neither at the end of the previous span, nor at the start of the next one, nor hidden inside a locale-table value | E | The two runs render glued together — "1842" and "Clicks" come out as "1842Clicks" — while the same space inside a plain txt survives, which makes it look random | Use a dedicated spacer span: set its text to ${@i18n.nbsp} and the locale value to U+00A0 (no-break space); that span's font size is the gap width, so the size contrast between the number and its unit stays untouched | |
| A spacer span must not evaluate to plain whitespace | E | A span that is nothing but plain whitespace is dropped entirely at render time, as if it were never written | Make it resolve to U+00A0 (no-break space) |
G39 Secrets must never be written into the data.* sandbox
One line: a value whose leak puts the account at risk is a secret and belongs only in the credential store; everything else (user names, repo names, regions, switches) is configuration and belongs in params or data.*. This rule only reads the literal key (the part before ${) and the literal value — a computed key tells a static check nothing.
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| In a .df or .af, data.set / data.merge with a credential-shaped key (token / secret / password / api_key / access_key / private_key / credential / authorization / bearer / pat) is always rejected | E | The sandbox is plain text on disk, it is visible in the workbench data panel, every flow in the package can read it and therefore send it out — and it travels inside the local backup file, so one backup leaves the key sitting there in the clear | Use the front door: declare a credentials entry in the manifest and put "credential": " |
|
| A key that looks harmless but whose written value is credential-shaped (it contains token / authorization / secret / password / api_key / bearer / credential) is rejected just the same | E | Exactly the same exposure as the row above — hiding the key behind an innocuous name does not make it any safer | Move it to a credentials declaration as well. This rule has no exemption switch: if it really is a false positive, rename the field away from these words rather than commenting out a leak |
G40 Declared credentials must actually be used
A package can only ask about credentials it declared, so "declared but never actually used" is the one shape user fingerprinting takes: declare a batch of decl ids (github / cloudflare / stripe…), ask only whether each is bound, then send the answers to your own server. Close that and the fingerprinting path is gone. The rule has two levels, and what matters is which kind of reference exists, not whether one exists.
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| A credentials entry is declared and queried with credential.state, but no request carries "credential": " |
E | This is exactly the fingerprinting shape — for anyone who installs the package, whether they have GitHub or Cloudflare bound is one query away, while the package never intends to actually use those credentials | If you really use it, put credential:" |
|
| A credentials entry is declared, but no request uses it and no credential.state queries it | W | The install sheet shows one extra line — "this package requests your X credential" — for something it never touches, asking the user to accept a permission that is never used | Delete the declaration if it is unused. A package that deliberately keeps one as a disclosure or binding fixture (a test package) can ignore this — it is a warning, not a release blocker |
G41 A loop body's first node reads a value that accumulates across rounds
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| The first node of a forEach / for body must not reference a key that the same body writes | W | Each round's first node reads the snapshot taken as the loop was entered, not what the previous round wrote — a guard flag never fires and "pick the first match" silently picks the last; the flow still reports success, check is 0 error, the widget looks fine and only the number is wrong | Put a barrier node as the body's first line, e.g. {"op":"set","props":{"key":"_lb","value":"1"}} |
G42 Bridge methods must actually exist
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| An html page calls a bridge method that is not in the full method table, with no existence check around it | E | The page throws a TypeError, and calls like this are almost always swallowed by the page's own try/catch — nothing crashes, nothing is reported, the log is clean, the feature is just quietly absent | Check the name against the full method table in numable docs bridge |
|
A name outside the full method table is referenced behind an if (xbridge.x) / typeof guard |
W | Nothing crashes, but the fallback branch is now the only one that ever runs — it looks like it goes through the bridge and never does | Switch to a name from the table; if the bridge has no such capability, drop the dead branch instead of leaving a bridge call that can never run |
G43 banner.xbanner must use the v2 canvas shape
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
banner.xbanner must be { version:2, canvas:{source,depends[,refresh]} } with no top-level rcn / flow |
E | The App reads only canvas; a banner with a top-level rcn / flow is treated as missing and replaced by the default template, with no error and a clean log |
Move rcn.rc into canvas.source and turn fetching into canvas.depends pointing at a .df; see numable docs layout |
|
canvas.source is an @[file://…rcn] reference or an inline {cells,…} object; canvas.depends is an array |
E | The whole banner does not show, or the fetch flow never runs | Write references as package-root paths (xWidget/rc/…); write depends the same way as in a .xwidget |
G44 A .df should end with a resultFilter that limits its output
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
A .df whose actions contain no resultFilter |
W | The flow's output is the whole data scope: inputs (including the host-injected @i18n/@app) and loop temporaries all reach the render layer and are persisted with the cache. One proven failure: a leaked empty @i18n once masked the RCN's own i18n table, blanking every ${@i18n.*} on the widget with no error |
Add a resultFilter at the end whose keys list only what the render actually reads |
G45 A .xjob must be one of the three shapes
A .xjob has no type field; its shape comes from the combination: alert without task.depends = a static alert; alert with depends = a dynamic alert; no alert = a background job. This code checks for anything outside those three combinations.
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| G45.shape | id may only contain [a-z0-9-] (it defaults to the file name), and title is required and must not be empty; the top level only accepts version / id / title / sub / i18n / params / form / events / task / alert (_note-prefixed keys pass), so a leftover type or a top-level level / cooldown counts as unknown |
E | An instance is identified by package + rule id + params, so respelling the id creates a second rule (the old one keeps firing); without a title the consent sheet, the alerts page and the widget's long-press menu have no name to show |
Use lowercase letters, digits and hyphens for id; give title a rule name a user can read |
| G45.location / G45.id-filename | A .xjob must sit directly under xJob/ (no sub-folders), and if you write an id it must match the file name (it defaults to the file name) |
E | Clients enumerate xJob/<id>.xjob one level deep: a file in a sub-folder ships with the package yet its rule never exists, and no alert can be added from it; when id and file name differ, a widget's jobs[].id and alert.add resolve it by file name while instances are keyed by id — rename it and the alert the user already added never matches again |
Move the file back to xJob/ itself; either omit id or make it character-for-character the file name |
| G45.task | Every .xjob needs a task; task.refresh is required and may not be an empty object; if depends is present it must be an array, and every slot — like then — must carry a flow; task only accepts depends / then / refresh |
E | A job with no cadence is never evaluated — it installs, it is listed on the alerts page, and it simply never fires; a slot without a flow fetches nothing, so its share of the data is absent from the merge |
Add task.refresh: at (daily times) for a static alert, interval (a window or plain seconds) for a dynamic one |
| G45.depends-slot | Every slot in task.depends is a { flow, params? } object — a bare string is not shorthand |
E | The platform reads the slot's flow field; with a bare string there is nothing to read, so that slot silently fetches nothing, its share is missing from the merged data, and the flow still reports success with a clean log |
Write { "flow": "@[file://xWidget/flow/x.df]" }, adding params when you need to pass values in |
| G45.kind | task and alert cannot both be absent |
E | The file does nothing at all | Write alert.message to notify, or task.then to only write state in the background |
| G45.depends-only | Without task.depends you may not write task.then, alert.activeCondition or task.refresh.cooldown |
E | A static alert fetches nothing when it is armed, so there is no data for any of the three to judge, write or hit | Add task.depends (it can point at a .df the widget already uses) if you really want a data-driven verdict; otherwise drop those keys |
| G45.alert-only | Without alert (a background job) you may not write params, form or events |
E | A background job is a single package-level instance: no consent-sheet fields, no user parameters, no tap | If the user must fill in parameters it should be an alert — add alert.message |
| G45.task-then | Without alert (a background job) task.then is required |
E | A job that writes no state leaves nothing behind — every run fetches data for nothing | Write data.* in the .df behind task.then; if it really writes nothing it should be an alert — add alert.message |
| G45.alert-keys | alert only accepts message, activeCondition and level, and level must be quiet, normal or urgent |
E | Extra keys are never read; an unrecognised level falls back to the default while the author believes it was changed |
Put the copy in alert.message.{title,body} and the intensity in alert.level (one of the three) |
| G45.alert-message | alert.message.title is required and must not be empty |
E | An alert with no title fires as an empty notification | Write alert.message.title (body is optional) and put translations in i18n[locale].message |
| G45.form-keys | Every form key must already exist in params, and every params value must be a string |
E | The consent sheet only renders fields for params keys, so the extra field never appears; a non-string value destabilises the instance hash and one rule ends up as two instances |
Give the default in params first (write numbers as strings too, e.g. "08:00" / "3"), then describe its input widget in form |
| G45.hit | With task.depends there must be a way to reach a verdict: write task.then or alert.activeCondition, or have the .df behind depends output hit |
E | Omitting activeCondition means $[eq::(${hit},1)]; with no hit to read the verdict is always unknown, the alert never fires, and the log stays clean |
Expose hit in the .df's resultFilter, or write an explicit alert.activeCondition |
| G45.message-scope | In an alert without depends, alert.message may only reference params and @app |
E | Those are the only two things that exist when the alert is armed; any other key renders empty — and by then the notification has already gone out | Only use params the user has filled in; if you need data, make it a dynamic alert by adding task.depends |
| G45.days / G45.at-param | refresh.days and a ${param} inside refresh.at / refresh.interval belong only to a static alert with no depends; a .xwidget's canvas.refresh does not accept days either (cooldown there is merely inert) |
E | A dynamic job's evaluation cadence is the host's business; filtering by weekday or using a parameter as the time only means something for a system timer, so neither takes effect | To fire on selected weekdays or at a time the user picked, make it a static alert (no depends) |
| G45.once | A dated at entry (YYYY-MM-DD HH:MM, fires once) belongs only to a static alert with no depends; the same at may not mix it with daily HH:MM entries, and it may not be combined with interval or days; a literal date without ${} must be valid (one ASCII space, 24-hour clock, zero-padded, a date that actually exists). An entry counts as dated when, after trimming, it contains whitespace in the middle; a bare ${when} cannot be told apart and is not checked |
E | None of these fail at runtime: the dated entry is still scheduled, a daily entry still fires every day, days is ignored — and the author never gets what they meant; a hard-coded February 30 can never be scheduled, so the alert never fires |
Give the fire-once alert a rule of its own: "at": ["${date} ${time}"], with date as a datePicker (format: "YYYY-MM-DD") and time as a timePicker in form; write a separate rule for anything that repeats daily |
| G45.recipe | task.then is either a decision flow {flow, params} or a runtime decision recipe {recipe, …}: only cross (threshold cross: value + line, dir defaults to below) and changed (value change: 1–4 keys) exist; each field is a whole-string ${key} or a literal; oncePerDay / fireOnFirst are booleans; recipes are for alerts only (a rule with alert) |
E | With both recipe and flow, the flow never runs; a misspelled recipe, a missing field or an unknown key leaves the host unable to decide, so the alert never fires; a recipe on a background task writes nothing to data.* | For a threshold cross write {"recipe":"cross","value":"${px}","line":"${price}","dir":"${dir}"}; for a value change {"recipe":"changed","keys":["${ver}"]}; "above threshold" needs no recipe — write alert.activeCondition + refresh.cooldown; background tasks still use a write flow |
| G45.all-urgent | Not every alert in a package should be marked alert.level: "urgent" (checked in the publish profile only) |
W · publish only | The levels exist for the one alert that truly cannot wait; when everything is urgent there is no level at all, and the user most likely silences the package's notifications altogether — taking the genuinely urgent one down with them | Keep urgent for the one that really should interrupt, use normal for the rest and quiet for purely informational ones |
| G45.min-engine | If the package has an xJob/ folder, any .af uses alert.add / alert.skip, or an H5 page calls xbridge.alertAdd, manifest.minEngine must be ≥ 2; if it uses a dated at (a one-time alert), alert.remove in an .af, or xbridge.alertRemove in an H5 page, it must be ≥ 3; if task.then uses a runtime decision recipe (recipe), it must be ≥ 4 (the requirement is the major each feature shipped with and does not rise with the current major) |
E | On an older client xJob/ merely stays silent, but alert.* in an .af is an unknown action — the whole flow fails and the user's tap does nothing; a client before engine major 3 cannot parse a dated at and silently drops that entry — it installs fine and simply never fires; a client before engine major 4 does not know decision recipes and judges every round unknown — again it installs fine and never fires |
Set manifest.minEngine to the major the features require (2, 3 or 4) or higher so older clients never install the package |
G46 A widget's jobs declaration must resolve and map cleanly
A .xwidget's jobs powers the "long-press the widget → Add alert" entry: it lists the .xjob rules that can be created from this widget and pre-fills them by mapping the widget instance's parameters in. References to an .xjob by id from alert.add / xbridge.alertAdd belong to this group too.
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| G46.id / G46.shape | jobs is an array of objects each carrying an id, and xJob/<id>.xjob really exists |
E | Long-pressing the widget lists an "Add alert" entry that cannot open | Write id as the file name under this package's xJob/ folder, without the extension |
| G46.params-key | Every mapped key must be a params key of that .xjob |
E | The mapped value has no receiver, never shows up on the consent sheet, and the user has to type it again | Check the key names against that .xjob's params table |
| G46.params-ref | A mapped value may only reference the widget's own ${param} or be a literal |
E | Fetch output is a result, not an identity, and does not exist at the moment of the long press — the mapping lands empty | Map an identity key from the widget's params (such as ${secid}), or write a literal for a fixed value |
| G46.add-id | A literal id in alert.add / xbridge.alertAdd must be an alert (with an alert block) under this package's xJob/ |
E | Tapping it opens no consent sheet and the step fails outright (rule_not_found) — it never comes back as cancel, but you only see it once you actually tap there |
Write id as the name of the alert file under this package's xJob/, without the extension; background jobs cannot be added by the user |
| G46.task-ref | The referenced .xjob must be an alert (it has an alert block), not a background job |
W | A background job is a single package-level instance with no consent sheet and no user parameters — the widget's "Add alert" menu filters on the presence of alert, so this entry simply never appears while the declaration looks fine |
Point at a real alert; if this rule really should be creatable per-parameters by the user, give it an alert.message |
G47 A .xjob's i18n has four slots, and translations must keep every variable
A .xjob's i18n is a single side table: i18n[locale] = { title, sub, message, form }, with the bare fields being the base language. Translations of message contain ${} directly, so each language places the variables where it wants.
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| G47.slots | i18n[locale] only accepts the four slots title, sub, message and form |
W · publish only | Anything outside the four slots is never read by the client — the work is wasted | Put field labels in form.<key>.title and everything else in title / sub / message |
| G47.message | The ${} references in a translated message must match the base language field by field |
E | A variable dropped or mistyped while translating renders empty — and by then the notification has already gone out, with nothing left to reproduce | Check the variable names against the base language; translating only part (just title) is fine, but whatever you do translate must carry all of that line's variables |
| G20(扩到 .xjob) | When title / sub have a base-language value there should also be an i18n["en-US"] translation |
W · publish only | In an English environment the alerts page and the consent sheet show the base language here | Add i18n["en-US"].title (same rule as a widget title) |
G48 File references must point at files that exist in the package
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
Every @[file://…] reference (canvas.source / canvas.depends / event bindings in .xwidget, flow bindings in .rcn and pages, and so on) must point at a file that exists |
E | The whole widget fails to load, or a tap does nothing; numable run reports that the referenced file does not exist |
When you rename a .df / .rcn / .af, update every reference to it too (easy to miss right after cloning the starter) |
G49 A call-type bridge method returns an envelope
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
In an H5 page the return value of a call-type bridge method (runDataFlow / getData / confirm / appInfo / credentialState …) must not be used as the data itself: the result is in .data, check code === 0 first |
W | Every field is undefined; if (await xbridge.confirm(…)) is always true, so the code runs on even when the user tapped Cancel — neither raises an error |
Write a call() that unwraps the envelope (throw when code !== 0, otherwise return data) and route every call-type method through it; see numable docs bridge |
|
xbridge.confirm / xbridge.alert take three positional parameters (title, message, options), not a single object |
W | The object is taken as the title: the dialog shows [object Object] or a blank title, an empty message, and the button labels are ignored | Write xbridge.confirm("Title", "Message", { okText, cancelText, destructive }) |
G50 Numeric inputs: no type="number", and normalize full-width characters
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
H5 pages must not use inputs with type="number" (in markup, built in JS strings, or set via .type = "number") |
W | When a Chinese keyboard types a full-width "。" (e.g. 112。4), .value is sanitized to an empty string and the page thinks nothing was entered — the number is silently lost, with no error |
Use type="text" + inputmode="decimal" (numeric for integers); the keyboard stays the same |
|
When a page has an inputmode="decimal" / "numeric" input, it must normalize full-width digits and 。., to ASCII |
W | parseFloat("12。5") silently gives 12 and "12.5" gives NaN — the wrong number still looks like a number |
Write a normNum() (subtract 0xFEE0 from full-width digits, turn 。.,、 into .), apply it in document-level input (skip while isComposing) and compositionend handlers, and again wherever the value is read |
G51 Tabular data needs minEngine 3
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
If any .df / .af / .rcn / page in the package uses formatType: "tsv" / "csv" on a request (a literal, case-insensitive) or calls mapField / groupSum / convertSum, the major version of manifest.minEngine must be ≥ 3 |
E | An older app does not know tsv / csv and hands back the raw table text (nothing at all on iOS); the three methods do not exist there and silently evaluate to empty — no error anywhere, it installs and runs, and sales always show 0 or blank | Set "minEngine": "3.0.0" in the manifest; an older app then asks the user to update before installing |
G52 request / sleep parameters must be strings
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
timeout on a request, every value inside its queryParams / header, and timestamp on a sleep must not be a JSON number / boolean / object, nor a whole string that is a single number-returning method call ($[calc::(…)], $[floor::(…)] and the like); ${key} references can't be typed statically and are not checked |
E | On HarmonyOS the step fails with a parameter error and never runs (the same happens on iOS for queryParams / header), so the widget always fails — while the web engine, the CLI and the render preview all run it fine and look green |
Write them as strings: "timeout": "8000", "timestamp": "1500"; wrap computed numbers in parseNumber::(…,0) (which yields a string), e.g. "$[parseNumber::(calc::(5050-${el}),0)]" |
G53 No Chinese text in .xwidget default params
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
Any string value in an .xwidget's params contains Chinese, Japanese or Korean characters (arrays and objects are walked; _note doesn't count) |
W | params are the widget's defaults, and that is exactly what a user with a non-Chinese interface gets: the title shows Chinese, and defaults such as a city or a holiday land on the Chinese one |
Keep natural language out of default params: write "" or "auto" and fill it in the .df with the China-default rule (${@app.region} is cn, or it is empty and ${@app.language} starts with zh); for titles use ${@i18n.*} in the RCN |
G54 Check for Chinese, not for English
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
An expression contains startsWith::(${@app.language},en) (or en-…; same for @app.locale / @device.language); _note doesn't count |
W | It asks "is this English?", so Japanese, German and other systems fall into the Chinese branch — non-Chinese users see Chinese | There is only one language rule: starts with zh → Chinese, everything else (including empty) → English. Write $[startsWith::(${@app.language},zh)] |
G55 Sticky elements in H5 pages sit under the collapsed title bar
| Item | What is checked | Level | Symptom | Fix |
|---|---|---|---|---|
| Under page/html, a CSS declaration block or style attribute has position: sticky with top set to 0 or var(--xb-content-top) | W | Once the page scrolls past its header, the container fades in a small title bar at the top; a toolbar with top: 0 slides underneath it and gets covered, and one with var(--xb-content-top) leaves a gap below it | Use top: var(--xb-bar-bottom), and add --xb-bar-bottom: 0px to :root as a fallback for local preview |