@next/codemod 16.2.0-canary.23 → 16.2.0-canary.25

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.
@@ -0,0 +1,294 @@
1
+ /* global jest */
2
+ jest.autoMockOff()
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const os = require('os')
7
+ const { runAgentsMd } = require('../../bin/agents-md')
8
+
9
+ /**
10
+ * TRUE E2E TESTS
11
+ * These tests invoke the actual CLI entry point (runAgentsMd),
12
+ * simulating what happens when a user runs:
13
+ * npx @next/codemod agents-md --version 15.0.0 --output CLAUDE.md
14
+ */
15
+ describe('agents-md e2e (CLI invocation)', () => {
16
+ let testProjectDir
17
+ let originalConsoleLog
18
+ let consoleOutput
19
+
20
+ beforeEach(() => {
21
+ // Create isolated test project directory
22
+ const tmpBase = process.env.NEXT_TEST_DIR || os.tmpdir()
23
+ testProjectDir = path.join(
24
+ tmpBase,
25
+ `agents-md-e2e-${Date.now()}-${(Math.random() * 1000) | 0}`
26
+ )
27
+ fs.mkdirSync(testProjectDir, { recursive: true })
28
+
29
+ // Mock console.log to capture CLI output
30
+ originalConsoleLog = console.log
31
+ consoleOutput = []
32
+ console.log = (...args) => {
33
+ consoleOutput.push(args.join(' '))
34
+ }
35
+ })
36
+
37
+ afterEach(() => {
38
+ // Restore console.log
39
+ console.log = originalConsoleLog
40
+
41
+ // Clean up test directory
42
+ if (testProjectDir && fs.existsSync(testProjectDir)) {
43
+ fs.rmSync(testProjectDir, { recursive: true, force: true })
44
+ }
45
+ })
46
+
47
+ it('creates CLAUDE.md and .next-docs directory when run with --version and --output', async () => {
48
+ // Create a minimal package.json (not required, but realistic)
49
+ const packageJson = {
50
+ name: 'test-project',
51
+ version: '1.0.0',
52
+ dependencies: {
53
+ next: '15.0.0',
54
+ },
55
+ }
56
+ fs.writeFileSync(
57
+ path.join(testProjectDir, 'package.json'),
58
+ JSON.stringify(packageJson, null, 2)
59
+ )
60
+
61
+ // Change to test directory
62
+ const originalCwd = process.cwd()
63
+ process.chdir(testProjectDir)
64
+
65
+ try {
66
+ // Run the actual CLI command
67
+ await runAgentsMd({
68
+ version: '15.0.0',
69
+ output: 'CLAUDE.md',
70
+ })
71
+
72
+ // Verify .next-docs directory was created and populated
73
+ const docsDir = path.join(testProjectDir, '.next-docs')
74
+ expect(fs.existsSync(docsDir)).toBe(true)
75
+
76
+ const docFiles = fs.readdirSync(docsDir, { recursive: true })
77
+ expect(docFiles.length).toBeGreaterThan(0)
78
+
79
+ // Should contain mdx/md files
80
+ const mdxFiles = docFiles.filter(
81
+ (f) => f.endsWith('.mdx') || f.endsWith('.md')
82
+ )
83
+ expect(mdxFiles.length).toBeGreaterThan(0)
84
+
85
+ // Verify CLAUDE.md was created
86
+ const claudeMdPath = path.join(testProjectDir, 'CLAUDE.md')
87
+ expect(fs.existsSync(claudeMdPath)).toBe(true)
88
+
89
+ const claudeMdContent = fs.readFileSync(claudeMdPath, 'utf-8')
90
+
91
+ // Verify content structure
92
+ expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-START -->')
93
+ expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-END -->')
94
+ expect(claudeMdContent).toContain('[Next.js Docs Index]')
95
+ expect(claudeMdContent).toContain('root: ./.next-docs')
96
+
97
+ // Verify paths are normalized to forward slashes (cross-platform)
98
+ const lines = claudeMdContent.split('|')
99
+ const pathLines = lines.filter((line) => line.includes(':'))
100
+ pathLines.forEach((line) => {
101
+ // Should not contain Windows backslashes in the output
102
+ const pathPart = line.split(':')[0]
103
+ if (pathPart && pathPart.includes('/')) {
104
+ expect(line).not.toMatch(/[^:]\\/)
105
+ }
106
+ })
107
+
108
+ // Verify .gitignore was updated
109
+ const gitignorePath = path.join(testProjectDir, '.gitignore')
110
+ if (fs.existsSync(gitignorePath)) {
111
+ const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8')
112
+ expect(gitignoreContent).toContain('.next-docs')
113
+ }
114
+
115
+ // Verify console output
116
+ const output = consoleOutput.join('\n')
117
+ expect(output).toContain('Downloading Next.js')
118
+ expect(output).toContain('15.0.0')
119
+ expect(output).toContain('CLAUDE.md')
120
+ } finally {
121
+ // Restore original directory
122
+ process.chdir(originalCwd)
123
+ }
124
+ })
125
+
126
+ it('updates existing CLAUDE.md without losing content', async () => {
127
+ const originalCwd = process.cwd()
128
+ process.chdir(testProjectDir)
129
+
130
+ try {
131
+ // Create existing CLAUDE.md with custom content
132
+ const existingContent = `# My Project
133
+
134
+ This is my project documentation.
135
+
136
+ ## Features
137
+ - Feature 1
138
+ - Feature 2
139
+ `
140
+ fs.writeFileSync(
141
+ path.join(testProjectDir, 'CLAUDE.md'),
142
+ existingContent
143
+ )
144
+
145
+ // Run CLI
146
+ await runAgentsMd({
147
+ version: '15.0.0',
148
+ output: 'CLAUDE.md',
149
+ })
150
+
151
+ // Verify file was updated, not replaced
152
+ const claudeMdContent = fs.readFileSync(
153
+ path.join(testProjectDir, 'CLAUDE.md'),
154
+ 'utf-8'
155
+ )
156
+
157
+ // Original content should still be there
158
+ expect(claudeMdContent).toContain('# My Project')
159
+ expect(claudeMdContent).toContain('This is my project documentation.')
160
+ expect(claudeMdContent).toContain('## Features')
161
+
162
+ // New index should be injected
163
+ expect(claudeMdContent).toContain('<!-- NEXT-AGENTS-MD-START -->')
164
+ expect(claudeMdContent).toContain('[Next.js Docs Index]')
165
+ } finally {
166
+ process.chdir(originalCwd)
167
+ }
168
+ })
169
+
170
+ it('handles custom output filename', async () => {
171
+ const originalCwd = process.cwd()
172
+ process.chdir(testProjectDir)
173
+
174
+ try {
175
+ // Run with custom output file
176
+ await runAgentsMd({
177
+ version: '15.0.0',
178
+ output: 'AGENTS.md',
179
+ })
180
+
181
+ // Verify AGENTS.md was created (not CLAUDE.md)
182
+ expect(fs.existsSync(path.join(testProjectDir, 'AGENTS.md'))).toBe(true)
183
+ expect(fs.existsSync(path.join(testProjectDir, 'CLAUDE.md'))).toBe(false)
184
+
185
+ const agentsMdContent = fs.readFileSync(
186
+ path.join(testProjectDir, 'AGENTS.md'),
187
+ 'utf-8'
188
+ )
189
+ expect(agentsMdContent).toContain('[Next.js Docs Index]')
190
+ } finally {
191
+ process.chdir(originalCwd)
192
+ }
193
+ })
194
+
195
+ it('works when run from a subdirectory', async () => {
196
+ const originalCwd = process.cwd()
197
+
198
+ // Create a subdirectory
199
+ const subDir = path.join(testProjectDir, 'packages', 'app')
200
+ fs.mkdirSync(subDir, { recursive: true })
201
+
202
+ // Create package.json in root
203
+ const packageJson = {
204
+ dependencies: { next: '15.0.0' },
205
+ }
206
+ fs.writeFileSync(
207
+ path.join(testProjectDir, 'package.json'),
208
+ JSON.stringify(packageJson)
209
+ )
210
+
211
+ // Change to subdirectory
212
+ process.chdir(subDir)
213
+
214
+ try {
215
+ // Run from subdirectory - should create files in CWD (subdirectory)
216
+ await runAgentsMd({
217
+ version: '15.0.0',
218
+ output: 'CLAUDE.md',
219
+ })
220
+
221
+ // Verify files created in subdirectory
222
+ expect(fs.existsSync(path.join(subDir, 'CLAUDE.md'))).toBe(true)
223
+ expect(fs.existsSync(path.join(subDir, '.next-docs'))).toBe(true)
224
+ } finally {
225
+ process.chdir(originalCwd)
226
+ }
227
+ })
228
+
229
+ it('normalizes paths on Windows (cross-platform test)', async () => {
230
+ const originalCwd = process.cwd()
231
+ process.chdir(testProjectDir)
232
+
233
+ try {
234
+ await runAgentsMd({
235
+ version: '15.0.0',
236
+ output: 'CLAUDE.md',
237
+ })
238
+
239
+ const claudeMdContent = fs.readFileSync(
240
+ path.join(testProjectDir, 'CLAUDE.md'),
241
+ 'utf-8'
242
+ )
243
+
244
+ // Extract the index content between markers
245
+ const startMarker = '<!-- NEXT-AGENTS-MD-START -->'
246
+ const endMarker = '<!-- NEXT-AGENTS-MD-END -->'
247
+ const startIdx = claudeMdContent.indexOf(startMarker) + startMarker.length
248
+ const endIdx = claudeMdContent.indexOf(endMarker)
249
+ const indexContent = claudeMdContent.slice(startIdx, endIdx)
250
+
251
+ // Parse the index (format: "dir:{file1,file2}|dir2:{file3}")
252
+ const sections = indexContent.split('|').filter((s) => s.includes(':'))
253
+
254
+ sections.forEach((section) => {
255
+ const [dirPath, filesStr] = section.split(':')
256
+ if (dirPath && dirPath.trim() && !dirPath.includes('root')) {
257
+ // Verify no Windows backslashes in directory paths
258
+ expect(dirPath).not.toContain('\\')
259
+ // Verify uses forward slashes
260
+ if (dirPath.includes('/')) {
261
+ expect(dirPath).toMatch(/^[^\\]+$/)
262
+ }
263
+ }
264
+ })
265
+ } finally {
266
+ process.chdir(originalCwd)
267
+ }
268
+ })
269
+
270
+ it('handles version that requires git clone from GitHub', async () => {
271
+ const originalCwd = process.cwd()
272
+ process.chdir(testProjectDir)
273
+
274
+ try {
275
+ // Use a known stable version
276
+ await runAgentsMd({
277
+ version: '14.2.0',
278
+ output: 'CLAUDE.md',
279
+ })
280
+
281
+ // Verify docs were downloaded
282
+ const docsDir = path.join(testProjectDir, '.next-docs')
283
+ expect(fs.existsSync(docsDir)).toBe(true)
284
+
285
+ const docFiles = fs.readdirSync(docsDir, { recursive: true })
286
+ const mdxFiles = docFiles.filter(
287
+ (f) => f.endsWith('.mdx') || f.endsWith('.md')
288
+ )
289
+ expect(mdxFiles.length).toBeGreaterThan(50) // Should have many doc files
290
+ } finally {
291
+ process.chdir(originalCwd)
292
+ }
293
+ }, 30000) // Increase timeout for git clone
294
+ })
package/lib/agents-md.js CHANGED
@@ -16,7 +16,7 @@ exports.buildDocTree = buildDocTree;
16
16
  exports.generateClaudeMdIndex = generateClaudeMdIndex;
17
17
  exports.injectIntoClaudeMd = injectIntoClaudeMd;
18
18
  exports.ensureGitignoreEntry = ensureGitignoreEntry;
19
- const child_process_1 = require("child_process");
19
+ const execa_1 = __importDefault(require("execa"));
20
20
  const fs_1 = __importDefault(require("fs"));
21
21
  const path_1 = __importDefault(require("path"));
22
22
  const os_1 = __importDefault(require("os"));
@@ -108,7 +108,17 @@ async function cloneDocsFolder(tag, destDir) {
108
108
  const tempDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'next-agents-md-'));
109
109
  try {
110
110
  try {
111
- (0, child_process_1.execSync)(`git clone --depth 1 --filter=blob:none --sparse --branch ${tag} https://github.com/vercel/next.js.git .`, { cwd: tempDir, stdio: 'pipe' });
111
+ await (0, execa_1.default)('git', [
112
+ 'clone',
113
+ '--depth',
114
+ '1',
115
+ '--filter=blob:none',
116
+ '--sparse',
117
+ '--branch',
118
+ tag,
119
+ 'https://github.com/vercel/next.js.git',
120
+ '.',
121
+ ], { cwd: tempDir });
112
122
  }
113
123
  catch (error) {
114
124
  const message = error instanceof Error ? error.message : String(error);
@@ -117,7 +127,7 @@ async function cloneDocsFolder(tag, destDir) {
117
127
  }
118
128
  throw error;
119
129
  }
120
- (0, child_process_1.execSync)('git sparse-checkout set docs', { cwd: tempDir, stdio: 'pipe' });
130
+ await (0, execa_1.default)('git', ['sparse-checkout', 'set', 'docs'], { cwd: tempDir });
121
131
  const sourceDocsDir = path_1.default.join(tempDir, 'docs');
122
132
  if (!fs_1.default.existsSync(sourceDocsDir)) {
123
133
  throw new Error('docs folder not found in cloned repository');
@@ -137,16 +147,16 @@ async function cloneDocsFolder(tag, destDir) {
137
147
  function collectDocFiles(dir) {
138
148
  return fs_1.default.readdirSync(dir, { recursive: true })
139
149
  .filter((f) => (f.endsWith('.mdx') || f.endsWith('.md')) &&
140
- !f.endsWith('/index.mdx') &&
141
- !f.endsWith('/index.md') &&
150
+ !/[/\\]index\.mdx$/.test(f) &&
151
+ !/[/\\]index\.md$/.test(f) &&
142
152
  !f.startsWith('index.'))
143
153
  .sort()
144
- .map((f) => ({ relativePath: f }));
154
+ .map((f) => ({ relativePath: f.replace(/\\/g, '/') }));
145
155
  }
146
156
  function buildDocTree(files) {
147
157
  const sections = new Map();
148
158
  for (const file of files) {
149
- const parts = file.relativePath.split('/');
159
+ const parts = file.relativePath.split(/[/\\]/);
150
160
  if (parts.length < 2)
151
161
  continue;
152
162
  const topLevelDir = parts[0];
@@ -221,7 +231,7 @@ function collectAllFilesFromSections(sections) {
221
231
  function groupByDirectory(files) {
222
232
  const grouped = new Map();
223
233
  for (const filePath of files) {
224
- const lastSlash = filePath.lastIndexOf('/');
234
+ const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
225
235
  const dir = lastSlash === -1 ? '.' : filePath.slice(0, lastSlash);
226
236
  const fileName = lastSlash === -1 ? filePath : filePath.slice(lastSlash + 1);
227
237
  const existing = grouped.get(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next/codemod",
3
- "version": "16.2.0-canary.23",
3
+ "version": "16.2.0-canary.25",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,142 +0,0 @@
1
- /* global jest */
2
- jest.autoMockOff()
3
-
4
- const { injectIntoClaudeMd, buildDocTree } = require('../agents-md')
5
-
6
- describe('agents-md', () => {
7
- describe('injectIntoClaudeMd', () => {
8
- const START_MARKER = '<!-- NEXT-AGENTS-MD-START -->'
9
- const END_MARKER = '<!-- NEXT-AGENTS-MD-END -->'
10
-
11
- it('appends to empty file', () => {
12
- const result = injectIntoClaudeMd('', 'index content')
13
- // Empty string doesn't end with \n, so separator is \n\n
14
- expect(result).toBe(`\n\n${START_MARKER}index content${END_MARKER}\n`)
15
- })
16
-
17
- it('appends to file without markers', () => {
18
- const existing = '# My Project\n\nSome existing content.'
19
- const result = injectIntoClaudeMd(existing, 'index content')
20
- expect(result).toBe(
21
- `${existing}\n\n${START_MARKER}index content${END_MARKER}\n`
22
- )
23
- })
24
-
25
- it('replaces content between existing markers', () => {
26
- const existing = `# My Project
27
-
28
- Some content before.
29
-
30
- ${START_MARKER}old index${END_MARKER}
31
-
32
- Some content after.`
33
- const result = injectIntoClaudeMd(existing, 'new index')
34
- expect(result).toBe(`# My Project
35
-
36
- Some content before.
37
-
38
- ${START_MARKER}new index${END_MARKER}
39
-
40
- Some content after.`)
41
- })
42
-
43
- it('is idempotent - running twice produces same result', () => {
44
- const initial = '# Project\n'
45
- const first = injectIntoClaudeMd(initial, 'index v1')
46
- const second = injectIntoClaudeMd(first, 'index v1')
47
- expect(second).toBe(first)
48
- })
49
-
50
- it('preserves content before and after markers on update', () => {
51
- const before = '# Header\n\nIntro paragraph.'
52
- const after = '\n\n## Footer\n\nMore content.'
53
- const existing = `${before}\n\n${START_MARKER}old${END_MARKER}${after}`
54
- const result = injectIntoClaudeMd(existing, 'new')
55
- expect(result).toContain(before)
56
- expect(result).toContain(after)
57
- expect(result).toContain(`${START_MARKER}new${END_MARKER}`)
58
- expect(result).not.toContain('old')
59
- })
60
- })
61
-
62
- describe('buildDocTree', () => {
63
- it('groups files by top-level directory', () => {
64
- const files = [
65
- { relativePath: '01-getting-started/installation.mdx' },
66
- { relativePath: '01-getting-started/project-structure.mdx' },
67
- { relativePath: '02-app/routing.mdx' },
68
- ]
69
- const tree = buildDocTree(files)
70
-
71
- expect(tree).toHaveLength(2)
72
- expect(tree[0].name).toBe('01-getting-started')
73
- expect(tree[0].files).toHaveLength(2)
74
- expect(tree[1].name).toBe('02-app')
75
- expect(tree[1].files).toHaveLength(1)
76
- })
77
-
78
- it('creates nested subsections for deeper paths', () => {
79
- const files = [
80
- { relativePath: '02-app/01-building/layouts.mdx' },
81
- { relativePath: '02-app/01-building/pages.mdx' },
82
- { relativePath: '02-app/02-api/route-handlers.mdx' },
83
- ]
84
- const tree = buildDocTree(files)
85
-
86
- expect(tree).toHaveLength(1)
87
- const appSection = tree[0]
88
- expect(appSection.name).toBe('02-app')
89
- expect(appSection.files).toHaveLength(0) // no direct files
90
- expect(appSection.subsections).toHaveLength(2)
91
-
92
- const building = appSection.subsections.find(
93
- (s) => s.name === '01-building'
94
- )
95
- expect(building).toBeDefined()
96
- expect(building.files).toHaveLength(2)
97
-
98
- const api = appSection.subsections.find((s) => s.name === '02-api')
99
- expect(api).toBeDefined()
100
- expect(api.files).toHaveLength(1)
101
- })
102
-
103
- it('handles 4-level deep paths with sub-subsections', () => {
104
- const files = [
105
- { relativePath: '02-app/01-building/01-routing/dynamic-routes.mdx' },
106
- { relativePath: '02-app/01-building/01-routing/parallel-routes.mdx' },
107
- ]
108
- const tree = buildDocTree(files)
109
-
110
- const routing = tree[0].subsections[0].subsections[0]
111
- expect(routing.name).toBe('01-routing')
112
- expect(routing.files).toHaveLength(2)
113
- })
114
-
115
- it('skips single-segment paths (root-level files)', () => {
116
- const files = [
117
- { relativePath: 'index.mdx' },
118
- { relativePath: '01-getting-started/intro.mdx' },
119
- ]
120
- const tree = buildDocTree(files)
121
-
122
- // Root-level index.mdx should be skipped (parts.length < 2)
123
- expect(tree).toHaveLength(1)
124
- expect(tree[0].name).toBe('01-getting-started')
125
- })
126
-
127
- it('sorts sections and files alphabetically', () => {
128
- const files = [
129
- { relativePath: 'z-section/b-file.mdx' },
130
- { relativePath: 'a-section/z-file.mdx' },
131
- { relativePath: 'a-section/a-file.mdx' },
132
- { relativePath: 'z-section/a-file.mdx' },
133
- ]
134
- const tree = buildDocTree(files)
135
-
136
- expect(tree[0].name).toBe('a-section')
137
- expect(tree[1].name).toBe('z-section')
138
- expect(tree[0].files[0].relativePath).toBe('a-section/a-file.mdx')
139
- expect(tree[0].files[1].relativePath).toBe('a-section/z-file.mdx')
140
- })
141
- })
142
- })