@astryxdesign/cli 0.1.7-canary.cecb773 → 0.1.7-canary.d91c851
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/package.json +9 -9
- package/src/codemods/__tests__/registry.test.mjs +1 -0
- package/src/codemods/registry.mjs +1 -0
- package/src/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +42 -7
- package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +43 -0
- package/src/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +102 -3
- package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +72 -6
- package/src/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +161 -0
- package/src/codemods/transforms/v0.1.8/index.mjs +19 -0
- package/src/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +234 -0
- package/src/commands/agent-docs.mjs +72 -0
- package/src/commands/agent-docs.test.mjs +86 -0
- package/src/commands/upgrade.mjs +130 -27
- package/src/commands/upgrade.test.mjs +154 -0
- package/src/types/upgrade.d.ts +39 -2
- package/templates/blocks/components/Avatar/AvatarFallbackChain.tsx +4 -4
- package/templates/blocks/components/Avatar/AvatarGroup.tsx +2 -2
- package/templates/blocks/components/Avatar/AvatarInitialsFallback.tsx +1 -1
- package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -4
- package/templates/blocks/components/Avatar/AvatarUserCard.tsx +1 -1
- package/templates/blocks/components/Avatar/AvatarWithImage.tsx +4 -4
- package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -3
- package/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.tsx +2 -2
- package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowCustomText.tsx +1 -1
- package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowDefault.tsx +1 -1
- package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowShowcase.tsx +2 -2
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotShowcase.tsx +3 -3
- package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +3 -3
- package/templates/blocks/components/Carousel/CarouselSnap.tsx +1 -1
- package/templates/blocks/components/ChatMessage/ChatMessageAvatarName.tsx +2 -2
- package/templates/blocks/components/ChatMessage/ChatMessageMultiBubble.tsx +1 -1
- package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleGrouping.tsx +1 -1
- package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleMetadata.tsx +1 -1
- package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +5 -9
- package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -1
- package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -1
- package/templates/blocks/components/Item/ItemWithMedia.tsx +2 -2
- package/templates/blocks/components/ListItem/ListItemWithMedia.tsx +2 -2
- package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -6
- package/templates/blocks/components/Stack/StackFillItem.tsx +2 -6
- package/templates/blocks/components/TypeaheadItem/TypeaheadItemShowcase.tsx +1 -1
- package/templates/pages/ai-chat/page.tsx +4 -4
- package/templates/pages/dashboard-portfolio/page.tsx +3 -11
- package/templates/pages/detail-page/page.tsx +5 -12
- package/templates/pages/documentation-design/page.tsx +1 -1
- package/templates/pages/messaging-shell/page.tsx +6 -8
- package/templates/pages/table-grouped/page.tsx +9 -14
- package/templates/pages/table-page/page.tsx +7 -9
- package/templates/pages/table-page-heatmap-status/page.tsx +5 -13
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Codemod: Rename Avatar named sizes to Icon's abbreviated scale
|
|
5
|
+
* @see https://github.com/facebook/astryx/issues/2672
|
|
6
|
+
*
|
|
7
|
+
* As of v0.1.8, Avatar and AvatarGroup use the same abbreviated size scale as
|
|
8
|
+
* Icon (`xsm`/`sm`/`md`/`lg`/`xl`) instead of full words. Pixel values are
|
|
9
|
+
* unchanged — only the names move:
|
|
10
|
+
*
|
|
11
|
+
* tiny (20px) → xsm
|
|
12
|
+
* xsmall (24px) → sm
|
|
13
|
+
* small (36px) → md ← also the new default (was `small`)
|
|
14
|
+
* medium (48px) → lg
|
|
15
|
+
* large (128px) → xl
|
|
16
|
+
*
|
|
17
|
+
* The default size shifts from `small` to `md`, but that is the SAME 36px, so
|
|
18
|
+
* call sites that relied on the default need no change.
|
|
19
|
+
*
|
|
20
|
+
* ## Precision
|
|
21
|
+
*
|
|
22
|
+
* `small`, `medium`, and `large` are common English words that appear as
|
|
23
|
+
* unrelated string/type literals (priorities, statuses, breakpoints, …). To
|
|
24
|
+
* avoid corrupting those, the three AMBIGUOUS names are renamed ONLY in a
|
|
25
|
+
* precise context: a `size` JSX attribute on a component that resolves to an
|
|
26
|
+
* Avatar/AvatarGroup import (alias-aware).
|
|
27
|
+
*
|
|
28
|
+
* `tiny` and `xsmall` are unique to Avatar's scale, so they are additionally
|
|
29
|
+
* renamed in context-blind positions (object properties keyed `size`/`*Size`,
|
|
30
|
+
* Storybook `options` arrays, and size-typed union literals) within files that
|
|
31
|
+
* import Avatar/AvatarGroup.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const meta = {
|
|
35
|
+
title: 'Rename Avatar named sizes tiny/xsmall/small/medium/large → xsm/sm/md/lg/xl',
|
|
36
|
+
description:
|
|
37
|
+
'Avatar and AvatarGroup adopt Icon\'s abbreviated size scale ' +
|
|
38
|
+
'(xsm/sm/md/lg/xl). Pixel values are unchanged; the default moves from ' +
|
|
39
|
+
'`small` to `md` (both 36px). The ambiguous names small/medium/large are ' +
|
|
40
|
+
'only renamed on Avatar/AvatarGroup `size` JSX attributes; the unique ' +
|
|
41
|
+
'names tiny/xsmall are also renamed in size-keyed props, Storybook ' +
|
|
42
|
+
'options, and union types in files importing Avatar/AvatarGroup.',
|
|
43
|
+
pr: '#2672',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Import sources that provide the Astryx Avatar/AvatarGroup components. */
|
|
47
|
+
const IMPORT_SOURCES = new Set([
|
|
48
|
+
'@astryxdesign/core',
|
|
49
|
+
'@astryxdesign/core/Avatar',
|
|
50
|
+
'@astryxdesign/core/AvatarGroup',
|
|
51
|
+
'@xds/core',
|
|
52
|
+
'@xds/core/Avatar',
|
|
53
|
+
'@xds/core/AvatarGroup',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Component names whose `size` prop is renamed. */
|
|
57
|
+
const TARGET_IMPORTED_NAMES = new Set(['Avatar', 'AvatarGroup']);
|
|
58
|
+
|
|
59
|
+
/** Old → new size-name mapping. */
|
|
60
|
+
const RENAMES = new Map([
|
|
61
|
+
['tiny', 'xsm'],
|
|
62
|
+
['xsmall', 'sm'],
|
|
63
|
+
['small', 'md'],
|
|
64
|
+
['medium', 'lg'],
|
|
65
|
+
['large', 'xl'],
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ambiguous names — common words that must ONLY be renamed in a precise
|
|
70
|
+
* context. `tiny`/`xsmall` are omitted because they are unique to Avatar's
|
|
71
|
+
* scale; their presence in a collection proves it is an Avatar size enum.
|
|
72
|
+
*/
|
|
73
|
+
const AMBIGUOUS = new Set(['small', 'medium', 'large']);
|
|
74
|
+
|
|
75
|
+
/** Names that appear only in Avatar's scale — safe signals of Avatar sizing. */
|
|
76
|
+
const UNIQUE = new Set(['tiny', 'xsmall']);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Rename a string-literal node if its value is an old size name.
|
|
80
|
+
* Unwraps `'small' as const`. In non-precise contexts, ambiguous names are
|
|
81
|
+
* skipped. Returns true when a rename occurred.
|
|
82
|
+
*/
|
|
83
|
+
function renameValue(node, {precise = false} = {}) {
|
|
84
|
+
if (!node) return false;
|
|
85
|
+
const target = node.type === 'TSAsExpression' ? node.expression : node;
|
|
86
|
+
const isString =
|
|
87
|
+
target.type === 'StringLiteral' || target.type === 'Literal';
|
|
88
|
+
if (!isString || typeof target.value !== 'string') return false;
|
|
89
|
+
const replacement = RENAMES.get(target.value);
|
|
90
|
+
if (!replacement) return false;
|
|
91
|
+
if (!precise && AMBIGUOUS.has(target.value)) return false;
|
|
92
|
+
target.value = replacement;
|
|
93
|
+
if (target.raw) target.raw = undefined;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Extract a plain string value from a literal-ish node, or null. */
|
|
98
|
+
function literalString(node) {
|
|
99
|
+
const target = node?.type === 'TSAsExpression' ? node.expression : node;
|
|
100
|
+
if (
|
|
101
|
+
target &&
|
|
102
|
+
(target.type === 'StringLiteral' || target.type === 'Literal') &&
|
|
103
|
+
typeof target.value === 'string'
|
|
104
|
+
) {
|
|
105
|
+
return target.value;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default function transformer(file, api) {
|
|
111
|
+
const j = api.jscodeshift;
|
|
112
|
+
const root = j(file.source);
|
|
113
|
+
let hasChanges = false;
|
|
114
|
+
|
|
115
|
+
// Track alias-aware local names for the target components.
|
|
116
|
+
const targetLocals = new Set();
|
|
117
|
+
root.find(j.ImportDeclaration).forEach((path) => {
|
|
118
|
+
if (!IMPORT_SOURCES.has(path.node.source.value)) return;
|
|
119
|
+
for (const spec of path.node.specifiers ?? []) {
|
|
120
|
+
if (
|
|
121
|
+
spec.type === 'ImportSpecifier' &&
|
|
122
|
+
TARGET_IMPORTED_NAMES.has(spec.imported.name)
|
|
123
|
+
) {
|
|
124
|
+
targetLocals.add(spec.local.name);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (targetLocals.size === 0) return undefined;
|
|
130
|
+
|
|
131
|
+
function renameArrayElements(node) {
|
|
132
|
+
let arr = node;
|
|
133
|
+
if (arr.type === 'TSAsExpression') arr = arr.expression;
|
|
134
|
+
if (arr.type !== 'ArrayExpression') return;
|
|
135
|
+
// If the array contains a name unique to Avatar's scale (tiny/xsmall), the
|
|
136
|
+
// whole collection is unambiguously the Avatar size enum, so it is safe to
|
|
137
|
+
// rename the ambiguous members too. Otherwise stay conservative.
|
|
138
|
+
const precise = arr.elements.some((el) => UNIQUE.has(literalString(el)));
|
|
139
|
+
for (const el of arr.elements) {
|
|
140
|
+
if (renameValue(el, {precise})) hasChanges = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 1. `size` JSX attributes on target components — PRECISE (component known),
|
|
145
|
+
// so all five names, including the ambiguous ones, are renamed here.
|
|
146
|
+
root.find(j.JSXOpeningElement).forEach((path) => {
|
|
147
|
+
const name = path.node.name;
|
|
148
|
+
const componentName = name.type === 'JSXIdentifier' ? name.name : null;
|
|
149
|
+
if (!componentName || !targetLocals.has(componentName)) return;
|
|
150
|
+
|
|
151
|
+
for (const attr of path.node.attributes) {
|
|
152
|
+
if (attr.type !== 'JSXAttribute' || attr.name?.name !== 'size') continue;
|
|
153
|
+
const value = attr.value;
|
|
154
|
+
|
|
155
|
+
// size="small"
|
|
156
|
+
if (renameValue(value, {precise: true})) {
|
|
157
|
+
hasChanges = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
value &&
|
|
162
|
+
value.type === 'JSXExpressionContainer' &&
|
|
163
|
+
value.expression
|
|
164
|
+
) {
|
|
165
|
+
// size={'small'}
|
|
166
|
+
if (renameValue(value.expression, {precise: true})) {
|
|
167
|
+
hasChanges = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
// size={cond ? 'small' : 'large'}
|
|
171
|
+
if (value.expression.type === 'ConditionalExpression') {
|
|
172
|
+
if (renameValue(value.expression.consequent, {precise: true}))
|
|
173
|
+
hasChanges = true;
|
|
174
|
+
if (renameValue(value.expression.alternate, {precise: true}))
|
|
175
|
+
hasChanges = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// 2. Object properties keyed `size` (or `*Size`) + Storybook `options`
|
|
182
|
+
// arrays — NON-PRECISE (the component isn't known here), so only the
|
|
183
|
+
// unique names tiny/xsmall are renamed.
|
|
184
|
+
const PropertyType = j.ObjectProperty ?? j.Property;
|
|
185
|
+
root.find(PropertyType).forEach((path) => {
|
|
186
|
+
const key = path.node.key;
|
|
187
|
+
const keyName =
|
|
188
|
+
key.type === 'Identifier'
|
|
189
|
+
? key.name
|
|
190
|
+
: key.type === 'StringLiteral' || key.type === 'Literal'
|
|
191
|
+
? key.value
|
|
192
|
+
: null;
|
|
193
|
+
if (typeof keyName !== 'string') return;
|
|
194
|
+
|
|
195
|
+
const lower = keyName.toLowerCase();
|
|
196
|
+
if (lower !== 'size' && !lower.endsWith('size')) return;
|
|
197
|
+
|
|
198
|
+
const value = path.node.value;
|
|
199
|
+
// size: 'tiny'
|
|
200
|
+
if (renameValue(value)) {
|
|
201
|
+
hasChanges = true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// size: { control: 'select', options: ['tiny', ...] }
|
|
205
|
+
if (value.type === 'ObjectExpression') {
|
|
206
|
+
const optionsProp = value.properties.find(
|
|
207
|
+
(p) => p.key && (p.key.name === 'options' || p.key.value === 'options'),
|
|
208
|
+
);
|
|
209
|
+
if (optionsProp) renameArrayElements(optionsProp.value);
|
|
210
|
+
}
|
|
211
|
+
// size: ['tiny', 'xsmall', ...]
|
|
212
|
+
renameArrayElements(value);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// 3. Union-type literals — NON-PRECISE, so only the unique names tiny/xsmall
|
|
216
|
+
// are renamed. This avoids corrupting unrelated unions that happen to use
|
|
217
|
+
// common words like 'medium' (e.g. a priority or breakpoint type) in a
|
|
218
|
+
// file that also imports Avatar.
|
|
219
|
+
root.find(j.TSLiteralType).forEach((path) => {
|
|
220
|
+
if (renameValue(path.node.literal)) hasChanges = true;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// 4. Standalone array literals whose members are exactly the Avatar size
|
|
224
|
+
// scale (identified by the presence of a unique name like tiny/xsmall) —
|
|
225
|
+
// e.g. `(['tiny', 'xsmall', 'small', 'medium', 'large'] as const).map(...)`
|
|
226
|
+
// used to render every size. The unique name proves the whole array is the
|
|
227
|
+
// Avatar enum, so its ambiguous members can be renamed safely.
|
|
228
|
+
root.find(j.ArrayExpression).forEach((path) => {
|
|
229
|
+
renameArrayElements(path.node);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (!hasChanges) return undefined;
|
|
233
|
+
return root.toSource({quote: 'single'});
|
|
234
|
+
}
|
|
@@ -104,6 +104,78 @@ export function isAstryxInitialized(targetDir = process.cwd()) {
|
|
|
104
104
|
return false;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Parse the Astryx version a managed block was generated for, from its header
|
|
109
|
+
* line ("Astryx v1.2.3 · N components"). Returns null when the block predates
|
|
110
|
+
* the versioned header (e.g. a legacy XDS block) or has no header at all.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} content File contents, or just the block text.
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
export function parseBlockVersion(content) {
|
|
116
|
+
const match = /Astryx v(\d+\.\d+\.\d+[^\s·]*)/.exec(content ?? '');
|
|
117
|
+
return match ? match[1] : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read-only staleness assessment of the managed agent-docs block(s) against the
|
|
122
|
+
* installed core version. This is the detection half of the `astryx upgrade`
|
|
123
|
+
* agent-docs refresh: it never writes, so `upgrade` can run it on EVERY path —
|
|
124
|
+
* including the up-to-date / no-codemods short-circuits — and decide whether to
|
|
125
|
+
* rewrite a stale block, nudge an uninitialized repo, or stay silent.
|
|
126
|
+
*
|
|
127
|
+
* A managed block is:
|
|
128
|
+
* - `stale` — it carries a legacy XDS marker, has no parseable version, or
|
|
129
|
+
* records a version other than the installed one.
|
|
130
|
+
* - `current` — every managed block already matches the installed version.
|
|
131
|
+
* And the project is `missing` when no managed block exists anywhere (the repo
|
|
132
|
+
* has agent-doc files without our markers, or none at all — i.e. never `init`ed).
|
|
133
|
+
*
|
|
134
|
+
* @param {string} targetDir
|
|
135
|
+
* @param {string} [installedVersion] Defaults to the installed core version.
|
|
136
|
+
* @returns {{
|
|
137
|
+
* installedVersion: string,
|
|
138
|
+
* status: 'missing' | 'stale' | 'current',
|
|
139
|
+
* files: Array<{path: string, blockVersion: string|null, legacy: boolean, stale: boolean}>,
|
|
140
|
+
* staleFiles: string[],
|
|
141
|
+
* blockVersions: string[],
|
|
142
|
+
* }}
|
|
143
|
+
*/
|
|
144
|
+
export function inspectAgentDocs(targetDir, installedVersion) {
|
|
145
|
+
const version = installedVersion ?? getXdsVersion(findCoreDir(targetDir));
|
|
146
|
+
const files = [];
|
|
147
|
+
|
|
148
|
+
for (const rel of discoverAgentDocs(targetDir)) {
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
content = fs.readFileSync(path.join(targetDir, rel), 'utf-8');
|
|
152
|
+
} catch {
|
|
153
|
+
continue; // Unreadable — treat as absent.
|
|
154
|
+
}
|
|
155
|
+
const hasNew = content.includes(MARKER_START);
|
|
156
|
+
const hasLegacy = content.includes(LEGACY_MARKER_START);
|
|
157
|
+
if (!hasNew && !hasLegacy) continue; // Not a block we manage.
|
|
158
|
+
|
|
159
|
+
const legacy = !hasNew && hasLegacy;
|
|
160
|
+
const blockVersion = parseBlockVersion(content);
|
|
161
|
+
const stale = legacy || blockVersion == null || blockVersion !== version;
|
|
162
|
+
files.push({path: rel, blockVersion, legacy, stale});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const staleEntries = files.filter(f => f.stale);
|
|
166
|
+
const staleFiles = staleEntries.map(f => f.path);
|
|
167
|
+
const blockVersions = [
|
|
168
|
+
...new Set(staleEntries.map(f => f.blockVersion).filter(Boolean)),
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
let status;
|
|
172
|
+
if (files.length === 0) status = 'missing';
|
|
173
|
+
else if (staleFiles.length > 0) status = 'stale';
|
|
174
|
+
else status = 'current';
|
|
175
|
+
|
|
176
|
+
return {installedVersion: version, status, files, staleFiles, blockVersions};
|
|
177
|
+
}
|
|
178
|
+
|
|
107
179
|
/**
|
|
108
180
|
* Resolve which file(s) to write for a given agent tool preset.
|
|
109
181
|
* Searches for existing files first, falls back to default creation path.
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
removeXdsBlock,
|
|
17
17
|
discoverAgentDocs,
|
|
18
18
|
resolveAgentPaths,
|
|
19
|
+
parseBlockVersion,
|
|
20
|
+
inspectAgentDocs,
|
|
19
21
|
} from './agent-docs.mjs';
|
|
20
22
|
|
|
21
23
|
let tmpDir;
|
|
@@ -609,3 +611,87 @@ describe('resolveAgentPaths', () => {
|
|
|
609
611
|
expect(result).toEqual({inject: [], create: ['.claude/CLAUDE.md']});
|
|
610
612
|
});
|
|
611
613
|
});
|
|
614
|
+
|
|
615
|
+
describe('parseBlockVersion', () => {
|
|
616
|
+
it('reads the version from the header of a generated block', () => {
|
|
617
|
+
expect(parseBlockVersion(generateCompressedIndex('1.2.3'))).toBe('1.2.3');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('reads prerelease versions', () => {
|
|
621
|
+
expect(parseBlockVersion('Astryx v1.2.3-beta.4 · 10 components')).toBe(
|
|
622
|
+
'1.2.3-beta.4',
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('returns null when no versioned header is present', () => {
|
|
627
|
+
expect(parseBlockVersion('<!-- XDS:START -->\nold\n<!-- XDS:END -->')).toBeNull();
|
|
628
|
+
expect(parseBlockVersion('')).toBeNull();
|
|
629
|
+
expect(parseBlockVersion(undefined)).toBeNull();
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
describe('inspectAgentDocs', () => {
|
|
634
|
+
function writeBlock(rel, version) {
|
|
635
|
+
const filePath = path.join(tmpDir, rel);
|
|
636
|
+
fs.mkdirSync(path.dirname(filePath), {recursive: true});
|
|
637
|
+
fs.writeFileSync(filePath, `# Doc\n\n${generateCompressedIndex(version)}\n`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
it('reports missing when no agent docs exist at all', () => {
|
|
641
|
+
const res = inspectAgentDocs(tmpDir, '1.0.0');
|
|
642
|
+
expect(res.status).toBe('missing');
|
|
643
|
+
expect(res.files).toEqual([]);
|
|
644
|
+
expect(res.staleFiles).toEqual([]);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('reports missing when agent files exist but carry no Astryx marker', () => {
|
|
648
|
+
fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n\nHand-written notes.\n');
|
|
649
|
+
expect(inspectAgentDocs(tmpDir, '1.0.0').status).toBe('missing');
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('reports current when the only block matches the installed version', () => {
|
|
653
|
+
writeBlock('AGENTS.md', '1.0.0');
|
|
654
|
+
const res = inspectAgentDocs(tmpDir, '1.0.0');
|
|
655
|
+
expect(res.status).toBe('current');
|
|
656
|
+
expect(res.staleFiles).toEqual([]);
|
|
657
|
+
expect(res.blockVersions).toEqual([]);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('reports stale (with the old version) when the block is behind', () => {
|
|
661
|
+
writeBlock('AGENTS.md', '1.0.0');
|
|
662
|
+
const res = inspectAgentDocs(tmpDir, '2.0.0');
|
|
663
|
+
expect(res.status).toBe('stale');
|
|
664
|
+
expect(res.staleFiles).toEqual(['AGENTS.md']);
|
|
665
|
+
expect(res.blockVersions).toEqual(['1.0.0']);
|
|
666
|
+
expect(res.installedVersion).toBe('2.0.0');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('treats a legacy XDS block as stale even with no parseable version', () => {
|
|
670
|
+
fs.writeFileSync(
|
|
671
|
+
path.join(tmpDir, 'CLAUDE.md'),
|
|
672
|
+
'# Claude\n\n<!-- XDS:START -->\nlegacy index\n<!-- XDS:END -->\n',
|
|
673
|
+
);
|
|
674
|
+
const res = inspectAgentDocs(tmpDir, '1.0.0');
|
|
675
|
+
expect(res.status).toBe('stale');
|
|
676
|
+
expect(res.files[0].legacy).toBe(true);
|
|
677
|
+
expect(res.blockVersions).toEqual([]);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('is stale if ANY marked file is behind (mixed current + stale)', () => {
|
|
681
|
+
writeBlock('AGENTS.md', '2.0.0');
|
|
682
|
+
writeBlock('CLAUDE.md', '1.0.0');
|
|
683
|
+
const res = inspectAgentDocs(tmpDir, '2.0.0');
|
|
684
|
+
expect(res.status).toBe('stale');
|
|
685
|
+
expect(res.staleFiles).toEqual(['CLAUDE.md']);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('defaults the installed version to the core package when omitted', () => {
|
|
689
|
+
const coreDir = path.join(tmpDir, 'node_modules', '@astryxdesign', 'core');
|
|
690
|
+
fs.mkdirSync(coreDir, {recursive: true});
|
|
691
|
+
fs.writeFileSync(path.join(coreDir, 'package.json'), JSON.stringify({version: '3.0.0'}));
|
|
692
|
+
writeBlock('AGENTS.md', '3.0.0');
|
|
693
|
+
const res = inspectAgentDocs(tmpDir);
|
|
694
|
+
expect(res.installedVersion).toBe('3.0.0');
|
|
695
|
+
expect(res.status).toBe('current');
|
|
696
|
+
});
|
|
697
|
+
});
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Pipeline (--apply):
|
|
13
13
|
* 1. Read installed @astryxdesign/core (or legacy @xds/core) version
|
|
14
|
-
* 2.
|
|
15
|
-
*
|
|
14
|
+
* 2. Refresh the managed agent-docs block (AGENTS.md / CLAUDE.md) to the
|
|
15
|
+
* installed version — runs on EVERY path (including the up-to-date /
|
|
16
|
+
* no-codemods short-circuits), because the block documents the installed
|
|
17
|
+
* library, not the codemod outcome. Dry-run reports without writing.
|
|
18
|
+
* 3. Run codemods for --from → installed version
|
|
16
19
|
*
|
|
17
20
|
* Options:
|
|
18
21
|
* --from <version> Previous version before the dependency upgrade
|
|
@@ -46,7 +49,7 @@ import {
|
|
|
46
49
|
selectIntegrationCodemods,
|
|
47
50
|
} from '../codemods/integration-discovery.mjs';
|
|
48
51
|
import {runIntegrationCodemods} from '../codemods/integration-runner.mjs';
|
|
49
|
-
import {installAgentDocs,
|
|
52
|
+
import {installAgentDocs, inspectAgentDocs} from './agent-docs.mjs';
|
|
50
53
|
import {getCliInvocation, formatCliCommand} from '../utils/package-manager.mjs';
|
|
51
54
|
import {isValidSemver, semverGte} from '../utils/semver.mjs';
|
|
52
55
|
import {jsonOut, jsonError} from '../lib/json.mjs';
|
|
@@ -135,6 +138,103 @@ async function runPostCodemodHooks(hooks, context, silent) {
|
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Refresh (or, in dry-run, report) the managed agent-docs block after a version
|
|
143
|
+
* bump. The block (`<!-- ASTRYX:START --> … <!-- ASTRYX:END -->`) documents the
|
|
144
|
+
* INSTALLED library — its version, component index, and agent rules — so it must
|
|
145
|
+
* be re-synced on EVERY upgrade path, including the up-to-date / no-codemods
|
|
146
|
+
* short-circuits where no source file changes. That was the gap in #4168: an
|
|
147
|
+
* agent reading a stale index queries missing components and follows superseded
|
|
148
|
+
* rules. Three cases, one detection pass:
|
|
149
|
+
*
|
|
150
|
+
* - `stale` — a managed block records an older version (or a legacy XDS
|
|
151
|
+
* marker). `--apply` rewrites it; dry-run reports the pending
|
|
152
|
+
* refresh as a loud next step and writes nothing.
|
|
153
|
+
* - `missing` — core is installed but no managed block exists anywhere (the repo
|
|
154
|
+
* never ran `init`, or its agent docs were never marked). We never
|
|
155
|
+
* silently create docs mid-upgrade; we nudge to run `init`.
|
|
156
|
+
* - `current` — every block already matches the installed version: stay silent.
|
|
157
|
+
*
|
|
158
|
+
* @param {{cwd: string, installedVersion: string, apply: boolean, json: boolean}} ctx
|
|
159
|
+
* @returns {import('../types/upgrade').AgentDocsSummary}
|
|
160
|
+
*/
|
|
161
|
+
export function refreshAgentDocs({cwd, installedVersion, apply, json}) {
|
|
162
|
+
const inspection = inspectAgentDocs(cwd, installedVersion);
|
|
163
|
+
/** @type {import('../types/upgrade').AgentDocsSummary} */
|
|
164
|
+
const summary = {
|
|
165
|
+
status: inspection.status,
|
|
166
|
+
installedVersion,
|
|
167
|
+
fromVersions: inspection.blockVersions,
|
|
168
|
+
files: [],
|
|
169
|
+
refreshed: false,
|
|
170
|
+
action: 'none',
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Never initialized — don't silently create docs during an upgrade; nudge.
|
|
174
|
+
if (inspection.status === 'missing') {
|
|
175
|
+
summary.action = 'nudge-init';
|
|
176
|
+
if (!json) {
|
|
177
|
+
p.log.warn(
|
|
178
|
+
`No Astryx agent-docs block found — AI agents have no component index. Run \`${formatCliCommand('astryx init --features agents')}\` to install it.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return summary;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (inspection.status === 'current') return summary;
|
|
185
|
+
|
|
186
|
+
// Stale.
|
|
187
|
+
summary.files = inspection.staleFiles;
|
|
188
|
+
const fromLabel = summary.fromVersions.length
|
|
189
|
+
? `v${summary.fromVersions.join(', v')}`
|
|
190
|
+
: 'an unknown version';
|
|
191
|
+
|
|
192
|
+
if (!apply) {
|
|
193
|
+
// Dry-run: report the pending change, never write.
|
|
194
|
+
summary.action = 'would-refresh';
|
|
195
|
+
if (!json) {
|
|
196
|
+
p.log.warn(
|
|
197
|
+
`Agent docs are stale: block is at ${fromLabel}, installed is v${installedVersion}. Re-run with --apply to refresh (${summary.files.join(', ')}).`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return summary;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Apply: rewrite only files that already carry a marker (onlyReplace).
|
|
204
|
+
try {
|
|
205
|
+
const written = installAgentDocs(cwd, {onlyReplace: true});
|
|
206
|
+
summary.refreshed = written.length > 0;
|
|
207
|
+
summary.files = written;
|
|
208
|
+
if (summary.refreshed) {
|
|
209
|
+
summary.action = 'refreshed';
|
|
210
|
+
if (!json) {
|
|
211
|
+
p.log.success(
|
|
212
|
+
`Agent docs refreshed → v${installedVersion} (from ${fromLabel}): ${written.join(', ')}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// We detected a stale marked block but rewrote nothing, and
|
|
217
|
+
// installAgentDocs did not throw — the block markers are malformed (e.g. a
|
|
218
|
+
// START with no matching END, so the writer can't safely splice it). Don't
|
|
219
|
+
// fail silently: the block is exactly the artifact agents rely on.
|
|
220
|
+
summary.action = 'error';
|
|
221
|
+
if (!json) {
|
|
222
|
+
p.log.warn(
|
|
223
|
+
`Agent docs look stale but couldn't be refreshed — the <!-- ASTRYX:START -->/<!-- ASTRYX:END --> markers may be malformed. Run \`${formatCliCommand('astryx init --features agents')}\` to reinstall the block.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
summary.action = 'error';
|
|
229
|
+
if (!json) {
|
|
230
|
+
p.log.warn(
|
|
231
|
+
`Could not refresh agent docs. Run \`${formatCliCommand('astryx init --features agents')}\` to update them manually.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return summary;
|
|
236
|
+
}
|
|
237
|
+
|
|
138
238
|
/**
|
|
139
239
|
* Register the `upgrade` command (codemod-driven version migration).
|
|
140
240
|
*/
|
|
@@ -254,6 +354,19 @@ export function registerUpgrade(program) {
|
|
|
254
354
|
);
|
|
255
355
|
}
|
|
256
356
|
|
|
357
|
+
// Sync the managed agent-docs block to the installed version FIRST. It
|
|
358
|
+
// documents the installed library (version + component index + rules),
|
|
359
|
+
// independent of any source codemods, so it must be refreshed on every
|
|
360
|
+
// path below — including the up-to-date / no-codemods short-circuits that
|
|
361
|
+
// return before codemods ever run (issue #4168). Dry-run reports without
|
|
362
|
+
// writing. Folded into every terminal payload as `agentDocs`.
|
|
363
|
+
const agentDocs = refreshAgentDocs({
|
|
364
|
+
cwd: process.cwd(),
|
|
365
|
+
installedVersion: targetVersion,
|
|
366
|
+
apply: options.apply,
|
|
367
|
+
json,
|
|
368
|
+
});
|
|
369
|
+
|
|
257
370
|
// ───────────────────────────────────────────────────────────────────
|
|
258
371
|
// PIPELINE ORDERING
|
|
259
372
|
//
|
|
@@ -275,6 +388,7 @@ export function registerUpgrade(program) {
|
|
|
275
388
|
status: 'up_to_date',
|
|
276
389
|
from: currentVersion,
|
|
277
390
|
to: targetVersion,
|
|
391
|
+
agentDocs,
|
|
278
392
|
});
|
|
279
393
|
}
|
|
280
394
|
p.log.success('Already up to date — no codemods to run.');
|
|
@@ -409,6 +523,7 @@ export function registerUpgrade(program) {
|
|
|
409
523
|
suggestedCommand,
|
|
410
524
|
message: guidance,
|
|
411
525
|
note: 'Integrations are skipped in this preview; they will be processed on the --apply run.',
|
|
526
|
+
agentDocs,
|
|
412
527
|
});
|
|
413
528
|
}
|
|
414
529
|
p.log.warn(guidance);
|
|
@@ -420,11 +535,12 @@ export function registerUpgrade(program) {
|
|
|
420
535
|
return;
|
|
421
536
|
}
|
|
422
537
|
// Genuine config error (apply mode, OR dry-run with no pending core
|
|
423
|
-
// config codemod that would fix it): abort as before.
|
|
538
|
+
// config codemod that would fix it): abort as before. The agent-docs
|
|
539
|
+
// refresh already ran (it's independent of config), so surface it.
|
|
424
540
|
if (json)
|
|
425
541
|
return jsonError(
|
|
426
542
|
err.message,
|
|
427
|
-
|
|
543
|
+
{agentDocs},
|
|
428
544
|
ERROR_CODES.ERR_INVALID_ARGUMENT,
|
|
429
545
|
);
|
|
430
546
|
p.log.error(err.message);
|
|
@@ -502,6 +618,7 @@ export function registerUpgrade(program) {
|
|
|
502
618
|
status: 'no_codemods',
|
|
503
619
|
from: currentVersion,
|
|
504
620
|
to: targetVersion,
|
|
621
|
+
agentDocs,
|
|
505
622
|
});
|
|
506
623
|
}
|
|
507
624
|
p.log.success('No codemods available for this version range.');
|
|
@@ -513,7 +630,7 @@ export function registerUpgrade(program) {
|
|
|
513
630
|
if (totalTransforms === 0 && totalOptional === 0) {
|
|
514
631
|
const msg = `Codemod "${options.codemod}" not found. Use --list to see available codemods.`;
|
|
515
632
|
if (json)
|
|
516
|
-
return jsonError(msg,
|
|
633
|
+
return jsonError(msg, {agentDocs}, ERROR_CODES.ERR_UNKNOWN_CODEMOD);
|
|
517
634
|
p.log.error(msg);
|
|
518
635
|
p.outro('Aborted');
|
|
519
636
|
process.exitCode = 1;
|
|
@@ -535,7 +652,10 @@ export function registerUpgrade(program) {
|
|
|
535
652
|
to: targetVersion,
|
|
536
653
|
codemods: totalTransforms,
|
|
537
654
|
integrations: integrations.map(i => i.name ?? i.__spec),
|
|
538
|
-
|
|
655
|
+
// Refreshed up front (before the gates above), so the receipt just
|
|
656
|
+
// reports what happened. `agentDocsRefreshed` kept for back-compat.
|
|
657
|
+
agentDocsRefreshed: agentDocs.refreshed,
|
|
658
|
+
agentDocs,
|
|
539
659
|
};
|
|
540
660
|
|
|
541
661
|
// Run file-based integration codemods alongside the core registry
|
|
@@ -607,26 +727,9 @@ export function registerUpgrade(program) {
|
|
|
607
727
|
}
|
|
608
728
|
}
|
|
609
729
|
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
const existingDocs = discoverAgentDocs(process.cwd());
|
|
614
|
-
if (existingDocs.length > 0) {
|
|
615
|
-
try {
|
|
616
|
-
// onlyReplace: only update files that already have Astryx markers.
|
|
617
|
-
// Don't inject into files that never had Astryx content.
|
|
618
|
-
const written = installAgentDocs(process.cwd(), {onlyReplace: true});
|
|
619
|
-
receipt.agentDocsRefreshed = written.length > 0;
|
|
620
|
-
if (!json && written.length > 0)
|
|
621
|
-
p.log.success(`Agent docs updated: ${written.join(', ')}`);
|
|
622
|
-
} catch {
|
|
623
|
-
if (!json) {
|
|
624
|
-
p.log.warn(
|
|
625
|
-
`Could not update agent docs. Run \`${getCliInvocation()} init --features agents\` to update manually.`,
|
|
626
|
-
);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
730
|
+
// NOTE: the managed agent-docs block was already refreshed up front (see
|
|
731
|
+
// refreshAgentDocs after installed-version detection), so it stays in sync
|
|
732
|
+
// even on the short-circuit paths that return before this point.
|
|
630
733
|
|
|
631
734
|
receipt.filesChanged = mergedFilesChanged;
|
|
632
735
|
receipt.transformsApplied = mergedTransformsApplied;
|