specsmith 0.1.0__py3-none-any.whl

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 (59) hide show
  1. specsmith/__init__.py +5 -0
  2. specsmith/__main__.py +7 -0
  3. specsmith/auditor.py +470 -0
  4. specsmith/cli.py +592 -0
  5. specsmith/commands/__init__.py +3 -0
  6. specsmith/compressor.py +152 -0
  7. specsmith/config.py +269 -0
  8. specsmith/differ.py +81 -0
  9. specsmith/doctor.py +106 -0
  10. specsmith/exporter.py +150 -0
  11. specsmith/importer.py +536 -0
  12. specsmith/integrations/__init__.py +53 -0
  13. specsmith/integrations/aider.py +38 -0
  14. specsmith/integrations/base.py +31 -0
  15. specsmith/integrations/claude_code.py +58 -0
  16. specsmith/integrations/copilot.py +55 -0
  17. specsmith/integrations/cursor.py +62 -0
  18. specsmith/integrations/gemini.py +49 -0
  19. specsmith/integrations/warp.py +70 -0
  20. specsmith/integrations/windsurf.py +46 -0
  21. specsmith/scaffolder.py +372 -0
  22. specsmith/templates/agents.md.j2 +159 -0
  23. specsmith/templates/docs/architecture.md.j2 +39 -0
  24. specsmith/templates/docs/requirements.md.j2 +130 -0
  25. specsmith/templates/docs/test-spec.md.j2 +42 -0
  26. specsmith/templates/docs/workflow.md.j2 +15 -0
  27. specsmith/templates/gitattributes.j2 +15 -0
  28. specsmith/templates/gitignore.j2 +119 -0
  29. specsmith/templates/governance/context-budget.md.j2 +50 -0
  30. specsmith/templates/governance/drift-metrics.md.j2 +54 -0
  31. specsmith/templates/governance/roles.md.j2 +30 -0
  32. specsmith/templates/governance/rules.md.j2 +49 -0
  33. specsmith/templates/governance/verification.md.j2 +59 -0
  34. specsmith/templates/governance/workflow.md.j2 +80 -0
  35. specsmith/templates/ledger.md.j2 +27 -0
  36. specsmith/templates/pyproject.toml.j2 +18 -0
  37. specsmith/templates/python/cli.py.j2 +13 -0
  38. specsmith/templates/python/init.py.j2 +3 -0
  39. specsmith/templates/readme.md.j2 +30 -0
  40. specsmith/templates/scripts/exec.cmd.j2 +33 -0
  41. specsmith/templates/scripts/exec.sh.j2 +38 -0
  42. specsmith/templates/scripts/run.cmd.j2 +14 -0
  43. specsmith/templates/scripts/run.sh.j2 +7 -0
  44. specsmith/templates/scripts/setup.cmd.j2 +23 -0
  45. specsmith/templates/scripts/setup.sh.j2 +12 -0
  46. specsmith/tools.py +410 -0
  47. specsmith/upgrader.py +118 -0
  48. specsmith/validator.py +218 -0
  49. specsmith/vcs/__init__.py +41 -0
  50. specsmith/vcs/base.py +122 -0
  51. specsmith/vcs/bitbucket.py +135 -0
  52. specsmith/vcs/github.py +261 -0
  53. specsmith/vcs/gitlab.py +142 -0
  54. specsmith-0.1.0.dist-info/METADATA +137 -0
  55. specsmith-0.1.0.dist-info/RECORD +59 -0
  56. specsmith-0.1.0.dist-info/WHEEL +5 -0
  57. specsmith-0.1.0.dist-info/entry_points.txt +2 -0
  58. specsmith-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. specsmith-0.1.0.dist-info/top_level.txt +1 -0
specsmith/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """specsmith — Forge governed project scaffolds."""
4
+
5
+ __version__ = "0.1.0"
specsmith/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Allow running specsmith as ``python -m specsmith``."""
4
+
5
+ from specsmith.cli import main
6
+
7
+ main()
specsmith/auditor.py ADDED
@@ -0,0 +1,470 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Auditor — drift detection and health checks (Spec Sections 23 + 26)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+
16
+ @dataclass
17
+ class AuditResult:
18
+ """Result of a single audit check."""
19
+
20
+ name: str
21
+ passed: bool
22
+ message: str
23
+ fixable: bool = False
24
+
25
+
26
+ @dataclass
27
+ class AuditReport:
28
+ """Aggregate audit report."""
29
+
30
+ results: list[AuditResult] = field(default_factory=list)
31
+
32
+ @property
33
+ def passed(self) -> int:
34
+ return sum(1 for r in self.results if r.passed)
35
+
36
+ @property
37
+ def failed(self) -> int:
38
+ return sum(1 for r in self.results if not r.passed)
39
+
40
+ @property
41
+ def fixable(self) -> int:
42
+ return sum(1 for r in self.results if not r.passed and r.fixable)
43
+
44
+ @property
45
+ def healthy(self) -> bool:
46
+ return self.failed == 0
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Governance file existence checks
51
+ # ---------------------------------------------------------------------------
52
+
53
+ REQUIRED_FILES = [
54
+ "AGENTS.md",
55
+ "LEDGER.md",
56
+ ]
57
+
58
+ GOVERNANCE_FILES = [
59
+ "docs/governance/rules.md",
60
+ "docs/governance/workflow.md",
61
+ "docs/governance/roles.md",
62
+ "docs/governance/context-budget.md",
63
+ "docs/governance/verification.md",
64
+ "docs/governance/drift-metrics.md",
65
+ ]
66
+
67
+ RECOMMENDED_FILES = [
68
+ "docs/REQUIREMENTS.md",
69
+ "docs/TEST_SPEC.md",
70
+ "docs/architecture.md",
71
+ ]
72
+
73
+
74
+ def check_governance_files(root: Path) -> list[AuditResult]:
75
+ """Check that all required governance files exist."""
76
+ results: list[AuditResult] = []
77
+
78
+ for f in REQUIRED_FILES:
79
+ path = root / f
80
+ results.append(
81
+ AuditResult(
82
+ name=f"file-exists:{f}",
83
+ passed=path.exists(),
84
+ message=f"Required file {f} {'exists' if path.exists() else 'MISSING'}",
85
+ )
86
+ )
87
+
88
+ # Modular governance: either all exist or AGENTS.md is self-contained (>200 lines)
89
+ agents_path = root / "AGENTS.md"
90
+ agents_lines = 0
91
+ if agents_path.exists():
92
+ agents_lines = len(agents_path.read_text(encoding="utf-8").splitlines())
93
+
94
+ if agents_lines > 200:
95
+ # Modular governance is REQUIRED
96
+ for f in GOVERNANCE_FILES:
97
+ path = root / f
98
+ results.append(
99
+ AuditResult(
100
+ name=f"file-exists:{f}",
101
+ passed=path.exists(),
102
+ message=(
103
+ f"Governance file {f} {'exists' if path.exists() else 'MISSING'}"
104
+ f" (AGENTS.md is {agents_lines} lines — modular split required)"
105
+ ),
106
+ )
107
+ )
108
+ else:
109
+ # Recommended but not required
110
+ for f in GOVERNANCE_FILES:
111
+ path = root / f
112
+ if path.exists():
113
+ results.append(
114
+ AuditResult(
115
+ name=f"file-exists:{f}",
116
+ passed=True,
117
+ message=f"Governance file {f} exists",
118
+ )
119
+ )
120
+
121
+ for f in RECOMMENDED_FILES:
122
+ path = root / f
123
+ results.append(
124
+ AuditResult(
125
+ name=f"recommended:{f}",
126
+ passed=path.exists(),
127
+ message=f"Recommended file {f} {'exists' if path.exists() else 'missing'}",
128
+ )
129
+ )
130
+
131
+ return results
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Requirement ↔ Test consistency
136
+ # ---------------------------------------------------------------------------
137
+
138
+ _REQ_PATTERN = re.compile(r"\b(REQ-[A-Z]+-\d+)\b")
139
+ _TEST_PATTERN = re.compile(r"\b(TEST-[A-Z]+-\d+)\b")
140
+ _TEST_COVERS_PATTERN = re.compile(r"Covers:\s*(REQ-[A-Z]+-\d+(?:\s*,\s*REQ-[A-Z]+-\d+)*)")
141
+
142
+
143
+ def check_req_test_consistency(root: Path) -> list[AuditResult]:
144
+ """Check that every REQ has at least one TEST and vice versa."""
145
+ results: list[AuditResult] = []
146
+
147
+ req_path = root / "docs" / "REQUIREMENTS.md"
148
+ test_path = root / "docs" / "TEST_SPEC.md"
149
+
150
+ if not req_path.exists() or not test_path.exists():
151
+ results.append(
152
+ AuditResult(
153
+ name="req-test-consistency",
154
+ passed=True,
155
+ message="Skipped: REQUIREMENTS.md or TEST_SPEC.md not found",
156
+ )
157
+ )
158
+ return results
159
+
160
+ req_text = req_path.read_text(encoding="utf-8")
161
+ test_text = test_path.read_text(encoding="utf-8")
162
+
163
+ req_ids = set(_REQ_PATTERN.findall(req_text))
164
+
165
+ # Find which REQs are covered by tests
166
+ covered_reqs: set[str] = set()
167
+ for match in _TEST_COVERS_PATTERN.finditer(test_text):
168
+ for req_id in _REQ_PATTERN.findall(match.group(0)):
169
+ covered_reqs.add(req_id)
170
+
171
+ uncovered = req_ids - covered_reqs
172
+ if uncovered:
173
+ results.append(
174
+ AuditResult(
175
+ name="req-test-coverage",
176
+ passed=False,
177
+ message=(
178
+ f"{len(uncovered)} REQ(s) without test coverage: {', '.join(sorted(uncovered))}"
179
+ ),
180
+ )
181
+ )
182
+ else:
183
+ results.append(
184
+ AuditResult(
185
+ name="req-test-coverage",
186
+ passed=True,
187
+ message=f"All {len(req_ids)} REQ(s) have test coverage",
188
+ )
189
+ )
190
+
191
+ # Orphaned tests
192
+ orphaned = covered_reqs - req_ids
193
+ if orphaned:
194
+ results.append(
195
+ AuditResult(
196
+ name="orphaned-tests",
197
+ passed=False,
198
+ message=(
199
+ f"{len(orphaned)} TEST(s) reference non-existent REQ(s): "
200
+ f"{', '.join(sorted(orphaned))}"
201
+ ),
202
+ )
203
+ )
204
+
205
+ return results
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Ledger health
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def check_ledger_health(root: Path) -> list[AuditResult]:
214
+ """Check ledger quality and staleness."""
215
+ results: list[AuditResult] = []
216
+ ledger_path = root / "LEDGER.md"
217
+
218
+ if not ledger_path.exists():
219
+ results.append(
220
+ AuditResult(
221
+ name="ledger-exists",
222
+ passed=False,
223
+ message="LEDGER.md not found",
224
+ )
225
+ )
226
+ return results
227
+
228
+ text = ledger_path.read_text(encoding="utf-8")
229
+ lines = text.splitlines()
230
+ line_count = len(lines)
231
+
232
+ # Size check
233
+ if line_count > 500:
234
+ results.append(
235
+ AuditResult(
236
+ name="ledger-size",
237
+ passed=False,
238
+ message=(
239
+ f"LEDGER.md has {line_count} lines (threshold: 500). "
240
+ f"Consider `specsmith compress`."
241
+ ),
242
+ fixable=True,
243
+ )
244
+ )
245
+ else:
246
+ results.append(
247
+ AuditResult(
248
+ name="ledger-size",
249
+ passed=True,
250
+ message=f"LEDGER.md has {line_count} lines (within threshold)",
251
+ )
252
+ )
253
+
254
+ # Open TODOs
255
+ open_todos = sum(1 for line in lines if "- [ ]" in line)
256
+ closed_todos = sum(1 for line in lines if "- [x]" in line)
257
+ if open_todos > 20:
258
+ results.append(
259
+ AuditResult(
260
+ name="ledger-open-todos",
261
+ passed=False,
262
+ message=f"{open_todos} open TODOs in ledger (may indicate stale items)",
263
+ )
264
+ )
265
+ else:
266
+ results.append(
267
+ AuditResult(
268
+ name="ledger-open-todos",
269
+ passed=True,
270
+ message=f"{open_todos} open, {closed_todos} closed TODOs",
271
+ )
272
+ )
273
+
274
+ return results
275
+
276
+
277
+ # ---------------------------------------------------------------------------
278
+ # Context size (governance bloat detection)
279
+ # ---------------------------------------------------------------------------
280
+
281
+
282
+ def check_context_size(root: Path) -> list[AuditResult]:
283
+ """Check governance file sizes against thresholds."""
284
+ results: list[AuditResult] = []
285
+ thresholds = {
286
+ "AGENTS.md": 200,
287
+ "docs/governance/rules.md": 300,
288
+ "docs/governance/workflow.md": 300,
289
+ "docs/governance/roles.md": 200,
290
+ "docs/governance/context-budget.md": 200,
291
+ "docs/governance/verification.md": 200,
292
+ "docs/governance/drift-metrics.md": 200,
293
+ }
294
+
295
+ for rel_path, max_lines in thresholds.items():
296
+ path = root / rel_path
297
+ if not path.exists():
298
+ continue
299
+ line_count = len(path.read_text(encoding="utf-8").splitlines())
300
+ ok = line_count <= max_lines
301
+ results.append(
302
+ AuditResult(
303
+ name=f"context-size:{rel_path}",
304
+ passed=ok,
305
+ message=(
306
+ f"{rel_path}: {line_count} lines"
307
+ + ("" if ok else f" (exceeds {max_lines} threshold)")
308
+ ),
309
+ )
310
+ )
311
+
312
+ return results
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Public API
317
+ # ---------------------------------------------------------------------------
318
+
319
+
320
+ def check_tool_configuration(root: Path) -> list[AuditResult]:
321
+ """Check that CI config references the expected verification tools."""
322
+ results: list[AuditResult] = []
323
+ scaffold_path = root / "scaffold.yml"
324
+ if not scaffold_path.exists():
325
+ return results # Not a specsmith project — skip silently
326
+
327
+ import yaml
328
+
329
+ from specsmith.config import ProjectConfig
330
+ from specsmith.tools import get_tools
331
+
332
+ try:
333
+ with open(scaffold_path) as f:
334
+ raw = yaml.safe_load(f)
335
+ config = ProjectConfig(**raw)
336
+ except Exception: # noqa: BLE001
337
+ return results # Malformed config — other checks will catch this
338
+
339
+ tools = get_tools(config)
340
+ if not any([tools.lint, tools.typecheck, tools.test, tools.security]):
341
+ return results # No tools registered for this type
342
+
343
+ # Check CI configs for expected tool references
344
+ ci_files = list(root.glob(".github/workflows/*.yml")) + [
345
+ root / ".gitlab-ci.yml",
346
+ root / "bitbucket-pipelines.yml",
347
+ ]
348
+ ci_content = ""
349
+ for ci_file in ci_files:
350
+ if ci_file.exists():
351
+ ci_content += ci_file.read_text(encoding="utf-8")
352
+
353
+ if not ci_content:
354
+ results.append(
355
+ AuditResult(
356
+ name="tool-ci-config",
357
+ passed=True,
358
+ message="No CI config found — tool verification skipped",
359
+ )
360
+ )
361
+ return results
362
+
363
+ missing: list[str] = []
364
+ # Check that at least the primary lint and test tools appear in CI
365
+ for cmd in tools.lint[:1]:
366
+ tool_name = cmd.split()[0] # e.g. "ruff" from "ruff check"
367
+ if tool_name not in ci_content:
368
+ missing.append(f"lint:{tool_name}")
369
+ for cmd in tools.test[:1]:
370
+ tool_name = cmd.split()[0]
371
+ if tool_name not in ci_content:
372
+ missing.append(f"test:{tool_name}")
373
+
374
+ if missing:
375
+ results.append(
376
+ AuditResult(
377
+ name="tool-ci-config",
378
+ passed=False,
379
+ message=f"CI config missing expected tools: {', '.join(missing)}",
380
+ )
381
+ )
382
+ else:
383
+ results.append(
384
+ AuditResult(
385
+ name="tool-ci-config",
386
+ passed=True,
387
+ message=f"CI config references expected verification tools for {config.type.value}",
388
+ )
389
+ )
390
+
391
+ return results
392
+
393
+
394
+ def run_audit(root: Path) -> AuditReport:
395
+ """Run all audit checks and return a report."""
396
+ report = AuditReport()
397
+ report.results.extend(check_governance_files(root))
398
+ report.results.extend(check_req_test_consistency(root))
399
+ report.results.extend(check_ledger_health(root))
400
+ report.results.extend(check_context_size(root))
401
+ report.results.extend(check_tool_configuration(root))
402
+ return report
403
+
404
+
405
+ def run_auto_fix(root: Path, report: AuditReport) -> list[str]:
406
+ """Attempt to auto-fix issues found by audit.
407
+
408
+ Returns list of human-readable fix descriptions.
409
+ """
410
+ fixed: list[str] = []
411
+
412
+ for result in report.results:
413
+ if result.passed:
414
+ continue
415
+
416
+ # Fix missing required files with minimal stubs
417
+ if result.name == "file-exists:AGENTS.md":
418
+ path = root / "AGENTS.md"
419
+ path.write_text(
420
+ "# AGENTS.md\n\nGovernance hub. Populate with project details.\n",
421
+ encoding="utf-8",
422
+ )
423
+ fixed.append("Created stub AGENTS.md")
424
+
425
+ elif result.name == "file-exists:LEDGER.md":
426
+ path = root / "LEDGER.md"
427
+ path.write_text("# Ledger\n\nNo entries yet.\n", encoding="utf-8")
428
+ fixed.append("Created stub LEDGER.md")
429
+
430
+ elif result.name.startswith("file-exists:docs/governance/"):
431
+ rel = result.name.split(":", 1)[1]
432
+ path = root / rel
433
+ path.parent.mkdir(parents=True, exist_ok=True)
434
+ fname = path.stem.replace("-", " ").title()
435
+ path.write_text(f"# {fname}\n\nPopulate per spec.\n", encoding="utf-8")
436
+ fixed.append(f"Created stub {rel}")
437
+
438
+ # Compress oversized ledger
439
+ elif result.name == "ledger-size" and result.fixable:
440
+ from specsmith.compressor import run_compress
441
+
442
+ compress_result = run_compress(root)
443
+ if compress_result.archived_entries > 0:
444
+ fixed.append(compress_result.message)
445
+
446
+ # Generate missing CI config from tool registry
447
+ elif result.name == "tool-ci-config" and not result.passed:
448
+ scaffold_path = root / "scaffold.yml"
449
+ if scaffold_path.exists():
450
+ import yaml
451
+
452
+ from specsmith.config import ProjectConfig
453
+
454
+ try:
455
+ with open(scaffold_path) as f:
456
+ raw = yaml.safe_load(f)
457
+ config = ProjectConfig(**raw)
458
+ if config.vcs_platform:
459
+ from specsmith.vcs import get_platform
460
+
461
+ platform = get_platform(config.vcs_platform)
462
+ platform.generate_all(config, root)
463
+ fixed.append(
464
+ f"Generated {config.vcs_platform} CI config "
465
+ f"with tools for {config.type.value}"
466
+ )
467
+ except Exception: # noqa: BLE001
468
+ pass # Best-effort
469
+
470
+ return fixed