@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.
- package/README.md +99 -66
- package/api/build/_adapter.d.mts +58 -0
- package/api/build/_adapter.mjs +115 -3
- package/api/build/blocks/blocks.d.mts +42 -0
- package/api/build/blocks/blocks.mjs +169 -0
- package/api/build/blocks/blocks.test.mjs +108 -0
- package/api/build/blocks/blocks.weights.json +1 -0
- package/api/build/build.mjs +25 -6
- package/api/build/kit/answer.d.mts +14 -0
- package/api/build/kit/answer.mjs +85 -0
- package/api/build/kit/answer.test.mjs +38 -0
- package/api/build/kit/kind.d.mts +25 -0
- package/api/build/kit/kind.mjs +170 -0
- package/api/build/kit/kit.d.mts +2 -4
- package/api/build/kit/kit.mjs +8 -398
- package/api/build/kit/rank.d.mts +3 -32
- package/api/build/kit/rank.mjs +25 -342
- package/api/build/kit/rank.test.mjs +2 -1
- package/api/build/kit/signals.d.mts +60 -0
- package/api/build/kit/signals.mjs +284 -0
- package/api/build/kit/start.d.mts +32 -0
- package/api/build/kit/start.mjs +163 -0
- package/api/build/kit/words.d.mts +72 -0
- package/api/build/kit/words.mjs +202 -0
- package/api/build/tree.d.mts +20 -0
- package/api/build/tree.mjs +52 -0
- package/api/build/tree.test.mjs +177 -0
- package/api/build/tree.weights.json +1 -0
- package/api/component/component.mjs +16 -2
- package/api/component/component.test.mjs +15 -0
- package/api/discover/_adapter.mjs +36 -9
- package/api/discover/_adapter.test.mjs +42 -0
- package/api/docs/_adapter.mjs +17 -5
- package/api/docs/docs.test.mjs +7 -1
- package/api/doctor/doctor.mjs +86 -12
- package/api/doctor/doctor.test.mjs +28 -3
- package/api/init/init.doc.mjs +20 -6
- package/api/init/init.test.mjs +18 -2
- package/api/init/init.type.d.mts +3 -3
- package/api/init/init.type.mjs +3 -3
- package/api/init/remove/remove.mjs +17 -4
- package/api/init/remove/remove.test.mjs +114 -2
- package/api/init/run/run.mjs +69 -6
- package/api/integration/authoring-checks.mjs +83 -9
- package/api/integration/pack-check.mjs +123 -4
- package/api/integration/pack-check.test.mjs +87 -0
- package/api/integration/validate-integration-case-disk.test.mjs +111 -0
- package/api/integration/validate-integration-gaps.test.mjs +1964 -0
- package/api/integration/validate-integration.d.mts +11 -2
- package/api/integration/validate-integration.mjs +226 -32
- package/api/integration/validate-integration.type.d.mts +58 -1
- package/api/integration/validate-integration.type.mjs +45 -2
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/search/ORDERING.md +28 -0
- package/api/search/_adapter.d.mts +66 -2
- package/api/search/_adapter.mjs +118 -10
- package/api/search/_candidate-score.d.mts +40 -0
- package/api/search/_candidate-score.mjs +238 -0
- package/api/search/_cards.json +6098 -0
- package/api/search/_compound.d.mts +50 -0
- package/api/search/_compound.mjs +170 -0
- package/api/search/_doc-searcher.d.mts +4 -0
- package/api/search/_doc-searcher.mjs +23 -0
- package/api/search/_doc-searcher.test.mjs +36 -0
- package/api/search/_doc-searcher.weights.json +1 -0
- package/api/search/_doc-text.d.mts +30 -0
- package/api/search/_doc-text.mjs +87 -0
- package/api/search/_entries.d.mts +52 -0
- package/api/search/_entries.mjs +158 -0
- package/api/search/_features.d.mts +35 -0
- package/api/search/_features.mjs +155 -0
- package/api/search/_gather-components.d.mts +26 -0
- package/api/search/_gather-components.mjs +284 -0
- package/api/search/_gather-docs.d.mts +20 -0
- package/api/search/_gather-docs.mjs +254 -0
- package/api/search/_gather-hooks.d.mts +12 -0
- package/api/search/_gather-hooks.mjs +52 -0
- package/api/search/_gather-templates.d.mts +25 -0
- package/api/search/_gather-templates.mjs +107 -0
- package/api/search/_lexical.d.mts +135 -0
- package/api/search/_lexical.mjs +351 -0
- package/api/search/_main-searcher.d.mts +19 -0
- package/api/search/_main-searcher.mjs +115 -0
- package/api/search/_main-searcher.test.mjs +106 -0
- package/api/search/_main-searcher.weights.json +1 -0
- package/api/search/_module-doc.d.mts +32 -0
- package/api/search/_module-doc.mjs +78 -0
- package/api/search/_order.d.mts +32 -0
- package/api/search/_order.mjs +162 -0
- package/api/search/_order.test.mjs +72 -0
- package/api/search/_priority.d.mts +50 -0
- package/api/search/_priority.mjs +139 -0
- package/api/search/_query-words.d.mts +25 -0
- package/api/search/_query-words.mjs +92 -0
- package/api/search/_query.d.mts +65 -0
- package/api/search/_query.mjs +176 -0
- package/api/search/_result.d.mts +97 -0
- package/api/search/_result.mjs +150 -0
- package/api/search/_score.d.mts +60 -0
- package/api/search/_score.mjs +283 -0
- package/api/search/_synonyms.d.mts +21 -0
- package/api/search/_synonyms.mjs +67 -0
- package/api/search/_template-searcher.d.mts +4 -0
- package/api/search/_template-searcher.mjs +23 -0
- package/api/search/_template-searcher.test.mjs +40 -0
- package/api/search/_template-searcher.weights.json +1 -0
- package/api/search/_titles.d.mts +33 -0
- package/api/search/_titles.mjs +65 -0
- package/api/search/_words.d.mts +64 -0
- package/api/search/_words.mjs +157 -0
- package/api/search/search.d.mts +22 -305
- package/api/search/search.doc.mjs +7 -5
- package/api/search/search.mjs +53 -2483
- package/api/search/search.type.d.mts +6 -1
- package/api/search/search.type.mjs +6 -1
- package/api/search/tree.d.mts +15 -0
- package/api/search/tree.mjs +40 -0
- package/api/search/tree.test.mjs +66 -0
- package/api/search/tree.weights.json +1 -0
- package/api/theme/build/build.packed-old-core.test.mjs +1 -0
- package/api/upgrade/_adapter.d.mts +57 -9
- package/api/upgrade/_adapter.mjs +198 -28
- package/api/upgrade/registry/registry.d.mts +10 -4
- package/api/upgrade/registry/registry.mjs +33 -16
- package/api/upgrade/registry/registry.test.mjs +85 -0
- package/api/upgrade/run/run.d.mts +4 -2
- package/api/upgrade/run/run.mjs +13 -2
- package/api/upgrade/run/run.test.mjs +417 -3
- package/api/upgrade/upgrade.doc.mjs +10 -5
- package/api/upgrade/upgrade.mjs +13 -2
- package/api/upgrade/upgrade.test.mjs +194 -0
- package/api/upgrade/upgrade.type.d.mts +5 -3
- package/api/upgrade/upgrade.type.mjs +8 -6
- package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
- package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
- package/assets/docs/is-my-code-good.doc.mjs +287 -0
- package/assets/docs/tree/api.doc.mjs +6 -0
- package/assets/docs/tree/host-entry.doc.mjs +128 -0
- package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
- package/assets/docs/tree/tokens-data.doc.mjs +2 -2
- package/assets/docs/working-with-ai.doc.mjs +6 -2
- package/assets/templates/themes/butter/butterTheme.ts +4 -2
- package/assets/templates/themes/stone/stoneTheme.ts +4 -2
- package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
- package/authoring/debug/parse.d.mts +1 -1
- package/clients/cli/bin/astryx.mjs +7 -78
- package/clients/cli/commands/doctor.mjs +30 -0
- package/clients/cli/commands/doctor.test.mjs +65 -0
- package/clients/cli/commands/init.behavior.test.mjs +230 -1
- package/clients/cli/commands/init.doc.mjs +28 -9
- package/clients/cli/commands/init.flag-help.test.mjs +1 -0
- package/clients/cli/commands/init.mjs +2 -2
- package/clients/cli/commands/outside-project.test.mjs +2 -1
- package/clients/cli/commands/setup-nudge.test.mjs +61 -0
- package/clients/cli/commands/upgrade.doc.mjs +4 -2
- package/clients/cli/commands/upgrade.mjs +9 -2
- package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
- package/clients/cli/index.mjs +19 -1
- package/clients/cli/run.mjs +108 -0
- package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
- package/foundation/agent-docs/agent-doc-state.mjs +35 -8
- package/foundation/agent-docs/agent-docs.d.mts +100 -12
- package/foundation/agent-docs/agent-docs.mjs +1010 -99
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
- package/foundation/agent-docs/agent-docs.test.mjs +678 -35
- package/foundation/config/project.mjs +20 -22
- package/foundation/config/project.test.mjs +56 -0
- package/foundation/discovery/component-discovery.d.mts +37 -0
- package/foundation/discovery/component-discovery.mjs +80 -5
- package/foundation/discovery/component-loader.d.mts +1 -1
- package/foundation/discovery/docs-discovery.mjs +14 -1
- package/foundation/discovery/module-exports.d.mts +41 -0
- package/foundation/discovery/module-exports.mjs +253 -0
- package/foundation/discovery/template-adapter.mjs +18 -1
- package/foundation/discovery/template-needs.mjs +1 -44
- package/foundation/discovery/theme-discovery.mjs +147 -3
- package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
- package/foundation/doc-compiler/read.mjs +59 -1
- package/foundation/doc-compiler/read.test.mjs +11 -2
- package/foundation/fs/paths.d.mts +70 -8
- package/foundation/fs/paths.mjs +556 -38
- package/foundation/fs/paths.test.mjs +430 -54
- package/foundation/integrations/autolink.d.mts +2 -1
- package/foundation/integrations/autolink.mjs +4 -13
- package/foundation/integrations/computer-integrations.d.mts +6 -3
- package/foundation/integrations/computer-integrations.mjs +38 -9
- package/foundation/integrations/contribution-inventory.d.mts +15 -20
- package/foundation/integrations/contribution-inventory.mjs +41 -5
- package/foundation/integrations/integrations.d.mts +27 -1
- package/foundation/integrations/integrations.mjs +61 -2
- package/foundation/integrations/validate-contributions.d.mts +115 -0
- package/foundation/integrations/validate-contributions.mjs +1168 -13
- package/foundation/release/handoff.d.mts +23 -4
- package/foundation/release/handoff.mjs +175 -8
- package/foundation/release/run-context.mjs +14 -0
- package/foundation/response/response-types.doc.mjs +1 -1
- package/foundation/route/route.d.mts +285 -0
- package/foundation/route/route.mjs +324 -0
- package/foundation/route/route.test.mjs +153 -0
- package/foundation/runtime/hosted-runtime.test.mjs +403 -0
- package/foundation/runtime/locked-versions.d.mts +32 -0
- package/foundation/runtime/locked-versions.mjs +65 -0
- package/foundation/runtime/package-root.d.mts +91 -0
- package/foundation/runtime/package-root.mjs +174 -0
- package/foundation/runtime/package-set.d.mts +62 -0
- package/foundation/runtime/package-set.mjs +130 -0
- package/foundation/runtime/package-set.test.mjs +432 -0
- package/host/door-conformance.test.mjs +274 -0
- package/host/index.d.ts +116 -0
- package/host/index.mjs +295 -0
- package/host/index.test.mjs +302 -0
- package/package.json +14 -9
|
@@ -23,8 +23,12 @@
|
|
|
23
23
|
|
|
24
24
|
import * as fs from 'node:fs';
|
|
25
25
|
import * as path from 'node:path';
|
|
26
|
+
import {parse as parseModule} from '@babel/parser';
|
|
26
27
|
import {isValidSemver} from '../env/semver.mjs';
|
|
28
|
+
import {findProjectCoreDir} from '../fs/paths.mjs';
|
|
29
|
+
import {KNOWN_INTEGRATION_KEYS} from '../../authoring/integration/schema.mjs';
|
|
27
30
|
import {
|
|
31
|
+
contributionFiles,
|
|
28
32
|
findSourceOnlyCandidates,
|
|
29
33
|
findTemplateSourceOnlyCandidates,
|
|
30
34
|
} from './contribution-inventory.mjs';
|
|
@@ -48,6 +52,12 @@ import {MANIFEST_BASENAMES} from './integrations.mjs';
|
|
|
48
52
|
import {discoverIntegrationCodemods} from '../../assets/codemods/integration-discovery.mjs';
|
|
49
53
|
import {discoverIntegrationTemplatesForOne} from '../discovery/template-adapter.mjs';
|
|
50
54
|
import * as componentDiscovery from '../discovery/component-discovery.mjs';
|
|
55
|
+
import {loadComponentDoc} from '../discovery/component-loader.mjs';
|
|
56
|
+
import {
|
|
57
|
+
moduleExportNames,
|
|
58
|
+
moduleExportsName,
|
|
59
|
+
moduleExportVerdict,
|
|
60
|
+
} from '../discovery/module-exports.mjs';
|
|
51
61
|
import {discoverIntegrationDocs} from '../discovery/docs-discovery.mjs';
|
|
52
62
|
import {discoverIntegrationThemes} from '../discovery/theme-discovery.mjs';
|
|
53
63
|
|
|
@@ -75,6 +85,32 @@ export function issueWarning(code, message) {
|
|
|
75
85
|
return {code, severity: 'warning', message};
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
/** Issues `issueListed` made: listed in validate's `layout` report only. */
|
|
89
|
+
const LISTED = new WeakSet();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A layout finding added after the last minor release: validate lists it, in
|
|
93
|
+
* its `layout` report, but it is never an issue, so nothing counts it and
|
|
94
|
+
* `integration check --strict` does not fail on it. A new failing finding
|
|
95
|
+
* joins only in a minor release (architecture:cli-surface INV35).
|
|
96
|
+
* @param {string} code
|
|
97
|
+
* @param {string} message
|
|
98
|
+
* @returns {Issue}
|
|
99
|
+
*/
|
|
100
|
+
export function issueListed(code, message) {
|
|
101
|
+
const issue = issueWarning(code, message);
|
|
102
|
+
LISTED.add(issue);
|
|
103
|
+
return issue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether `issueListed` made this issue.
|
|
108
|
+
* @param {Issue} issue
|
|
109
|
+
*/
|
|
110
|
+
export function isListedIssue(issue) {
|
|
111
|
+
return LISTED.has(issue);
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
/**
|
|
79
115
|
* Report the manifest keys this CLI does not know.
|
|
80
116
|
*
|
|
@@ -92,11 +128,36 @@ export function issueWarning(code, message) {
|
|
|
92
128
|
function checkUnknownKeys(integration, issues) {
|
|
93
129
|
const keys = integration.__unknownKeys ?? [];
|
|
94
130
|
if (keys.length === 0) return;
|
|
131
|
+
// A key one edit away from a known one is almost always a typo: say so
|
|
132
|
+
// first, since "a newer CLI" sends the author to upgrade for nothing.
|
|
133
|
+
const typos = keys
|
|
134
|
+
.map(key => ({key, known: nearestKnownKey(key)}))
|
|
135
|
+
.filter(each => each.known != null);
|
|
136
|
+
for (const {key, known} of typos) {
|
|
137
|
+
// The manifest object already declares the key it is a near miss of:
|
|
138
|
+
// renaming would give it twice, so the stray key goes. Read from what
|
|
139
|
+
// the manifest says, not the loaded integration, which fills some keys
|
|
140
|
+
// in (providerId from the package name).
|
|
141
|
+
const alreadySet = (integration.__declaredKeys ?? []).includes(
|
|
142
|
+
/** @type {string} */ (known),
|
|
143
|
+
);
|
|
144
|
+
const fix = alreadySet
|
|
145
|
+
? `Fix: remove "${key}" from ${manifestName(integration)}; it already sets "${known}".`
|
|
146
|
+
: `Fix: rename "${key}" to "${known}" in ${manifestName(integration)}.`;
|
|
147
|
+
issues.push(
|
|
148
|
+
issueWarning(
|
|
149
|
+
'unknown_manifest_key',
|
|
150
|
+
`This CLI does not know the manifest key "${key}", so it is ignored. Did you mean \`${known}\`? ${fix} If it is a key from a newer Astryx CLI instead, upgrading @astryxdesign/cli picks it up.`,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const rest = keys.filter(key => !typos.some(each => each.key === key));
|
|
155
|
+
if (rest.length === 0) return;
|
|
95
156
|
issues.push(
|
|
96
157
|
issueWarning(
|
|
97
158
|
'unknown_manifest_key',
|
|
98
|
-
`This CLI does not know the manifest ${
|
|
99
|
-
`${
|
|
159
|
+
`This CLI does not know the manifest ${rest.length === 1 ? 'key' : 'keys'} ` +
|
|
160
|
+
`${rest.map(key => `"${key}"`).join(', ')}, so ${rest.length === 1 ? 'it is' : 'they are'} ` +
|
|
100
161
|
`ignored — everything else in the manifest still applies. This usually ` +
|
|
101
162
|
`means the integration was published against a newer Astryx CLI than ` +
|
|
102
163
|
`this project resolves; upgrading @astryxdesign/cli picks it up.`,
|
|
@@ -104,6 +165,60 @@ function checkUnknownKeys(integration, issues) {
|
|
|
104
165
|
);
|
|
105
166
|
}
|
|
106
167
|
|
|
168
|
+
/** @param {{__manifestFile?: string}} integration */
|
|
169
|
+
function manifestName(integration) {
|
|
170
|
+
return integration.__manifestFile
|
|
171
|
+
? path.basename(integration.__manifestFile)
|
|
172
|
+
: 'the integration manifest';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The known manifest key `key` is a near miss of: a different letter case, or
|
|
177
|
+
* at most two letters added, dropped or changed (`component`, `doc`,
|
|
178
|
+
* `tempaltes`). Null when no known key is that close, or two are.
|
|
179
|
+
* @param {string} key
|
|
180
|
+
* @returns {string | null}
|
|
181
|
+
*/
|
|
182
|
+
export function nearestKnownKey(key) {
|
|
183
|
+
const lower = key.toLowerCase();
|
|
184
|
+
const limit = lower.length <= 4 ? 1 : 2;
|
|
185
|
+
/** @type {string[]} */
|
|
186
|
+
const close = [];
|
|
187
|
+
for (const known of KNOWN_INTEGRATION_KEYS) {
|
|
188
|
+
const distance = editDistance(lower, known.toLowerCase());
|
|
189
|
+
if (distance <= limit) close.push(known);
|
|
190
|
+
}
|
|
191
|
+
return close.length === 1 ? close[0] : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Levenshtein distance, with a swap of two neighbours counted as one edit.
|
|
196
|
+
* @param {string} a
|
|
197
|
+
* @param {string} b
|
|
198
|
+
*/
|
|
199
|
+
function editDistance(a, b) {
|
|
200
|
+
/** @type {number[][]} */
|
|
201
|
+
const d = Array.from({length: a.length + 1}, (_, i) =>
|
|
202
|
+
Array.from({length: b.length + 1}, (_, j) =>
|
|
203
|
+
i === 0 ? j : j === 0 ? i : 0,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
for (let i = 1; i <= a.length; i++) {
|
|
207
|
+
for (let j = 1; j <= b.length; j++) {
|
|
208
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
209
|
+
d[i][j] = Math.min(
|
|
210
|
+
d[i - 1][j] + 1,
|
|
211
|
+
d[i][j - 1] + 1,
|
|
212
|
+
d[i - 1][j - 1] + cost,
|
|
213
|
+
);
|
|
214
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
215
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return d[a.length][b.length];
|
|
220
|
+
}
|
|
221
|
+
|
|
107
222
|
/**
|
|
108
223
|
* Why a declared root cannot be read as a folder, or null when it can.
|
|
109
224
|
* A missing root keeps its own issue; a root that is a file or that the
|
|
@@ -139,11 +254,14 @@ export function rootProblem(root) {
|
|
|
139
254
|
* declared-but-missing root is a `missing_root` error, a file is an
|
|
140
255
|
* `invalid_root` error, and a folder that cannot be read is an
|
|
141
256
|
* `unreadable_root` error. The contribution checks skip all three.
|
|
142
|
-
*
|
|
257
|
+
* A readable root reached through a symlinked folder is a `symlinked_root`
|
|
258
|
+
* warning: npm pack does not follow it, so the published package loses it.
|
|
259
|
+
* @param {{components?: string, templates?: string, codemods?: string, docs?: string, themes?: string, __manifestFile?: string}} resolved
|
|
143
260
|
* absolute resolved roots (undefined when not declared)
|
|
144
261
|
* @param {Issue[]} issues
|
|
262
|
+
* @param {string} [packageDir] the package the roots belong to
|
|
145
263
|
*/
|
|
146
|
-
function checkRoots(resolved, issues) {
|
|
264
|
+
function checkRoots(resolved, issues, packageDir) {
|
|
147
265
|
const kinds = /** @type {const} */ ([
|
|
148
266
|
'components',
|
|
149
267
|
'templates',
|
|
@@ -155,7 +273,28 @@ function checkRoots(resolved, issues) {
|
|
|
155
273
|
const root = resolved[kind];
|
|
156
274
|
if (root == null) continue;
|
|
157
275
|
const problem = rootProblem(root);
|
|
158
|
-
if (problem == null)
|
|
276
|
+
if (problem == null) {
|
|
277
|
+
const link = packageDir ? symlinkedFolder(root, packageDir) : null;
|
|
278
|
+
if (link && packageDir) {
|
|
279
|
+
const shown = slash(path.relative(packageDir, link.path));
|
|
280
|
+
const realPackage = fs.realpathSync(packageDir);
|
|
281
|
+
const target = slash(path.relative(realPackage, link.target));
|
|
282
|
+
const realRoot = slash(
|
|
283
|
+
path.relative(realPackage, fs.realpathSync(root)),
|
|
284
|
+
);
|
|
285
|
+
const outside = target.startsWith('../') || path.isAbsolute(target);
|
|
286
|
+
const fix = outside
|
|
287
|
+
? 'replace the link with a real folder holding its files'
|
|
288
|
+
: `replace the link with the folder itself (move ${target}/ to ${shown}/), or set \`${kind}: './${realRoot}'\` in ${manifestName(resolved)}`;
|
|
289
|
+
issues.push(
|
|
290
|
+
issueListed(
|
|
291
|
+
'symlinked_root',
|
|
292
|
+
`Declared ${kind} root "${slash(path.relative(packageDir, root))}" is reached through the symlink "${shown}" (to "${target}"). Astryx follows it locally, but npm pack does not follow a symlinked folder, so the published package ships none of these files. Fix: ${fix}.`,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
159
298
|
if (problem.kind === 'missing') {
|
|
160
299
|
issues.push(
|
|
161
300
|
issueError(
|
|
@@ -181,6 +320,41 @@ function checkRoots(resolved, issues) {
|
|
|
181
320
|
}
|
|
182
321
|
}
|
|
183
322
|
|
|
323
|
+
/** @param {string} file */
|
|
324
|
+
function slash(file) {
|
|
325
|
+
return file.split(path.sep).join('/');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The first folder on the way from the package to `root` (the root itself
|
|
330
|
+
* included) that is a symlink, with where it points, or null.
|
|
331
|
+
* @param {string} root absolute
|
|
332
|
+
* @param {string} packageDir absolute
|
|
333
|
+
* @returns {{path: string, target: string} | null}
|
|
334
|
+
*/
|
|
335
|
+
export function symlinkedFolder(root, packageDir) {
|
|
336
|
+
const relative = path.relative(packageDir, root);
|
|
337
|
+
if (
|
|
338
|
+
relative === '' ||
|
|
339
|
+
relative.startsWith('..') ||
|
|
340
|
+
path.isAbsolute(relative)
|
|
341
|
+
) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
let current = packageDir;
|
|
345
|
+
for (const segment of relative.split(path.sep)) {
|
|
346
|
+
current = path.join(current, segment);
|
|
347
|
+
try {
|
|
348
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
349
|
+
return {path: current, target: fs.realpathSync(current)};
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
184
358
|
/**
|
|
185
359
|
* Validate the integration's codemods via the landed discovery. Discovery is
|
|
186
360
|
* strict (throws on bad export / duplicate id); we convert any throw into an
|
|
@@ -304,6 +478,551 @@ async function checkTemplates(integration, issues) {
|
|
|
304
478
|
checkTemplateSourcesWithoutDoc(integration, issues);
|
|
305
479
|
}
|
|
306
480
|
|
|
481
|
+
/**
|
|
482
|
+
* The component names each of a package's templates gives in `exampleFor`
|
|
483
|
+
* and `componentsUsed`, judged against the components `astryx component`
|
|
484
|
+
* finds: the package's own, and every doc it resolves in Core. A name the
|
|
485
|
+
* template imports needs no doc of its own. A name neither has is an
|
|
486
|
+
* `unknown_block_component` finding, since the template list and the
|
|
487
|
+
* block's search keywords then give a name no one can look up. A name the
|
|
488
|
+
* template may reach through anything the walk does not model, and, without
|
|
489
|
+
* Core installed, a name the package does not have, cannot be judged and is
|
|
490
|
+
* listed in `unchecked` instead.
|
|
491
|
+
* @param {LoadedIntegration} integration
|
|
492
|
+
* @returns {Promise<{findings: Array<{docPath: string, name: string, message: string}>, unchecked: Array<{docPath: string, name: string, reason: string}>}>}
|
|
493
|
+
*/
|
|
494
|
+
async function judgeBlockComponents(integration) {
|
|
495
|
+
/** @type {Array<{docPath: string, name: string, message: string}>} */
|
|
496
|
+
const findings = [];
|
|
497
|
+
/** @type {Array<{docPath: string, name: string, reason: string}>} */
|
|
498
|
+
const unchecked = [];
|
|
499
|
+
const root = integration.templates;
|
|
500
|
+
if (!root || rootProblem(root)) return {findings, unchecked};
|
|
501
|
+
let templates;
|
|
502
|
+
try {
|
|
503
|
+
({templates} = await discoverIntegrationTemplatesForOne(integration));
|
|
504
|
+
} catch {
|
|
505
|
+
return {findings, unchecked};
|
|
506
|
+
}
|
|
507
|
+
/** @type {Set<string>} */
|
|
508
|
+
let own;
|
|
509
|
+
try {
|
|
510
|
+
own = new Set(
|
|
511
|
+
componentDiscovery
|
|
512
|
+
.discoverIntegrationComponents(integration)
|
|
513
|
+
.map(record => record.name),
|
|
514
|
+
);
|
|
515
|
+
} catch {
|
|
516
|
+
return {findings, unchecked};
|
|
517
|
+
}
|
|
518
|
+
// The Core installed beside the package, never the one the CLI carries:
|
|
519
|
+
// a name is judged against the Core the package's users install.
|
|
520
|
+
const coreDir = findProjectCoreDir(integration.__packageDir ?? root);
|
|
521
|
+
/** @type {Map<string, boolean>} */
|
|
522
|
+
const inCore = new Map();
|
|
523
|
+
/** @param {string} name */
|
|
524
|
+
const coreHas = name => {
|
|
525
|
+
if (!coreDir) return false;
|
|
526
|
+
if (!inCore.has(name)) {
|
|
527
|
+
let found;
|
|
528
|
+
try {
|
|
529
|
+
found = componentDiscovery.findComponentReadme(coreDir, name) != null;
|
|
530
|
+
} catch {
|
|
531
|
+
found = false;
|
|
532
|
+
}
|
|
533
|
+
inCore.set(name, found);
|
|
534
|
+
}
|
|
535
|
+
return /** @type {boolean} */ (inCore.get(name));
|
|
536
|
+
};
|
|
537
|
+
for (const template of templates) {
|
|
538
|
+
const fields = [
|
|
539
|
+
...(typeof template.exampleFor === 'string'
|
|
540
|
+
? [{field: 'exampleFor', name: template.exampleFor}]
|
|
541
|
+
: []),
|
|
542
|
+
...(Array.isArray(template.componentsUsed) ? template.componentsUsed : [])
|
|
543
|
+
.filter(name => typeof name === 'string')
|
|
544
|
+
.map(name => ({field: 'componentsUsed', name})),
|
|
545
|
+
];
|
|
546
|
+
if (fields.length === 0) continue;
|
|
547
|
+
const imported = importedNames(template.filePath, integration);
|
|
548
|
+
for (const {field, name} of fields) {
|
|
549
|
+
if (own.has(name) || imported.names.has(name)) continue;
|
|
550
|
+
if (coreHas(name)) continue;
|
|
551
|
+
// A name the template may reach through a namespace import, or
|
|
552
|
+
// through a local module this read could not follow, is not judged.
|
|
553
|
+
if (imported.unread.length > 0) {
|
|
554
|
+
unchecked.push({
|
|
555
|
+
docPath: template.docPath,
|
|
556
|
+
name,
|
|
557
|
+
reason: imported.unread[0],
|
|
558
|
+
});
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (!coreDir) {
|
|
562
|
+
unchecked.push({
|
|
563
|
+
docPath: template.docPath,
|
|
564
|
+
name,
|
|
565
|
+
reason:
|
|
566
|
+
'Core is not installed, so its component names cannot be checked.',
|
|
567
|
+
});
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
findings.push({
|
|
571
|
+
docPath: template.docPath,
|
|
572
|
+
name,
|
|
573
|
+
message: `Template "${template.dirName}" names "${name}" in ${field}, but neither this package nor Core has a component by that name and the template does not import it, so \`astryx template --list\` and the block's search keywords give a component name no one can look up. Fix: use the component's exact name, or remove "${name}" from ${field}.`,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return {findings, unchecked};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Visit every node under `node`, depth first.
|
|
582
|
+
* @param {any} node
|
|
583
|
+
* @param {(node: any) => void} visit
|
|
584
|
+
*/
|
|
585
|
+
function walkNodes(node, visit) {
|
|
586
|
+
if (node == null || typeof node !== 'object') return;
|
|
587
|
+
if (Array.isArray(node)) {
|
|
588
|
+
for (const each of node) walkNodes(each, visit);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (typeof node.type === 'string') visit(node);
|
|
592
|
+
for (const [key, value] of Object.entries(node)) {
|
|
593
|
+
if (
|
|
594
|
+
key === 'loc' ||
|
|
595
|
+
key === 'start' ||
|
|
596
|
+
key === 'end' ||
|
|
597
|
+
key === 'extra' ||
|
|
598
|
+
key === 'leadingComments' ||
|
|
599
|
+
key === 'trailingComments'
|
|
600
|
+
) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (value && typeof value === 'object') walkNodes(value, visit);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** The extensions a local import may leave off, in the order tried. */
|
|
608
|
+
const LOCAL_MODULE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs'];
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Libraries that render no component of this package or of Core: React's
|
|
612
|
+
* own exports and StyleX. Importing them, even as a namespace
|
|
613
|
+
* (`import * as stylex`), leaves no name unjudged.
|
|
614
|
+
*/
|
|
615
|
+
const RENDERLESS_SOURCES = ['react', 'react-dom', '@stylexjs/stylex'];
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Packages a template may import from without leaving its names unjudged:
|
|
619
|
+
* Core and the renderless libraries. Any other package may render components
|
|
620
|
+
* this check does not see, such as a grid that renders its cards.
|
|
621
|
+
*/
|
|
622
|
+
const KNOWN_SOURCES = ['@astryxdesign/core', ...RENDERLESS_SOURCES];
|
|
623
|
+
|
|
624
|
+
/** @param {string} source */
|
|
625
|
+
function renderlessSource(source) {
|
|
626
|
+
return RENDERLESS_SOURCES.some(
|
|
627
|
+
known => source === known || source.startsWith(`${known}/`),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Whether an import source is one whose components this check sees: Core, a
|
|
633
|
+
* library that renders none, or a relative helper (followed and read in full
|
|
634
|
+
* on its own). The package's own name is followed like a helper too.
|
|
635
|
+
* @param {string} source
|
|
636
|
+
*/
|
|
637
|
+
function knownSource(source) {
|
|
638
|
+
if (source.startsWith('.')) return true;
|
|
639
|
+
return KNOWN_SOURCES.some(
|
|
640
|
+
known => source === known || source.startsWith(`${known}/`),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Every name a template imports, as imported and as bound locally, read
|
|
646
|
+
* without running it, with the names its local helper modules import (a
|
|
647
|
+
* relative import or re-export is followed, so a barrel such as
|
|
648
|
+
* `parts/index.ts` counts). A name is judged only where the template reaches
|
|
649
|
+
* components solely through named imports and re-exports this walk reads:
|
|
650
|
+
* `unread` gives the reason for anything else it meets, which leaves an
|
|
651
|
+
* unresolved name unjudged. That covers a default or namespace import, an
|
|
652
|
+
* alias of an imported binding, a member read off one (in JSX or not,
|
|
653
|
+
* optional or computed), a rendered component it cannot trace to an import,
|
|
654
|
+
* `require()`, a dynamic `import()`, an `export *` from another package, and
|
|
655
|
+
* a local module it cannot read. So does any `default` binding (`import X`,
|
|
656
|
+
* `{default as X}`, `export {default as X} from`) and any import from a
|
|
657
|
+
* package other than Core or a library that renders no component (see
|
|
658
|
+
* KNOWN_SOURCES). An import of the package's own name is followed like a
|
|
659
|
+
* relative helper, through its package.json `exports`. Type-only imports
|
|
660
|
+
* render nothing and are skipped, as is a namespace or default import of a
|
|
661
|
+
* library that renders no component.
|
|
662
|
+
* @param {string} file
|
|
663
|
+
* @param {LoadedIntegration} integration
|
|
664
|
+
* @returns {{names: Set<string>, unread: string[]}}
|
|
665
|
+
*/
|
|
666
|
+
function importedNames(file, integration) {
|
|
667
|
+
const packageName = integration.name;
|
|
668
|
+
/** @type {Set<string>} */
|
|
669
|
+
const names = new Set();
|
|
670
|
+
/** @type {string[]} */
|
|
671
|
+
const unread = [];
|
|
672
|
+
const top = path.dirname(file);
|
|
673
|
+
const shownTop = path.basename(file);
|
|
674
|
+
/** @type {Set<string>} */
|
|
675
|
+
const seen = new Set();
|
|
676
|
+
/** @param {string} current */
|
|
677
|
+
const visit = current => {
|
|
678
|
+
if (seen.has(current)) return;
|
|
679
|
+
seen.add(current);
|
|
680
|
+
const shown = slash(path.relative(top, current)) || shownTop;
|
|
681
|
+
let ast;
|
|
682
|
+
try {
|
|
683
|
+
ast = parseModule(fs.readFileSync(current, 'utf8'), {
|
|
684
|
+
sourceType: 'module',
|
|
685
|
+
plugins: ['typescript', 'jsx'],
|
|
686
|
+
errorRecovery: true,
|
|
687
|
+
});
|
|
688
|
+
} catch {
|
|
689
|
+
unread.push(
|
|
690
|
+
`${shown} could not be read, so the components it renders are not known.`,
|
|
691
|
+
);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
// A file that parses only with errors recovered may be missing parts.
|
|
695
|
+
if (/** @type {any} */ (ast).errors?.length > 0) {
|
|
696
|
+
unread.push(
|
|
697
|
+
`${shown} does not parse cleanly, so the components it renders are not known.`,
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
/** @type {Map<string, string>} local binding -> the module it came from */
|
|
701
|
+
const bindings = new Map();
|
|
702
|
+
for (const node of ast.program.body) {
|
|
703
|
+
const reexport =
|
|
704
|
+
(node.type === 'ExportNamedDeclaration' && node.source != null) ||
|
|
705
|
+
node.type === 'ExportAllDeclaration';
|
|
706
|
+
if (node.type !== 'ImportDeclaration' && !reexport) continue;
|
|
707
|
+
// A type-only import or re-export renders nothing.
|
|
708
|
+
if (
|
|
709
|
+
/** @type {any} */ (node).importKind === 'type' ||
|
|
710
|
+
/** @type {any} */ (node).exportKind === 'type'
|
|
711
|
+
) {
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const source = String(/** @type {any} */ (node).source.value);
|
|
715
|
+
// The package's own name: read like a relative helper, below.
|
|
716
|
+
const own =
|
|
717
|
+
typeof packageName === 'string' &&
|
|
718
|
+
packageName !== '' &&
|
|
719
|
+
(source === packageName || source.startsWith(`${packageName}/`));
|
|
720
|
+
if (!own && !knownSource(source)) {
|
|
721
|
+
unread.push(
|
|
722
|
+
`${shown} imports from "${source}", another package whose components may render ones this check does not see.`,
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
if (node.type === 'ImportDeclaration') {
|
|
726
|
+
for (const specifier of node.specifiers) {
|
|
727
|
+
if (/** @type {any} */ (specifier).importKind === 'type') continue;
|
|
728
|
+
if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
729
|
+
// `import * as stylex`: a library that renders no component.
|
|
730
|
+
if (renderlessSource(source)) continue;
|
|
731
|
+
unread.push(
|
|
732
|
+
`${shown} imports "${source}" as a namespace (${specifier.local.name}), whose component names this check does not read.`,
|
|
733
|
+
);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (
|
|
737
|
+
specifier.type === 'ImportDefaultSpecifier' &&
|
|
738
|
+
!renderlessSource(source)
|
|
739
|
+
) {
|
|
740
|
+
// A default may be any component, under any name.
|
|
741
|
+
unread.push(
|
|
742
|
+
`${shown} imports the default of "${source}" as ${specifier.local.name}, which may be any component.`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
names.add(specifier.local.name);
|
|
746
|
+
bindings.set(specifier.local.name, source);
|
|
747
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
748
|
+
const imported = specifier.imported;
|
|
749
|
+
const importedName =
|
|
750
|
+
imported.type === 'Identifier' ? imported.name : imported.value;
|
|
751
|
+
if (importedName === 'default') {
|
|
752
|
+
unread.push(
|
|
753
|
+
`${shown} imports the default of "${source}" as ${specifier.local.name}, which may be any component.`,
|
|
754
|
+
);
|
|
755
|
+
} else {
|
|
756
|
+
names.add(importedName);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} else if (node.type === 'ExportNamedDeclaration') {
|
|
761
|
+
// A barrel: `export {Body} from './Body'` passes Body on, and what
|
|
762
|
+
// Body renders comes from the module it names.
|
|
763
|
+
for (const each of node.specifiers) {
|
|
764
|
+
const specifier = /** @type {any} */ (each);
|
|
765
|
+
if (specifier.exportKind === 'type') continue;
|
|
766
|
+
if (specifier.type !== 'ExportSpecifier') {
|
|
767
|
+
// `export * as UI from`, `export UI from`.
|
|
768
|
+
unread.push(
|
|
769
|
+
`${shown} re-exports "${source}" as a whole (${specifier.exported.type === 'Identifier' ? specifier.exported.name : specifier.exported.value}), whose component names this check does not read.`,
|
|
770
|
+
);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const local =
|
|
774
|
+
specifier.local.type === 'Identifier'
|
|
775
|
+
? specifier.local.name
|
|
776
|
+
: specifier.local.value;
|
|
777
|
+
const exported =
|
|
778
|
+
specifier.exported.type === 'Identifier'
|
|
779
|
+
? specifier.exported.name
|
|
780
|
+
: specifier.exported.value;
|
|
781
|
+
if (local === 'default') {
|
|
782
|
+
unread.push(
|
|
783
|
+
`${shown} re-exports the default of "${source}" as ${exported}, which may be any component.`,
|
|
784
|
+
);
|
|
785
|
+
} else {
|
|
786
|
+
names.add(local);
|
|
787
|
+
}
|
|
788
|
+
names.add(exported);
|
|
789
|
+
}
|
|
790
|
+
} else if (!source.startsWith('.') && !own) {
|
|
791
|
+
// `export * from` another package passes on names this check
|
|
792
|
+
// does not list.
|
|
793
|
+
unread.push(
|
|
794
|
+
`${shown} re-exports everything from "${source}", whose component names this check does not read.`,
|
|
795
|
+
);
|
|
796
|
+
continue;
|
|
797
|
+
} else if (/** @type {any} */ (node).exported) {
|
|
798
|
+
// `export * as UI from './parts'`.
|
|
799
|
+
unread.push(
|
|
800
|
+
`${shown} re-exports "${source}" as a whole, whose component names this check does not read.`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (!source.startsWith('.') && !own) continue;
|
|
804
|
+
if (own) {
|
|
805
|
+
const resolved = componentDiscovery.ownPackageModule(
|
|
806
|
+
integration,
|
|
807
|
+
source,
|
|
808
|
+
);
|
|
809
|
+
/** @type {boolean} */
|
|
810
|
+
let isFile;
|
|
811
|
+
try {
|
|
812
|
+
isFile = resolved != null && fs.statSync(resolved).isFile();
|
|
813
|
+
} catch {
|
|
814
|
+
isFile = false;
|
|
815
|
+
}
|
|
816
|
+
if (
|
|
817
|
+
resolved &&
|
|
818
|
+
isFile &&
|
|
819
|
+
LOCAL_MODULE_EXTENSIONS.includes(path.extname(resolved))
|
|
820
|
+
) {
|
|
821
|
+
visit(resolved);
|
|
822
|
+
} else {
|
|
823
|
+
unread.push(
|
|
824
|
+
`${shown} imports "${source}", this package's own name, which resolves to no source this check can read.`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const base = path.resolve(path.dirname(current), source);
|
|
830
|
+
const target = [
|
|
831
|
+
base,
|
|
832
|
+
...LOCAL_MODULE_EXTENSIONS.map(extension => `${base}${extension}`),
|
|
833
|
+
...LOCAL_MODULE_EXTENSIONS.map(extension =>
|
|
834
|
+
path.join(base, `index${extension}`),
|
|
835
|
+
),
|
|
836
|
+
].find(candidate => {
|
|
837
|
+
try {
|
|
838
|
+
return fs.statSync(candidate).isFile();
|
|
839
|
+
} catch {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
if (!target) {
|
|
844
|
+
unread.push(
|
|
845
|
+
`${shown} imports "${source}", which resolves to no file this check can read.`,
|
|
846
|
+
);
|
|
847
|
+
} else if (LOCAL_MODULE_EXTENSIONS.includes(path.extname(target))) {
|
|
848
|
+
visit(target);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// Anything this check does not model leaves the names unjudged: what
|
|
852
|
+
// the module renders through a binding, an alias, a member read, a
|
|
853
|
+
// component held in a variable, or a module loaded at runtime.
|
|
854
|
+
/** @type {Set<string>} functions and classes the module declares */
|
|
855
|
+
const declared = new Set();
|
|
856
|
+
walkNodes(ast.program, node => {
|
|
857
|
+
if (
|
|
858
|
+
(node.type === 'FunctionDeclaration' ||
|
|
859
|
+
node.type === 'ClassDeclaration') &&
|
|
860
|
+
node.id?.name
|
|
861
|
+
) {
|
|
862
|
+
declared.add(node.id.name);
|
|
863
|
+
} else if (
|
|
864
|
+
node.type === 'VariableDeclarator' &&
|
|
865
|
+
node.id?.type === 'Identifier' &&
|
|
866
|
+
[
|
|
867
|
+
'ArrowFunctionExpression',
|
|
868
|
+
'FunctionExpression',
|
|
869
|
+
'ClassExpression',
|
|
870
|
+
].includes(node.init?.type)
|
|
871
|
+
) {
|
|
872
|
+
declared.add(node.id.name);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
const from = (/** @type {string} */ name) =>
|
|
876
|
+
bindings.has(name) ? ` (imported from "${bindings.get(name)}")` : '';
|
|
877
|
+
walkNodes(ast.program, node => {
|
|
878
|
+
if (node.type === 'JSXMemberExpression') {
|
|
879
|
+
let object = node.object;
|
|
880
|
+
while (object?.type === 'JSXMemberExpression') object = object.object;
|
|
881
|
+
const name = object?.name ?? 'an object';
|
|
882
|
+
unread.push(
|
|
883
|
+
`${shown} renders members of ${name}${from(name)}, whose names this check does not read.`,
|
|
884
|
+
);
|
|
885
|
+
} else if (
|
|
886
|
+
node.type === 'JSXOpeningElement' &&
|
|
887
|
+
node.name?.type === 'JSXIdentifier' &&
|
|
888
|
+
/^[A-Z]/u.test(node.name.name) &&
|
|
889
|
+
!names.has(node.name.name) &&
|
|
890
|
+
!declared.has(node.name.name)
|
|
891
|
+
) {
|
|
892
|
+
unread.push(
|
|
893
|
+
`${shown} renders ${node.name.name}, a value this check does not trace to an import.`,
|
|
894
|
+
);
|
|
895
|
+
} else if (
|
|
896
|
+
node.type === 'VariableDeclarator' &&
|
|
897
|
+
node.init?.type === 'Identifier' &&
|
|
898
|
+
bindings.has(node.init.name)
|
|
899
|
+
) {
|
|
900
|
+
// `const UI = Lib`, `const {Panel} = Lib`: an alias of an import.
|
|
901
|
+
if (node.id?.type === 'Identifier') {
|
|
902
|
+
bindings.set(
|
|
903
|
+
node.id.name,
|
|
904
|
+
/** @type {string} */ (bindings.get(node.init.name)),
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
unread.push(
|
|
908
|
+
`${shown} reads ${node.init.name}${from(node.init.name)} into another name, which this check does not follow.`,
|
|
909
|
+
);
|
|
910
|
+
} else if (
|
|
911
|
+
(node.type === 'MemberExpression' ||
|
|
912
|
+
node.type === 'OptionalMemberExpression') &&
|
|
913
|
+
node.object?.type === 'Identifier' &&
|
|
914
|
+
bindings.has(node.object.name)
|
|
915
|
+
) {
|
|
916
|
+
// `UI.Panel`, `UI?.Panel`, `UI['Panel']`, `createElement(UI.Panel)`.
|
|
917
|
+
unread.push(
|
|
918
|
+
`${shown} reads members of ${node.object.name}${from(node.object.name)}, whose names this check does not read.`,
|
|
919
|
+
);
|
|
920
|
+
} else if (
|
|
921
|
+
node.type === 'CallExpression' &&
|
|
922
|
+
node.callee?.type === 'Identifier' &&
|
|
923
|
+
node.callee.name === 'require'
|
|
924
|
+
) {
|
|
925
|
+
const argument = node.arguments?.[0];
|
|
926
|
+
const spec =
|
|
927
|
+
argument?.type === 'StringLiteral'
|
|
928
|
+
? `"${argument.value}"`
|
|
929
|
+
: 'a module';
|
|
930
|
+
unread.push(
|
|
931
|
+
`${shown} loads ${spec} with require(), whose component names this check does not read.`,
|
|
932
|
+
);
|
|
933
|
+
} else if (
|
|
934
|
+
node.type === 'ImportExpression' ||
|
|
935
|
+
(node.type === 'CallExpression' && node.callee?.type === 'Import')
|
|
936
|
+
) {
|
|
937
|
+
const argument = node.source ?? node.arguments?.[0];
|
|
938
|
+
const spec =
|
|
939
|
+
argument?.type === 'StringLiteral'
|
|
940
|
+
? `"${argument.value}"`
|
|
941
|
+
: 'a module';
|
|
942
|
+
unread.push(
|
|
943
|
+
`${shown} loads ${spec} with a dynamic import(), whose component names this check does not read.`,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
};
|
|
948
|
+
visit(file);
|
|
949
|
+
return {names, unread};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* The local name a module's default export declares (`export default
|
|
954
|
+
* function AcmeWidget()`, `export default class AcmeWidget`), or of the
|
|
955
|
+
* binding it exports as default (`export default AcmeWidget`), or null.
|
|
956
|
+
* @param {string} file
|
|
957
|
+
* @returns {string | null}
|
|
958
|
+
*/
|
|
959
|
+
function defaultBinding(file) {
|
|
960
|
+
let ast;
|
|
961
|
+
try {
|
|
962
|
+
ast = parseModule(fs.readFileSync(file, 'utf8'), {
|
|
963
|
+
sourceType: 'module',
|
|
964
|
+
plugins: ['typescript', 'jsx'],
|
|
965
|
+
errorRecovery: true,
|
|
966
|
+
});
|
|
967
|
+
} catch {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
for (const node of ast.program.body) {
|
|
971
|
+
if (node.type !== 'ExportDefaultDeclaration') continue;
|
|
972
|
+
const declaration = /** @type {any} */ (node.declaration);
|
|
973
|
+
if (typeof declaration?.id?.name === 'string') return declaration.id.name;
|
|
974
|
+
if (declaration?.type === 'Identifier') return declaration.name;
|
|
975
|
+
}
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* A note: information beside validate's issues that fails nothing, with the
|
|
981
|
+
* fix in its message.
|
|
982
|
+
* @typedef {{code: string, message: string}} Note
|
|
983
|
+
*/
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* @param {string} code
|
|
987
|
+
* @param {{message: string}} finding
|
|
988
|
+
* @returns {Note}
|
|
989
|
+
*/
|
|
990
|
+
function note(code, finding) {
|
|
991
|
+
return {code, message: finding.message};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* The block component names this check could not judge, shaped like the
|
|
996
|
+
* doc type report so `integration check` counts them as not compared: each
|
|
997
|
+
* name a template gives in `exampleFor` or `componentsUsed` that neither the
|
|
998
|
+
* package nor Core has, when the template reaches components through
|
|
999
|
+
* anything but named imports and re-exports from the package, Core, or
|
|
1000
|
+
* helpers read in full (see importedNames), or when Core is not installed to
|
|
1001
|
+
* look it up in; its findings are always empty. A name judged unknown is an
|
|
1002
|
+
* `unknown_block_component` note, with its fix in the message: validate
|
|
1003
|
+
* lists notes beside its issues, and nothing counts them, so `--strict` does
|
|
1004
|
+
* not fail on it until the next minor release (architecture:cli-surface
|
|
1005
|
+
* INV35).
|
|
1006
|
+
* @param {LoadedIntegration} integration
|
|
1007
|
+
* @returns {Promise<{report: {status: 'checked', findings: [], unchecked: Array<{file: string, name: string, reason: string}>}, notes: Note[]}>}
|
|
1008
|
+
*/
|
|
1009
|
+
export async function blockComponentsCheck(integration) {
|
|
1010
|
+
const {findings, unchecked} = await judgeBlockComponents(integration);
|
|
1011
|
+
const base = integration.__packageDir ?? integration.templates ?? '';
|
|
1012
|
+
return {
|
|
1013
|
+
report: {
|
|
1014
|
+
status: 'checked',
|
|
1015
|
+
findings: [],
|
|
1016
|
+
unchecked: unchecked.map(entry => ({
|
|
1017
|
+
file: slash(path.relative(base, entry.docPath)),
|
|
1018
|
+
name: entry.name,
|
|
1019
|
+
reason: entry.reason,
|
|
1020
|
+
})),
|
|
1021
|
+
},
|
|
1022
|
+
notes: findings.map(entry => note('unknown_block_component', entry)),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
307
1026
|
/**
|
|
308
1027
|
* Warn about a `.tsx` directly in the templates root with no same-stem template
|
|
309
1028
|
* metadata: template discovery pairs sources with docs, so `astryx template`
|
|
@@ -340,6 +1059,8 @@ async function checkComponents(integration, issues) {
|
|
|
340
1059
|
if (!integration.components || rootProblem(integration.components)) return;
|
|
341
1060
|
const discover = componentDiscovery.discoverValidIntegrationComponents;
|
|
342
1061
|
if (typeof discover !== 'function') return; // feature not present yet
|
|
1062
|
+
const root = /** @type {string} */ (integration.components);
|
|
1063
|
+
const files = componentDocFiles(root);
|
|
343
1064
|
try {
|
|
344
1065
|
const {discovered, errors} = await discover(integration);
|
|
345
1066
|
/** @type {import('./contribution-fixes.mjs').FixContext | undefined} */
|
|
@@ -352,13 +1073,75 @@ async function checkComponents(integration, issues) {
|
|
|
352
1073
|
continue;
|
|
353
1074
|
}
|
|
354
1075
|
const docPath = record.docPath;
|
|
355
|
-
|
|
1076
|
+
// Reported once, below, as the case mismatch it is.
|
|
1077
|
+
if (docPath && files.caseMismatch.has(docPath)) continue;
|
|
1078
|
+
const docName = docPath
|
|
1079
|
+
? path.basename(docPath)
|
|
1080
|
+
: `${record.name}.doc.mjs`;
|
|
1081
|
+
let message = error.message;
|
|
1082
|
+
let fix = `Fix: add ${record.name}.tsx beside ${docName}.`;
|
|
356
1083
|
const stamp = docPath ? readContributionStamp(docPath, false) : null;
|
|
357
1084
|
if (stamp && stamp.type !== 'component' && integration.__packageDir) {
|
|
358
1085
|
context ??= createFixContext(integration.__packageDir, integration);
|
|
359
1086
|
fix = notAComponentFix(context, docPath, stamp);
|
|
1087
|
+
} else {
|
|
1088
|
+
const other = docPath ? otherSourceFor(docPath) : null;
|
|
1089
|
+
if (other) {
|
|
1090
|
+
message = `${message.replace(/\.$/u, '')}: found ${other}, but a component source must be a .tsx file.`;
|
|
1091
|
+
fix = `Fix: rename ${other} to ${record.name}.tsx (and point its package export at the new file).`;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
issues.push(issueError('invalid_component', `${message} ${fix}`));
|
|
1095
|
+
}
|
|
1096
|
+
/** @type {Set<string>} sources a case-only rename is reported for */
|
|
1097
|
+
const reportedSources = new Set();
|
|
1098
|
+
for (const [docPath, source] of files.caseMismatch) {
|
|
1099
|
+
const doc = path.basename(docPath);
|
|
1100
|
+
const renamed = `${source.slice(0, -'.tsx'.length)}${doc.slice(docStemOf(doc).length)}`;
|
|
1101
|
+
const shown = slash(path.relative(root, docPath));
|
|
1102
|
+
const linux = `They pair on a case-insensitive disk (macOS, Windows) but not on Linux, where CI and most installs run`;
|
|
1103
|
+
const fix = `Fix: rename ${doc} to ${renamed}.`;
|
|
1104
|
+
// Where discovery still pairs the doc with its source (a
|
|
1105
|
+
// case-insensitive disk), the component works here: listed, as
|
|
1106
|
+
// validate passed it before. A hidden doc that does not pair leaves its
|
|
1107
|
+
// source without a doc, which the source check below reports as it
|
|
1108
|
+
// always has (architecture:cli-surface INV37).
|
|
1109
|
+
const pairs = componentDiscovery.integrationSourceForDoc(docPath) != null;
|
|
1110
|
+
const hidden = componentDiscovery.isHiddenComponentDoc(docPath);
|
|
1111
|
+
if (hidden && !pairs) continue;
|
|
1112
|
+
reportedSources.add(path.join(path.dirname(docPath), source));
|
|
1113
|
+
if (hidden) {
|
|
1114
|
+
issues.push(
|
|
1115
|
+
issueListed(
|
|
1116
|
+
'source_without_component_doc',
|
|
1117
|
+
`Hidden component doc "${shown}" and its source "${source}" differ only in letter case. ${linux}. ${fix}`,
|
|
1118
|
+
),
|
|
1119
|
+
);
|
|
1120
|
+
} else if (pairs) {
|
|
1121
|
+
issues.push(
|
|
1122
|
+
issueListed(
|
|
1123
|
+
'source_without_component_doc',
|
|
1124
|
+
`Component doc "${shown}" and its source "${source}" differ only in letter case. ${linux}, so there the component is missing. ${fix}`,
|
|
1125
|
+
),
|
|
1126
|
+
);
|
|
1127
|
+
} else {
|
|
1128
|
+
issues.push(
|
|
1129
|
+
issueError(
|
|
1130
|
+
'invalid_component',
|
|
1131
|
+
`Component doc "${shown}" and its source "${source}" differ only in letter case. ${linux}, so here the component is missing. ${fix}`,
|
|
1132
|
+
),
|
|
1133
|
+
);
|
|
360
1134
|
}
|
|
361
|
-
|
|
1135
|
+
}
|
|
1136
|
+
for (const {read, ignored} of files.duplicates) {
|
|
1137
|
+
const readName = path.basename(read);
|
|
1138
|
+
const ignoredNames = ignored.map(file => path.basename(file));
|
|
1139
|
+
issues.push(
|
|
1140
|
+
issueListed(
|
|
1141
|
+
'duplicate_component_doc',
|
|
1142
|
+
`Component "${docStemOf(readName)}" has ${ignored.length + 1} docs: ${[readName, ...ignoredNames].join(', ')}. Astryx reads ${readName} and ignores ${ignoredNames.join(' and ')}. Fix: delete ${ignoredNames.join(' and ')}, moving anything only ${ignored.length === 1 ? 'it says' : 'they say'} into ${readName}.`,
|
|
1143
|
+
),
|
|
1144
|
+
);
|
|
362
1145
|
}
|
|
363
1146
|
const overlap = integration.__packageDir
|
|
364
1147
|
? newComponentDocNote(
|
|
@@ -372,16 +1155,13 @@ async function checkComponents(integration, issues) {
|
|
|
372
1155
|
// A hidden doc still pairs with its source; discovery just skips it.
|
|
373
1156
|
if (
|
|
374
1157
|
['.doc.ts', '.doc.mjs', '.doc.js'].some(suffix =>
|
|
375
|
-
fs.existsSync(
|
|
376
|
-
path.join(
|
|
377
|
-
/** @type {string} */ (integration.components),
|
|
378
|
-
`${name}${suffix}`,
|
|
379
|
-
),
|
|
380
|
-
),
|
|
1158
|
+
fs.existsSync(path.join(root, `${name}${suffix}`)),
|
|
381
1159
|
)
|
|
382
1160
|
) {
|
|
383
1161
|
continue;
|
|
384
1162
|
}
|
|
1163
|
+
// A doc whose name differs only in case is reported above.
|
|
1164
|
+
if (reportedSources.has(path.join(root, `${name}.tsx`))) continue;
|
|
385
1165
|
issues.push(
|
|
386
1166
|
issueWarning(
|
|
387
1167
|
'source_without_component_doc',
|
|
@@ -396,6 +1176,251 @@ async function checkComponents(integration, issues) {
|
|
|
396
1176
|
}
|
|
397
1177
|
}
|
|
398
1178
|
|
|
1179
|
+
const COMPONENT_DOC_SUFFIXES = ['.doc.ts', '.doc.mjs', '.doc.js'];
|
|
1180
|
+
|
|
1181
|
+
/** @param {string} file a doc file name */
|
|
1182
|
+
function docStemOf(file) {
|
|
1183
|
+
const suffix = COMPONENT_DOC_SUFFIXES.find(each => file.endsWith(each));
|
|
1184
|
+
return suffix ? file.slice(0, -suffix.length) : file;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* A same-stem source discovery does not read: `.jsx`, `.js`, `.ts` or `.mjs`.
|
|
1189
|
+
* @param {string} docPath
|
|
1190
|
+
* @returns {string | null} its file name
|
|
1191
|
+
*/
|
|
1192
|
+
function otherSourceFor(docPath) {
|
|
1193
|
+
const stem = docStemOf(path.basename(docPath));
|
|
1194
|
+
const dir = path.dirname(docPath);
|
|
1195
|
+
return (
|
|
1196
|
+
['.jsx', '.js', '.ts', '.mjs']
|
|
1197
|
+
.map(extension => `${stem}${extension}`)
|
|
1198
|
+
.find(file => fs.existsSync(path.join(dir, file))) ?? null
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* The component docs under a root, read from the real directory listings:
|
|
1204
|
+
* those beside a source whose name differs only in letter case (which an
|
|
1205
|
+
* existence check on a case-insensitive disk would call paired), and stems
|
|
1206
|
+
* with more than one doc, with the doc discovery reads.
|
|
1207
|
+
* @param {string} root
|
|
1208
|
+
*/
|
|
1209
|
+
function componentDocFiles(root) {
|
|
1210
|
+
/** @type {Map<string, string>} doc path -> the source's real file name */
|
|
1211
|
+
const caseMismatch = new Map();
|
|
1212
|
+
/** @type {Array<{read: string, ignored: string[]}>} */
|
|
1213
|
+
const duplicates = [];
|
|
1214
|
+
/** @param {string} dir */
|
|
1215
|
+
const walk = dir => {
|
|
1216
|
+
let entries;
|
|
1217
|
+
try {
|
|
1218
|
+
entries = fs.readdirSync(dir, {withFileTypes: true});
|
|
1219
|
+
} catch {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
const names = new Set(entries.map(entry => entry.name));
|
|
1223
|
+
/** @type {Map<string, string[]>} */
|
|
1224
|
+
const byStem = new Map();
|
|
1225
|
+
for (const entry of entries) {
|
|
1226
|
+
const full = path.join(dir, entry.name);
|
|
1227
|
+
if (entry.isDirectory()) {
|
|
1228
|
+
if (entry.name !== 'node_modules' && entry.name !== '__tests__') {
|
|
1229
|
+
walk(full);
|
|
1230
|
+
}
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
const stem = docStemOf(entry.name);
|
|
1234
|
+
if (stem === entry.name) continue;
|
|
1235
|
+
byStem.set(stem, [...(byStem.get(stem) ?? []), entry.name]);
|
|
1236
|
+
const source = `${stem}.tsx`;
|
|
1237
|
+
if (names.has(source)) continue;
|
|
1238
|
+
const other = [...names].find(
|
|
1239
|
+
name => name !== source && name.toLowerCase() === source.toLowerCase(),
|
|
1240
|
+
);
|
|
1241
|
+
// The source's own doc, if it has one, is its pair.
|
|
1242
|
+
if (
|
|
1243
|
+
other &&
|
|
1244
|
+
!COMPONENT_DOC_SUFFIXES.some(suffix =>
|
|
1245
|
+
names.has(`${other.slice(0, -'.tsx'.length)}${suffix}`),
|
|
1246
|
+
)
|
|
1247
|
+
) {
|
|
1248
|
+
caseMismatch.set(full, other);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
for (const docs of byStem.values()) {
|
|
1252
|
+
if (docs.length < 2) continue;
|
|
1253
|
+
const ordered = COMPONENT_DOC_SUFFIXES.map(suffix =>
|
|
1254
|
+
docs.find(doc => doc.endsWith(suffix)),
|
|
1255
|
+
).filter(doc => doc != null);
|
|
1256
|
+
duplicates.push({
|
|
1257
|
+
read: path.join(dir, /** @type {string} */ (ordered[0])),
|
|
1258
|
+
ignored: ordered
|
|
1259
|
+
.slice(1)
|
|
1260
|
+
.map(doc => path.join(dir, /** @type {string} */ (doc))),
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
};
|
|
1264
|
+
walk(root);
|
|
1265
|
+
return {caseMismatch, duplicates};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Judge each component doc's `name` against the module its printed import
|
|
1270
|
+
* names: a finding when that module provably does not export it, and an
|
|
1271
|
+
* entry in `unchecked` when it cannot be judged, so it is listed instead.
|
|
1272
|
+
* @param {LoadedIntegration} integration
|
|
1273
|
+
* @param {Array<{name: string, docPath: string, sourcePath: string|null}>} components
|
|
1274
|
+
* @param {string} root the components root
|
|
1275
|
+
* @param {Map<string, string>} skip docs already reported, by path
|
|
1276
|
+
* @param {Array<{docPath: string, name: string, message: string}>} findings
|
|
1277
|
+
* @param {Array<{docPath: string, name: string, reason: string}>} unchecked
|
|
1278
|
+
*/
|
|
1279
|
+
async function judgeComponentDocNames(
|
|
1280
|
+
integration,
|
|
1281
|
+
components,
|
|
1282
|
+
root,
|
|
1283
|
+
skip,
|
|
1284
|
+
findings,
|
|
1285
|
+
unchecked,
|
|
1286
|
+
) {
|
|
1287
|
+
const base = integration.__packageDir ?? root;
|
|
1288
|
+
for (const record of components) {
|
|
1289
|
+
if (componentDiscovery.isIntegrationMember(record)) continue;
|
|
1290
|
+
if (skip.has(record.docPath)) continue;
|
|
1291
|
+
// A doc with no source is its own error.
|
|
1292
|
+
if (record.sourcePath == null) continue;
|
|
1293
|
+
let doc;
|
|
1294
|
+
try {
|
|
1295
|
+
doc = await loadComponentDoc(record.docPath);
|
|
1296
|
+
} catch {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
const name = doc?.name;
|
|
1300
|
+
if (typeof name !== 'string' || name === record.name) continue;
|
|
1301
|
+
// The module the printed import names: the doc's import, the published
|
|
1302
|
+
// subpath, or the package root, resolved through package.json "exports".
|
|
1303
|
+
// Without a package to resolve it in, the same-stem source.
|
|
1304
|
+
const printed = componentDiscovery.importedModule(
|
|
1305
|
+
integration,
|
|
1306
|
+
record.docPath,
|
|
1307
|
+
doc,
|
|
1308
|
+
);
|
|
1309
|
+
if (printed === null) {
|
|
1310
|
+
unchecked.push({
|
|
1311
|
+
docPath: record.docPath,
|
|
1312
|
+
name,
|
|
1313
|
+
reason:
|
|
1314
|
+
'Its import names another package, or resolves to no file this check can read.',
|
|
1315
|
+
});
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const module = printed ?? record.sourcePath;
|
|
1319
|
+
const shown = slash(path.relative(base, module));
|
|
1320
|
+
// Only what the code contradicts: the module must provably not export
|
|
1321
|
+
// the name the doc gives.
|
|
1322
|
+
const judged = moduleExportVerdict(module, name);
|
|
1323
|
+
if (judged.verdict === 'exports') continue;
|
|
1324
|
+
if (judged.verdict === 'unknown') {
|
|
1325
|
+
unchecked.push({docPath: record.docPath, name, reason: judged.reason});
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
// A source whose only export is a default, read because no printed
|
|
1329
|
+
// import could be resolved: the package's entry may publish it under
|
|
1330
|
+
// any name, so the name is not judged. A printed import that names a
|
|
1331
|
+
// default-only module does not export the name, which is a mismatch.
|
|
1332
|
+
const exportedNames = moduleExportNames(module);
|
|
1333
|
+
const defaultOnly =
|
|
1334
|
+
exportedNames != null &&
|
|
1335
|
+
exportedNames.length === 1 &&
|
|
1336
|
+
exportedNames[0] === 'default';
|
|
1337
|
+
if (defaultOnly && printed === undefined) {
|
|
1338
|
+
unchecked.push({
|
|
1339
|
+
docPath: record.docPath,
|
|
1340
|
+
name,
|
|
1341
|
+
reason: `${shown} exports a default, which the package entry may publish under any name.`,
|
|
1342
|
+
});
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
const file = path.basename(record.docPath);
|
|
1346
|
+
// The name to propose: the file name when the module exports it, else
|
|
1347
|
+
// the one component the module exports.
|
|
1348
|
+
let fix;
|
|
1349
|
+
if (defaultOnly) {
|
|
1350
|
+
fix =
|
|
1351
|
+
defaultBinding(module) === name
|
|
1352
|
+
? `Fix: add \`export {${name}};\` to ${shown}, so it exports ${name} by name as well, or set the doc's import to the module that publishes it as ${name}.`
|
|
1353
|
+
: `Fix: bind the default to ${name} in ${shown} and export it by name too (\`const ${name} = …; export default ${name}; export {${name}};\`), or set the doc's import to the module that publishes it as ${name}.`;
|
|
1354
|
+
} else if (moduleExportsName(module, record.name)) {
|
|
1355
|
+
fix = `Fix: set name: '${record.name}' in ${file}.`;
|
|
1356
|
+
} else {
|
|
1357
|
+
const exported = (exportedNames ?? []).filter(each =>
|
|
1358
|
+
/^[A-Z][A-Za-z0-9]*$/u.test(each),
|
|
1359
|
+
);
|
|
1360
|
+
fix =
|
|
1361
|
+
exported.length === 1
|
|
1362
|
+
? `Fix: set name: '${exported[0]}' in ${file}, the component ${shown} exports.`
|
|
1363
|
+
: `Fix: set name in ${file} to the component ${shown} exports${exported.length > 1 ? ` (${exported.join(', ')})` : ''}.`;
|
|
1364
|
+
}
|
|
1365
|
+
findings.push({
|
|
1366
|
+
docPath: record.docPath,
|
|
1367
|
+
name,
|
|
1368
|
+
message: `Component doc "${slash(path.relative(root, record.docPath))}" sets name: '${name}', but ${shown}, the module its import names, does not export ${name}, so Astryx lists the component as "${record.name}" (its file name), and \`astryx component ${record.name}\` prints \`import {${name}}\`. ${fix}`,
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* The component doc names the check could not judge, shaped like the doc type
|
|
1375
|
+
* report so `integration check` counts them as not compared: each doc whose
|
|
1376
|
+
* `name` differs from the name it is listed under and whose source's exports
|
|
1377
|
+
* cannot all be read without running it; its findings are always empty. A
|
|
1378
|
+
* name the module its printed import names provably lacks is a
|
|
1379
|
+
* `doc_name_mismatch` note, with its fix in the message: validate lists
|
|
1380
|
+
* notes beside its issues, and nothing counts them, so `--strict` does not
|
|
1381
|
+
* fail on it until the next minor release (architecture:cli-surface INV35).
|
|
1382
|
+
* @param {LoadedIntegration} integration
|
|
1383
|
+
* @returns {Promise<{report: {status: 'checked', findings: [], unchecked: Array<{file: string, name: string, reason: string}>}, notes: Note[]}>}
|
|
1384
|
+
*/
|
|
1385
|
+
export async function componentDocNamesCheck(integration) {
|
|
1386
|
+
/** @type {Array<{docPath: string, name: string, message: string}>} */
|
|
1387
|
+
const findings = [];
|
|
1388
|
+
/** @type {Array<{docPath: string, name: string, reason: string}>} */
|
|
1389
|
+
const unchecked = [];
|
|
1390
|
+
const root = integration.components;
|
|
1391
|
+
if (root && !rootProblem(root)) {
|
|
1392
|
+
try {
|
|
1393
|
+
const {components} =
|
|
1394
|
+
await componentDiscovery.discoverValidIntegrationComponents(
|
|
1395
|
+
integration,
|
|
1396
|
+
);
|
|
1397
|
+
await judgeComponentDocNames(
|
|
1398
|
+
integration,
|
|
1399
|
+
components,
|
|
1400
|
+
root,
|
|
1401
|
+
componentDocFiles(root).caseMismatch,
|
|
1402
|
+
findings,
|
|
1403
|
+
unchecked,
|
|
1404
|
+
);
|
|
1405
|
+
} catch {
|
|
1406
|
+
// A components root discovery cannot read is reported as an issue.
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
const base = integration.__packageDir ?? root ?? '';
|
|
1410
|
+
return {
|
|
1411
|
+
report: {
|
|
1412
|
+
status: 'checked',
|
|
1413
|
+
findings: [],
|
|
1414
|
+
unchecked: unchecked.map(entry => ({
|
|
1415
|
+
file: slash(path.relative(base, entry.docPath)),
|
|
1416
|
+
name: entry.name,
|
|
1417
|
+
reason: entry.reason,
|
|
1418
|
+
})),
|
|
1419
|
+
},
|
|
1420
|
+
notes: findings.map(entry => note('doc_name_mismatch', entry)),
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
|
|
399
1424
|
/**
|
|
400
1425
|
* Validate the integration's doc topics via the landed discovery. A doc that
|
|
401
1426
|
* cannot be loaded, is not a usable topic, or collides with a sibling is
|
|
@@ -431,18 +1456,123 @@ async function checkDocs(integration, issues) {
|
|
|
431
1456
|
async function checkThemes(integration, issues) {
|
|
432
1457
|
if (!integration.themes || rootProblem(integration.themes)) return;
|
|
433
1458
|
try {
|
|
1459
|
+
// A theme may name itself differently from its folder (its built files
|
|
1460
|
+
// then carry that name), so a name that differs is no finding here:
|
|
1461
|
+
// verify judges it against the files the package exports.
|
|
434
1462
|
await discoverIntegrationThemes(integration);
|
|
435
1463
|
} catch (err) {
|
|
436
1464
|
issues.push(issueError('invalid_theme', /** @type {any} */ (err).message));
|
|
437
1465
|
}
|
|
438
1466
|
}
|
|
439
1467
|
|
|
1468
|
+
/**
|
|
1469
|
+
* The `name` a theme source passes to defineTheme for its exported theme,
|
|
1470
|
+
* when the file says it as a string literal: `export const oceanTheme =
|
|
1471
|
+
* defineTheme({name: 'ocean', ...})`. Null whenever only running the module
|
|
1472
|
+
* could tell (a variable, a factory, a spread after the name, `extends`
|
|
1473
|
+
* alone), so nothing is judged that a build would decide.
|
|
1474
|
+
* @param {string} file
|
|
1475
|
+
* @param {string} exportName
|
|
1476
|
+
* @returns {string | null}
|
|
1477
|
+
*/
|
|
1478
|
+
export function literalThemeName(file, exportName) {
|
|
1479
|
+
let ast;
|
|
1480
|
+
try {
|
|
1481
|
+
ast = parseModule(fs.readFileSync(file, 'utf8'), {
|
|
1482
|
+
sourceType: 'module',
|
|
1483
|
+
plugins: ['typescript', 'jsx'],
|
|
1484
|
+
errorRecovery: true,
|
|
1485
|
+
});
|
|
1486
|
+
} catch {
|
|
1487
|
+
return null;
|
|
1488
|
+
}
|
|
1489
|
+
/** @type {any} */
|
|
1490
|
+
let init = null;
|
|
1491
|
+
for (const node of ast.program.body) {
|
|
1492
|
+
const declaration =
|
|
1493
|
+
node.type === 'ExportNamedDeclaration' ? node.declaration : node;
|
|
1494
|
+
if (declaration?.type !== 'VariableDeclaration') continue;
|
|
1495
|
+
for (const declarator of declaration.declarations) {
|
|
1496
|
+
if (
|
|
1497
|
+
declarator.id.type === 'Identifier' &&
|
|
1498
|
+
declarator.id.name === exportName
|
|
1499
|
+
) {
|
|
1500
|
+
init = declarator.init;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
while (
|
|
1505
|
+
init?.type === 'TSAsExpression' ||
|
|
1506
|
+
init?.type === 'TSSatisfiesExpression'
|
|
1507
|
+
) {
|
|
1508
|
+
init = init.expression;
|
|
1509
|
+
}
|
|
1510
|
+
if (init?.type !== 'CallExpression') return null;
|
|
1511
|
+
const callee =
|
|
1512
|
+
init.callee.type === 'Identifier'
|
|
1513
|
+
? init.callee.name
|
|
1514
|
+
: init.callee.type === 'MemberExpression' &&
|
|
1515
|
+
init.callee.property.type === 'Identifier'
|
|
1516
|
+
? init.callee.property.name
|
|
1517
|
+
: null;
|
|
1518
|
+
if (callee !== 'defineTheme') return null;
|
|
1519
|
+
const [config] = init.arguments;
|
|
1520
|
+
if (config?.type !== 'ObjectExpression') return null;
|
|
1521
|
+
/** @type {string | null} */
|
|
1522
|
+
let name = null;
|
|
1523
|
+
for (const property of config.properties) {
|
|
1524
|
+
if (property.type === 'SpreadElement') {
|
|
1525
|
+
name = null;
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
if (property.type !== 'ObjectProperty' || property.computed) continue;
|
|
1529
|
+
const key =
|
|
1530
|
+
property.key.type === 'Identifier'
|
|
1531
|
+
? property.key.name
|
|
1532
|
+
: property.key.type === 'StringLiteral'
|
|
1533
|
+
? property.key.value
|
|
1534
|
+
: null;
|
|
1535
|
+
if (key !== 'name') continue;
|
|
1536
|
+
name =
|
|
1537
|
+
property.value.type === 'StringLiteral' ? property.value.value : null;
|
|
1538
|
+
}
|
|
1539
|
+
return name;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* A warning for each contribution file a reader reads (the docs and sources
|
|
1544
|
+
* `integration verify` expects in the tarball) that the user running astryx
|
|
1545
|
+
* cannot read; files no reader takes, such as `.DS_Store`, are not looked at: Astryx cannot check it, and npm pack stops on it. A
|
|
1546
|
+
* warning, so validate keeps its exit code; `integration verify` fails on the
|
|
1547
|
+
* pack itself.
|
|
1548
|
+
* @param {LoadedIntegration} integration
|
|
1549
|
+
* @param {Issue[]} issues
|
|
1550
|
+
*/
|
|
1551
|
+
function checkUnreadableFiles(integration, issues) {
|
|
1552
|
+
const packageDir = integration.__packageDir;
|
|
1553
|
+
if (!packageDir) return;
|
|
1554
|
+
for (const shown of contributionFiles(integration, packageDir)) {
|
|
1555
|
+
try {
|
|
1556
|
+
fs.accessSync(path.join(packageDir, shown), fs.constants.R_OK);
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
const code = String(/** @type {any} */ (err)?.code ?? 'unknown error');
|
|
1559
|
+
issues.push(
|
|
1560
|
+
issueListed(
|
|
1561
|
+
'unreadable_file',
|
|
1562
|
+
`Could not read "${shown}" (${code}), so Astryx cannot check it and npm pack fails on it. Fix: make it readable (chmod a+r ${shown}), or delete it if it does not belong in the package.`,
|
|
1563
|
+
),
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
440
1569
|
/**
|
|
441
1570
|
* Run every contribution validator against a loaded-integration-shaped object.
|
|
442
1571
|
* @param {LoadedIntegration} integration
|
|
443
1572
|
* @param {Issue[]} issues
|
|
444
1573
|
*/
|
|
445
1574
|
async function runContributionChecks(integration, issues) {
|
|
1575
|
+
checkUnreadableFiles(integration, issues);
|
|
446
1576
|
await checkCodemods(integration, issues);
|
|
447
1577
|
await checkTemplates(integration, issues);
|
|
448
1578
|
await checkComponents(integration, issues);
|
|
@@ -461,6 +1591,29 @@ async function runContributionChecks(integration, issues) {
|
|
|
461
1591
|
* @returns {Promise<Issue[]>}
|
|
462
1592
|
*/
|
|
463
1593
|
export async function validateLoadedIntegration(loaded) {
|
|
1594
|
+
return (await validateLoadedIntegrationListed(loaded)).issues;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Every contribution check, split: `issues` as validateLoadedIntegration
|
|
1599
|
+
* returns them, and `listed`, the layout findings validate lists in its
|
|
1600
|
+
* `layout` report without counting them.
|
|
1601
|
+
* @param {LoadedIntegration} loaded
|
|
1602
|
+
* @returns {Promise<{issues: Issue[], listed: Issue[]}>}
|
|
1603
|
+
*/
|
|
1604
|
+
export async function validateLoadedIntegrationListed(loaded) {
|
|
1605
|
+
const all = await allLoadedIntegrationIssues(loaded);
|
|
1606
|
+
return {
|
|
1607
|
+
issues: all.filter(issue => !LISTED.has(issue)),
|
|
1608
|
+
listed: all.filter(issue => LISTED.has(issue)),
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
/**
|
|
1613
|
+
* @param {LoadedIntegration} loaded
|
|
1614
|
+
* @returns {Promise<Issue[]>}
|
|
1615
|
+
*/
|
|
1616
|
+
async function allLoadedIntegrationIssues(loaded) {
|
|
464
1617
|
/** @type {Issue[]} */
|
|
465
1618
|
const issues = [];
|
|
466
1619
|
if (!loaded || typeof loaded !== 'object') return issues;
|
|
@@ -497,8 +1650,10 @@ export async function validateLoadedIntegration(loaded) {
|
|
|
497
1650
|
codemods: loaded.codemods,
|
|
498
1651
|
docs: loaded.docs,
|
|
499
1652
|
themes: loaded.themes,
|
|
1653
|
+
__manifestFile: loaded.__manifestFile,
|
|
500
1654
|
},
|
|
501
1655
|
issues,
|
|
1656
|
+
loaded.__packageDir,
|
|
502
1657
|
);
|
|
503
1658
|
await runContributionChecks(loaded, issues);
|
|
504
1659
|
return issues;
|