@weatherboard/gyde-design 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,6 +171,24 @@ into their repository.
171
171
  product's; a scaffolder that clobbers has taken ownership of something it does
172
172
  not own, silently.
173
173
 
174
+ ## Importing a module directly
175
+
176
+ The barrel (`@weatherboard/gyde-design`) is the curated named surface. Every
177
+ module the package ships is also reachable by its own subpath:
178
+
179
+ ```js
180
+ import { themeStartup } from "@weatherboard/gyde-design/theme-entry.mjs";
181
+ import pkg from "@weatherboard/gyde-design/package.json" with { type: "json" };
182
+ ```
183
+
184
+ That is a stated guarantee rather than an accident of file layout, and
185
+ `exports.test.mjs` fails if a shipped module is not reachable — so verifying
186
+ your own repository never means reaching into `node_modules` by filesystem
187
+ path. The two exceptions are named there with their reasons: the CLI, which is
188
+ the `bin` and executes on import, and the vendored parsers under `vendor/`,
189
+ which ship so the Action can run with nothing installed and are not ours to
190
+ promise.
191
+
174
192
  ## The rules, as data
175
193
 
176
194
  A rule id reaches you three times — in a finding, in the `rules` stamp inside
@@ -368,6 +386,19 @@ whatever lands under that name next, and it is the only way the list could
368
386
  shrink invisibly. The same discipline the allowance ledger has — it may only
369
387
  ever get shorter, and never by accident.
370
388
 
389
+ **One `file` + tag may repeat, but not with different reasons.** An exception
390
+ identifies its element by file and tag name matched in source order, so nothing
391
+ records *which* `<div>` a reason was written about. Many elements sharing one
392
+ argument is normal and safe — an inline-styled block of markup is genuinely one
393
+ decision — and reordering them changes nothing a reader could act on. Two
394
+ entries for one file and tag carrying **different** arguments is a different
395
+ thing: a reorder reattaches each reason to an element nobody wrote it about,
396
+ with the count, the figure and the verdict all unchanged. That is an **error**,
397
+ and the fix is one reason true of the whole group, because that is the
398
+ granularity the exemption actually has. No occurrence index is offered: an
399
+ explicit ordinal makes the identity visible without making it stable, so the
400
+ same reorder still reattaches the reasons, now with a number agreeing.
401
+
371
402
  **Every path fails closed.** A malformed entry, an unknown key, a scope that
372
403
  does not resolve, a scope that matches no markup files, a scope whose files make
373
404
  no visual decision, an unreadable component barrel, an exception naming
@@ -382,6 +413,13 @@ and a rule that matches nothing reads exactly like a rule that passes.
382
413
  bespoke element and carries a reason somebody wrote. It cannot tell a true
383
414
  reason from a plausible sentence. What it buys is that every one of them is
384
415
  on the artefact, in the diff, with a name and a line number attached.
416
+ - **Element identity is still ordinal.** The reason-collision check above is a
417
+ check on the *configuration*, never on the source. A single entry for `<div>`
418
+ in a file that later grows a second bespoke `<div>` forms no group, so nothing
419
+ fires — and that entry now matches whichever comes first in source order,
420
+ which may not be the one it was written about. Within an identical-reason
421
+ group nothing is guarded at all, by design; if one member is later edited so
422
+ the shared reason stops being true of it, the group still validates.
385
423
  - **It measures markup, not rendering.** An element is the system's if its tag
386
424
  is one of the design system's exports. A component that imports the system
387
425
  and then restyles it at the call site still counts — that is the styling
package/floor.mjs CHANGED
@@ -149,6 +149,112 @@ const EXCEPTION_KEYS = new Set(["file", "element", "because"]);
149
149
  */
150
150
  const MIN_REASON = 12;
151
151
 
152
+ /**
153
+ * G-135 — two exceptions for one `file` + tag, carrying DIFFERENT reasons.
154
+ *
155
+ * ===========================================================================
156
+ * THE DEFECT, AND WHY THE OBVIOUS GUARD IS THE WRONG ONE
157
+ * ===========================================================================
158
+ *
159
+ * `measureFloor` identifies an excepted element by `file` + tag name, matched
160
+ * in SOURCE ORDER: the first unclaimed `<div>` in that file, then the next.
161
+ * Nothing in the configuration says WHICH `div`, because nothing in the
162
+ * configuration can. So move a block, wrap something, reorder a return — and
163
+ * entry 1's reason attaches to entry 2's element. Every element is still real,
164
+ * the count is still right, the floor is still met: the gate stays green and
165
+ * the ledger now misdescribes which element was argued for and why. A silent
166
+ * wrong answer, which this repository holds to be worse than a red one.
167
+ *
168
+ * The obvious guard — refuse any repeated `file` + tag — is too blunt, and
169
+ * measurably so. Against the first consumer to adopt a floor, fourteen of
170
+ * nineteen exceptions sat in same-file-same-tag groups, and thirteen of those
171
+ * were one inline-styled preview surface: many elements, genuinely ONE
172
+ * argument. Reordering them is harmless, because the reasons are
173
+ * interchangeable in fact and not merely in the matcher. Forcing thirteen
174
+ * distinct sentences there would produce worse prose, not better identity.
175
+ *
176
+ * So the hazard is not repetition. It is **repetition carrying different
177
+ * reasons**, and that is exactly what is checked here.
178
+ *
179
+ * ===========================================================================
180
+ * WHY THIS REFUSES RATHER THAN WARNS
181
+ * ===========================================================================
182
+ *
183
+ * Every other malformed floor is an error — an unknown key, a duplicate scope,
184
+ * a zero-match scope, a stale exception. A warning would be the one advisory
185
+ * thing in a fail-closed file, and this repository's standing finding is that
186
+ * a gate nobody must act on is a gate that does not exist.
187
+ *
188
+ * The usual objection to refusing is that it removes a capability with nothing
189
+ * to replace it. It does not, and this is the crux: the per-element precision
190
+ * a differing-reason group appears to have **was never delivered**. Four divs
191
+ * with four reasons read as four arguments about four elements; the matcher
192
+ * can only offer "four of the divs in this file are excepted". The
193
+ * configuration is claiming a precision the mechanism does not have, and
194
+ * refusing it is Gyde declining to keep up the appearance.
195
+ *
196
+ * The way to express it is therefore already in the format, needs no new key,
197
+ * and is what the error says: **write one reason that covers the group.** A
198
+ * single argument true of all four members is a claim the matcher can actually
199
+ * honour, and a reader who disagrees with it disagrees with all four at once —
200
+ * which is the granularity the exemption really has.
201
+ *
202
+ * NO DISAMBIGUATOR IS ADDED, AND THAT IS A DECISION RATHER THAN AN OMISSION.
203
+ * An optional occurrence index (`nth`) is the tempting small fix and it buys
204
+ * nothing: it makes the ordinal identity EXPLICIT without making it STABLE, so
205
+ * the reorder that reattached the reasons silently still reattaches them —
206
+ * now with a number in the config agreeing. Line anchors and content hashes do
207
+ * fix identity, and were rejected on the issue for churning a reviewed ledger
208
+ * into a file people update mechanically without reading, which is the failure
209
+ * the reasons exist to prevent. A guard that needs no new vocabulary is worth
210
+ * more here than a key that needs one and still does not work.
211
+ *
212
+ * ===========================================================================
213
+ * WHAT THIS DOES NOT PROTECT AGAINST
214
+ * ===========================================================================
215
+ *
216
+ * - **A reorder inside an identical-reason group.** Deliberately unguarded:
217
+ * the reasons are interchangeable, so the reattachment changes nothing a
218
+ * reader could act on. The residue is that if one member is later edited so
219
+ * the shared reason stops being true of it, the group still validates and
220
+ * nothing notices.
221
+ * - **A collision introduced by the FILE rather than the config.** This is a
222
+ * schema check: it reads the configuration and never the source. One entry
223
+ * for `<div>` in a file that later grows a second bespoke `<div>` is not a
224
+ * group, so nothing here fires — and yet the single entry now matches
225
+ * whichever `div` comes first in source order, which may not be the one it
226
+ * was written about. That is the same defect arriving from the other side,
227
+ * and it is not covered.
228
+ * - **Whether a shared reason is true.** As everywhere else in this file,
229
+ * Gyde checks that a reason was written, never that it is honest.
230
+ */
231
+ function reasonCollisions(at, exceptions) {
232
+ const groups = new Map();
233
+ exceptions.forEach((ex, j) => {
234
+ if (!isPlainObject(ex) || typeof ex.file !== "string" || typeof ex.element !== "string") return;
235
+ const key = JSON.stringify([norm(ex.file), ex.element]);
236
+ if (!groups.has(key)) groups.set(key, []);
237
+ groups.get(key).push({ j, because: typeof ex.because === "string" ? ex.because.trim() : "" });
238
+ });
239
+
240
+ const out = [];
241
+ for (const [key, members] of groups) {
242
+ if (members.length < 2) continue;
243
+ const distinct = new Set(members.map((m) => m.because));
244
+ if (distinct.size < 2) continue;
245
+ const [file, element] = JSON.parse(key);
246
+ out.push(
247
+ `${at}.exceptions: ${members.length} exceptions name <${element}> in ${file} and ${distinct.size} of them ` +
248
+ `give DIFFERENT reasons (entries ${members.map((m) => m.j).join(", ")}). An exception identifies its element ` +
249
+ `by file and tag name in source order, so nothing records which <${element}> each reason was written about: ` +
250
+ `reorder the file and the reasons reattach to different elements, silently, with the count and the verdict ` +
251
+ `unchanged. Repeating a tag is fine — repeating it with different arguments is not. Give the group ONE reason ` +
252
+ `that is true of all ${members.length}, which is the granularity this exemption actually has.`,
253
+ );
254
+ }
255
+ return out;
256
+ }
257
+
152
258
  /**
153
259
  * Validate the declared floors.
154
260
  *
@@ -243,6 +349,7 @@ export function validateFloors(raw) {
243
349
  `(at least ${MIN_REASON} characters). An exception nobody had to justify is an exemption, not an exception.`);
244
350
  }
245
351
  });
352
+ for (const e of reasonCollisions(at, exceptions)) errors.push(e);
246
353
  }
247
354
 
248
355
  floors.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weatherboard/gyde-design",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "Scaffolds a design system into a product repository, then keeps auditing it.",
6
6
  "type": "module",
@@ -15,32 +15,39 @@
15
15
  },
16
16
  "exports": {
17
17
  ".": "./index.mjs",
18
- "./workspace.mjs": "./workspace.mjs",
19
- "./normalise.mjs": "./normalise.mjs",
20
- "./rules.mjs": "./rules.mjs",
21
- "./ruleindex.mjs": "./ruleindex.mjs",
22
- "./scan.mjs": "./scan.mjs",
23
- "./tokens.mjs": "./tokens.mjs",
18
+ "./package.json": "./package.json",
19
+ "./adoption.mjs": "./adoption.mjs",
20
+ "./agentdocs.mjs": "./agentdocs.mjs",
24
21
  "./boundaries.mjs": "./boundaries.mjs",
25
- "./ratchet.mjs": "./ratchet.mjs",
26
- "./emit.mjs": "./emit.mjs",
27
22
  "./catalogue.mjs": "./catalogue.mjs",
28
- "./agentdocs.mjs": "./agentdocs.mjs",
29
- "./upgrade.mjs": "./upgrade.mjs",
30
- "./usage.mjs": "./usage.mjs",
31
- "./wiring.mjs": "./wiring.mjs",
32
23
  "./clientboundary.mjs": "./clientboundary.mjs",
33
- "./props.mjs": "./props.mjs",
34
- "./docdrift.mjs": "./docdrift.mjs",
35
24
  "./compound.mjs": "./compound.mjs",
25
+ "./docdrift.mjs": "./docdrift.mjs",
26
+ "./emit.mjs": "./emit.mjs",
27
+ "./enforcement.mjs": "./enforcement.mjs",
28
+ "./floor.mjs": "./floor.mjs",
36
29
  "./htmlscan.mjs": "./htmlscan.mjs",
37
30
  "./markup.mjs": "./markup.mjs",
38
- "./floor.mjs": "./floor.mjs",
39
- "./adoption.mjs": "./adoption.mjs",
40
- "./tailwind.mjs": "./tailwind.mjs",
41
31
  "./migration.mjs": "./migration.mjs",
32
+ "./normalise.mjs": "./normalise.mjs",
33
+ "./props.mjs": "./props.mjs",
34
+ "./ratchet.mjs": "./ratchet.mjs",
35
+ "./ruleindex.mjs": "./ruleindex.mjs",
36
+ "./rules.mjs": "./rules.mjs",
37
+ "./scan.mjs": "./scan.mjs",
38
+ "./scope.mjs": "./scope.mjs",
39
+ "./selfgate.mjs": "./selfgate.mjs",
42
40
  "./stylex.mjs": "./stylex.mjs",
43
- "./enforcement.mjs": "./enforcement.mjs"
41
+ "./tailwind.mjs": "./tailwind.mjs",
42
+ "./theme-choice.mjs": "./theme-choice.mjs",
43
+ "./theme-entry.mjs": "./theme-entry.mjs",
44
+ "./themehost.mjs": "./themehost.mjs",
45
+ "./tokens.mjs": "./tokens.mjs",
46
+ "./upgrade.mjs": "./upgrade.mjs",
47
+ "./usage.mjs": "./usage.mjs",
48
+ "./wiring.mjs": "./wiring.mjs",
49
+ "./workflow.mjs": "./workflow.mjs",
50
+ "./workspace.mjs": "./workspace.mjs"
44
51
  },
45
52
  "files": [
46
53
  "*.mjs",
package/theme-entry.mjs CHANGED
@@ -106,29 +106,103 @@ export function parseEntry(source) {
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * Why the answer names the QUESTION it answered, and not only "no" (G-141).
111
+ *
112
+ * `{ started: false, parseError: null }` used to be the answer to four
113
+ * different questions, and a caller could not tell them apart:
114
+ *
115
+ * - the entry has no import of `startThemeChoice` at all
116
+ * - it imports it as a TYPE, which is erased before the module runs
117
+ * - it imports it and the call is not the next top-level statement
118
+ * - `systemPackage` was never passed, so the walk searched for an import
119
+ * from the literal string `undefined/theme-choice` and matched nothing
120
+ *
121
+ * The last is a CALLER error and not a verdict about the entry, and it is the
122
+ * one that cost two wrong measurements: `themeStartup(source)` reads unary,
123
+ * returns the byte-identical shape of a genuinely unwired entry, and a
124
+ * correctly-wired file is then reported as failing the gate. So a missing or
125
+ * non-string `systemPackage` now THROWS rather than being searched for. A
126
+ * programming mistake that produces a plausible verdict is worse than one that
127
+ * produces an exception, which is G-134's lesson one layer along: a file the
128
+ * parser never read is not a wiring complaint, and a question nobody asked is
129
+ * not a wiring verdict either.
130
+ *
131
+ * The other three are real verdicts and stay verdicts — but each gets its own
132
+ * `reason`, because the caller's single message named only the third
133
+ * possibility and was therefore a false statement about the first two.
134
+ */
135
+ export const STARTUP_REASON = Object.freeze({
136
+ STARTED: "started",
137
+ PARSE_ERROR: "parse-error",
138
+ /** Nothing in the file imports `startThemeChoice` as a value from the controller. */
139
+ NO_IMPORT: "no-import",
140
+ /** It is imported, but as a type, so the binding does not exist at runtime. */
141
+ TYPE_ONLY_IMPORT: "type-only-import",
142
+ /** It is imported as a value, and the call is not the next top-level statement. */
143
+ CALL_NOT_ADJACENT: "call-not-adjacent",
144
+ });
145
+
146
+ /**
147
+ * Worse news wins. A file with one irrelevant import and one nearly-correct one
148
+ * should be told about the nearly-correct one, so the reasons are ranked and
149
+ * the walk keeps the strongest it has seen rather than the last.
150
+ */
151
+ const REASON_RANK = [
152
+ STARTUP_REASON.NO_IMPORT,
153
+ STARTUP_REASON.TYPE_ONLY_IMPORT,
154
+ STARTUP_REASON.CALL_NOT_ADJACENT,
155
+ ];
156
+
109
157
  /**
110
158
  * Only an actual VALUE import followed by a direct top-level call counts.
111
159
  *
112
- * `{ started: true }` the contract is met
113
- * `{ started: false }` parsed fine, the wiring is not there
114
- * `{ started: false, parseError }` never read; say so and say where
160
+ * `{ started: true, reason: "started" }` the contract is met
161
+ * `{ started: false, reason: "no-import" }` parsed fine, nothing imports it
162
+ * `{ started: false, reason: "type-only-import" }` imported, but erased at runtime
163
+ * `{ started: false, reason: "call-not-adjacent" }` imported, not called next
164
+ * `{ started: false, reason: "parse-error", parseError }` never read; say where
165
+ *
166
+ * @throws {TypeError} if `systemPackage` is missing or is not a non-empty
167
+ * string. See the note above: searching for `undefined/theme-choice` and
168
+ * reporting the miss as a wiring failure is a lie about the consumer's file.
115
169
  */
116
170
  export function themeStartup(source, systemPackage) {
171
+ if (typeof systemPackage !== "string" || systemPackage === "") {
172
+ throw new TypeError(
173
+ "themeStartup(source, systemPackage): systemPackage must be a non-empty string naming the design-system " +
174
+ `package whose /theme-choice module the entry imports; received ${
175
+ systemPackage === undefined ? "undefined — it takes TWO arguments" : JSON.stringify(systemPackage)}`,
176
+ );
177
+ }
117
178
  const { program, error } = parseEntry(source);
118
- if (error) return { started: false, parseError: error };
179
+ if (error) return { started: false, reason: STARTUP_REASON.PARSE_ERROR, parseError: error };
180
+ let reason = STARTUP_REASON.NO_IMPORT;
181
+ const worse = (next) => {
182
+ if (REASON_RANK.indexOf(next) > REASON_RANK.indexOf(reason)) reason = next;
183
+ };
119
184
  for (const [index, node] of program.body.entries()) {
120
185
  if (node.type !== "ImportDeclaration" ||
121
186
  node.source.value !== `${systemPackage}/theme-choice`) continue;
122
- // `import type { … }` is erased before the module runs.
123
- if (node.importKind === "type") continue;
124
- const binding = node.specifiers.find((part) => part.type === "ImportSpecifier" &&
125
- part.importKind !== "type" &&
126
- part.imported.type === "Identifier" && part.imported.name === "startThemeChoice")?.local.name;
127
- if (!binding) continue;
187
+ const named = node.specifiers.find((part) => part.type === "ImportSpecifier" &&
188
+ part.imported.type === "Identifier" && part.imported.name === "startThemeChoice");
189
+ if (!named) continue;
190
+ // `import type { … }` and `import { type … }` are both erased before the
191
+ // module runs, so the call beside them throws rather than starting the theme.
192
+ if (node.importKind === "type" || named.importKind === "type") {
193
+ worse(STARTUP_REASON.TYPE_ONLY_IMPORT);
194
+ continue;
195
+ }
196
+ worse(STARTUP_REASON.CALL_NOT_ADJACENT);
197
+ const binding = named.local.name;
128
198
  const next = program.body[index + 1];
129
- if (next?.type === "ExpressionStatement" && directCall(next.expression, binding)) return { started: true };
199
+ if (next?.type === "ExpressionStatement" && directCall(next.expression, binding)) {
200
+ return { started: true, reason: STARTUP_REASON.STARTED, parseError: null };
201
+ }
130
202
  if (next?.type === "VariableDeclaration" && next.kind === "const" &&
131
- next.declarations.length === 1 && directCall(next.declarations[0].init, binding)) return { started: true };
203
+ next.declarations.length === 1 && directCall(next.declarations[0].init, binding)) {
204
+ return { started: true, reason: STARTUP_REASON.STARTED, parseError: null };
205
+ }
132
206
  }
133
- return { started: false, parseError: null };
207
+ return { started: false, reason, parseError: null };
134
208
  }
package/themehost.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { readFileSync, existsSync } from "node:fs";
3
3
  import { join, resolve, relative, sep } from "node:path";
4
4
  import { htmlElements } from "./htmlscan.mjs";
5
- import { themeStartup } from "./theme-entry.mjs";
5
+ import { themeStartup, STARTUP_REASON } from "./theme-entry.mjs";
6
6
 
7
7
  function within(root, path) {
8
8
  if (typeof path !== "string" || !path || path.startsWith("/")) return null;
@@ -24,14 +24,43 @@ function read(root, path) {
24
24
  */
25
25
  export function checkThemeHosts(root, design = {}, { packages = null } = {}) {
26
26
  const declared = design.themeHosts;
27
- if (declared === undefined) return { checked: 0, errors: [] };
28
- if (!Array.isArray(declared)) return { checked: 0, errors: ["design.themeHosts must be an array"] };
27
+ if (declared === undefined) return { declared: 0, checked: 0, errors: [] };
28
+ if (!Array.isArray(declared)) return { declared: 0, checked: 0, errors: ["design.themeHosts must be an array"] };
29
29
  const errors = [];
30
+ /**
31
+ * `declared` and `checked` are two different numbers and used to be one
32
+ * (G-141 follow-up). The config-gap return below reported
33
+ * `checked: declared.length` from a path that inspects nothing — a count
34
+ * claiming work it did not do, which is the same defect class as the verdict
35
+ * that claimed an answer to a question nobody asked. CHARTER §5: "Gyde did
36
+ * not run" and "Gyde found nothing" must never render the same, and a single
37
+ * number cannot tell them apart.
38
+ *
39
+ * So this one is COUNTED as hosts are inspected rather than restated from
40
+ * the input. A count derived from `declared.length` is an assertion about
41
+ * the loop; a count the loop increments is a measurement of it, and only the
42
+ * second goes wrong when the loop stops running.
43
+ */
44
+ let checked = 0;
30
45
  const systemPath = design.systemPath || "packages/design-system";
31
- const systemPackage = design.systemPackage || `${design.scope}/design-system`;
46
+ // `${undefined}/design-system` is a STRING, so an undeclared scope used to
47
+ // produce the specifier "undefined/design-system", match no import, and be
48
+ // reported as a consumer's broken wiring (G-141, same shape as the arity bug
49
+ // below). Say which key is missing instead.
50
+ const named = (value) => (typeof value === "string" && value !== "" ? value : null);
51
+ const systemPackage = named(design.systemPackage) ??
52
+ (named(design.scope) ? `${design.scope}/design-system` : null);
53
+ if (systemPackage === null) {
54
+ return {
55
+ declared: declared.length,
56
+ checked: 0,
57
+ errors: ["design.themeHosts is declared, but neither design.systemPackage nor design.scope names the design-system package the entry must import"],
58
+ };
59
+ }
32
60
  const expected = read(root, `${systemPath}/src/theme-bootstrap.js`);
33
61
  for (const [index, host] of declared.entries()) {
34
62
  const label = `design.themeHosts[${index}]`;
63
+ checked += 1;
35
64
  if (!host || typeof host !== "object" || host.adapter !== "html-inline") {
36
65
  errors.push(`${label}: unsupported adapter; this gate currently supports html-inline`);
37
66
  continue;
@@ -74,20 +103,35 @@ export function checkThemeHosts(root, design = {}, { packages = null } = {}) {
74
103
  // A file the parser never finished reading has not failed the wiring
75
104
  // contract; it has failed to be read. Saying otherwise sends the reader to
76
105
  // rearrange imports the parser never reached (G-134).
106
+ //
107
+ // And one message for "not wired" was a false statement about two of the
108
+ // three ways an entry can be unwired (G-141): a file with no import at all
109
+ // was told its call was misplaced. Each reason gets the sentence that is
110
+ // true of it, or the reader is sent to fix something that is not wrong.
77
111
  const startup = themeStartup(entry, systemPackage);
78
- if (startup.parseError) {
112
+ if (startup.reason === STARTUP_REASON.PARSE_ERROR) {
79
113
  const { message, line, column } = startup.parseError;
80
114
  const at = line === null ? "" : ` at line ${line}, column ${column}`;
81
115
  errors.push(`${label}: declared module entry ${host.entry} could not be parsed${at} — ${message}`);
82
- } else if (!startup.started) {
83
- errors.push(`${label}: linked entry must import and immediately call startThemeChoice at module top level`);
116
+ } else if (startup.reason === STARTUP_REASON.NO_IMPORT) {
117
+ errors.push(`${label}: linked entry ${host.entry} has no import of startThemeChoice from ${systemPackage}/theme-choice`);
118
+ } else if (startup.reason === STARTUP_REASON.TYPE_ONLY_IMPORT) {
119
+ errors.push(`${label}: linked entry ${host.entry} imports startThemeChoice as a TYPE, which is erased before the module runs — import it as a value and call it at module top level`);
120
+ } else if (startup.reason === STARTUP_REASON.CALL_NOT_ADJACENT) {
121
+ errors.push(`${label}: linked entry ${host.entry} imports startThemeChoice but must call it immediately, at module top level`);
84
122
  }
85
123
  }
86
- return { checked: declared.length, errors };
124
+ return { declared: declared.length, checked, errors };
87
125
  }
88
126
 
127
+ /**
128
+ * Both numbers are printed, always. The line used to render `checked` under
129
+ * the word "declared", so one number stood for two claims and the reader could
130
+ * not see a run that declared hosts and inspected none of them.
131
+ */
89
132
  export function formatThemeHosts(result) {
133
+ const counts = `${result.declared} declared, ${result.checked} checked`;
90
134
  return result.errors.length
91
- ? `theme hosts ${result.checked} declared, ${result.errors.length} error(s):\n${result.errors.map((e) => ` ${e}`).join("\n")}`
92
- : `theme hosts ${result.checked} declared, checked`;
135
+ ? `theme hosts ${counts}, ${result.errors.length} error(s):\n${result.errors.map((e) => ` ${e}`).join("\n")}`
136
+ : `theme hosts ${counts}`;
93
137
  }