docguard-cli 0.9.11 → 0.11.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 (55) hide show
  1. package/PHILOSOPHY.md +59 -106
  2. package/README.md +26 -3
  3. package/cli/commands/diagnose.mjs +171 -58
  4. package/cli/commands/diff.mjs +110 -137
  5. package/cli/commands/fix.mjs +152 -4
  6. package/cli/commands/generate.mjs +148 -27
  7. package/cli/commands/guard.mjs +45 -24
  8. package/cli/commands/hooks.mjs +40 -2
  9. package/cli/commands/score.mjs +22 -0
  10. package/cli/commands/sync.mjs +123 -0
  11. package/cli/docguard.mjs +22 -0
  12. package/cli/scanners/api-doc.mjs +122 -0
  13. package/cli/scanners/doc-tools.mjs +1 -1
  14. package/cli/scanners/frontend.mjs +438 -0
  15. package/cli/scanners/integrations.mjs +116 -0
  16. package/cli/scanners/memory-plan.mjs +242 -0
  17. package/cli/scanners/project-type.mjs +310 -0
  18. package/cli/scanners/routes.mjs +194 -32
  19. package/cli/scanners/schemas.mjs +174 -1
  20. package/cli/shared-source.mjs +247 -0
  21. package/cli/validators/api-surface.mjs +254 -0
  22. package/cli/validators/architecture.mjs +4 -3
  23. package/cli/validators/changelog.mjs +45 -4
  24. package/cli/validators/doc-quality.mjs +3 -2
  25. package/cli/validators/docs-coverage.mjs +9 -14
  26. package/cli/validators/docs-diff.mjs +117 -66
  27. package/cli/validators/docs-sync.mjs +30 -24
  28. package/cli/validators/drift.mjs +6 -2
  29. package/cli/validators/environment.mjs +43 -3
  30. package/cli/validators/freshness.mjs +4 -3
  31. package/cli/validators/metadata-sync.mjs +17 -7
  32. package/cli/validators/metrics-consistency.mjs +9 -4
  33. package/cli/validators/schema-sync.mjs +19 -10
  34. package/cli/validators/security.mjs +20 -7
  35. package/cli/validators/structure.mjs +8 -1
  36. package/cli/validators/test-spec.mjs +26 -17
  37. package/cli/validators/todo-tracking.mjs +21 -8
  38. package/cli/validators/traceability.mjs +61 -36
  39. package/cli/writers/api-reference.mjs +101 -0
  40. package/cli/writers/mechanical.mjs +116 -0
  41. package/cli/writers/sections.mjs +148 -0
  42. package/commands/docguard.fix.md +19 -3
  43. package/commands/docguard.guard.md +5 -4
  44. package/docs/doc-sections.md +37 -0
  45. package/docs/quickstart.md +1 -1
  46. package/extensions/spec-kit-docguard/README.md +8 -5
  47. package/extensions/spec-kit-docguard/commands/fix.md +74 -0
  48. package/extensions/spec-kit-docguard/commands/generate.md +25 -2
  49. package/extensions/spec-kit-docguard/commands/guard.md +6 -5
  50. package/extensions/spec-kit-docguard/commands/sync.md +62 -0
  51. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +11 -1
  52. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +3 -2
  53. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
  54. package/package.json +1 -1
  55. package/templates/commands/docguard.guard.md +3 -3
package/cli/docguard.mjs CHANGED
@@ -35,6 +35,7 @@ import { runCI } from './commands/ci.mjs';
35
35
  import { runFix } from './commands/fix.mjs';
36
36
  import { runWatch } from './commands/watch.mjs';
37
37
  import { runDiagnose } from './commands/diagnose.mjs';
38
+ import { runSync } from './commands/sync.mjs';
38
39
  import { runPublish } from './commands/publish.mjs';
39
40
  import { runTrace } from './commands/trace.mjs';
40
41
  import { runLlms } from './commands/llms.mjs';
@@ -236,6 +237,10 @@ ${c.bold}Enforcement:${c.reset}
236
237
  ${c.green}guard${c.reset} Validate project against canonical docs (51+ checks)
237
238
  ${c.green}diagnose${c.reset} AI orchestrator — guard → fix in one command
238
239
 
240
+ ${c.bold}Memory (build & maintain docs):${c.reset}
241
+ ${c.green}generate --plan${c.reset} AI-powered: scan any project, emit agent task manifest + skeleton
242
+ ${c.green}sync${c.reset} Refresh code-truth doc sections to match current code (always up to date)
243
+
239
244
  ${c.bold}Analysis:${c.reset}
240
245
  ${c.green}score${c.reset} CDD maturity score (0-100)
241
246
  ${c.green}trace${c.reset} Requirements traceability matrix
@@ -267,6 +272,13 @@ ${c.bold}Options:${c.reset}
267
272
  --threshold <n> Minimum score for CI pass (used with ci command)
268
273
  --fail-on-warning Fail CI on warnings (used with ci command)
269
274
  --auto Auto-fix what's possible (used with fix command)
275
+ --write Apply deterministic fixes in place (fix command): removes
276
+ documented endpoints the OpenAPI spec confirms are gone.
277
+ Only edits docguard:generated docs unless --force.
278
+ --plan AI-powered Generate (generate command): scan any project
279
+ (JS/Python/Rust/Go/Java/…), emit the agent task manifest +
280
+ code-truth skeleton. Add --write to scaffold, --format json
281
+ for the machine-readable manifest.
270
282
  --doc <name> Generate AI prompt for specific doc (architecture, security, etc.)
271
283
  --profile <p> Compliance profile: starter, standard, enterprise (init command)
272
284
  --tax Show estimated documentation maintenance cost (with score)
@@ -346,6 +358,13 @@ async function main() {
346
358
  flags.failOnWarning = true;
347
359
  } else if (args[i] === '--auto') {
348
360
  flags.auto = true;
361
+ } else if (args[i] === '--write') {
362
+ flags.write = true;
363
+ } else if (args[i] === '--plan') {
364
+ flags.plan = true;
365
+ } else if (args[i] === '--since' && args[i + 1]) {
366
+ flags.since = args[i + 1];
367
+ i++;
349
368
  } else if (args[i] === '--doc' && args[i + 1]) {
350
369
  flags.doc = args[i + 1];
351
370
  i++;
@@ -443,6 +462,9 @@ async function main() {
443
462
  case 'watch':
444
463
  runWatch(projectDir, config, flags);
445
464
  break;
465
+ case 'sync':
466
+ runSync(projectDir, config, flags);
467
+ break;
446
468
  case 'publish':
447
469
  case 'pub':
448
470
  runPublish(projectDir, config, flags);
@@ -0,0 +1,122 @@
1
+ /**
2
+ * API Documentation Parser
3
+ *
4
+ * Extracts API endpoints from a canonical API doc (API-REFERENCE.md) robustly,
5
+ * and provides normalized comparison primitives shared by `docguard diff` and
6
+ * the API-Surface guard validator.
7
+ *
8
+ * Handles two documentation styles:
9
+ * 1. Headings: `#### GET /api/admin/users` (method + path, backticks optional)
10
+ * 2. Table rows: | `GET` | `/api/admin/users` | ... |
11
+ *
12
+ * Normalization makes comparison reliable:
13
+ * - method split into its own field, upper-cased
14
+ * - path params unified: `:id` ≡ `{id}` → `{}` placeholder
15
+ * - trailing slashes, backticks, and table pipes stripped
16
+ * - query strings / fragments removed
17
+ *
18
+ * Zero NPM dependencies — pure Node.js built-ins only.
19
+ */
20
+
21
+ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
22
+
23
+ /**
24
+ * Normalize an API path for comparison.
25
+ * Strips decoration and collapses param syntax so `:id` and `{id}` match.
26
+ * @param {string} raw
27
+ * @returns {string} normalized path (e.g. "/api/users/{}") or '' if not a path
28
+ */
29
+ export function normalizePath(raw) {
30
+ if (!raw) return '';
31
+ let p = String(raw).trim();
32
+ // strip surrounding backticks / pipes / quotes / whitespace
33
+ p = p.replace(/^[|`'"\s]+/, '').replace(/[|`'"\s]+$/, '');
34
+ // cut query string / fragment
35
+ p = p.split(/[?#]/)[0];
36
+ if (!p.startsWith('/')) return '';
37
+ // collapse param syntax: :param and {param} → {}
38
+ p = p.replace(/\{[^}/]+\}/g, '{}').replace(/:[^/]+/g, '{}');
39
+ // strip trailing slash (but keep root "/")
40
+ if (p.length > 1) p = p.replace(/\/+$/, '');
41
+ return p;
42
+ }
43
+
44
+ /** Build a canonical comparison key for an endpoint. */
45
+ export function endpointKey(method, path) {
46
+ return `${String(method).toUpperCase()} ${normalizePath(path)}`;
47
+ }
48
+
49
+ /**
50
+ * Parse endpoints documented in an API reference markdown string.
51
+ * @param {string} content
52
+ * @returns {Array<{ method: string, path: string, key: string }>}
53
+ */
54
+ export function parseApiReferenceDoc(content) {
55
+ if (!content) return [];
56
+ const found = new Map(); // key → { method, path, key }
57
+
58
+ const addEndpoint = (method, rawPath) => {
59
+ const m = String(method).toUpperCase();
60
+ if (!HTTP_METHODS.has(m)) return;
61
+ const path = normalizePath(rawPath);
62
+ if (!path || path.length < 2) return;
63
+ const key = `${m} ${path}`;
64
+ if (!found.has(key)) found.set(key, { method: m, path, key });
65
+ };
66
+
67
+ const lines = content.split('\n');
68
+
69
+ // Style 1: headings — "#### GET `/api/...`" (method + path, backticks optional)
70
+ const headingRe = /^#{2,6}\s+`?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)`?\s+`?(\/[^\s`|]+)`?/i;
71
+
72
+ // Style 2: table rows — "| `GET` | `/api/...` | ... |"
73
+ for (const line of lines) {
74
+ const h = line.match(headingRe);
75
+ if (h) {
76
+ addEndpoint(h[1], h[2]);
77
+ continue;
78
+ }
79
+
80
+ if (line.includes('|')) {
81
+ const cells = line.split('|').map(s => s.trim()).filter(s => s.length > 0);
82
+ if (cells.length >= 2) {
83
+ const c0 = cells[0].replace(/`/g, '').trim().toUpperCase();
84
+ if (HTTP_METHODS.has(c0)) {
85
+ // path is the next cell that looks like a route
86
+ for (let i = 1; i < cells.length; i++) {
87
+ const cand = cells[i].replace(/`/g, '').trim();
88
+ if (cand.startsWith('/')) { addEndpoint(c0, cand); break; }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return [...found.values()];
96
+ }
97
+
98
+ /**
99
+ * Compare a documented endpoint set against an actual endpoint set.
100
+ * @param {Array<{method,path}>} documented
101
+ * @param {Array<{method,path}>} actual
102
+ * @returns {{ documentedButAbsent: object[], presentButUndocumented: object[], matched: object[] }}
103
+ */
104
+ export function compareEndpoints(documented, actual) {
105
+ const docMap = new Map();
106
+ for (const e of documented) docMap.set(endpointKey(e.method, e.path), e);
107
+ const actMap = new Map();
108
+ for (const e of actual) actMap.set(endpointKey(e.method, e.path), e);
109
+
110
+ const documentedButAbsent = [];
111
+ const matched = [];
112
+ for (const [key, e] of docMap) {
113
+ if (actMap.has(key)) matched.push(e);
114
+ else documentedButAbsent.push(e);
115
+ }
116
+ const presentButUndocumented = [];
117
+ for (const [key, e] of actMap) {
118
+ if (!docMap.has(key)) presentButUndocumented.push(e);
119
+ }
120
+
121
+ return { documentedButAbsent, presentButUndocumented, matched };
122
+ }
@@ -36,7 +36,7 @@ export function detectDocTools(dir) {
36
36
 
37
37
  // ── OpenAPI / Swagger Spec ─────────────────────────────────────────────────
38
38
 
39
- function detectOpenAPI(dir) {
39
+ export function detectOpenAPI(dir) {
40
40
  const candidates = [
41
41
  'openapi.yaml', 'openapi.yml', 'openapi.json',
42
42
  'swagger.yaml', 'swagger.yml', 'swagger.json',
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Frontend Scanner — captures the UI surface of a project so the generated
3
+ * "memory" covers screens/pages/components, not just the backend API.
4
+ *
5
+ * Detects:
6
+ * - Screens/routes:
7
+ * • React Router — <Route path="/x" element={<XPage/>} /> and route-object configs
8
+ * • Next.js App — app/**​/page.{tsx,jsx}
9
+ * • Next.js Pages — pages/**​/*.{tsx,jsx} (excluding api/ and _files)
10
+ * - Component inventory (files under components/ dirs)
11
+ * - State + data libraries (from dependencies)
12
+ *
13
+ * Monorepo-aware via resolveSourceRoots(). Pure read-only scanning.
14
+ * Zero NPM dependencies — Node.js built-ins only.
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
18
+ import { resolve, join, relative, basename, extname } from 'node:path';
19
+ import { resolveSourceRoots, collectPackageJsons } from '../shared-source.mjs';
20
+
21
+ const IGNORE_DIRS = new Set([
22
+ 'node_modules', '.git', '.next', 'dist', 'build', 'coverage',
23
+ '.cache', '__pycache__', '.venv', 'vendor', '.turbo', '.vercel',
24
+ ]);
25
+ const UI_EXT = new Set(['.tsx', '.jsx']);
26
+
27
+ function walk(dir, onFile, depth = 0) {
28
+ if (depth > 12 || !existsSync(dir)) return;
29
+ let entries;
30
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
31
+ for (const e of entries) {
32
+ if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue;
33
+ const full = join(dir, e.name);
34
+ if (e.isDirectory()) walk(full, onFile, depth + 1);
35
+ else if (e.isFile()) onFile(full);
36
+ }
37
+ }
38
+
39
+ function readSafe(p) { try { return readFileSync(p, 'utf-8'); } catch { return ''; } }
40
+
41
+ /** Normalize a route path param syntax to {param} and strip trailing slash. */
42
+ function normRoute(p) {
43
+ let s = String(p).trim();
44
+ if (!s.startsWith('/')) s = '/' + s;
45
+ s = s.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\[(?:\.\.\.)?(\w+)\]/g, '{$1}');
46
+ if (s.length > 1) s = s.replace(/\/+$/, '');
47
+ return s;
48
+ }
49
+
50
+ /** Detect the frontend stack from merged dependencies. */
51
+ function detectFrontendStack(projectDir, config) {
52
+ const deps = {};
53
+ for (const { pkg } of collectPackageJsons(projectDir, config)) {
54
+ Object.assign(deps, pkg.dependencies || {}, pkg.devDependencies || {});
55
+ }
56
+ let framework = '';
57
+ if (deps.next) framework = 'Next.js';
58
+ else if (deps['react-router-dom'] || deps['react-router']) framework = 'React Router';
59
+ else if (deps['@tanstack/react-router']) framework = 'TanStack Router';
60
+ else if (deps.react) framework = 'React';
61
+ else if (deps.vue) framework = 'Vue';
62
+ else if (deps.svelte || deps['@sveltejs/kit']) framework = 'Svelte';
63
+
64
+ const stateLib = deps.zustand ? 'Zustand'
65
+ : deps['@reduxjs/toolkit'] || deps.redux ? 'Redux'
66
+ : deps.jotai ? 'Jotai' : deps.mobx ? 'MobX' : null;
67
+ const dataLib = deps['@tanstack/react-query'] ? 'TanStack Query'
68
+ : deps.swr ? 'SWR' : deps['@apollo/client'] ? 'Apollo' : null;
69
+ const buildTool = deps.vite ? 'Vite' : deps.next ? 'Next.js' : deps.webpack ? 'Webpack' : null;
70
+
71
+ return { framework, stateLib, dataLib, buildTool };
72
+ }
73
+
74
+ // Components that WRAP a route's real screen — skip them when identifying the page.
75
+ const ROUTE_WRAPPERS = new Set([
76
+ 'RequireAuth', 'ProtectedRoute', 'PrivateRoute', 'PublicRoute', 'AuthGuard',
77
+ 'Guard', 'Suspense', 'Layout', 'AppLayout', 'MainLayout', 'RootLayout',
78
+ 'Fragment', 'Outlet', 'Navigate', 'ErrorBoundary', 'Route', 'Routes',
79
+ 'Provider', 'Wrapper',
80
+ ]);
81
+
82
+ /** Pick the most meaningful screen component from candidates in a route element. */
83
+ function pickScreenComponent(candidates) {
84
+ const real = candidates.filter(c => !ROUTE_WRAPPERS.has(c));
85
+ if (real.length === 0) return candidates[candidates.length - 1] || null;
86
+ // Prefer a *Page / *Screen / *View name, else the innermost (last) real one.
87
+ const named = real.find(c => /(Page|Screen|View)$/.test(c));
88
+ return named || real[real.length - 1];
89
+ }
90
+
91
+ /** Extract React Router screens: <Route path=".." element={<Wrapper><Comp/></Wrapper>} /> + route objects. */
92
+ function scanReactRouterScreens(roots, projectDir) {
93
+ const screens = [];
94
+ const seen = new Set();
95
+ const add = (path, component, file) => {
96
+ const p = normRoute(path);
97
+ if (seen.has(p)) return; // one screen per path
98
+ seen.add(p);
99
+ screens.push({ path: p, component: component || null, file: relative(projectDir, file) });
100
+ };
101
+
102
+ // Find each `path="..."`, then look in a window AFTER it for the element's
103
+ // component(s) — the element JSX can nest wrappers, so collect all and pick.
104
+ const pathRe = /\bpath\s*[=:]\s*["'`]([^"'`]+)["'`]/g;
105
+ const compRe = /<\s*([A-Z][A-Za-z0-9_]*)/g;
106
+
107
+ for (const root of roots) {
108
+ walk(root, (file) => {
109
+ if (!UI_EXT.has(extname(file)) && !/\.(ts|js|mjs)$/.test(file)) return;
110
+ const content = readSafe(file);
111
+ if (!content.includes('<Route') && !content.includes('createBrowserRouter') &&
112
+ !content.includes('useRoutes') && !content.includes('createRoutesFrom')) return;
113
+
114
+ let m;
115
+ const re = new RegExp(pathRe.source, 'g');
116
+ while ((m = re.exec(content)) !== null) {
117
+ // Window = from this path up to the START of the next route entry
118
+ // (next `path=`/`path:`), capped — so nested `<Spinner/>` fallbacks don't
119
+ // truncate the window before the real screen component.
120
+ const start = m.index + m[0].length;
121
+ const nextPath = new RegExp(pathRe.source, 'g');
122
+ nextPath.lastIndex = start;
123
+ const nm = nextPath.exec(content);
124
+ const end = Math.min(nm ? nm.index : content.length, start + 400);
125
+ const windowStr = content.slice(start, end);
126
+ const comps = [];
127
+ let cm;
128
+ const cre = new RegExp(compRe.source, 'g');
129
+ while ((cm = cre.exec(windowStr)) !== null) comps.push(cm[1]);
130
+ add(m[1], pickScreenComponent(comps), file);
131
+ }
132
+ });
133
+ }
134
+ return screens;
135
+ }
136
+
137
+ /** Extract Next.js screens from app/ and pages/ file conventions. */
138
+ function scanNextScreens(projectDir, config) {
139
+ const screens = [];
140
+ const seen = new Set();
141
+ const add = (path, file) => {
142
+ const p = normRoute(path || '/');
143
+ if (seen.has(p)) return;
144
+ seen.add(p);
145
+ screens.push({ path: p, component: basename(relative(projectDir, file)), file: relative(projectDir, file) });
146
+ };
147
+
148
+ // Next conventions live at a PACKAGE root (app/, pages/, src/app, src/pages),
149
+ // so search package/project bases — not the granular source roots (which
150
+ // already include `app/` and would double-append).
151
+ const bases = new Set([resolve(projectDir)]);
152
+ for (const { dir } of collectPackageJsons(projectDir, config)) bases.add(dir);
153
+
154
+ for (const root of bases) {
155
+ // App Router: app/**/page.{tsx,jsx}
156
+ for (const appBase of ['app', 'src/app']) {
157
+ const appDir = resolve(root, appBase);
158
+ if (!existsSync(appDir)) continue;
159
+ walk(appDir, (file) => {
160
+ if (!/^page\.(tsx|jsx|ts|js)$/.test(basename(file))) return;
161
+ const rel = relative(appDir, file).replace(/\/page\.\w+$/, '').replace(/^page\.\w+$/, '');
162
+ // strip route groups (group) segments
163
+ const routePath = '/' + rel.split('/').filter(seg => seg && !/^\(.*\)$/.test(seg)).join('/');
164
+ add(routePath, file);
165
+ });
166
+ }
167
+ // Pages Router: pages/**/*.{tsx,jsx} excluding api and _files
168
+ for (const pagesBase of ['pages', 'src/pages']) {
169
+ const pagesDir = resolve(root, pagesBase);
170
+ if (!existsSync(pagesDir)) continue;
171
+ walk(pagesDir, (file) => {
172
+ if (!UI_EXT.has(extname(file))) return;
173
+ const rel = relative(pagesDir, file);
174
+ if (rel.startsWith('api/') || basename(file).startsWith('_')) return;
175
+ const routePath = '/' + rel.replace(extname(rel), '').replace(/\/index$/, '').replace(/^index$/, '');
176
+ add(routePath, file);
177
+ });
178
+ }
179
+ }
180
+ return screens;
181
+ }
182
+
183
+ /** Inventory component files under components/ directories. */
184
+ function scanComponents(roots, projectDir) {
185
+ const components = [];
186
+ const seen = new Set();
187
+ for (const root of roots) {
188
+ walk(root, (file) => {
189
+ if (!UI_EXT.has(extname(file))) return;
190
+ const rel = relative(projectDir, file);
191
+ // Heuristic: under a components/ dir, PascalCase filename, not a test/story.
192
+ if (!/(^|\/)components\//.test(rel)) return;
193
+ if (/\.(test|spec|stories)\./.test(rel)) return;
194
+ const name = basename(file, extname(file));
195
+ if (!/^[A-Z]/.test(name)) return;
196
+ if (seen.has(rel)) return;
197
+ seen.add(rel);
198
+ components.push({ name, file: rel });
199
+ });
200
+ }
201
+ return components;
202
+ }
203
+
204
+ /** Scan frontend state stores (Zustand, Redux Toolkit slices, Jotai atoms, MobX). */
205
+ function scanStores(roots, projectDir) {
206
+ const out = [];
207
+ const seen = new Set();
208
+ const add = (name, library, file) => {
209
+ const key = `${name}::${file}`;
210
+ if (seen.has(key)) return;
211
+ seen.add(key);
212
+ out.push({ name, library, file: relative(projectDir, file) });
213
+ };
214
+ // Zustand: const useThing = create(...) / create<T>(...)
215
+ const zustand = /\b(?:export\s+)?const\s+(use[A-Z]\w*)\s*=\s*create\b/g;
216
+ // Redux Toolkit slice
217
+ const rtkSlice = /\bcreateSlice\s*\(\s*\{[^}]*?\bname\s*:\s*["']([^"']+)["']/g;
218
+ // Jotai atoms: const xAtom = atom(...) / atomWithStorage(...)
219
+ const jotai = /\b(?:export\s+)?const\s+(\w+Atom)\s*=\s*atom(?:WithStorage)?\b/g;
220
+ // MobX: class XStore { makeObservable / makeAutoObservable }
221
+ const mobx = /\bclass\s+(\w+Store)\b[\s\S]{0,400}?(?:makeObservable|makeAutoObservable)\b/g;
222
+
223
+ for (const root of roots) {
224
+ walk(root, (file) => {
225
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
226
+ const content = readSafe(file);
227
+ if (!content) return;
228
+ let m;
229
+ if (content.includes('create(') || content.includes('create<')) {
230
+ const re = new RegExp(zustand.source, 'g');
231
+ while ((m = re.exec(content)) !== null) add(m[1], 'Zustand', file);
232
+ }
233
+ if (content.includes('createSlice')) {
234
+ const re = new RegExp(rtkSlice.source, 'g');
235
+ while ((m = re.exec(content)) !== null) add(m[1], 'Redux Toolkit', file);
236
+ }
237
+ if (content.includes('atom(') || content.includes('atomWithStorage')) {
238
+ const re = new RegExp(jotai.source, 'g');
239
+ while ((m = re.exec(content)) !== null) add(m[1], 'Jotai', file);
240
+ }
241
+ if (content.includes('makeObservable') || content.includes('makeAutoObservable')) {
242
+ const re = new RegExp(mobx.source, 'g');
243
+ while ((m = re.exec(content)) !== null) add(m[1], 'MobX', file);
244
+ }
245
+ });
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /** Inventory custom React hooks: exported `useXxx` declarations. */
251
+ function scanHooks(roots, projectDir) {
252
+ const out = [];
253
+ const seen = new Set();
254
+ // export function useThing / export const useThing = / export { useThing }
255
+ const decl = /\bexport\s+(?:function|const|let)\s+(use[A-Z]\w*)\b/g;
256
+ const reexport = /\bexport\s*\{\s*([^}]+)\}/g;
257
+ for (const root of roots) {
258
+ walk(root, (file) => {
259
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
260
+ if (/\.(test|spec|stories)\./.test(file)) return;
261
+ const content = readSafe(file);
262
+ if (!content || !content.includes('use')) return;
263
+ const rel = relative(projectDir, file);
264
+ let m;
265
+ const re1 = new RegExp(decl.source, 'g');
266
+ while ((m = re1.exec(content)) !== null) {
267
+ if (seen.has(m[1])) continue;
268
+ seen.add(m[1]);
269
+ out.push({ name: m[1], file: rel });
270
+ }
271
+ const re2 = new RegExp(reexport.source, 'g');
272
+ while ((m = re2.exec(content)) !== null) {
273
+ // For `X as Y`, the EXPORTED name is the alias (Y) — that's what consumers see.
274
+ for (const id of m[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop()).filter(Boolean)) {
275
+ if (/^use[A-Z]/.test(id) && !seen.has(id)) {
276
+ seen.add(id);
277
+ out.push({ name: id, file: rel });
278
+ }
279
+ }
280
+ }
281
+ });
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /** React Context inventory: `const XContext = createContext(...)`. */
287
+ function scanContexts(roots, projectDir) {
288
+ const out = [];
289
+ const seen = new Set();
290
+ const re = /\b(?:export\s+)?const\s+(\w+Context)\s*=\s*(?:React\.)?createContext\b/g;
291
+ for (const root of roots) {
292
+ walk(root, (file) => {
293
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
294
+ const content = readSafe(file);
295
+ if (!content || !content.includes('createContext')) return;
296
+ let m;
297
+ const r = new RegExp(re.source, 'g');
298
+ while ((m = r.exec(content)) !== null) {
299
+ if (seen.has(m[1])) continue;
300
+ seen.add(m[1]);
301
+ out.push({ name: m[1], file: relative(projectDir, file) });
302
+ }
303
+ });
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /**
309
+ * i18n: detect translation keys used in code (`t('a.b')`, `i18n.t('a.b')`, `i18nKey="a.b"`)
310
+ * and the locale files that define them. Reports keys used in code but missing
311
+ * from locales as a small drift signal the agent can call out.
312
+ * @returns {{ usedKeys, locales, missing }}
313
+ */
314
+ function scanI18n(projectDir, roots) {
315
+ const usedKeys = new Set();
316
+ const codeRe = /\b(?:i18n\.)?t\(\s*[`'"]([a-zA-Z][\w.-]*\.[\w.-]+)[`'"]/g;
317
+ const propRe = /\bi18nKey\s*=\s*[`'"]([a-zA-Z][\w.-]*\.[\w.-]+)[`'"]/g;
318
+
319
+ for (const root of roots) {
320
+ walk(root, (file) => {
321
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
322
+ const content = readSafe(file);
323
+ if (!content || (!content.includes('t(') && !content.includes('i18nKey'))) return;
324
+ let m;
325
+ const r1 = new RegExp(codeRe.source, 'g');
326
+ while ((m = r1.exec(content)) !== null) usedKeys.add(m[1]);
327
+ const r2 = new RegExp(propRe.source, 'g');
328
+ while ((m = r2.exec(content)) !== null) usedKeys.add(m[1]);
329
+ });
330
+ }
331
+
332
+ // Locale files: src/i18n/locales/<lang>.json, src/locales/<lang>.json, public/locales/<lang>/*.json
333
+ const locales = [];
334
+ const localeKeys = new Set();
335
+ const seenLocaleDirs = new Set();
336
+ const seenLocaleFiles = new Set();
337
+ const candidates = ['i18n', 'locales', 'src/i18n', 'src/locales', 'public/locales'];
338
+ for (const base of [resolve(projectDir), ...roots]) {
339
+ for (const sub of candidates) {
340
+ const localesDir = resolve(base, sub);
341
+ if (seenLocaleDirs.has(localesDir) || !existsSync(localesDir)) continue;
342
+ seenLocaleDirs.add(localesDir);
343
+ walk(localesDir, (file) => {
344
+ if (seenLocaleFiles.has(file)) return;
345
+ seenLocaleFiles.add(file);
346
+ if (!file.endsWith('.json')) return;
347
+ let json;
348
+ try { json = JSON.parse(readSafe(file) || '{}'); } catch { return; }
349
+ const rel = relative(projectDir, file);
350
+ const collected = [];
351
+ const walkObj = (obj, prefix) => {
352
+ for (const [k, v] of Object.entries(obj || {})) {
353
+ const path = prefix ? `${prefix}.${k}` : k;
354
+ if (v && typeof v === 'object' && !Array.isArray(v)) walkObj(v, path);
355
+ else { localeKeys.add(path); collected.push(path); }
356
+ }
357
+ };
358
+ walkObj(json, '');
359
+ if (collected.length) locales.push({ file: rel, keys: collected.length });
360
+ });
361
+ }
362
+ }
363
+
364
+ const missing = [...usedKeys].filter(k => !localeKeys.has(k)).sort();
365
+ return { usedKeys: [...usedKeys].sort(), locales, missing };
366
+ }
367
+
368
+ /**
369
+ * Frontend → backend wiring: extract API calls (`axios.get('/api/...')`,
370
+ * `fetch('/api/...')`, generic client methods) so the agent can map screens
371
+ * to the endpoints they hit.
372
+ */
373
+ function scanApiCalls(roots, projectDir) {
374
+ const out = [];
375
+ const seen = new Set();
376
+ const add = (method, path, file) => {
377
+ const key = `${method} ${path}::${file}`;
378
+ if (seen.has(key)) return;
379
+ seen.add(key);
380
+ out.push({ method, path, file: relative(projectDir, file) });
381
+ };
382
+ // method-call style: <obj>.get('/api/...'), axios.post('/api/...'), apiClient.users.get('/api/...')
383
+ const methodCall = /\b(?:axios|api|client|apiClient|http|fetcher)\b[\w.]*\.(get|post|put|delete|patch)\s*\(\s*[`'"](\/[^`'")\s]+)/gi;
384
+ // bare fetch
385
+ const fetchCall = /\bfetch\s*\(\s*[`'"](\/[^`'")\s]+)[`'"]\s*(?:,\s*\{[^}]*?method\s*:\s*[`'"](GET|POST|PUT|DELETE|PATCH))?/gi;
386
+
387
+ for (const root of roots) {
388
+ walk(root, (file) => {
389
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(file)) return;
390
+ const content = readSafe(file);
391
+ if (!content) return;
392
+ let m;
393
+ const a = new RegExp(methodCall.source, 'gi');
394
+ while ((m = a.exec(content)) !== null) add(m[1].toUpperCase(), m[2], file);
395
+ const f = new RegExp(fetchCall.source, 'gi');
396
+ while ((m = f.exec(content)) !== null) add((m[2] || 'GET').toUpperCase(), m[1], file);
397
+ });
398
+ }
399
+ return out;
400
+ }
401
+
402
+ /**
403
+ * Scan the frontend surface of a project.
404
+ * @returns {{ framework, buildTool, stateLib, dataLib, routerType,
405
+ * screens: object[], components: object[],
406
+ * stores: object[], hooks: object[], contexts: object[], apiCalls: object[] }}
407
+ */
408
+ export function scanFrontend(projectDir, config = {}) {
409
+ const stack = detectFrontendStack(projectDir, config);
410
+ const roots = resolveSourceRoots(projectDir, config);
411
+
412
+ let screens = [];
413
+ let routerType = null;
414
+ if (stack.framework === 'Next.js') {
415
+ screens = scanNextScreens(projectDir, config);
416
+ routerType = 'next';
417
+ } else {
418
+ // React Router (and TanStack/React fallbacks use the same JSX/route-object forms)
419
+ screens = scanReactRouterScreens(roots, projectDir);
420
+ if (screens.length) routerType = 'react-router';
421
+ // If nothing matched but a Next layout exists, try Next as a fallback.
422
+ if (!screens.length) {
423
+ const nx = scanNextScreens(projectDir, config);
424
+ if (nx.length) { screens = nx; routerType = 'next'; }
425
+ }
426
+ }
427
+
428
+ screens.sort((a, b) => a.path.localeCompare(b.path));
429
+ const components = scanComponents(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
430
+ const stores = scanStores(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
431
+ const hooks = scanHooks(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
432
+ const contexts = scanContexts(roots, projectDir).sort((a, b) => a.name.localeCompare(b.name));
433
+ const apiCalls = scanApiCalls(roots, projectDir).sort((a, b) =>
434
+ a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
435
+ const i18n = scanI18n(projectDir, roots);
436
+
437
+ return { ...stack, routerType, screens, components, stores, hooks, contexts, apiCalls, i18n };
438
+ }