@readme/cli 0.0.26 → 0.0.29

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 (123) hide show
  1. package/README.md +2 -2
  2. package/package.json +5 -3
  3. package/src/cli.js +6 -1
  4. package/src/commands/components.js +288 -0
  5. package/src/commands/import.js +2456 -376
  6. package/src/commands/import.slug-planner.test.js +86 -0
  7. package/src/commands/import.test.js +136 -0
  8. package/src/commands/lint.js +32 -35
  9. package/src/commands/oas-sync.js +60 -75
  10. package/src/commands/pretty.js +314 -77
  11. package/src/prompts/index.js +187 -24
  12. package/src/utils/lint.js +33 -33
  13. package/src/utils/llms.js +224 -0
  14. package/src/utils/llms.test.js +257 -0
  15. package/src/utils/marketplace.js +208 -0
  16. package/src/utils/multi-spinner.js +71 -0
  17. package/src/utils/promise-pool.js +17 -0
  18. package/src/utils/url-segs.js +124 -0
  19. package/src/utils/url-segs.test.js +239 -0
  20. package/src/validators/components.js +1 -0
  21. package/src/validators/duplicates.js +6 -4
  22. package/src/validators/frontmatter.js +11 -0
  23. package/src/validators/oas-reference.js +1 -31
  24. package/src/validators/ordering.js +169 -83
  25. package/vendor/TOOLS.md +1 -1
  26. package/src/dev/.next/app-build-manifest.json +0 -20
  27. package/src/dev/.next/build-manifest.json +0 -31
  28. package/src/dev/.next/cache/.rscinfo +0 -1
  29. package/src/dev/.next/cache/next-devtools-config.json +0 -1
  30. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  36. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  37. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  40. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  43. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  44. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  46. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  50. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  67. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  68. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  69. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  70. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  71. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  72. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  73. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  74. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  75. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  76. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  77. package/src/dev/.next/package.json +0 -1
  78. package/src/dev/.next/prerender-manifest.json +0 -11
  79. package/src/dev/.next/react-loadable-manifest.json +0 -1
  80. package/src/dev/.next/routes-manifest.json +0 -1
  81. package/src/dev/.next/server/app/[...slug]/page.js +0 -360
  82. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +0 -1
  83. package/src/dev/.next/server/app/page.js +0 -349
  84. package/src/dev/.next/server/app/page_client-reference-manifest.js +0 -1
  85. package/src/dev/.next/server/app-paths-manifest.json +0 -3
  86. package/src/dev/.next/server/edge-runtime-webpack.js +0 -1151
  87. package/src/dev/.next/server/interception-route-rewrite-manifest.js +0 -1
  88. package/src/dev/.next/server/middleware-build-manifest.js +0 -33
  89. package/src/dev/.next/server/middleware-manifest.json +0 -32
  90. package/src/dev/.next/server/middleware-react-loadable-manifest.js +0 -1
  91. package/src/dev/.next/server/middleware.js +0 -1113
  92. package/src/dev/.next/server/next-font-manifest.js +0 -1
  93. package/src/dev/.next/server/next-font-manifest.json +0 -1
  94. package/src/dev/.next/server/pages-manifest.json +0 -5
  95. package/src/dev/.next/server/server-reference-manifest.js +0 -1
  96. package/src/dev/.next/server/server-reference-manifest.json +0 -5
  97. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +0 -1
  98. package/src/dev/.next/server/vendor-chunks/@readme.js +0 -25
  99. package/src/dev/.next/server/vendor-chunks/@swc.js +0 -55
  100. package/src/dev/.next/server/vendor-chunks/next.js +0 -3659
  101. package/src/dev/.next/server/webpack-runtime.js +0 -209
  102. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +0 -28
  103. package/src/dev/.next/static/chunks/app/[...slug]/page.js +0 -28
  104. package/src/dev/.next/static/chunks/app/layout.js +0 -171
  105. package/src/dev/.next/static/chunks/app/page.js +0 -28
  106. package/src/dev/.next/static/chunks/app-pages-internals.js +0 -182
  107. package/src/dev/.next/static/chunks/main-app.js +0 -1882
  108. package/src/dev/.next/static/chunks/polyfills.js +0 -1
  109. package/src/dev/.next/static/chunks/webpack.js +0 -1393
  110. package/src/dev/.next/static/css/app/layout.css +0 -559
  111. package/src/dev/.next/static/development/_buildManifest.js +0 -1
  112. package/src/dev/.next/static/development/_ssgManifest.js +0 -1
  113. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +0 -1
  114. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +0 -1
  115. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +0 -12
  116. package/src/dev/.next/trace +0 -21
  117. package/src/dev/.next/types/app/[...slug]/page.ts +0 -84
  118. package/src/dev/.next/types/app/layout.ts +0 -84
  119. package/src/dev/.next/types/app/page.ts +0 -84
  120. package/src/dev/.next/types/cache-life.d.ts +0 -141
  121. package/src/dev/.next/types/package.json +0 -1
  122. package/src/dev/.next/types/routes.d.ts +0 -55
  123. package/src/validators/numbering.js +0 -136
@@ -0,0 +1,86 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { ensureUniqueSlugs } from './import.js'
4
+
5
+ function assignedSlug(categories, page) {
6
+ return ensureUniqueSlugs(categories).get(page)
7
+ }
8
+
9
+ test('ensureUniqueSlugs: generic docs terminal paths fall back to introduction', () => {
10
+ for (const segment of ['doc', 'docs', 'documentation', 'documentations']) {
11
+ const page = { title: segment, url: `https://example.com/${segment}` }
12
+ assert.equal(
13
+ assignedSlug([{ title: 'Documentation', pages: [page] }], page),
14
+ 'introduction',
15
+ )
16
+ }
17
+ })
18
+
19
+ test('ensureUniqueSlugs: terminal generic docs paths use introduction under non-generic categories', () => {
20
+ const page = { title: 'Docs', url: 'https://example.com/docs' }
21
+
22
+ assert.equal(
23
+ assignedSlug([{ title: 'Guides', pages: [page] }], page),
24
+ 'introduction',
25
+ )
26
+ })
27
+
28
+ test('ensureUniqueSlugs: category segment disambiguates introduction collisions', () => {
29
+ const guidesPage = { title: 'Docs', url: 'https://example.com/docs' }
30
+ const apiPage = { title: 'Docs', url: 'https://example.com/docs' }
31
+ const slugs = ensureUniqueSlugs([
32
+ { title: 'Guides', pages: [guidesPage] },
33
+ { title: 'API', pages: [apiPage] },
34
+ ])
35
+
36
+ assert.equal(slugs.get(guidesPage), 'guides-introduction')
37
+ assert.equal(slugs.get(apiPage), 'api-introduction')
38
+ })
39
+
40
+ test('ensureUniqueSlugs: generic docs category does not prefix an available page slug', () => {
41
+ const page = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
42
+
43
+ assert.equal(
44
+ assignedSlug([{ title: 'Docs', pages: [page] }], page),
45
+ 'getting-started',
46
+ )
47
+ })
48
+
49
+ test('ensureUniqueSlugs: generic docs categories use existing numeric suffix fallback for irreducible collisions', () => {
50
+ const first = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
51
+ const second = { title: 'Getting Started', url: 'https://example.com/documentation/getting-started' }
52
+ const slugs = ensureUniqueSlugs([
53
+ { title: 'Docs', pages: [first] },
54
+ { title: 'Documentation', pages: [second] },
55
+ ])
56
+
57
+ assert.equal(slugs.get(first), 'getting-started')
58
+ assert.equal(slugs.get(second), 'getting-started-2')
59
+ })
60
+
61
+ test('ensureUniqueSlugs: generic docs category is assigned before non-generic categories', () => {
62
+ const docsPage = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
63
+ const aiPage = { title: 'Getting Started', url: 'https://example.com/ai/getting-started' }
64
+ const slugs = ensureUniqueSlugs([
65
+ { title: 'AI', pages: [aiPage] },
66
+ { title: 'Docs', pages: [docsPage] },
67
+ ])
68
+
69
+ assert.equal(slugs.get(docsPage), 'getting-started')
70
+ assert.equal(slugs.get(aiPage), 'ai-getting-started')
71
+ })
72
+
73
+ test('ensureUniqueSlugs: non-generic docs paths keep current base slug behavior', () => {
74
+ const api = { title: 'API', url: 'https://example.com/docs/api' }
75
+ const gettingStarted = { title: 'Getting Started', url: 'https://example.com/docs/getting-started' }
76
+ const docsApi = { title: 'Docs API', url: 'https://example.com/docs-api' }
77
+ const documentationSettings = { title: 'Documentation Settings', url: 'https://example.com/documentation-settings' }
78
+ const slugs = ensureUniqueSlugs([
79
+ { title: 'Guides', pages: [api, gettingStarted, docsApi, documentationSettings] },
80
+ ])
81
+
82
+ assert.equal(slugs.get(api), 'api')
83
+ assert.equal(slugs.get(gettingStarted), 'getting-started')
84
+ assert.equal(slugs.get(docsApi), 'docs-api')
85
+ assert.equal(slugs.get(documentationSettings), 'documentation-settings')
86
+ })
@@ -0,0 +1,136 @@
1
+ import { afterEach, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { __test__ } from './import.js'
4
+
5
+ const originalFetch = globalThis.fetch
6
+
7
+ afterEach(() => {
8
+ globalThis.fetch = originalFetch
9
+ })
10
+
11
+ function mockLlmsFetch(files) {
12
+ const seen = []
13
+ globalThis.fetch = async (url) => {
14
+ const href = String(url)
15
+ seen.push(href)
16
+ if (!Object.hasOwn(files, href)) return new Response('', { status: 404 })
17
+ return new Response(files[href], {
18
+ status: 200,
19
+ headers: { 'content-type': 'text/plain' },
20
+ })
21
+ }
22
+ return seen
23
+ }
24
+
25
+ function mockRedirectFetch(files) {
26
+ globalThis.fetch = async (url) => {
27
+ const entry = files[String(url)]
28
+ if (!entry) return { ok: false, status: 404, url: String(url), text: async () => '' }
29
+ return { ok: true, status: 200, url: entry.finalUrl || String(url), text: async () => entry.body || '' }
30
+ }
31
+ }
32
+
33
+ function mergedItems(merged) {
34
+ return merged.parsed.sections.flatMap((section) => section.items.map((item) => ({ text: item.text, url: item.url })))
35
+ }
36
+
37
+ test('llms discovery and merge resolve nested files, dedupe canonical pages, and preserve distinct page states', async () => {
38
+ const seen = mockLlmsFetch({
39
+ 'https://example.com/docs/llms.txt': `# Docs\n\n## Index\n- [Guide llms](guide/llms.txt)\n- [API llms](https://example.com/docs/api/llms.txt)\n`,
40
+ 'https://example.com/docs/guide/llms.txt': `# Guide\n\n## Guides\n- [Rendered redirect](/getting-started)\n- [Rendered slash](/getting-started/)\n- [Rendered index](/getting-started/index.html)\n- [Markdown source](/getting-started.md)\n- [Search API](/search?product=api)\n- [Search SDK](/search?product=sdk)\n- [Search API Anchor](/search?product=api#overview)\n- [Hash only](#local)\n- [Mail](mailto:test@example.com)\n- [JS](javascript:alert(1))\n- [Malformed](http://%zz)\n`,
41
+ 'https://example.com/docs/api/llms.txt': `# API\n\n## Reference\n- [Reference](/reference.md)\n- [OpenAPI](/openapi.yaml)\n`,
42
+ })
43
+
44
+ const discovery = await __test__.discoverLlmsTxt(new URL('https://example.com/docs/'))
45
+ const merged = __test__.mergeValidHits(discovery.hits)
46
+ const items = mergedItems(merged)
47
+ const urls = items.map((item) => item.url)
48
+
49
+ assert(seen.includes('https://example.com/docs/guide/llms.txt'))
50
+ assert(seen.includes('https://example.com/docs/api/llms.txt'))
51
+
52
+ assert.equal(urls.filter((url) => url.includes('/docs/guide/getting-started')).length, 1)
53
+ assert(urls.includes('https://example.com/docs/guide/getting-started.md'))
54
+
55
+ assert(urls.includes('https://example.com/docs/guide/search?product=api'))
56
+ assert(urls.includes('https://example.com/docs/guide/search?product=sdk'))
57
+ assert.equal(urls.filter((url) => url.includes('/docs/guide/search?product=api')).length, 1)
58
+
59
+ assert(urls.includes('https://example.com/docs/api/reference.md'))
60
+ assert(urls.includes('https://example.com/openapi.yaml'))
61
+
62
+ assert(!urls.some((url) => url.includes('%zz')))
63
+ assert(!urls.some((url) => url.startsWith('mailto:') || url.startsWith('javascript:')))
64
+ })
65
+
66
+ test('llms.txt fetched through a cross-origin redirect resolves links against the final URL', async () => {
67
+ mockRedirectFetch({
68
+ 'https://docs.example.com/llms.txt': {
69
+ finalUrl: 'https://example.com/docs/llms.txt',
70
+ body: `# Docs\n\n## Guides\n- [Sample APIs](/docs/sample-apis.md)\n- [Policies](/policies.md)\n`,
71
+ },
72
+ })
73
+
74
+ const discovery = await __test__.discoverLlmsTxt(new URL('https://docs.example.com'))
75
+ const merged = __test__.mergeValidHits(discovery.hits)
76
+ const urls = mergedItems(merged).map((item) => item.url)
77
+
78
+ assert(urls.includes('https://example.com/docs/sample-apis.md'))
79
+ assert(urls.includes('https://example.com/docs/policies.md'))
80
+ assert(!urls.some((url) => url.startsWith('https://docs.example.com/')))
81
+ })
82
+
83
+ test('nested llms.txt with its own redirect resolves links on its final path', async () => {
84
+ mockRedirectFetch({
85
+ 'https://example.com/llms.txt': {
86
+ body: `# Root\n\n## Index\n- [Guide llms](/guide/llms.txt)\n`,
87
+ },
88
+ 'https://example.com/guide/llms.txt': {
89
+ finalUrl: 'https://example.com/docs/guide/llms.txt',
90
+ body: `# Guide\n\n## Guides\n- [Intro](intro.md)\n- [Setup](setup.md)\n`,
91
+ },
92
+ })
93
+
94
+ const discovery = await __test__.discoverLlmsTxt(new URL('https://example.com'))
95
+ const merged = __test__.mergeValidHits(discovery.hits)
96
+ const urls = mergedItems(merged).map((item) => item.url)
97
+
98
+ assert(urls.includes('https://example.com/docs/guide/intro.md'))
99
+ assert(urls.includes('https://example.com/docs/guide/setup.md'))
100
+ assert(!urls.includes('https://example.com/guide/intro.md'))
101
+ assert(!urls.includes('https://example.com/guide/setup.md'))
102
+ })
103
+
104
+ test('resolveRedirectedSourceUrl adopts cross-origin path redirects', async () => {
105
+ mockRedirectFetch({
106
+ 'https://docs.example.com/': { finalUrl: 'https://example.com/docs' },
107
+ })
108
+ const final = await __test__.resolveRedirectedSourceUrl(new URL('https://docs.example.com/'))
109
+ assert.equal(final?.href, 'https://example.com/docs')
110
+ })
111
+
112
+ test('resolveRedirectedSourceUrl ignores trailing-slash redirects', async () => {
113
+ mockRedirectFetch({
114
+ 'https://example.com/docs': { finalUrl: 'https://example.com/docs/' },
115
+ })
116
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/docs')), null)
117
+ })
118
+
119
+ test('resolveRedirectedSourceUrl ignores query and hash-only redirects', async () => {
120
+ mockRedirectFetch({
121
+ 'https://example.com/docs': { finalUrl: 'https://example.com/docs?lang=en#top' },
122
+ })
123
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/docs')), null)
124
+ })
125
+
126
+ test('resolveRedirectedSourceUrl returns null on non-ok responses', async () => {
127
+ globalThis.fetch = async (url) => ({ ok: false, status: 500, url: String(url), text: async () => '' })
128
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
129
+ })
130
+
131
+ test('resolveRedirectedSourceUrl returns null when fetch throws', async () => {
132
+ globalThis.fetch = async () => {
133
+ throw new Error('network down')
134
+ }
135
+ assert.equal(await __test__.resolveRedirectedSourceUrl(new URL('https://example.com/')), null)
136
+ })
@@ -1,22 +1,22 @@
1
- import { createRequire } from 'node:module';
2
- import { collectFiles, runValidators } from '../utils/lint.js';
3
- import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js';
4
- import { printHeader, isAgenticCli } from '../utils/eyes.js';
5
- import * as styles from '../utils/styles.js';
1
+ import { createRequire } from 'node:module'
2
+ import { collectFiles, runValidators } from '../utils/lint.js'
3
+ import { createHumanReporter, createJsonReporter, createGithubReporter } from '../utils/reporter.js'
4
+ import { printHeader, isAgenticCli } from '../utils/eyes.js'
5
+ import * as styles from '../utils/styles.js'
6
6
 
7
- const require = createRequire(import.meta.url);
8
- const pkg = require('../../package.json');
7
+ const require = createRequire(import.meta.url)
8
+ const pkg = require('../../package.json')
9
9
 
10
- export const command = 'lint';
11
- export const order = 1;
12
- export const aliases = ['validate'];
13
- export const category = 'Linting';
14
- export const description = 'Lint and validate your ReadMe docs';
10
+ export const command = 'lint'
11
+ export const order = 1
12
+ export const aliases = ['validate']
13
+ export const category = 'Linting'
14
+ export const description = 'Lint and validate your ReadMe docs'
15
15
 
16
16
  export function args(cmd) {
17
- cmd.option('--json', 'Output results as JSON (for CI and automation)');
18
- cmd.option('--github', 'Output a GitHub PR comment body as markdown');
19
- cmd.option('--fix', 'Automatically fix issues where possible');
17
+ cmd.option('--json', 'Output results as JSON (for CI and automation)')
18
+ cmd.option('--github', 'Output a GitHub PR comment body as markdown')
19
+ cmd.option('--fix', 'Automatically fix issues where possible')
20
20
  }
21
21
 
22
22
  /**
@@ -25,46 +25,43 @@ export function args(cmd) {
25
25
  * exit on their own terms.
26
26
  *
27
27
  * @param {object} [opts]
28
- * @param {string} [opts.cwd] Repo root to lint (defaults to process.cwd()).
29
- * @param {boolean} [opts.fix] Apply autofixes where validators support it.
28
+ * @param {string} [opts.cwd] Repo root to lint (defaults to process.cwd()).
29
+ * @param {boolean} [opts.fix] Apply autofixes where validators support it.
30
+ * @param {boolean} [opts.nonInteractive] Skip any interactive prompts (e.g. rename confirmation). Fixes that would normally prompt are not applied.
30
31
  * @param {(file: string) => void} [opts.onFile] Called per file before validation.
31
32
  * @param {() => void} [opts.onBeforeCrossFile] Called once before cross-file checks run.
32
33
  * @returns {Promise<{ gitRoot: string, files: string[], results: object[], hasErrors: boolean }>}
33
34
  */
34
- export async function lint({ cwd, fix = false, onFile, onBeforeCrossFile } = {}) {
35
- const gitRoot = cwd || process.cwd();
36
- const files = collectFiles(gitRoot);
37
- const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix });
38
- const hasErrors = results.some((r) => r.severity !== 'warning');
39
- return { gitRoot, files, results, hasErrors };
35
+ export async function lint({ cwd, fix = false, nonInteractive = false, onFile, onBeforeCrossFile } = {}) {
36
+ const gitRoot = cwd || process.cwd()
37
+ const files = collectFiles(gitRoot)
38
+ const results = await runValidators(files, gitRoot, { onFile, onBeforeCrossFile, fix, nonInteractive })
39
+ const hasErrors = results.some((r) => r.severity !== 'warning')
40
+ return { gitRoot, files, results, hasErrors }
40
41
  }
41
42
 
42
43
  export async function run(options, _cmd, ctx) {
43
- const { gitRoot } = ctx;
44
+ const { gitRoot } = ctx
44
45
 
45
46
  if (!options.json && !options.github && !isAgenticCli()) {
46
- printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' });
47
- console.log();
47
+ printHeader({ version: pkg.version, binName: styles.binName(), indent: ' ' })
48
+ console.log()
48
49
  }
49
50
 
50
- const reporter = options.github
51
- ? createGithubReporter()
52
- : options.json
53
- ? createJsonReporter()
54
- : createHumanReporter();
51
+ const reporter = options.github ? createGithubReporter() : options.json ? createJsonReporter() : createHumanReporter()
55
52
 
56
53
  const { files, results, hasErrors } = await lint({
57
54
  cwd: gitRoot,
58
55
  fix: options.fix,
59
56
  onFile: (f) => reporter.onFile(f),
60
57
  onBeforeCrossFile: () => reporter.pause(),
61
- });
58
+ })
62
59
 
63
- reporter.finish(files.length, results, files, { fix: options.fix, gitRoot });
60
+ reporter.finish(files.length, results, files, { fix: options.fix, gitRoot })
64
61
 
65
62
  if (hasErrors) {
66
63
  // Wait for stdout to flush (important when piped to a file), then exit
67
- await new Promise((resolve) => process.stdout.write('', resolve));
68
- process.exit(1);
64
+ await new Promise((resolve) => process.stdout.write('', resolve))
65
+ process.exit(1)
69
66
  }
70
67
  }
@@ -14,12 +14,6 @@ export const description = 'Sync reference pages with OpenAPI specs';
14
14
 
15
15
  const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
16
16
 
17
- /**
18
- * Check if this is a ReadMeConfig spec (internal ReadMe pages — skip title/excerpt updates).
19
- */
20
- function isReadMeConfig(spec) {
21
- return spec.info?.title === 'ReadMeConfig';
22
- }
23
17
 
24
18
  /**
25
19
  * Find OAS files at the root of reference/ (JSON or YAML).
@@ -168,19 +162,31 @@ function removeFromOrder(orderPath, slug) {
168
162
  }
169
163
  }
170
164
 
171
- function buildPageContent({ oasFilename, operationId, summary, description }) {
165
+ /**
166
+ * Spec-derived values (info.title, tags, operationIds) become directory and
167
+ * file names. Collapse path separators and dot-only names into a single safe
168
+ * segment so a crafted spec can't write outside reference/.
169
+ */
170
+ function safeSegment(value, fallback) {
171
+ const segment = String(value).replace(/[/\\]/g, '-').trim();
172
+ return !segment || segment === '.' || segment === '..' ? fallback : segment;
173
+ }
174
+
175
+ function isWithin(baseDir, target) {
176
+ const rel = path.relative(baseDir, target);
177
+ return (
178
+ rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
179
+ );
180
+ }
181
+
182
+ function buildPageContent({ oasFilename, operationId }) {
172
183
  const frontmatter = {
173
- title: summary || operationId,
174
184
  api: {
175
185
  file: oasFilename,
176
186
  operationId,
177
187
  },
178
188
  };
179
189
 
180
- if (description) {
181
- frontmatter.excerpt = description;
182
- }
183
-
184
190
  return matter.stringify('', frontmatter);
185
191
  }
186
192
 
@@ -189,8 +195,10 @@ function buildPageContent({ oasFilename, operationId, summary, description }) {
189
195
  */
190
196
  function syncOneOas(refDir, oasFilename, spec) {
191
197
  const specOps = extractOperations(spec);
192
- const infoTitle = spec.info?.title || path.basename(oasFilename, path.extname(oasFilename));
193
- const skipUpdates = isReadMeConfig(spec);
198
+ const infoTitle = safeSegment(
199
+ spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
200
+ 'api',
201
+ );
194
202
 
195
203
  const existingPages = collectExistingPages(refDir).filter(
196
204
  (p) => p.data.api.file === oasFilename,
@@ -201,7 +209,7 @@ function syncOneOas(refDir, oasFilename, spec) {
201
209
  pagesByOpId.set(page.data.api.operationId, page);
202
210
  }
203
211
 
204
- const changes = { added: [], deleted: [], updated: [] };
212
+ const changes = { added: [], deleted: [], skipped: [] };
205
213
 
206
214
  // Deletes: pages referencing operations that no longer exist.
207
215
  for (const [opId, page] of pagesByOpId) {
@@ -216,61 +224,32 @@ function syncOneOas(refDir, oasFilename, spec) {
216
224
  }
217
225
  }
218
226
 
219
- // Adds + Updates.
227
+ // Adds: operations with no page yet. Title/excerpt are owned by the OAS spec
228
+ // at render time, so generated pages carry only the api reference.
220
229
  for (const [opId, op] of specOps) {
221
- const existing = pagesByOpId.get(opId);
222
- const tag = op.tag || 'Other';
223
-
224
- if (!existing) {
225
- const pageDir = path.join(refDir, infoTitle, tag);
226
- fs.mkdirSync(pageDir, { recursive: true });
227
-
228
- const pagePath = path.join(pageDir, `${opId}.md`);
229
- const content = buildPageContent({
230
- oasFilename,
231
- operationId: opId,
232
- summary: op.summary,
233
- description: op.description,
234
- });
235
- fs.writeFileSync(pagePath, content);
236
-
237
- addToOrder(path.join(pageDir, '_order.yaml'), opId);
238
- addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
239
-
240
- changes.added.push(path.relative(refDir, pagePath));
241
- } else if (!skipUpdates) {
242
- const expectedTitle = op.summary || opId;
243
- const expectedExcerpt = op.description || null;
244
- const currentTitle = existing.data.title;
245
- const currentExcerpt = existing.data.excerpt || null;
246
-
247
- const titleChanged = currentTitle !== expectedTitle;
248
- const excerptChanged = currentExcerpt !== expectedExcerpt;
249
-
250
- if (titleChanged || excerptChanged) {
251
- const updated = { ...existing.data };
252
- const updateDetails = [];
230
+ if (pagesByOpId.has(opId)) continue;
231
+
232
+ const tag = safeSegment(op.tag || 'Other', 'Other');
233
+ const slug = safeSegment(opId, 'operation');
234
+ const pageDir = path.join(refDir, infoTitle, tag);
235
+ const pagePath = path.join(pageDir, `${slug}.md`);
236
+
237
+ // Never overwrite an existing file: it belongs to a manual page, another
238
+ // spec, or a different operation whose sanitized name collides with this
239
+ // one. Skipping (rather than clobbering) keeps repeated syncs stable.
240
+ if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
241
+ changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: opId });
242
+ continue;
243
+ }
244
+ fs.mkdirSync(pageDir, { recursive: true });
253
245
 
254
- if (titleChanged) {
255
- updated.title = expectedTitle;
256
- updateDetails.push('title');
257
- }
258
- if (excerptChanged) {
259
- if (expectedExcerpt) {
260
- updated.excerpt = expectedExcerpt;
261
- } else {
262
- delete updated.excerpt;
263
- }
264
- updateDetails.push('excerpt');
265
- }
246
+ const content = buildPageContent({ oasFilename, operationId: opId });
247
+ fs.writeFileSync(pagePath, content);
266
248
 
267
- const body = matter(existing.content).content;
268
- const newContent = matter.stringify(body, updated);
269
- fs.writeFileSync(existing.filePath, newContent);
249
+ addToOrder(path.join(pageDir, '_order.yaml'), slug);
250
+ addToOrder(path.join(refDir, infoTitle, '_order.yaml'), tag);
270
251
 
271
- changes.updated.push(`${existing.relativePath} (${updateDetails.join(', ')})`);
272
- }
273
- }
252
+ changes.added.push(path.relative(refDir, pagePath));
274
253
  }
275
254
 
276
255
  return changes;
@@ -285,7 +264,7 @@ function syncOneOas(refDir, oasFilename, spec) {
285
264
  *
286
265
  * @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
287
266
  * @returns {null | Array<{ filename: string, spec: object, opCount: number,
288
- * changes: { added: string[], deleted: string[], updated: string[] } }>}
267
+ * changes: { added: string[], deleted: string[] } }>}
289
268
  * Returns null if there's no reference/ dir or no specs.
290
269
  */
291
270
  export function syncOas(input) {
@@ -325,11 +304,12 @@ export async function run(_options, _cmd, ctx) {
325
304
 
326
305
  let totalAdded = 0;
327
306
  let totalDeleted = 0;
328
- let totalUpdated = 0;
307
+ let totalSkipped = 0;
329
308
 
330
309
  for (const { filename, spec, opCount, changes } of results) {
331
310
  const title = spec.info?.title || filename;
332
- const hasChanges = changes.added.length + changes.deleted.length + changes.updated.length > 0;
311
+ const hasChanges =
312
+ changes.added.length + changes.deleted.length + changes.skipped.length > 0;
333
313
 
334
314
  const dot = hasChanges ? styles.warn('●') : styles.success('●');
335
315
  console.log();
@@ -345,20 +325,25 @@ export async function run(_options, _cmd, ctx) {
345
325
  for (const file of changes.deleted) {
346
326
  console.log(` ${styles.err('−')} Deleted ${file}`);
347
327
  }
348
- for (const file of changes.updated) {
349
- console.log(` ${styles.warn('~')} Updated ${file}`);
328
+ for (const { path: file, operationId } of changes.skipped) {
329
+ console.log(
330
+ ` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
331
+ );
350
332
  }
351
333
 
352
334
  totalAdded += changes.added.length;
353
335
  totalDeleted += changes.deleted.length;
354
- totalUpdated += changes.updated.length;
336
+ totalSkipped += changes.skipped.length;
355
337
  }
356
338
 
357
339
  console.log();
358
- const total = totalAdded + totalDeleted + totalUpdated;
359
- if (total === 0) {
340
+ const total = totalAdded + totalDeleted;
341
+ const skippedNote = totalSkipped > 0 ? `, ${totalSkipped} skipped` : '';
342
+ if (total === 0 && totalSkipped === 0) {
360
343
  styles.ok('Reference pages are already in sync.');
344
+ } else if (total === 0) {
345
+ styles.warning(`No pages synced; ${totalSkipped} skipped (destination already exists).`);
361
346
  } else {
362
- styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted, ${totalUpdated} updated.`);
347
+ styles.ok(`Synced: ${totalAdded} added, ${totalDeleted} deleted${skippedNote}.`);
363
348
  }
364
349
  }