claudeos-core 1.6.0 → 1.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.1] — 2026-04-09
4
+
5
+ ### Fixed
6
+
7
+ - **Path traversal hardening (Windows)** — `plan-validator` and `sync-checker` now use case-insensitive path comparison on Windows, preventing UNC/case-mismatch bypass of root boundary check
8
+ - **Null pointer crash in `stack-detector.js`** — `readFileSafe()` return value for `pnpm-workspace.yaml` now guarded; prevents crash when file exists but is unreadable
9
+ - **Empty pass3 prompt generation** — `prompt-generator.js` now early-returns with warning when pass3 template is missing, instead of silently writing header+footer-only prompt
10
+ - **Domain group boundary off-by-one** — `splitDomainGroups` changed `>=` to `>` for file count threshold; groups now fill up to exactly `MAX_FILES_PER_GROUP` (40) instead of flushing one file early
11
+ - **Perl regex injection in `bootstrap.sh`** — All placeholder substitution migrated from `perl -pi -e` to Node.js `String.replace()`; eliminates regex special character risk in domain names; `perl` is no longer a prerequisite
12
+ - **Flask default port** — `plan-installer` now maps Flask to port 5000 (was falling through to 8080)
13
+ - **Health-checker dependency chain** — `sync-checker` is now automatically skipped when `manifest-generator` fails, instead of running against missing `sync-map.json`
14
+ - **`pass-json-validator` null template crash** — Added null guard before `typeof` check; `null` no longer passes `typeof === "object"` gate
15
+ - **`pass-json-validator` missing backend frameworks** — Added `"fastify"` and `"flask"` to backend framework list; these stacks previously skipped backend section validation
16
+ - **Init error messages** — Pass 1/2/3 failure messages now include actionable guidance (check output above, retry with `--force`, verify prompt file)
17
+ - **Manifest-generator error context** — `.catch()` handler now prefixes error with tool name
18
+ - **Line counting off-by-one** — `statSafe()` and `manifest-generator stat()` no longer count trailing newline as an extra line
19
+ - **Windows CRLF drift** — `plan-validator` now normalizes `\r\n` → `\n` before content comparison; prevents false drift on Windows
20
+ - **`stale-report.js` mutation** — `Object.assign(ex.summary, patch)` replaced with spread operator to avoid in-place mutation
21
+ - **Undefined in sync-checker Set** — Malformed mappings with missing `sourcePath` no longer insert `undefined` into the registered paths Set
22
+ - **BOM frontmatter detection** — `content-validator` now strips UTF-8 BOM (`\uFEFF`) before checking `---` frontmatter marker
23
+ - **Health-checker stderr loss** — Error output now combines both `stdout` and `stderr` instead of preferring one
24
+ - **`bootstrap.sh` exit code preservation** — EXIT trap now captures and restores `$?` instead of always exiting 0
25
+ - **`bootstrap.sh` NODE_MAJOR stderr** — `node -e` stderr redirected to `/dev/null` to prevent parse failure from noise
26
+
3
27
  ## [1.6.0] — 2026-04-08
4
28
 
5
29
  ### Added
@@ -242,11 +242,11 @@ async function cmdInit(parsedArgs) {
242
242
  const elapsed1 = formatElapsed(Date.now() - t1);
243
243
 
244
244
  if (!ok) {
245
- throw new InitError(`Pass 1-${i} failed`);
245
+ throw new InitError(`Pass 1-${i} failed. Check the claude error output above.\n If this persists, try: npx claudeos-core init --force`);
246
246
  }
247
247
 
248
248
  if (!fileExists(pass1Json)) {
249
- throw new InitError(`pass1-${i}.json was not created`);
249
+ throw new InitError(`pass1-${i}.json was not created. Claude may have run but not produced expected output.\n Ensure the prompt instructs Claude to write to claudeos-core/generated/pass1-${i}.json`);
250
250
  }
251
251
 
252
252
  log(` ✅ pass1-${i}.json created (${elapsed1})`);
@@ -272,11 +272,11 @@ async function cmdInit(parsedArgs) {
272
272
  const elapsed2 = formatElapsed(Date.now() - t2);
273
273
 
274
274
  if (!ok) {
275
- throw new InitError("Pass 2 failed");
275
+ throw new InitError("Pass 2 failed. Check the claude error output above.\n If this persists, try: npx claudeos-core init --force");
276
276
  }
277
277
 
278
278
  if (!fileExists(pass2Json)) {
279
- throw new InitError("pass2-merged.json was not created");
279
+ throw new InitError("pass2-merged.json was not created. Claude may have run but not produced expected output.");
280
280
  }
281
281
 
282
282
  log(` ✅ pass2-merged.json created (${elapsed2})`);
@@ -298,11 +298,11 @@ async function cmdInit(parsedArgs) {
298
298
  const elapsed3 = formatElapsed(Date.now() - t3);
299
299
 
300
300
  if (!ok3) {
301
- throw new InitError("Pass 3 failed");
301
+ throw new InitError("Pass 3 failed. Check the claude error output above.\n If this persists, try: npx claudeos-core init --force");
302
302
  }
303
303
 
304
304
  if (!fileExists(path.join(PROJECT_ROOT, "CLAUDE.md"))) {
305
- throw new InitError("CLAUDE.md was not created. Pass 3 may have failed silently.");
305
+ throw new InitError("CLAUDE.md was not created. Claude ran but did not produce CLAUDE.md.\n Verify pass3-prompt.md instructs Claude to create CLAUDE.md at project root.");
306
306
  }
307
307
  log(` ✅ Pass 3 complete (${elapsed3})`);
308
308
  log("");
package/bootstrap.sh CHANGED
@@ -5,8 +5,8 @@
5
5
  # One-click full system auto-build
6
6
  # Automatically splits Pass 1 into N runs based on project size
7
7
  #
8
- # Prerequisites: bash, node (v18+), claude CLI, perl
9
- # For cross-platform use without perl, use: node bin/cli.js init
8
+ # Prerequisites: bash, node (v18+), claude CLI
9
+ # Cross-platform alternative: node bin/cli.js init
10
10
  #
11
11
  # Usage: bash claudeos-core-tools/bootstrap.sh --lang ko
12
12
  # bash claudeos-core-tools/bootstrap.sh (interactive)
@@ -27,7 +27,7 @@ GENERATED_DIR="$PROJECT_ROOT/claudeos-core/generated"
27
27
  cd "$PROJECT_ROOT"
28
28
 
29
29
  # Cleanup temp files on exit (Ctrl+C, errors, etc.)
30
- trap 'rm -f "$GENERATED_DIR"/_tmp_*.md "$GENERATED_DIR"/_tmp_*.md.final 2>/dev/null' EXIT
30
+ trap 'rc=$?; rm -f "$GENERATED_DIR"/_tmp_*.md "$GENERATED_DIR"/_tmp_*.md.final 2>/dev/null; exit $rc' EXIT
31
31
 
32
32
  # ─── Language selection (required) ──────────────────────────────
33
33
  SUPPORTED_LANGS=("en" "ko" "zh-CN" "ja" "es" "vi" "hi" "ru" "fr" "de")
@@ -97,7 +97,7 @@ if ! command -v node &> /dev/null; then
97
97
  exit 1
98
98
  fi
99
99
 
100
- NODE_MAJOR=$(node -e "console.log(process.versions.node.split('.')[0])")
100
+ NODE_MAJOR=$(node -e "console.log(process.versions.node.split('.')[0])" 2>/dev/null)
101
101
  if ! [[ "$NODE_MAJOR" =~ ^[0-9]+$ ]] || [ "$NODE_MAJOR" -lt 18 ]; then
102
102
  echo ""
103
103
  echo " ❌ Node.js v18+ required (current: v$(node --version))"
@@ -115,13 +115,7 @@ if ! command -v claude &> /dev/null; then
115
115
  exit 1
116
116
  fi
117
117
 
118
- if ! command -v perl &> /dev/null; then
119
- echo ""
120
- echo " ❌ perl not found (required for placeholder substitution)."
121
- echo " Install perl or use the Node.js CLI instead: npx claudeos-core init"
122
- echo ""
123
- exit 1
124
- fi
118
+ ## perl is no longer required placeholder substitution now uses Node.js
125
119
 
126
120
 
127
121
 
@@ -246,18 +240,20 @@ for i in $(seq 1 "$TOTAL_GROUPS"); do
246
240
  if [ ! -f "$PROMPT_FILE" ]; then
247
241
  PROMPT_FILE="$GENERATED_DIR/pass1-prompt.md"
248
242
  fi
249
- # Substitute placeholders via temp file (avoids sed special char issues and $() newline stripping)
243
+ # Substitute placeholders via temp file (uses Node.js for safe literal replacement)
250
244
  TMP_PROMPT="$GENERATED_DIR/_tmp_pass1_prompt.md"
251
245
  cp "$PROMPT_FILE" "$TMP_PROMPT"
252
- # Use perl with $ENV{} for safe literal replacement (no shell interpolation into Perl code)
253
246
  export _DOMAIN_LIST="$DOMAIN_LIST"
254
247
  export _PASS_NUM="$i"
255
- perl -pi -e 's/\{\{DOMAIN_GROUP\}\}/$ENV{_DOMAIN_LIST}/g' "$TMP_PROMPT"
256
- perl -pi -e 's/\{\{PASS_NUM\}\}/$ENV{_PASS_NUM}/g' "$TMP_PROMPT"
257
- # inject_project_root: pipe through perl, write to final temp file
258
248
  export _PROJECT_ROOT="$PROJECT_ROOT"
259
- perl -pe 's/\{\{PROJECT_ROOT\}\}/$ENV{_PROJECT_ROOT}/g' "$TMP_PROMPT" > "${TMP_PROMPT}.final"
260
- mv "${TMP_PROMPT}.final" "$TMP_PROMPT"
249
+ node -e "
250
+ const fs = require('fs');
251
+ let c = fs.readFileSync(process.argv[1], 'utf8');
252
+ c = c.replace(/\{\{DOMAIN_GROUP\}\}/g, process.env._DOMAIN_LIST);
253
+ c = c.replace(/\{\{PASS_NUM\}\}/g, process.env._PASS_NUM);
254
+ c = c.replace(/\{\{PROJECT_ROOT\}\}/g, process.env._PROJECT_ROOT);
255
+ fs.writeFileSync(process.argv[1], c);
256
+ " "$TMP_PROMPT"
261
257
 
262
258
  echo " ⏳ [Pass 1-${i}/${TOTAL_GROUPS}] Running claude -p (no output is normal, please wait)..."
263
259
  if ! (cd "$PROJECT_ROOT" && cat "$TMP_PROMPT" | claude -p --dangerously-skip-permissions); then
@@ -288,7 +284,12 @@ if [ -f "$GENERATED_DIR/pass2-merged.json" ]; then
288
284
  else
289
285
  TMP_PASS2="$GENERATED_DIR/_tmp_pass2_prompt.md"
290
286
  export _PROJECT_ROOT="$PROJECT_ROOT"
291
- perl -pe 's/\{\{PROJECT_ROOT\}\}/$ENV{_PROJECT_ROOT}/g' "$GENERATED_DIR/pass2-prompt.md" > "$TMP_PASS2"
287
+ node -e "
288
+ const fs = require('fs');
289
+ let c = fs.readFileSync(process.argv[1], 'utf8');
290
+ c = c.replace(/\{\{PROJECT_ROOT\}\}/g, process.env._PROJECT_ROOT);
291
+ fs.writeFileSync(process.argv[2], c);
292
+ " "$GENERATED_DIR/pass2-prompt.md" "$TMP_PASS2"
292
293
 
293
294
  echo " ⏳ [Pass 2] Running claude -p (no output is normal, please wait)..."
294
295
  if ! (cd "$PROJECT_ROOT" && cat "$TMP_PASS2" | claude -p --dangerously-skip-permissions); then
@@ -313,7 +314,12 @@ echo "[6] Pass 3 — Generating all files..."
313
314
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
314
315
  TMP_PASS3="$GENERATED_DIR/_tmp_pass3_prompt.md"
315
316
  export _PROJECT_ROOT="$PROJECT_ROOT"
316
- perl -pe 's/\{\{PROJECT_ROOT\}\}/$ENV{_PROJECT_ROOT}/g' "$GENERATED_DIR/pass3-prompt.md" > "$TMP_PASS3"
317
+ node -e "
318
+ const fs = require('fs');
319
+ let c = fs.readFileSync(process.argv[1], 'utf8');
320
+ c = c.replace(/\{\{PROJECT_ROOT\}\}/g, process.env._PROJECT_ROOT);
321
+ fs.writeFileSync(process.argv[2], c);
322
+ " "$GENERATED_DIR/pass3-prompt.md" "$TMP_PASS3"
317
323
 
318
324
  echo " ⏳ [Pass 3] Running claude -p (no output is normal, please wait)..."
319
325
  if ! (cd "$PROJECT_ROOT" && cat "$TMP_PASS3" | claude -p --dangerously-skip-permissions); then
@@ -107,7 +107,7 @@ async function main() {
107
107
  continue;
108
108
  }
109
109
  // All rules must have paths: frontmatter (value varies by category — e.g. ["**/*"] for core/backend, scoped patterns for infra/sync)
110
- const hasFrontmatter = c.startsWith("---");
110
+ const hasFrontmatter = c.replace(/^\uFEFF/, "").startsWith("---");
111
111
  const hasPathsKey = c.includes("paths:");
112
112
  if (!hasFrontmatter) {
113
113
  warnings.push({ file: r, type: "NO_FRONTMATTER", msg: "Missing YAML frontmatter (---)" });
@@ -32,7 +32,7 @@ function run(name, script) {
32
32
  });
33
33
  return { name, ok: true, output };
34
34
  } catch (e) {
35
- return { name, ok: false, output: e.stdout || e.stderr || e.message || "", exitCode: e.status || 1 };
35
+ return { name, ok: false, output: [e.stdout, e.stderr].filter(Boolean).join("\n") || e.message || "", exitCode: e.status || 1 };
36
36
  }
37
37
  }
38
38
 
@@ -43,15 +43,17 @@ function main() {
43
43
 
44
44
  // ─── [0] Run manifest-generator first (prerequisite) ──────────────────
45
45
  // Must run first because sync-checker reads sync-map.json
46
+ let manifestOk = false;
46
47
  const manifestScript = path.join(TOOLS, "manifest-generator/index.js");
47
48
  if (fs.existsSync(manifestScript)) {
48
49
  process.stdout.write(" ⏳ manifest-generator — generating metadata...");
49
50
  const r = run("manifest-generator", manifestScript);
50
51
  if (r.ok) {
51
52
  console.log(" ✅");
53
+ manifestOk = true;
52
54
  } else {
53
55
  console.log(" ❌");
54
- console.log(" ⚠️ manifest-generator failed. Subsequent sync-checker results may be inaccurate.");
56
+ console.log(" ⚠️ manifest-generator failed. sync-checker will be skipped.");
55
57
  }
56
58
  } else {
57
59
  console.log(" ⏭️ manifest-generator — not found");
@@ -75,6 +77,12 @@ function main() {
75
77
  results.push({ name: t.name, status: "skipped" });
76
78
  continue;
77
79
  }
80
+ // Skip sync-checker if manifest-generator failed (depends on sync-map.json)
81
+ if (t.name === "sync-checker" && !manifestOk) {
82
+ console.log(` ⏭️ ${t.name} — skipped (manifest-generator failed)`);
83
+ results.push({ name: t.name, status: "skipped" });
84
+ continue;
85
+ }
78
86
  process.stdout.write(` ⏳ ${t.name} — ${t.desc}...`);
79
87
  const r = run(t.name, t.script);
80
88
  if (r.ok) {
package/lib/safe-fs.js CHANGED
@@ -91,7 +91,7 @@ function statSafe(filePath) {
91
91
  const c = fs.readFileSync(filePath, "utf-8");
92
92
  const s = fs.statSync(filePath);
93
93
  return {
94
- lines: c.split("\n").length,
94
+ lines: c.endsWith("\n") ? c.split("\n").length - 1 : c.split("\n").length,
95
95
  bytes: s.size,
96
96
  modified: s.mtime.toISOString().split("T")[0],
97
97
  };
@@ -27,8 +27,7 @@ function updateStaleReport(genDir, key, data, summaryPatch) {
27
27
  }
28
28
  ex[key] = data;
29
29
  if (summaryPatch) {
30
- if (!ex.summary) ex.summary = {};
31
- Object.assign(ex.summary, summaryPatch);
30
+ ex.summary = { ...(ex.summary || {}), ...summaryPatch };
32
31
  }
33
32
  fs.writeFileSync(rp, JSON.stringify(ex, null, 2));
34
33
  }
@@ -38,13 +38,17 @@ function rel(p) {
38
38
  }
39
39
 
40
40
  function stat(f) {
41
- const s = fs.statSync(f);
42
- const c = fs.readFileSync(f, "utf-8");
43
- return {
44
- lines: c.split("\n").length,
45
- bytes: s.size,
46
- modified: s.mtime.toISOString().split("T")[0],
47
- };
41
+ try {
42
+ const s = fs.statSync(f);
43
+ const c = fs.readFileSync(f, "utf-8");
44
+ return {
45
+ lines: c.endsWith("\n") ? c.split("\n").length - 1 : c.split("\n").length,
46
+ bytes: s.size,
47
+ modified: s.mtime.toISOString().split("T")[0],
48
+ };
49
+ } catch (_e) {
50
+ return { lines: 0, bytes: 0, modified: "unknown" };
51
+ }
48
52
  }
49
53
 
50
54
  function frontmatter(f) {
@@ -158,7 +162,7 @@ async function main() {
158
162
  }
159
163
 
160
164
  if (require.main === module) {
161
- main().catch(e => { console.error(e); process.exit(1); });
165
+ main().catch(e => { console.error(`\n ❌ Manifest Generator failed: ${e.message || e}`); process.exit(1); });
162
166
  }
163
167
 
164
168
  module.exports = { main };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeos-core",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Auto-generate Claude Code documentation from your actual source code — Standards, Rules, Skills, and Guides tailored to your project",
5
5
  "main": "bin/cli.js",
6
6
  "bin": {
@@ -86,7 +86,7 @@ async function main() {
86
86
  console.log(` domains: ${pa.domains.length}`);
87
87
  console.log(` lang: ${pa.lang || "en (default)"}`);
88
88
  const tmpl = pa.templates || pa.template;
89
- if (typeof tmpl === "object") {
89
+ if (tmpl && typeof tmpl === "object") {
90
90
  console.log(` templates: backend=${tmpl.backend || "none"}, frontend=${tmpl.frontend || "none"}`);
91
91
  if (pa.isMultiStack) console.log(` mode: 🔀 multi-stack`);
92
92
  } else {
@@ -195,7 +195,7 @@ async function main() {
195
195
  const framework = paData.stack?.framework;
196
196
  const language = paData.stack?.language;
197
197
  const architecture = paData.stack?.architecture;
198
- isBackend = !frontend || ["express", "nestjs", "django", "fastapi", "spring-boot"].includes(framework);
198
+ isBackend = !frontend || ["express", "nestjs", "fastify", "django", "fastapi", "flask", "spring-boot"].includes(framework);
199
199
  isKotlin = language === "kotlin";
200
200
  isKotlinCqrs = isKotlin && (architecture === "cqrs" || paData.stack?.multiModule);
201
201
  } catch (_e) { /* If project-analysis parsing fails, conservatively assume backend */ }
@@ -14,7 +14,7 @@ function splitDomainGroups(domains, type, template) {
14
14
 
15
15
  for (const d of domains) {
16
16
  // Flush current group before adding if it would exceed limits
17
- if (current.length > 0 && (fileCount + d.totalFiles >= MAX_FILES_PER_GROUP || current.length >= MAX_DOMAINS_PER_GROUP)) {
17
+ if (current.length > 0 && (fileCount + d.totalFiles > MAX_FILES_PER_GROUP || current.length >= MAX_DOMAINS_PER_GROUP)) {
18
18
  groups.push({ type, template, domains: [...current], estimatedFiles: fileCount });
19
19
  current = [];
20
20
  fileCount = 0;
@@ -92,6 +92,7 @@ async function main() {
92
92
 
93
93
  // Save outputs
94
94
  const defaultPort = (stack.framework === "fastapi" || stack.framework === "django") ? 8000
95
+ : stack.framework === "flask" ? 5000
95
96
  : (stack.framework === "express" || stack.framework === "nestjs" || stack.framework === "fastify") ? 3000 : 8080;
96
97
  const analysis = {
97
98
  analyzedAt: new Date().toISOString(), lang,
@@ -63,7 +63,11 @@ function generatePrompts(templates, lang, templatesDir, generatedDir) {
63
63
 
64
64
  if (primaryTemplate) {
65
65
  const primaryBody = readTemplate(primaryTemplate, "pass3");
66
- let combinedBody = primaryBody || "";
66
+ if (!primaryBody) {
67
+ console.log(` ⚠️ pass3 template not found for ${primaryTemplate}, skipping`);
68
+ return;
69
+ }
70
+ let combinedBody = primaryBody;
67
71
 
68
72
  if (templates.backend && templates.frontend && templates.backend !== templates.frontend) {
69
73
  const frontendBody = readTemplate(templates.frontend, "pass3");
@@ -252,8 +252,10 @@ async function detectStack(ROOT) {
252
252
  else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) wsPatterns = pkg.workspaces.packages;
253
253
  if (wsPatterns.length === 0 && existsSafe(path.join(ROOT, "pnpm-workspace.yaml"))) {
254
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;
255
+ if (wy) {
256
+ const wm = [...wy.matchAll(/- ['"]?([^'"#\n]+)['"]?/g)].map(m => m[1].trim());
257
+ if (wm.length > 0) wsPatterns = wm;
258
+ }
257
259
  }
258
260
  if (wsPatterns.length > 0) stack.workspaces = wsPatterns;
259
261
  }
@@ -28,6 +28,16 @@ function rel(p) {
28
28
  return path.relative(ROOT, p).replace(/\\/g, "/");
29
29
  }
30
30
 
31
+ function isWithinRoot(absPath) {
32
+ let resolved = path.resolve(absPath);
33
+ let root = path.resolve(ROOT);
34
+ if (process.platform === "win32") {
35
+ resolved = resolved.toLowerCase();
36
+ root = root.toLowerCase();
37
+ }
38
+ return resolved === root || resolved.startsWith(root + path.sep);
39
+ }
40
+
31
41
  // Aliases for backward compatibility (used by tests and main())
32
42
  function extractFileBlocks(content) { return parseFileBlocks(content, { includeContent: true }); }
33
43
  function extractCodeBlocks(content) { return parseCodeBlocks(content, { includeContent: true }); }
@@ -70,9 +80,7 @@ async function main() {
70
80
  const abs = path.join(ROOT, b.path);
71
81
 
72
82
  // Block path traversal attempts (allow files at ROOT level and below)
73
- const resolvedAbs = path.resolve(abs);
74
- const resolvedRoot = path.resolve(ROOT);
75
- if (resolvedAbs !== resolvedRoot && !resolvedAbs.startsWith(resolvedRoot + path.sep)) {
83
+ if (!isWithinRoot(abs)) {
76
84
  console.log(` ⚠️ SKIPPED: ${b.path} (path traversal blocked)`);
77
85
  continue;
78
86
  }
@@ -93,9 +101,9 @@ async function main() {
93
101
  continue;
94
102
  }
95
103
 
96
- // File exists — compare content (normalize trailing newlines only)
97
- const diskContent = fs.readFileSync(abs, "utf-8").replace(/\n+$/, "");
98
- const planContent = b.content.replace(/\n+$/, "");
104
+ // File exists — compare content (normalize CRLF + trailing newlines)
105
+ const diskContent = fs.readFileSync(abs, "utf-8").replace(/\r\n/g, "\n").replace(/\n+$/, "");
106
+ const planContent = b.content.replace(/\r\n/g, "\n").replace(/\n+$/, "");
99
107
 
100
108
  if (diskContent === planContent) {
101
109
  synced++;
@@ -34,6 +34,16 @@ function rel(p) {
34
34
  return path.relative(ROOT, p).replace(/\\/g, "/");
35
35
  }
36
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
+
37
47
  async function main() {
38
48
  console.log("\n╔═══════════════════════════════════════╗");
39
49
  console.log("║ ClaudeOS-Core — Sync Checker ║");
@@ -55,7 +65,7 @@ async function main() {
55
65
  console.log(" ❌ sync-map.json has no mappings array.\n");
56
66
  process.exit(1);
57
67
  }
58
- const reg = new Set(sm.mappings.map((m) => m.sourcePath));
68
+ const reg = new Set(sm.mappings.map((m) => m.sourcePath).filter(Boolean));
59
69
  const issues = { unreg: [], orphan: [] };
60
70
 
61
71
  // ─── [1/2] Disk → Plan: detect unregistered files ───────
@@ -83,9 +93,7 @@ async function main() {
83
93
  for (const m of sm.mappings) {
84
94
  const abs = path.join(ROOT, m.sourcePath);
85
95
  // Skip path traversal attempts (allow files at ROOT level and below)
86
- const resolvedAbs = path.resolve(abs);
87
- const resolvedRoot = path.resolve(ROOT);
88
- if (resolvedAbs !== resolvedRoot && !resolvedAbs.startsWith(resolvedRoot + path.sep)) continue;
96
+ if (!isWithinRoot(abs)) continue;
89
97
  if (!fs.existsSync(abs)) {
90
98
  issues.orphan.push({ path: m.sourcePath, plan: m.planFile });
91
99
  }