docguard-cli 0.10.0 → 0.11.1
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.
- package/PHILOSOPHY.md +59 -106
- package/README.md +23 -1
- package/cli/commands/diagnose.mjs +157 -52
- package/cli/commands/fix.mjs +113 -1
- package/cli/commands/generate.mjs +91 -0
- package/cli/commands/hooks.mjs +40 -2
- package/cli/commands/score.mjs +22 -0
- package/cli/commands/sync.mjs +123 -0
- package/cli/docguard.mjs +22 -0
- package/cli/scanners/cdk.mjs +10 -0
- package/cli/scanners/frontend.mjs +438 -0
- package/cli/scanners/iac.mjs +235 -0
- package/cli/scanners/integrations.mjs +116 -0
- package/cli/scanners/memory-plan.mjs +242 -0
- package/cli/scanners/project-type.mjs +310 -0
- package/cli/scanners/routes.mjs +149 -0
- package/cli/scanners/schemas.mjs +174 -1
- package/cli/shared-ignore.mjs +29 -2
- package/cli/shared-source.mjs +2 -1
- package/cli/validators/api-surface.mjs +112 -37
- package/cli/validators/changelog.mjs +3 -2
- package/cli/validators/docs-coverage.mjs +125 -6
- package/cli/validators/docs-sync.mjs +49 -8
- package/cli/validators/metadata-sync.mjs +6 -1
- package/cli/validators/metrics-consistency.mjs +5 -2
- package/cli/validators/test-spec.mjs +129 -11
- package/cli/validators/todo-tracking.mjs +55 -2
- package/cli/writers/api-reference.mjs +101 -0
- package/cli/writers/mechanical.mjs +116 -0
- package/cli/writers/sections.mjs +148 -0
- package/commands/docguard.fix.md +19 -3
- package/docs/doc-sections.md +37 -0
- package/extensions/spec-kit-docguard/README.md +7 -4
- package/extensions/spec-kit-docguard/commands/fix.md +74 -0
- package/extensions/spec-kit-docguard/commands/generate.md +25 -2
- package/extensions/spec-kit-docguard/commands/sync.md +62 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +13 -3
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +111 -0
- package/package.json +1 -1
- package/templates/ARCHITECTURE.md.template +52 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IaC Detector — Identifies Infrastructure-as-Code projects.
|
|
3
|
+
*
|
|
4
|
+
* IaC code is real production source that defines cloud infrastructure.
|
|
5
|
+
* It MUST be documented in ARCHITECTURE.md, not silently ignored. This
|
|
6
|
+
* detector identifies which IaC tool the project uses so docs-coverage
|
|
7
|
+
* can emit ONE consolidated actionable warning naming the actual layout
|
|
8
|
+
* (instead of multiple generic per-directory warnings).
|
|
9
|
+
*
|
|
10
|
+
* Supported tools:
|
|
11
|
+
* - AWS CDK → cdk.json marker file
|
|
12
|
+
* - Terraform → *.tf files in any non-ignored directory
|
|
13
|
+
* - Pulumi → Pulumi.yaml marker file
|
|
14
|
+
* - AWS SAM → template.yaml/yml with "AWS::Serverless::"
|
|
15
|
+
* - Serverless Fmw → serverless.yml/serverless.yaml/serverless.ts
|
|
16
|
+
*
|
|
17
|
+
* Zero NPM dependencies — pure Node.js built-ins only.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { join, relative } from 'node:path';
|
|
22
|
+
import { DEFAULT_IGNORE_DIRS } from '../shared-ignore.mjs';
|
|
23
|
+
|
|
24
|
+
const MAX_DEPTH = 6;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Per-tool conventions: marker file/pattern + the directories that hold the
|
|
28
|
+
* actual IaC source. Used to construct the consolidated warning text.
|
|
29
|
+
*/
|
|
30
|
+
const TOOL_PROFILES = {
|
|
31
|
+
cdk: {
|
|
32
|
+
label: 'AWS CDK',
|
|
33
|
+
markerFile: 'cdk.json',
|
|
34
|
+
sourceDirs: ['bin/ (app entrypoint)', 'lib/stacks/', 'lib/constructs/'],
|
|
35
|
+
headingPattern: /^#+\s+(infrastructure|cdk|iac)\b/im,
|
|
36
|
+
},
|
|
37
|
+
terraform: {
|
|
38
|
+
label: 'Terraform',
|
|
39
|
+
markerFile: null, // any *.tf file
|
|
40
|
+
sourceDirs: ['*.tf (root module)', 'modules/ (reusable modules)', 'environments/ (per-env tfvars)'],
|
|
41
|
+
headingPattern: /^#+\s+(infrastructure|terraform|iac)\b/im,
|
|
42
|
+
},
|
|
43
|
+
pulumi: {
|
|
44
|
+
label: 'Pulumi',
|
|
45
|
+
markerFile: 'Pulumi.yaml',
|
|
46
|
+
sourceDirs: ['index.ts (main program)', 'stacks/', 'config/'],
|
|
47
|
+
headingPattern: /^#+\s+(infrastructure|pulumi|iac)\b/im,
|
|
48
|
+
},
|
|
49
|
+
sam: {
|
|
50
|
+
label: 'AWS SAM',
|
|
51
|
+
markerFile: 'template.yaml', // also template.yml — checked below
|
|
52
|
+
sourceDirs: ['template.yaml (SAM manifest)', 'src/ (Lambda handlers)', 'events/'],
|
|
53
|
+
headingPattern: /^#+\s+(infrastructure|sam|serverless|iac)\b/im,
|
|
54
|
+
},
|
|
55
|
+
serverless: {
|
|
56
|
+
label: 'Serverless Framework',
|
|
57
|
+
markerFile: 'serverless.yml', // also .yaml, .ts — checked below
|
|
58
|
+
sourceDirs: ['serverless.yml (manifest)', 'handlers/', 'src/'],
|
|
59
|
+
headingPattern: /^#+\s+(infrastructure|serverless|iac)\b/im,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect every IaC tool used in the project. Walks the tree from projectDir
|
|
65
|
+
* looking for marker files, respecting DEFAULT_IGNORE_DIRS.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} projectDir - Absolute path to project root
|
|
68
|
+
* @returns {{
|
|
69
|
+
* isIaC: boolean,
|
|
70
|
+
* tools: Array<{
|
|
71
|
+
* tool: string, // 'cdk' | 'terraform' | 'pulumi' | 'sam' | 'serverless'
|
|
72
|
+
* label: string, // 'AWS CDK' etc.
|
|
73
|
+
* markerPaths: string[], // relative paths to detected marker files
|
|
74
|
+
* packageDirs: string[], // relative dirs containing the markers
|
|
75
|
+
* sourceDirs: string[], // expected source layout per tool convention
|
|
76
|
+
* }>
|
|
77
|
+
* }}
|
|
78
|
+
*/
|
|
79
|
+
export function detectIaC(projectDir) {
|
|
80
|
+
const findings = {
|
|
81
|
+
cdk: { markerPaths: [], packageDirs: [] },
|
|
82
|
+
terraform: { markerPaths: [], packageDirs: [] },
|
|
83
|
+
pulumi: { markerPaths: [], packageDirs: [] },
|
|
84
|
+
sam: { markerPaths: [], packageDirs: [] },
|
|
85
|
+
serverless: { markerPaths: [], packageDirs: [] },
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const recordFinding = (tool, fullPath) => {
|
|
89
|
+
const relPath = relative(projectDir, fullPath);
|
|
90
|
+
findings[tool].markerPaths.push(relPath);
|
|
91
|
+
const pkgDir = relative(projectDir, dirnameOf(fullPath)) || '.';
|
|
92
|
+
if (!findings[tool].packageDirs.includes(pkgDir)) {
|
|
93
|
+
findings[tool].packageDirs.push(pkgDir);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const walk = (dir, depth) => {
|
|
98
|
+
if (depth > MAX_DEPTH) return;
|
|
99
|
+
let entries;
|
|
100
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
101
|
+
|
|
102
|
+
for (const e of entries) {
|
|
103
|
+
if (!e.isFile()) continue;
|
|
104
|
+
const full = join(dir, e.name);
|
|
105
|
+
|
|
106
|
+
// CDK
|
|
107
|
+
if (e.name === 'cdk.json') recordFinding('cdk', full);
|
|
108
|
+
|
|
109
|
+
// Terraform — any .tf file (we record one per directory, not per file)
|
|
110
|
+
if (e.name.endsWith('.tf')) {
|
|
111
|
+
const pkgDir = relative(projectDir, dir) || '.';
|
|
112
|
+
if (!findings.terraform.packageDirs.includes(pkgDir)) {
|
|
113
|
+
findings.terraform.markerPaths.push(relative(projectDir, full));
|
|
114
|
+
findings.terraform.packageDirs.push(pkgDir);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Pulumi
|
|
119
|
+
if (e.name === 'Pulumi.yaml' || e.name === 'Pulumi.yml') {
|
|
120
|
+
recordFinding('pulumi', full);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// SAM — template.yaml/yml WITH AWS::Serverless::
|
|
124
|
+
if (e.name === 'template.yaml' || e.name === 'template.yml') {
|
|
125
|
+
if (fileContains(full, 'AWS::Serverless::')) recordFinding('sam', full);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Serverless Framework
|
|
129
|
+
if (
|
|
130
|
+
e.name === 'serverless.yml' ||
|
|
131
|
+
e.name === 'serverless.yaml' ||
|
|
132
|
+
e.name === 'serverless.ts' ||
|
|
133
|
+
e.name === 'serverless.js'
|
|
134
|
+
) {
|
|
135
|
+
recordFinding('serverless', full);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
if (!e.isDirectory()) continue;
|
|
141
|
+
if (DEFAULT_IGNORE_DIRS.has(e.name)) continue;
|
|
142
|
+
if (e.name.startsWith('.')) continue;
|
|
143
|
+
walk(join(dir, e.name), depth + 1);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (existsSync(projectDir)) {
|
|
148
|
+
try {
|
|
149
|
+
if (statSync(projectDir).isDirectory()) walk(projectDir, 0);
|
|
150
|
+
} catch { /* skip */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const tools = [];
|
|
154
|
+
for (const [tool, data] of Object.entries(findings)) {
|
|
155
|
+
if (data.markerPaths.length > 0) {
|
|
156
|
+
tools.push({
|
|
157
|
+
tool,
|
|
158
|
+
label: TOOL_PROFILES[tool].label,
|
|
159
|
+
markerPaths: data.markerPaths,
|
|
160
|
+
packageDirs: data.packageDirs,
|
|
161
|
+
sourceDirs: TOOL_PROFILES[tool].sourceDirs,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { isIaC: tools.length > 0, tools };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check whether ARCHITECTURE.md content includes an Infrastructure/CDK/IaC/
|
|
171
|
+
* Terraform/Pulumi/SAM heading at any level. Case-insensitive.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} archContent - Full ARCHITECTURE.md content
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
export function hasInfrastructureHeading(archContent) {
|
|
177
|
+
if (!archContent) return false;
|
|
178
|
+
return /^#+\s+(infrastructure|cdk|iac|terraform|pulumi|sam|serverless)\b/im.test(archContent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build the consolidated warning text for a detected IaC tool.
|
|
183
|
+
* One warning per tool — names the marker location and required content.
|
|
184
|
+
*/
|
|
185
|
+
export function buildIaCWarning(toolFinding) {
|
|
186
|
+
const primary = toolFinding.markerPaths[0];
|
|
187
|
+
const pkgDir = toolFinding.packageDirs[0];
|
|
188
|
+
const where = pkgDir === '.' ? '' : pkgDir + '/';
|
|
189
|
+
const sourceList = toolFinding.sourceDirs
|
|
190
|
+
.map(s => s.startsWith('*.') ? `${where}${s}` : `${where}${s}`)
|
|
191
|
+
.join(', ');
|
|
192
|
+
return `${toolFinding.label} detected at ${primary} — add an "Infrastructure" section to ` +
|
|
193
|
+
`ARCHITECTURE.md covering ${sourceList}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function dirnameOf(p) {
|
|
199
|
+
const i = p.lastIndexOf('/');
|
|
200
|
+
if (i < 0) {
|
|
201
|
+
const j = p.lastIndexOf('\\');
|
|
202
|
+
return j < 0 ? p : p.slice(0, j);
|
|
203
|
+
}
|
|
204
|
+
return p.slice(0, i);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function fileContains(filePath, needle) {
|
|
208
|
+
try {
|
|
209
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
210
|
+
return content.includes(needle);
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Backwards-compatibility shim ────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Legacy CDK-only API kept for callers that don't need multi-tool detection.
|
|
220
|
+
* Delegates to detectIaC and projects the CDK slice into the old shape.
|
|
221
|
+
*
|
|
222
|
+
* @deprecated Use detectIaC for new code.
|
|
223
|
+
*/
|
|
224
|
+
export function detectCDK(projectDir) {
|
|
225
|
+
const result = detectIaC(projectDir);
|
|
226
|
+
const cdk = result.tools.find(t => t.tool === 'cdk');
|
|
227
|
+
if (!cdk) {
|
|
228
|
+
return { isCDK: false, cdkJsonPaths: [], cdkPackageDirs: [] };
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
isCDK: true,
|
|
232
|
+
cdkJsonPaths: cdk.markerPaths,
|
|
233
|
+
cdkPackageDirs: cdk.packageDirs,
|
|
234
|
+
};
|
|
235
|
+
}
|