@readme/cli 0.1.0 → 0.3.0

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 (57) hide show
  1. package/bin/git-format.js +80 -0
  2. package/bin/readme.js +1 -1
  3. package/package.json +54 -26
  4. package/src/bootstrap.js +9 -13
  5. package/src/cli.js +17 -11
  6. package/src/commands/components.js +35 -18
  7. package/src/commands/dev.js +12 -8
  8. package/src/commands/eyes.js +12 -8
  9. package/src/commands/import.js +2200 -2049
  10. package/src/commands/lint.js +33 -29
  11. package/src/commands/oas-bundle.js +6 -2
  12. package/src/commands/oas-sync.js +271 -161
  13. package/src/commands/oas-validate.js +10 -7
  14. package/src/commands/pretty.js +188 -165
  15. package/src/commands/setup.js +24 -26
  16. package/src/commands/versions.js +6 -2
  17. package/src/dev/app/[...slug]/page.js +1 -0
  18. package/src/dev/app/layout.js +18 -11
  19. package/src/dev/app/page.js +1 -0
  20. package/src/dev/lib/docs.js +27 -19
  21. package/src/dev/next.config.mjs +2 -2
  22. package/src/gha.js +7 -7
  23. package/src/prompts/index.js +16 -6
  24. package/src/utils/containment.js +87 -0
  25. package/src/utils/eyes.js +143 -51
  26. package/src/utils/git.js +3 -3
  27. package/src/utils/lint.js +39 -38
  28. package/src/utils/llms.js +94 -91
  29. package/src/utils/markdown.js +256 -0
  30. package/src/utils/marketplace.js +12 -5
  31. package/src/utils/multi-spinner.js +5 -7
  32. package/src/utils/promise-pool.js +7 -5
  33. package/src/utils/reporter.js +88 -94
  34. package/src/utils/setup-templates.js +12 -6
  35. package/src/utils/styles.js +2 -1
  36. package/src/utils/tamagotchi.js +257 -91
  37. package/src/utils/tips.js +18 -9
  38. package/src/utils/url-segs.js +46 -41
  39. package/src/validators/components.js +55 -63
  40. package/src/validators/content.js +16 -14
  41. package/src/validators/duplicates.js +6 -2
  42. package/src/validators/frontmatter.js +21 -29
  43. package/src/validators/links.js +4 -8
  44. package/src/validators/oas-reference.js +25 -18
  45. package/src/validators/oas-schema.js +2 -0
  46. package/src/validators/ordering.js +5 -5
  47. package/src/validators/recipes.js +4 -12
  48. package/vendor/git-format/CLAUDE.md +352 -0
  49. package/vendor/git-format/frontmatter.schema.json +199 -0
  50. package/src/commands/import-oas.test.js +0 -277
  51. package/src/commands/import.classify.test.js +0 -172
  52. package/src/commands/import.landing.test.js +0 -217
  53. package/src/commands/import.slug-planner.test.js +0 -86
  54. package/src/commands/import.staging.test.js +0 -230
  55. package/src/commands/import.test.js +0 -382
  56. package/src/utils/llms.test.js +0 -302
  57. package/src/utils/url-segs.test.js +0 -239
@@ -1,12 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { createRequire } from 'node:module';
4
- import matter from 'gray-matter';
5
- import * as styles from '../utils/styles.js';
6
- import { writeGithubActionsOutputs } from '../utils/gha-output.js';
7
3
 
8
- const require = createRequire(import.meta.url);
9
- const yaml = require('js-yaml');
4
+ import { CORE_SCHEMA, load as loadYAML, mergeTag } from 'js-yaml';
5
+
6
+ import { makeContainment, PathEscapeError } from '../utils/containment.js';
7
+ import { writeGithubActionsOutputs } from '../utils/gha-output.js';
8
+ import { parseFrontmatter, toFrontmatter, frontmatterWriteError } from '../utils/markdown.js';
9
+ import * as styles from '../utils/styles.js';
10
10
 
11
11
  export const command = 'oas:sync';
12
12
  export const order = 2;
@@ -15,25 +15,31 @@ export const description = 'Sync reference pages with OpenAPI specs';
15
15
 
16
16
  const HTTP_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']);
17
17
 
18
+ // js-yaml 5's CORE_SCHEMA omits YAML merge keys (`<<: *anchor`). OpenAPI specs commonly
19
+ // reuse path items via merges — keep that working when reading YAML specs.
20
+ const OAS_YAML_SCHEMA = CORE_SCHEMA.withTags(mergeTag);
18
21
 
19
22
  /**
20
23
  * Find OAS files at the root of reference/ (JSON or YAML).
21
24
  */
22
25
  export function findOasFiles(refDir) {
23
- const entries = fs.readdirSync(refDir);
26
+ const entries = fs.readdirSync(refDir, { withFileTypes: true });
24
27
  const oasFiles = [];
25
28
 
26
29
  for (const entry of entries) {
27
- if (!/\.(json|yaml|yml)$/i.test(entry)) continue;
28
- const filePath = path.join(refDir, entry);
29
- if (!fs.statSync(filePath).isFile()) continue;
30
+ // `stat` follows links. A spec committed as a symlink would be read from
31
+ // outside the repo; skip it. `syncOas` refuses the link itself, including
32
+ // when it is the only spec, so this skip cannot turn into a silent success.
33
+ if (entry.isSymbolicLink() || !entry.isFile()) continue;
34
+ if (!/\.(json|yaml|yml)$/i.test(entry.name)) continue;
35
+ const filePath = path.join(refDir, entry.name);
30
36
 
31
37
  try {
32
38
  const raw = fs.readFileSync(filePath, 'utf-8');
33
- const parsed = entry.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
39
+ const parsed = entry.name.endsWith('.json') ? JSON.parse(raw) : loadYAML(raw, { schema: OAS_YAML_SCHEMA });
34
40
 
35
41
  if (parsed && (parsed.openapi || parsed.swagger)) {
36
- oasFiles.push({ filename: entry, spec: parsed });
42
+ oasFiles.push({ filename: entry.name, spec: parsed });
37
43
  }
38
44
  } catch {
39
45
  // Skip files that can't be parsed.
@@ -139,7 +145,7 @@ function resolveXInternal(operation, spec) {
139
145
  * `webhook POST newPet`) for lint to warn about.
140
146
  */
141
147
  export function findIgnoredInternalExtensions(spec) {
142
- const hasInternal = (obj) => {
148
+ const hasInternal = obj => {
143
149
  const xReadme = obj?.['x-readme'];
144
150
  return !!xReadme && typeof xReadme === 'object' && 'internal' in xReadme;
145
151
  };
@@ -147,7 +153,10 @@ export function findIgnoredInternalExtensions(spec) {
147
153
  const locations = [];
148
154
  if (hasInternal(spec)) locations.push('root');
149
155
 
150
- for (const [entries, isWebhook] of [[spec?.paths, false], [spec?.webhooks, true]]) {
156
+ for (const [entries, isWebhook] of [
157
+ [spec?.paths, false],
158
+ [spec?.webhooks, true],
159
+ ]) {
151
160
  for (const [name, rawItem] of Object.entries(entries || {})) {
152
161
  for (const [method, operation] of Object.entries(resolveLocalPathItemRef(rawItem, spec) || {})) {
153
162
  if (!HTTP_METHODS.has(method) || !hasInternal(operation)) continue;
@@ -199,7 +208,7 @@ export function extractOperations(spec) {
199
208
  summary: operation.summary || null,
200
209
  description: operation.description || null,
201
210
  // The platform groups by the first *non-empty* tag.
202
- tag: (Array.isArray(operation.tags) && operation.tags.find((t) => t)) || null,
211
+ tag: (Array.isArray(operation.tags) && operation.tags.find(t => t)) || null,
203
212
  path: pathStr,
204
213
  isWebhook,
205
214
  xInternal: resolveXInternal(operation, spec),
@@ -222,25 +231,23 @@ export function collectExistingPages(refDir) {
222
231
  const pages = [];
223
232
 
224
233
  function walk(dir) {
225
- for (const entry of fs.readdirSync(dir)) {
226
- const full = path.join(dir, entry);
227
- const stat = fs.statSync(full);
228
- if (stat.isDirectory()) {
234
+ // Dirent, not `stat`: `stat` follows symlinks, so a directory link
235
+ // committed under reference/ would be walked and its pages later unlinked.
236
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
237
+ if (entry.isSymbolicLink()) continue;
238
+ const full = path.join(dir, entry.name);
239
+ if (entry.isDirectory()) {
229
240
  walk(full);
230
- } else if (entry.endsWith('.md')) {
231
- try {
232
- const content = fs.readFileSync(full, 'utf-8');
233
- const { data } = matter(content);
234
- if (data.api && data.api.file && data.api.operationId) {
235
- pages.push({
236
- filePath: full,
237
- relativePath: path.relative(refDir, full),
238
- data,
239
- content,
240
- });
241
- }
242
- } catch {
243
- // Skip unparseable files.
241
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
242
+ const content = fs.readFileSync(full, 'utf-8');
243
+ const { data } = parseFrontmatter(content);
244
+ if (data.api && data.api.file && data.api.operationId) {
245
+ pages.push({
246
+ filePath: full,
247
+ relativePath: path.relative(refDir, full),
248
+ data,
249
+ content,
250
+ });
244
251
  }
245
252
  }
246
253
  }
@@ -259,36 +266,55 @@ function yamlSafeSlug(slug) {
259
266
  function parseOrderYaml(content) {
260
267
  return content
261
268
  .split('\n')
262
- .map((line) => line.trim())
263
- .filter((line) => line.startsWith('- '))
264
- .map((line) => line.slice(2).trim().replace(/^["'](.+)["']$/, '$1'));
269
+ .map(line => line.trim())
270
+ .filter(line => line.startsWith('- '))
271
+ .map(line =>
272
+ line
273
+ .slice(2)
274
+ .trim()
275
+ .replace(/^["'](.+)["']$/, '$1'),
276
+ );
265
277
  }
266
278
 
267
- function writeOrderYaml(filePath, slugs) {
268
- const content = slugs.map((s) => `- ${yamlSafeSlug(s)}`).join('\n') + '\n';
279
+ function writeOrderYaml(filePath, slugs, assertContained) {
280
+ assertContained(filePath);
281
+ const content = `${slugs.map(s => `- ${yamlSafeSlug(s)}`).join('\n')}\n`;
269
282
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
270
283
  fs.writeFileSync(filePath, content);
271
284
  }
272
285
 
273
- function addToOrder(orderPath, slug) {
274
- if (fs.existsSync(orderPath)) {
275
- const content = fs.readFileSync(orderPath, 'utf-8');
276
- const slugs = parseOrderYaml(content);
277
- if (!slugs.includes(slug)) {
278
- slugs.push(slug);
279
- writeOrderYaml(orderPath, slugs);
280
- }
281
- } else {
282
- writeOrderYaml(orderPath, [slug]);
286
+ // `assertContained` allows a path that does not exist yet, so lstat first to
287
+ // tell an absent order file from one that must be refused before it is read.
288
+ function readOrderIfExists(orderPath, assertContained) {
289
+ try {
290
+ fs.lstatSync(orderPath);
291
+ } catch (err) {
292
+ if (err.code === 'ENOENT') return null;
293
+ throw err;
283
294
  }
295
+ assertContained(orderPath);
296
+ return fs.readFileSync(orderPath, 'utf-8');
284
297
  }
285
298
 
286
- function removeFromOrder(orderPath, slug) {
287
- if (!fs.existsSync(orderPath)) return;
288
- const content = fs.readFileSync(orderPath, 'utf-8');
289
- const slugs = parseOrderYaml(content).filter((s) => s !== slug);
299
+ function addToOrder(orderPath, slug, assertContained) {
300
+ const content = readOrderIfExists(orderPath, assertContained);
301
+ if (content === null) {
302
+ writeOrderYaml(orderPath, [slug], assertContained);
303
+ return;
304
+ }
305
+ const slugs = parseOrderYaml(content);
306
+ if (!slugs.includes(slug)) {
307
+ slugs.push(slug);
308
+ writeOrderYaml(orderPath, slugs, assertContained);
309
+ }
310
+ }
311
+
312
+ function removeFromOrder(orderPath, slug, assertContained) {
313
+ const content = readOrderIfExists(orderPath, assertContained);
314
+ if (content === null) return;
315
+ const slugs = parseOrderYaml(content).filter(s => s !== slug);
290
316
  if (slugs.length > 0) {
291
- writeOrderYaml(orderPath, slugs);
317
+ writeOrderYaml(orderPath, slugs, assertContained);
292
318
  } else {
293
319
  fs.unlinkSync(orderPath);
294
320
  }
@@ -304,21 +330,33 @@ function safeSegment(value, fallback) {
304
330
  return !segment || segment === '.' || segment === '..' ? fallback : segment;
305
331
  }
306
332
 
333
+ /**
334
+ * Lexical "is `target` inside `baseDir`?" for placement decisions about
335
+ * pages the walk already found without following links. Not a write guard —
336
+ * writes go through `assertContained`, which refuses symlinks. `path.relative`
337
+ * alone treats a symlink under reference/ as inside the repo.
338
+ */
307
339
  function isWithin(baseDir, target) {
308
340
  const rel = path.relative(baseDir, target);
309
- return (
310
- rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
311
- );
341
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
312
342
  }
313
343
 
314
344
  /**
315
- * Render a frontmatter-only page. `matter.stringify` always appends a blank
316
- * body after the closing fence (even for an empty body); the platform's own
317
- * generated pages end immediately after the fence with no trailing newline,
318
- * so trim it to match.
345
+ * Throw if any symlink exists under `refDir`. Run before the first mutation:
346
+ * a link discovered halfway through the sync would otherwise leave the
347
+ * in-repo half of the run applied. Does not follow links.
319
348
  */
320
- function stringifyFrontmatter(frontmatter) {
321
- return matter.stringify('', frontmatter).replace(/\n+$/, '');
349
+ function refuseSymlinks(refDir) {
350
+ const walk = dir => {
351
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
352
+ const full = path.join(dir, entry.name);
353
+ if (entry.isSymbolicLink()) {
354
+ throw new PathEscapeError(full, 'symbolic links inside the reference directory are not followed');
355
+ }
356
+ if (entry.isDirectory()) walk(full);
357
+ }
358
+ };
359
+ walk(refDir);
322
360
  }
323
361
 
324
362
  function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false }) {
@@ -339,7 +377,7 @@ function buildPageContent({ oasFilename, operationId, isWebhook, hidden = false
339
377
  hidden,
340
378
  };
341
379
 
342
- return stringifyFrontmatter(frontmatter);
380
+ return toFrontmatter(frontmatter);
343
381
  }
344
382
 
345
383
  /**
@@ -354,7 +392,7 @@ function buildTagIndexContent(title, description) {
354
392
  // As with operation pages, upload always stamps hidden: false on new pages.
355
393
  frontmatter.hidden = false;
356
394
 
357
- return stringifyFrontmatter(frontmatter);
395
+ return toFrontmatter(frontmatter);
358
396
  }
359
397
 
360
398
  /**
@@ -402,13 +440,16 @@ function operationGroup(op) {
402
440
  */
403
441
  function existingFoldersBySlug(apiDir) {
404
442
  const bySlug = new Map();
405
- let entries;
406
443
  try {
407
- entries = fs.readdirSync(apiDir, { withFileTypes: true });
408
- } catch {
409
- return bySlug; // apiDir doesn't exist yet — nothing to reuse.
444
+ // Caller has already refused a symlink at apiDir. lstat only distinguishes
445
+ // "not created yet" from a real directory `readdir` can list.
446
+ fs.lstatSync(apiDir);
447
+ } catch (err) {
448
+ if (err.code === 'ENOENT') return bySlug; // apiDir doesn't exist yet — nothing to reuse.
449
+ throw err;
410
450
  }
411
- for (const entry of entries) {
451
+
452
+ for (const entry of fs.readdirSync(apiDir, { withFileTypes: true })) {
412
453
  if (entry.isDirectory()) bySlug.set(slugifyFolder(entry.name), entry.name);
413
454
  }
414
455
  return bySlug;
@@ -432,20 +473,16 @@ function collectReferenceSlugs(refDir) {
432
473
  const counts = new Map();
433
474
 
434
475
  function walk(dir) {
435
- for (const entry of fs.readdirSync(dir)) {
436
- const full = path.join(dir, entry);
437
- let stat;
438
- try {
439
- stat = fs.statSync(full);
440
- } catch {
441
- continue;
442
- }
443
- if (stat.isDirectory()) {
476
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
477
+ if (entry.isSymbolicLink()) continue;
478
+
479
+ const full = path.join(dir, entry.name);
480
+ if (entry.isDirectory()) {
444
481
  walk(full);
445
- } else if (entry.endsWith('.md')) {
482
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
446
483
  // A folder's index.md contributes the folder name as a slug; any other
447
484
  // page contributes its own filename.
448
- const slug = entry === 'index.md' ? path.basename(dir) : path.basename(entry, '.md');
485
+ const slug = entry.name === 'index.md' ? path.basename(dir) : path.basename(entry.name, '.md');
449
486
  takeSlug(counts, slug);
450
487
  }
451
488
  }
@@ -493,18 +530,19 @@ function reserveSlug(takenSlugs, base) {
493
530
  /**
494
531
  * Rewrite a page's frontmatter in place, keeping its body. `mutate` receives
495
532
  * a copy of the parsed frontmatter and edits it. Returns true if the file
496
- * changed. A copy matters: gray-matter caches parse results by input string,
497
- * so mutating the returned `data` would poison later parses of that content.
533
+ * changed.
498
534
  */
499
- function updateFrontmatter(filePath, mutate) {
535
+ function updateFrontmatter(filePath, mutate, assertContained) {
536
+ assertContained(filePath);
500
537
  const content = fs.readFileSync(filePath, 'utf-8');
501
- const parsed = matter(content);
538
+ const parsed = parseFrontmatter(content);
539
+ // Refuse to rewrite from empty `data` when YAML failed to parse.
540
+ if (frontmatterWriteError(parsed)) return false;
502
541
  const data = structuredClone(parsed.data);
503
542
  mutate(data);
504
- const next = parsed.content.trim()
505
- ? matter.stringify(parsed.content, data)
506
- : stringifyFrontmatter(data);
543
+ const next = parsed.content.trim() ? toFrontmatter(data, parsed.content) : toFrontmatter(data);
507
544
  if (next === content || JSON.stringify(data) === JSON.stringify(parsed.data)) return false;
545
+ assertContained(filePath);
508
546
  fs.writeFileSync(filePath, next);
509
547
  return true;
510
548
  }
@@ -527,7 +565,7 @@ export function applyOASOrder(currentOrder, orderedSlugs) {
527
565
  const order = [...new Set(currentOrder)];
528
566
  if (!order.length) return desired;
529
567
 
530
- const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter((i) => i > -1);
568
+ const slots = order.map((s, i) => (desiredSet.has(s) ? i : -1)).filter(i => i > -1);
531
569
  if (!slots.length) return [...order, ...desired];
532
570
 
533
571
  const remaining = [...desired];
@@ -561,21 +599,17 @@ const INDEX_FILES = ['index.md', 'index.mdx', 'index.html'];
561
599
  * deleting it. A stale page can be removed by hand; a deleted edit is gone.
562
600
  */
563
601
  function isGeneratedTagIndex(indexPath, specDescriptions) {
564
- let parsed;
565
- try {
566
- parsed = matter(fs.readFileSync(indexPath, 'utf-8'));
567
- } catch {
568
- return false;
569
- }
570
- const { data, content } = parsed;
571
- if (content.trim()) return false;
602
+ const { data, content, error } = parseFrontmatter(fs.readFileSync(indexPath, 'utf-8'));
603
+ if (error || content.trim()) return false;
572
604
  if (typeof data.title !== 'string' || !('hidden' in data)) return false;
573
- if (Object.keys(data).some((k) => !['title', 'hidden', 'excerpt'].includes(k))) return false;
605
+ if (Object.keys(data).some(k => !['title', 'hidden', 'excerpt'].includes(k))) return false;
574
606
  if ('excerpt' in data && !specDescriptions.has(data.excerpt)) return false;
575
607
 
576
608
  const dirSlug = slugifyFolder(path.basename(path.dirname(indexPath)));
577
609
  const titleSlug = slugifyFolder(data.title);
578
- return dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug);
610
+ return (
611
+ dirSlug === titleSlug || new RegExp(`^${titleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-\\d+$`).test(dirSlug)
612
+ );
579
613
  }
580
614
 
581
615
  /**
@@ -585,23 +619,32 @@ function isGeneratedTagIndex(indexPath, specDescriptions) {
585
619
  * by flattening `tag/index.md` into a sibling `tag.md` (same slug, so the
586
620
  * parent `_order.yaml` entry still applies).
587
621
  */
588
- function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }) {
589
- let entries;
622
+ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained }) {
623
+ assertContained(dir);
590
624
  try {
591
- entries = fs.readdirSync(dir);
592
- } catch {
593
- return;
625
+ fs.lstatSync(dir);
626
+ } catch (err) {
627
+ if (err.code === 'ENOENT') return;
628
+ throw err;
594
629
  }
595
- const others = entries.filter((e) => !INDEX_FILES.includes(e) && e !== '_order.yaml');
630
+
631
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
632
+ for (const entry of entries) {
633
+ assertContained(path.join(dir, entry.name));
634
+ }
635
+
636
+ const names = entries.map(e => e.name);
637
+ const others = names.filter(e => !INDEX_FILES.includes(e) && e !== '_order.yaml');
596
638
  if (others.length) return;
597
639
 
598
640
  const parentDir = path.dirname(dir);
599
641
  const slug = path.basename(dir);
600
- const indexFile = entries.find((e) => INDEX_FILES.includes(e));
642
+ const indexFile = names.find(e => INDEX_FILES.includes(e));
601
643
 
602
644
  if (!indexFile || isGeneratedTagIndex(path.join(dir, indexFile), specDescriptions)) {
645
+ assertContained(dir);
603
646
  fs.rmSync(dir, { recursive: true, force: true });
604
- removeFromOrder(path.join(parentDir, '_order.yaml'), slug);
647
+ removeFromOrder(path.join(parentDir, '_order.yaml'), slug, assertContained);
605
648
  if (indexFile) {
606
649
  releaseSlug(takenSlugs, slug);
607
650
  changes.deleted.push(path.relative(refDir, path.join(dir, indexFile)));
@@ -611,8 +654,11 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
611
654
 
612
655
  const from = path.join(dir, indexFile);
613
656
  const to = path.join(parentDir, `${slug}${path.extname(indexFile)}`);
657
+ assertContained(from);
658
+ assertContained(to);
614
659
  if (fs.existsSync(to)) return;
615
660
  fs.renameSync(from, to);
661
+ assertContained(dir);
616
662
  fs.rmSync(dir, { recursive: true, force: true });
617
663
  changes.moved.push({ from: path.relative(refDir, from), to: path.relative(refDir, to) });
618
664
  }
@@ -623,12 +669,9 @@ function cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes }
623
669
  * `takenSlugs` is the reference-wide set of slugs already in use; it is read and
624
670
  * mutated so slugs stay unique across every spec processed in one sync run.
625
671
  */
626
- function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
672
+ function syncOneOas(refDir, oasFilename, spec, takenSlugs, assertContained) {
627
673
  const specOps = extractOperations(spec);
628
- const infoTitle = safeSegment(
629
- spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)),
630
- 'api',
631
- );
674
+ const infoTitle = safeSegment(spec.info?.title || path.basename(oasFilename, path.extname(oasFilename)), 'api');
632
675
 
633
676
  // Hyphen vs. space in a group's folder name is not a meaningful difference
634
677
  // — "shipping-labels" and "shipping labels" are the same folder to a
@@ -637,18 +680,17 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
637
680
  // folder for this API as an O(1) lookup, rather than walking apiDir again
638
681
  // for each distinct tag/group.
639
682
  const apiDir = path.join(refDir, infoTitle);
683
+
684
+ // Before any read of the target or any delete. A title directory committed
685
+ // as a symlink would otherwise be readdir'd (and later written) through.
686
+ assertContained(apiDir);
640
687
  const foldersBySlug = existingFoldersBySlug(apiDir);
641
688
 
642
- const existingPages = collectExistingPages(refDir).filter(
643
- (p) => p.data.api.file === oasFilename,
644
- );
689
+ const existingPages = collectExistingPages(refDir).filter(p => p.data.api.file === oasFilename);
645
690
 
646
691
  const pagesByOpId = new Map();
647
692
  for (const page of existingPages) {
648
- pagesByOpId.set(
649
- operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }),
650
- page,
651
- );
693
+ pagesByOpId.set(operationKey({ operationId: page.data.api.operationId, isWebhook: !!page.data.api.webhook }), page);
652
694
  }
653
695
 
654
696
  const changes = { added: [], deleted: [], moved: [], updated: [], skipped: [] };
@@ -661,9 +703,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
661
703
  // Tag descriptions from the spec's top-level `tags` array, used for the
662
704
  // per-tag category landing page (index.md).
663
705
  const tagDescriptions = new Map(
664
- (Array.isArray(spec.tags) ? spec.tags : [])
665
- .filter((t) => t && t.name)
666
- .map((t) => [t.name, t.description || null]),
706
+ (Array.isArray(spec.tags) ? spec.tags : []).filter(t => t && t.name).map(t => [t.name, t.description || null]),
667
707
  );
668
708
  // Every description the spec supplies, regardless of which tag: what an
669
709
  // emptied tag folder's excerpt is checked against (see `isGeneratedTagIndex`).
@@ -672,6 +712,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
672
712
  // Deletes: pages referencing operations that no longer exist.
673
713
  for (const [opId, page] of pagesByOpId) {
674
714
  if (!specOps.has(opId)) {
715
+ assertContained(page.filePath);
675
716
  fs.unlinkSync(page.filePath);
676
717
 
677
718
  const pageDir = path.dirname(page.filePath);
@@ -686,7 +727,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
686
727
  const isIndexPage = path.basename(page.filePath) === 'index.md';
687
728
  const pageSlug = path.basename(page.filePath, '.md');
688
729
  const referenceSlug = isIndexPage ? path.basename(pageDir) : pageSlug;
689
- removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug);
730
+ removeFromOrder(path.join(pageDir, '_order.yaml'), pageSlug, assertContained);
690
731
  releaseSlug(takenSlugs, referenceSlug);
691
732
 
692
733
  changes.deleted.push(page.relativePath);
@@ -714,25 +755,49 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
714
755
  // listed in `tags`) keeps its natural encounter order, appended after every
715
756
  // declared tag.
716
757
  const declaredOrder = (Array.isArray(spec.tags) ? spec.tags : [])
717
- .filter((t) => t && t.name)
718
- .map((t) => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
758
+ .filter(t => t && t.name)
759
+ .map(t => slugifyFolder(safeSegment(t.name, 'Other')) || 'other');
719
760
  const orderedFolders = [
720
- ...declaredOrder.filter((folder) => groupsByFolder.has(folder)),
721
- ...[...groupsByFolder.keys()].filter((folder) => !declaredOrder.includes(folder)),
761
+ ...declaredOrder.filter(folder => groupsByFolder.has(folder)),
762
+ ...[...groupsByFolder.keys()].filter(folder => !declaredOrder.includes(folder)),
722
763
  ];
723
764
 
765
+ // Groups that will hold at least one of this spec's pages after this run:
766
+ // a new page is created in its folder, an existing page already lives
767
+ // anywhere under it (a direct child, or nested in a hand-made subfolder),
768
+ // or `apply-tag-changes` will move one in (see the move pass below). A group
769
+ // whose pages were all moved somewhere else (another category, or a folder
770
+ // spelled differently) gets no category page — creating one would leave an
771
+ // empty index.md with no children.
772
+ const populatedFolders = new Set();
773
+ for (const [key, op] of specOps) {
774
+ const { folder } = operationGroup(op);
775
+ const page = pagesByOpId.get(key);
776
+ const tagDir = path.join(apiDir, foldersBySlug.get(folder) || folder);
777
+ if (
778
+ !page ||
779
+ isWithin(tagDir, page.filePath) ||
780
+ (applyTagChanges && isWithin(apiDir, page.filePath) && path.basename(page.filePath) !== 'index.md')
781
+ ) {
782
+ populatedFolders.add(folder);
783
+ }
784
+ }
785
+
724
786
  const groupDirs = new Map();
725
787
  const createdIndexes = new Set();
726
788
  for (const folder of orderedFolders) {
727
789
  const { title, description } = groupsByFolder.get(folder);
728
790
  const actualFolder = foldersBySlug.get(folder) || folder;
729
791
  const pageDir = path.join(refDir, infoTitle, actualFolder);
730
- if (!isWithin(refDir, pageDir)) continue;
731
- groupDirs.set(folder, pageDir);
792
+ assertContained(pageDir);
732
793
 
733
794
  const indexPath = path.join(pageDir, 'index.md');
795
+ if (!populatedFolders.has(folder) && !fs.existsSync(indexPath)) continue;
796
+ groupDirs.set(folder, pageDir);
797
+
734
798
  if (!fs.existsSync(indexPath)) {
735
799
  // Never overwrite an existing index.md — it may be a hand-written category.
800
+ assertContained(indexPath);
736
801
  fs.mkdirSync(pageDir, { recursive: true });
737
802
  fs.writeFileSync(indexPath, buildTagIndexContent(title, description));
738
803
  changes.added.push(path.relative(refDir, indexPath));
@@ -745,15 +810,19 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
745
810
  // With `apply-tag-changes`, the spec owns the category page's title and
746
811
  // excerpt too (a tag with no description clears the excerpt). Its body
747
812
  // and any other frontmatter are the user's and are kept.
748
- const updated = updateFrontmatter(indexPath, (data) => {
749
- data.title = title;
750
- if (description) data.excerpt = description;
751
- else delete data.excerpt;
752
- });
813
+ const updated = updateFrontmatter(
814
+ indexPath,
815
+ data => {
816
+ data.title = title;
817
+ if (description) data.excerpt = description;
818
+ else delete data.excerpt;
819
+ },
820
+ assertContained,
821
+ );
753
822
  if (updated) changes.updated.push(path.relative(refDir, indexPath));
754
823
  }
755
- addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder);
756
- addToOrder(path.join(refDir, '_order.yaml'), infoTitle);
824
+ addToOrder(path.join(refDir, infoTitle, '_order.yaml'), actualFolder, assertContained);
825
+ addToOrder(path.join(refDir, '_order.yaml'), infoTitle, assertContained);
757
826
  }
758
827
 
759
828
  // Where each of this spec's operations ends up this run, by operationKey.
@@ -781,6 +850,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
781
850
  path.dirname(filePath) !== targetDir
782
851
  ) {
783
852
  const target = path.join(targetDir, path.basename(filePath));
853
+ assertContained(filePath);
854
+ assertContained(target);
784
855
  if (fs.existsSync(target)) {
785
856
  changes.skipped.push({ path: path.relative(refDir, target), operationId: op.operationId });
786
857
  } else {
@@ -788,8 +859,8 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
788
859
  const slug = path.basename(filePath, '.md');
789
860
  fs.mkdirSync(targetDir, { recursive: true });
790
861
  fs.renameSync(filePath, target);
791
- removeFromOrder(path.join(fromDir, '_order.yaml'), slug);
792
- addToOrder(path.join(targetDir, '_order.yaml'), slug);
862
+ removeFromOrder(path.join(fromDir, '_order.yaml'), slug, assertContained);
863
+ addToOrder(path.join(targetDir, '_order.yaml'), slug, assertContained);
793
864
  vacatedDirs.add(fromDir);
794
865
  changes.moved.push({ from: page.relativePath, to: path.relative(refDir, target) });
795
866
  filePath = target;
@@ -801,9 +872,13 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
801
872
  // its own `hidden` — removing the extension never unhides a page.
802
873
  if (op.xInternal.present) {
803
874
  const hidden = Boolean(op.xInternal.value);
804
- const updated = updateFrontmatter(filePath, (data) => {
805
- data.hidden = hidden;
806
- });
875
+ const updated = updateFrontmatter(
876
+ filePath,
877
+ data => {
878
+ data.hidden = hidden;
879
+ },
880
+ assertContained,
881
+ );
807
882
  if (updated) changes.updated.push(path.relative(refDir, filePath));
808
883
  }
809
884
 
@@ -824,9 +899,12 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
824
899
  const slug = reserveSlug(takenSlugs, safeSegment(op.operationId, 'operation').toLowerCase());
825
900
  const pagePath = path.join(pageDir, `${slug}.md`);
826
901
 
827
- // Guard against a spec-crafted name escaping reference/, or a stale slug set
828
- // vs. disk. reserveSlug already prevents slug collisions.
829
- if (!isWithin(refDir, pagePath) || fs.existsSync(pagePath)) {
902
+ // A spec-crafted name that still escapes reference/, or a symlink anywhere
903
+ // on the way there, fails the sync instead of being skipped quietly.
904
+ // reserveSlug already prevents slug collisions; an existing real file is
905
+ // the only skip left.
906
+ assertContained(pagePath);
907
+ if (fs.existsSync(pagePath)) {
830
908
  changes.skipped.push({ path: path.relative(refDir, pagePath), operationId: op.operationId });
831
909
  continue;
832
910
  }
@@ -840,7 +918,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
840
918
  });
841
919
  fs.writeFileSync(pagePath, content);
842
920
 
843
- addToOrder(path.join(pageDir, '_order.yaml'), slug);
921
+ addToOrder(path.join(pageDir, '_order.yaml'), slug, assertContained);
844
922
 
845
923
  changes.added.push(path.relative(refDir, pagePath));
846
924
  opPaths.set(key, pagePath);
@@ -861,11 +939,15 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
861
939
  const indexPath = path.join(dir, 'index.md');
862
940
  if (!fs.existsSync(indexPath)) continue;
863
941
  const isNew = createdIndexes.has(indexPath);
864
- const allHidden = children.every((x) => x.present && (isNew ? Boolean(x.value) : x.value === true));
942
+ const allHidden = children.every(x => x.present && (isNew ? Boolean(x.value) : x.value === true));
865
943
  if (!allHidden) continue;
866
- const updated = updateFrontmatter(indexPath, (data) => {
867
- data.hidden = true;
868
- });
944
+ const updated = updateFrontmatter(
945
+ indexPath,
946
+ data => {
947
+ data.hidden = true;
948
+ },
949
+ assertContained,
950
+ );
869
951
  if (updated && !isNew) changes.updated.push(path.relative(refDir, indexPath));
870
952
  }
871
953
 
@@ -873,7 +955,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
873
955
  // category folder, and never a folder an operation still lives in.
874
956
  for (const dir of vacatedDirs) {
875
957
  if (!isWithin(apiDir, dir) || childrenByDir.has(dir)) continue;
876
- cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes });
958
+ cleanupTagFolder(dir, { refDir, specDescriptions, takenSlugs, changes, assertContained });
877
959
  }
878
960
 
879
961
  // `apply-endpoint-order`: reorder each folder's operation pages to match
@@ -897,7 +979,7 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
897
979
  const current = fs.existsSync(orderPath) ? parseOrderYaml(fs.readFileSync(orderPath, 'utf-8')) : [];
898
980
  const next = applyOASOrder(current, slugs);
899
981
  if (next.join('\n') === current.join('\n')) continue;
900
- writeOrderYaml(orderPath, next);
982
+ writeOrderYaml(orderPath, next, assertContained);
901
983
  changes.updated.push(path.relative(refDir, orderPath));
902
984
  }
903
985
  }
@@ -916,7 +998,9 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
916
998
  *
917
999
  * Pure programmatic API: returns per-file change descriptors and prints
918
1000
  * nothing. Used by the CLI command, the lint --fix flow, and external
919
- * callers.
1001
+ * callers. Throws `PathEscapeError` if `reference/` contains a symlink
1002
+ * (before anything is modified) or a destination would leave it.
1003
+ * Callers report that and exit non-zero.
920
1004
  *
921
1005
  * @param {string | { cwd?: string }} input Repo root path, or `{ cwd }` object.
922
1006
  * @returns {null | Array<{ filename: string, spec: object, opCount: number,
@@ -925,13 +1009,22 @@ function syncOneOas(refDir, oasFilename, spec, takenSlugs) {
925
1009
  * Returns null if there's no reference/ dir or no specs.
926
1010
  */
927
1011
  export function syncOas(input) {
928
- const gitRoot = typeof input === 'string' ? input : (input?.cwd || process.cwd());
1012
+ const gitRoot = typeof input === 'string' ? input : input?.cwd || process.cwd();
929
1013
  const refDir = path.join(gitRoot, 'reference');
930
1014
  if (!fs.existsSync(refDir)) return null;
931
1015
 
1016
+ // Before the no-spec return and before any write. `findOasFiles` skips
1017
+ // symlink specs, so a reference/ whose only spec is a link would otherwise
1018
+ // report success and leave pages unsynced. Refusing first also keeps a later
1019
+ // real spec from being half-applied, and keeps a link the page walk would
1020
+ // have skipped from being deleted or written through.
1021
+ refuseSymlinks(refDir);
1022
+
932
1023
  const oasFiles = findOasFiles(refDir);
933
1024
  if (oasFiles.length === 0) return null;
934
1025
 
1026
+ const assertContained = makeContainment(refDir);
1027
+
935
1028
  // Reference slugs share one flat namespace across every spec, so build the set
936
1029
  // of in-use slugs once and let each spec read/extend it.
937
1030
  const takenSlugs = collectReferenceSlugs(refDir);
@@ -939,7 +1032,7 @@ export function syncOas(input) {
939
1032
 
940
1033
  for (const { filename, spec } of oasFiles) {
941
1034
  const ops = extractOperations(spec);
942
- const changes = syncOneOas(refDir, filename, spec, takenSlugs);
1035
+ const changes = syncOneOas(refDir, filename, spec, takenSlugs, assertContained);
943
1036
  allChanges.push({ filename, spec, opCount: ops.size, changes });
944
1037
  }
945
1038
 
@@ -977,7 +1070,9 @@ export function printSyncResults(results) {
977
1070
 
978
1071
  const dot = hasChanges ? styles.warn('●') : styles.success('●');
979
1072
  console.log();
980
- console.log(` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`);
1073
+ console.log(
1074
+ ` ${dot} ${styles.bold(title)} ${styles.dim(`(${filename} · ${opCount} ${opCount === 1 ? 'endpoint' : 'endpoints'})`)}`,
1075
+ );
981
1076
 
982
1077
  if (!hasChanges) {
983
1078
  continue;
@@ -996,9 +1091,7 @@ export function printSyncResults(results) {
996
1091
  console.log(` ${styles.warn('~')} Updated ${file}`);
997
1092
  }
998
1093
  for (const { path: file, operationId } of changes.skipped) {
999
- console.log(
1000
- ` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`,
1001
- );
1094
+ console.log(` ${styles.warn('!')} Skipped ${file} for "${operationId}" (destination already exists)`);
1002
1095
  skipped.push({ filename, path: file, operationId });
1003
1096
  }
1004
1097
 
@@ -1030,7 +1123,23 @@ export async function run(_options, _cmd, ctx) {
1030
1123
  process.exit(1);
1031
1124
  }
1032
1125
 
1033
- const results = syncOas(gitRoot);
1126
+ let results;
1127
+ try {
1128
+ results = syncOas(gitRoot);
1129
+ } catch (err) {
1130
+ if (!(err instanceof PathEscapeError)) throw err;
1131
+ styles.error(err.message);
1132
+ writeGithubActionsOutputs({
1133
+ 'added-count': '0',
1134
+ 'deleted-count': '0',
1135
+ 'moved-count': '0',
1136
+ 'updated-count': '0',
1137
+ 'skipped-count': '0',
1138
+ skipped: [],
1139
+ 'has-errors': 'true',
1140
+ });
1141
+ process.exit(1);
1142
+ }
1034
1143
 
1035
1144
  if (!results) {
1036
1145
  styles.info('No OpenAPI spec files found in reference/.');
@@ -1046,8 +1155,7 @@ export async function run(_options, _cmd, ctx) {
1046
1155
  return;
1047
1156
  }
1048
1157
 
1049
- const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } =
1050
- printSyncResults(results);
1158
+ const { totalAdded, totalDeleted, totalMoved, totalUpdated, totalSkipped, skipped } = printSyncResults(results);
1051
1159
 
1052
1160
  console.log();
1053
1161
  const total = totalAdded + totalDeleted + totalMoved + totalUpdated;
@@ -1064,7 +1172,9 @@ export async function run(_options, _cmd, ctx) {
1064
1172
  // to whoever wraps this command in CI to notice and fail on it themselves.
1065
1173
  if (totalSkipped > 0) {
1066
1174
  const syncedNote = total > 0 ? ` (${totalAdded} added, ${totalDeleted} deleted${extraNote})` : '';
1067
- styles.error(`${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`);
1175
+ styles.error(
1176
+ `${totalSkipped} ${totalSkipped === 1 ? 'page' : 'pages'} skipped${syncedNote} — see above for which, and why.`,
1177
+ );
1068
1178
  } else if (total === 0) {
1069
1179
  styles.ok('Reference pages are already in sync.');
1070
1180
  } else {