@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4

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 (212) hide show
  1. package/README.md +99 -66
  2. package/api/build/_adapter.d.mts +58 -0
  3. package/api/build/_adapter.mjs +115 -3
  4. package/api/build/blocks/blocks.d.mts +42 -0
  5. package/api/build/blocks/blocks.mjs +169 -0
  6. package/api/build/blocks/blocks.test.mjs +108 -0
  7. package/api/build/blocks/blocks.weights.json +1 -0
  8. package/api/build/build.mjs +25 -6
  9. package/api/build/kit/answer.d.mts +14 -0
  10. package/api/build/kit/answer.mjs +85 -0
  11. package/api/build/kit/answer.test.mjs +38 -0
  12. package/api/build/kit/kind.d.mts +25 -0
  13. package/api/build/kit/kind.mjs +170 -0
  14. package/api/build/kit/kit.d.mts +2 -4
  15. package/api/build/kit/kit.mjs +8 -398
  16. package/api/build/kit/rank.d.mts +3 -32
  17. package/api/build/kit/rank.mjs +25 -342
  18. package/api/build/kit/rank.test.mjs +2 -1
  19. package/api/build/kit/signals.d.mts +60 -0
  20. package/api/build/kit/signals.mjs +284 -0
  21. package/api/build/kit/start.d.mts +32 -0
  22. package/api/build/kit/start.mjs +163 -0
  23. package/api/build/kit/words.d.mts +72 -0
  24. package/api/build/kit/words.mjs +202 -0
  25. package/api/build/tree.d.mts +20 -0
  26. package/api/build/tree.mjs +52 -0
  27. package/api/build/tree.test.mjs +177 -0
  28. package/api/build/tree.weights.json +1 -0
  29. package/api/component/component.mjs +16 -2
  30. package/api/component/component.test.mjs +15 -0
  31. package/api/discover/_adapter.mjs +36 -9
  32. package/api/discover/_adapter.test.mjs +42 -0
  33. package/api/docs/_adapter.mjs +17 -5
  34. package/api/docs/docs.test.mjs +7 -1
  35. package/api/doctor/doctor.mjs +86 -12
  36. package/api/doctor/doctor.test.mjs +28 -3
  37. package/api/init/init.doc.mjs +20 -6
  38. package/api/init/init.test.mjs +18 -2
  39. package/api/init/init.type.d.mts +3 -3
  40. package/api/init/init.type.mjs +3 -3
  41. package/api/init/remove/remove.mjs +17 -4
  42. package/api/init/remove/remove.test.mjs +114 -2
  43. package/api/init/run/run.mjs +69 -6
  44. package/api/integration/authoring-checks.mjs +83 -9
  45. package/api/integration/pack-check.mjs +123 -4
  46. package/api/integration/pack-check.test.mjs +87 -0
  47. package/api/integration/validate-integration-case-disk.test.mjs +111 -0
  48. package/api/integration/validate-integration-gaps.test.mjs +1964 -0
  49. package/api/integration/validate-integration.d.mts +11 -2
  50. package/api/integration/validate-integration.mjs +226 -32
  51. package/api/integration/validate-integration.type.d.mts +58 -1
  52. package/api/integration/validate-integration.type.mjs +45 -2
  53. package/api/integration/validateIntegration.doc.mjs +1 -1
  54. package/api/search/ORDERING.md +28 -0
  55. package/api/search/_adapter.d.mts +66 -2
  56. package/api/search/_adapter.mjs +118 -10
  57. package/api/search/_candidate-score.d.mts +40 -0
  58. package/api/search/_candidate-score.mjs +238 -0
  59. package/api/search/_cards.json +6098 -0
  60. package/api/search/_compound.d.mts +50 -0
  61. package/api/search/_compound.mjs +170 -0
  62. package/api/search/_doc-searcher.d.mts +4 -0
  63. package/api/search/_doc-searcher.mjs +23 -0
  64. package/api/search/_doc-searcher.test.mjs +36 -0
  65. package/api/search/_doc-searcher.weights.json +1 -0
  66. package/api/search/_doc-text.d.mts +30 -0
  67. package/api/search/_doc-text.mjs +87 -0
  68. package/api/search/_entries.d.mts +52 -0
  69. package/api/search/_entries.mjs +158 -0
  70. package/api/search/_features.d.mts +35 -0
  71. package/api/search/_features.mjs +155 -0
  72. package/api/search/_gather-components.d.mts +26 -0
  73. package/api/search/_gather-components.mjs +284 -0
  74. package/api/search/_gather-docs.d.mts +20 -0
  75. package/api/search/_gather-docs.mjs +254 -0
  76. package/api/search/_gather-hooks.d.mts +12 -0
  77. package/api/search/_gather-hooks.mjs +52 -0
  78. package/api/search/_gather-templates.d.mts +25 -0
  79. package/api/search/_gather-templates.mjs +107 -0
  80. package/api/search/_lexical.d.mts +135 -0
  81. package/api/search/_lexical.mjs +351 -0
  82. package/api/search/_main-searcher.d.mts +19 -0
  83. package/api/search/_main-searcher.mjs +115 -0
  84. package/api/search/_main-searcher.test.mjs +106 -0
  85. package/api/search/_main-searcher.weights.json +1 -0
  86. package/api/search/_module-doc.d.mts +32 -0
  87. package/api/search/_module-doc.mjs +78 -0
  88. package/api/search/_order.d.mts +32 -0
  89. package/api/search/_order.mjs +162 -0
  90. package/api/search/_order.test.mjs +72 -0
  91. package/api/search/_priority.d.mts +50 -0
  92. package/api/search/_priority.mjs +139 -0
  93. package/api/search/_query-words.d.mts +25 -0
  94. package/api/search/_query-words.mjs +92 -0
  95. package/api/search/_query.d.mts +65 -0
  96. package/api/search/_query.mjs +176 -0
  97. package/api/search/_result.d.mts +97 -0
  98. package/api/search/_result.mjs +150 -0
  99. package/api/search/_score.d.mts +60 -0
  100. package/api/search/_score.mjs +283 -0
  101. package/api/search/_synonyms.d.mts +21 -0
  102. package/api/search/_synonyms.mjs +67 -0
  103. package/api/search/_template-searcher.d.mts +4 -0
  104. package/api/search/_template-searcher.mjs +23 -0
  105. package/api/search/_template-searcher.test.mjs +40 -0
  106. package/api/search/_template-searcher.weights.json +1 -0
  107. package/api/search/_titles.d.mts +33 -0
  108. package/api/search/_titles.mjs +65 -0
  109. package/api/search/_words.d.mts +64 -0
  110. package/api/search/_words.mjs +157 -0
  111. package/api/search/search.d.mts +22 -305
  112. package/api/search/search.doc.mjs +7 -5
  113. package/api/search/search.mjs +53 -2483
  114. package/api/search/search.type.d.mts +6 -1
  115. package/api/search/search.type.mjs +6 -1
  116. package/api/search/tree.d.mts +15 -0
  117. package/api/search/tree.mjs +40 -0
  118. package/api/search/tree.test.mjs +66 -0
  119. package/api/search/tree.weights.json +1 -0
  120. package/api/theme/build/build.packed-old-core.test.mjs +1 -0
  121. package/api/upgrade/_adapter.d.mts +57 -9
  122. package/api/upgrade/_adapter.mjs +198 -28
  123. package/api/upgrade/registry/registry.d.mts +10 -4
  124. package/api/upgrade/registry/registry.mjs +33 -16
  125. package/api/upgrade/registry/registry.test.mjs +85 -0
  126. package/api/upgrade/run/run.d.mts +4 -2
  127. package/api/upgrade/run/run.mjs +13 -2
  128. package/api/upgrade/run/run.test.mjs +417 -3
  129. package/api/upgrade/upgrade.doc.mjs +10 -5
  130. package/api/upgrade/upgrade.mjs +13 -2
  131. package/api/upgrade/upgrade.test.mjs +194 -0
  132. package/api/upgrade/upgrade.type.d.mts +5 -3
  133. package/api/upgrade/upgrade.type.mjs +8 -6
  134. package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
  135. package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
  136. package/assets/docs/is-my-code-good.doc.mjs +287 -0
  137. package/assets/docs/tree/api.doc.mjs +6 -0
  138. package/assets/docs/tree/host-entry.doc.mjs +128 -0
  139. package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
  140. package/assets/docs/tree/tokens-data.doc.mjs +2 -2
  141. package/assets/docs/working-with-ai.doc.mjs +6 -2
  142. package/assets/templates/themes/butter/butterTheme.ts +4 -2
  143. package/assets/templates/themes/stone/stoneTheme.ts +4 -2
  144. package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
  145. package/authoring/debug/parse.d.mts +1 -1
  146. package/clients/cli/bin/astryx.mjs +7 -78
  147. package/clients/cli/commands/doctor.mjs +30 -0
  148. package/clients/cli/commands/doctor.test.mjs +65 -0
  149. package/clients/cli/commands/init.behavior.test.mjs +230 -1
  150. package/clients/cli/commands/init.doc.mjs +28 -9
  151. package/clients/cli/commands/init.flag-help.test.mjs +1 -0
  152. package/clients/cli/commands/init.mjs +2 -2
  153. package/clients/cli/commands/outside-project.test.mjs +2 -1
  154. package/clients/cli/commands/setup-nudge.test.mjs +61 -0
  155. package/clients/cli/commands/upgrade.doc.mjs +4 -2
  156. package/clients/cli/commands/upgrade.mjs +9 -2
  157. package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
  158. package/clients/cli/index.mjs +19 -1
  159. package/clients/cli/run.mjs +108 -0
  160. package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
  161. package/foundation/agent-docs/agent-doc-state.mjs +35 -8
  162. package/foundation/agent-docs/agent-docs.d.mts +100 -12
  163. package/foundation/agent-docs/agent-docs.mjs +1010 -99
  164. package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
  165. package/foundation/agent-docs/agent-docs.test.mjs +678 -35
  166. package/foundation/config/project.mjs +20 -22
  167. package/foundation/config/project.test.mjs +56 -0
  168. package/foundation/discovery/component-discovery.d.mts +37 -0
  169. package/foundation/discovery/component-discovery.mjs +80 -5
  170. package/foundation/discovery/component-loader.d.mts +1 -1
  171. package/foundation/discovery/docs-discovery.mjs +14 -1
  172. package/foundation/discovery/module-exports.d.mts +41 -0
  173. package/foundation/discovery/module-exports.mjs +253 -0
  174. package/foundation/discovery/template-adapter.mjs +18 -1
  175. package/foundation/discovery/template-needs.mjs +1 -44
  176. package/foundation/discovery/theme-discovery.mjs +147 -3
  177. package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
  178. package/foundation/doc-compiler/read.mjs +59 -1
  179. package/foundation/doc-compiler/read.test.mjs +11 -2
  180. package/foundation/fs/paths.d.mts +70 -8
  181. package/foundation/fs/paths.mjs +556 -38
  182. package/foundation/fs/paths.test.mjs +430 -54
  183. package/foundation/integrations/autolink.d.mts +2 -1
  184. package/foundation/integrations/autolink.mjs +4 -13
  185. package/foundation/integrations/computer-integrations.d.mts +6 -3
  186. package/foundation/integrations/computer-integrations.mjs +38 -9
  187. package/foundation/integrations/contribution-inventory.d.mts +15 -20
  188. package/foundation/integrations/contribution-inventory.mjs +41 -5
  189. package/foundation/integrations/integrations.d.mts +27 -1
  190. package/foundation/integrations/integrations.mjs +61 -2
  191. package/foundation/integrations/validate-contributions.d.mts +115 -0
  192. package/foundation/integrations/validate-contributions.mjs +1168 -13
  193. package/foundation/release/handoff.d.mts +23 -4
  194. package/foundation/release/handoff.mjs +175 -8
  195. package/foundation/release/run-context.mjs +14 -0
  196. package/foundation/response/response-types.doc.mjs +1 -1
  197. package/foundation/route/route.d.mts +285 -0
  198. package/foundation/route/route.mjs +324 -0
  199. package/foundation/route/route.test.mjs +153 -0
  200. package/foundation/runtime/hosted-runtime.test.mjs +403 -0
  201. package/foundation/runtime/locked-versions.d.mts +32 -0
  202. package/foundation/runtime/locked-versions.mjs +65 -0
  203. package/foundation/runtime/package-root.d.mts +91 -0
  204. package/foundation/runtime/package-root.mjs +174 -0
  205. package/foundation/runtime/package-set.d.mts +62 -0
  206. package/foundation/runtime/package-set.mjs +130 -0
  207. package/foundation/runtime/package-set.test.mjs +432 -0
  208. package/host/door-conformance.test.mjs +274 -0
  209. package/host/index.d.ts +116 -0
  210. package/host/index.mjs +295 -0
  211. package/host/index.test.mjs +302 -0
  212. package/package.json +14 -9
@@ -4,10 +4,12 @@
4
4
  * @file init.run leaf — non-interactive project setup + feature installer.
5
5
  *
6
6
  * `run(options, ctx)` is the default / `--features` / `--all` path behind
7
- * `astryx init`. It installs the AGENTS.md/CLAUDE.md agent-docs cheat sheet,
8
- * points at the theme + page-building workflows, and (optionally) scaffolds a
9
- * starter template — with NO prompts, so it behaves identically for humans,
10
- * agents, CI, and piped I/O. It performs the side effects and returns an
7
+ * `astryx init`. It installs the agent-docs cheat sheet at the project root so
8
+ * every agent reads it (AGENTS.md, a copy in the project's Claude Code file, and
9
+ * a GEMINI.md import), points at the theme + page-building workflows, and
10
+ * (optionally) scaffolds a starter template — with NO prompts, so it behaves
11
+ * identically for humans, agents, CI, and piped I/O. It performs the side
12
+ * effects and returns an
11
13
  * `init.run` receipt. Hard errors (unknown feature/template) throw AstryxError
12
14
  * with a stable code. Human output is emitted through the shared `logger`
13
15
  * (silent by default) so the CLI keeps its exact plain output and a
@@ -21,8 +23,11 @@ import {PathSafetyError, assertWithin} from '../../../foundation/fs/path-safety.
21
23
  import {getCliInvocation} from '../../../foundation/env/package-manager.mjs';
22
24
  import {isOutsideProject} from '../../../foundation/env/project-scope.mjs';
23
25
  import {
26
+ AgentDocsHomeError,
27
+ assertAgentDocsTarget,
24
28
  installAgentDocs,
25
29
  renderAgentDocsBlock,
30
+ agentDocsTargetDir,
26
31
  } from '../../../foundation/agent-docs/agent-docs.mjs';
27
32
  import {themeTemplate} from '../../theme/template/template.mjs';
28
33
  import {listTemplates} from '../../template/template.mjs';
@@ -64,6 +69,24 @@ export function getNextSteps(invocation) {
64
69
  ];
65
70
  }
66
71
 
72
+ /**
73
+ * Whether two paths name one folder, links resolved.
74
+ * @param {string} a
75
+ * @param {string} b
76
+ * @returns {boolean}
77
+ */
78
+ function sameFolder(a, b) {
79
+ /** @param {string} p */
80
+ const real = p => {
81
+ try {
82
+ return fs.realpathSync(p);
83
+ } catch {
84
+ return path.resolve(p);
85
+ }
86
+ };
87
+ return real(a) === real(b);
88
+ }
89
+
67
90
  /**
68
91
  * Install the agent-docs block; records the outcome (or soft error) on `data`.
69
92
  * Mirrors the CLI's historical soft-error policy: a PathSafetyError surfaces its
@@ -92,14 +115,46 @@ async function applyAgents(cwd, options, invocation, data) {
92
115
  ? options.agentDocsPath
93
116
  : [options.agentDocsPath]
94
117
  : undefined;
95
- const renderedBlock = await renderAgentDocsBlock(cwd);
96
- const written = installAgentDocs(cwd, {
118
+ // The docs belong at the project root, not in the subfolder init ran in;
119
+ // with no project above, they go where init ran, as before. Explicit
120
+ // --agent-docs-path entries stay relative to where init ran.
121
+ const root = agentDocsTargetDir(cwd);
122
+ const targetDir = paths ? cwd : root;
123
+ // With explicit paths, installAgentDocs checks each file's own folder.
124
+ if (!paths) assertAgentDocsTarget(targetDir);
125
+ const renderedBlock = await renderAgentDocsBlock(root);
126
+ /** @type {string[]} */
127
+ const skipped = [];
128
+ const files = installAgentDocs(targetDir, {
97
129
  agent: options.agent,
98
130
  paths,
99
131
  renderedBlock,
132
+ onSkipped: (rel, reason) => skipped.push(`${rel} (${reason})`),
133
+ // Below the project root, 0.7.1 wrote in the folder init ran in, so a
134
+ // project-root file that escapes the project is skipped and reported,
135
+ // not refused.
136
+ refuseEscapes: sameFolder(targetDir, cwd),
100
137
  });
138
+ // Each file is reported from where init ran, as before.
139
+ const written =
140
+ path.resolve(targetDir) === path.resolve(cwd)
141
+ ? files
142
+ : files.map(rel =>
143
+ path.relative(cwd, path.join(targetDir, rel)).split(path.sep).join('/'),
144
+ );
101
145
  data.docsWritten = written;
102
146
  logger.log(`[ok] AI agent docs installed -> ${written.join(', ')}`);
147
+ // A file 0.7.1 did not write (GEMINI.md, or a project-root file when init
148
+ // runs below the root) that resolves outside the project is left as it
149
+ // is, reported in docsError with one note; the exit code stays 0. A file
150
+ // 0.7.1 wrote whose path escapes the project still fails the run (below).
151
+ if (skipped.length > 0) {
152
+ const message =
153
+ `Left as ${skipped.length === 1 ? 'it is' : 'they are'}: ${skipped.join('; ')}. ` +
154
+ 'To have init write the Astryx block there, make it a regular file inside the project, or copy the block from AGENTS.md by hand.';
155
+ logger.warn(`Note: ${message}`);
156
+ data.docsError = {kind: 'install-failed', message};
157
+ }
103
158
  } catch (err) {
104
159
  // PathSafetyError carries a precise, user-actionable message — surface it
105
160
  // (and flag exit 1) instead of the generic "could not install" warning so
@@ -109,6 +164,14 @@ async function applyAgents(cwd, options, invocation, data) {
109
164
  data.docsError = {kind: 'path-safety', message: err.message};
110
165
  return;
111
166
  }
167
+ // The home folder, and a settings folder in it outside a project, are
168
+ // refused: nothing is written, the reason is printed as a note, and the
169
+ // exit code stays 0.
170
+ if (err instanceof AgentDocsHomeError) {
171
+ logger.warn(`Note: ${err.message}`);
172
+ data.docsError = {kind: 'install-failed', message: err.message};
173
+ return;
174
+ }
112
175
  logger.error(
113
176
  `Could not install agent docs. Try again with \`${invocation} init --features agents\`.`,
114
177
  );
@@ -51,6 +51,8 @@ import {
51
51
  validateInstalledIntegration,
52
52
  validateLocalIntegration,
53
53
  } from './validate-integration.mjs';
54
+ import {rootProblem} from '../../foundation/integrations/validate-contributions.mjs';
55
+ import {normalizeProviderId} from '../../foundation/identity/provider-identity.mjs';
54
56
 
55
57
  /** @param {string} value */
56
58
  export function shellArg(value) {
@@ -58,15 +60,61 @@ export function shellArg(value) {
58
60
  return `'${value.replace(/'/g, `'\\''`)}'`;
59
61
  }
60
62
 
63
+ /**
64
+ * The provider ID a package without a name is checked under: a valid bare
65
+ * package name no published package can hold (npm names are lowercase).
66
+ */
67
+ const UNNAMED_PROVIDER = 'astryx-unnamed-local-package';
68
+
69
+ /**
70
+ * A message as the package sees it: the stand-in provider ID gone, its docs
71
+ * named by their path in the package.
72
+ * @param {string} message
73
+ */
74
+ function unnamedShown(message) {
75
+ return message
76
+ .split(`${UNNAMED_PROVIDER}/`)
77
+ .join('')
78
+ .split(UNNAMED_PROVIDER)
79
+ .join('this package');
80
+ }
81
+
61
82
  /**
62
83
  * Resolve one local or installed integration for an authoring check.
63
84
  * @param {string | undefined} pkg
64
85
  * @param {string} cwd
65
86
  */
66
87
  async function resolveIntegration(pkg, cwd) {
67
- return pkg
68
- ? validateInstalledIntegration(pkg, cwd)
69
- : validateLocalIntegration(cwd);
88
+ const resolved = pkg
89
+ ? await validateInstalledIntegration(pkg, cwd)
90
+ : await validateLocalIntegration(cwd);
91
+ const integration = resolved.found ? resolved.integration : undefined;
92
+ if (!integration) return resolved;
93
+ // A root that is not a readable folder is reported in the issues; the
94
+ // checks read nothing from it rather than fail on it.
95
+ /** @type {typeof integration & {__unnamed?: boolean}} */
96
+ const usable = {...integration};
97
+ // The provider ID readers key the package on: the manifest's providerId,
98
+ // else its name. A package with neither (validate warned about it) is
99
+ // still checked; the docs check names its docs under a stand-in ID.
100
+ try {
101
+ usable.providerId = normalizeProviderId(
102
+ integration.__declaredProviderId ?? integration.name,
103
+ );
104
+ } catch {
105
+ usable.__unnamed = true;
106
+ }
107
+ for (const kind of /** @type {const} */ ([
108
+ 'components',
109
+ 'templates',
110
+ 'codemods',
111
+ 'docs',
112
+ 'themes',
113
+ ])) {
114
+ const root = usable[kind];
115
+ if (root != null && rootProblem(root)) usable[kind] = undefined;
116
+ }
117
+ return {...resolved, integration: usable};
70
118
  }
71
119
 
72
120
  /**
@@ -387,8 +435,20 @@ export async function integrationDocConflicts(pkg, options = {}) {
387
435
  };
388
436
  }
389
437
 
438
+ // The docs tree names a package's docs by its provider ID, which a
439
+ // package.json without a name does not give: its docs are checked under a
440
+ // stand-in ID, which no message names.
441
+ const unnamed = /** @type {{__unnamed?: boolean}} */ (resolved.integration)
442
+ .__unnamed;
443
+ const docsIntegration = unnamed
444
+ ? {
445
+ ...resolved.integration,
446
+ name: UNNAMED_PROVIDER,
447
+ providerId: UNNAMED_PROVIDER,
448
+ }
449
+ : resolved.integration;
390
450
  const [discovered, builtinTopics] = await Promise.all([
391
- discoverIntegrationDocs(resolved.integration),
451
+ discoverIntegrationDocs(docsIntegration),
392
452
  Promise.resolve(discoverBuiltinTopics()),
393
453
  ]);
394
454
  const {records, errors} = discovered;
@@ -458,27 +518,40 @@ export async function integrationDocConflicts(pkg, options = {}) {
458
518
  // its docs (spec:AST-046, spec:AST-047).
459
519
  if (errors.length === 0) {
460
520
  for (const {severity, message} of await packageDocsProblems(
461
- /** @type {{name: string}} */ (resolved.integration),
521
+ /** @type {{name: string}} */ (docsIntegration),
462
522
  discovered,
463
523
  )) {
464
- issues.push({code: 'invalid_doc_graph', severity, message});
524
+ issues.push({
525
+ code: 'invalid_doc_graph',
526
+ severity,
527
+ message: unnamedShown(message),
528
+ });
465
529
  }
466
530
 
467
531
  // A reference block includes content rather than linking to it, so one
468
532
  // that cannot include what it names loses that content for every reader:
469
533
  // an error, where a link that names no doc still prints as written.
470
534
  for (const message of await packageReferenceProblems(
471
- /** @type {{name: string}} */ (resolved.integration),
535
+ /** @type {{name: string}} */ (docsIntegration),
472
536
  discovered,
473
537
  )) {
474
- issues.push({code: 'invalid_doc_reference', severity: 'error', message});
538
+ issues.push({
539
+ code: 'invalid_doc_reference',
540
+ severity: 'error',
541
+ message: unnamedShown(message),
542
+ });
475
543
  }
476
544
 
477
545
  // One fact has one owner: a passage two topics repeat goes stale in one
478
546
  // of them. Each is listed and is a warning (architecture:cli-surface
479
547
  // DEC-8), so `integration check --strict` fails until it is fixed;
480
548
  // `doctor integration docs` keeps its exit code, as it fails on errors.
481
- copiedPassages.push(...(await integrationCopiedPassages(discovered)));
549
+ copiedPassages.push(
550
+ ...(await integrationCopiedPassages(discovered)).map(passage => ({
551
+ ...passage,
552
+ message: unnamedShown(passage.message),
553
+ })),
554
+ );
482
555
  for (const {message} of copiedPassages) {
483
556
  issues.push({code: 'copied_passage', severity: 'warning', message});
484
557
  }
@@ -488,6 +561,7 @@ export async function integrationDocConflicts(pkg, options = {}) {
488
561
  ...(await integrationFluff(discovered)).map(each => ({
489
562
  ...each,
490
563
  file: fromPackage(each.file),
564
+ message: unnamedShown(each.message),
491
565
  })),
492
566
  );
493
567
  for (const {message} of fluff) {
@@ -26,6 +26,11 @@ import {fileURLToPath} from 'node:url';
26
26
  import {spawnSync} from 'node:child_process';
27
27
  import {assertWithin} from '../../foundation/fs/path-safety.mjs';
28
28
  import {resolvePackageDir} from '../../foundation/integrations/integrations.mjs';
29
+ import {
30
+ literalThemeName,
31
+ rootProblem,
32
+ symlinkedFolder,
33
+ } from '../../foundation/integrations/validate-contributions.mjs';
29
34
  import {
30
35
  componentReplacesCliProblem,
31
36
  docsTreeCliProblem,
@@ -953,6 +958,10 @@ async function validatePackedThemeExports(
953
958
  );
954
959
  }
955
960
 
961
+ // The name the source gives defineTheme, when the file says it: the
962
+ // build names its files after it.
963
+ const builtName = literalThemeName(source, localTheme.exportName);
964
+
956
965
  const declaredOutputs = declaredThemeBuildOutputs(
957
966
  localIntegration,
958
967
  localTheme,
@@ -1097,7 +1106,15 @@ async function validatePackedThemeExports(
1097
1106
  ? `Theme "${localTheme.slug}" module is not the module \`${formatCliCommand(`theme build ${sourceRelative}`, localIntegration.__packageDir)}\` writes and does not only wrap it: ${localWrapper.reason}. A module that wraps the built module may only import the theme's own stylesheet and font stylesheet for their side effects and re-export from the built module either everything or only its theme, as ${localTheme.exportName}.`
1098
1107
  : localWrapper && !localWrapper.ok
1099
1108
  ? `Theme "${localTheme.slug}" module re-exports ${localWrapper.reason}, which does not match ${sourceRelative}. Run \`${formatCliCommand(`theme build ${sourceRelative}`, localIntegration.__packageDir)}\` before packing.`
1100
- : `Theme "${localTheme.slug}" ${output.kind} does not match ${sourceRelative}. Run \`${formatCliCommand(`theme build ${sourceRelative}`, localIntegration.__packageDir)}\` before packing.`,
1109
+ : (renamedOutputMessage(
1110
+ localTheme,
1111
+ output.kind,
1112
+ output.local,
1113
+ source,
1114
+ builtName,
1115
+ localIntegration.__packageDir,
1116
+ ) ??
1117
+ `Theme "${localTheme.slug}" ${output.kind} does not match ${sourceRelative}. Run \`${formatCliCommand(`theme build ${sourceRelative}`, localIntegration.__packageDir)}\` before packing.`),
1101
1118
  ),
1102
1119
  );
1103
1120
  }
@@ -1265,7 +1282,17 @@ export async function integrationPackCheck(options = {}) {
1265
1282
  // ── Phase 1: structure validation ──
1266
1283
  const localResult = await validateLocalIntegration(cwd);
1267
1284
  /** @type {Issue[]} */
1268
- const issues = [...localResult.issues];
1285
+ const issues = [
1286
+ ...localResult.issues,
1287
+ // Validate lists these layout findings without counting them. A
1288
+ // package.json npm cannot pack is what fails the pack here, as it did
1289
+ // before they were named; the rest stay warnings, which fail nothing.
1290
+ ...(localResult.found ? (localResult.listed ?? []) : []).map(issue =>
1291
+ issue.code === 'invalid_package_json'
1292
+ ? {...issue, severity: /** @type {const} */ ('error')}
1293
+ : issue,
1294
+ ),
1295
+ ];
1269
1296
 
1270
1297
  if (!localResult.found || !localResult.integration) {
1271
1298
  return emptyReceipt(pkgName, [
@@ -1314,6 +1341,8 @@ export async function integrationPackCheck(options = {}) {
1314
1341
  );
1315
1342
  }
1316
1343
  for (const root of fileInv.roots) {
1344
+ // A root that is not a readable folder has its own issue.
1345
+ if (rootProblem(/** @type {string} */ (loaded[root.kind]))) continue;
1317
1346
  if (root.files.length === 0) {
1318
1347
  issues.push(
1319
1348
  error(
@@ -1403,6 +1432,9 @@ export async function integrationPackCheck(options = {}) {
1403
1432
  );
1404
1433
  /** @type {string|null} */
1405
1434
  let scratchBase = null;
1435
+ /** @type {string|undefined} the packed copy of the package */
1436
+ let extractDir;
1437
+ const issuesBeforePack = issues.length;
1406
1438
 
1407
1439
  try {
1408
1440
  // npm pack
@@ -1451,11 +1483,16 @@ export async function integrationPackCheck(options = {}) {
1451
1483
  for (const root of fileInv.roots) {
1452
1484
  const missing = root.files.filter(f => !packResult.packedPaths.has(f));
1453
1485
  if (missing.length === root.files.length && root.files.length > 0) {
1486
+ const absolute = /** @type {string} */ (loaded[root.kind]);
1487
+ const link = symlinkedFolder(absolute, packageDir);
1454
1488
  issues.push(
1455
1489
  error(
1456
1490
  'root_not_packed',
1457
1491
  `Declared ${root.kind} root "${root.path}" has 0 of ${root.files.length} expected ` +
1458
- `files in the pack list. Add "${root.path.replace(/^\.\//, '')}" to "files" in package.json.`,
1492
+ `files in the pack list. ` +
1493
+ (link
1494
+ ? `It is reached through the symlink "${path.relative(packageDir, link.path).split(path.sep).join('/')}", and npm pack does not follow a symlinked folder. Fix: replace the link with the folder itself, or point "${root.kind}" at the folder it links to.`
1495
+ : `Add "${root.path.replace(/^\.\//, '')}" to "files" in package.json.`),
1459
1496
  ),
1460
1497
  );
1461
1498
  } else {
@@ -1494,7 +1531,6 @@ export async function integrationPackCheck(options = {}) {
1494
1531
  }
1495
1532
 
1496
1533
  // ── Phase 3: extract + identity comparison ──
1497
- let extractDir;
1498
1534
  try {
1499
1535
  extractDir = resolvePackageDir(pkgName, scratchBase);
1500
1536
  } catch (err) {
@@ -1616,11 +1652,94 @@ export async function integrationPackCheck(options = {}) {
1616
1652
  issues,
1617
1653
  );
1618
1654
  } finally {
1655
+ // The packed copy lives in a scratch folder that is gone once this
1656
+ // returns: name its files the way the package does.
1657
+ if (extractDir && scratchBase) {
1658
+ packageRelative(issues.slice(issuesBeforePack), extractDir, scratchBase);
1659
+ }
1619
1660
  removeTreeBestEffort(tgzTmpDir, 'temporary tarball directory', issues);
1620
1661
  removeTreeBestEffort(scratchBase, 'scratch consumer', issues);
1621
1662
  }
1622
1663
  }
1623
1664
 
1665
+ /**
1666
+ * When a theme's source names it something other than the file the package
1667
+ * exports for it, a rebuild writes new files under that name and leaves the
1668
+ * exported one stale forever: say so, with both ways out. Null otherwise.
1669
+ * @param {{slug: string}} theme
1670
+ * @param {string} kind 'module' or 'stylesheet'
1671
+ * @param {string} exported the file the package exports for it
1672
+ * @param {string} source the theme's source
1673
+ * @param {string | null} builtName the literal defineTheme name, if any
1674
+ * @param {string} packageDir
1675
+ * @returns {string | null}
1676
+ */
1677
+ function renamedOutputMessage(
1678
+ theme,
1679
+ kind,
1680
+ exported,
1681
+ source,
1682
+ builtName,
1683
+ packageDir,
1684
+ ) {
1685
+ if (builtName == null || builtName === theme.slug) return null;
1686
+ const extension = kind === 'module' ? '.js' : '.css';
1687
+ if (path.basename(exported) === `${builtName}${extension}`) return null;
1688
+ const rel = (/** @type {string} */ file) =>
1689
+ path.relative(packageDir, file).split(path.sep).join('/');
1690
+ const built = rel(
1691
+ path.join(path.dirname(source), `${builtName}${extension}`),
1692
+ );
1693
+ return `Theme "${theme.slug}" ${kind} is ${rel(exported)}, but ${path.basename(source)} calls defineTheme with name: '${builtName}', so \`${formatCliCommand(`theme build ${rel(source)}`, packageDir)}\` writes ${built} instead and rebuilding never updates ${path.basename(exported)}. Fix: point the package export that serves it at ${built} after building, or set name: '${theme.slug}' in ${path.basename(source)} and rebuild.`;
1694
+ }
1695
+
1696
+ /**
1697
+ * Rewrite the scratch copy's absolute paths in each issue's message: a file
1698
+ * of the packed package as its path in the package, and any other path in the
1699
+ * scratch consumer (such as the module that resolved an import) as that
1700
+ * consumer. Both folders are gone once verify returns.
1701
+ * @param {Issue[]} issues
1702
+ * @param {string} packedDir the packed package inside the scratch consumer
1703
+ * @param {string} consumerDir the scratch consumer
1704
+ */
1705
+ function packageRelative(issues, packedDir, consumerDir) {
1706
+ /** @param {string} dir */
1707
+ const spellings = dir => {
1708
+ const out = [dir];
1709
+ try {
1710
+ const real = fs.realpathSync(dir);
1711
+ if (real !== dir) out.push(real);
1712
+ } catch {
1713
+ // Already removed: the plain path is all there is.
1714
+ }
1715
+ return out;
1716
+ };
1717
+ /** @param {string} text */
1718
+ const escape = text => text.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
1719
+ const packed = spellings(packedDir);
1720
+ const consumer = spellings(consumerDir);
1721
+ for (const issue of issues) {
1722
+ if (typeof issue.message !== 'string') continue;
1723
+ let message = issue.message;
1724
+ for (const prefix of packed) {
1725
+ message = message
1726
+ .split(`${prefix}${path.sep}`)
1727
+ .join('')
1728
+ .replace(
1729
+ new RegExp(`${escape(prefix)}(?![\\w-]|\\.\\w)`, 'gu'),
1730
+ 'the package root',
1731
+ );
1732
+ }
1733
+ for (const prefix of consumer) {
1734
+ message = message.replace(
1735
+ new RegExp(`${escape(prefix)}[^\\s"'\`]*`, 'gu'),
1736
+ 'a consumer project',
1737
+ );
1738
+ }
1739
+ issue.message = message;
1740
+ }
1741
+ }
1742
+
1624
1743
  /**
1625
1744
  * Assemble the receipt.
1626
1745
  * @param {string} name
@@ -580,6 +580,24 @@ describe('a theme module that loads its own styles', () => {
580
580
  expect(cli.status, cli.stderr).toBe(0);
581
581
  });
582
582
 
583
+ it('says a rebuild cannot fix exports that name the theme after its folder once its source renames it', async () => {
584
+ writePackage({
585
+ files: ['astryx.integration.mjs', 'themes'],
586
+ themeExports: true,
587
+ });
588
+ fs.writeFileSync(
589
+ path.join(tmpDir, 'themes', 'ocean', 'oceanTheme.ts'),
590
+ "import {defineTheme} from '@astryxdesign/core/theme';\n\nexport const oceanTheme = defineTheme({name: 'sea'});\n",
591
+ );
592
+
593
+ const result = await integrationPackCheck({cwd: tmpDir});
594
+
595
+ expect(result.data.issues.map(issue => issue.message)).toEqual([
596
+ `Theme "ocean" module is themes/ocean/ocean.js, but oceanTheme.ts calls defineTheme with name: 'sea', so \`${formatCliCommand('theme build themes/ocean/oceanTheme.ts', tmpDir)}\` writes themes/ocean/sea.js instead and rebuilding never updates ocean.js. Fix: point the package export that serves it at themes/ocean/sea.js after building, or set name: 'ocean' in oceanTheme.ts and rebuild.`,
597
+ `Theme "ocean" stylesheet is themes/ocean/ocean.css, but oceanTheme.ts calls defineTheme with name: 'sea', so \`${formatCliCommand('theme build themes/ocean/oceanTheme.ts', tmpDir)}\` writes themes/ocean/sea.css instead and rebuilding never updates ocean.css. Fix: point the package export that serves it at themes/ocean/sea.css after building, or set name: 'ocean' in oceanTheme.ts and rebuild.`,
598
+ ]);
599
+ });
600
+
583
601
  it('compares a partial export set with the module named after the theme', async () => {
584
602
  writeRenamedOcean();
585
603
  setThemeExports({'./themes/ocean': './themes/ocean/deep-sea.js'});
@@ -1919,6 +1937,75 @@ describe('integrationPackCheck', () => {
1919
1937
  expect(sourceWarnings[0].message).toContain('stray.tsx');
1920
1938
  });
1921
1939
 
1940
+ it('says npm pack skips a symlinked root instead of asking for a "files" entry', async () => {
1941
+ writePackage({
1942
+ manifest:
1943
+ "export default {themes: './themes', components: './components'};\n",
1944
+ });
1945
+ const real = path.join(tmpDir, 'real-components');
1946
+ fs.mkdirSync(real, {recursive: true});
1947
+ fs.writeFileSync(
1948
+ path.join(real, 'Card.doc.mjs'),
1949
+ "export default {type: 'component', name: 'Card', displayName: 'Card', usage: {description: 'Card.'}, props: []};\n",
1950
+ );
1951
+ fs.writeFileSync(
1952
+ path.join(real, 'Card.tsx'),
1953
+ 'export function Card() { return null; }\n',
1954
+ );
1955
+ fs.symlinkSync('real-components', path.join(tmpDir, 'components'));
1956
+
1957
+ const result = await integrationPackCheck({cwd: tmpDir});
1958
+ const codes = result.data.issues.map(issue => issue.code);
1959
+ expect(codes).toContain('symlinked_root');
1960
+ const notPacked = result.data.issues.find(
1961
+ issue => issue.code === 'root_not_packed',
1962
+ );
1963
+ expect(notPacked?.message).toBe(
1964
+ 'Declared components root "components" has 0 of 2 expected files in the pack list. It is reached through the symlink "components", and npm pack does not follow a symlinked folder. Fix: replace the link with the folder itself, or point "components" at the folder it links to.',
1965
+ );
1966
+ });
1967
+
1968
+ it('names packed files by their path in the package, never the scratch copy', async () => {
1969
+ writePackage({
1970
+ manifest:
1971
+ "export default {themes: './themes', docs: './docs/a.doc.mjs'};\n",
1972
+ });
1973
+ fs.mkdirSync(path.join(tmpDir, 'docs'), {recursive: true});
1974
+ fs.writeFileSync(path.join(tmpDir, 'docs', 'a.doc.mjs'), '');
1975
+
1976
+ const result = await integrationPackCheck({cwd: tmpDir});
1977
+ const messages = result.data.issues.map(issue => issue.message);
1978
+ expect(messages.join('\n')).not.toContain(
1979
+ path.join(tmpDir, '.astryx-pack-check-'),
1980
+ );
1981
+ expect(messages).toContain(
1982
+ 'Packed: Declared docs root is a file, not a folder: docs/a.doc.mjs. Fix: point "docs" in the manifest at the folder that holds them.',
1983
+ );
1984
+ // The root's own issue covers it: no empty-root error, no raw errno.
1985
+ const codes = result.data.issues.map(issue => issue.code);
1986
+ expect(codes).not.toContain('empty_contribution_root');
1987
+ expect(codes).not.toContain('local_discovery_failed');
1988
+ expect(codes).not.toContain('packed_discovery_failed');
1989
+ });
1990
+
1991
+ it('fails on a package.json npm cannot pack, which validate only warns about', async () => {
1992
+ writePackage({files: ['astryx.integration.mjs', 'themes']});
1993
+ const packageFile = path.join(tmpDir, 'package.json');
1994
+ const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf-8'));
1995
+ delete pkg.name;
1996
+ fs.writeFileSync(packageFile, `${JSON.stringify(pkg, null, 2)}\n`);
1997
+
1998
+ const result = await integrationPackCheck({cwd: tmpDir});
1999
+
2000
+ expect(result.data.packable).toBe(false);
2001
+ expect(
2002
+ result.data.issues.map(issue => [issue.code, issue.severity]),
2003
+ ).toEqual([
2004
+ ['invalid_package_json', 'error'],
2005
+ ['pack_failed', 'error'],
2006
+ ]);
2007
+ });
2008
+
1922
2009
  it('cleans up its scratch consumer when npm pack fails', async () => {
1923
2010
  writePackage({
1924
2011
  files: ['astryx.integration.mjs', 'themes'],
@@ -0,0 +1,111 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file A component doc whose file name differs from its source only in
5
+ * letter case, on a case-insensitive disk (macOS, Windows), where discovery
6
+ * still pairs the two: a warning naming the rename, not an error. The disk is
7
+ * simulated by finding the doc's source case-insensitively.
8
+ */
9
+
10
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
11
+ import * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+
14
+ vi.mock(
15
+ '../../foundation/discovery/component-discovery.mjs',
16
+ async importOriginal => {
17
+ const original =
18
+ /** @type {typeof import('../../foundation/discovery/component-discovery.mjs')} */ (
19
+ await importOriginal()
20
+ );
21
+ return {
22
+ ...original,
23
+ /** @param {string} docPath */
24
+ integrationSourceForDoc: docPath => {
25
+ const dir = path.dirname(docPath);
26
+ const want = `${path
27
+ .basename(docPath)
28
+ .replace(/\.doc\.(ts|mjs|js)$/u, '')}.tsx`.toLowerCase();
29
+ const found = fs
30
+ .readdirSync(dir)
31
+ .find(name => name.toLowerCase() === want);
32
+ return found ? path.join(dir, found) : null;
33
+ },
34
+ };
35
+ },
36
+ );
37
+
38
+ const {validateIntegration} = await import('./validate-integration.mjs');
39
+
40
+ /** @type {string} */
41
+ let tmpDir;
42
+
43
+ beforeEach(() => {
44
+ tmpDir = fs.mkdtempSync(path.join(process.cwd(), '.astryx-case-disk-'));
45
+ });
46
+
47
+ afterEach(() => {
48
+ fs.rmSync(tmpDir, {recursive: true, force: true});
49
+ });
50
+
51
+ describe('a doc whose name differs from its source only in case, on a case-insensitive disk', () => {
52
+ it('lists source_without_component_doc with the rename, without an issue', async () => {
53
+ fs.writeFileSync(
54
+ path.join(tmpDir, 'package.json'),
55
+ JSON.stringify({name: '@acme/kit', version: '1.0.0'}),
56
+ );
57
+ fs.writeFileSync(
58
+ path.join(tmpDir, 'astryx.integration.mjs'),
59
+ "export default {components: './components'};\n",
60
+ );
61
+ fs.mkdirSync(path.join(tmpDir, 'components'));
62
+ fs.writeFileSync(
63
+ path.join(tmpDir, 'components', 'Acmewidget.doc.mjs'),
64
+ "export default {type: 'component', name: 'AcmeWidget', displayName: 'AcmeWidget', usage: {description: 'A widget.'}, props: []};\n",
65
+ );
66
+ fs.writeFileSync(
67
+ path.join(tmpDir, 'components', 'AcmeWidget.tsx'),
68
+ 'export function AcmeWidget() { return null; }\n',
69
+ );
70
+
71
+ const {data} = await validateIntegration(undefined, {cwd: tmpDir});
72
+ expect(data.issues).toEqual([]);
73
+ expect(data.notes).toEqual([
74
+ {
75
+ code: 'source_without_component_doc',
76
+ message:
77
+ 'Component doc "Acmewidget.doc.mjs" and its source "AcmeWidget.tsx" differ only in letter case. They pair on a case-insensitive disk (macOS, Windows) but not on Linux, where CI and most installs run, so there the component is missing. Fix: rename Acmewidget.doc.mjs to AcmeWidget.doc.mjs.',
78
+ },
79
+ ]);
80
+ });
81
+
82
+ it('lists a hidden doc that pairs with its source, without an issue', async () => {
83
+ fs.writeFileSync(
84
+ path.join(tmpDir, 'package.json'),
85
+ JSON.stringify({name: '@acme/kit', version: '1.0.0'}),
86
+ );
87
+ fs.writeFileSync(
88
+ path.join(tmpDir, 'astryx.integration.mjs'),
89
+ "export default {components: './components'};\n",
90
+ );
91
+ fs.mkdirSync(path.join(tmpDir, 'components'));
92
+ fs.writeFileSync(
93
+ path.join(tmpDir, 'components', 'Acmewidget.doc.mjs'),
94
+ "export default {type: 'component',\n hidden: true,\n name: 'AcmeWidget', displayName: 'AcmeWidget', usage: {description: 'A widget.'}, props: []};\n",
95
+ );
96
+ fs.writeFileSync(
97
+ path.join(tmpDir, 'components', 'AcmeWidget.tsx'),
98
+ 'export function AcmeWidget() { return null; }\n',
99
+ );
100
+
101
+ const {data} = await validateIntegration(undefined, {cwd: tmpDir});
102
+ expect(data.issues).toEqual([]);
103
+ expect(data.notes).toEqual([
104
+ {
105
+ code: 'source_without_component_doc',
106
+ message:
107
+ 'Hidden component doc "Acmewidget.doc.mjs" and its source "AcmeWidget.tsx" differ only in letter case. They pair on a case-insensitive disk (macOS, Windows) but not on Linux, where CI and most installs run. Fix: rename Acmewidget.doc.mjs to AcmeWidget.doc.mjs.',
108
+ },
109
+ ]);
110
+ });
111
+ });