@tyroneross/build-loop 0.36.0 → 0.43.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.
- package/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/build-loop-surface.mdc +12 -11
- package/.cursor/rules/skill-index.mdc +33 -0
- package/AGENTS.md +213 -34
- package/README.md +99 -31
- package/agents/advisor.md +4 -4
- package/agents/alignment-checker.md +2 -2
- package/agents/architecture-scout.md +4 -4
- package/agents/build-orchestrator.md +38 -36
- package/agents/database-assessor.md +11 -5
- package/agents/design-contract-specialist.md +8 -8
- package/agents/fact-checker.md +13 -3
- package/agents/fix-critique.md +2 -2
- package/agents/independent-auditor.md +60 -7
- package/agents/leak-scanner.md +82 -0
- package/agents/overfitting-reviewer.md +2 -2
- package/agents/plan-critic.md +1 -1
- package/agents/promotion-reviewer.md +5 -5
- package/agents/retrospective-synthesizer.md +138 -35
- package/agents/scope-auditor.md +82 -11
- package/agents/security-reviewer.md +56 -2
- package/agents/self-improvement-architect.md +17 -3
- package/agents/transcript-pattern-miner.md +5 -5
- package/agents/ui-validator.md +1 -1
- package/bin/build-loop-debugger.js +143 -0
- package/bin/build-loop-install.js +1 -4
- package/bin/build-loop-load-probe.js +345 -0
- package/codex-skills/build-loop/SKILL.md +28 -6
- package/commands/feedback.md +37 -0
- package/dist/src/interactive-verifier.d.ts +1 -14
- package/dist/src/interactive-verifier.d.ts.map +1 -1
- package/dist/src/interactive-verifier.js +6 -113
- package/dist/src/interactive-verifier.js.map +1 -1
- package/dist/src/quality.d.ts +5 -0
- package/dist/src/quality.d.ts.map +1 -0
- package/dist/src/quality.js +81 -0
- package/dist/src/quality.js.map +1 -0
- package/dist/src/storage.d.ts.map +1 -1
- package/dist/src/storage.js +37 -3
- package/dist/src/storage.js.map +1 -1
- package/docs/agent-surface-policy.md +35 -31
- package/docs/memory-setup.md +19 -0
- package/hooks/git/pre-push +65 -4
- package/hooks/hooks.json +95 -38
- package/hooks/pre-commit +20 -1
- package/hooks/pre-edit-rally-point.sh +10 -3
- package/hooks/session-start-codex-hook-trust.sh +30 -0
- package/hooks/session-start-git-hooks.sh +3 -1
- package/hooks/session-start-rally-point.sh +52 -4
- package/hooks/session-start-worktree-gc.sh +47 -94
- package/hooks/stop-transcript-sweep.sh +173 -0
- package/hooks/test_closeout.sh +14 -2
- package/package.json +8 -7
- package/scripts/README.md +1 -1
- package/scripts/_paths.py +65 -0
- package/scripts/groundwork_exchange.py +1012 -0
- package/scripts/install_memory.py +33 -1
- package/scripts/lessons_index/ingest.py +13 -2
- package/scripts/lessons_index/query.py +36 -13
- package/scripts/memory_context/__init__.py +108 -14
- package/scripts/memory_graph/__init__.py +5 -1
- package/scripts/project_resolver.py +42 -36
- package/scripts/sync_plugin_cache.py +37 -2
- package/skills/agent-rally-point/SKILL.md +46 -0
- package/skills/api-registry-bridge/SKILL.md +1 -1
- package/skills/architecture/dead/SKILL.md +1 -1
- package/skills/architecture/impact/SKILL.md +1 -1
- package/skills/architecture/review/SKILL.md +1 -1
- package/skills/architecture/rules/SKILL.md +3 -3
- package/skills/architecture/scan/SKILL.md +1 -1
- package/skills/architecture/trace/SKILL.md +1 -1
- package/skills/attribution-standard/SKILL.md +6 -6
- package/skills/auto-decision-capture/SKILL.md +31 -2
- package/skills/auto-finding-capture/SKILL.md +28 -1
- package/skills/build-loop/SKILL.md +131 -23
- package/skills/build-loop/fallbacks.md +16 -21
- package/skills/build-loop/phases/ui-validation.md +2 -2
- package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
- package/skills/build-loop/references/apple-native-planning.md +1 -1
- package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
- package/skills/build-loop/references/autonomy-dashboard.md +115 -0
- package/skills/build-loop/references/capability-routing.md +24 -2
- package/skills/build-loop/references/coordination.md +24 -6
- package/skills/build-loop/references/experiment-results-template.md +15 -3
- package/skills/build-loop/references/leadership.md +1 -1
- package/skills/build-loop/references/memory.md +14 -3
- package/skills/build-loop/references/modular-systems-pack.md +8 -0
- package/skills/build-loop/references/output-style.md +86 -0
- package/skills/build-loop/references/phase-1-assess.md +102 -2
- package/skills/build-loop/references/phase-2-plan.md +9 -1
- package/skills/build-loop/references/phase-3-execute.md +5 -2
- package/skills/build-loop/references/phase-4-review.md +85 -8
- package/skills/build-loop/references/phase-5-iterate.md +76 -8
- package/skills/build-loop/references/phase-6-learn.md +10 -17
- package/skills/build-loop/references/privileged-request-broker.md +254 -0
- package/skills/build-loop/references/resource-aware-execution.md +183 -0
- package/skills/build-loop/references/self-recursive-dev.md +2 -2
- package/skills/build-loop/references/status-output-format.md +207 -0
- package/skills/build-loop/references/verify-dispatch.md +56 -2
- package/skills/building-with-deepagents/SKILL.md +1 -1
- package/skills/claim-scope/SKILL.md +185 -0
- package/skills/color-engine/SKILL.md +103 -0
- package/skills/color-engine/_core.py +464 -0
- package/skills/color-engine/color_engine.py +175 -0
- package/skills/cost-rca/SKILL.md +61 -0
- package/skills/data-plane-worktrees/SKILL.md +139 -0
- package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
- package/skills/database-practice/SKILL.md +200 -0
- package/skills/database-practice/references/diagnostic-queries.sql +126 -0
- package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
- package/skills/database-practice/scripts/db_table_map.py +1244 -0
- package/skills/database-practice/scripts/test_db_table_map.py +514 -0
- package/skills/debug-loop/SKILL.md +36 -6
- package/skills/debugging-memory/SKILL.md +32 -430
- package/skills/debugging-memory/references/pattern-extraction.md +4 -4
- package/skills/debugging-memory/references/search.md +32 -120
- package/skills/debugging-memory/references/store.md +32 -126
- package/skills/debugging-memory/references/subagent-integration.md +1 -1
- package/skills/decision-queue/SKILL.md +251 -0
- package/skills/decision-queue/assets/template.html +1242 -0
- package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
- package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
- package/skills/defenseclaw-bridge/SKILL.md +2 -2
- package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
- package/skills/drain-proposals/SKILL.md +53 -0
- package/skills/focused-loop-builder/SKILL.md +31 -0
- package/skills/focused-loop-builder/references/spec-format.md +27 -0
- package/skills/handoff/SKILL.md +169 -8
- package/skills/ibr-bridge/SKILL.md +4 -1
- package/skills/knowledge/SKILL.md +26 -14
- package/skills/knowledge/references/review-mode.md +2 -3
- package/skills/knowledge/templates/madr-minimal.md +1 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/model-bakeoff/SKILL.md +48 -10
- package/skills/model-tiering/SKILL.md +92 -31
- package/skills/native-ax-driver/SKILL.md +38 -5
- package/skills/native-ax-driver/scripts/native_driver.py +278 -22
- package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
- package/skills/optimize/SKILL.md +1 -1
- package/skills/plugin-builder/SKILL.md +48 -1
- package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
- package/skills/plugin-builder/references/distribution.md +13 -2
- package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
- package/skills/plugin-tests/SKILL.md +2 -2
- package/skills/recursive-retrospective/SKILL.md +1 -1
- package/skills/repo-closeout/SKILL.md +17 -0
- package/skills/repo-closeout/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/SKILL.md +179 -0
- package/skills/repo-maintenance/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
- package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
- package/skills/repo-maintenance/references/safety-protocol.md +106 -0
- package/skills/repo-maintenance/references/stack-profiles.md +138 -0
- package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
- package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
- package/skills/repository-intelligence/SKILL.md +189 -0
- package/skills/repository-intelligence/agents/openai.yaml +4 -0
- package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
- package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
- package/skills/research/SKILL.md +12 -2
- package/skills/root-cause-analysis/SKILL.md +1 -1
- package/skills/runtime-parity-verification/SKILL.md +36 -1
- package/skills/security-methodology/SKILL.md +23 -10
- package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
- package/skills/security-methodology/references/cross-source-matrix.md +1 -1
- package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
- package/skills/security-scan/SKILL.md +55 -15
- package/skills/self-improve/SKILL.md +70 -50
- package/skills/silent-assumptions/SKILL.md +341 -0
- package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
- package/skills/spec-writing/SKILL.md +128 -24
- package/skills/spec-writing/scripts/check_checklist.py +114 -15
- package/skills/ui-design/SKILL.md +6 -4
- package/skills/ui-design/references/color-engine.md +132 -0
- package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
- package/skills/ui-design/references/ui-guidance-sources.md +1 -1
- package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
- package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
- package/plugin-artifacts/codex/AGENTS.md +0 -560
- package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
- package/plugin-artifacts/codex/LICENSE +0 -202
- package/plugin-artifacts/codex/README.md +0 -313
- package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
- package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
- package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
- package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
- package/plugin-artifacts/codex/references/backlog-system.md +0 -285
- package/plugin-artifacts/codex/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
- package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
- package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
- package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
- package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
- package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/references/leadership.md +0 -72
- package/plugin-artifacts/codex/references/memory-systems.md +0 -261
- package/plugin-artifacts/codex/references/memory.md +0 -313
- package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
- package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
- package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
- package/plugin-artifacts/codex/references/self-review.md +0 -234
- package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
- package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
- package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
- package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
- package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
- package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
- package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
- package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
- package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
- package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
- package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
- package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
- package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
- package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
- package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
- package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
- package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
- package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
- package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
- package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
- package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
- package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
- package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
- package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
- package/scripts/build_codex_plugin_artifact.py +0 -321
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only inventory for repository maintenance, structure, and closeout."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import fnmatch
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_ARTIFACT_PATTERNS = (
|
|
19
|
+
"build",
|
|
20
|
+
"build-*",
|
|
21
|
+
"build_*",
|
|
22
|
+
"build-rust",
|
|
23
|
+
"target",
|
|
24
|
+
".build",
|
|
25
|
+
"DerivedData",
|
|
26
|
+
"dist",
|
|
27
|
+
"coverage",
|
|
28
|
+
".pytest_cache",
|
|
29
|
+
".mypy_cache",
|
|
30
|
+
".ruff_cache",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEFAULT_PROTECTED_ARTIFACTS = frozenset({"build", "build-rust"})
|
|
34
|
+
DISCOVERY_PRUNE_NAMES = frozenset(
|
|
35
|
+
{".git", ".nox", ".tox", ".venv", "node_modules", "site-packages", "venv"}
|
|
36
|
+
)
|
|
37
|
+
RELEASE_ARTIFACT_SUFFIXES = (
|
|
38
|
+
".aab",
|
|
39
|
+
".apk",
|
|
40
|
+
".appx",
|
|
41
|
+
".deb",
|
|
42
|
+
".dmg",
|
|
43
|
+
".exe",
|
|
44
|
+
".ipa",
|
|
45
|
+
".msi",
|
|
46
|
+
".msix",
|
|
47
|
+
".pkg",
|
|
48
|
+
".rpm",
|
|
49
|
+
".tar.gz",
|
|
50
|
+
".xcarchive",
|
|
51
|
+
".zip",
|
|
52
|
+
)
|
|
53
|
+
RELEASE_CONTAINER_NAMES = frozenset({"artifacts", "dist", "release", "releases"})
|
|
54
|
+
RELEASE_BUNDLE_SUFFIXES = (".app",)
|
|
55
|
+
RELEASE_EVIDENCE_LIMIT = 20
|
|
56
|
+
|
|
57
|
+
LANGUAGE_EXTENSIONS = {
|
|
58
|
+
".c": "C/C++",
|
|
59
|
+
".cc": "C/C++",
|
|
60
|
+
".cpp": "C/C++",
|
|
61
|
+
".cs": "C#",
|
|
62
|
+
".dart": "Dart",
|
|
63
|
+
".go": "Go",
|
|
64
|
+
".h": "C/C++",
|
|
65
|
+
".hpp": "C/C++",
|
|
66
|
+
".java": "Java",
|
|
67
|
+
".js": "JavaScript",
|
|
68
|
+
".jsx": "JavaScript",
|
|
69
|
+
".kt": "Kotlin",
|
|
70
|
+
".kts": "Kotlin",
|
|
71
|
+
".m": "Objective-C",
|
|
72
|
+
".mm": "Objective-C",
|
|
73
|
+
".php": "PHP",
|
|
74
|
+
".py": "Python",
|
|
75
|
+
".rb": "Ruby",
|
|
76
|
+
".rs": "Rust",
|
|
77
|
+
".sh": "Shell",
|
|
78
|
+
".swift": "Swift",
|
|
79
|
+
".tf": "Terraform",
|
|
80
|
+
".ts": "TypeScript",
|
|
81
|
+
".tsx": "TypeScript",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
PROFILE_EVIDENCE_LIMIT = 20
|
|
85
|
+
MANIFEST_CONTENT_SCAN_LIMIT = 200
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["git", "-C", str(repo), *args],
|
|
91
|
+
text=True,
|
|
92
|
+
stdout=subprocess.PIPE,
|
|
93
|
+
stderr=subprocess.PIPE,
|
|
94
|
+
check=False,
|
|
95
|
+
)
|
|
96
|
+
if check and result.returncode != 0:
|
|
97
|
+
raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_root(repo: Path) -> Path:
|
|
102
|
+
result = run_git(repo, "rev-parse", "--show-toplevel")
|
|
103
|
+
return Path(result.stdout.strip()).resolve()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ref_exists(repo: Path, ref: str) -> bool:
|
|
107
|
+
commit_ref = f"{ref}^{{commit}}"
|
|
108
|
+
return run_git(repo, "rev-parse", "--verify", "--quiet", commit_ref, check=False).returncode == 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
|
|
112
|
+
return run_git(repo, "merge-base", "--is-ancestor", ancestor, descendant, check=False).returncode == 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ahead_behind(repo: Path, left: str, right: str) -> tuple[int, int]:
|
|
116
|
+
result = run_git(repo, "rev-list", "--left-right", "--count", f"{left}...{right}")
|
|
117
|
+
left_only, right_only = result.stdout.split()
|
|
118
|
+
return int(left_only), int(right_only)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_worktrees(repo: Path) -> list[dict[str, Any]]:
|
|
122
|
+
output = run_git(repo, "worktree", "list", "--porcelain").stdout
|
|
123
|
+
records: list[dict[str, Any]] = []
|
|
124
|
+
current: dict[str, Any] = {}
|
|
125
|
+
for line in output.splitlines() + [""]:
|
|
126
|
+
if not line:
|
|
127
|
+
if current:
|
|
128
|
+
branch = current.get("branch")
|
|
129
|
+
if isinstance(branch, str) and branch.startswith("refs/heads/"):
|
|
130
|
+
current["branch"] = branch.removeprefix("refs/heads/")
|
|
131
|
+
path = Path(str(current["worktree"]))
|
|
132
|
+
current["exists"] = path.exists()
|
|
133
|
+
git_root = (
|
|
134
|
+
run_git(path, "rev-parse", "--show-toplevel", check=False)
|
|
135
|
+
if path.exists()
|
|
136
|
+
else None
|
|
137
|
+
)
|
|
138
|
+
current["git_available"] = bool(
|
|
139
|
+
git_root
|
|
140
|
+
and git_root.returncode == 0
|
|
141
|
+
and git_root.stdout.strip()
|
|
142
|
+
and Path(git_root.stdout.strip()).resolve() == path.resolve()
|
|
143
|
+
)
|
|
144
|
+
current["dirty_paths"] = (
|
|
145
|
+
parse_status(repo=path) if current["git_available"] else []
|
|
146
|
+
)
|
|
147
|
+
current["dirty"] = bool(current["dirty_paths"])
|
|
148
|
+
records.append(current)
|
|
149
|
+
current = {}
|
|
150
|
+
continue
|
|
151
|
+
key, _, value = line.partition(" ")
|
|
152
|
+
current[key] = value if value else True
|
|
153
|
+
return records
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_status(repo: Path) -> list[str]:
|
|
157
|
+
output = run_git(repo, "status", "--porcelain=v1", "-z", "--untracked-files=all").stdout
|
|
158
|
+
fields = output.split("\0")
|
|
159
|
+
paths: list[str] = []
|
|
160
|
+
index = 0
|
|
161
|
+
while index < len(fields):
|
|
162
|
+
field = fields[index]
|
|
163
|
+
index += 1
|
|
164
|
+
if not field:
|
|
165
|
+
continue
|
|
166
|
+
status = field[:2]
|
|
167
|
+
paths.append(field[3:])
|
|
168
|
+
if "R" in status or "C" in status:
|
|
169
|
+
index += 1
|
|
170
|
+
return paths
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def branch_inventory(repo: Path, base: str, worktrees: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
174
|
+
checked_out = {str(w.get("branch")): str(w["worktree"]) for w in worktrees if w.get("branch")}
|
|
175
|
+
fmt = "%(refname:short)%00%(objectname)%00%(upstream:short)"
|
|
176
|
+
output = run_git(repo, "for-each-ref", f"--format={fmt}", "refs/heads").stdout
|
|
177
|
+
branches: list[dict[str, Any]] = []
|
|
178
|
+
for line in output.splitlines():
|
|
179
|
+
name, head, upstream = line.split("\0")
|
|
180
|
+
item: dict[str, Any] = {
|
|
181
|
+
"name": name,
|
|
182
|
+
"head": head,
|
|
183
|
+
"upstream": upstream or None,
|
|
184
|
+
"worktree": checked_out.get(name),
|
|
185
|
+
}
|
|
186
|
+
if ref_exists(repo, base):
|
|
187
|
+
behind, ahead = ahead_behind(repo, base, name)
|
|
188
|
+
item.update(
|
|
189
|
+
{
|
|
190
|
+
"behind_base": behind,
|
|
191
|
+
"ahead_base": ahead,
|
|
192
|
+
"merged_into_base": is_ancestor(repo, name, base),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
if name != base and ahead:
|
|
196
|
+
cherry = run_git(repo, "cherry", base, name, check=False)
|
|
197
|
+
item["cherry"] = cherry.stdout.splitlines() if cherry.returncode == 0 else []
|
|
198
|
+
if upstream and ref_exists(repo, upstream):
|
|
199
|
+
upstream_behind, upstream_ahead = ahead_behind(repo, upstream, name)
|
|
200
|
+
item["behind_upstream"] = upstream_behind
|
|
201
|
+
item["ahead_upstream"] = upstream_ahead
|
|
202
|
+
branches.append(item)
|
|
203
|
+
return branches
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def stash_inventory(repo: Path) -> list[dict[str, Any]]:
|
|
207
|
+
output = run_git(repo, "stash", "list", "--format=%gd%x00%H%x00%gs").stdout
|
|
208
|
+
stashes: list[dict[str, Any]] = []
|
|
209
|
+
for line in output.splitlines():
|
|
210
|
+
ref, commit, subject = line.split("\0", 2)
|
|
211
|
+
parents = run_git(repo, "show", "-s", "--format=%P", commit).stdout.split()
|
|
212
|
+
names = run_git(repo, "stash", "show", "--include-untracked", "--name-only", ref).stdout.splitlines()
|
|
213
|
+
stashes.append(
|
|
214
|
+
{
|
|
215
|
+
"ref": ref,
|
|
216
|
+
"commit": commit,
|
|
217
|
+
"subject": subject,
|
|
218
|
+
"parents": parents,
|
|
219
|
+
"has_untracked_parent": len(parents) >= 3,
|
|
220
|
+
"paths": names,
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
return stashes
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def operation_state(repo: Path) -> list[str]:
|
|
227
|
+
git_dir = Path(run_git(repo, "rev-parse", "--git-dir").stdout.strip())
|
|
228
|
+
if not git_dir.is_absolute():
|
|
229
|
+
git_dir = repo / git_dir
|
|
230
|
+
markers = {
|
|
231
|
+
"merge": "MERGE_HEAD",
|
|
232
|
+
"cherry-pick": "CHERRY_PICK_HEAD",
|
|
233
|
+
"revert": "REVERT_HEAD",
|
|
234
|
+
"rebase-merge": "rebase-merge",
|
|
235
|
+
"rebase-apply": "rebase-apply",
|
|
236
|
+
"bisect": "BISECT_LOG",
|
|
237
|
+
}
|
|
238
|
+
return [name for name, marker in markers.items() if (git_dir / marker).exists()]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def tracked_structure(repo: Path) -> dict[str, Any]:
|
|
242
|
+
paths = run_git(repo, "ls-files", "-z").stdout.split("\0")
|
|
243
|
+
paths = [path for path in paths if path]
|
|
244
|
+
top_level: dict[str, int] = {}
|
|
245
|
+
for path in paths:
|
|
246
|
+
root = path.split("/", 1)[0]
|
|
247
|
+
top_level[root] = top_level.get(root, 0) + 1
|
|
248
|
+
nested_git_roots = [
|
|
249
|
+
child.name
|
|
250
|
+
for child in repo.iterdir()
|
|
251
|
+
if child.is_dir() and not child.is_symlink() and (child / ".git").exists()
|
|
252
|
+
]
|
|
253
|
+
return {
|
|
254
|
+
"tracked_file_count": len(paths),
|
|
255
|
+
"tracked_files_by_top_level": dict(sorted(top_level.items())),
|
|
256
|
+
"nested_git_roots": sorted(nested_git_roots),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def tracked_paths_at_ref(repo: Path, ref: str) -> list[str]:
|
|
261
|
+
output = run_git(repo, "ls-tree", "-r", "--name-only", "-z", ref).stdout
|
|
262
|
+
return sorted(path for path in output.split("\0") if path)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def read_text_at_ref(repo: Path, ref: str, path: str) -> str | None:
|
|
266
|
+
result = run_git(repo, "show", f"{ref}:{path}", check=False)
|
|
267
|
+
return result.stdout if result.returncode == 0 else None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def limited_evidence(paths: list[str] | set[str] | tuple[str, ...]) -> list[str]:
|
|
271
|
+
return sorted(set(paths))[:PROFILE_EVIDENCE_LIMIT]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def package_metadata(
|
|
275
|
+
repo: Path, ref: str, paths: list[str]
|
|
276
|
+
) -> tuple[set[str], list[str], list[str], dict[str, Any]]:
|
|
277
|
+
dependencies: set[str] = set()
|
|
278
|
+
workspace_manifests: list[str] = []
|
|
279
|
+
script_manifests: list[str] = []
|
|
280
|
+
manifests = sorted(
|
|
281
|
+
(item for item in paths if Path(item).name == "package.json"),
|
|
282
|
+
key=lambda item: (item.count("/"), item),
|
|
283
|
+
)
|
|
284
|
+
scanned = manifests[:MANIFEST_CONTENT_SCAN_LIMIT]
|
|
285
|
+
for path in scanned:
|
|
286
|
+
raw = read_text_at_ref(repo, ref, path)
|
|
287
|
+
if raw is None:
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
manifest = json.loads(raw)
|
|
291
|
+
except json.JSONDecodeError:
|
|
292
|
+
continue
|
|
293
|
+
if not isinstance(manifest, dict):
|
|
294
|
+
continue
|
|
295
|
+
if manifest.get("workspaces"):
|
|
296
|
+
workspace_manifests.append(path)
|
|
297
|
+
if manifest.get("scripts"):
|
|
298
|
+
script_manifests.append(path)
|
|
299
|
+
for field in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
300
|
+
values = manifest.get(field, {})
|
|
301
|
+
if isinstance(values, dict):
|
|
302
|
+
dependencies.update(str(name) for name in values)
|
|
303
|
+
return (
|
|
304
|
+
dependencies,
|
|
305
|
+
workspace_manifests,
|
|
306
|
+
script_manifests,
|
|
307
|
+
{
|
|
308
|
+
"package_manifests_found": len(manifests),
|
|
309
|
+
"package_manifests_scanned": len(scanned),
|
|
310
|
+
"package_manifest_scan_truncated": len(manifests) > len(scanned),
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def detect_profile_signals(repo: Path, ref: str) -> dict[str, Any]:
|
|
316
|
+
"""Return evidence signals without claiming a final architecture classification."""
|
|
317
|
+
|
|
318
|
+
if not ref_exists(repo, ref):
|
|
319
|
+
return {
|
|
320
|
+
"source_ref": None,
|
|
321
|
+
"inference_policy": (
|
|
322
|
+
"Signals are evidence, not a final application, topology, deployment, or ownership decision."
|
|
323
|
+
),
|
|
324
|
+
"languages": [],
|
|
325
|
+
"build_systems": [],
|
|
326
|
+
"application_signals": [],
|
|
327
|
+
"composition_signals": [],
|
|
328
|
+
"layout_roots": [],
|
|
329
|
+
"scan_summary": {
|
|
330
|
+
"tracked_paths": 0,
|
|
331
|
+
"package_manifests_found": 0,
|
|
332
|
+
"package_manifests_scanned": 0,
|
|
333
|
+
"package_manifest_scan_truncated": False,
|
|
334
|
+
"cargo_manifests_found": 0,
|
|
335
|
+
"cargo_manifests_scanned": 0,
|
|
336
|
+
"cargo_manifest_scan_truncated": False,
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
paths = tracked_paths_at_ref(repo, ref)
|
|
341
|
+
language_paths: dict[str, list[str]] = {}
|
|
342
|
+
for path in paths:
|
|
343
|
+
language = LANGUAGE_EXTENSIONS.get(Path(path).suffix.lower())
|
|
344
|
+
if language:
|
|
345
|
+
language_paths.setdefault(language, []).append(path)
|
|
346
|
+
languages = [
|
|
347
|
+
{
|
|
348
|
+
"name": name,
|
|
349
|
+
"file_count": len(language_paths[name]),
|
|
350
|
+
"sample_paths": limited_evidence(language_paths[name])[:5],
|
|
351
|
+
}
|
|
352
|
+
for name in sorted(language_paths, key=lambda item: (-len(language_paths[item]), item))
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
by_name: dict[str, list[str]] = {}
|
|
356
|
+
for path in paths:
|
|
357
|
+
by_name.setdefault(Path(path).name, []).append(path)
|
|
358
|
+
|
|
359
|
+
build_systems: list[dict[str, Any]] = []
|
|
360
|
+
|
|
361
|
+
def add_build(name: str, role: str, evidence: list[str]) -> None:
|
|
362
|
+
if evidence:
|
|
363
|
+
build_systems.append(
|
|
364
|
+
{"name": name, "role": role, "evidence": limited_evidence(evidence)}
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
cargo_manifests = by_name.get("Cargo.toml", [])
|
|
368
|
+
swift_manifests = by_name.get("Package.swift", [])
|
|
369
|
+
xcode_projects = [path for path in paths if path.endswith(".xcodeproj/project.pbxproj")]
|
|
370
|
+
xcodegen_manifests = by_name.get("project.yml", [])
|
|
371
|
+
package_manifests = by_name.get("package.json", [])
|
|
372
|
+
gradle_manifests = [
|
|
373
|
+
path
|
|
374
|
+
for path in paths
|
|
375
|
+
if Path(path).name
|
|
376
|
+
in {"build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"}
|
|
377
|
+
]
|
|
378
|
+
dockerfiles = [path for path in paths if Path(path).name.startswith("Dockerfile")]
|
|
379
|
+
terraform_files = [path for path in paths if path.endswith(".tf")]
|
|
380
|
+
(
|
|
381
|
+
dependencies,
|
|
382
|
+
node_workspace_manifests,
|
|
383
|
+
node_script_manifests,
|
|
384
|
+
scan_summary,
|
|
385
|
+
) = package_metadata(repo, ref, paths)
|
|
386
|
+
|
|
387
|
+
add_build("cargo", "language-package-build", cargo_manifests)
|
|
388
|
+
add_build("swiftpm", "language-package-build", swift_manifests)
|
|
389
|
+
add_build("xcodegen", "project-generator", xcodegen_manifests)
|
|
390
|
+
add_build("xcodebuild", "platform-build", xcode_projects or xcodegen_manifests)
|
|
391
|
+
add_build("gradle", "language-package-build", gradle_manifests)
|
|
392
|
+
add_build("maven", "language-package-build", by_name.get("pom.xml", []))
|
|
393
|
+
add_build("go", "language-package-build", by_name.get("go.mod", []))
|
|
394
|
+
add_build("python-build", "language-package-build", by_name.get("pyproject.toml", []))
|
|
395
|
+
add_build(
|
|
396
|
+
"msbuild",
|
|
397
|
+
"language-package-build",
|
|
398
|
+
[path for path in paths if path.endswith((".csproj", ".sln"))],
|
|
399
|
+
)
|
|
400
|
+
add_build("cmake", "project-generator", by_name.get("CMakeLists.txt", []))
|
|
401
|
+
add_build("nextjs", "framework-compiler", package_manifests if "next" in dependencies else [])
|
|
402
|
+
add_build("vite", "framework-bundler", package_manifests if "vite" in dependencies else [])
|
|
403
|
+
add_build("webpack", "framework-bundler", package_manifests if "webpack" in dependencies else [])
|
|
404
|
+
add_build("turborepo", "workspace-coordinator", by_name.get("turbo.json", []))
|
|
405
|
+
add_build("nx", "workspace-coordinator", by_name.get("nx.json", []))
|
|
406
|
+
add_build("pnpm", "package-manager", by_name.get("pnpm-lock.yaml", []))
|
|
407
|
+
add_build("yarn", "package-manager", by_name.get("yarn.lock", []))
|
|
408
|
+
add_build("npm", "package-manager", by_name.get("package-lock.json", []))
|
|
409
|
+
add_build(
|
|
410
|
+
"node-scripts",
|
|
411
|
+
"task-orchestration",
|
|
412
|
+
node_script_manifests,
|
|
413
|
+
)
|
|
414
|
+
add_build("make", "task-orchestration", by_name.get("Makefile", []))
|
|
415
|
+
add_build("shell-build", "task-orchestration", by_name.get("build.sh", []))
|
|
416
|
+
add_build("docker", "container-package", dockerfiles)
|
|
417
|
+
add_build("terraform", "infrastructure", terraform_files)
|
|
418
|
+
add_build(
|
|
419
|
+
"github-actions",
|
|
420
|
+
"ci-release-automation",
|
|
421
|
+
[path for path in paths if path.startswith(".github/workflows/")],
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
root_cargo_workspace = []
|
|
425
|
+
cargo_scanned = sorted(
|
|
426
|
+
cargo_manifests, key=lambda item: (item.count("/"), item)
|
|
427
|
+
)[:MANIFEST_CONTENT_SCAN_LIMIT]
|
|
428
|
+
for path in cargo_scanned:
|
|
429
|
+
content = read_text_at_ref(repo, ref, path) or ""
|
|
430
|
+
if "[workspace]" in content:
|
|
431
|
+
root_cargo_workspace.append(path)
|
|
432
|
+
workspace_evidence = [
|
|
433
|
+
*root_cargo_workspace,
|
|
434
|
+
*by_name.get("pnpm-workspace.yaml", []),
|
|
435
|
+
*node_workspace_manifests,
|
|
436
|
+
*by_name.get("turbo.json", []),
|
|
437
|
+
*by_name.get("nx.json", []),
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
layout_names = {
|
|
441
|
+
"apps",
|
|
442
|
+
"clients",
|
|
443
|
+
"crates",
|
|
444
|
+
"daemon",
|
|
445
|
+
"deploy",
|
|
446
|
+
"infrastructure",
|
|
447
|
+
"libs",
|
|
448
|
+
"modules",
|
|
449
|
+
"packages",
|
|
450
|
+
"plugins",
|
|
451
|
+
"services",
|
|
452
|
+
"Sources",
|
|
453
|
+
"src",
|
|
454
|
+
"tools",
|
|
455
|
+
"workers",
|
|
456
|
+
}
|
|
457
|
+
top_level = {path.split("/", 1)[0] for path in paths}
|
|
458
|
+
layout_roots = sorted(top_level & layout_names)
|
|
459
|
+
|
|
460
|
+
application_signals: list[dict[str, Any]] = []
|
|
461
|
+
|
|
462
|
+
def add_application(
|
|
463
|
+
name: str,
|
|
464
|
+
evidence: list[str],
|
|
465
|
+
*,
|
|
466
|
+
confidence: str,
|
|
467
|
+
interpretation: str,
|
|
468
|
+
) -> None:
|
|
469
|
+
if evidence:
|
|
470
|
+
application_signals.append(
|
|
471
|
+
{
|
|
472
|
+
"name": name,
|
|
473
|
+
"confidence": confidence,
|
|
474
|
+
"evidence": limited_evidence(evidence),
|
|
475
|
+
"interpretation": interpretation,
|
|
476
|
+
}
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
swift_evidence = language_paths.get("Swift", [])
|
|
480
|
+
rust_evidence = language_paths.get("Rust", [])
|
|
481
|
+
android_manifests = by_name.get("AndroidManifest.xml", [])
|
|
482
|
+
web_frameworks = dependencies & {
|
|
483
|
+
"@angular/core",
|
|
484
|
+
"next",
|
|
485
|
+
"nuxt",
|
|
486
|
+
"react",
|
|
487
|
+
"remix",
|
|
488
|
+
"svelte",
|
|
489
|
+
"vue",
|
|
490
|
+
}
|
|
491
|
+
runtime_roots = [
|
|
492
|
+
root for root in ("daemon", "services", "workers") if root in top_level
|
|
493
|
+
]
|
|
494
|
+
runtime_evidence = [
|
|
495
|
+
path
|
|
496
|
+
for path in paths
|
|
497
|
+
if any(path == root or path.startswith(f"{root}/") for root in runtime_roots)
|
|
498
|
+
and Path(path).name
|
|
499
|
+
in {
|
|
500
|
+
"Cargo.toml",
|
|
501
|
+
"Dockerfile",
|
|
502
|
+
"go.mod",
|
|
503
|
+
"main.py",
|
|
504
|
+
"main.rs",
|
|
505
|
+
"package.json",
|
|
506
|
+
"pyproject.toml",
|
|
507
|
+
}
|
|
508
|
+
]
|
|
509
|
+
rust_component_evidence = [
|
|
510
|
+
path for path in paths if path.endswith(("/src/main.rs", "/src/lib.rs"))
|
|
511
|
+
]
|
|
512
|
+
plugin_evidence = [
|
|
513
|
+
path
|
|
514
|
+
for path in paths
|
|
515
|
+
if path.endswith(".claude-plugin/plugin.json")
|
|
516
|
+
or path == ".claude-plugin/plugin.json"
|
|
517
|
+
or path.endswith("/manifest.json") and "extension" in path.lower()
|
|
518
|
+
]
|
|
519
|
+
agent_overlay_evidence = [
|
|
520
|
+
path
|
|
521
|
+
for path in paths
|
|
522
|
+
if path.startswith((".claude/agents/", ".codex/skills/"))
|
|
523
|
+
or path.endswith("/SKILL.md")
|
|
524
|
+
]
|
|
525
|
+
data_ml_evidence = [
|
|
526
|
+
path
|
|
527
|
+
for path in paths
|
|
528
|
+
if path.endswith(".ipynb")
|
|
529
|
+
or path.startswith(("models/", "training/", "evals/", "notebooks/"))
|
|
530
|
+
]
|
|
531
|
+
infra_evidence = [
|
|
532
|
+
*terraform_files,
|
|
533
|
+
*[path for path in paths if path.startswith(("infrastructure/", "deploy/", "charts/"))],
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
add_application(
|
|
537
|
+
"apple-native",
|
|
538
|
+
[*xcode_projects, *xcodegen_manifests, *swift_evidence[:5]]
|
|
539
|
+
if swift_evidence and (xcode_projects or xcodegen_manifests)
|
|
540
|
+
else [],
|
|
541
|
+
confidence="high",
|
|
542
|
+
interpretation="Native Apple source and project/build configuration are present.",
|
|
543
|
+
)
|
|
544
|
+
add_application(
|
|
545
|
+
"android-native",
|
|
546
|
+
[*android_manifests, *gradle_manifests] if android_manifests else [],
|
|
547
|
+
confidence="high" if gradle_manifests else "medium",
|
|
548
|
+
interpretation="Android manifests and Gradle project evidence are present.",
|
|
549
|
+
)
|
|
550
|
+
add_application(
|
|
551
|
+
"web-application",
|
|
552
|
+
package_manifests if web_frameworks else [],
|
|
553
|
+
confidence="medium",
|
|
554
|
+
interpretation=f"Web framework dependencies detected: {', '.join(sorted(web_frameworks))}.",
|
|
555
|
+
)
|
|
556
|
+
add_application(
|
|
557
|
+
"service-or-daemon",
|
|
558
|
+
runtime_evidence or runtime_roots,
|
|
559
|
+
confidence="medium",
|
|
560
|
+
interpretation="Long-lived runtime layout is present; confirm independent deployment from release configuration.",
|
|
561
|
+
)
|
|
562
|
+
add_application(
|
|
563
|
+
"rust-cli-or-library-components",
|
|
564
|
+
[*cargo_manifests, *rust_component_evidence] if rust_evidence and rust_component_evidence else [],
|
|
565
|
+
confidence="medium",
|
|
566
|
+
interpretation="Rust binary and/or library component entrypoints are present.",
|
|
567
|
+
)
|
|
568
|
+
add_application(
|
|
569
|
+
"plugin-or-extension",
|
|
570
|
+
plugin_evidence,
|
|
571
|
+
confidence="high",
|
|
572
|
+
interpretation="A host-discovered plugin or extension manifest is tracked.",
|
|
573
|
+
)
|
|
574
|
+
add_application(
|
|
575
|
+
"agent-tooling-overlay",
|
|
576
|
+
agent_overlay_evidence,
|
|
577
|
+
confidence="high",
|
|
578
|
+
interpretation="Repository-local agent configuration exists; this is tooling, not product architecture.",
|
|
579
|
+
)
|
|
580
|
+
add_application(
|
|
581
|
+
"data-or-ml",
|
|
582
|
+
data_ml_evidence,
|
|
583
|
+
confidence="medium",
|
|
584
|
+
interpretation="Notebook, model, training, or evaluation paths are present.",
|
|
585
|
+
)
|
|
586
|
+
add_application(
|
|
587
|
+
"infrastructure",
|
|
588
|
+
infra_evidence,
|
|
589
|
+
confidence="medium",
|
|
590
|
+
interpretation="Infrastructure or deployment definitions are tracked.",
|
|
591
|
+
)
|
|
592
|
+
if swift_evidence and rust_evidence and (xcode_projects or xcodegen_manifests) and runtime_roots:
|
|
593
|
+
add_application(
|
|
594
|
+
"mixed-native-product",
|
|
595
|
+
[*xcodegen_manifests, *xcode_projects, *cargo_manifests, *runtime_roots],
|
|
596
|
+
confidence="high",
|
|
597
|
+
interpretation="Native Apple application and Rust runtime components coexist in one repository.",
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
composition_signals: list[dict[str, Any]] = []
|
|
601
|
+
|
|
602
|
+
def add_composition(name: str, evidence: list[str], interpretation: str) -> None:
|
|
603
|
+
if evidence:
|
|
604
|
+
composition_signals.append(
|
|
605
|
+
{
|
|
606
|
+
"name": name,
|
|
607
|
+
"evidence": limited_evidence(evidence),
|
|
608
|
+
"interpretation": interpretation,
|
|
609
|
+
}
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
package_roots = [root for root in ("packages", "crates", "modules", "libs") if root in top_level]
|
|
613
|
+
app_roots = [root for root in ("apps", "clients") if root in top_level]
|
|
614
|
+
add_composition(
|
|
615
|
+
"workspace",
|
|
616
|
+
workspace_evidence,
|
|
617
|
+
"One or more build tools coordinate multiple packages in this checkout.",
|
|
618
|
+
)
|
|
619
|
+
add_composition(
|
|
620
|
+
"package-family-layout",
|
|
621
|
+
package_roots,
|
|
622
|
+
"Top-level package/module collection exists; confirm enforceable package boundaries.",
|
|
623
|
+
)
|
|
624
|
+
add_composition(
|
|
625
|
+
"multi-app-layout",
|
|
626
|
+
app_roots,
|
|
627
|
+
"Top-level application/client collection exists; confirm the number of runnable applications.",
|
|
628
|
+
)
|
|
629
|
+
add_composition(
|
|
630
|
+
"runtime-layout",
|
|
631
|
+
runtime_roots,
|
|
632
|
+
"Service, worker, or daemon roots exist; confirm deployment independence.",
|
|
633
|
+
)
|
|
634
|
+
substantial_languages = [
|
|
635
|
+
item["name"] for item in languages if item["name"] not in {"Shell", "Terraform"}
|
|
636
|
+
]
|
|
637
|
+
add_composition(
|
|
638
|
+
"mixed-language",
|
|
639
|
+
[sample for name in substantial_languages for sample in language_paths[name][:1]]
|
|
640
|
+
if len(substantial_languages) > 1
|
|
641
|
+
else [],
|
|
642
|
+
f"Multiple implementation languages are tracked: {', '.join(substantial_languages)}.",
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
"source_ref": ref,
|
|
647
|
+
"inference_policy": (
|
|
648
|
+
"Signals are evidence, not a final application, topology, deployment, or ownership decision."
|
|
649
|
+
),
|
|
650
|
+
"languages": languages,
|
|
651
|
+
"build_systems": build_systems,
|
|
652
|
+
"application_signals": application_signals,
|
|
653
|
+
"composition_signals": composition_signals,
|
|
654
|
+
"layout_roots": layout_roots,
|
|
655
|
+
"scan_summary": {
|
|
656
|
+
"tracked_paths": len(paths),
|
|
657
|
+
**scan_summary,
|
|
658
|
+
"cargo_manifests_found": len(cargo_manifests),
|
|
659
|
+
"cargo_manifests_scanned": len(cargo_scanned),
|
|
660
|
+
"cargo_manifest_scan_truncated": len(cargo_manifests) > len(cargo_scanned),
|
|
661
|
+
},
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def process_snapshot() -> list[tuple[int, str]]:
|
|
666
|
+
result = subprocess.run(
|
|
667
|
+
["ps", "-axo", "pid=,command="],
|
|
668
|
+
text=True,
|
|
669
|
+
stdout=subprocess.PIPE,
|
|
670
|
+
stderr=subprocess.PIPE,
|
|
671
|
+
check=False,
|
|
672
|
+
)
|
|
673
|
+
if result.returncode != 0:
|
|
674
|
+
return []
|
|
675
|
+
processes: list[tuple[int, str]] = []
|
|
676
|
+
for line in result.stdout.splitlines():
|
|
677
|
+
line = line.strip()
|
|
678
|
+
if not line:
|
|
679
|
+
continue
|
|
680
|
+
pid_text, _, command = line.partition(" ")
|
|
681
|
+
if pid_text.isdigit():
|
|
682
|
+
processes.append((int(pid_text), command.strip()))
|
|
683
|
+
return processes
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def matches_artifact_pattern(name: str, patterns: tuple[str, ...]) -> bool:
|
|
687
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def discover_artifact_paths(repo: Path, patterns: tuple[str, ...]) -> list[Path]:
|
|
691
|
+
"""Find artifact roots recursively without counting descendants twice."""
|
|
692
|
+
discovered: list[Path] = []
|
|
693
|
+
for current_root, directories, _files in os.walk(repo, followlinks=False):
|
|
694
|
+
current = Path(current_root)
|
|
695
|
+
retained: list[str] = []
|
|
696
|
+
for name in sorted(directories):
|
|
697
|
+
candidate = current / name
|
|
698
|
+
if (
|
|
699
|
+
candidate.is_symlink()
|
|
700
|
+
or name in DISCOVERY_PRUNE_NAMES
|
|
701
|
+
or (candidate / "pyvenv.cfg").is_file()
|
|
702
|
+
):
|
|
703
|
+
continue
|
|
704
|
+
if matches_artifact_pattern(name, patterns):
|
|
705
|
+
discovered.append(candidate)
|
|
706
|
+
continue
|
|
707
|
+
retained.append(name)
|
|
708
|
+
directories[:] = retained
|
|
709
|
+
return sorted(discovered, key=lambda path: path.relative_to(repo).as_posix())
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def command_references_path(command: str, path: Path) -> bool:
|
|
713
|
+
target = str(path)
|
|
714
|
+
start = 0
|
|
715
|
+
while True:
|
|
716
|
+
index = command.find(target, start)
|
|
717
|
+
if index < 0:
|
|
718
|
+
return False
|
|
719
|
+
end = index + len(target)
|
|
720
|
+
before = command[index - 1] if index else " "
|
|
721
|
+
after = command[end] if end < len(command) else " "
|
|
722
|
+
if before in " \t\"'=:([" and after in " \t\"'/)]":
|
|
723
|
+
return True
|
|
724
|
+
start = index + 1
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def command_artifact_paths(
|
|
728
|
+
repo: Path, command: str, patterns: tuple[str, ...]
|
|
729
|
+
) -> set[Path]:
|
|
730
|
+
"""Extract artifact-root prefixes under repo from a process command."""
|
|
731
|
+
try:
|
|
732
|
+
tokens = shlex.split(command)
|
|
733
|
+
except ValueError:
|
|
734
|
+
tokens = command.split()
|
|
735
|
+
repo_prefix = f"{repo}{os.sep}"
|
|
736
|
+
roots: set[Path] = set()
|
|
737
|
+
for token in tokens:
|
|
738
|
+
index = token.find(repo_prefix)
|
|
739
|
+
if index < 0:
|
|
740
|
+
continue
|
|
741
|
+
path_text = token[index:].rstrip("\"'`,;:)]}")
|
|
742
|
+
try:
|
|
743
|
+
relative = Path(path_text).relative_to(repo)
|
|
744
|
+
except ValueError:
|
|
745
|
+
continue
|
|
746
|
+
current = repo
|
|
747
|
+
for part in relative.parts:
|
|
748
|
+
current /= part
|
|
749
|
+
if matches_artifact_pattern(part, patterns):
|
|
750
|
+
roots.add(current)
|
|
751
|
+
break
|
|
752
|
+
return roots
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def active_missing_artifacts(
|
|
756
|
+
repo: Path,
|
|
757
|
+
patterns: tuple[str, ...],
|
|
758
|
+
processes: list[tuple[int, str]],
|
|
759
|
+
) -> list[dict[str, Any]]:
|
|
760
|
+
refs: dict[str, list[dict[str, Any]]] = {}
|
|
761
|
+
for pid, command in processes:
|
|
762
|
+
for path in command_artifact_paths(repo, command, patterns):
|
|
763
|
+
if path.exists():
|
|
764
|
+
continue
|
|
765
|
+
relative = path.relative_to(repo).as_posix()
|
|
766
|
+
refs.setdefault(relative, []).append(
|
|
767
|
+
{"pid": pid, "command": command[:300]}
|
|
768
|
+
)
|
|
769
|
+
return [
|
|
770
|
+
{
|
|
771
|
+
"path": relative,
|
|
772
|
+
"exists": False,
|
|
773
|
+
"disposition": "active-missing-artifact",
|
|
774
|
+
"active_process_refs": process_refs,
|
|
775
|
+
}
|
|
776
|
+
for relative, process_refs in sorted(refs.items())
|
|
777
|
+
]
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def directory_stats(path: Path) -> tuple[int, int, int, float]:
|
|
781
|
+
allocated_bytes = 0
|
|
782
|
+
apparent_bytes = 0
|
|
783
|
+
file_count = 0
|
|
784
|
+
latest_mtime = path.stat().st_mtime
|
|
785
|
+
for current_root, directories, files in os.walk(path, followlinks=False):
|
|
786
|
+
directories[:] = [
|
|
787
|
+
name for name in directories if not (Path(current_root) / name).is_symlink()
|
|
788
|
+
]
|
|
789
|
+
for name in files:
|
|
790
|
+
candidate = Path(current_root) / name
|
|
791
|
+
try:
|
|
792
|
+
stat = candidate.stat(follow_symlinks=False)
|
|
793
|
+
except OSError:
|
|
794
|
+
continue
|
|
795
|
+
apparent_bytes += stat.st_size
|
|
796
|
+
allocated_bytes += getattr(stat, "st_blocks", 0) * 512
|
|
797
|
+
file_count += 1
|
|
798
|
+
latest_mtime = max(latest_mtime, stat.st_mtime)
|
|
799
|
+
return allocated_bytes, apparent_bytes, file_count, latest_mtime
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def release_artifact_evidence(repo: Path, path: Path) -> list[str]:
|
|
803
|
+
evidence: list[str] = []
|
|
804
|
+
release_container = (
|
|
805
|
+
path.name.lower() in RELEASE_CONTAINER_NAMES
|
|
806
|
+
or path.name.lower().endswith(RELEASE_ARTIFACT_SUFFIXES)
|
|
807
|
+
or path.name.lower().endswith(RELEASE_BUNDLE_SUFFIXES)
|
|
808
|
+
)
|
|
809
|
+
for current_root, directories, files in os.walk(path, followlinks=False):
|
|
810
|
+
current = Path(current_root)
|
|
811
|
+
directories[:] = [
|
|
812
|
+
name
|
|
813
|
+
for name in directories
|
|
814
|
+
if not (current / name).is_symlink()
|
|
815
|
+
]
|
|
816
|
+
for name in [*directories, *files]:
|
|
817
|
+
lower_name = name.lower()
|
|
818
|
+
high_confidence = lower_name.endswith(RELEASE_ARTIFACT_SUFFIXES)
|
|
819
|
+
bundle_in_release_container = (
|
|
820
|
+
release_container and lower_name.endswith(RELEASE_BUNDLE_SUFFIXES)
|
|
821
|
+
)
|
|
822
|
+
if not high_confidence and not bundle_in_release_container:
|
|
823
|
+
continue
|
|
824
|
+
evidence.append((current / name).relative_to(repo).as_posix())
|
|
825
|
+
if len(evidence) >= RELEASE_EVIDENCE_LIMIT:
|
|
826
|
+
return evidence
|
|
827
|
+
return evidence
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def artifact_inventory(
|
|
831
|
+
repo: Path,
|
|
832
|
+
patterns: tuple[str, ...],
|
|
833
|
+
protected: set[str],
|
|
834
|
+
stale_days: float,
|
|
835
|
+
*,
|
|
836
|
+
use_default_protections: bool = True,
|
|
837
|
+
) -> dict[str, Any]:
|
|
838
|
+
processes = process_snapshot()
|
|
839
|
+
now = time.time()
|
|
840
|
+
paths = discover_artifact_paths(repo, patterns)
|
|
841
|
+
artifacts: list[dict[str, Any]] = []
|
|
842
|
+
for path in paths:
|
|
843
|
+
relative = path.relative_to(repo).as_posix()
|
|
844
|
+
allocated_bytes, apparent_bytes, file_count, latest_mtime = directory_stats(path)
|
|
845
|
+
active_refs = [
|
|
846
|
+
{"pid": pid, "command": command[:300]}
|
|
847
|
+
for pid, command in processes
|
|
848
|
+
if command_references_path(command, path)
|
|
849
|
+
]
|
|
850
|
+
ignored = run_git(repo, "check-ignore", "-q", "--", relative, check=False).returncode == 0
|
|
851
|
+
age_days = max(0.0, (now - latest_mtime) / 86_400)
|
|
852
|
+
default_protected = use_default_protections and relative in DEFAULT_PROTECTED_ARTIFACTS
|
|
853
|
+
is_protected = default_protected or relative in protected or path.name in protected
|
|
854
|
+
release_evidence = release_artifact_evidence(repo, path)
|
|
855
|
+
contains_release_artifact = bool(release_evidence)
|
|
856
|
+
stale = age_days >= stale_days
|
|
857
|
+
cleanup_candidate = (
|
|
858
|
+
ignored
|
|
859
|
+
and stale
|
|
860
|
+
and not is_protected
|
|
861
|
+
and not active_refs
|
|
862
|
+
and not contains_release_artifact
|
|
863
|
+
)
|
|
864
|
+
if is_protected:
|
|
865
|
+
disposition = "protected"
|
|
866
|
+
elif active_refs:
|
|
867
|
+
disposition = "active"
|
|
868
|
+
elif contains_release_artifact:
|
|
869
|
+
disposition = "release-artifact"
|
|
870
|
+
elif not ignored:
|
|
871
|
+
disposition = "review-tracked-or-unignored"
|
|
872
|
+
elif not stale:
|
|
873
|
+
disposition = "recent-cache"
|
|
874
|
+
else:
|
|
875
|
+
disposition = "cleanup-candidate"
|
|
876
|
+
artifacts.append(
|
|
877
|
+
{
|
|
878
|
+
"path": relative,
|
|
879
|
+
"bytes": allocated_bytes,
|
|
880
|
+
"allocated_bytes": allocated_bytes,
|
|
881
|
+
"apparent_bytes": apparent_bytes,
|
|
882
|
+
"file_count": file_count,
|
|
883
|
+
"latest_mtime": latest_mtime,
|
|
884
|
+
"age_days": round(age_days, 2),
|
|
885
|
+
"ignored_by_git": ignored,
|
|
886
|
+
"protected": is_protected,
|
|
887
|
+
"default_protected": default_protected,
|
|
888
|
+
"active_process_refs": active_refs,
|
|
889
|
+
"contains_release_artifact": contains_release_artifact,
|
|
890
|
+
"release_artifact_evidence": release_evidence,
|
|
891
|
+
"stale": stale,
|
|
892
|
+
"cleanup_candidate": cleanup_candidate,
|
|
893
|
+
"disposition": disposition,
|
|
894
|
+
}
|
|
895
|
+
)
|
|
896
|
+
return {
|
|
897
|
+
"patterns": list(patterns),
|
|
898
|
+
"default_protected": (
|
|
899
|
+
sorted(DEFAULT_PROTECTED_ARTIFACTS) if use_default_protections else []
|
|
900
|
+
),
|
|
901
|
+
"protected": sorted(set(protected) | (
|
|
902
|
+
set(DEFAULT_PROTECTED_ARTIFACTS) if use_default_protections else set()
|
|
903
|
+
)),
|
|
904
|
+
"stale_days": stale_days,
|
|
905
|
+
"total_bytes": sum(item["bytes"] for item in artifacts),
|
|
906
|
+
"total_apparent_bytes": sum(item["apparent_bytes"] for item in artifacts),
|
|
907
|
+
"cleanup_candidate_bytes": sum(
|
|
908
|
+
item["bytes"] for item in artifacts if item["cleanup_candidate"]
|
|
909
|
+
),
|
|
910
|
+
"cleanup_candidate_apparent_bytes": sum(
|
|
911
|
+
item["apparent_bytes"] for item in artifacts if item["cleanup_candidate"]
|
|
912
|
+
),
|
|
913
|
+
"active_missing_artifacts": active_missing_artifacts(repo, patterns, processes),
|
|
914
|
+
"artifacts": artifacts,
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def tree_entries(repo: Path, ref: str, prefix: str | None = None) -> dict[str, dict[str, str]]:
|
|
919
|
+
args = ["ls-tree", "-r", "-z", ref]
|
|
920
|
+
normalized_prefix = prefix.strip("/") if prefix else None
|
|
921
|
+
if normalized_prefix:
|
|
922
|
+
args.extend(["--", normalized_prefix])
|
|
923
|
+
output = run_git(repo, *args).stdout
|
|
924
|
+
entries: dict[str, dict[str, str]] = {}
|
|
925
|
+
for record in output.split("\0"):
|
|
926
|
+
if not record:
|
|
927
|
+
continue
|
|
928
|
+
metadata, path = record.split("\t", 1)
|
|
929
|
+
mode, kind, object_id = metadata.split(" ", 2)
|
|
930
|
+
if normalized_prefix:
|
|
931
|
+
prefix_with_slash = f"{normalized_prefix}/"
|
|
932
|
+
if path.startswith(prefix_with_slash):
|
|
933
|
+
path = path.removeprefix(prefix_with_slash)
|
|
934
|
+
entries[path] = {"mode": mode, "kind": kind, "object": object_id}
|
|
935
|
+
return entries
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def compare_source_tree(
|
|
939
|
+
repo: Path,
|
|
940
|
+
base: str,
|
|
941
|
+
source_repo: Path,
|
|
942
|
+
source_ref: str,
|
|
943
|
+
target_prefix: str,
|
|
944
|
+
) -> dict[str, Any]:
|
|
945
|
+
source_root = resolve_root(source_repo)
|
|
946
|
+
source_head = run_git(source_root, "rev-parse", source_ref).stdout.strip()
|
|
947
|
+
source_entries = tree_entries(source_root, source_ref)
|
|
948
|
+
target_entries = tree_entries(repo, base, target_prefix)
|
|
949
|
+
source_paths = set(source_entries)
|
|
950
|
+
target_paths = set(target_entries)
|
|
951
|
+
common = source_paths & target_paths
|
|
952
|
+
changed = sorted(path for path in common if source_entries[path] != target_entries[path])
|
|
953
|
+
source_only = sorted(source_paths - target_paths)
|
|
954
|
+
target_only = sorted(target_paths - source_paths)
|
|
955
|
+
source_head_known = ref_exists(repo, source_head)
|
|
956
|
+
source_head_ancestor = source_head_known and is_ancestor(repo, source_head, base)
|
|
957
|
+
return {
|
|
958
|
+
"source_repo": str(source_root),
|
|
959
|
+
"source_ref": source_ref,
|
|
960
|
+
"source_head": source_head,
|
|
961
|
+
"target_ref": base,
|
|
962
|
+
"target_prefix": target_prefix.strip("/"),
|
|
963
|
+
"source_tracked_paths": len(source_paths),
|
|
964
|
+
"target_tracked_paths": len(target_paths),
|
|
965
|
+
"matching_paths": len(common) - len(changed),
|
|
966
|
+
"changed_paths": changed,
|
|
967
|
+
"source_only_paths": source_only,
|
|
968
|
+
"target_only_paths": target_only,
|
|
969
|
+
"exact_tree_match": bool(source_entries) and source_entries == target_entries,
|
|
970
|
+
"source_head_known_to_target_repo": source_head_known,
|
|
971
|
+
"source_head_ancestor_of_target_ref": source_head_ancestor,
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def audit(
|
|
976
|
+
repo: Path,
|
|
977
|
+
base: str = "main",
|
|
978
|
+
*,
|
|
979
|
+
include_artifacts: bool = False,
|
|
980
|
+
artifact_patterns: tuple[str, ...] = DEFAULT_ARTIFACT_PATTERNS,
|
|
981
|
+
protected_artifacts: set[str] | None = None,
|
|
982
|
+
use_default_artifact_protections: bool = True,
|
|
983
|
+
stale_days: float = 7,
|
|
984
|
+
compare_repo: Path | None = None,
|
|
985
|
+
compare_prefix: str | None = None,
|
|
986
|
+
compare_ref: str = "HEAD",
|
|
987
|
+
) -> dict[str, Any]:
|
|
988
|
+
root = resolve_root(repo)
|
|
989
|
+
scan_head_start = run_git(root, "rev-parse", "HEAD", check=False).stdout.strip() or None
|
|
990
|
+
base_exists = ref_exists(root, base)
|
|
991
|
+
worktrees = parse_worktrees(root)
|
|
992
|
+
branches = branch_inventory(root, base, worktrees) if base_exists else []
|
|
993
|
+
current_branch = run_git(root, "branch", "--show-current").stdout.strip() or None
|
|
994
|
+
base_upstream_result = run_git(root, "rev-parse", "--abbrev-ref", f"{base}@{{upstream}}", check=False)
|
|
995
|
+
base_upstream = base_upstream_result.stdout.strip() if base_upstream_result.returncode == 0 else None
|
|
996
|
+
upstream_state = None
|
|
997
|
+
if base_upstream and ref_exists(root, base_upstream):
|
|
998
|
+
behind, ahead = ahead_behind(root, base_upstream, base)
|
|
999
|
+
upstream_state = {"ref": base_upstream, "behind": behind, "ahead": ahead}
|
|
1000
|
+
candidates = [
|
|
1001
|
+
branch["name"]
|
|
1002
|
+
for branch in branches
|
|
1003
|
+
if branch["name"] != base and not branch.get("merged_into_base", False)
|
|
1004
|
+
]
|
|
1005
|
+
removable = [
|
|
1006
|
+
branch["name"]
|
|
1007
|
+
for branch in branches
|
|
1008
|
+
if branch["name"] != base and branch.get("merged_into_base", False)
|
|
1009
|
+
]
|
|
1010
|
+
report: dict[str, Any] = {
|
|
1011
|
+
"schema_version": 5,
|
|
1012
|
+
"repo_root": str(root),
|
|
1013
|
+
"base": base,
|
|
1014
|
+
"base_exists": base_exists,
|
|
1015
|
+
"base_head": run_git(root, "rev-parse", base).stdout.strip() if base_exists else None,
|
|
1016
|
+
"base_upstream": upstream_state,
|
|
1017
|
+
"current_branch": current_branch,
|
|
1018
|
+
"canonical_dirty_paths": parse_status(root),
|
|
1019
|
+
"operations_in_progress": operation_state(root),
|
|
1020
|
+
"worktrees": worktrees,
|
|
1021
|
+
"missing_worktrees": [
|
|
1022
|
+
str(worktree["worktree"])
|
|
1023
|
+
for worktree in worktrees
|
|
1024
|
+
if not worktree.get("git_available", False)
|
|
1025
|
+
],
|
|
1026
|
+
"branches": branches,
|
|
1027
|
+
"stashes": stash_inventory(root),
|
|
1028
|
+
"unmerged_candidates": candidates,
|
|
1029
|
+
"merged_branch_candidates": removable,
|
|
1030
|
+
"archive_tags": run_git(root, "tag", "-l", "archive/pre-closeout-*").stdout.splitlines(),
|
|
1031
|
+
"structure": tracked_structure(root),
|
|
1032
|
+
"profile_signals": detect_profile_signals(root, base if base_exists else "HEAD"),
|
|
1033
|
+
}
|
|
1034
|
+
if include_artifacts:
|
|
1035
|
+
report["artifacts"] = artifact_inventory(
|
|
1036
|
+
root,
|
|
1037
|
+
artifact_patterns,
|
|
1038
|
+
protected_artifacts or set(),
|
|
1039
|
+
stale_days,
|
|
1040
|
+
use_default_protections=use_default_artifact_protections,
|
|
1041
|
+
)
|
|
1042
|
+
if compare_repo is not None or compare_prefix is not None:
|
|
1043
|
+
if compare_repo is None or not compare_prefix:
|
|
1044
|
+
raise ValueError("--compare-repo and --compare-prefix must be used together")
|
|
1045
|
+
report["source_comparison"] = compare_source_tree(
|
|
1046
|
+
root,
|
|
1047
|
+
base,
|
|
1048
|
+
compare_repo,
|
|
1049
|
+
compare_ref,
|
|
1050
|
+
compare_prefix,
|
|
1051
|
+
)
|
|
1052
|
+
scan_head_end = run_git(root, "rev-parse", "HEAD", check=False).stdout.strip() or None
|
|
1053
|
+
report["scan_head"] = {
|
|
1054
|
+
"start": scan_head_start,
|
|
1055
|
+
"end": scan_head_end,
|
|
1056
|
+
"moved": bool(scan_head_start and scan_head_end and scan_head_start != scan_head_end),
|
|
1057
|
+
}
|
|
1058
|
+
return report
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def render_text(report: dict[str, Any]) -> str:
|
|
1062
|
+
upstream = report.get("base_upstream")
|
|
1063
|
+
upstream_text = "none"
|
|
1064
|
+
if upstream:
|
|
1065
|
+
upstream_text = f"{upstream['ref']} ahead={upstream['ahead']} behind={upstream['behind']}"
|
|
1066
|
+
lines = [
|
|
1067
|
+
f"repo: {report['repo_root']}",
|
|
1068
|
+
f"base: {report['base']} {report['base_head'] or 'missing'}",
|
|
1069
|
+
f"upstream: {upstream_text}",
|
|
1070
|
+
f"canonical dirty paths: {len(report['canonical_dirty_paths'])}",
|
|
1071
|
+
f"worktrees: {len(report['worktrees'])}",
|
|
1072
|
+
f"missing worktrees: {', '.join(report['missing_worktrees']) or 'none'}",
|
|
1073
|
+
f"local branches: {len(report['branches'])}",
|
|
1074
|
+
f"unmerged candidates: {', '.join(report['unmerged_candidates']) or 'none'}",
|
|
1075
|
+
f"merged branch candidates: {', '.join(report['merged_branch_candidates']) or 'none'}",
|
|
1076
|
+
f"stashes: {len(report['stashes'])}",
|
|
1077
|
+
f"operations in progress: {', '.join(report['operations_in_progress']) or 'none'}",
|
|
1078
|
+
f"archive tags: {len(report['archive_tags'])}",
|
|
1079
|
+
f"tracked files: {report['structure']['tracked_file_count']}",
|
|
1080
|
+
]
|
|
1081
|
+
scan_head = report.get("scan_head") or {}
|
|
1082
|
+
if scan_head.get("moved"):
|
|
1083
|
+
lines.append(
|
|
1084
|
+
f"HEAD MOVED DURING SCAN: {scan_head['start']} -> {scan_head['end']} "
|
|
1085
|
+
"— every count in this report is a snapshot of a tree that changed under it"
|
|
1086
|
+
)
|
|
1087
|
+
elif scan_head.get("start"):
|
|
1088
|
+
lines.append(f"scan head: {scan_head['start']} (stable)")
|
|
1089
|
+
profile = report["profile_signals"]
|
|
1090
|
+
lines.extend(
|
|
1091
|
+
[
|
|
1092
|
+
"languages: "
|
|
1093
|
+
+ (", ".join(item["name"] for item in profile["languages"]) or "none"),
|
|
1094
|
+
"build systems: "
|
|
1095
|
+
+ (", ".join(item["name"] for item in profile["build_systems"]) or "none"),
|
|
1096
|
+
"application signals: "
|
|
1097
|
+
+ (", ".join(item["name"] for item in profile["application_signals"]) or "none"),
|
|
1098
|
+
"composition signals: "
|
|
1099
|
+
+ (", ".join(item["name"] for item in profile["composition_signals"]) or "none"),
|
|
1100
|
+
]
|
|
1101
|
+
)
|
|
1102
|
+
artifacts = report.get("artifacts")
|
|
1103
|
+
if artifacts:
|
|
1104
|
+
candidates = [
|
|
1105
|
+
item["path"] for item in artifacts["artifacts"] if item["cleanup_candidate"]
|
|
1106
|
+
]
|
|
1107
|
+
lines.extend(
|
|
1108
|
+
[
|
|
1109
|
+
f"artifact directories: {len(artifacts['artifacts'])}",
|
|
1110
|
+
f"artifact bytes: {artifacts['total_bytes']}",
|
|
1111
|
+
f"artifact cleanup candidates: {', '.join(candidates) or 'none'}",
|
|
1112
|
+
"active missing artifacts: "
|
|
1113
|
+
+ (
|
|
1114
|
+
", ".join(item["path"] for item in artifacts["active_missing_artifacts"])
|
|
1115
|
+
or "none"
|
|
1116
|
+
),
|
|
1117
|
+
]
|
|
1118
|
+
)
|
|
1119
|
+
comparison = report.get("source_comparison")
|
|
1120
|
+
if comparison:
|
|
1121
|
+
lines.extend(
|
|
1122
|
+
[
|
|
1123
|
+
f"source comparison: {comparison['source_repo']} -> {comparison['target_prefix']}",
|
|
1124
|
+
f"source head ancestor of {comparison['target_ref']}: "
|
|
1125
|
+
f"{comparison['source_head_ancestor_of_target_ref']}",
|
|
1126
|
+
f"source tree exact match: {comparison['exact_tree_match']}",
|
|
1127
|
+
f"source/changed/target-only paths: "
|
|
1128
|
+
f"{len(comparison['source_only_paths'])}/"
|
|
1129
|
+
f"{len(comparison['changed_paths'])}/"
|
|
1130
|
+
f"{len(comparison['target_only_paths'])}",
|
|
1131
|
+
]
|
|
1132
|
+
)
|
|
1133
|
+
return "\n".join(lines)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def main() -> int:
|
|
1137
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1138
|
+
parser.add_argument("--repo", default=".", help="Path inside the Git repository")
|
|
1139
|
+
parser.add_argument("--base", default="main", help="Local integration branch")
|
|
1140
|
+
parser.add_argument(
|
|
1141
|
+
"--artifacts",
|
|
1142
|
+
action="store_true",
|
|
1143
|
+
help="Inventory common build, cache, and generated directories recursively",
|
|
1144
|
+
)
|
|
1145
|
+
parser.add_argument(
|
|
1146
|
+
"--artifact-pattern",
|
|
1147
|
+
action="append",
|
|
1148
|
+
default=[],
|
|
1149
|
+
help="Additional artifact-directory glob (repeatable)",
|
|
1150
|
+
)
|
|
1151
|
+
parser.add_argument(
|
|
1152
|
+
"--protect-artifact",
|
|
1153
|
+
action="append",
|
|
1154
|
+
default=[],
|
|
1155
|
+
help="Artifact path or name that must not be a cleanup candidate (repeatable)",
|
|
1156
|
+
)
|
|
1157
|
+
parser.add_argument(
|
|
1158
|
+
"--no-default-artifact-protection",
|
|
1159
|
+
action="store_true",
|
|
1160
|
+
help="Do not automatically protect canonical top-level build and build-rust roots",
|
|
1161
|
+
)
|
|
1162
|
+
parser.add_argument(
|
|
1163
|
+
"--stale-days",
|
|
1164
|
+
type=float,
|
|
1165
|
+
default=7,
|
|
1166
|
+
help="Minimum age for an ignored artifact cleanup candidate (default: 7)",
|
|
1167
|
+
)
|
|
1168
|
+
parser.add_argument("--compare-repo", help="Sibling/source Git repository to compare")
|
|
1169
|
+
parser.add_argument(
|
|
1170
|
+
"--compare-prefix",
|
|
1171
|
+
help="Path inside --base that should represent --compare-repo",
|
|
1172
|
+
)
|
|
1173
|
+
parser.add_argument("--compare-ref", default="HEAD", help="Source repository ref")
|
|
1174
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON")
|
|
1175
|
+
args = parser.parse_args()
|
|
1176
|
+
try:
|
|
1177
|
+
patterns = tuple(dict.fromkeys((*DEFAULT_ARTIFACT_PATTERNS, *args.artifact_pattern)))
|
|
1178
|
+
report = audit(
|
|
1179
|
+
Path(args.repo),
|
|
1180
|
+
base=args.base,
|
|
1181
|
+
include_artifacts=args.artifacts,
|
|
1182
|
+
artifact_patterns=patterns,
|
|
1183
|
+
protected_artifacts=set(args.protect_artifact),
|
|
1184
|
+
use_default_artifact_protections=not args.no_default_artifact_protection,
|
|
1185
|
+
stale_days=args.stale_days,
|
|
1186
|
+
compare_repo=Path(args.compare_repo) if args.compare_repo else None,
|
|
1187
|
+
compare_prefix=args.compare_prefix,
|
|
1188
|
+
compare_ref=args.compare_ref,
|
|
1189
|
+
)
|
|
1190
|
+
except (RuntimeError, OSError, ValueError) as error:
|
|
1191
|
+
print(f"repository maintenance audit failed: {error}", file=sys.stderr)
|
|
1192
|
+
return 2
|
|
1193
|
+
print(json.dumps(report, indent=2, sort_keys=True) if args.json else render_text(report))
|
|
1194
|
+
return 0
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
if __name__ == "__main__":
|
|
1198
|
+
raise SystemExit(main())
|