@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.
Files changed (49) hide show
  1. package/package.json +9 -9
  2. package/src/codemods/__tests__/registry.test.mjs +1 -0
  3. package/src/codemods/registry.mjs +1 -0
  4. package/src/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +42 -7
  5. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +43 -0
  6. package/src/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +102 -3
  7. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +72 -6
  8. package/src/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +161 -0
  9. package/src/codemods/transforms/v0.1.8/index.mjs +19 -0
  10. package/src/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +234 -0
  11. package/src/commands/agent-docs.mjs +72 -0
  12. package/src/commands/agent-docs.test.mjs +86 -0
  13. package/src/commands/upgrade.mjs +130 -27
  14. package/src/commands/upgrade.test.mjs +154 -0
  15. package/src/types/upgrade.d.ts +39 -2
  16. package/templates/blocks/components/Avatar/AvatarFallbackChain.tsx +4 -4
  17. package/templates/blocks/components/Avatar/AvatarGroup.tsx +2 -2
  18. package/templates/blocks/components/Avatar/AvatarInitialsFallback.tsx +1 -1
  19. package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -4
  20. package/templates/blocks/components/Avatar/AvatarUserCard.tsx +1 -1
  21. package/templates/blocks/components/Avatar/AvatarWithImage.tsx +4 -4
  22. package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -3
  23. package/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.tsx +2 -2
  24. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowCustomText.tsx +1 -1
  25. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowDefault.tsx +1 -1
  26. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowShowcase.tsx +2 -2
  27. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotShowcase.tsx +3 -3
  28. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +3 -3
  29. package/templates/blocks/components/Carousel/CarouselSnap.tsx +1 -1
  30. package/templates/blocks/components/ChatMessage/ChatMessageAvatarName.tsx +2 -2
  31. package/templates/blocks/components/ChatMessage/ChatMessageMultiBubble.tsx +1 -1
  32. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleGrouping.tsx +1 -1
  33. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleMetadata.tsx +1 -1
  34. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +5 -9
  35. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -1
  36. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -1
  37. package/templates/blocks/components/Item/ItemWithMedia.tsx +2 -2
  38. package/templates/blocks/components/ListItem/ListItemWithMedia.tsx +2 -2
  39. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -6
  40. package/templates/blocks/components/Stack/StackFillItem.tsx +2 -6
  41. package/templates/blocks/components/TypeaheadItem/TypeaheadItemShowcase.tsx +1 -1
  42. package/templates/pages/ai-chat/page.tsx +4 -4
  43. package/templates/pages/dashboard-portfolio/page.tsx +3 -11
  44. package/templates/pages/detail-page/page.tsx +5 -12
  45. package/templates/pages/documentation-design/page.tsx +1 -1
  46. package/templates/pages/messaging-shell/page.tsx +6 -8
  47. package/templates/pages/table-grouped/page.tsx +9 -14
  48. package/templates/pages/table-page/page.tsx +7 -9
  49. 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
+ });
@@ -11,8 +11,11 @@
11
11
  *
12
12
  * Pipeline (--apply):
13
13
  * 1. Read installed @astryxdesign/core (or legacy @xds/core) version
14
- * 2. Run codemods for --from → installed version
15
- * 3. Refresh agent docs (AGENTS.md / CLAUDE.md) if present
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, discoverAgentDocs} from './agent-docs.mjs';
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
- undefined,
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, undefined, ERROR_CODES.ERR_UNKNOWN_CODEMOD);
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
- agentDocsRefreshed: false,
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
- // Refresh agent docs if any exist (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, etc.)
611
- // Always update after --apply; also update during dry-run if files exist,
612
- // since the index reflects the installed CLI version, not the codemods.
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;