@krapwoo/ds-viewer 0.2.0 → 0.4.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.
Files changed (52) hide show
  1. package/README.md +151 -0
  2. package/config/index.ts +4 -2
  3. package/dist/cli/agentsFile.js +31 -0
  4. package/dist/cli/dev.js +312 -15
  5. package/dist/cli/doctor.js +502 -0
  6. package/dist/cli/endpoint.js +142 -0
  7. package/dist/cli/explain.js +151 -0
  8. package/dist/cli/githubAction.js +65 -0
  9. package/dist/cli/init.js +9 -5
  10. package/dist/cli/kitDiff.js +132 -0
  11. package/dist/cli/kitRoot.js +20 -0
  12. package/dist/cli/main.js +123 -15
  13. package/dist/cli/migrate.js +30 -0
  14. package/dist/cli/migrateEntry.js +19 -0
  15. package/dist/cli/migrations/index.js +14 -0
  16. package/dist/cli/migrations/types.js +9 -0
  17. package/dist/cli/packageManager.js +29 -0
  18. package/dist/cli/packageVersion.js +24 -0
  19. package/dist/cli/pageIndex.js +27 -1
  20. package/dist/cli/props.js +4 -3
  21. package/dist/cli/semver.js +26 -0
  22. package/dist/cli/staticPage.js +356 -0
  23. package/dist/cli/sync.js +11 -4
  24. package/dist/cli/update.js +93 -0
  25. package/dist/cli/updateCheck.js +143 -0
  26. package/dist/cli/updatePlan.js +202 -0
  27. package/dist/cli/workspace.js +10 -5
  28. package/dist/native/catalog/catalogNavigation.js +129 -0
  29. package/dist/native/catalog/comparison.js +283 -0
  30. package/dist/native/catalog/tokens.js +134 -0
  31. package/dist/native/catalog/types.js +1 -0
  32. package/native/catalog/CatalogShell.tsx +105 -11
  33. package/native/catalog/CatalogSidebar.tsx +58 -2
  34. package/native/catalog/ComparisonGrid.tsx +9 -7
  35. package/native/catalog/SectionBlock.tsx +5 -0
  36. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  37. package/native/catalog/UpdatePanel.tsx +556 -0
  38. package/native/catalog/catalogNavigation.ts +70 -1
  39. package/native/catalog/comparison.ts +63 -5
  40. package/native/catalog/index.ts +5 -1
  41. package/native/catalog/pageApi.ts +11 -2
  42. package/native/catalog/tokens.ts +15 -0
  43. package/native/catalog/types.ts +19 -5
  44. package/native/catalog/updatePanelState.ts +74 -0
  45. package/package.json +5 -1
  46. package/starter-kit/components/Avatar/Avatar.catalog.tsx +4 -22
  47. package/starter-kit/components/Badge/Badge.catalog.tsx +15 -23
  48. package/starter-kit/components/Banner/Banner.tsx +1 -1
  49. package/starter-kit/components/BottomSheet/BottomSheet.tsx +2 -2
  50. package/starter-kit/components/Button/Button.catalog.tsx +15 -29
  51. package/starter-kit/components/Dialog/Dialog.tsx +2 -2
  52. package/starter-kit/components/Pill/Pill.catalog.tsx +12 -26
@@ -0,0 +1,502 @@
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
+ }
493
+ /** `formatHuman`'s own output always ends with its one summary line (design §4's own example: "0
494
+ * errors, 15 warnings — 37 components (37 with examples), 130 unbound examples.") — every issue
495
+ * and its fix come before it. Used by `dev.ts`/`update.ts` to shrink the `doctor` step's captured
496
+ * stdout down to that one line for the update panel's success state (Minor finding, raised to
497
+ * Important by the controller: the mockup's own success page shows one line, not the whole
498
+ * report). Never throws on empty input — falls back to the trimmed input itself. */
499
+ export function lastSummaryLine(output) {
500
+ const lines = output.split('\n').map((line) => line.trimEnd()).filter((line) => line.length > 0);
501
+ return lines.length > 0 ? lines[lines.length - 1] : output.trim();
502
+ }
@@ -0,0 +1,142 @@
1
+ // cli/endpoint.ts
2
+ import http from 'node:http';
3
+ import { timingSafeEqual } from 'node:crypto';
4
+ import { DS_VIEWER_SECRET_HEADER } from "../native/catalog/catalogNavigation.js";
5
+ /** A constant-time secret compare (Minor finding, Fable correction pass; controller decision 1
6
+ * makes this required, not optional) — `!==` on two strings short-circuits at the first
7
+ * differing byte, which leaks the secret's length and prefix through response-timing one byte at
8
+ * a time. Buffers of different lengths are rejected before ever reaching `timingSafeEqual`
9
+ * (which throws, rather than returning `false`, when its two inputs aren't the same length). */
10
+ function secretMatches(received, expected) {
11
+ if (typeof received !== 'string')
12
+ return false;
13
+ const receivedBuf = Buffer.from(received);
14
+ const expectedBuf = Buffer.from(expected);
15
+ return receivedBuf.length === expectedBuf.length && timingSafeEqual(receivedBuf, expectedBuf);
16
+ }
17
+ function sendJson(res, status, body, allowedOrigin) {
18
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': allowedOrigin });
19
+ res.end(JSON.stringify(body));
20
+ }
21
+ /** Design §5 "Local endpoint safeguards", every one of them, in the exact order this plan's own
22
+ * `spikes/cors-endpoint/` spike proved: Origin (including `OPTIONS`) → secret → route. Does not
23
+ * call `.listen()` itself — Task 18's `dev.ts` owns the real port (`0` → OS-assigned, `127.0.0.1`
24
+ * only) and this task's own tests each pick their own ephemeral port the same way. */
25
+ export function createUpdateEndpoint(deps) {
26
+ // Critical finding, Fable correction pass: the "one update at a time" refusal only ever checked
27
+ // `getStatus()`, which doesn't become `'updating'` until `startUpdate` runs — but `buildPlan`
28
+ // (re-downloading the tarball) can take seconds, and the panel leaves **Update now** enabled
29
+ // throughout, so a second click during that window passed the same check twice. This flag closes
30
+ // exactly that window: set synchronously before the plan is even (re-)built, reset on every path
31
+ // that does *not* hand the plan to `startUpdate` — the 502/409 refusals below. Once `startUpdate`
32
+ // is actually called, `getStatus()` already reports `'updating'` (set synchronously inside it, by
33
+ // every real caller), so resetting this flag right after is safe and lets a later retry (after a
34
+ // `'failure'`, in the same process) through again.
35
+ let starting = false;
36
+ return http.createServer(async (req, res) => {
37
+ // Controller decision 1 (required, not optional): reject a request whose `Host` header is not
38
+ // this very socket's own `127.0.0.1:<port>` — a DNS-rebinding defence, since `req.socket`'s own
39
+ // `localPort` is the port the OS actually accepted this connection on, never spoofable the way
40
+ // `Origin` (an ordinary request header) technically is from a non-browser client. Checked before
41
+ // everything else, including `OPTIONS`.
42
+ if (req.headers.host !== `127.0.0.1:${req.socket.localPort}`) {
43
+ res.writeHead(400).end('Invalid Host header.');
44
+ return;
45
+ }
46
+ const origin = req.headers.origin;
47
+ if (req.method === 'OPTIONS') {
48
+ // The browser's own preflight never attaches the app's custom header to itself — checking
49
+ // the secret here would make every state-changing request fail cross-origin before it ever
50
+ // got the chance to send one.
51
+ if (origin !== deps.allowedOrigin) {
52
+ res.writeHead(403).end();
53
+ return;
54
+ }
55
+ res.writeHead(204, {
56
+ 'Access-Control-Allow-Origin': deps.allowedOrigin,
57
+ 'Access-Control-Allow-Methods': 'POST, GET',
58
+ 'Access-Control-Allow-Headers': DS_VIEWER_SECRET_HEADER,
59
+ });
60
+ res.end();
61
+ return;
62
+ }
63
+ if (origin === undefined) {
64
+ res.writeHead(400).end('Missing Origin header.');
65
+ return;
66
+ }
67
+ if (origin !== deps.allowedOrigin) {
68
+ res.writeHead(403).end('Origin rejected.');
69
+ return;
70
+ }
71
+ if (!secretMatches(req.headers[DS_VIEWER_SECRET_HEADER], deps.secret)) {
72
+ res.writeHead(401).end('Missing or wrong secret.');
73
+ return;
74
+ }
75
+ if (req.method === 'POST' && req.url === '/plan') {
76
+ const plan = await deps.buildPlan();
77
+ sendJson(res, 200, plan, deps.allowedOrigin);
78
+ return;
79
+ }
80
+ if (req.method === 'POST' && req.url === '/update') {
81
+ // "Runs one update at a time; further start requests are refused while one runs" (design
82
+ // §5) — checked before even building a fresh plan, so a second click never re-downloads
83
+ // anything while the first update is still in flight.
84
+ const status = deps.getStatus();
85
+ if (starting || status.phase === 'updating' || status.phase === 'restarting') {
86
+ sendJson(res, 409, { error: 'An update is already running.' }, deps.allowedOrigin);
87
+ return;
88
+ }
89
+ starting = true;
90
+ let plan;
91
+ try {
92
+ plan = await deps.buildPlan();
93
+ }
94
+ catch (error) {
95
+ // A rejected plan must not leave `starting` stuck, or every later Update now is refused.
96
+ starting = false;
97
+ sendJson(res, 502, { error: error.message }, deps.allowedOrigin);
98
+ return;
99
+ }
100
+ if ('error' in plan) {
101
+ starting = false;
102
+ sendJson(res, 502, { error: plan.error }, deps.allowedOrigin);
103
+ return;
104
+ }
105
+ // "Refuses with the plan's dirty-file list ... the panel offers no override" (design §5) —
106
+ // this is the one, authoritative refusal: even if the panel's own UI somehow let a dirty
107
+ // plan through, the endpoint itself still never starts.
108
+ if (plan.dirtyFiles.length > 0) {
109
+ starting = false;
110
+ sendJson(res, 409, { error: 'dirty-files', dirtyFiles: plan.dirtyFiles }, deps.allowedOrigin);
111
+ return;
112
+ }
113
+ // Sends 202 *before* ever calling `startUpdate` — install/migrate/doctor run as real child
114
+ // processes that can take seconds (Critical finding, Fable correction pass: calling
115
+ // `startUpdate` first, synchronously, blocked this response until the whole update finished,
116
+ // so the browser's `POST /update` saw a connection reset instead of 202, and every
117
+ // `/update/status` poll during the install went unanswered). `setImmediate` defers the actual
118
+ // work to the next turn of the event loop, after this response has already been written.
119
+ sendJson(res, 202, { started: true }, deps.allowedOrigin);
120
+ // Errata 3: `startUpdate` is wrapped here too — a synchronous throw inside `setImmediate`'s
121
+ // callback would otherwise become an unhandled exception on the whole process, since there is
122
+ // no request/response left at that point to carry the error back to.
123
+ setImmediate(() => {
124
+ try {
125
+ deps.startUpdate(plan);
126
+ }
127
+ catch (error) {
128
+ console.warn('Update could not start: ' + error.message);
129
+ }
130
+ finally {
131
+ starting = false;
132
+ }
133
+ });
134
+ return;
135
+ }
136
+ if (req.method === 'GET' && req.url === '/update/status') {
137
+ sendJson(res, 200, deps.getStatus(), deps.allowedOrigin);
138
+ return;
139
+ }
140
+ res.writeHead(404).end('Not found.');
141
+ });
142
+ }