claude-company 0.1.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/.claude/agents/architect.md +69 -0
- package/.claude/agents/auditor.md +44 -0
- package/.claude/agents/developer.md +86 -0
- package/.claude/agents/devops-engineer.md +38 -0
- package/.claude/agents/docs-librarian.md +36 -0
- package/.claude/agents/ideation-strategist.md +54 -0
- package/.claude/agents/product-manager.md +54 -0
- package/.claude/agents/qa-engineer.md +58 -0
- package/.claude/agents/security-reviewer.md +44 -0
- package/.claude/agents/tech-lead.md +80 -0
- package/.claude/hooks/_common.py +209 -0
- package/.claude/hooks/gate_stamp.py +77 -0
- package/.claude/hooks/gates_detect.py +387 -0
- package/.claude/hooks/guard_commit.py +123 -0
- package/.claude/hooks/guard_frozen.py +135 -0
- package/.claude/hooks/guard_spec.py +95 -0
- package/.claude/hooks/guard_tests.py +124 -0
- package/.claude/hooks/no_slop.py +134 -0
- package/.claude/hooks/session_start.py +63 -0
- package/.claude/hooks/stop_gate.py +59 -0
- package/.claude/settings.json +70 -0
- package/.claude/skills/autopilot/SKILL.md +65 -0
- package/.claude/skills/brainstorm/SKILL.md +61 -0
- package/.claude/skills/company-init/SKILL.md +51 -0
- package/.claude/skills/cr/SKILL.md +44 -0
- package/.claude/skills/feature/SKILL.md +42 -0
- package/.claude/skills/gates/SKILL.md +33 -0
- package/.claude/skills/onboard/SKILL.md +52 -0
- package/.claude/skills/orchestrator/SKILL.md +84 -0
- package/.claude/skills/standup/SKILL.md +38 -0
- package/.mcp.json +1 -0
- package/LICENSE +21 -0
- package/ORCHESTRATOR.md +191 -0
- package/README.md +236 -0
- package/bin/claude-company.js +112 -0
- package/company/EXTENDING.md +58 -0
- package/company/GATES.md +59 -0
- package/company/GIT.md +119 -0
- package/company/IDEATION.md +102 -0
- package/company/LOOPS.md +108 -0
- package/company/METHOD.md +151 -0
- package/company/frozen-surfaces.json +17 -0
- package/company/gates.config +5 -0
- package/company/run-gates.sh +158 -0
- package/company/state/DECISIONS.md +9 -0
- package/company/state/RESUME.md +28 -0
- package/company/state/STATUS.md +30 -0
- package/company/state/WORRIES.md +12 -0
- package/company/state/adherence.log +1 -0
- package/company/templates/BRIEF-TEMPLATE.md +69 -0
- package/company/templates/CR-TEMPLATE.md +28 -0
- package/company/templates/MODULE-TEMPLATE.md +23 -0
- package/company/templates/OPTIONS-TEMPLATE.md +42 -0
- package/company/templates/REPORT-TEMPLATE.md +35 -0
- package/company/templates/SPEC-TEMPLATE.md +69 -0
- package/docs/customizing.md +88 -0
- package/docs/getting-started.md +99 -0
- package/docs/how-it-works.md +153 -0
- package/install +4 -0
- package/install.sh +331 -0
- package/lib/install-tui.js +1600 -0
- package/package.json +47 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared helpers for the claude-company enforcement hooks.
|
|
3
|
+
|
|
4
|
+
Python 3 stdlib only. Everything here fails open: on any internal error the
|
|
5
|
+
callers should treat the result as "allow" rather than bricking the session.
|
|
6
|
+
The one deliberate exception is git-tracked uncertainty in the immutability
|
|
7
|
+
checks, which fail safe (treat as tracked) per the frozen-surface contract.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import datetime
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
# Documented anti-accident salt (not anti-adversary). Bump the suffix only on a
|
|
18
|
+
# real stamp-format change.
|
|
19
|
+
CHECKSUM_SALT = "claude-company.gates.v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_stdin_json():
|
|
23
|
+
"""Parse the hook JSON payload from stdin. None on any failure."""
|
|
24
|
+
try:
|
|
25
|
+
return json.load(sys.stdin)
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def project_root(payload):
|
|
31
|
+
"""Resolve the project root: CLAUDE_PROJECT_DIR, else stdin cwd, else cwd."""
|
|
32
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
33
|
+
if root:
|
|
34
|
+
return root
|
|
35
|
+
if isinstance(payload, dict):
|
|
36
|
+
cwd = payload.get("cwd")
|
|
37
|
+
if cwd:
|
|
38
|
+
return cwd
|
|
39
|
+
return os.getcwd()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def iso_now():
|
|
43
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime(
|
|
44
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def adherence_log(root, hook_name, action, target, reason):
|
|
49
|
+
"""Append one line to company/state/adherence.log. Never raises."""
|
|
50
|
+
try:
|
|
51
|
+
state_dir = os.path.join(root, "company", "state")
|
|
52
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
53
|
+
target = (target or "").replace("\n", " ")
|
|
54
|
+
reason = (reason or "").replace("\n", " ")
|
|
55
|
+
line = "{} | {} | {} | {} | {}\n".format(
|
|
56
|
+
iso_now(), hook_name, action, target, reason
|
|
57
|
+
)
|
|
58
|
+
with open(os.path.join(state_dir, "adherence.log"), "a") as f:
|
|
59
|
+
f.write(line)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def block(root, hook_name, target, short_reason, message):
|
|
65
|
+
"""Log a BLOCK line, print the human message to stderr, exit 2."""
|
|
66
|
+
adherence_log(root, hook_name, "BLOCK", target, short_reason)
|
|
67
|
+
print(message, file=sys.stderr)
|
|
68
|
+
sys.exit(2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def log_bypass(root, hook_name, target, short_reason):
|
|
72
|
+
adherence_log(root, hook_name, "BYPASS", target, short_reason)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_json_file(path):
|
|
76
|
+
try:
|
|
77
|
+
with open(path) as f:
|
|
78
|
+
return json.load(f)
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def active_task(root):
|
|
84
|
+
return read_json_file(
|
|
85
|
+
os.path.join(root, "company", "state", "active-task.json")
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def gates_config(root):
|
|
90
|
+
return read_json_file(os.path.join(root, "company", "gates.config"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rel_path(root, file_path):
|
|
94
|
+
"""Project-relative, forward-slash path for file_path under root.
|
|
95
|
+
|
|
96
|
+
Falls back to the input (minus a leading slash) when file_path is outside
|
|
97
|
+
the project tree.
|
|
98
|
+
"""
|
|
99
|
+
if not file_path:
|
|
100
|
+
return ""
|
|
101
|
+
norm = file_path.replace("\\", "/")
|
|
102
|
+
try:
|
|
103
|
+
root_norm = os.path.abspath(root).replace("\\", "/").rstrip("/")
|
|
104
|
+
if norm.startswith("/"):
|
|
105
|
+
candidate = norm
|
|
106
|
+
else:
|
|
107
|
+
candidate = root_norm + "/" + norm
|
|
108
|
+
candidate = os.path.normpath(candidate).replace("\\", "/")
|
|
109
|
+
if candidate == root_norm:
|
|
110
|
+
return ""
|
|
111
|
+
if candidate.startswith(root_norm + "/"):
|
|
112
|
+
return candidate[len(root_norm) + 1:]
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
return norm.lstrip("/")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _git(root, args):
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
["git", "-C", root] + args, capture_output=True, timeout=5
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
return None
|
|
127
|
+
return result.stdout.decode("utf-8", "replace")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def current_branch(root):
|
|
131
|
+
"""Current branch name, or None on git uncertainty."""
|
|
132
|
+
out = _git(root, ["symbolic-ref", "--short", "HEAD"])
|
|
133
|
+
if out is None:
|
|
134
|
+
return None
|
|
135
|
+
return out.strip() or None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def is_git_tracked(file_path):
|
|
139
|
+
"""True if committed/staged in git (shipped, immutable).
|
|
140
|
+
|
|
141
|
+
Returns True on any uncertainty (git missing, not a repo) so immutability
|
|
142
|
+
checks fail safe. Returncode 1 is a real untracked file inside a repo,
|
|
143
|
+
which is the freshly generated artifact we want to leave editable.
|
|
144
|
+
"""
|
|
145
|
+
directory = os.path.dirname(file_path) or "."
|
|
146
|
+
try:
|
|
147
|
+
result = subprocess.run(
|
|
148
|
+
["git", "-C", directory, "ls-files", "--error-unmatch", file_path],
|
|
149
|
+
capture_output=True,
|
|
150
|
+
timeout=5,
|
|
151
|
+
)
|
|
152
|
+
except Exception:
|
|
153
|
+
return True
|
|
154
|
+
return result.returncode != 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def work_hash(root):
|
|
158
|
+
"""Fingerprint the working tree. Fail-open to 'no-git'.
|
|
159
|
+
|
|
160
|
+
company/state/ is excluded from the fingerprint: the stamp and adherence
|
|
161
|
+
log live there and would otherwise self-invalidate the hash the moment they
|
|
162
|
+
are written.
|
|
163
|
+
"""
|
|
164
|
+
exclude = ["--", ".", ":(exclude)company/state"]
|
|
165
|
+
head = _git(root, ["rev-parse", "HEAD"])
|
|
166
|
+
status = _git(root, ["status", "--porcelain"] + exclude)
|
|
167
|
+
diff = _git(root, ["diff"] + exclude)
|
|
168
|
+
cached = _git(root, ["diff", "--cached"] + exclude)
|
|
169
|
+
if head is None and status is None and diff is None and cached is None:
|
|
170
|
+
return "no-git"
|
|
171
|
+
digest = hashlib.sha256()
|
|
172
|
+
for part in (head, status, diff, cached):
|
|
173
|
+
digest.update((part or "").encode("utf-8", "replace"))
|
|
174
|
+
digest.update(b"\x00")
|
|
175
|
+
return digest.hexdigest()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def stamp_checksum(payload_without_checksum):
|
|
179
|
+
"""sha256 of canonical stamp payload plus the salt."""
|
|
180
|
+
canonical = json.dumps(
|
|
181
|
+
payload_without_checksum, sort_keys=True, separators=(",", ":")
|
|
182
|
+
)
|
|
183
|
+
return hashlib.sha256(
|
|
184
|
+
(canonical + CHECKSUM_SALT).encode("utf-8")
|
|
185
|
+
).hexdigest()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def read_stamp(root):
|
|
189
|
+
return read_json_file(
|
|
190
|
+
os.path.join(root, "company", "state", "gates.status")
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_stamp(root):
|
|
195
|
+
"""Return (ok, reason). ok iff the stamp is green, fresh, and valid."""
|
|
196
|
+
stamp = read_stamp(root)
|
|
197
|
+
if stamp is None:
|
|
198
|
+
return False, "no gates.status stamp (gates have not been run)"
|
|
199
|
+
if not isinstance(stamp, dict):
|
|
200
|
+
return False, "gates.status is malformed"
|
|
201
|
+
stored = stamp.get("checksum")
|
|
202
|
+
payload = {k: v for k, v in stamp.items() if k != "checksum"}
|
|
203
|
+
if stored != stamp_checksum(payload):
|
|
204
|
+
return False, "gates.status checksum invalid (stamp edited by hand)"
|
|
205
|
+
if stamp.get("status") != "green":
|
|
206
|
+
return False, "gates are red (last run had failing gates)"
|
|
207
|
+
if stamp.get("work_hash") != work_hash(root):
|
|
208
|
+
return False, "gates.status is stale (work changed since gates ran)"
|
|
209
|
+
return True, "green"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Gate stamp CLI (NOT a hook). Called by the gate runner.
|
|
3
|
+
|
|
4
|
+
python3 gate_stamp.py --results '{"gates":[{"name":"tests","ok":true}]}'
|
|
5
|
+
Compute overall status (green iff every gate ok), the work hash, and a
|
|
6
|
+
checksum, then write company/state/gates.status.
|
|
7
|
+
|
|
8
|
+
python3 gate_stamp.py --check
|
|
9
|
+
Exit 0 if the stamp is green + fresh + valid, else exit 1 with a reason.
|
|
10
|
+
|
|
11
|
+
Project root comes from CLAUDE_PROJECT_DIR, falling back to the cwd.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
import _common as c # noqa: E402
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_root():
|
|
24
|
+
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_stamp(root, results_json):
|
|
28
|
+
data = json.loads(results_json)
|
|
29
|
+
gates = data.get("gates", []) or []
|
|
30
|
+
all_ok = all(bool(g.get("ok")) for g in gates)
|
|
31
|
+
status = "green" if all_ok else "red"
|
|
32
|
+
payload = {
|
|
33
|
+
"status": status,
|
|
34
|
+
"ran_at": c.iso_now(),
|
|
35
|
+
"work_hash": c.work_hash(root),
|
|
36
|
+
"gates": gates,
|
|
37
|
+
}
|
|
38
|
+
payload["checksum"] = c.stamp_checksum(
|
|
39
|
+
{k: v for k, v in payload.items() if k != "checksum"}
|
|
40
|
+
)
|
|
41
|
+
state_dir = os.path.join(root, "company", "state")
|
|
42
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
43
|
+
with open(os.path.join(state_dir, "gates.status"), "w") as f:
|
|
44
|
+
json.dump(payload, f, indent=2)
|
|
45
|
+
f.write("\n")
|
|
46
|
+
return status
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
ap = argparse.ArgumentParser(description="claude-company gate stamp")
|
|
51
|
+
ap.add_argument("--results", help="JSON gate results")
|
|
52
|
+
ap.add_argument("--check", action="store_true", help="verify stamp")
|
|
53
|
+
args = ap.parse_args()
|
|
54
|
+
|
|
55
|
+
root = resolve_root()
|
|
56
|
+
|
|
57
|
+
if args.check:
|
|
58
|
+
ok, reason = c.check_stamp(root)
|
|
59
|
+
print(reason)
|
|
60
|
+
sys.exit(0 if ok else 1)
|
|
61
|
+
|
|
62
|
+
if args.results:
|
|
63
|
+
try:
|
|
64
|
+
status = write_stamp(root, args.results)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
print("gate_stamp: failed to write stamp: {}".format(exc),
|
|
67
|
+
file=sys.stderr)
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
print("wrote gates.status: {}".format(status))
|
|
70
|
+
sys.exit(0)
|
|
71
|
+
|
|
72
|
+
ap.print_help()
|
|
73
|
+
sys.exit(2)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
main()
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""gates_detect.py - sniff a project and propose real mechanical gates.
|
|
3
|
+
|
|
4
|
+
CLI (not a hook): `python3 .claude/hooks/gates_detect.py [--write]`.
|
|
5
|
+
|
|
6
|
+
Detects the project stack (JS/TS, Python, Go, Rust, Makefile) and proposes
|
|
7
|
+
concrete gate commands ordered cheap-to-expensive (lint, typecheck, tests,
|
|
8
|
+
build). Only commands whose tool is actually invocable (shutil.which on the
|
|
9
|
+
binary) are proposed; the rest are reported as "detected_but_missing_tool" and
|
|
10
|
+
skipped on --write.
|
|
11
|
+
|
|
12
|
+
Without --write: prints a human table plus a machine-readable JSON line
|
|
13
|
+
(prefixed `GATES_JSON: `) and leaves company/gates.config untouched.
|
|
14
|
+
|
|
15
|
+
With --write: writes company/gates.config in the existing
|
|
16
|
+
`{"gates": [{"name", "command", "blocking": true}]}` shape, UNLESS the existing
|
|
17
|
+
config already holds a non-placeholder gate (a gate whose command contains
|
|
18
|
+
"CONFIGURE ME" is a placeholder and may be replaced). If no stack is detected
|
|
19
|
+
the config is left untouched either way.
|
|
20
|
+
|
|
21
|
+
Stdlib only. Exits 0 in the normal case.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
import _common as c # noqa: E402
|
|
32
|
+
|
|
33
|
+
# cheap-to-expensive ordering buckets
|
|
34
|
+
ORDER = {"lint": 0, "typecheck": 1, "tests": 2, "build": 3, "other": 4}
|
|
35
|
+
|
|
36
|
+
PLACEHOLDER_MARK = "configure me"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project_dir():
|
|
40
|
+
root = os.environ.get("CLAUDE_PROJECT_DIR")
|
|
41
|
+
if root:
|
|
42
|
+
return root
|
|
43
|
+
return os.getcwd()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_text(path):
|
|
47
|
+
try:
|
|
48
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
49
|
+
return f.read()
|
|
50
|
+
except Exception:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def exists(root, name):
|
|
55
|
+
return os.path.exists(os.path.join(root, name))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def gate(name, command, binary, kind):
|
|
59
|
+
"""Build a proposed-gate record."""
|
|
60
|
+
return {
|
|
61
|
+
"name": name,
|
|
62
|
+
"command": command,
|
|
63
|
+
"binary": binary,
|
|
64
|
+
"kind": kind,
|
|
65
|
+
"order": ORDER.get(kind, ORDER["other"]),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def detect_node(root, gates, stacks):
|
|
70
|
+
pkg_path = os.path.join(root, "package.json")
|
|
71
|
+
if not os.path.exists(pkg_path):
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
pkg = json.loads(read_text(pkg_path))
|
|
75
|
+
except Exception:
|
|
76
|
+
pkg = {}
|
|
77
|
+
if not isinstance(pkg, dict):
|
|
78
|
+
pkg = {}
|
|
79
|
+
stacks.append("package.json")
|
|
80
|
+
|
|
81
|
+
# package manager from lockfiles
|
|
82
|
+
if exists(root, "pnpm-lock.yaml"):
|
|
83
|
+
pm = "pnpm"
|
|
84
|
+
elif exists(root, "yarn.lock"):
|
|
85
|
+
pm = "yarn"
|
|
86
|
+
else:
|
|
87
|
+
pm = "npm"
|
|
88
|
+
|
|
89
|
+
scripts = pkg.get("scripts") or {}
|
|
90
|
+
if not isinstance(scripts, dict):
|
|
91
|
+
scripts = {}
|
|
92
|
+
|
|
93
|
+
def run_cmd(script):
|
|
94
|
+
# `npm test` is idiomatic; other scripts go through `run`.
|
|
95
|
+
if script == "test":
|
|
96
|
+
return "{} test".format(pm)
|
|
97
|
+
return "{} run {}".format(pm, script)
|
|
98
|
+
|
|
99
|
+
if scripts.get("lint"):
|
|
100
|
+
gates.append(gate("lint", run_cmd("lint"), pm, "lint"))
|
|
101
|
+
if scripts.get("typecheck"):
|
|
102
|
+
gates.append(gate("typecheck", run_cmd("typecheck"), pm, "typecheck"))
|
|
103
|
+
else:
|
|
104
|
+
deps = {}
|
|
105
|
+
for key in ("dependencies", "devDependencies"):
|
|
106
|
+
d = pkg.get(key)
|
|
107
|
+
if isinstance(d, dict):
|
|
108
|
+
deps.update(d)
|
|
109
|
+
if "typescript" in deps:
|
|
110
|
+
gates.append(
|
|
111
|
+
gate("typecheck", "tsc --noEmit", "tsc", "typecheck")
|
|
112
|
+
)
|
|
113
|
+
if scripts.get("test"):
|
|
114
|
+
gates.append(gate("tests", run_cmd("test"), pm, "tests"))
|
|
115
|
+
if scripts.get("build"):
|
|
116
|
+
gates.append(gate("build", run_cmd("build"), pm, "build"))
|
|
117
|
+
return pm
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_python(root, gates, stacks):
|
|
121
|
+
pyproject = os.path.join(root, "pyproject.toml")
|
|
122
|
+
pyproject_txt = read_text(pyproject) if os.path.exists(pyproject) else ""
|
|
123
|
+
setup_cfg_txt = (
|
|
124
|
+
read_text(os.path.join(root, "setup.cfg"))
|
|
125
|
+
if exists(root, "setup.cfg")
|
|
126
|
+
else ""
|
|
127
|
+
)
|
|
128
|
+
tox_txt = (
|
|
129
|
+
read_text(os.path.join(root, "tox.ini"))
|
|
130
|
+
if exists(root, "tox.ini")
|
|
131
|
+
else ""
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
test_signals = any(
|
|
135
|
+
exists(root, f)
|
|
136
|
+
for f in ("pyproject.toml", "setup.cfg", "pytest.ini", "conftest.py")
|
|
137
|
+
)
|
|
138
|
+
if not test_signals:
|
|
139
|
+
return
|
|
140
|
+
stacks.append("python")
|
|
141
|
+
|
|
142
|
+
# lint: ruff, then flake8
|
|
143
|
+
ruff_cfg = (
|
|
144
|
+
"[tool.ruff" in pyproject_txt
|
|
145
|
+
or exists(root, "ruff.toml")
|
|
146
|
+
or exists(root, ".ruff.toml")
|
|
147
|
+
)
|
|
148
|
+
flake8_cfg = (
|
|
149
|
+
exists(root, ".flake8")
|
|
150
|
+
or "[flake8]" in setup_cfg_txt
|
|
151
|
+
or "[flake8]" in tox_txt
|
|
152
|
+
)
|
|
153
|
+
if ruff_cfg:
|
|
154
|
+
gates.append(gate("lint", "ruff check .", "ruff", "lint"))
|
|
155
|
+
elif flake8_cfg:
|
|
156
|
+
gates.append(gate("lint", "flake8", "flake8", "lint"))
|
|
157
|
+
|
|
158
|
+
# typecheck: mypy
|
|
159
|
+
mypy_cfg = (
|
|
160
|
+
"[tool.mypy" in pyproject_txt
|
|
161
|
+
or exists(root, "mypy.ini")
|
|
162
|
+
or exists(root, ".mypy.ini")
|
|
163
|
+
or "[mypy]" in setup_cfg_txt
|
|
164
|
+
)
|
|
165
|
+
if mypy_cfg:
|
|
166
|
+
gates.append(gate("typecheck", "mypy .", "mypy", "typecheck"))
|
|
167
|
+
|
|
168
|
+
# tests: pytest
|
|
169
|
+
gates.append(gate("tests", "python3 -m pytest", "python3", "tests"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def detect_go(root, gates, stacks):
|
|
173
|
+
if not exists(root, "go.mod"):
|
|
174
|
+
return
|
|
175
|
+
stacks.append("go.mod")
|
|
176
|
+
gates.append(gate("vet", "go vet ./...", "go", "lint"))
|
|
177
|
+
gates.append(gate("tests", "go test ./...", "go", "tests"))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect_rust(root, gates, stacks):
|
|
181
|
+
if not exists(root, "Cargo.toml"):
|
|
182
|
+
return
|
|
183
|
+
stacks.append("Cargo.toml")
|
|
184
|
+
gates.append(
|
|
185
|
+
gate("clippy", "cargo clippy -- -D warnings", "cargo", "lint")
|
|
186
|
+
)
|
|
187
|
+
gates.append(gate("tests", "cargo test", "cargo", "tests"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def detect_make(root, gates, stacks):
|
|
191
|
+
if not exists(root, "Makefile"):
|
|
192
|
+
return
|
|
193
|
+
txt = read_text(os.path.join(root, "Makefile"))
|
|
194
|
+
targets = set()
|
|
195
|
+
for line in txt.splitlines():
|
|
196
|
+
m = re.match(r"^([A-Za-z0-9_-]+)\s*:", line)
|
|
197
|
+
if m:
|
|
198
|
+
targets.add(m.group(1))
|
|
199
|
+
wanted = [("lint", "lint"), ("gates", "other"), ("test", "tests")]
|
|
200
|
+
found = False
|
|
201
|
+
for target, kind in wanted:
|
|
202
|
+
if target in targets:
|
|
203
|
+
found = True
|
|
204
|
+
name = "tests" if target == "test" else target
|
|
205
|
+
gates.append(
|
|
206
|
+
gate(name, "make {}".format(target), "make", kind)
|
|
207
|
+
)
|
|
208
|
+
if found:
|
|
209
|
+
stacks.append("Makefile")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def dedupe_and_order(gates):
|
|
213
|
+
"""Sort cheap-to-expensive, drop duplicate commands, unique-ify names."""
|
|
214
|
+
ordered = sorted(
|
|
215
|
+
enumerate(gates), key=lambda pair: (pair[1]["order"], pair[0])
|
|
216
|
+
)
|
|
217
|
+
seen_cmd = set()
|
|
218
|
+
seen_name = {}
|
|
219
|
+
out = []
|
|
220
|
+
for _, g in ordered:
|
|
221
|
+
cmd = g["command"]
|
|
222
|
+
if cmd in seen_cmd:
|
|
223
|
+
continue
|
|
224
|
+
seen_cmd.add(cmd)
|
|
225
|
+
name = g["name"]
|
|
226
|
+
if name in seen_name:
|
|
227
|
+
seen_name[name] += 1
|
|
228
|
+
name = "{}-{}".format(name, seen_name[name])
|
|
229
|
+
else:
|
|
230
|
+
seen_name[name] = 1
|
|
231
|
+
entry = dict(g)
|
|
232
|
+
entry["name"] = name
|
|
233
|
+
out.append(entry)
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def split_invocable(gates):
|
|
238
|
+
proposed, skipped = [], []
|
|
239
|
+
for g in gates:
|
|
240
|
+
if shutil.which(g["binary"]):
|
|
241
|
+
proposed.append(g)
|
|
242
|
+
else:
|
|
243
|
+
skipped.append(g)
|
|
244
|
+
return proposed, skipped
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def config_has_real_gates(cfg):
|
|
248
|
+
if not isinstance(cfg, dict):
|
|
249
|
+
return False
|
|
250
|
+
for g in cfg.get("gates") or []:
|
|
251
|
+
if not isinstance(g, dict):
|
|
252
|
+
continue
|
|
253
|
+
cmd = str(g.get("command", ""))
|
|
254
|
+
if cmd and PLACEHOLDER_MARK not in cmd.lower():
|
|
255
|
+
return True
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def to_config_gates(gates):
|
|
260
|
+
return [
|
|
261
|
+
{"name": g["name"], "command": g["command"], "blocking": True}
|
|
262
|
+
for g in gates
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def write_config(root, config_gates):
|
|
267
|
+
path = os.path.join(root, "company", "gates.config")
|
|
268
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
269
|
+
body = {
|
|
270
|
+
"$comment": (
|
|
271
|
+
"Auto-generated by gates_detect.py. Every gate is blocking; "
|
|
272
|
+
"edit freely. See company/GATES.md."
|
|
273
|
+
),
|
|
274
|
+
"gates": config_gates,
|
|
275
|
+
}
|
|
276
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
277
|
+
f.write(json.dumps(body, indent=2))
|
|
278
|
+
f.write("\n")
|
|
279
|
+
return path
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def print_table(proposed, skipped, pm, stacks):
|
|
283
|
+
if stacks:
|
|
284
|
+
print("Detected stack: {}".format(", ".join(stacks)))
|
|
285
|
+
if pm:
|
|
286
|
+
print("Package manager: {}".format(pm))
|
|
287
|
+
print("")
|
|
288
|
+
header = "{:<12} {:<10} {}".format("GATE", "STATUS", "COMMAND")
|
|
289
|
+
print(header)
|
|
290
|
+
print("-" * len(header))
|
|
291
|
+
for g in proposed:
|
|
292
|
+
print("{:<12} {:<10} {}".format(g["name"], "ready", g["command"]))
|
|
293
|
+
for g in skipped:
|
|
294
|
+
print(
|
|
295
|
+
"{:<12} {:<10} {} (missing: {})".format(
|
|
296
|
+
g["name"], "no-tool", g["command"], g["binary"]
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
if not proposed and not skipped:
|
|
300
|
+
print("(no gate candidates)")
|
|
301
|
+
print("")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def emit_json(obj):
|
|
305
|
+
print("GATES_JSON: " + json.dumps(obj, sort_keys=True))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def main(argv):
|
|
309
|
+
write = "--write" in argv[1:]
|
|
310
|
+
root = project_dir()
|
|
311
|
+
|
|
312
|
+
gates = []
|
|
313
|
+
stacks = []
|
|
314
|
+
pm = detect_node(root, gates, stacks)
|
|
315
|
+
detect_python(root, gates, stacks)
|
|
316
|
+
detect_go(root, gates, stacks)
|
|
317
|
+
detect_rust(root, gates, stacks)
|
|
318
|
+
detect_make(root, gates, stacks)
|
|
319
|
+
|
|
320
|
+
gates = dedupe_and_order(gates)
|
|
321
|
+
proposed, skipped = split_invocable(gates)
|
|
322
|
+
|
|
323
|
+
proposed_json = to_config_gates(proposed)
|
|
324
|
+
skipped_json = [
|
|
325
|
+
{
|
|
326
|
+
"name": g["name"],
|
|
327
|
+
"command": g["command"],
|
|
328
|
+
"binary": g["binary"],
|
|
329
|
+
"reason": "detected_but_missing_tool",
|
|
330
|
+
}
|
|
331
|
+
for g in skipped
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
# No stack at all: leave config untouched, report, exit 0.
|
|
335
|
+
if not stacks:
|
|
336
|
+
print("no stack detected - leaving company/gates.config untouched")
|
|
337
|
+
emit_json(
|
|
338
|
+
{
|
|
339
|
+
"stacks": [],
|
|
340
|
+
"package_manager": pm,
|
|
341
|
+
"proposed": [],
|
|
342
|
+
"skipped": [],
|
|
343
|
+
"wrote": False,
|
|
344
|
+
"status": "no_stack",
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
print_table(proposed, skipped, pm, stacks)
|
|
350
|
+
|
|
351
|
+
status = "proposed"
|
|
352
|
+
wrote = False
|
|
353
|
+
if write:
|
|
354
|
+
existing = c.gates_config(root)
|
|
355
|
+
if config_has_real_gates(existing):
|
|
356
|
+
status = "preserved_existing"
|
|
357
|
+
print(
|
|
358
|
+
"company/gates.config already has real gates - preserved, "
|
|
359
|
+
"not overwritten."
|
|
360
|
+
)
|
|
361
|
+
elif not proposed:
|
|
362
|
+
status = "nothing_invocable"
|
|
363
|
+
print(
|
|
364
|
+
"no invocable gate commands on this machine - config left "
|
|
365
|
+
"untouched."
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
path = write_config(root, proposed_json)
|
|
369
|
+
wrote = True
|
|
370
|
+
status = "wrote"
|
|
371
|
+
print("wrote {} gate(s) to {}".format(len(proposed_json), path))
|
|
372
|
+
|
|
373
|
+
emit_json(
|
|
374
|
+
{
|
|
375
|
+
"stacks": stacks,
|
|
376
|
+
"package_manager": pm,
|
|
377
|
+
"proposed": proposed_json,
|
|
378
|
+
"skipped": skipped_json,
|
|
379
|
+
"wrote": wrote,
|
|
380
|
+
"status": status,
|
|
381
|
+
}
|
|
382
|
+
)
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
if __name__ == "__main__":
|
|
387
|
+
sys.exit(main(sys.argv))
|