@krapwoo/ds-viewer 0.2.0 → 0.3.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
@@ -61,6 +61,8 @@ Either way, then:
61
61
  ```bash
62
62
  npm run ds-viewer dev # opens the catalog in a browser, re-syncing on every file change
63
63
  npm run ds-viewer sync # regenerates component/token/page data without starting a server
64
+ npm run ds-viewer doctor # checks every page for drift and coverage gaps (--json, --ci)
65
+ npm run ds-viewer explain <Page> # prints why a page's specimens are laid out the way they are
64
66
  ```
65
67
 
66
68
  `ds-viewer.config.ts` fields: `name`, `logo` (optional — a path to an image asset, shown in the
@@ -204,6 +206,86 @@ data, not a component — see `ColorsGallery`/`SpacingGallery`/`TypographyGaller
204
206
  output full width under a "Tokens" label and shows only a Quick reference card with the source
205
207
  path.
206
208
 
209
+ ## Keeping the catalog current
210
+
211
+ ### Binding examples to props
212
+
213
+ A grid's row or column axis can name the real prop it demonstrates:
214
+
215
+ ```tsx
216
+ import { defineCatalogPage, grid } from '@krapwoo/ds-viewer';
217
+
218
+ comparison: grid(
219
+ 'Variant', 'State',
220
+ { prop: 'variant', items: [
221
+ { key: 'primary', label: 'Primary' },
222
+ { key: 'ghost', label: 'Ghost' },
223
+ ] },
224
+ [{ key: 'default', label: 'Default' }, { key: 'disabled', label: 'Disabled' }],
225
+ (row, column) => <Button variant={row} disabled={column === 'disabled'} onPress={() => {}} />,
226
+ ),
227
+ ```
228
+
229
+ Only a prop typed as a string-literal union (2 or more options) can be bound this way; each item's
230
+ `key` must then be a real option of that prop. An axis that doesn't name a single real prop (e.g.
231
+ "which of three optional props is set," or a continuous `number`) stays a plain array of
232
+ `{ key, label }` — just as valid, and never flagged by `doctor`. `variants`/`states` list items use
233
+ the existing `props` tag (`{ key: 'ghost', props: { variant: 'ghost' }, node: ... }`) the same way.
234
+ Binding is what lets `doctor` tell you precisely when a renamed or removed option leaves a stale
235
+ example behind, instead of you finding out by reading the diff.
236
+
237
+ ### `npx ds-viewer doctor`
238
+
239
+ Reads every page with the TypeScript compiler — it never imports a page file or runs app code.
240
+ Human output is grouped by page, each issue with a one-line fix:
241
+
242
+ ```
243
+ Button
244
+ [error] bound-option-removed: The row "outline" is bound to "variant", which no longer has that option.
245
+ Fix: Delete this row, or update its key to a current option of "variant".
246
+
247
+ 0 errors, 1 warning — 38 components (37 with examples), 2 unbound examples.
248
+ ```
249
+
250
+ - `--json` prints the same report as stable, versioned data: `{ version: 1, summary: { errors, warnings, components, withExamples, unboundExamples }, update: null, issues: [{ id, severity, page?, file?, line?, message, fix }] }`. `update` is always `null` in this release (filled in starting 0.4's update check); `file` is always relative to the project root. `line` is populated for a `page-parse-error` found by typechecking a page file (see below), and always `undefined` for every other issue id — threading a real position through every rule is future work.
251
+ - `--ci` exits 1 only when an error was found — a warning alone never fails CI, and `--ci` never makes a network call.
252
+ - `doctor.strict: true` in `ds-viewer.config.ts` promotes every warning to an error, for both the summary counts and `--ci`'s exit code.
253
+ - A page whose layout data (`specimenSize`, `group`, grid `rows`/`columns`, list items' `key`/`name`/`props`/`group`, `propNotes` keys) isn't a literal — written as something other than a literal value, array, object, or a same-file `const` reference — gets one "page not statically checkable" warning. A page file that fails to parse *or typecheck* is `page-parse-error` instead: every page file is also run through the TypeScript type checker (the same way a component file already is), so a genuine syntax or type error in a page is reported with its line.
254
+
255
+ ## Known gaps
256
+
257
+ - The viewer's own dev-mode console warnings only cover `duplicate-page-id` (design §4's "the same rules appear as development warnings naming the page" — scoped to that one rule in 0.3). Bound-axis drift (`bound-option-removed`, `bound-axis-prop-removed`) has no viewer-side warning yet; `npx ds-viewer doctor` (and its `--ci` workflow) is the only place that catches it today.
258
+
259
+ ### `npx ds-viewer explain <Page>`
260
+
261
+ Prints each layout decision with its reason, using the exact same pure functions the catalog itself
262
+ renders with:
263
+
264
+ ```
265
+ Button
266
+ Grid (Variant × State)
267
+ 5 rows × 3 columns (regular); 3 of 3 max regular columns used — fits a 1280px laptop.
268
+ ```
269
+
270
+ Side-by-side placement depends on a real measured height, so by default `explain` reports it as
271
+ "decided in the viewer." Pass `--heights <firstBlockPx>,<secondBlockPx>` to check that decision here
272
+ too, assuming a 1280px laptop (952px content width) the same way `doctor`'s own column-limit check
273
+ does. `--json` is available on `explain` as well.
274
+
275
+ ### The `AGENTS.md` section and GitHub Action
276
+
277
+ `npx @krapwoo/ds-viewer init` (either path) appends a short section to `AGENTS.md` between
278
+ `<!-- ds-viewer:start v1 -->`/`<!-- ds-viewer:end -->` markers — creating the file if it doesn't
279
+ exist — telling an AI working on the project to update a page alongside its component, bind
280
+ examples to props, never invent grid cells or groups, and run `doctor`/`explain` before finishing.
281
+ It also writes `.github/workflows/ds-viewer.yml`, a SHA-pinned Action that installs dependencies
282
+ (picking `npm ci`, `pnpm install --frozen-lockfile`, or `yarn install --immutable` from whichever
283
+ lockfile the project has at that moment) and then runs `npx ds-viewer doctor --ci` on every pull
284
+ request. Both are written once; re-running `init` adds
285
+ either one only if it's missing (an `AGENTS.md` without the start marker counts as missing the
286
+ section, even if the file already has other content) and never touches anything outside its own
287
+ markers or its own file.
288
+
207
289
  ## What's included
208
290
 
209
291
  Tokens · Icons (45) · and generic components:
@@ -0,0 +1,31 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const START_MARKER = '<!-- ds-viewer:start v1 -->';
4
+ const END_MARKER = '<!-- ds-viewer:end -->';
5
+ // Design §4's four numbered lines, verbatim — no heading added: the design names only these four
6
+ // lines as the section's content.
7
+ const SECTION_BODY = [
8
+ "1. When you add or change a component's props or a token, update its \`*.catalog.tsx\` in the same change.",
9
+ "2. Bind examples to props (\`prop\` on grid axes, \`props\` on list items) so drift is detected.",
10
+ '3. Never invent grid cells or groups; author only combinations that exist.',
11
+ "4. Run \`npx ds-viewer doctor\` before finishing and fix every error. Use \`npx ds-viewer explain <Page>\` to check layout; see the README's page-authoring guide.",
12
+ ].join('\n');
13
+ /** Design §4 "AGENTS.md section", §1 Ownership, §2 step 3. Appends between the exact markers,
14
+ * creating the file when absent — never touches a byte outside them, and never runs at all once
15
+ * the start marker is already present, even if the rest of the file looks nothing like what this
16
+ * function would have written (design: "An AGENTS.md without the markers counts as missing the
17
+ * section" — the converse is also true: a file WITH the marker is never rewritten in 0.3). */
18
+ export function ensureAgentsFileSection(projectRoot) {
19
+ const agentsPath = path.join(projectRoot, 'AGENTS.md');
20
+ const existing = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
21
+ if (existing.includes(START_MARKER))
22
+ return [];
23
+ // Minor finding (Fable's review): a CRLF `AGENTS.md` must not end up with mixed line endings
24
+ // after the append — the separator and the whole section below use whichever newline the
25
+ // existing file already uses.
26
+ const newline = existing.includes('\r\n') ? '\r\n' : '\n';
27
+ const separator = existing === '' ? '' : existing.endsWith(`${newline}${newline}`) ? '' : existing.endsWith(newline) ? newline : `${newline}${newline}`;
28
+ const body = newline === '\n' ? SECTION_BODY : SECTION_BODY.replace(/\n/g, newline);
29
+ writeFileSync(agentsPath, `${existing}${separator}${START_MARKER}${newline}${body}${newline}${END_MARKER}${newline}`);
30
+ return [agentsPath];
31
+ }
@@ -0,0 +1,492 @@
1
+ import * as ts from 'typescript';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { discoverAllPageFiles, resolvePageId } from "./pageIndex.js";
5
+ import { readHostTsconfigOptions } from "./props.js";
6
+ import { readStaticPages } from "./staticPage.js";
7
+ import { resolveGlob } from "./glob.js";
8
+ import { sync } from "./sync.js";
9
+ import { gridColumnLimit } from "../native/catalog/comparison.js";
10
+ function issue(fields) {
11
+ return fields;
12
+ }
13
+ function pageRef(page) {
14
+ return { page: resolvePageId(page, page.file), file: page.file };
15
+ }
16
+ /** Errata (controller, binding): a page discovered from a component folder (a `components` glob's
17
+ * folder, not a `pages` glob) with no explicit `component:` documents the component named by its
18
+ * own file (design §2; the viewer already applies this same default via `page.component ??
19
+ * page.id`, and the generated page index already defaults a missing `id` to the file stem). A
20
+ * page from a standalone `pages` glob is never touched — `componentFolders` only ever contains
21
+ * folders resolved from `config.components`, so a standalone page's own folder is never a member. */
22
+ export function applyComponentDefaults(pages, componentFolders) {
23
+ return pages.map((page) => page.component === undefined && componentFolders.has(path.dirname(page.file))
24
+ ? { ...page, component: resolvePageId(page, page.file) }
25
+ : page);
26
+ }
27
+ function checkParseErrors(pages) {
28
+ return pages
29
+ .filter((p) => Boolean(p.parseError))
30
+ .map((p) => issue({ id: 'page-parse-error', severity: 'error', file: p.file, message: `Failed to parse: ${p.parseError}`, fix: 'Fix the syntax error, then rerun doctor.' }));
31
+ }
32
+ function checkNotCheckable(pages) {
33
+ return pages
34
+ .filter((p) => !p.checkable && !p.parseError)
35
+ .map((p) => issue({
36
+ id: 'page-not-checkable', severity: 'warning', ...pageRef(p),
37
+ message: 'Page not statically checkable — some of its layout data is not a literal doctor can read.',
38
+ fix: 'Write rows/columns/items/propNotes as literal arrays/objects (or a same-file const), not a function call.',
39
+ }));
40
+ }
41
+ function checkComponentExportRemoved(pages, components) {
42
+ const names = new Set(components.map((c) => c.name));
43
+ return pages
44
+ .filter((p) => p.component && !names.has(p.component))
45
+ .map((p) => issue({
46
+ id: 'component-export-removed', severity: 'error', ...pageRef(p),
47
+ message: `Documents "${p.component}", which no longer exists as a component export.`,
48
+ fix: 'Delete this page, or update "component" to the export\'s new name.',
49
+ }));
50
+ }
51
+ /** True when a page documents at least one example by any mechanism design §4 recognizes — a
52
+ * grid, a `variants` item, a `states` item, or `render`. A page can be "fully covered" by
53
+ * `checkComponentExportRemoved`'s standard (it names a real component) and still have none of
54
+ * these — e.g. `init`'s own "Needs examples" draft, which sets only `component`/`group`/
55
+ * `description` — in which case the viewer shows the exact same "No examples documented." an
56
+ * undocumented component would. */
57
+ function pageHasExamples(page) {
58
+ return Boolean(page.comparison) || Boolean(page.variantsItems?.length) || Boolean(page.statesItems?.length) || page.hasRender === true;
59
+ }
60
+ function checkComponentNoExamples(pages, components) {
61
+ const byComponent = new Map();
62
+ for (const page of pages) {
63
+ if (page.component)
64
+ byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
65
+ }
66
+ return components
67
+ .filter((c) => {
68
+ const componentPages = byComponent.get(c.name) ?? [];
69
+ // A not-statically-checkable page is given the benefit of the doubt here — it might have
70
+ // real examples this reader simply couldn't read (Global Constraint: never a partial read;
71
+ // its own `page-not-checkable` warning already covers it). Only "every page is checkable
72
+ // and genuinely empty" (or "no page at all") fires this rule.
73
+ return componentPages.length === 0 || componentPages.every((p) => p.checkable && !pageHasExamples(p));
74
+ })
75
+ .map((c) => {
76
+ const hasPage = (byComponent.get(c.name) ?? []).length > 0;
77
+ return issue({
78
+ id: 'component-no-examples', severity: 'warning', page: c.name, file: c.file,
79
+ message: hasPage
80
+ ? `"${c.name}" has a catalog page with no variants, states, comparison, or render — it will show "No examples documented."`
81
+ : `"${c.name}" has no catalog page — it will show "No examples documented."`,
82
+ fix: hasPage ? `Add variants/states/comparison, or render, to ${c.name}'s catalog page.` : `Add ${c.name}.catalog.tsx beside ${c.name}'s own file.`,
83
+ });
84
+ });
85
+ }
86
+ function checkDuplicatePageId(pages) {
87
+ const byId = new Map();
88
+ for (const page of pages) {
89
+ const id = resolvePageId(page, page.file);
90
+ byId.set(id, [...(byId.get(id) ?? []), page]);
91
+ }
92
+ const issues = [];
93
+ for (const [id, group] of byId) {
94
+ if (group.length < 2)
95
+ continue;
96
+ for (const page of group) {
97
+ const others = group.filter((p) => p !== page).map((p) => p.file).join(', ');
98
+ issues.push(issue({
99
+ id: 'duplicate-page-id', severity: 'error', page: id, file: page.file,
100
+ message: `Page id "${id}" is also used by ${others}.`,
101
+ fix: 'Give one of these pages its own "id".',
102
+ }));
103
+ }
104
+ }
105
+ return issues;
106
+ }
107
+ function checkGridAxisKeys(pages) {
108
+ const issues = [];
109
+ for (const page of pages) {
110
+ // Global Constraints: "never a partial read" — a not-checkable page gets only
111
+ // `page-not-checkable` (`checkNotCheckable` above); a *different* field on it happening to be
112
+ // literal (e.g. its grid, while its `propNotes` wasn't) is never grounds for a second opinion.
113
+ if (!page.checkable || !page.comparison)
114
+ continue;
115
+ for (const [axisName, axis] of [['row', page.comparison.rows], ['column', page.comparison.columns]]) {
116
+ const seen = new Set();
117
+ for (const item of axis.items) {
118
+ if (item.key === '') {
119
+ issues.push(issue({ id: 'grid-axis-key-invalid', severity: 'error', ...pageRef(page), message: `A ${axisName} has an empty key.`, fix: `Give every ${axisName} a non-empty, unique key.` }));
120
+ }
121
+ else if (seen.has(item.key)) {
122
+ issues.push(issue({ id: 'grid-axis-key-invalid', severity: 'error', ...pageRef(page), message: `Duplicate ${axisName} key "${item.key}".`, fix: `Give each ${axisName} its own key.` }));
123
+ }
124
+ seen.add(item.key);
125
+ }
126
+ }
127
+ }
128
+ return issues;
129
+ }
130
+ function checkGridColumnLimit(pages) {
131
+ const issues = [];
132
+ for (const page of pages) {
133
+ if (!page.checkable || !page.comparison)
134
+ continue; // same "never a partial read" reasoning as checkGridAxisKeys above.
135
+ const size = (page.comparison.size ?? page.specimenSize ?? 'regular');
136
+ const limit = gridColumnLimit(size);
137
+ const count = page.comparison.columns.items.length;
138
+ if (count > limit) {
139
+ issues.push(issue({
140
+ id: 'grid-column-limit', severity: 'error', ...pageRef(page),
141
+ message: `Grid has ${count} columns; at most ${limit} ${size} columns fit a 1280px laptop.`,
142
+ fix: 'Swap the axes, use two lists, or pick a smaller specimenSize.',
143
+ }));
144
+ }
145
+ }
146
+ return issues;
147
+ }
148
+ /** Everything that needs a page's own matching `ComponentRecord` to check: `propNotes`, bound
149
+ * axes, tagged list items, coverage, grouped-state matching, and guidance/a11y presence. One
150
+ * function because every one of these checks shares the same per-page `component`/`propsByName`
151
+ * lookup — splitting it further would only duplicate that lookup, not clarify anything. */
152
+ function checkComponentPages(pages, components) {
153
+ const componentsByName = new Map(components.map((c) => [c.name, c]));
154
+ const issues = [];
155
+ for (const page of pages) {
156
+ if (!page.component)
157
+ continue;
158
+ if (!page.checkable)
159
+ continue; // never a partial read — see checkGridAxisKeys's own note.
160
+ const component = componentsByName.get(page.component);
161
+ if (!component)
162
+ continue; // already reported by checkComponentExportRemoved
163
+ const ref = pageRef(page);
164
+ const propsByName = new Map(component.props.map((p) => [p.name, p]));
165
+ for (const key of page.propNoteKeys ?? []) {
166
+ if (!propsByName.has(key)) {
167
+ issues.push(issue({
168
+ id: 'propnotes-prop-removed', severity: 'error', ...ref,
169
+ message: `propNotes names "${key}", which is no longer a prop of ${component.name}.`,
170
+ fix: `Delete the "${key}" entry from propNotes, or rename it to the prop's new name.`,
171
+ }));
172
+ }
173
+ }
174
+ if (page.comparison) {
175
+ for (const [axisName, axis] of [['row', page.comparison.rows], ['column', page.comparison.columns]]) {
176
+ if (axis.prop === undefined)
177
+ continue;
178
+ const prop = propsByName.get(axis.prop);
179
+ if (!prop) {
180
+ issues.push(issue({
181
+ id: 'bound-axis-prop-removed', severity: 'error', ...ref,
182
+ message: `The ${axisName} axis is bound to "${axis.prop}", which is no longer a prop of ${component.name}.`,
183
+ fix: 'Update "prop" to the prop\'s new name, or unbind this axis.',
184
+ }));
185
+ continue;
186
+ }
187
+ if (!prop.options) {
188
+ issues.push(issue({
189
+ id: 'bound-axis-not-union', severity: 'error', ...ref,
190
+ message: `The ${axisName} axis is bound to "${axis.prop}", which is not a string-literal-union prop.`,
191
+ fix: 'Only a prop with a fixed set of string options can be bound; unbind this axis.',
192
+ }));
193
+ continue;
194
+ }
195
+ for (const item of axis.items) {
196
+ if (!prop.options.includes(item.key)) {
197
+ issues.push(issue({
198
+ id: 'bound-option-removed', severity: 'error', ...ref,
199
+ message: `The ${axisName} "${item.key}" is bound to "${axis.prop}", which no longer has that option.`,
200
+ fix: `Delete this ${axisName}, or update its key to a current option of "${axis.prop}".`,
201
+ }));
202
+ }
203
+ }
204
+ }
205
+ }
206
+ const listItems = [...(page.variantsItems ?? []), ...(page.statesItems ?? [])];
207
+ for (const item of listItems) {
208
+ for (const [propName, value] of Object.entries(item.props ?? {})) {
209
+ if (typeof value !== 'string')
210
+ continue;
211
+ const prop = propsByName.get(propName);
212
+ if (!prop) {
213
+ // A name missing from `props` is only real drift when the component has nothing
214
+ // inherited to explain it — `component.props` deliberately omits a node_modules-declared
215
+ // prop (cli/props.ts:200), so e.g. `autoCapitalize` on a `TextInput`-extending component
216
+ // is expected to be absent here, not a removed prop (design: `inheritedFrom` summarises
217
+ // those instead of listing them).
218
+ if (component.inheritedFrom.length > 0)
219
+ continue;
220
+ // The tagged prop *name* itself no longer exists — arguably the same drift as
221
+ // `bound-axis-prop-removed`, but a tagged list item (unlike a bound axis) has no
222
+ // "prop" field of its own to report that id against, so it's reported here instead.
223
+ // Previously silently skipped (`!prop?.options` was true for both "prop missing" and
224
+ // "prop exists but isn't a union"), masking exactly the drift this rule exists to catch.
225
+ issues.push(issue({
226
+ id: 'bound-option-removed', severity: 'error', ...ref,
227
+ message: `"${item.name}" is tagged props.${propName} = "${value}", but "${propName}" is no longer a prop of ${component.name}.`,
228
+ fix: 'Delete the tag, or update it to a current prop name.',
229
+ }));
230
+ continue;
231
+ }
232
+ if (!prop.options || prop.options.includes(value))
233
+ continue;
234
+ issues.push(issue({
235
+ id: 'bound-option-removed', severity: 'error', ...ref,
236
+ message: `"${item.name}" is tagged props.${propName} = "${value}", which is no longer an option of "${propName}".`,
237
+ fix: 'Update the tagged value, or delete this item if the option no longer exists.',
238
+ }));
239
+ }
240
+ }
241
+ for (const prop of component.props) {
242
+ if (!prop.options)
243
+ continue;
244
+ const covered = new Set();
245
+ if (page.comparison) {
246
+ for (const axis of [page.comparison.rows, page.comparison.columns]) {
247
+ if (axis.prop === prop.name)
248
+ for (const item of axis.items)
249
+ covered.add(item.key);
250
+ }
251
+ }
252
+ for (const item of listItems) {
253
+ const value = item.props?.[prop.name];
254
+ if (typeof value === 'string')
255
+ covered.add(value);
256
+ }
257
+ for (const option of prop.options.filter((o) => !covered.has(o))) {
258
+ issues.push(issue({
259
+ id: 'option-not-covered', severity: 'warning', ...ref,
260
+ message: `"${prop.name}" option "${option}" has no bound or tagged example.`,
261
+ fix: `Bind a grid axis to "${prop.name}", or add { props: { ${prop.name}: '${option}' } } to a variants/states item.`,
262
+ }));
263
+ }
264
+ }
265
+ const variantKeys = new Set((page.variantsItems ?? []).map((i) => i.key));
266
+ for (const item of page.statesItems ?? []) {
267
+ if (item.group !== undefined && !variantKeys.has(item.group)) {
268
+ issues.push(issue({
269
+ id: 'group-unmatched', severity: 'warning', ...ref,
270
+ message: `"${item.name}" names group "${item.group}", which matches no variant key; it will show under "Other configurations".`,
271
+ fix: 'Fix the group value, or remove it if this item isn\'t tied to a specific variant.',
272
+ }));
273
+ }
274
+ }
275
+ // Design §4 names this one rule, "missing guidance or accessibility notes" — one id, and (per
276
+ // the controller) both halves: a missing `whenToUse` and a missing `a11y` each get their own
277
+ // message under `missing-guidance-or-a11y`, since the viewer renders a visible placeholder
278
+ // ("No usage guidance documented."/"No accessibility notes documented.") for either gap, not
279
+ // just the second. Never invented content — both messages only ever point the author at
280
+ // writing their own, real sentence.
281
+ if (!page.tokenGallery && page.hasWhenToUse === false) {
282
+ issues.push(issue({
283
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
284
+ message: 'No "whenToUse" guidance documented.',
285
+ fix: 'Add a one-sentence whenToUse note disambiguating this component from its closest look-alike(s), or omit it only when there genuinely is none.',
286
+ }));
287
+ }
288
+ if (!page.tokenGallery && page.hasA11y === false && !page.hidesAccessibility) {
289
+ issues.push(issue({
290
+ id: 'missing-guidance-or-a11y', severity: 'warning', ...ref,
291
+ message: 'No "a11y" notes documented.',
292
+ fix: 'Add a one-sentence a11y note, grounded in the component\'s real behavior — even "no explicit handling beyond defaults" is better than leaving it blank.',
293
+ }));
294
+ }
295
+ }
296
+ return issues;
297
+ }
298
+ /** Every page-scoped issue, from every rule above. */
299
+ export function collectIssues(pages, components) {
300
+ return [
301
+ ...checkParseErrors(pages),
302
+ ...checkNotCheckable(pages),
303
+ ...checkComponentExportRemoved(pages, components),
304
+ ...checkComponentNoExamples(pages, components),
305
+ ...checkDuplicatePageId(pages),
306
+ ...checkGridAxisKeys(pages),
307
+ ...checkGridColumnLimit(pages),
308
+ ...checkComponentPages(pages, components),
309
+ ];
310
+ }
311
+ /** Issues that aren't tied to one page: `sync`'s own "Could not read" warnings, and a configured
312
+ * project with no token modules at all. */
313
+ export function collectGlobalIssues(input) {
314
+ const issues = input.syncWarnings.map((warning) => issue({ id: 'props-unreadable', severity: 'warning', message: warning, fix: 'Fix the file so sync can read it, or exclude it from the components/tokens globs.' }));
315
+ if (input.tokenFileCount === 0) {
316
+ issues.push(issue({ id: 'no-token-modules', severity: 'warning', message: 'No token modules are configured.', fix: 'Add a tokens glob to ds-viewer.config.ts.' }));
317
+ }
318
+ return issues;
319
+ }
320
+ /** Design §4's other half of "fails to parse or typecheck" — the half `cli/staticPage.ts` (Task 3)
321
+ * deliberately never attempts, since it has no type checker and no module resolution at all.
322
+ * This builds exactly one `ts.Program` over every page file, using the identical compiler-host
323
+ * construction `cli/props.ts`'s `createComponentReader` already uses for component files (same
324
+ * `compilerOptions` shape, same `fallbackPaths` mechanism for a page outside the project root —
325
+ * `runDoctor`, Task 5, computes `fallbackPaths` the same way `cli/sync.ts` already does for
326
+ * component files), and reports every syntactic *and* semantic diagnostic as `page-parse-error`,
327
+ * with a real `line` (unlike every other rule in this file — see this task's own resolved-scope
328
+ * note above). Errata (controller, binding): a page that already has a `parseError` from
329
+ * `cli/staticPage.ts`'s own syntactic reader is skipped here — its syntax error was already
330
+ * reported once by `checkParseErrors`, and this program would otherwise report it again (its own
331
+ * syntactic diagnostics are a superset of what `ts.transpileModule` already found). A spike
332
+ * against the real kit (`spikes/page-typecheck/` in this plan's spike folder) found exactly one
333
+ * real, previously-uncaught diagnostic, fixed in Task 2. */
334
+ export function checkPageTypeErrors(pages, options = {}) {
335
+ const checkablePages = pages.filter((p) => !p.parseError);
336
+ const pageFiles = checkablePages.map((p) => p.file);
337
+ if (pageFiles.length === 0)
338
+ return [];
339
+ const byFile = new Map(checkablePages.map((p) => [p.file, p]));
340
+ // Important finding 2 (Fable's review): the host project's own tsconfig decides every option —
341
+ // its ambient declarations (expo-env.d.ts's `*.png`/`*.svg` modules, a project's own
342
+ // declarations.d.ts) and its `strict`/`lib`/etc. settings — not a fixed set copied from
343
+ // `createComponentReader` (where a mismatch only degrades prop reading silently; here it would
344
+ // produce CI-failing errors the host's own `tsc` would never raise). Only `noEmit`,
345
+ // `skipLibCheck`, and the merged `paths` are forced on top.
346
+ const host = readHostTsconfigOptions(pageFiles[0]);
347
+ const compilerOptions = {
348
+ ...host.options,
349
+ noEmit: true,
350
+ skipLibCheck: true,
351
+ paths: { ...options.fallbackPaths, ...host.paths },
352
+ };
353
+ const program = ts.createProgram([...pageFiles, ...host.declarationFiles], compilerOptions);
354
+ const issues = [];
355
+ for (const file of pageFiles) {
356
+ const sourceFile = program.getSourceFile(file);
357
+ if (!sourceFile)
358
+ continue;
359
+ const diagnostics = [...program.getSyntacticDiagnostics(sourceFile), ...program.getSemanticDiagnostics(sourceFile)];
360
+ const page = byFile.get(file);
361
+ const ref = page ? pageRef(page) : { file };
362
+ for (const d of diagnostics) {
363
+ const line = d.start !== undefined ? sourceFile.getLineAndCharacterOfPosition(d.start).line + 1 : undefined;
364
+ issues.push(issue({
365
+ id: 'page-parse-error', severity: 'error', ...ref, line,
366
+ message: `Failed to typecheck: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`,
367
+ fix: 'Fix the error, then rerun doctor.',
368
+ }));
369
+ }
370
+ }
371
+ return issues;
372
+ }
373
+ /** "With examples" means what `checkComponentNoExamples` (Task 4) checks the *absence* of: at
374
+ * least one checkable page with a real example, not merely "has a page" (an `init`-drafted
375
+ * "Needs examples" page has one, with none). */
376
+ function countWithExamples(pages, components) {
377
+ const byComponent = new Map();
378
+ for (const page of pages) {
379
+ if (page.component)
380
+ byComponent.set(page.component, [...(byComponent.get(page.component) ?? []), page]);
381
+ }
382
+ return components.filter((c) => (byComponent.get(c.name) ?? []).some((p) => p.checkable && pageHasExamples(p))).length;
383
+ }
384
+ function countUnboundExamples(pages) {
385
+ let count = 0;
386
+ for (const page of pages) {
387
+ if (page.comparison) {
388
+ if (page.comparison.rows.prop === undefined)
389
+ count += page.comparison.rows.items.length;
390
+ if (page.comparison.columns.prop === undefined)
391
+ count += page.comparison.columns.items.length;
392
+ }
393
+ for (const item of [...(page.variantsItems ?? []), ...(page.statesItems ?? [])]) {
394
+ if (!item.props || Object.keys(item.props).length === 0)
395
+ count += 1;
396
+ }
397
+ }
398
+ return count;
399
+ }
400
+ /** Runs `sync`, reads every page `cli/staticPage.ts` can find (`discoverAllPageFiles` —
401
+ * deliberately wider than the viewer's own page list, so a renamed/removed component's orphaned
402
+ * page is still seen), applies the component-folder default (Errata, `applyComponentDefaults`
403
+ * above), and applies every rule from Task 4. `config.doctor.strict` promotes every warning to an
404
+ * error for both the summary counts and the exit code (design §2 Config: "`strict: true` promotes
405
+ * `doctor` warnings to errors"). */
406
+ export function runDoctor(config) {
407
+ const syncResult = sync(config);
408
+ const components = JSON.parse(readFileSync(path.join(syncResult.generatedDir, 'components.json'), 'utf8'));
409
+ // Same exclusion `sync` already applies to `componentEntryFiles` (cli/sync.ts:29-30) — without
410
+ // it, an excluded folder's own `*.catalog.tsx` is still discovered below (`discoverAllPageFiles`)
411
+ // even though its component never makes it into `components.json`, firing a false
412
+ // `component-export-removed`.
413
+ const excluded = new Set((config.exclude ?? []).flatMap((pattern) => resolveGlob(config.projectRoot, pattern)));
414
+ const componentFolders = [
415
+ ...new Set(config.components
416
+ .flatMap((pattern) => resolveGlob(config.projectRoot, pattern))
417
+ .filter((file) => !excluded.has(file))
418
+ .map((file) => path.dirname(file))),
419
+ ];
420
+ const tokenFileCount = config.tokens.flatMap((pattern) => resolveGlob(config.projectRoot, pattern)).length;
421
+ const pageFiles = discoverAllPageFiles({ componentFolders, standalonePageGlobs: config.pages ?? [], projectRoot: config.projectRoot });
422
+ const pages = applyComponentDefaults(readStaticPages(pageFiles), new Set(componentFolders));
423
+ // Same "a page outside the project root needs a fallback paths entry" rule `cli/sync.ts` already
424
+ // applies to component entry files (e.g. `kit-host`'s own component pages, which physically live
425
+ // in the sibling `../starter-kit/`) — mirrored here for page files, since `checkPageTypeErrors`
426
+ // below needs it for the identical reason `createComponentReader` does. `@krapwoo/ds-viewer`
427
+ // itself resolves from `../starter-kit` pages through Node's own self-name resolution (the repo
428
+ // root `package.json`'s `name` + `exports`), not this fallback — never a `starter-kit/package.json`.
429
+ const hasPageOutsideProjectRoot = pageFiles.some((file) => !file.startsWith(`${config.projectRoot}${path.sep}`));
430
+ const fallbackPaths = hasPageOutsideProjectRoot
431
+ ? { '*': [path.join(config.projectRoot, 'node_modules', '@types', '*'), path.join(config.projectRoot, 'node_modules', '*')] }
432
+ : undefined;
433
+ let issues = [
434
+ ...collectIssues(pages, components),
435
+ ...checkPageTypeErrors(pages, { fallbackPaths }),
436
+ ...collectGlobalIssues({ syncWarnings: syncResult.warnings, tokenFileCount }),
437
+ ];
438
+ // Design's own `--json` example gives a project-relative `file`; `StaticPage.file` is absolute
439
+ // (`discoverAllPageFiles`) and `ComponentRecord.file` is cwd-relative (`cli/props.ts`) — cwd
440
+ // equals `config.projectRoot` for every real CLI invocation, so resolving against one and
441
+ // re-relativizing against the other normalizes both to the same, documented shape.
442
+ issues = issues.map((i) => (i.file ? { ...i, file: path.relative(config.projectRoot, path.resolve(config.projectRoot, i.file)) } : i));
443
+ if (config.doctor?.strict)
444
+ issues = issues.map((i) => (i.severity === 'warning' ? { ...i, severity: 'error' } : i));
445
+ const summary = {
446
+ errors: issues.filter((i) => i.severity === 'error').length,
447
+ warnings: issues.filter((i) => i.severity === 'warning').length,
448
+ components: components.length,
449
+ withExamples: countWithExamples(pages, components),
450
+ unboundExamples: countUnboundExamples(pages),
451
+ };
452
+ return { version: 1, summary, update: null, issues, exitCode: summary.errors > 0 ? 1 : 0 };
453
+ }
454
+ export function toDoctorJson(result) {
455
+ const { version, summary, update, issues } = result;
456
+ return { version, summary, update, issues };
457
+ }
458
+ /** Human report (design §4 "Output"): grouped by page, each issue with its one-line fix; issues
459
+ * with no `page` (`props-unreadable`, `no-token-modules`) are listed under "General". Ends with
460
+ * the same counts `--json`'s `summary` carries. */
461
+ export function formatHuman(result) {
462
+ const byPage = new Map();
463
+ const general = [];
464
+ for (const issue of result.issues) {
465
+ if (issue.page)
466
+ byPage.set(issue.page, [...(byPage.get(issue.page) ?? []), issue]);
467
+ else
468
+ general.push(issue);
469
+ }
470
+ const lines = [];
471
+ const renderIssue = (issue) => {
472
+ const location = issue.file ? ` (${issue.file}${issue.line ? `:${issue.line}` : ''})` : '';
473
+ lines.push(` [${issue.severity}] ${issue.id}: ${issue.message}${location}`);
474
+ lines.push(` Fix: ${issue.fix}`);
475
+ };
476
+ for (const [page, issues] of [...byPage].sort(([a], [b]) => a.localeCompare(b))) {
477
+ lines.push(page);
478
+ for (const issue of issues)
479
+ renderIssue(issue);
480
+ lines.push('');
481
+ }
482
+ if (general.length > 0) {
483
+ lines.push('General');
484
+ for (const issue of general)
485
+ renderIssue(issue);
486
+ lines.push('');
487
+ }
488
+ const { errors, warnings, components, withExamples, unboundExamples } = result.summary;
489
+ lines.push(`${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'} — ` +
490
+ `${components} components (${withExamples} with examples), ${unboundExamples} unbound examples.`);
491
+ return lines.join('\n');
492
+ }