claudeos-core 1.7.1 → 2.0.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.
@@ -7,9 +7,60 @@
7
7
  * Also provides frontend file count statistics.
8
8
  */
9
9
 
10
+ const fs = require("fs");
10
11
  const path = require("path");
11
12
  const { glob } = require("glob");
12
13
 
14
+ // Project-level override: `.claudeos-scan.json` at project root can extend
15
+ // the defaults. Supported fields (all optional, all additive — never replace
16
+ // defaults):
17
+ // {
18
+ // "frontendScan": {
19
+ // "platformKeywords": ["extra-platform", "custom-tier"],
20
+ // "skipSubappNames": ["my-shared-dir"],
21
+ // "minSubappFiles": 3
22
+ // }
23
+ // }
24
+ // Invalid JSON or missing file: silently falls back to defaults.
25
+ function loadScanOverrides(ROOT) {
26
+ try {
27
+ const content = fs.readFileSync(path.join(ROOT, ".claudeos-scan.json"), "utf-8");
28
+ return JSON.parse(content) || {};
29
+ } catch (_e) {
30
+ return {};
31
+ }
32
+ }
33
+
34
+ // Build output / cache / generated dirs that should never be scanned for
35
+ // source code. Centralized so platform scan, Fallback E, and future scanners
36
+ // share the same exclusion set.
37
+ const BUILD_IGNORE_DIRS = [
38
+ "**/node_modules/**",
39
+ "**/build/**", "**/dist/**", "**/out/**",
40
+ "**/.next/**", "**/.nuxt/**", "**/.svelte-kit/**", "**/.angular/**",
41
+ "**/.turbo/**", "**/.cache/**", "**/.parcel-cache/**",
42
+ "**/coverage/**", "**/storybook-static/**",
43
+ "**/.vercel/**", "**/.netlify/**",
44
+ ];
45
+
46
+ // Test / story / type-declaration file globs.
47
+ const TEST_FILE_IGNORE = [
48
+ "**/*.spec.*", "**/*.test.*", "**/*.stories.*",
49
+ "**/*.e2e.*", "**/*.cy.*",
50
+ "**/__snapshots__/**", "**/__tests__/**",
51
+ ];
52
+
53
+ // Build a glob prefix from a glob-returned directory path. Glob v10+ strips
54
+ // trailing slashes from results, and on Windows returns backslash paths;
55
+ // without normalization, the pattern `${dir}**/*.tsx` becomes something like
56
+ // `src/foo**/*.tsx` which only matches one level deep (foo/X.tsx) — not
57
+ // nested paths like foo/routes/X.tsx. Ensuring a trailing `/` turns it into
58
+ // `src/foo/**/*.tsx` which matches any depth.
59
+ function dirGlobPrefix(dir) {
60
+ const fwd = dir.replace(/\\/g, "/");
61
+ return fwd.endsWith("/") ? fwd : fwd + "/";
62
+ }
63
+
13
64
  async function scanFrontendDomains(stack, ROOT) {
14
65
  const frontendDomains = [];
15
66
 
@@ -20,11 +71,16 @@ async function scanFrontendDomains(stack, ROOT) {
20
71
  ...await glob("{src/app,app}/*/", { cwd: ROOT }),
21
72
  ...await glob("{apps,packages}/*/src/app/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
22
73
  ];
23
- const skipAngularDirs = ["shared", "core", "common", "layout", "layouts", "environments", "assets", "styles", "testing", "utils"];
74
+ // Skip structural containers (modules/features/pages/views) at the
75
+ // src/app/*/ level — the files INSIDE those containers are the real
76
+ // features, and the Angular deep fallback below extracts them properly.
77
+ const skipAngularDirs = ["shared", "core", "common", "layout", "layouts",
78
+ "environments", "assets", "styles", "testing", "utils",
79
+ "modules", "features", "pages", "views"];
24
80
  for (const dir of angularAppDirs) {
25
81
  const name = path.basename(dir.replace(/\/$/, ""));
26
82
  if (skipAngularDirs.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
27
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
83
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
28
84
  if (files.length > 0) {
29
85
  const components = files.filter(f => /\.component\.ts$/.test(f)).length;
30
86
  const services = files.filter(f => /\.service\.ts$/.test(f)).length;
@@ -42,7 +98,7 @@ async function scanFrontendDomains(stack, ROOT) {
42
98
  for (const dir of deepAngularDirs) {
43
99
  const name = path.basename(dir.replace(/\/$/, ""));
44
100
  if (skipAngularDirs.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
45
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
101
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.ts`, { cwd: ROOT, ignore: ["**/*.spec.ts", "**/*.test.ts"] });
46
102
  if (files.length >= 2) {
47
103
  const components = files.filter(f => /\.component\.ts$/.test(f)).length;
48
104
  const services = files.filter(f => /\.service\.ts$/.test(f)).length;
@@ -67,11 +123,16 @@ async function scanFrontendDomains(stack, ROOT) {
67
123
  // Vite SPA / CRA common paths (src/views/*, src/screens/*, src/routes/*)
68
124
  ...await glob("src/{views,screens,routes}/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] }),
69
125
  ];
70
- const skipPages = ["api", "_app", "_document", "fonts", "not-found", "error", "loading"];
126
+ // Reserved Next.js/Router segments + structural containers that are never
127
+ // real route features. "components"/"hooks"/"widgets" under app/ or
128
+ // pages/ are UI containers handled by dedicated scanners — not routes.
129
+ const skipPages = ["api", "_app", "_document", "fonts", "not-found", "error", "loading",
130
+ "components", "hooks", "widgets", "entities", "features", "modules",
131
+ "lib", "libs", "utils", "util", "config", "types", "shared", "common", "assets"];
71
132
  for (const dir of allDirs) {
72
133
  const name = path.basename(dir);
73
134
  if (skipPages.includes(name) || name.startsWith("(") || name.startsWith("[") || name.startsWith("_") || name.startsWith(".")) continue;
74
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT });
135
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT });
75
136
  if (files.length > 0) {
76
137
  const pages = files.filter(f => /page\.|index\./.test(f)).length;
77
138
  const layouts = files.filter(f => /layout\./.test(f)).length;
@@ -95,7 +156,7 @@ async function scanFrontendDomains(stack, ROOT) {
95
156
  for (const dir of fsdDirs) {
96
157
  const name = path.basename(dir);
97
158
  if (["ui", "common", "shared", "lib", "config", "index"].includes(name)) continue;
98
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
159
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
99
160
  if (files.length > 0) {
100
161
  const uiFiles = files.filter(f => /\bui\b/.test(f)).length;
101
162
  const modelFiles = files.filter(f => /model|store|hook/.test(f)).length;
@@ -113,7 +174,7 @@ async function scanFrontendDomains(stack, ROOT) {
113
174
  for (const dir of compDirs) {
114
175
  const name = path.basename(dir);
115
176
  if (["ui", "common", "shared", "layout", "icons"].includes(name)) continue;
116
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.{tsx,jsx,vue}`, { cwd: ROOT });
177
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,vue}`, { cwd: ROOT });
117
178
  if (files.length >= 2) {
118
179
  frontendDomains.push({ name: `comp-${name}`, type: "frontend", components: files.length, totalFiles: files.length });
119
180
  }
@@ -187,7 +248,7 @@ async function scanFrontendDomains(stack, ROOT) {
187
248
  for (const dir of deepCompDirs) {
188
249
  const name = path.basename(dir.replace(/\/$/, ""));
189
250
  if (skipDomainNames.includes(name) || name.startsWith("_") || name.startsWith(".")) continue;
190
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
251
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
191
252
  if (files.length >= 2) {
192
253
  if (!deepDomains[name]) deepDomains[name] = { components: 0, totalFiles: 0 };
193
254
  deepDomains[name].components += files.length;
@@ -212,7 +273,7 @@ async function scanFrontendDomains(stack, ROOT) {
212
273
  for (const dir of dirs) {
213
274
  const name = path.basename(dir.replace(/\/$/, ""));
214
275
  if (skipDirNames.includes(name) || name.startsWith("_") || name.startsWith(".") || name.startsWith("(") || name.startsWith("[")) continue;
215
- const files = await glob(`${dir.replace(/\\/g, "/")}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
276
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, { cwd: ROOT, ignore: ["**/*.spec.*", "**/*.test.*", "**/*.stories.*"] });
216
277
  if (files.length >= 2) {
217
278
  if (!deepDirDomains[name]) deepDirDomains[name] = { components: 0, pages: 0, totalFiles: 0, sources: [] };
218
279
  const tsx = files.filter(f => /\.(tsx|jsx|vue)$/.test(f)).length;
@@ -226,6 +287,152 @@ async function scanFrontendDomains(stack, ROOT) {
226
287
  frontendDomains.push({ name, type: "frontend", components: data.components, totalFiles: data.totalFiles, sources: data.sources });
227
288
  }
228
289
  }
290
+
291
+ }
292
+ }
293
+
294
+ // ── Shared frontend patterns (run for ANY frontend: Angular, Next.js, React, Vue) ──
295
+ //
296
+ // These patterns don't depend on framework-specific file extensions or
297
+ // folder conventions — they look for top-level segmentation by platform or
298
+ // by routes/-file layouts, which appear across all frontend frameworks.
299
+ if (stack.frontend) {
300
+ // Read optional per-project override (.claudeos-scan.json).
301
+ const overrides = loadScanOverrides(ROOT).frontendScan || {};
302
+ // Platform-split layout: src/{platform}/{subapp}/ where platform is a
303
+ // device/target-environment OR access-tier keyword. Both form the same
304
+ // structural pattern (top-level segmentation with a common subapp layout).
305
+ // Subapp name comes from the filesystem at scan time via path.basename —
306
+ // no project-specific names are hardcoded.
307
+ // Produces one domain per (platform, subapp) pair, named `{platform}-{subapp}`.
308
+ // NOTE: 3-letter+ access-tier names only. The short `adm` abbreviation
309
+ // is deliberately excluded — too ambiguous in isolation and false-positive
310
+ // risk isn't worth the small convenience gain. If a project uses `src/adm/`
311
+ // as an admin tier root, rename to `admin` or add `"adm"` to
312
+ // `frontendScan.platformKeywords` in `.claudeos-scan.json`.
313
+ const DEFAULT_PLATFORM_KEYWORDS = [
314
+ // device / target-environment
315
+ "desktop", "pc", "web",
316
+ "mobile", "mc", "mo", "sp",
317
+ "tablet", "tab",
318
+ "pwa",
319
+ "tv", "ctv", "ott",
320
+ "watch", "wear",
321
+ // access-tier / audience
322
+ "admin", "cms", "backoffice", "back-office", "portal",
323
+ ];
324
+ const PLATFORM_KEYWORDS = [
325
+ ...DEFAULT_PLATFORM_KEYWORDS,
326
+ ...(Array.isArray(overrides.platformKeywords) ? overrides.platformKeywords : []),
327
+ ];
328
+ // Minimum source files to qualify as a subapp. A single-file directory
329
+ // under a platform root is almost always an accidental fixture or a
330
+ // placeholder, not a real subapp. Raising the floor avoids noisy
331
+ // 1-file "domains" in the Pass 1 group plan.
332
+ const MIN_SUBAPP_FILES = typeof overrides.minSubappFiles === "number" && overrides.minSubappFiles >= 1
333
+ ? overrides.minSubappFiles
334
+ : 2;
335
+ // Conservative skip list — never-a-feature names at the subapp level.
336
+ // Includes infrastructure dirs, structural dirs that other scanners
337
+ // already handle (components/hooks/layouts), FSD layer names, and
338
+ // framework router dirs. Patterns like `src/admin/pages/*/` and
339
+ // `src/admin/components/*/` fall through to the App/Pages Router and
340
+ // components scanners instead of being captured as bare subapps.
341
+ // `store`/`stores` are deliberately NOT skipped — e-commerce projects
342
+ // legitimately use them as subapp names.
343
+ const DEFAULT_SKIP_SUBAPP_NAMES = [
344
+ // infrastructure
345
+ "assets", "common", "shared", "utils", "util",
346
+ "lib", "libs", "config", "constants", "helpers", "types",
347
+ "test", "tests", "__mocks__", "mocks", "__tests__",
348
+ // structural (handled by dedicated scanners at a deeper level)
349
+ "components", "hooks", "layouts", "layout",
350
+ // FSD layers (handled by FSD scanner)
351
+ "widgets", "features", "entities",
352
+ // framework router dirs (handled by App/Pages Router scanner + fallback D)
353
+ "app", "pages", "routes", "views", "screens", "containers",
354
+ "modules", "domains",
355
+ ];
356
+ const SKIP_SUBAPP_NAMES = [
357
+ ...DEFAULT_SKIP_SUBAPP_NAMES,
358
+ ...(Array.isArray(overrides.skipSubappNames) ? overrides.skipSubappNames : []),
359
+ ];
360
+ // Match both standalone projects (src/{platform}/{subapp}/) and monorepo
361
+ // workspaces ({apps,packages}/*/src/{platform}/{subapp}/ and
362
+ // {apps,packages}/{platform}/{subapp}/ — some monorepos skip the src/ wrapper).
363
+ const platformGlobs = [
364
+ `src/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
365
+ `{apps,packages}/*/src/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
366
+ `{apps,packages}/{${PLATFORM_KEYWORDS.join(",")}}/*/`,
367
+ ];
368
+ const platformDirs = [];
369
+ for (const p of platformGlobs) {
370
+ const dirs = await glob(p, { cwd: ROOT, ignore: ["**/node_modules/**"] });
371
+ platformDirs.push(...dirs);
372
+ }
373
+ // Dedupe (the three globs can produce overlapping matches in some layouts)
374
+ const seenPlatformDirs = new Set();
375
+ for (const dir of platformDirs) {
376
+ const dirFwd = dir.replace(/\\/g, "/").replace(/\/$/, "");
377
+ if (seenPlatformDirs.has(dirFwd)) continue;
378
+ seenPlatformDirs.add(dirFwd);
379
+ const parts = dirFwd.split("/");
380
+ // Locate platform segment: the FIRST segment that matches a keyword.
381
+ // findIndex (not findLast) — if the subapp name also happens to be a
382
+ // keyword (e.g., `src/pc/admin/`), the subapp should stay as the
383
+ // second match, not be mistaken for the platform segment.
384
+ // Paths handled: src/<p>/<s>, apps/<workspace>/src/<p>/<s>, apps/<p>/<s>.
385
+ const platformIdx = parts.findIndex(seg => PLATFORM_KEYWORDS.includes(seg));
386
+ if (platformIdx < 0 || platformIdx + 1 >= parts.length) continue;
387
+ const platform = parts[platformIdx];
388
+ const subapp = parts[platformIdx + 1];
389
+ if (!subapp || SKIP_SUBAPP_NAMES.includes(subapp) || subapp.startsWith("_") || subapp.startsWith(".")) continue;
390
+ const files = await glob(`${dirGlobPrefix(dir)}**/*.{tsx,jsx,ts,js,vue}`, {
391
+ cwd: ROOT,
392
+ ignore: [...BUILD_IGNORE_DIRS, ...TEST_FILE_IGNORE],
393
+ });
394
+ if (files.length < MIN_SUBAPP_FILES) continue;
395
+ // Normalize Windows backslashes so the segment regex works cross-platform.
396
+ const filesFwd = files.map(f => f.replace(/\\/g, "/"));
397
+ const routes = filesFwd.filter(f => /\/routes\//.test(f)).length;
398
+ const components = filesFwd.filter(f => /\/components\//.test(f)).length;
399
+ const layouts = filesFwd.filter(f => /\/layouts?\//.test(f)).length;
400
+ const hooks = filesFwd.filter(f => /\/hooks\//.test(f)).length;
401
+ frontendDomains.push({
402
+ name: `${platform}-${subapp}`,
403
+ type: "frontend",
404
+ platform,
405
+ subapp,
406
+ routes, components, layouts, hooks,
407
+ totalFiles: files.length,
408
+ });
409
+ }
410
+
411
+ // Fallback E: React Router file-routing (any depth). Groups by the
412
+ // parent dir of `routes/`. Domain name = parent basename. Covers
413
+ // CRA/Vite + React Router projects that don't match Next.js page.tsx
414
+ // or FSD layout. Only fires when every other primary and fallback
415
+ // scanner returned 0 domains.
416
+ if (frontendDomains.length === 0) {
417
+ const routeFiles = await glob("**/routes/*.{tsx,jsx,ts,js,vue}", {
418
+ cwd: ROOT,
419
+ ignore: [...BUILD_IGNORE_DIRS, ...TEST_FILE_IGNORE],
420
+ });
421
+ const routeDomains = {};
422
+ const skipParents = ["src", "app", "pages", "", "."];
423
+ for (const f of routeFiles) {
424
+ const parts = f.replace(/\\/g, "/").split("/");
425
+ const routesIdx = parts.lastIndexOf("routes");
426
+ if (routesIdx < 1) continue;
427
+ const parent = parts[routesIdx - 1];
428
+ if (skipParents.includes(parent) || parent.startsWith("_") || parent.startsWith(".")) continue;
429
+ if (!routeDomains[parent]) routeDomains[parent] = { routes: 0, totalFiles: 0 };
430
+ routeDomains[parent].routes++;
431
+ routeDomains[parent].totalFiles++;
432
+ }
433
+ for (const [name, data] of Object.entries(routeDomains)) {
434
+ frontendDomains.push({ name, type: "frontend", routes: data.routes, totalFiles: data.totalFiles });
435
+ }
229
436
  }
230
437
  }
231
438
 
@@ -1,132 +1,133 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * ClaudeOS-Core — Sync Checker
5
- *
6
- * Role: Check disk ↔ Master Plan sync status based on sync-map.json
7
- * Detection items:
8
- * - Unregistered: file exists on disk but not registered in any plan
9
- * - Orphaned: registered in plan but missing from disk
10
- *
11
- * Usage: npx claudeos-core <cmd> or node claudeos-core-tools/sync-checker/index.js
12
- * Depends: manifest-generator must run first (sync-map.json)
13
- */
14
-
15
- const fs = require("fs");
16
- const path = require("path");
17
- const { glob } = require("glob");
18
- const { updateStaleReport } = require("../lib/stale-report");
19
-
20
- const ROOT = process.env.CLAUDEOS_ROOT || path.resolve(__dirname, "../..");
21
- const GEN = path.join(ROOT, "claudeos-core/generated");
22
- const SMP = path.join(GEN, "sync-map.json");
23
-
24
- const TRACKED = [
25
- { dir: ".claude/rules", pfx: "rules" },
26
- { dir: "claudeos-core/standard", pfx: "standard" },
27
- { dir: "claudeos-core/skills", pfx: "skills" },
28
- { dir: "claudeos-core/guide", pfx: "guide" },
29
- { dir: "claudeos-core/database", pfx: "database" },
30
- { dir: "claudeos-core/mcp-guide", pfx: "mcp-guide" },
31
- ];
32
-
33
- function rel(p) {
34
- return path.relative(ROOT, p).replace(/\\/g, "/");
35
- }
36
-
37
- function isWithinRoot(absPath) {
38
- let resolved = path.resolve(absPath);
39
- let root = path.resolve(ROOT);
40
- if (process.platform === "win32") {
41
- resolved = resolved.toLowerCase();
42
- root = root.toLowerCase();
43
- }
44
- return resolved === root || resolved.startsWith(root + path.sep);
45
- }
46
-
47
- async function main() {
48
- console.log("\n╔═══════════════════════════════════════╗");
49
- console.log("║ ClaudeOS-Core — Sync Checker ║");
50
- console.log("╚═══════════════════════════════════════╝\n");
51
-
52
- if (!fs.existsSync(SMP)) {
53
- console.log(" sync-map.json not found. Run manifest-generator first.\n");
54
- process.exit(1);
55
- }
56
-
57
- let sm;
58
- try {
59
- sm = JSON.parse(fs.readFileSync(SMP, "utf-8"));
60
- } catch (e) {
61
- console.log(` sync-map.json is malformed: ${e.message}\n`);
62
- process.exit(1);
63
- }
64
- if (!Array.isArray(sm.mappings)) {
65
- console.log(" sync-map.json has no mappings array.\n");
66
- process.exit(1);
67
- }
68
- const reg = new Set(sm.mappings.map((m) => m.sourcePath).filter(Boolean));
69
- const issues = { unreg: [], orphan: [] };
70
-
71
- // ─── [1/2] Disk → Plan: detect unregistered files ───────
72
- console.log(" [1/2] Disk → Plan...");
73
- for (const t of TRACKED) {
74
- const abs = path.join(ROOT, t.dir);
75
- if (!fs.existsSync(abs)) continue;
76
-
77
- for (const f of await glob("**/*.md", { cwd: abs, absolute: true })) {
78
- const r = rel(f);
79
- if (path.basename(f) === "README.md") continue;
80
- if (!reg.has(r)) {
81
- issues.unreg.push({ path: r, domain: t.pfx });
82
- }
83
- }
84
- }
85
-
86
- // Check CLAUDE.md separately
87
- if (fs.existsSync(path.join(ROOT, "CLAUDE.md")) && !reg.has("CLAUDE.md")) {
88
- issues.unreg.push({ path: "CLAUDE.md", domain: "root" });
89
- }
90
-
91
- // ─── [2/2] Plan → Disk: detect orphaned files ───────────────
92
- console.log(" [2/2] Plan → Disk...");
93
- for (const m of sm.mappings) {
94
- if (!m.sourcePath) continue;
95
- const abs = path.join(ROOT, m.sourcePath);
96
- // Skip path traversal attempts (allow files at ROOT level and below)
97
- if (!isWithinRoot(abs)) continue;
98
- if (!fs.existsSync(abs)) {
99
- issues.orphan.push({ path: m.sourcePath, plan: m.planFile });
100
- }
101
- }
102
-
103
- // ─── Output results ─────────────────────────────────────────
104
- if (issues.unreg.length) {
105
- console.log(`\n ⚠️ Unregistered (${issues.unreg.length}):`);
106
- issues.unreg.forEach((i) => console.log(` + ${i.path}`));
107
- }
108
- if (issues.orphan.length) {
109
- console.log(`\n ⚠️ Orphaned (${issues.orphan.length}):`);
110
- issues.orphan.forEach((i) => console.log(` - ${i.path}`));
111
- }
112
-
113
- const total = issues.unreg.length + issues.orphan.length;
114
- console.log(`\n Registered: ${reg.size} | Unregistered: ${issues.unreg.length} | Orphaned: ${issues.orphan.length}`);
115
- console.log(total === 0 ? " ✅ All in sync\n" : ` ⚠️ ${total} issues\n`);
116
-
117
- // ─── Update stale-report.json ────────────────────────────
118
- updateStaleReport(GEN, "syncMisses",
119
- { checkedAt: new Date().toISOString(), unregistered: issues.unreg, orphaned: issues.orphan },
120
- { syncIssues: total, status: total === 0 ? "ok" : "warning" }
121
- );
122
-
123
- // Exit 1 only for orphaned files (actual breakage), not for unregistered (informational)
124
- const orphanCount = issues.orphan.length;
125
- process.exit(orphanCount > 0 ? 1 : 0);
126
- }
127
-
128
- if (require.main === module) {
129
- main().catch(e => { console.error(`\n ❌ Unexpected error: ${e.message || e}`); process.exit(1); });
130
- }
131
-
132
- module.exports = { main };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ClaudeOS-Core — Sync Checker
5
+ *
6
+ * Role: Check disk ↔ Master Plan sync status based on sync-map.json
7
+ * Detection items:
8
+ * - Unregistered: file exists on disk but not registered in any plan
9
+ * - Orphaned: registered in plan but missing from disk
10
+ *
11
+ * Usage: npx claudeos-core <cmd> or node claudeos-core-tools/sync-checker/index.js
12
+ * Depends: manifest-generator must run first (sync-map.json)
13
+ */
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const { glob } = require("glob");
18
+ const { updateStaleReport } = require("../lib/stale-report");
19
+
20
+ const ROOT = process.env.CLAUDEOS_ROOT || path.resolve(__dirname, "../..");
21
+ const GEN = path.join(ROOT, "claudeos-core/generated");
22
+ const SMP = path.join(GEN, "sync-map.json");
23
+
24
+ const TRACKED = [
25
+ { dir: ".claude/rules", pfx: "rules" },
26
+ { dir: "claudeos-core/standard", pfx: "standard" },
27
+ { dir: "claudeos-core/skills", pfx: "skills" },
28
+ { dir: "claudeos-core/guide", pfx: "guide" },
29
+ { dir: "claudeos-core/database", pfx: "database" },
30
+ { dir: "claudeos-core/mcp-guide", pfx: "mcp-guide" },
31
+ { dir: "claudeos-core/memory", pfx: "memory" },
32
+ ];
33
+
34
+ function rel(p) {
35
+ return path.relative(ROOT, p).replace(/\\/g, "/");
36
+ }
37
+
38
+ function isWithinRoot(absPath) {
39
+ let resolved = path.resolve(absPath);
40
+ let root = path.resolve(ROOT);
41
+ if (process.platform === "win32") {
42
+ resolved = resolved.toLowerCase();
43
+ root = root.toLowerCase();
44
+ }
45
+ return resolved === root || resolved.startsWith(root + path.sep);
46
+ }
47
+
48
+ async function main() {
49
+ console.log("\n╔═══════════════════════════════════════╗");
50
+ console.log("║ ClaudeOS-Core — Sync Checker ║");
51
+ console.log("╚═══════════════════════════════════════╝\n");
52
+
53
+ if (!fs.existsSync(SMP)) {
54
+ console.log(" ❌ sync-map.json not found. Run manifest-generator first.\n");
55
+ process.exit(1);
56
+ }
57
+
58
+ let sm;
59
+ try {
60
+ sm = JSON.parse(fs.readFileSync(SMP, "utf-8"));
61
+ } catch (e) {
62
+ console.log(` ❌ sync-map.json is malformed: ${e.message}\n`);
63
+ process.exit(1);
64
+ }
65
+ if (!Array.isArray(sm.mappings)) {
66
+ console.log(" ❌ sync-map.json has no mappings array.\n");
67
+ process.exit(1);
68
+ }
69
+ const reg = new Set(sm.mappings.map((m) => m.sourcePath).filter(Boolean));
70
+ const issues = { unreg: [], orphan: [] };
71
+
72
+ // ─── [1/2] Disk → Plan: detect unregistered files ───────
73
+ console.log(" [1/2] Disk Plan...");
74
+ for (const t of TRACKED) {
75
+ const abs = path.join(ROOT, t.dir);
76
+ if (!fs.existsSync(abs)) continue;
77
+
78
+ for (const f of await glob("**/*.md", { cwd: abs, absolute: true })) {
79
+ const r = rel(f);
80
+ if (path.basename(f) === "README.md") continue;
81
+ if (!reg.has(r)) {
82
+ issues.unreg.push({ path: r, domain: t.pfx });
83
+ }
84
+ }
85
+ }
86
+
87
+ // Check CLAUDE.md separately
88
+ if (fs.existsSync(path.join(ROOT, "CLAUDE.md")) && !reg.has("CLAUDE.md")) {
89
+ issues.unreg.push({ path: "CLAUDE.md", domain: "root" });
90
+ }
91
+
92
+ // ─── [2/2] Plan → Disk: detect orphaned files ───────────────
93
+ console.log(" [2/2] Plan Disk...");
94
+ for (const m of sm.mappings) {
95
+ if (!m.sourcePath) continue;
96
+ const abs = path.join(ROOT, m.sourcePath);
97
+ // Skip path traversal attempts (allow files at ROOT level and below)
98
+ if (!isWithinRoot(abs)) continue;
99
+ if (!fs.existsSync(abs)) {
100
+ issues.orphan.push({ path: m.sourcePath, plan: m.planFile });
101
+ }
102
+ }
103
+
104
+ // ─── Output results ─────────────────────────────────────────
105
+ if (issues.unreg.length) {
106
+ console.log(`\n ⚠️ Unregistered (${issues.unreg.length}):`);
107
+ issues.unreg.forEach((i) => console.log(` + ${i.path}`));
108
+ }
109
+ if (issues.orphan.length) {
110
+ console.log(`\n ⚠️ Orphaned (${issues.orphan.length}):`);
111
+ issues.orphan.forEach((i) => console.log(` - ${i.path}`));
112
+ }
113
+
114
+ const total = issues.unreg.length + issues.orphan.length;
115
+ console.log(`\n Registered: ${reg.size} | Unregistered: ${issues.unreg.length} | Orphaned: ${issues.orphan.length}`);
116
+ console.log(total === 0 ? " ✅ All in sync\n" : ` ⚠️ ${total} issues\n`);
117
+
118
+ // ─── Update stale-report.json ────────────────────────────
119
+ updateStaleReport(GEN, "syncMisses",
120
+ { checkedAt: new Date().toISOString(), unregistered: issues.unreg, orphaned: issues.orphan },
121
+ { syncIssues: total, status: total === 0 ? "ok" : "warning" }
122
+ );
123
+
124
+ // Exit 1 only for orphaned files (actual breakage), not for unregistered (informational)
125
+ const orphanCount = issues.orphan.length;
126
+ process.exit(orphanCount > 0 ? 1 : 0);
127
+ }
128
+
129
+ if (require.main === module) {
130
+ main().catch(e => { console.error(`\n ❌ Unexpected error: ${e.message || e}`); process.exit(1); });
131
+ }
132
+
133
+ module.exports = { main };