delimit-cli 3.14.28 → 3.14.30

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 (48) hide show
  1. package/bin/delimit-setup.js +3 -6
  2. package/gateway/ai/backends/deploy_bridge.py +56 -2
  3. package/gateway/ai/backends/gateway_core.py +212 -1
  4. package/gateway/ai/backends/generate_bridge.py +84 -13
  5. package/gateway/ai/backends/governance_bridge.py +63 -16
  6. package/gateway/ai/backends/memory_bridge.py +77 -76
  7. package/gateway/ai/backends/ops_bridge.py +76 -6
  8. package/gateway/ai/backends/os_bridge.py +23 -3
  9. package/gateway/ai/backends/repo_bridge.py +156 -17
  10. package/gateway/ai/backends/tools_design.py +116 -9
  11. package/gateway/ai/backends/tools_infra.py +200 -72
  12. package/gateway/ai/backends/tools_real.py +8 -0
  13. package/gateway/ai/backends/ui_bridge.py +115 -5
  14. package/gateway/ai/backends/vault_bridge.py +69 -114
  15. package/gateway/ai/content_engine.py +1276 -0
  16. package/gateway/ai/context_fs.py +193 -0
  17. package/gateway/ai/daemon.py +500 -0
  18. package/gateway/ai/data_plane.py +291 -0
  19. package/gateway/ai/deliberation.py +1033 -6
  20. package/gateway/ai/events.py +39 -0
  21. package/gateway/ai/founding_users.py +162 -0
  22. package/gateway/ai/governance.py +698 -4
  23. package/gateway/ai/inbox_daemon.py +78 -17
  24. package/gateway/ai/integrations/__init__.py +1 -0
  25. package/gateway/ai/integrations/opensage_wrapper.py +288 -0
  26. package/gateway/ai/key_resolver.py +95 -0
  27. package/gateway/ai/ledger_manager.py +289 -1
  28. package/gateway/ai/license.py +62 -4
  29. package/gateway/ai/license_core.py +208 -7
  30. package/gateway/ai/local_server.py +215 -0
  31. package/gateway/ai/loop_engine.py +408 -0
  32. package/gateway/ai/mcp_bridge.py +178 -0
  33. package/gateway/ai/release_sync.py +2 -2
  34. package/gateway/ai/screen_record.py +374 -0
  35. package/gateway/ai/secrets_broker.py +235 -0
  36. package/gateway/ai/social.py +189 -27
  37. package/gateway/ai/social_target.py +1635 -0
  38. package/gateway/ai/supabase_sync.py +190 -0
  39. package/gateway/ai/tracing.py +195 -0
  40. package/gateway/core/contract_ledger.py +1 -1
  41. package/gateway/core/dependency_graph.py +1 -1
  42. package/gateway/core/dependency_manifest.py +1 -1
  43. package/gateway/core/diff_engine_v2.py +272 -78
  44. package/gateway/core/event_backbone.py +2 -2
  45. package/gateway/core/event_schema.py +1 -1
  46. package/gateway/core/impact_analyzer.py +1 -1
  47. package/gateway/core/policy_engine.py +4 -0
  48. package/package.json +1 -1
@@ -194,10 +194,13 @@ def design_extract_tokens(
194
194
  ) -> Dict[str, Any]:
195
195
  """Extract design tokens from project CSS/SCSS/Tailwind config.
196
196
 
197
- If FIGMA_TOKEN env var is set and figma_file_key provided, fetches from Figma API.
197
+ If a Figma token is available and figma_file_key provided, fetches from Figma API.
198
198
  Otherwise scans local project files for CSS variables, Tailwind config, etc.
199
+
200
+ Token resolution order: FIGMA_TOKEN env var -> ~/.delimit/secrets/figma.json -> free fallback.
199
201
  """
200
- figma_token = os.environ.get("FIGMA_TOKEN", "")
202
+ from ai.key_resolver import get_figma_token
203
+ figma_token, _token_source = get_figma_token()
201
204
  if figma_token and figma_file_key:
202
205
  return _figma_extract_tokens(figma_file_key, figma_token, token_types)
203
206
 
@@ -255,7 +258,7 @@ def design_extract_tokens(
255
258
  all_tokens["breakpoints"] = unique_bp
256
259
 
257
260
  total = sum(len(v) for v in all_tokens.values())
258
- return {
261
+ result = {
259
262
  "tool": "design.extract_tokens",
260
263
  "status": "ok",
261
264
  "tokens": all_tokens,
@@ -263,6 +266,14 @@ def design_extract_tokens(
263
266
  "source_files": sorted(set(source_files)),
264
267
  "figma_used": False,
265
268
  }
269
+ # If user passed a figma_file_key but no token is available, add a hint
270
+ if figma_file_key and not figma_token:
271
+ result["hint"] = (
272
+ "Figma integration available -- set FIGMA_TOKEN env var "
273
+ "or store your token with `delimit_secret_store key=figma value=<token>`. "
274
+ "Local CSS/Tailwind tokens were extracted instead."
275
+ )
276
+ return result
266
277
 
267
278
 
268
279
  def _figma_extract_tokens(file_key: str, token: str, token_types: Optional[List[str]]) -> Dict[str, Any]:
@@ -647,6 +658,16 @@ def story_generate(
647
658
  ) -> Dict[str, Any]:
648
659
  """Generate a .stories.tsx file for a component (no Storybook required)."""
649
660
  comp = Path(component_path)
661
+ if not comp.exists() and "/" not in component_path and "\\" not in component_path:
662
+ # Bare name like "Button" — search current directory for matching files
663
+ search_root = Path.cwd()
664
+ name = comp.stem
665
+ for pattern in [f"**/{name}.tsx", f"**/{name}.jsx", f"**/{name}.ts", f"**/{name}.js"]:
666
+ matches = [p for p in search_root.glob(pattern)
667
+ if "node_modules" not in str(p) and ".next" not in str(p)]
668
+ if matches:
669
+ comp = matches[0]
670
+ break
650
671
  if not comp.exists():
651
672
  return {"tool": "story.generate", "error": f"Component file not found: {comp}"}
652
673
 
@@ -714,6 +735,55 @@ type Story = StoryObj<typeof {comp_name}>;
714
735
  }
715
736
 
716
737
 
738
+ # ---------------------------------------------------------------------------
739
+ # 25a. Puppeteer fallback for screenshots
740
+ # ---------------------------------------------------------------------------
741
+
742
+ def _puppeteer_screenshot_fallback(url: str, baselines_dir: Path) -> Dict[str, Any]:
743
+ """Take a screenshot via puppeteer (npx) when Playwright is not available."""
744
+ try:
745
+ baselines_dir.mkdir(parents=True, exist_ok=True)
746
+ safe_name = re.sub(r"[^a-zA-Z0-9]", "_", url)[:100]
747
+ screenshot_path = baselines_dir / f"{safe_name}.png"
748
+
749
+ # Inline JS script for puppeteer
750
+ script = (
751
+ "const puppeteer = require('puppeteer');"
752
+ "(async () => {"
753
+ " const browser = await puppeteer.launch({headless: 'new', args: ['--no-sandbox']});"
754
+ " const page = await browser.newPage();"
755
+ " await page.setViewport({width: 1280, height: 720});"
756
+ f" await page.goto('{url}', {{waitUntil: 'networkidle2', timeout: 15000}});"
757
+ f" await page.screenshot({{path: '{screenshot_path}'}});"
758
+ " await browser.close();"
759
+ "})();"
760
+ )
761
+ result = subprocess.run(
762
+ ["node", "-e", script],
763
+ capture_output=True,
764
+ timeout=30,
765
+ )
766
+ if result.returncode != 0:
767
+ return {
768
+ "tool": "story.visual_test",
769
+ "status": "puppeteer_error",
770
+ "error": result.stderr.decode(errors="replace")[:500],
771
+ "hint": "Puppeteer fallback failed. Install Playwright for better support: pip install playwright && python -m playwright install chromium",
772
+ }
773
+
774
+ return {
775
+ "tool": "story.visual_test",
776
+ "status": "ok",
777
+ "screenshot_path": str(screenshot_path),
778
+ "baseline_exists": False,
779
+ "diff_percent": None,
780
+ "engine": "puppeteer_fallback",
781
+ "hint": "Screenshot taken with puppeteer (fallback). Install Playwright for full visual regression with baseline comparison.",
782
+ }
783
+ except Exception as e:
784
+ return {"tool": "story.visual_test", "status": "error", "error": str(e)}
785
+
786
+
717
787
  # ---------------------------------------------------------------------------
718
788
  # 25. story_visual_test
719
789
  # ---------------------------------------------------------------------------
@@ -723,19 +793,39 @@ def story_visual_test(
723
793
  project_path: Optional[str] = None,
724
794
  threshold: float = 0.05,
725
795
  ) -> Dict[str, Any]:
726
- """Take a screenshot with Playwright and compare against baseline."""
796
+ """Take a screenshot with Playwright and compare against baseline.
797
+
798
+ Falls back to puppeteer (npx) if Playwright is not installed,
799
+ and returns install guidance if neither is available.
800
+ """
801
+ from ai.key_resolver import get_playwright, get_puppeteer
802
+
727
803
  root = Path(project_path) if project_path else Path.cwd()
728
804
  baselines_dir = root / ".delimit" / "visual-baselines"
729
805
 
730
- if not _has_playwright():
806
+ pw_available, _ = get_playwright()
807
+
808
+ if not pw_available:
809
+ # Try puppeteer fallback via npx
810
+ pup_available, _ = get_puppeteer()
811
+ if pup_available:
812
+ return _puppeteer_screenshot_fallback(url, baselines_dir)
813
+
731
814
  return {
732
815
  "tool": "story.visual_test",
733
- "status": "no_playwright",
734
- "message": "Playwright is not installed. Install with: pip install playwright && python -m playwright install chromium",
816
+ "status": "no_screenshot_tool",
817
+ "message": (
818
+ "No screenshot tool available. Install one of the following:\n"
819
+ " - Playwright (recommended): pip install playwright && python -m playwright install chromium\n"
820
+ " - Puppeteer (fallback): npm install -g puppeteer"
821
+ ),
735
822
  "screenshot_path": None,
736
823
  "baseline_exists": False,
737
824
  "diff_percent": None,
738
- "next_steps_hint": "Install Playwright for visual regression testing, or use static accessibility checks instead.",
825
+ "next_steps_hint": (
826
+ "Install Playwright for full visual regression testing, "
827
+ "or use `delimit_story_accessibility` for static checks that require no browser."
828
+ ),
739
829
  }
740
830
 
741
831
  try:
@@ -795,7 +885,24 @@ def story_visual_test(
795
885
  }
796
886
 
797
887
  except Exception as e:
798
- return {"tool": "story.visual_test", "error": str(e)}
888
+ pup_available, _ = get_puppeteer()
889
+ if pup_available:
890
+ return _puppeteer_screenshot_fallback(url, baselines_dir)
891
+
892
+ return {
893
+ "tool": "story.visual_test",
894
+ "status": "playwright_error",
895
+ "error": str(e),
896
+ "screenshot_path": None,
897
+ "baseline_exists": False,
898
+ "diff_percent": None,
899
+ "engine": "playwright",
900
+ "hint": (
901
+ "Playwright is installed but could not launch a browser in this environment. "
902
+ "Install/configure a browser runtime that works in the current sandbox, or "
903
+ "use Puppeteer as a fallback."
904
+ ),
905
+ }
799
906
 
800
907
 
801
908
  # ---------------------------------------------------------------------------
@@ -34,8 +34,8 @@ DEPLOYS_DIR = Path(os.environ.get("DELIMIT_DEPLOYS_DIR", os.path.expanduser("~/.
34
34
  SECRET_PATTERNS = {
35
35
  "aws_access_key": r"(?:AKIA[0-9A-Z]{16})",
36
36
  "aws_secret_key": r"(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*['\"]?[A-Za-z0-9/+=]{40}",
37
- "generic_api_key": r"(?:api[_-]?key|apikey)\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{20,}",
38
- "generic_secret": r"(?:secret|password|passwd|token)\s*[=:]\s*['\"]?[^\s'\"]{8,}",
37
+ "generic_api_key": r"\b(?:api[_-]?key|apikey)\b\s*[=:]\s*['\"]?[A-Za-z0-9_\-]{20,}",
38
+ "generic_secret": r"\b(?:secret|password|passwd|token)\b\s*[=:]\s*['\"]?[^\s'\"]{8,}",
39
39
  "private_key_header": r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----",
40
40
  "github_token": r"gh[pousr]_[A-Za-z0-9_]{36,}",
41
41
  "slack_token": r"xox[baprs]-[0-9A-Za-z\-]{10,}",
@@ -62,7 +62,17 @@ SKIP_DIRS = {"node_modules", ".git", "__pycache__", ".venv", "venv", ".tox", "di
62
62
 
63
63
 
64
64
  def _run_cmd(cmd: List[str], timeout: int = 30, cwd: Optional[str] = None) -> Dict[str, Any]:
65
- """Run a command and return stdout, stderr, returncode."""
65
+ """Run a command and return stdout, stderr, returncode.
66
+
67
+ Security: always uses list-form args (never shell=True).
68
+ Validates cwd if provided and rejects null bytes in arguments.
69
+ """
70
+ # Defense-in-depth: reject null bytes in any argument
71
+ for i, arg in enumerate(cmd):
72
+ if "\x00" in str(arg):
73
+ return {"stdout": "", "stderr": f"Argument {i} contains null bytes", "returncode": -4}
74
+ if cwd and "\x00" in cwd:
75
+ return {"stdout": "", "stderr": "cwd contains null bytes", "returncode": -4}
66
76
  try:
67
77
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd)
68
78
  return {"stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode}
@@ -74,6 +84,28 @@ def _run_cmd(cmd: List[str], timeout: int = 30, cwd: Optional[str] = None) -> Di
74
84
  return {"stdout": "", "stderr": str(e), "returncode": -3}
75
85
 
76
86
 
87
+ def _bump_semver(version: str, bump: str) -> str:
88
+ """Compute the next semver version without mutating files."""
89
+ try:
90
+ major_s, minor_s, patch_s = version.split(".", 2)
91
+ major = int(major_s)
92
+ minor = int(minor_s)
93
+ patch = int(patch_s)
94
+ except Exception as exc:
95
+ raise ValueError(f"Invalid semver version '{version}'") from exc
96
+
97
+ if bump == "patch":
98
+ patch += 1
99
+ elif bump == "minor":
100
+ minor += 1
101
+ patch = 0
102
+ elif bump == "major":
103
+ major += 1
104
+ minor = 0
105
+ patch = 0
106
+ return f"{major}.{minor}.{patch}"
107
+
108
+
77
109
  def _scan_files(target: str) -> List[Path]:
78
110
  """Collect scannable source files under target."""
79
111
  root = Path(target).resolve()
@@ -82,14 +114,15 @@ def _scan_files(target: str) -> List[Path]:
82
114
  return [root]
83
115
  if not root.is_dir():
84
116
  return []
85
- for p in root.rglob("*"):
86
- if any(skip in p.parts for skip in SKIP_DIRS):
87
- continue
88
- if p.is_file() and p.suffix in SCAN_EXTENSIONS:
89
- files.append(p)
90
- # Cap to avoid scanning massive repos
91
- if len(files) >= 5000:
92
- break
117
+ for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _err: None):
118
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
119
+ for filename in filenames:
120
+ p = Path(dirpath) / filename
121
+ if p.suffix in SCAN_EXTENSIONS:
122
+ files.append(p)
123
+ # Cap to avoid scanning massive repos
124
+ if len(files) >= 5000:
125
+ return files
93
126
  return files
94
127
 
95
128
 
@@ -892,36 +925,27 @@ def deploy_site(project_path: str = ".", message: str = "", env_vars: dict = Non
892
925
  except Exception as e:
893
926
  return {"error": f"Git status failed: {e}"}
894
927
 
895
- # 2. Git add + commit
896
- commit_msg = message or "deploy: site update"
928
+ # 2. Preflight the git remote before creating a commit.
897
929
  try:
898
- subprocess.run(["git", "add", "-A"], cwd=str(p), timeout=10, capture_output=True)
899
930
  result = subprocess.run(
900
- ["git", "commit", "-m", commit_msg],
901
- cwd=str(p), timeout=10, capture_output=True, text=True
902
- )
903
- if result.returncode == 0:
904
- results["steps"].append({"step": "commit", "status": "ok", "message": commit_msg})
905
- else:
906
- results["steps"].append({"step": "commit", "status": "skipped", "detail": "nothing to commit"})
907
- except Exception as e:
908
- results["steps"].append({"step": "commit", "status": "error", "detail": str(e)})
909
-
910
- # 3. Git push
911
- try:
912
- result = subprocess.run(
913
- ["git", "push", "origin", "HEAD"],
931
+ ["git", "push", "--dry-run", "origin", "HEAD"],
914
932
  cwd=str(p), timeout=30, capture_output=True, text=True
915
933
  )
916
- results["steps"].append({
917
- "step": "push",
918
- "status": "ok" if result.returncode == 0 else "error",
919
- "detail": result.stderr.strip()[:200] if result.returncode != 0 else "pushed"
920
- })
934
+ if result.returncode != 0:
935
+ results["steps"].append({
936
+ "step": "push_precheck",
937
+ "status": "error",
938
+ "detail": (result.stderr.strip() or result.stdout.strip())[:200],
939
+ })
940
+ results["status"] = "push_precheck_failed"
941
+ return results
942
+ results["steps"].append({"step": "push_precheck", "status": "ok"})
921
943
  except Exception as e:
922
- results["steps"].append({"step": "push", "status": "error", "detail": str(e)})
944
+ results["steps"].append({"step": "push_precheck", "status": "error", "detail": str(e)})
945
+ results["status"] = "push_precheck_error"
946
+ return results
923
947
 
924
- # 4. Vercel build
948
+ # 3. Vercel build
925
949
  env = {**os.environ}
926
950
  if env_vars:
927
951
  # Whitelist safe env var prefixes — block LD_PRELOAD, PATH overrides, etc.
@@ -952,7 +976,68 @@ def deploy_site(project_path: str = ".", message: str = "", env_vars: dict = Non
952
976
  results["status"] = "build_error"
953
977
  return results
954
978
 
955
- # 5. Vercel deploy
979
+ # 4. Git add + commit
980
+ commit_msg = message or "deploy: site update"
981
+ try:
982
+ result = subprocess.run(["git", "add", "-A"], cwd=str(p), timeout=10, capture_output=True, text=True)
983
+ if result.returncode != 0:
984
+ results["steps"].append({
985
+ "step": "git_add",
986
+ "status": "error",
987
+ "detail": (result.stderr.strip() or result.stdout.strip())[:200],
988
+ })
989
+ results["status"] = "git_add_failed"
990
+ return results
991
+ results["steps"].append({"step": "git_add", "status": "ok"})
992
+ except Exception as e:
993
+ results["steps"].append({"step": "git_add", "status": "error", "detail": str(e)})
994
+ results["status"] = "git_add_error"
995
+ return results
996
+
997
+ try:
998
+ result = subprocess.run(
999
+ ["git", "commit", "-m", commit_msg],
1000
+ cwd=str(p), timeout=10, capture_output=True, text=True
1001
+ )
1002
+ commit_output = f"{result.stdout}\n{result.stderr}".lower()
1003
+ if result.returncode == 0:
1004
+ results["steps"].append({"step": "commit", "status": "ok", "message": commit_msg})
1005
+ elif "nothing to commit" in commit_output or "working tree clean" in commit_output:
1006
+ results["steps"].append({"step": "commit", "status": "skipped", "detail": "nothing to commit"})
1007
+ else:
1008
+ results["steps"].append({
1009
+ "step": "commit",
1010
+ "status": "error",
1011
+ "detail": (result.stderr.strip() or result.stdout.strip())[:200],
1012
+ })
1013
+ results["status"] = "commit_failed"
1014
+ return results
1015
+ except Exception as e:
1016
+ results["steps"].append({"step": "commit", "status": "error", "detail": str(e)})
1017
+ results["status"] = "commit_error"
1018
+ return results
1019
+
1020
+ # 5. Git push
1021
+ try:
1022
+ result = subprocess.run(
1023
+ ["git", "push", "origin", "HEAD"],
1024
+ cwd=str(p), timeout=30, capture_output=True, text=True
1025
+ )
1026
+ if result.returncode != 0:
1027
+ results["steps"].append({
1028
+ "step": "push",
1029
+ "status": "error",
1030
+ "detail": (result.stderr.strip() or result.stdout.strip())[:200],
1031
+ })
1032
+ results["status"] = "push_failed"
1033
+ return results
1034
+ results["steps"].append({"step": "push", "status": "ok", "detail": "pushed"})
1035
+ except Exception as e:
1036
+ results["steps"].append({"step": "push", "status": "error", "detail": str(e)})
1037
+ results["status"] = "push_error"
1038
+ return results
1039
+
1040
+ # 6. Vercel deploy
956
1041
  try:
957
1042
  result = subprocess.run(
958
1043
  ["npx", "vercel", "deploy", "--prebuilt", "--prod"],
@@ -970,9 +1055,14 @@ def deploy_site(project_path: str = ".", message: str = "", env_vars: dict = Non
970
1055
  "status": "ok" if result.returncode == 0 else "error",
971
1056
  "url": deploy_url
972
1057
  })
1058
+ if result.returncode != 0:
1059
+ results["status"] = "deploy_failed"
1060
+ return results
973
1061
  results["deploy_url"] = deploy_url
974
1062
  except Exception as e:
975
1063
  results["steps"].append({"step": "deploy", "status": "error", "detail": str(e)})
1064
+ results["status"] = "deploy_error"
1065
+ return results
976
1066
 
977
1067
  results["status"] = "deployed"
978
1068
  return results
@@ -1036,32 +1126,71 @@ def deploy_npm(project_path: str = ".", bump: str = "patch", tag: str = "latest"
1036
1126
  except Exception:
1037
1127
  pass
1038
1128
 
1129
+ # ── Dry-run: simulate without touching the filesystem ──
1130
+ if dry_run:
1131
+ # Compute what the next version would be without actually bumping
1132
+ parts = current_version.split(".")
1133
+ if len(parts) == 3 and bump in ("patch", "minor", "major"):
1134
+ major, minor, patch_v = int(parts[0]), int(parts[1]), int(parts[2])
1135
+ if bump == "patch":
1136
+ patch_v += 1
1137
+ elif bump == "minor":
1138
+ minor += 1
1139
+ patch_v = 0
1140
+ elif bump == "major":
1141
+ major += 1
1142
+ minor = 0
1143
+ patch_v = 0
1144
+ simulated_version = f"{major}.{minor}.{patch_v}"
1145
+ else:
1146
+ simulated_version = current_version
1147
+ results["new_version"] = simulated_version
1148
+ results["steps"].append({"step": "version_bump", "status": "dry_run", "from": current_version, "to": simulated_version, "bump": bump})
1149
+ results["steps"].append({"step": "publish", "status": "dry_run", "tag": tag, "output": f"Would publish {pkg_name}@{simulated_version} with tag {tag}"})
1150
+ results["steps"].append({"step": "verify", "status": "dry_run"})
1151
+ results["status"] = "dry_run_complete"
1152
+ return results
1153
+
1039
1154
  # 4. Version bump
1040
1155
  if bump in ("patch", "minor", "major"):
1041
- try:
1042
- bump_cmd = ["npm", "version", bump, "--no-git-tag-version"]
1043
- result = subprocess.run(
1044
- bump_cmd, capture_output=True, text=True, timeout=10, cwd=str(p)
1045
- )
1046
- if result.returncode == 0:
1047
- new_version = result.stdout.strip().lstrip("v")
1156
+ if dry_run:
1157
+ try:
1158
+ new_version = _bump_semver(current_version, bump)
1048
1159
  results["new_version"] = new_version
1049
- results["steps"].append({"step": "version_bump", "status": "ok", "from": current_version, "to": new_version, "bump": bump})
1050
- else:
1051
- results["steps"].append({"step": "version_bump", "status": "error", "detail": result.stderr.strip()[:200]})
1160
+ results["steps"].append({
1161
+ "step": "version_bump",
1162
+ "status": "dry_run",
1163
+ "from": current_version,
1164
+ "to": new_version,
1165
+ "bump": bump,
1166
+ })
1167
+ except Exception as e:
1168
+ results["steps"].append({"step": "version_bump", "status": "error", "detail": str(e)})
1169
+ results["status"] = "bump_failed"
1170
+ return results
1171
+ else:
1172
+ try:
1173
+ bump_cmd = ["npm", "version", bump, "--no-git-tag-version"]
1174
+ result = subprocess.run(
1175
+ bump_cmd, capture_output=True, text=True, timeout=10, cwd=str(p)
1176
+ )
1177
+ if result.returncode == 0:
1178
+ new_version = result.stdout.strip().lstrip("v")
1179
+ results["new_version"] = new_version
1180
+ results["steps"].append({"step": "version_bump", "status": "ok", "from": current_version, "to": new_version, "bump": bump})
1181
+ else:
1182
+ results["steps"].append({"step": "version_bump", "status": "error", "detail": result.stderr.strip()[:200]})
1183
+ results["status"] = "bump_failed"
1184
+ return results
1185
+ except Exception as e:
1186
+ results["steps"].append({"step": "version_bump", "status": "error", "detail": str(e)})
1052
1187
  results["status"] = "bump_failed"
1053
1188
  return results
1054
- except Exception as e:
1055
- results["steps"].append({"step": "version_bump", "status": "error", "detail": str(e)})
1056
- results["status"] = "bump_failed"
1057
- return results
1058
1189
  else:
1059
1190
  results["new_version"] = current_version
1060
1191
 
1061
1192
  # 5. Publish
1062
1193
  publish_cmd = ["npm", "publish", "--tag", tag]
1063
- if dry_run:
1064
- publish_cmd.append("--dry-run")
1065
1194
 
1066
1195
  try:
1067
1196
  result = subprocess.run(
@@ -1070,7 +1199,7 @@ def deploy_npm(project_path: str = ".", bump: str = "patch", tag: str = "latest"
1070
1199
  if result.returncode == 0:
1071
1200
  results["steps"].append({
1072
1201
  "step": "publish",
1073
- "status": "ok" if not dry_run else "dry_run",
1202
+ "status": "ok",
1074
1203
  "tag": tag,
1075
1204
  "output": result.stdout.strip()[-300:]
1076
1205
  })
@@ -1091,27 +1220,26 @@ def deploy_npm(project_path: str = ".", bump: str = "patch", tag: str = "latest"
1091
1220
  results["status"] = "publish_failed"
1092
1221
  return results
1093
1222
 
1094
- # 6. Verify on registry (skip for dry run)
1095
- if not dry_run:
1096
- try:
1097
- import time
1098
- time.sleep(2) # brief wait for registry propagation
1099
- result = subprocess.run(
1100
- ["npm", "view", pkg_name, "version"],
1101
- capture_output=True, text=True, timeout=15
1102
- )
1103
- registry_version = result.stdout.strip()
1104
- verified = registry_version == results.get("new_version", current_version)
1105
- results["steps"].append({
1106
- "step": "verify",
1107
- "status": "ok" if verified else "mismatch",
1108
- "registry_version": registry_version
1109
- })
1110
- except Exception:
1111
- results["steps"].append({"step": "verify", "status": "skipped"})
1223
+ # 6. Verify on registry
1224
+ try:
1225
+ import time
1226
+ time.sleep(2) # brief wait for registry propagation
1227
+ result = subprocess.run(
1228
+ ["npm", "view", pkg_name, "version"],
1229
+ capture_output=True, text=True, timeout=15
1230
+ )
1231
+ registry_version = result.stdout.strip()
1232
+ verified = registry_version == results.get("new_version", current_version)
1233
+ results["steps"].append({
1234
+ "step": "verify",
1235
+ "status": "ok" if verified else "mismatch",
1236
+ "registry_version": registry_version
1237
+ })
1238
+ except Exception:
1239
+ results["steps"].append({"step": "verify", "status": "skipped"})
1112
1240
 
1113
1241
  # 7. Git commit the version bump
1114
- if bump in ("patch", "minor", "major") and not dry_run:
1242
+ if bump in ("patch", "minor", "major"):
1115
1243
  try:
1116
1244
  new_ver = results.get("new_version", current_version)
1117
1245
  subprocess.run(["git", "add", "package.json"], cwd=str(p), timeout=10, capture_output=True)
@@ -551,6 +551,8 @@ def docs_generate(target: str = ".", options: Optional[Dict] = None) -> Dict[str
551
551
 
552
552
  # Scan Python files
553
553
  for py_file in sorted(project.rglob("*.py")):
554
+ if not py_file.is_file():
555
+ continue
554
556
  if any(d in py_file.parts for d in skip_dirs):
555
557
  continue
556
558
  if py_file.name.startswith("test_") or py_file.name == "conftest.py":
@@ -569,6 +571,8 @@ def docs_generate(target: str = ".", options: Optional[Dict] = None) -> Dict[str
569
571
  # Scan JS/TS files
570
572
  for ext in ("*.js", "*.ts", "*.jsx", "*.tsx"):
571
573
  for js_file in sorted(project.rglob(ext)):
574
+ if not js_file.is_file():
575
+ continue
572
576
  if any(d in js_file.parts for d in skip_dirs):
573
577
  continue
574
578
  if ".test." in js_file.name or ".spec." in js_file.name:
@@ -715,6 +719,8 @@ def docs_validate(target: str = ".", options: Optional[Dict] = None) -> Dict[str
715
719
  missing_docs = []
716
720
 
717
721
  for py_file in sorted(project.rglob("*.py")):
722
+ if not py_file.is_file():
723
+ continue
718
724
  if any(d in py_file.parts for d in skip_dirs):
719
725
  continue
720
726
  if py_file.name.startswith("test_") or py_file.name == "conftest.py":
@@ -731,6 +737,8 @@ def docs_validate(target: str = ".", options: Optional[Dict] = None) -> Dict[str
731
737
  # 3. Check broken internal links in all markdown files
732
738
  broken_links = []
733
739
  for md_file in sorted(project.rglob("*.md")):
740
+ if not md_file.is_file():
741
+ continue
734
742
  if any(d in md_file.parts for d in skip_dirs):
735
743
  continue
736
744
  broken_links.extend(_check_broken_links(md_file, project))