claudeos-core 1.5.0 → 1.6.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 (39) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.de.md +54 -27
  3. package/README.es.md +54 -26
  4. package/README.fr.md +54 -26
  5. package/README.hi.md +54 -27
  6. package/README.ja.md +53 -26
  7. package/README.ko.md +56 -29
  8. package/README.md +53 -26
  9. package/README.ru.md +54 -27
  10. package/README.vi.md +54 -27
  11. package/README.zh-CN.md +54 -27
  12. package/bin/cli.js +6 -2
  13. package/bin/commands/init.js +48 -46
  14. package/bin/lib/cli-utils.js +6 -10
  15. package/bin/lib/lang-selector.js +10 -8
  16. package/bin/lib/resume-selector.js +10 -8
  17. package/content-validator/index.js +7 -12
  18. package/health-checker/index.js +7 -17
  19. package/lib/plan-parser.js +149 -0
  20. package/lib/safe-fs.js +2 -2
  21. package/lib/stale-report.js +36 -0
  22. package/manifest-generator/index.js +13 -36
  23. package/package.json +87 -87
  24. package/pass-json-validator/index.js +7 -12
  25. package/pass-prompts/templates/node-nestjs/pass1.md +132 -0
  26. package/pass-prompts/templates/node-nestjs/pass2.md +97 -0
  27. package/pass-prompts/templates/node-nestjs/pass3.md +121 -0
  28. package/pass-prompts/templates/vue-nuxt/pass1.md +119 -0
  29. package/pass-prompts/templates/vue-nuxt/pass2.md +83 -0
  30. package/pass-prompts/templates/vue-nuxt/pass3.md +100 -0
  31. package/plan-installer/domain-grouper.js +5 -2
  32. package/plan-installer/prompt-generator.js +1 -4
  33. package/plan-installer/scanners/scan-frontend.js +14 -7
  34. package/plan-installer/scanners/scan-java.js +18 -13
  35. package/plan-installer/scanners/scan-kotlin.js +47 -23
  36. package/plan-installer/scanners/scan-node.js +16 -2
  37. package/plan-installer/stack-detector.js +42 -1
  38. package/plan-validator/index.js +11 -121
  39. package/sync-checker/index.js +6 -19
@@ -20,7 +20,7 @@ async function scanJavaDomains(stack, ROOT) {
20
20
 
21
21
  const javaFiles = await glob("src/main/java/**/*.java", { cwd: ROOT });
22
22
  for (const f of javaFiles) {
23
- const m = f.match(/src\/main\/java\/(.+?)\/(controller|aggregator|service|mapper|dao|dto|entity|repository|adapter)/);
23
+ const m = f.match(/src\/main\/java\/(.+?)\/(controller|aggregator|facade|usecase|orchestrator|service|mapper|dao|dto|entity|repository|adapter)/);
24
24
  if (m) { rootPackage = m[1].replace(/\//g, "."); break; }
25
25
  }
26
26
  const domainMap = {};
@@ -100,16 +100,16 @@ async function scanJavaDomains(stack, ROOT) {
100
100
  if (Object.keys(domainMap).length > 0) detectedPattern = "C";
101
101
  }
102
102
 
103
- // ── Supplementary scan: detect domains without controllers (service/dao/aggregator only) ──
103
+ // ── Supplementary scan: detect domains without controllers (service/dao/aggregator/facade/usecase only) ──
104
104
  // Runs for ALL detected patterns (A/B/C/D/E) to catch core-only domains
105
105
  {
106
106
  const serviceDirs = await glob("src/main/java/**/*/service/*.java", { cwd: ROOT });
107
107
  const mapperDirs = await glob("src/main/java/**/*/{mapper,repository,dao}/*.java", { cwd: ROOT });
108
- const aggregatorDirs = await glob("src/main/java/**/*/aggregator/*.java", { cwd: ROOT });
109
- const allServiceFiles = [...serviceDirs, ...mapperDirs, ...aggregatorDirs];
108
+ const orchestrationDirs = await glob("src/main/java/**/*/{aggregator,facade,usecase,orchestrator}/*.java", { cwd: ROOT });
109
+ const allServiceFiles = [...serviceDirs, ...mapperDirs, ...orchestrationDirs];
110
110
  const skipDomains = ["common", "config", "util", "utils", "base", "core", "shared", "global", "framework", "infra", "front", "admin", "back", "internal", "external", "web", "app", "test", "tests", "main", "generated", "build"];
111
111
  for (const f of allServiceFiles) {
112
- const m = f.match(/\/([^/]+)\/(service|mapper|repository|dao|aggregator)\/[^/]+\.java$/);
112
+ const m = f.match(/\/([^/]+)\/(service|mapper|repository|dao|aggregator|facade|usecase|orchestrator)\/[^/]+\.java$/);
113
113
  if (m) {
114
114
  const d = m[1];
115
115
  if (!domainMap[d] && !skipDomains.includes(d) && !/^v\d+$/.test(d)) {
@@ -119,7 +119,7 @@ async function scanJavaDomains(stack, ROOT) {
119
119
  }
120
120
  }
121
121
 
122
- // Scan service/mapper/dao/aggregator/dto/xml files for each domain
122
+ // Scan service/mapper/dao/aggregator/facade/usecase/dto/xml files for each domain
123
123
  for (const d of Object.keys(domainMap)) {
124
124
  const p = domainMap[d].pattern;
125
125
  const dn = domainMap[d].domainName || d;
@@ -129,12 +129,12 @@ async function scanJavaDomains(stack, ROOT) {
129
129
  svcGlob = `src/main/java/**/service/${d}/*.java`;
130
130
  mprGlob = `src/main/java/**/{mapper,repository,dao}/${d}/*.java`;
131
131
  dtoGlob = `src/main/java/**/dto/${d}/**/*.java`;
132
- aggGlob = `src/main/java/**/aggregator/${d}/*.java`;
132
+ aggGlob = `src/main/java/**/{aggregator,facade,usecase,orchestrator}/${d}/*.java`;
133
133
  } else if (p === "B" || p === "D") {
134
134
  svcGlob = `src/main/java/**/${dn}/service/*.java`;
135
135
  mprGlob = `src/main/java/**/${dn}/{mapper,repository,dao}/*.java`;
136
136
  dtoGlob = `src/main/java/**/${dn}/dto/**/*.java`;
137
- aggGlob = `src/main/java/**/${dn}/aggregator/*.java`;
137
+ aggGlob = `src/main/java/**/${dn}/{aggregator,facade,usecase,orchestrator}/*.java`;
138
138
  } else if (p === "E") {
139
139
  svcGlob = `src/main/java/**/${d}/{application,domain}/**/*.java`;
140
140
  mprGlob = `src/main/java/**/${d}/adapter/out/{persistence,repository}/*.java`;
@@ -146,9 +146,14 @@ async function scanJavaDomains(stack, ROOT) {
146
146
  svcGlob = `src/main/java/**/service/${cap}*.java`;
147
147
  mprGlob = `src/main/java/**/{mapper,repository,dao}/${cap}*.java`;
148
148
  dtoGlob = `src/main/java/**/dto/${cap}*.java`;
149
- aggGlob = `src/main/java/**/aggregator/${cap}*.java`;
149
+ aggGlob = `src/main/java/**/{aggregator,facade,usecase,orchestrator}/${cap}*.java`;
150
150
  }
151
- const xmlGlob = `src/main/resources/{mapper,mybatis}/**/${dn}/*.xml`;
151
+ // Pattern C (flat): XML may be in flat directory without domain subdirectory (e.g., mapper/OrderMapper.xml)
152
+ // Other patterns: XML is in domain subdirectory (e.g., mapper/order/OrderMapper.xml)
153
+ const capDn = dn.charAt(0).toUpperCase() + dn.slice(1);
154
+ const xmlGlob = p === "C"
155
+ ? `src/main/resources/{mapper,mybatis}/**/{${dn}/${capDn}*.xml,${capDn}*.xml}`
156
+ : `src/main/resources/{mapper,mybatis}/**/${dn}/*.xml`;
152
157
 
153
158
  const svc = await glob(svcGlob, { cwd: ROOT });
154
159
  const mpr = await glob(mprGlob, { cwd: ROOT });
@@ -169,7 +174,7 @@ async function scanJavaDomains(stack, ROOT) {
169
174
  const javaDomains = {};
170
175
  const skipNames = ["common", "config", "util", "utils", "base", "shared", "global", "framework", "infra", "api", "main", "front", "admin", "back", "internal", "external", "web", "app", "test", "tests", "generated", "build"];
171
176
  const versionPattern = /^v\d+$/;
172
- const layerNames = ["controller", "aggregator", "service", "mapper", "repository", "dao", "dto", "vo", "entity", "adapter"];
177
+ const layerNames = ["controller", "aggregator", "facade", "usecase", "orchestrator", "service", "mapper", "repository", "dao", "dto", "vo", "entity", "adapter"];
173
178
 
174
179
  for (const f of allJava) {
175
180
  const parts = f.replace(/\\/g, "/").split("/");
@@ -182,7 +187,7 @@ async function scanJavaDomains(stack, ROOT) {
182
187
  if (i > 0 && !skipNames.includes(prevDir) && !layerNames.includes(prevDir) && !prevDir.includes(".") && !versionPattern.test(prevDir)) {
183
188
  if (!javaDomains[prevDir]) javaDomains[prevDir] = { controllers: 0, services: 0, mappers: 0, dtos: 0, xmlMappers: 0, pattern: "B" };
184
189
  if (parts[i] === "controller") javaDomains[prevDir].controllers++;
185
- else if (parts[i] === "aggregator" || parts[i] === "service") javaDomains[prevDir].services++;
190
+ else if (["aggregator", "facade", "usecase", "orchestrator", "service"].includes(parts[i])) javaDomains[prevDir].services++;
186
191
  else if (["mapper", "repository", "dao"].includes(parts[i])) javaDomains[prevDir].mappers++;
187
192
  else if (["dto", "vo"].includes(parts[i])) javaDomains[prevDir].dtos++;
188
193
  }
@@ -190,7 +195,7 @@ async function scanJavaDomains(stack, ROOT) {
190
195
  if (nextDir && !nextDir.endsWith(".java") && !skipNames.includes(nextDir) && !layerNames.includes(nextDir) && !versionPattern.test(nextDir)) {
191
196
  if (!javaDomains[nextDir]) javaDomains[nextDir] = { controllers: 0, services: 0, mappers: 0, dtos: 0, xmlMappers: 0, pattern: "A" };
192
197
  if (parts[i] === "controller") javaDomains[nextDir].controllers++;
193
- else if (parts[i] === "aggregator" || parts[i] === "service") javaDomains[nextDir].services++;
198
+ else if (["aggregator", "facade", "usecase", "orchestrator", "service"].includes(parts[i])) javaDomains[nextDir].services++;
194
199
  else if (["mapper", "repository", "dao"].includes(parts[i])) javaDomains[nextDir].mappers++;
195
200
  else if (["dto", "vo"].includes(parts[i])) javaDomains[nextDir].dtos++;
196
201
  }
@@ -9,15 +9,18 @@
9
9
  const path = require("path");
10
10
  const { glob } = require("glob");
11
11
 
12
+ // Normalize backslash paths from glob on Windows to forward slashes
13
+ const norm = (p) => p.replace(/\\/g, "/");
14
+
12
15
  async function scanKotlinDomains(stack, ROOT) {
13
16
  const backendDomains = [];
14
17
  let rootPackage = null;
15
18
 
16
19
  // Scan all .kt files across all submodules
17
- const ktFiles = await glob("**/src/main/kotlin/**/*.kt", {
20
+ const ktFiles = (await glob("**/src/main/kotlin/**/*.kt", {
18
21
  cwd: ROOT,
19
22
  ignore: ["**/node_modules/**", "**/build/**", "**/test/**", "**/generated/**"],
20
- });
23
+ })).map(norm);
21
24
 
22
25
  // Detect root package from first controller/service file
23
26
  for (const f of ktFiles) {
@@ -33,13 +36,19 @@ async function scanKotlinDomains(stack, ROOT) {
33
36
  ];
34
37
  const moduleSet = {};
35
38
  for (const mg of moduleGlobs) {
36
- const moduleDirs = await glob(mg, { cwd: ROOT });
39
+ const moduleDirs = (await glob(mg, { cwd: ROOT })).map(norm);
37
40
  for (const md of moduleDirs) {
38
- const parts = md.replace(/\/src\/main\/kotlin\/$/, "").split("/");
41
+ const parts = md.replace(/\/src\/main\/kotlin\/?$/, "").split("/");
39
42
  const moduleName = parts[parts.length - 1]; // e.g., "reservation-command-server"
40
43
  if (moduleSet[moduleName]) {
41
- // Name conflict: use full relative path as key to avoid silent loss
42
- const fullKey = md.replace(/\/src\/main\/kotlin\/$/, "").replace(/\//g, "__");
44
+ // Name conflict: upgrade first entry to full key too
45
+ if (!moduleName.includes("__")) {
46
+ const existingPath = moduleSet[moduleName];
47
+ const existingFullKey = existingPath.replace(/\/src\/main\/kotlin\/?$/, "").replace(/\//g, "__");
48
+ moduleSet[existingFullKey] = existingPath;
49
+ delete moduleSet[moduleName];
50
+ }
51
+ const fullKey = md.replace(/\/src\/main\/kotlin\/?$/, "").replace(/\//g, "__");
43
52
  moduleSet[fullKey] = md;
44
53
  } else {
45
54
  moduleSet[moduleName] = md;
@@ -53,16 +62,21 @@ async function scanKotlinDomains(stack, ROOT) {
53
62
  const domainMap = {};
54
63
 
55
64
  for (const [moduleName, modulePath] of Object.entries(moduleSet)) {
56
- if (skipModules.some(s => moduleName.includes(s))) continue;
65
+ // Full key (e.g., "servers__command__reservation-command-server") → extract actual module name
66
+ const actualModuleName = moduleName.includes("__")
67
+ ? moduleName.split("__").pop()
68
+ : moduleName;
69
+
70
+ if (skipModules.some(s => actualModuleName.includes(s))) continue;
57
71
 
58
72
  // Determine server type from module name
59
73
  let serverType = "standalone";
60
74
  for (const [key, val] of Object.entries(serverTypes)) {
61
- if (moduleName.includes(key)) { serverType = val; break; }
75
+ if (actualModuleName.includes(key)) { serverType = val; break; }
62
76
  }
63
77
 
64
78
  // Extract domain name from module name
65
- let domainName = moduleName
79
+ let domainName = actualModuleName
66
80
  .replace(/-(?:command|query|bff|integration|adapter)-server$/, "")
67
81
  .replace(/-server$/, "");
68
82
 
@@ -73,7 +87,7 @@ async function scanKotlinDomains(stack, ROOT) {
73
87
  }
74
88
 
75
89
  // Scan files in this module (append "/" to prevent prefix overlap)
76
- const modulePrefix = modulePath.replace(/\/$/, "").replace(/\/src\/main\/kotlin$/, "") + "/";
90
+ const modulePrefix = modulePath.replace(/\/?$/, "").replace(/\/src\/main\/kotlin$/, "") + "/";
77
91
  const moduleKtFiles = ktFiles.filter(f => f.startsWith(modulePrefix) || f === modulePrefix.slice(0, -1));
78
92
  const controllers = moduleKtFiles.filter(f => /controller/i.test(f)).length;
79
93
  const services = moduleKtFiles.filter(f => /service/i.test(f)).length;
@@ -84,17 +98,27 @@ async function scanKotlinDomains(stack, ROOT) {
84
98
  if (totalFiles === 0) continue;
85
99
 
86
100
  const key = `${domainName}:${serverType}`;
87
- domainMap[key] = {
88
- name: domainName,
89
- moduleName,
90
- modulePath,
91
- serverType,
92
- controllers,
93
- services,
94
- repositories,
95
- dtos,
96
- totalFiles,
97
- };
101
+ if (domainMap[key]) {
102
+ // Merge into existing entry (e.g., same domain+type from collided module names)
103
+ domainMap[key].moduleNames.push(moduleName);
104
+ domainMap[key].controllers += controllers;
105
+ domainMap[key].services += services;
106
+ domainMap[key].repositories += repositories;
107
+ domainMap[key].dtos += dtos;
108
+ domainMap[key].totalFiles += totalFiles;
109
+ } else {
110
+ domainMap[key] = {
111
+ name: domainName,
112
+ moduleNames: [moduleName],
113
+ modulePath,
114
+ serverType,
115
+ controllers,
116
+ services,
117
+ repositories,
118
+ dtos,
119
+ totalFiles,
120
+ };
121
+ }
98
122
  }
99
123
 
100
124
  // Group by domain: merge command/query/bff of same domain
@@ -105,7 +129,7 @@ async function scanKotlinDomains(stack, ROOT) {
105
129
  domainGroups[name] = { name, type: "backend", modules: [], controllers: 0, services: 0, repositories: 0, dtos: 0, totalFiles: 0, serverTypes: [] };
106
130
  }
107
131
  const dg = domainGroups[name];
108
- dg.modules.push(info.moduleName);
132
+ dg.modules.push(...info.moduleNames);
109
133
  dg.controllers += info.controllers;
110
134
  dg.services += info.services;
111
135
  dg.repositories += info.repositories;
@@ -131,7 +155,7 @@ async function scanKotlinDomains(stack, ROOT) {
131
155
  }
132
156
 
133
157
  // Also scan shared libraries as special domains
134
- const libDirs = await glob("{shared-lib,integration-lib,*-lib}/src/main/kotlin/", { cwd: ROOT });
158
+ const libDirs = (await glob("{shared-lib,integration-lib,*-lib}/src/main/kotlin/", { cwd: ROOT })).map(norm);
135
159
  for (const ld of libDirs) {
136
160
  const libName = ld.split("/")[0];
137
161
  if (domainGroups[libName]) continue; // Already captured
@@ -2,6 +2,7 @@
2
2
  * ClaudeOS-Core — Node.js Structure Scanner
3
3
  *
4
4
  * Scans Node.js backend (Express/NestJS/Fastify) project structure to discover domains.
5
+ * Supports monorepo layouts (apps/*, packages/*) in addition to single-project src/.
5
6
  */
6
7
 
7
8
  const path = require("path");
@@ -9,10 +10,23 @@ const { glob } = require("glob");
9
10
 
10
11
  async function scanNodeDomains(stack, ROOT) {
11
12
  const backendDomains = [];
13
+ const skipDirs = ["common", "shared", "config", "utils", "lib", "core", "main", "interfaces", "types", "constants", "guards", "decorators", "pipes", "filters", "interceptors"];
12
14
 
15
+ // Collect candidate directories: standard src/ + monorepo apps/*/src/
13
16
  const nestModules = await glob("src/modules/*/", { cwd: ROOT });
14
- const srcDirs = nestModules.length > 0 ? nestModules : await glob("src/*/", { cwd: ROOT });
15
- const skipDirs = ["common", "shared", "config", "utils", "lib", "core", "main", "interfaces", "types", "constants", "guards", "decorators", "pipes", "filters", "interceptors"];
17
+ let srcDirs = nestModules.length > 0 ? nestModules : await glob("src/*/", { cwd: ROOT });
18
+
19
+ // Monorepo: scan apps/*/src/ and packages/*/src/ when standard src/ yields nothing backend-relevant
20
+ if (stack.monorepo || srcDirs.length === 0) {
21
+ const monoModules = await glob("{apps,packages}/*/src/modules/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] });
22
+ if (monoModules.length > 0) {
23
+ srcDirs = [...srcDirs, ...monoModules];
24
+ } else {
25
+ const monoDirs = await glob("{apps,packages}/*/src/*/", { cwd: ROOT, ignore: ["**/node_modules/**"] });
26
+ srcDirs = [...srcDirs, ...monoDirs];
27
+ }
28
+ }
29
+
16
30
  for (let dir of srcDirs) {
17
31
  if (!dir.endsWith("/")) dir += "/";
18
32
  const name = path.basename(dir.replace(/\/$/, ""));
@@ -90,7 +90,8 @@ async function detectStack(ROOT) {
90
90
  framework: null, frameworkVersion: null,
91
91
  buildTool: null, database: null, orm: null,
92
92
  frontend: null, frontendVersion: null,
93
- packageManager: null, detected: [],
93
+ packageManager: null, monorepo: null, workspaces: null,
94
+ detected: [],
94
95
  };
95
96
 
96
97
  // ── Java/Kotlin: Gradle ──
@@ -115,6 +116,8 @@ async function detectStack(ROOT) {
115
116
  if (jv) stack.languageVersion = jv[1];
116
117
 
117
118
  detectFirst(stack, "orm", g, GRADLE_ORM_RULES);
119
+ // Exclude "postgres" (substring of postgresql — false positive on r2dbc-postgres) and "sqlite" (rare in Gradle deps)
120
+ // "postgresql" is still matched via DB_KEYWORD_RULES; h2 uses word-boundary check separately
118
121
  detectDb(stack, g, DB_KEYWORD_RULES.filter(([kw]) => !["postgres", "sqlite"].includes(kw)));
119
122
 
120
123
  // Kotlin detection: override language if Kotlin plugin found
@@ -235,7 +238,45 @@ async function detectStack(ROOT) {
235
238
  const pkg = readJsonSafe(path.join(ROOT, "package.json"));
236
239
  if (pkg) {
237
240
  stack.detected.push("package.json");
241
+
242
+ // ── Monorepo detection ──
243
+ // Detect monorepo markers: turbo.json, pnpm-workspace.yaml, lerna.json, package.json#workspaces
244
+ if (existsSafe(path.join(ROOT, "turbo.json"))) { stack.monorepo = "turborepo"; stack.detected.push("turbo.json"); }
245
+ else if (existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) { stack.monorepo = "pnpm-workspace"; stack.detected.push("pnpm-workspace.yaml"); }
246
+ else if (existsSafe(path.join(ROOT, "lerna.json"))) { stack.monorepo = "lerna"; stack.detected.push("lerna.json"); }
247
+ else if (pkg.workspaces) { stack.monorepo = "npm-workspaces"; stack.detected.push("npm-workspaces"); }
248
+ if (stack.monorepo) {
249
+ // Resolve workspace paths from package.json#workspaces or pnpm-workspace.yaml
250
+ let wsPatterns = [];
251
+ if (Array.isArray(pkg.workspaces)) wsPatterns = pkg.workspaces;
252
+ else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) wsPatterns = pkg.workspaces.packages;
253
+ if (wsPatterns.length === 0 && existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) {
254
+ const wy = readFileSafe(path.join(ROOT, "pnpm-workspace.yaml"));
255
+ const wm = [...wy.matchAll(/- ['"]?([^'"#\n]+)['"]?/g)].map(m => m[1].trim());
256
+ if (wm.length > 0) wsPatterns = wm;
257
+ }
258
+ if (wsPatterns.length > 0) stack.workspaces = wsPatterns;
259
+ }
260
+
261
+ // Merge deps from root + sub-package package.json files (monorepo)
262
+ // Sub-packages provide framework/frontend/ORM that root may lack
238
263
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
264
+ if (stack.monorepo) {
265
+ const subPkgGlobs = ["{apps,packages}/*/package.json"];
266
+ if (stack.workspaces) {
267
+ for (const ws of stack.workspaces) {
268
+ const wsGlob = ws.replace(/\/?\*?\*?$/, "/*/package.json");
269
+ if (!subPkgGlobs.includes(wsGlob)) subPkgGlobs.push(wsGlob);
270
+ }
271
+ }
272
+ for (const spg of subPkgGlobs) {
273
+ const subPkgs = await glob(spg, { cwd: ROOT, ignore: ["**/node_modules/**"] });
274
+ for (const sp of subPkgs) {
275
+ const sub = readJsonSafe(path.join(ROOT, sp));
276
+ if (sub) Object.assign(deps, sub.dependencies, sub.devDependencies);
277
+ }
278
+ }
279
+ }
239
280
 
240
281
  if (!stack.language) stack.language = deps.typescript ? "typescript" : "javascript";
241
282
  if (deps.typescript) { stack.detected.push("typescript"); const tv = deps.typescript.match(/(\d+(?:\.\d+)*)/); if (tv) stack.languageVersion = tv[1]; }
@@ -15,126 +15,22 @@
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
17
  const { glob } = require("glob");
18
+ const { parseFileBlocks, parseCodeBlocks, replaceFileBlock, replaceCodeBlock, CODE_BLOCK_PLANS } = require("../lib/plan-parser");
19
+ const { updateStaleReport } = require("../lib/stale-report");
18
20
 
19
21
  const ROOT = process.env.CLAUDEOS_ROOT || path.resolve(__dirname, "../..");
20
22
  const PLAN = path.join(ROOT, "claudeos-core/plan");
21
23
  const GEN = path.join(ROOT, "claudeos-core/generated");
22
24
 
23
- // Plan files using code block format (the rest use <file> block format)
24
- const CODE_BLOCK_PLANS = ["21.sync-rules-master.md"];
25
+ // CODE_BLOCK_PLANS imported from lib/plan-parser.js
25
26
 
26
27
  function rel(p) {
27
28
  return path.relative(ROOT, p).replace(/\\/g, "/");
28
29
  }
29
30
 
30
- // Extract <file path="..."> ... </file> blocks
31
- function extractFileBlocks(content) {
32
- const result = [];
33
- let m;
34
- const re = /<file\s+path="([^"]+)">\s*\n([\s\S]*?)\n<\/file>/g;
35
- while ((m = re.exec(content)) !== null) {
36
- result.push({ path: m[1], content: m[2] });
37
- }
38
- return result;
39
- }
40
-
41
- // Extract ## N. `path` \n```markdown ... ``` blocks
42
- // Uses indexOf-based parsing to correctly handle nested code fences inside markdown content
43
- function extractCodeBlocks(content) {
44
- const result = [];
45
- const headingRe = /^##\s+\d+\.\s+([^\n]+)/gm;
46
- let headingMatch;
47
- while ((headingMatch = headingRe.exec(content)) !== null) {
48
- const filePath = headingMatch[1].replace(/`/g, "").trim();
49
- // Find the opening ```markdown after the heading
50
- const openFence = content.indexOf("```markdown\n", headingMatch.index);
51
- if (openFence < 0) continue;
52
- const contentStart = openFence + "```markdown\n".length;
53
- // Find the matching closing ``` — track nesting depth to skip inner fenced blocks
54
- let searchPos = contentStart;
55
- let closingPos = -1;
56
- let nestDepth = 0;
57
- while (searchPos < content.length) {
58
- const nextFence = content.indexOf("\n```", searchPos);
59
- if (nextFence < 0) break;
60
- const fenceLineStart = nextFence + 1; // position of ```
61
- // Determine end of this ``` line
62
- const nextNewline = content.indexOf("\n", fenceLineStart + 3);
63
- const restOfLine = nextNewline >= 0
64
- ? content.substring(fenceLineStart + 3, nextNewline)
65
- : content.substring(fenceLineStart + 3);
66
- // Opening fence: ``` followed by a language tag (non-empty alphanumeric text)
67
- const isOpening = /^[a-zA-Z]/.test(restOfLine.trim());
68
- if (isOpening) {
69
- nestDepth++;
70
- searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
71
- } else if (nestDepth > 0) {
72
- nestDepth--;
73
- searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
74
- } else {
75
- closingPos = fenceLineStart;
76
- break;
77
- }
78
- }
79
- if (closingPos < 0) continue;
80
- const blockContent = content.substring(contentStart, closingPos).trimEnd();
81
- result.push({ path: filePath, content: blockContent });
82
- // Advance headingRe past this block to avoid re-matching inside content
83
- headingRe.lastIndex = closingPos;
84
- }
85
- return result;
86
- }
87
-
88
- // Replace <file> block content with new content
89
- function replaceFileBlock(content, filePath, newContent) {
90
- const escaped = filePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
- return content.replace(
92
- new RegExp(`(<file\\s+path="${escaped}">\\s*\\n)[\\s\\S]*?(\\n</file>)`, "g"),
93
- `$1${newContent}$2`
94
- );
95
- }
96
-
97
- // Replace code block content with new content
98
- // Uses indexOf-based approach to avoid regex issues with nested code fences
99
- function replaceCodeBlock(content, filePath, newContent) {
100
- const cleanPath = filePath.replace(/`/g, "");
101
- // Find the heading line containing the file path
102
- const headingPattern = new RegExp(`^##\\s+\\d+\\.\\s+\`?${cleanPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\`?`, "m");
103
- const headingMatch = headingPattern.exec(content);
104
- if (!headingMatch) return content;
105
- // Find the opening ```markdown after the heading
106
- const afterHeading = content.indexOf("```markdown\n", headingMatch.index);
107
- if (afterHeading < 0) return content;
108
- const contentStart = afterHeading + "```markdown\n".length;
109
- // Find the matching closing ``` — track nesting depth to skip inner fenced blocks
110
- let searchPos = contentStart;
111
- let closingPos = -1;
112
- let nestDepth = 0;
113
- while (searchPos < content.length) {
114
- const nextFence = content.indexOf("\n```", searchPos);
115
- if (nextFence < 0) break;
116
- const fenceLineStart = nextFence + 1; // position of ```
117
- // Determine end of this ``` line
118
- const nextNewline = content.indexOf("\n", fenceLineStart + 3);
119
- const restOfLine = nextNewline >= 0
120
- ? content.substring(fenceLineStart + 3, nextNewline)
121
- : content.substring(fenceLineStart + 3);
122
- // Opening fence: ``` followed by a language tag (non-empty alphanumeric text)
123
- const isOpening = /^[a-zA-Z]/.test(restOfLine.trim());
124
- if (isOpening) {
125
- nestDepth++;
126
- searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
127
- } else if (nestDepth > 0) {
128
- nestDepth--;
129
- searchPos = nextNewline >= 0 ? nextNewline : fenceLineStart + 3;
130
- } else {
131
- closingPos = fenceLineStart;
132
- break;
133
- }
134
- }
135
- if (closingPos < 0) return content;
136
- return content.substring(0, contentStart) + newContent + "\n" + content.substring(closingPos);
137
- }
31
+ // Aliases for backward compatibility (used by tests and main())
32
+ function extractFileBlocks(content) { return parseFileBlocks(content, { includeContent: true }); }
33
+ function extractCodeBlocks(content) { return parseCodeBlocks(content, { includeContent: true }); }
138
34
 
139
35
  async function main() {
140
36
  const validModes = ["--check", "--refresh", "--execute"];
@@ -244,16 +140,10 @@ async function main() {
244
140
  );
245
141
 
246
142
  // Also write to stale-report.json for consolidated health reporting
247
- const rp = path.join(GEN, "stale-report.json");
248
- let ex = {};
249
- if (fs.existsSync(rp)) {
250
- try { ex = JSON.parse(fs.readFileSync(rp, "utf-8")); } catch { ex = {}; }
251
- }
252
- ex.planValidation = { checkedAt: new Date().toISOString(), mode, total, synced, drift, missing };
253
- if (!ex.summary) ex.summary = {};
254
- ex.summary.planDrift = drift;
255
- ex.summary.planMissing = missing;
256
- fs.writeFileSync(rp, JSON.stringify(ex, null, 2));
143
+ updateStaleReport(GEN, "planValidation",
144
+ { checkedAt: new Date().toISOString(), mode, total, synced, drift, missing },
145
+ { planDrift: drift, planMissing: missing }
146
+ );
257
147
  }
258
148
 
259
149
  process.exit(drift + missing > 0 ? 1 : 0);
@@ -261,7 +151,7 @@ async function main() {
261
151
 
262
152
  // Export for testing; run main() only when executed directly
263
153
  if (require.main === module) {
264
- main().catch(console.error);
154
+ main().catch(e => { console.error(`\n ❌ Unexpected error: ${e.message || e}`); process.exit(1); });
265
155
  }
266
156
 
267
157
  module.exports = { extractFileBlocks, extractCodeBlocks, replaceFileBlock, replaceCodeBlock };
@@ -15,6 +15,7 @@
15
15
  const fs = require("fs");
16
16
  const path = require("path");
17
17
  const { glob } = require("glob");
18
+ const { updateStaleReport } = require("../lib/stale-report");
18
19
 
19
20
  const ROOT = process.env.CLAUDEOS_ROOT || path.resolve(__dirname, "../..");
20
21
  const GEN = path.join(ROOT, "claudeos-core/generated");
@@ -105,24 +106,10 @@ async function main() {
105
106
  console.log(total === 0 ? " ✅ All in sync\n" : ` ⚠️ ${total} issues\n`);
106
107
 
107
108
  // ─── Update stale-report.json ────────────────────────────
108
- if (fs.existsSync(GEN)) {
109
- const rp = path.join(GEN, "stale-report.json");
110
- let ex = {};
111
- if (fs.existsSync(rp)) {
112
- try { ex = JSON.parse(fs.readFileSync(rp, "utf-8")); } catch { ex = {}; }
113
- }
114
- ex.syncMisses = {
115
- checkedAt: new Date().toISOString(),
116
- unregistered: issues.unreg,
117
- orphaned: issues.orphan,
118
- };
119
- ex.summary = {
120
- ...ex.summary,
121
- syncIssues: total,
122
- status: total === 0 ? "ok" : "warning",
123
- };
124
- fs.writeFileSync(rp, JSON.stringify(ex, null, 2));
125
- }
109
+ updateStaleReport(GEN, "syncMisses",
110
+ { checkedAt: new Date().toISOString(), unregistered: issues.unreg, orphaned: issues.orphan },
111
+ { syncIssues: total, status: total === 0 ? "ok" : "warning" }
112
+ );
126
113
 
127
114
  // Exit 1 only for orphaned files (actual breakage), not for unregistered (informational)
128
115
  const orphanCount = issues.orphan.length;
@@ -130,7 +117,7 @@ async function main() {
130
117
  }
131
118
 
132
119
  if (require.main === module) {
133
- main().catch(console.error);
120
+ main().catch(e => { console.error(`\n ❌ Unexpected error: ${e.message || e}`); process.exit(1); });
134
121
  }
135
122
 
136
123
  module.exports = { main };