@open-agent-toolkit/cli 0.3.32 → 0.3.33

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 (38) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/docs-tooling/check.md +32 -8
  3. package/assets/docs/docs-tooling/commands.md +20 -2
  4. package/assets/docs/reference/cli-reference.md +2 -2
  5. package/assets/docs/reference/config-and-local-state.md +1 -1
  6. package/assets/docs/reference/configuration.md +1 -1
  7. package/assets/docs/reference/docs-index-contract.md +2 -2
  8. package/assets/docs/reference/oat-directory-structure.md +1 -1
  9. package/assets/public-package-versions.json +4 -4
  10. package/assets/skills/oat-docs-analyze/SKILL.md +35 -11
  11. package/assets/skills/oat-docs-analyze/references/analysis-artifact-template.md +2 -2
  12. package/assets/skills/oat-docs-analyze/scripts/package-drift.mjs +303 -30
  13. package/assets/skills/oat-docs-apply/SKILL.md +3 -1
  14. package/dist/commands/config/index.d.ts.map +1 -1
  15. package/dist/commands/config/index.js +42 -9
  16. package/dist/commands/docs/check/check.d.ts.map +1 -1
  17. package/dist/commands/docs/check/check.js +1 -0
  18. package/dist/commands/docs/check/checker.d.ts +2 -0
  19. package/dist/commands/docs/check/checker.d.ts.map +1 -1
  20. package/dist/commands/docs/check/checker.js +2 -0
  21. package/dist/commands/docs/check/commands.d.ts.map +1 -1
  22. package/dist/commands/docs/check/commands.js +74 -30
  23. package/dist/commands/docs/check/files.d.ts +2 -0
  24. package/dist/commands/docs/check/files.d.ts.map +1 -1
  25. package/dist/commands/docs/check/files.js +16 -0
  26. package/dist/commands/docs/check/navigation.d.ts +7 -1
  27. package/dist/commands/docs/check/navigation.d.ts.map +1 -1
  28. package/dist/commands/docs/check/navigation.js +19 -68
  29. package/dist/commands/docs/nav/fumadocs.d.ts +29 -0
  30. package/dist/commands/docs/nav/fumadocs.d.ts.map +1 -1
  31. package/dist/commands/docs/nav/fumadocs.js +8 -5
  32. package/dist/commands/docs/nav/membership.d.ts +37 -0
  33. package/dist/commands/docs/nav/membership.d.ts.map +1 -0
  34. package/dist/commands/docs/nav/membership.js +475 -0
  35. package/dist/commands/docs/nav/sync.d.ts +26 -2
  36. package/dist/commands/docs/nav/sync.d.ts.map +1 -1
  37. package/dist/commands/docs/nav/sync.js +160 -43
  38. package/package.json +3 -3
@@ -111,6 +111,38 @@ export async function checkCommands(target, pages, report) {
111
111
  if (!docs.length)
112
112
  return;
113
113
  const manifests = await packages(target);
114
+ // A framework app's own contributing pages usually speak from the app
115
+ // directory. Its package is tried first; the repository root remains the
116
+ // fallback context, so pages written from the repository root still resolve.
117
+ // Literal Markdown roots keep the repository-root context only.
118
+ const appContext = target.framework !== 'markdown' &&
119
+ target.appRoot !== target.repoRoot &&
120
+ manifests.some((item) => item.root === target.appRoot)
121
+ ? target.appRoot
122
+ : undefined;
123
+ const manifestName = (pkg, labelled) => {
124
+ const name = posix(relative(target.repoRoot, join(pkg.root, 'package.json'))) ||
125
+ 'package.json';
126
+ if (!labelled)
127
+ return name;
128
+ return pkg.root === target.appRoot
129
+ ? `${name} (app root)`
130
+ : pkg.root === target.repoRoot
131
+ ? `${name} (repository root)`
132
+ : name;
133
+ };
134
+ const lookup = async (root) => {
135
+ if (!root || !inside(target.repoRoot, root) || !(await present(root)))
136
+ return undefined;
137
+ let current = root;
138
+ while (inside(target.repoRoot, current)) {
139
+ const pkg = manifests.find((item) => item.root === current);
140
+ if (pkg || current === target.repoRoot)
141
+ return pkg;
142
+ current = dirname(current);
143
+ }
144
+ return undefined;
145
+ };
114
146
  for (const p of docs)
115
147
  for (const node of p.nodes) {
116
148
  if (node.type !== 'code' && node.type !== 'inlineCode')
@@ -119,7 +151,11 @@ export async function checkCommands(target, pages, report) {
119
151
  node.lang &&
120
152
  !['sh', 'shell', 'bash', 'zsh', 'console', 'powershell'].includes(node.lang))
121
153
  continue;
122
- let cwd = target.repoRoot;
154
+ // Each block starts in the repository-root context and, for framework
155
+ // apps, also in the app-root context; literal `cd` moves both.
156
+ let contexts = appContext
157
+ ? [appContext, target.repoRoot]
158
+ : [target.repoRoot];
123
159
  const start = node.position?.start;
124
160
  const openingLine = p.source.split(/\r?\n/).at((start?.line ?? 1) - 1) ?? '';
125
161
  const fenced = node.type === 'code' &&
@@ -137,36 +173,34 @@ export async function checkCommands(target, pages, report) {
137
173
  continue;
138
174
  const words = tokens(segment);
139
175
  if (words[0] === 'cd') {
140
- cwd =
141
- words[1] && !illustrative(words[1]) && cwd
142
- ? resolve(cwd, words[1])
143
- : undefined;
176
+ contexts = contexts.map((cwd) => words[1] && !illustrative(words[1]) && cwd
177
+ ? resolve(cwd, words[1])
178
+ : undefined);
144
179
  continue;
145
180
  }
146
181
  const manager = words.findIndex((w) => w === 'pnpm' || w === 'npm');
147
182
  if (manager < 0)
148
183
  continue;
149
184
  const kind = words[manager];
150
- let root = cwd;
185
+ const moves = [];
151
186
  let filter;
152
187
  let index = manager + 1;
153
188
  while (words[index]?.startsWith('-')) {
154
189
  const option = words[index++];
155
190
  if (['-w', '--workspace-root'].includes(option))
156
- root = target.repoRoot;
191
+ moves.push(() => target.repoRoot);
157
192
  else if (option === '--filter' || option === '-F')
158
193
  filter = words[index++];
159
194
  else if (option.startsWith('--filter='))
160
195
  filter = option.slice(9);
161
196
  else if (['--dir', '-C', '--prefix'].includes(option)) {
162
197
  const dir = words[index++];
163
- root =
164
- root && dir && !illustrative(dir)
165
- ? resolve(root, dir)
166
- : undefined;
198
+ moves.push((root) => root && dir && !illustrative(dir)
199
+ ? resolve(root, dir)
200
+ : undefined);
167
201
  }
168
202
  else if (['-r', '--recursive', '--workspace'].includes(option))
169
- root = undefined;
203
+ moves.push(() => undefined);
170
204
  }
171
205
  let script = words[index];
172
206
  if (!script)
@@ -190,29 +224,32 @@ export async function checkCommands(target, pages, report) {
190
224
  illustrative(script) ||
191
225
  (filter && illustrative(filter)))
192
226
  continue;
193
- let pkg;
227
+ const candidates = [];
194
228
  if (filter) {
195
229
  const selected = manifests.filter((item) => item.name === filter ||
196
230
  (filter.startsWith('./') &&
197
231
  posix(relative(target.repoRoot, item.root)) ===
198
232
  filter.slice(2)));
199
233
  if (selected.length === 1)
200
- pkg = selected[0];
234
+ candidates.push(selected[0]);
201
235
  }
202
- else if (root &&
203
- inside(target.repoRoot, root) &&
204
- (await present(root))) {
205
- let current = root;
206
- while (inside(target.repoRoot, current)) {
207
- pkg = manifests.find((item) => item.root === current);
208
- if (pkg || current === target.repoRoot)
209
- break;
210
- current = dirname(current);
236
+ else {
237
+ for (const cwd of contexts) {
238
+ const pkg = await lookup(moves.reduce((root, move) => move(root), cwd));
239
+ if (pkg && !candidates.includes(pkg))
240
+ candidates.push(pkg);
211
241
  }
212
242
  }
213
- if (!pkg)
243
+ if (!candidates.length) {
214
244
  report('commands', 'error', p.path, `Cannot resolve package context for ${kind} ${script}${filter ? ` (--filter ${filter})` : ''}; use literal cd, -w or --filter.`, line);
215
- else if (!Object.hasOwn(pkg.scripts ?? {}, script)) {
245
+ continue;
246
+ }
247
+ let pkg;
248
+ for (const candidate of candidates) {
249
+ if (Object.hasOwn(candidate.scripts ?? {}, script)) {
250
+ pkg = candidate;
251
+ break;
252
+ }
216
253
  // pnpm shorthand can invoke an installed local binary. Explicit run
217
254
  // remains script-only; never execute a binary or search system PATH.
218
255
  const installedBinary = kind === 'pnpm' &&
@@ -220,12 +257,19 @@ export async function checkCommands(target, pages, report) {
220
257
  !/[\\/]/.test(script) &&
221
258
  script !== '.' &&
222
259
  script !== '..' &&
223
- (await Promise.all([pkg.root, target.repoRoot].map((binRoot) => present(join(binRoot, 'node_modules/.bin', script))))).some(Boolean);
224
- if (!installedBinary)
225
- report('commands', 'error', p.path, `Script "${script}" does not exist in ${posix(relative(target.repoRoot, join(pkg.root, 'package.json'))) || 'package.json'}.`, line);
260
+ (await Promise.all([candidate.root, target.repoRoot].map((binRoot) => present(join(binRoot, 'node_modules/.bin', script))))).some(Boolean);
261
+ if (installedBinary) {
262
+ pkg = candidate;
263
+ break;
264
+ }
226
265
  }
227
- else if (/^\s*echo(?:\s|$)/.test(pkg.scripts[script]))
228
- report('commands', 'warn', p.path, `Script "${script}" is an echo stub in ${posix(relative(target.repoRoot, join(pkg.root, 'package.json')))}.`, line);
266
+ if (!pkg)
267
+ report('commands', 'error', p.path, `Script "${script}" does not exist in ${candidates
268
+ .map((candidate) => manifestName(candidate, candidates.length > 1))
269
+ .join(' or ')}.`, line);
270
+ else if (Object.hasOwn(pkg.scripts ?? {}, script) &&
271
+ /^\s*echo(?:\s|$)/.test(pkg.scripts[script]))
272
+ report('commands', 'warn', p.path, `Script "${script}" is an echo stub in ${manifestName(pkg, false)}.`, line);
229
273
  }
230
274
  }
231
275
  }
@@ -11,6 +11,8 @@ export interface CheckTarget {
11
11
  framework: 'markdown' | 'fumadocs' | 'mkdocs';
12
12
  documentation: OatDocumentationConfig;
13
13
  matcher: ExclusionMatcher;
14
+ /** Human notices about how an explicit target was interpreted. */
15
+ notices: string[];
14
16
  }
15
17
  export declare function resolveCheckTarget(cwd: string, targetDir?: string): Promise<CheckTarget>;
16
18
  export declare function readerFiles(target: CheckTarget): Promise<string[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/files.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAExD,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1C;AACD,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAQ1D;AACD,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa5D;AAKD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,UAAU,GAAG,UAAU,GAAG,QAAQ,CAAC;IAC9C,aAAa,EAAE,sBAAsB,CAAC;IACtC,OAAO,EAAE,gBAAgB,CAAC;CAC3B;AACD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CAiEtB;AACD,wBAAsB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsBxE;AACD,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,CAAC,CAKrB;AACD,+EAA+E;AAC/E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,OAAO,CAAC,CAMlB;AACD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAU3D"}
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/files.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAExD,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1C;AACD,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAQ1D;AACD,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa5D;AAKD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,UAAU,GAAG,UAAU,GAAG,QAAQ,CAAC;IAC9C,aAAa,EAAE,sBAAsB,CAAC;IACtC,OAAO,EAAE,gBAAgB,CAAC;IAC1B,kEAAkE;IAClE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AACD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CAiFtB;AACD,wBAAsB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA6BxE;AACD,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,CAAC,CAKrB;AACD,+EAA+E;AAC/E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,OAAO,CAAC,CAMlB;AACD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAU3D"}
@@ -70,6 +70,16 @@ export async function resolveCheckTarget(cwd, targetDir) {
70
70
  throw new Error(`Docs content directory does not exist: ${contentRoot}. Set documentation.root or pass --target-dir.`);
71
71
  const matchesContent = configuredContentPath !== undefined && configuredContentPath === appRoot;
72
72
  const documentation = matching || matchesContent ? doc : {};
73
+ const notices = [];
74
+ if (targetDir !== undefined && configured && doc.root && !matching) {
75
+ const configuredTooling = doc.tooling === 'fumadocs' || doc.tooling === 'mkdocs'
76
+ ? doc.tooling
77
+ : undefined;
78
+ if (matchesContent && configuredTooling && framework === 'markdown')
79
+ notices.push(`Notice: --target-dir ${targetDir} is the content directory of the configured ${configuredTooling} app ${doc.root}; it is checked as Markdown, so navigation is skipped. Pass --target-dir ${doc.root} to include navigation.`);
80
+ else if (!matchesContent)
81
+ notices.push(`Notice: --target-dir ${targetDir} is not the configured documentation.root (${doc.root}); checking it as ${framework === 'markdown' ? 'a literal Markdown root' : `a ${framework} app`} without the configured documentation policy. Omit --target-dir to check ${doc.root}.`);
82
+ }
73
83
  return {
74
84
  repoRoot,
75
85
  appRoot,
@@ -77,6 +87,7 @@ export async function resolveCheckTarget(cwd, targetDir) {
77
87
  framework,
78
88
  documentation,
79
89
  matcher: createExclusionMatcher(documentation.nonReaderPaths ?? []),
90
+ notices,
80
91
  };
81
92
  }
82
93
  export async function readerFiles(target) {
@@ -87,6 +98,11 @@ export async function readerFiles(target) {
87
98
  });
88
99
  for (const e of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
89
100
  const path = posix(join(dir, e.name));
101
+ // Installed dependencies and hidden tool state (.git, .oat, .next, ...)
102
+ // are never reader content, even under a broad explicit target.
103
+ if (e.isDirectory() &&
104
+ (e.name === 'node_modules' || e.name.startsWith('.')))
105
+ continue;
90
106
  if (e.isDirectory() && !isExcludedPath(target.matcher, path, true))
91
107
  await walk(path);
92
108
  else if (e.isFile() &&
@@ -1,4 +1,10 @@
1
1
  import type { Report } from './check.types.js';
2
- import { type CheckTarget } from './files.js';
2
+ import type { CheckTarget } from './files.js';
3
+ /**
4
+ * `navOwner: nav-sync` keeps strict generated freshness. Hand-authored and
5
+ * undeclared navigation gets the membership-and-order comparison (issue #367
6
+ * P21/G5): separators, labels, links, rest entries and borrowed pages are the
7
+ * author's presentation, never generator output to match.
8
+ */
3
9
  export declare function checkNavigation(target: CheckTarget, report: Report): Promise<void>;
4
10
  //# sourceMappingURL=navigation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/navigation.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAW,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAgCpD,wBAAsB,eAAe,CACnC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAmGf"}
1
+ {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/navigation.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CA0Df"}
@@ -1,41 +1,12 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
- import { isDeepStrictEqual } from 'node:util';
4
- import { buildDocsNavTree } from '../../docs/nav/contents.js';
5
1
  import { syncFumadocsNavigation } from '../../docs/nav/fumadocs.js';
2
+ import { compareFumadocsMembership, compareMkDocsMembership, } from '../../docs/nav/membership.js';
6
3
  import { syncDocsNavigation } from '../../docs/nav/sync.js';
7
- import YAML from 'yaml';
8
- import { present } from './files.js';
9
- function pageIdentity(item) {
10
- // Hand-authored collision links can use their own display labels.
11
- return item.replace(/^\[[^\]]*\]\((.*)\)$/, '[]($1)').replace(/^\.\//, '');
12
- }
13
- function navIdentity(value, expected) {
14
- if (!Array.isArray(value))
15
- return value;
16
- return value.map((item, index) => {
17
- if (typeof item === 'string')
18
- return item;
19
- if (typeof item !== 'object' || item === null)
20
- return item;
21
- const entry = Object.entries(item)[0];
22
- if (!entry)
23
- return item;
24
- const expectedItem = Array.isArray(expected) ? expected[index] : undefined;
25
- const children = expectedItem && typeof expectedItem === 'object'
26
- ? Object.values(expectedItem)[0]
27
- : undefined;
28
- // The Contents-derived tree identifies sections by their direct landing path.
29
- // Actual landing/page labels are presentation; explicit group labels remain meaningful.
30
- const section = Array.isArray(children) &&
31
- children.some((child) => typeof child === 'string' && child.endsWith('/index.md'));
32
- return Array.isArray(entry[1])
33
- ? {
34
- [section ? '$section' : entry[0]]: navIdentity(entry[1], children),
35
- }
36
- : entry[1];
37
- });
38
- }
4
+ /**
5
+ * `navOwner: nav-sync` keeps strict generated freshness. Hand-authored and
6
+ * undeclared navigation gets the membership-and-order comparison (issue #367
7
+ * P21/G5): separators, labels, links, rest entries and borrowed pages are the
8
+ * author's presentation, never generator output to match.
9
+ */
39
10
  export async function checkNavigation(target, report) {
40
11
  if (target.framework === 'markdown')
41
12
  return;
@@ -48,31 +19,14 @@ export async function checkNavigation(target, report) {
48
19
  check: true,
49
20
  nonReaderPaths,
50
21
  });
51
- for (const file of result.metaFiles) {
52
- let stale = file.changed;
53
- if (!generated) {
54
- if (!(await present(join(target.contentRoot, file.path))))
55
- continue;
56
- try {
57
- const actual = JSON.parse(await readFile(join(target.contentRoot, file.path), 'utf8'));
58
- if (actual &&
59
- typeof actual === 'object' &&
60
- !Array.isArray(actual) &&
61
- !Object.hasOwn(actual, 'pages'))
62
- continue;
63
- stale =
64
- !Array.isArray(actual.pages) ||
65
- !isDeepStrictEqual(actual.pages.map((p) => typeof p === 'string' ? pageIdentity(p) : p), file.meta.pages.map(pageIdentity));
66
- }
67
- catch {
68
- report('navigation', 'error', file.path, 'Cannot read or parse meta.json; check file permissions and JSON syntax.');
69
- continue;
70
- }
71
- }
72
- if (stale)
73
- report('navigation', 'error', file.path, generated
74
- ? 'Generated navigation is stale; run oat docs nav sync.'
75
- : 'Navigation membership/order differs from authored Contents; update pages or Contents.');
22
+ if (generated) {
23
+ for (const file of result.metaFiles)
24
+ if (file.changed)
25
+ report('navigation', 'error', file.path, 'Generated navigation is stale; run oat docs nav sync.');
26
+ }
27
+ else {
28
+ for (const issue of await compareFumadocsMembership(target.contentRoot, result.metaFiles))
29
+ report('navigation', 'error', issue.path, issue.message, issue.line);
76
30
  }
77
31
  for (const path of result.unlisted)
78
32
  report('navigation', 'error', path, 'Reader page/folder is unlisted in authored Contents and would be hidden from navigation.');
@@ -87,15 +41,12 @@ export async function checkNavigation(target, report) {
87
41
  report('navigation', 'error', path, 'Generated navigation is stale; run oat docs nav sync.');
88
42
  }
89
43
  else {
90
- const expected = await buildDocsNavTree({
44
+ for (const issue of await compareMkDocsMembership({
45
+ appRoot: target.appRoot,
91
46
  docsRoot: target.contentRoot,
92
47
  nonReaderPaths,
93
- });
94
- const actual = YAML.parse(await readFile(join(target.appRoot, 'mkdocs.yml'), 'utf8'));
95
- if (!actual || !Object.hasOwn(actual, 'nav'))
96
- return;
97
- if (!isDeepStrictEqual(navIdentity(actual.nav, expected), navIdentity(expected, expected)))
98
- report('navigation', 'error', 'mkdocs.yml', 'Navigation membership/order differs from authored Contents; update nav or Contents.');
48
+ }))
49
+ report('navigation', 'error', issue.path, issue.message, issue.line);
99
50
  }
100
51
  }
101
52
  catch (error) {
@@ -27,6 +27,9 @@ export interface SyncFumadocsNavigationResult {
27
27
  */
28
28
  unlisted: string[];
29
29
  }
30
+ export declare const FUMADOCS_REST_ITEMS: ReadonlySet<string>;
31
+ export declare const FUMADOCS_SEPARATOR: RegExp;
32
+ export declare const FUMADOCS_LINK: RegExp;
30
33
  /**
31
34
  * Reduce a Markdown heading to the plain text a sidebar shows: images and
32
35
  * links keep their text, code spans keep their content, and emphasis and
@@ -35,6 +38,12 @@ export interface SyncFumadocsNavigationResult {
35
38
  export declare function stripInlineMarkdown(text: string): string;
36
39
  /** Folder title: frontmatter `title`, else the first H1 outside code fences. */
37
40
  export declare function resolveFolderTitle(markdown: string): string | undefined;
41
+ /**
42
+ * The URL Fumadocs assigns a page, following `getSlugs` in fumadocs-core's
43
+ * slugs plugin with the scaffolded `baseUrl: '/'`: group folders are dropped,
44
+ * segments are URI-encoded, and an `index` file maps to its folder.
45
+ */
46
+ export declare function pageUrl(pagePath: string, nonIndexUrls?: ReadonlySet<string>): string;
38
47
  /**
39
48
  * The `pages` item for a page or folder in the same directory. A name the
40
49
  * builder would read as a directive (`!hidden`, `z...a`, `---`, ...) is
@@ -42,6 +51,26 @@ export declare function resolveFolderTitle(markdown: string): string | undefined
42
51
  * the same file; every other name is written as is.
43
52
  */
44
53
  export declare function toFumadocsPageItem(name: string): string;
54
+ /**
55
+ * Build one folder's `pages` from its index.md Contents map.
56
+ *
57
+ * A page in this folder or an immediate subfolder is referenced by name.
58
+ * Local same-name page/folder collisions use a precise source-file item, as
59
+ * does `../contributing.md` from its own contributing/ map: pinned core16.10.2
60
+ * selects the folder first for an extensionless reference. Other non-local
61
+ * targets become link entries. There is never a `"..."` rest entry: pages the Contents map does
62
+ * not list stay out of the tree and are reported instead.
63
+ */
64
+ /**
65
+ * Whether the fumadocs-core 16.10.2 page-tree builder supplies this folder's
66
+ * `index` page on its own, given the folder's effective metadata.
67
+ *
68
+ * `buildFolder` attaches `<folder>/index` as the folder index only when the
69
+ * folder is not a root folder (the docs root, or a preserved `root: true`)
70
+ * and no `pagesIndex` names a different page. Otherwise the page is shown
71
+ * only if `pages` lists it.
72
+ */
73
+ export declare function hasImplicitIndex(relativeDir: string, existing: Record<string, unknown> | null): boolean;
45
74
  /**
46
75
  * Write one strict `meta.json` per docs directory that has an index.md,
47
76
  * derived from that index's Contents map, and report every page the
@@ -1 +1 @@
1
- {"version":3,"file":"fumadocs.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/fumadocs.ts"],"names":[],"mappings":"AAoBA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,4BAA4B;IAC3C,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;OAGG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AA6DD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQxD;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoCvE;AAsCD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD;AAqND;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,4BAA4B,CAAC,CA8ExC"}
1
+ {"version":3,"file":"fumadocs.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/fumadocs.ts"],"names":[],"mappings":"AAoBA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,4BAA4B;IAC3C,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;OAGG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAOD,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAGlD,CAAC;AACH,eAAO,MAAM,kBAAkB,QACoB,CAAC;AACpD,eAAO,MAAM,aAAa,QAC6D,CAAC;AAmDxF;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQxD;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoCvE;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACrB,QAAQ,EAAE,MAAM,EAChB,YAAY,GAAE,WAAW,CAAC,MAAM,CAAa,GAC5C,MAAM,CAgBR;AAYD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD;AAOD;;;;;;;;;GASG;AACH;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GACvC,OAAO,CAUT;AA8KD;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,4BAA4B,CAAC,CA8ExC"}
@@ -10,9 +10,12 @@ const GROUP_FOLDER = /^\(.+\)$/;
10
10
  // The `pages` item grammar of the fumadocs-core 16.10.2 page-tree builder
11
11
  // (`resolveFolderItem`): exact rest entries, separators, links, and the
12
12
  // exclude and extract prefixes are checked before an item is read as a path.
13
- const FUMADOCS_REST_ITEMS = new Set(['...', 'z...a']);
14
- const FUMADOCS_SEPARATOR = /^---(?:\[(?<icon>[^\]]+)])?(?<name>.+)---|^---$/;
15
- const FUMADOCS_LINK = /^(?<external>external:)?(?:\[(?<icon>[^\]]+)])?\[(?<name>[^\]]+)]\((?<url>[^)]+)\)$/;
13
+ export const FUMADOCS_REST_ITEMS = new Set([
14
+ '...',
15
+ 'z...a',
16
+ ]);
17
+ export const FUMADOCS_SEPARATOR = /^---(?:\[(?<icon>[^\]]+)])?(?<name>.+)---|^---$/;
18
+ export const FUMADOCS_LINK = /^(?<external>external:)?(?:\[(?<icon>[^\]]+)])?\[(?<name>[^\]]+)]\((?<url>[^)]+)\)$/;
16
19
  const FUMADOCS_PREFIXES = ['!', '...'];
17
20
  const H1 = /^#\s+(.+?)\s*#*\s*$/;
18
21
  const FENCE = /^\s*(```|~~~)/;
@@ -104,7 +107,7 @@ export function resolveFolderTitle(markdown) {
104
107
  * slugs plugin with the scaffolded `baseUrl: '/'`: group folders are dropped,
105
108
  * segments are URI-encoded, and an `index` file maps to its folder.
106
109
  */
107
- function pageUrl(pagePath, nonIndexUrls = new Set()) {
110
+ export function pageUrl(pagePath, nonIndexUrls = new Set()) {
108
111
  const slugs = parentDir(pagePath)
109
112
  .split('/')
110
113
  .filter((segment) => segment.length > 0 && segment !== '.')
@@ -160,7 +163,7 @@ function hrefFragment(href) {
160
163
  * and no `pagesIndex` names a different page. Otherwise the page is shown
161
164
  * only if `pages` lists it.
162
165
  */
163
- function hasImplicitIndex(relativeDir, existing) {
166
+ export function hasImplicitIndex(relativeDir, existing) {
164
167
  if (relativeDir === '.' ||
165
168
  existing?.root === true ||
166
169
  (typeof existing?.root === 'string' && existing.root.trim().length > 0)) {
@@ -0,0 +1,37 @@
1
+ import { type FumadocsMetaFile } from './fumadocs.js';
2
+ /**
3
+ * One way a hand-authored (or undeclared) sidebar disagrees with the authored
4
+ * Contents maps. The comparison checks membership and relative order only:
5
+ * separators, labels, link items, rest entries and pages borrowed from another
6
+ * folder are the author's presentation and are never compared with generator
7
+ * output.
8
+ */
9
+ export interface NavMembershipIssue {
10
+ /** Content-relative path of the meta.json, or `mkdocs.yml`. */
11
+ path: string;
12
+ kind: 'missing' | 'dangling' | 'order' | 'malformed' | 'unreadable';
13
+ message: string;
14
+ line?: number;
15
+ }
16
+ /**
17
+ * Compare present Fumadocs `meta.json` files with the Contents-derived
18
+ * expectation in `metaFiles` (from a `check` run of nav sync).
19
+ *
20
+ * A Contents entry is reachable when its folder's `pages` lists it, a rest
21
+ * entry shows it, any other `pages` entry borrows it, or a root-relative link
22
+ * item points at its URL. An absent meta.json or one without `pages` uses the
23
+ * loader's folder order and is skipped.
24
+ */
25
+ export declare function compareFumadocsMembership(docsRoot: string, metaFiles: readonly FumadocsMetaFile[]): Promise<NavMembershipIssue[]>;
26
+ /**
27
+ * Compare a hand-authored MkDocs `nav:` with the Contents maps: every
28
+ * Contents page appears, every local nav page exists, and the entries of
29
+ * each Contents map (pages and section landing pages) keep their order. Section labels, nesting and external
30
+ * links are the author's. Returns no issues when `nav` is absent.
31
+ */
32
+ export declare function compareMkDocsMembership(options: {
33
+ appRoot: string;
34
+ docsRoot: string;
35
+ nonReaderPaths?: readonly string[];
36
+ }): Promise<NavMembershipIssue[]>;
37
+ //# sourceMappingURL=membership.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"membership.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/membership.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,gBAAgB,EACtB,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,YAAY,CAAC;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAqHD;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,gBAAgB,EAAE,GACrC,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAoS/B;AAeD;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAwEhC"}