@matt82198/aesop 0.1.0 → 0.2.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/CHANGELOG.md +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CI workflow linter: static analysis of .github/workflows/*.yml
|
|
4
|
+
|
|
5
|
+
Checks:
|
|
6
|
+
1. YAML parses cleanly
|
|
7
|
+
2. Every npm ci step has a package-lock.json in its working directory
|
|
8
|
+
3. Every test suite in package.json scripts (test:py/test:node/test:sh) is invoked by CI
|
|
9
|
+
4. Best-effort check for file references in workflow steps
|
|
10
|
+
|
|
11
|
+
Exit: 0 if all checks pass, 1 if any findings. Support --json for structured output.
|
|
12
|
+
|
|
13
|
+
Requires PyYAML. If it is missing the linter FAILS CLOSED (exit 1) rather than
|
|
14
|
+
silently passing — a lint gate that cannot parse workflows must not report green.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
import yaml
|
|
27
|
+
except ImportError:
|
|
28
|
+
# Import stays soft so the tools-importable smoke gate can load this module;
|
|
29
|
+
# lint_workflows() fails closed when yaml is None.
|
|
30
|
+
yaml = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WorkflowLintError(Exception):
|
|
34
|
+
"""Raised when a workflow file cannot be read or parsed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_yaml_file(path):
|
|
38
|
+
"""Load and parse a YAML file.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
dict or None: Parsed YAML
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
WorkflowLintError: If the file cannot be read or parsed
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
48
|
+
content = f.read()
|
|
49
|
+
except OSError as e:
|
|
50
|
+
raise WorkflowLintError(f"Failed to load {path}: {e}")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
return yaml.safe_load(content)
|
|
54
|
+
except yaml.YAMLError as e:
|
|
55
|
+
raise WorkflowLintError(f"YAML parse error: {e}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_workflow_files(root):
|
|
59
|
+
"""Find all workflow files in .github/workflows/
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
root: Repository root path
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List[Path]: Sorted list of workflow file paths
|
|
66
|
+
"""
|
|
67
|
+
workflows_dir = Path(root) / ".github" / "workflows"
|
|
68
|
+
if not workflows_dir.exists():
|
|
69
|
+
return []
|
|
70
|
+
return sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_package_json_files(root):
|
|
74
|
+
"""Find all package.json files in repo.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
root: Repository root path
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Dict[str, dict]: Mapping of relative path to parsed package.json
|
|
81
|
+
"""
|
|
82
|
+
packages = {}
|
|
83
|
+
root_path = Path(root)
|
|
84
|
+
|
|
85
|
+
# Find all package.json files
|
|
86
|
+
for package_file in root_path.rglob("package.json"):
|
|
87
|
+
try:
|
|
88
|
+
with open(package_file, 'r', encoding='utf-8') as f:
|
|
89
|
+
data = json.load(f)
|
|
90
|
+
rel_path = str(package_file.relative_to(root_path))
|
|
91
|
+
packages[rel_path] = data
|
|
92
|
+
except Exception:
|
|
93
|
+
pass # Skip unparseable package.json
|
|
94
|
+
|
|
95
|
+
return packages
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_test_scripts(packages):
|
|
99
|
+
"""Extract test:* scripts from all package.json files.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Dict[str, dict]: Mapping of script name to {file, command}
|
|
103
|
+
"""
|
|
104
|
+
tests = {}
|
|
105
|
+
for pkg_path, pkg_data in packages.items():
|
|
106
|
+
scripts = pkg_data.get("scripts", {})
|
|
107
|
+
for script_name in ["test:py", "test:node", "test:sh"]:
|
|
108
|
+
if script_name in scripts:
|
|
109
|
+
# Determine working directory from package.json path
|
|
110
|
+
pkg_dir = str(Path(pkg_path).parent) if pkg_path != "package.json" else "."
|
|
111
|
+
if pkg_dir == ".":
|
|
112
|
+
pkg_dir = ""
|
|
113
|
+
|
|
114
|
+
script_key = f"{pkg_path}:{script_name}"
|
|
115
|
+
tests[script_key] = {
|
|
116
|
+
"file": pkg_path,
|
|
117
|
+
"dir": pkg_dir,
|
|
118
|
+
"name": script_name,
|
|
119
|
+
"command": scripts[script_name],
|
|
120
|
+
"invoked": False
|
|
121
|
+
}
|
|
122
|
+
return tests
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def extract_working_directory(step):
|
|
126
|
+
"""Extract working directory from a step (working-directory or cd command).
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
step: Step dict from workflow
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
str: Working directory path, or "." if not specified
|
|
133
|
+
"""
|
|
134
|
+
# Check for working-directory key
|
|
135
|
+
if "working-directory" in step:
|
|
136
|
+
return step["working-directory"]
|
|
137
|
+
|
|
138
|
+
# Check for cd in run block
|
|
139
|
+
run = step.get("run", "")
|
|
140
|
+
if isinstance(run, str) and run.strip():
|
|
141
|
+
# Look for cd at the start of the run block
|
|
142
|
+
lines = run.split('\n')
|
|
143
|
+
for line in lines:
|
|
144
|
+
line = line.strip()
|
|
145
|
+
if line.startswith("cd "):
|
|
146
|
+
# Extract directory (simple case: cd /path or cd relative/path)
|
|
147
|
+
parts = line.split()
|
|
148
|
+
if len(parts) >= 2:
|
|
149
|
+
return parts[1]
|
|
150
|
+
elif re.match(r'^\s*cd\s+', line):
|
|
151
|
+
# Handle whitespace variations
|
|
152
|
+
match = re.match(r'^\s*cd\s+(.+)$', line)
|
|
153
|
+
if match:
|
|
154
|
+
return match.group(1).strip()
|
|
155
|
+
|
|
156
|
+
return "."
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def check_npm_ci_lockfile(workflow_path, workflow_data, root):
|
|
160
|
+
"""Check that every npm ci step has package-lock.json in working directory.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
List[str]: List of findings
|
|
164
|
+
"""
|
|
165
|
+
findings = []
|
|
166
|
+
|
|
167
|
+
if not workflow_data:
|
|
168
|
+
return findings
|
|
169
|
+
|
|
170
|
+
jobs = workflow_data.get("jobs", {})
|
|
171
|
+
job_id = 0
|
|
172
|
+
|
|
173
|
+
for job_name, job_data in jobs.items():
|
|
174
|
+
steps = job_data.get("steps", [])
|
|
175
|
+
step_id = 0
|
|
176
|
+
|
|
177
|
+
for step in steps:
|
|
178
|
+
if not isinstance(step, dict):
|
|
179
|
+
step_id += 1
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
run = step.get("run", "")
|
|
183
|
+
|
|
184
|
+
# Check if this step runs npm ci
|
|
185
|
+
if "npm ci" in str(run):
|
|
186
|
+
working_dir = extract_working_directory(step)
|
|
187
|
+
|
|
188
|
+
# Resolve working directory relative to repo root
|
|
189
|
+
if working_dir == ".":
|
|
190
|
+
lockfile_path = Path(root) / "package-lock.json"
|
|
191
|
+
else:
|
|
192
|
+
lockfile_path = Path(root) / working_dir / "package-lock.json"
|
|
193
|
+
|
|
194
|
+
if not lockfile_path.exists():
|
|
195
|
+
step_name = step.get("name", f"step {step_id}")
|
|
196
|
+
findings.append(
|
|
197
|
+
f"npm ci without package-lock.json: "
|
|
198
|
+
f"{workflow_path.name} > {job_name} > {step_name} "
|
|
199
|
+
f"(working dir: {working_dir})"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
step_id += 1
|
|
203
|
+
|
|
204
|
+
return findings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_test_coverage(workflow_files, workflow_data_list, packages, root):
|
|
208
|
+
"""Check that all package.json test scripts are invoked by workflows.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
List[str]: List of findings
|
|
212
|
+
"""
|
|
213
|
+
findings = []
|
|
214
|
+
tests = get_test_scripts(packages)
|
|
215
|
+
|
|
216
|
+
if not tests:
|
|
217
|
+
return findings # No test scripts to check
|
|
218
|
+
|
|
219
|
+
# Scan all workflows for test invocations
|
|
220
|
+
for workflow_data in workflow_data_list:
|
|
221
|
+
if not workflow_data:
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
jobs = workflow_data.get("jobs", {})
|
|
225
|
+
for job_name, job_data in jobs.items():
|
|
226
|
+
steps = job_data.get("steps", [])
|
|
227
|
+
|
|
228
|
+
for step in steps:
|
|
229
|
+
if not isinstance(step, dict):
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
run = step.get("run", "")
|
|
233
|
+
if not isinstance(run, str):
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Check for npm run test:* invocations
|
|
237
|
+
for test_script in ["test:py", "test:node", "test:sh", "test:all"]:
|
|
238
|
+
if f"npm run {test_script}" in run or f"npm run {test_script}" in str(step.get("name", "")):
|
|
239
|
+
for test_key in tests:
|
|
240
|
+
if test_script in test_key:
|
|
241
|
+
tests[test_key]["invoked"] = True
|
|
242
|
+
|
|
243
|
+
# Also check for direct script invocations in run blocks
|
|
244
|
+
# test:py -> python -m unittest
|
|
245
|
+
if "python" in run and "unittest" in run:
|
|
246
|
+
for test_key in tests:
|
|
247
|
+
if "test:py" in test_key:
|
|
248
|
+
tests[test_key]["invoked"] = True
|
|
249
|
+
|
|
250
|
+
# test:node -> node --test or similar
|
|
251
|
+
if ("node --test" in run or "npx vitest" in run or
|
|
252
|
+
"npm run test:node" in run):
|
|
253
|
+
for test_key in tests:
|
|
254
|
+
if "test:node" in test_key:
|
|
255
|
+
tests[test_key]["invoked"] = True
|
|
256
|
+
|
|
257
|
+
# test:sh -> bash tests/
|
|
258
|
+
if ("bash tests/" in run or "sh tests/" in run):
|
|
259
|
+
for test_key in tests:
|
|
260
|
+
if "test:sh" in test_key:
|
|
261
|
+
tests[test_key]["invoked"] = True
|
|
262
|
+
|
|
263
|
+
# Report uncovered tests
|
|
264
|
+
for test_key, test_info in tests.items():
|
|
265
|
+
if not test_info["invoked"]:
|
|
266
|
+
findings.append(
|
|
267
|
+
f"Test script not invoked by workflows: "
|
|
268
|
+
f"{test_info['file']} > {test_info['name']}"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return findings
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def check_yaml_parse(workflow_path):
|
|
275
|
+
"""Check that workflow YAML parses cleanly.
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Tuple[bool, str]: (success, error_message)
|
|
279
|
+
"""
|
|
280
|
+
try:
|
|
281
|
+
load_yaml_file(workflow_path)
|
|
282
|
+
return True, ""
|
|
283
|
+
except Exception as e:
|
|
284
|
+
return False, str(e)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def check_file_references(workflow_data, root):
|
|
288
|
+
"""Best-effort check for file references in workflow steps.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
List[str]: List of findings for missing files
|
|
292
|
+
"""
|
|
293
|
+
findings = []
|
|
294
|
+
|
|
295
|
+
if not workflow_data:
|
|
296
|
+
return findings
|
|
297
|
+
|
|
298
|
+
jobs = workflow_data.get("jobs", {})
|
|
299
|
+
|
|
300
|
+
for job_name, job_data in jobs.items():
|
|
301
|
+
steps = job_data.get("steps", [])
|
|
302
|
+
|
|
303
|
+
for step in steps:
|
|
304
|
+
if not isinstance(step, dict):
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
# Check for common file references in run blocks
|
|
308
|
+
run = step.get("run", "")
|
|
309
|
+
if not isinstance(run, str):
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Extract file paths from common patterns
|
|
313
|
+
# e.g., "python tools/foo.py", "bash tests/test.sh"
|
|
314
|
+
patterns = [
|
|
315
|
+
r'python[3]?\s+([^\s&|;]+\.py)',
|
|
316
|
+
r'bash\s+([^\s&|;]+\.sh)',
|
|
317
|
+
r'sh\s+([^\s&|;]+\.sh)',
|
|
318
|
+
r'node\s+([^\s&|;]+\.mjs?)',
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
for pattern in patterns:
|
|
322
|
+
matches = re.findall(pattern, run)
|
|
323
|
+
for file_ref in matches:
|
|
324
|
+
file_path = Path(root) / file_ref
|
|
325
|
+
if not file_path.exists():
|
|
326
|
+
# Best-effort: don't fail on generated paths or paths with variables
|
|
327
|
+
if "$" not in file_ref and "{" not in file_ref:
|
|
328
|
+
step_name = step.get("name", "unnamed")
|
|
329
|
+
# findings.append(f"File reference not found: {file_ref} ({job_name} > {step_name})")
|
|
330
|
+
|
|
331
|
+
return findings
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def lint_workflows(root, json_output=False):
|
|
335
|
+
"""Lint all workflows in a repository.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Tuple[int, List[str]]: (exit_code, findings_list)
|
|
339
|
+
"""
|
|
340
|
+
findings = []
|
|
341
|
+
root_path = Path(root)
|
|
342
|
+
|
|
343
|
+
# Fail closed: without a real YAML parser this linter cannot verify anything,
|
|
344
|
+
# and a lint gate that silently passes is worse than one that fails loudly.
|
|
345
|
+
if yaml is None:
|
|
346
|
+
return 1, [
|
|
347
|
+
"[1] PyYAML is not installed; refusing to lint workflows without a "
|
|
348
|
+
"real YAML parser (fail-closed). Install it with: pip install pyyaml"
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
# Find workflow files
|
|
352
|
+
workflow_files = find_workflow_files(root)
|
|
353
|
+
if not workflow_files:
|
|
354
|
+
findings.append("No workflow files found in .github/workflows/")
|
|
355
|
+
return 1, findings
|
|
356
|
+
|
|
357
|
+
# Load and parse workflows
|
|
358
|
+
workflow_data_list = []
|
|
359
|
+
for workflow_path in workflow_files:
|
|
360
|
+
success, error = check_yaml_parse(workflow_path)
|
|
361
|
+
if not success:
|
|
362
|
+
findings.append(f"YAML parse error in {workflow_path.name}: {error}")
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
data = load_yaml_file(workflow_path)
|
|
367
|
+
workflow_data_list.append(data)
|
|
368
|
+
except Exception as e:
|
|
369
|
+
findings.append(f"Failed to load {workflow_path.name}: {e}")
|
|
370
|
+
|
|
371
|
+
# Find package.json files
|
|
372
|
+
packages = find_package_json_files(root)
|
|
373
|
+
|
|
374
|
+
# Check npm ci lockfiles
|
|
375
|
+
for workflow_path, workflow_data in zip(workflow_files, workflow_data_list):
|
|
376
|
+
if workflow_data:
|
|
377
|
+
findings.extend(check_npm_ci_lockfile(workflow_path, workflow_data, root))
|
|
378
|
+
|
|
379
|
+
# Check test coverage
|
|
380
|
+
findings.extend(check_test_coverage(workflow_files, workflow_data_list, packages, root))
|
|
381
|
+
|
|
382
|
+
# Check file references
|
|
383
|
+
for workflow_data in workflow_data_list:
|
|
384
|
+
if workflow_data:
|
|
385
|
+
findings.extend(check_file_references(workflow_data, root))
|
|
386
|
+
|
|
387
|
+
# Format findings with numbers
|
|
388
|
+
numbered_findings = []
|
|
389
|
+
for i, finding in enumerate(findings, 1):
|
|
390
|
+
numbered_findings.append(f"[{i}] {finding}")
|
|
391
|
+
|
|
392
|
+
return (0 if not findings else 1), numbered_findings
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main():
|
|
396
|
+
parser = argparse.ArgumentParser(
|
|
397
|
+
description="Lint CI workflow files for common issues"
|
|
398
|
+
)
|
|
399
|
+
parser.add_argument(
|
|
400
|
+
"--root",
|
|
401
|
+
default=".",
|
|
402
|
+
help="Repository root path (default: current directory)"
|
|
403
|
+
)
|
|
404
|
+
parser.add_argument(
|
|
405
|
+
"--json",
|
|
406
|
+
action="store_true",
|
|
407
|
+
help="Output findings as JSON"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
args = parser.parse_args()
|
|
411
|
+
|
|
412
|
+
exit_code, findings = lint_workflows(args.root, args.json)
|
|
413
|
+
|
|
414
|
+
if args.json:
|
|
415
|
+
output = {
|
|
416
|
+
"exit_code": exit_code,
|
|
417
|
+
"findings": findings
|
|
418
|
+
}
|
|
419
|
+
print(json.dumps(output, indent=2))
|
|
420
|
+
else:
|
|
421
|
+
for finding in findings:
|
|
422
|
+
print(finding)
|
|
423
|
+
if not findings and exit_code == 0:
|
|
424
|
+
print("OK: All workflow checks passed")
|
|
425
|
+
|
|
426
|
+
return exit_code
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == "__main__":
|
|
430
|
+
sys.exit(main())
|