steersync 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steersync/__init__.py +5 -0
- steersync/__main__.py +6 -0
- steersync/cli.py +768 -0
- steersync/config.py +532 -0
- steersync/errors.py +52 -0
- steersync/gitops.py +550 -0
- steersync/hooks.py +146 -0
- steersync/lint.py +631 -0
- steersync/locking.py +69 -0
- steersync/logging_utils.py +60 -0
- steersync/mcp_server.py +171 -0
- steersync/paths.py +98 -0
- steersync/resources/default_config.yaml +154 -0
- steersync/steering.py +457 -0
- steersync/sync.py +726 -0
- steersync-0.2.0.dist-info/METADATA +342 -0
- steersync-0.2.0.dist-info/RECORD +20 -0
- steersync-0.2.0.dist-info/WHEEL +4 -0
- steersync-0.2.0.dist-info/entry_points.txt +2 -0
- steersync-0.2.0.dist-info/licenses/LICENSE +190 -0
steersync/lint.py
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""Validation of synced content: front matter, size, references, secrets, markdown, JSON.
|
|
2
|
+
|
|
3
|
+
Each collection selects a lint profile. ``steering`` applies every Kiro front matter
|
|
4
|
+
rule, ``skill`` validates ``SKILL.md`` metadata, ``hooks`` and ``agent`` check the JSON
|
|
5
|
+
shape Kiro expects, ``json`` and ``markdown`` check syntax and style, and ``generic``
|
|
6
|
+
only enforces size limits and secret scanning. Profiles are looked up in
|
|
7
|
+
:data:`PROFILES`, so a new kind of content is one function and one table entry.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
15
|
+
from pathlib import Path, PurePosixPath
|
|
16
|
+
from typing import Literal, NamedTuple
|
|
17
|
+
|
|
18
|
+
from steersync.config import LintConfig, LintProfile
|
|
19
|
+
from steersync.steering import (
|
|
20
|
+
FILE_REFERENCE_RE,
|
|
21
|
+
INCLUSION_MODES,
|
|
22
|
+
KNOWN_KEYS,
|
|
23
|
+
FileSpec,
|
|
24
|
+
SteeringDocument,
|
|
25
|
+
discover_files,
|
|
26
|
+
document_from_bytes,
|
|
27
|
+
file_references,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
Severity = Literal["error", "warning"]
|
|
31
|
+
_H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
|
|
32
|
+
SKILL_FILE = "SKILL.md"
|
|
33
|
+
HOOK_TRIGGERS: frozenset[str] = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"SessionStart",
|
|
36
|
+
"Stop",
|
|
37
|
+
"PreToolUse",
|
|
38
|
+
"PostToolUse",
|
|
39
|
+
"UserPromptSubmit",
|
|
40
|
+
"PreTaskExec",
|
|
41
|
+
"PostTaskExec",
|
|
42
|
+
"PostFileCreate",
|
|
43
|
+
"PostFileSave",
|
|
44
|
+
"PostFileDelete",
|
|
45
|
+
"Manual",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LintFinding(NamedTuple):
|
|
51
|
+
path: str
|
|
52
|
+
rule: str
|
|
53
|
+
severity: Severity
|
|
54
|
+
message: str
|
|
55
|
+
line: int | None = None
|
|
56
|
+
|
|
57
|
+
def render(self) -> str:
|
|
58
|
+
location = f"{self.path}:{self.line}" if self.line else self.path
|
|
59
|
+
return f"{location}: {self.severity}: [{self.rule}] {self.message}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LintReport(NamedTuple):
|
|
63
|
+
findings: tuple[LintFinding, ...]
|
|
64
|
+
checked: int
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def errors(self) -> tuple[LintFinding, ...]:
|
|
68
|
+
return tuple(f for f in self.findings if f.severity == "error")
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def warnings(self) -> tuple[LintFinding, ...]:
|
|
72
|
+
return tuple(f for f in self.findings if f.severity == "warning")
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def ok(self) -> bool:
|
|
76
|
+
return not self.errors
|
|
77
|
+
|
|
78
|
+
def to_json(self) -> str:
|
|
79
|
+
return json.dumps(
|
|
80
|
+
{
|
|
81
|
+
"checked": self.checked,
|
|
82
|
+
"errors": len(self.errors),
|
|
83
|
+
"warnings": len(self.warnings),
|
|
84
|
+
"findings": [f._asdict() for f in self.findings],
|
|
85
|
+
},
|
|
86
|
+
indent=2,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _front_matter_findings(doc: SteeringDocument, config: LintConfig) -> list[LintFinding]:
|
|
91
|
+
findings: list[LintFinding] = []
|
|
92
|
+
fm = doc.front_matter
|
|
93
|
+
if fm is None:
|
|
94
|
+
return findings
|
|
95
|
+
if fm.leading_blank:
|
|
96
|
+
findings.append(
|
|
97
|
+
LintFinding(
|
|
98
|
+
doc.relative_path,
|
|
99
|
+
"front-matter-position",
|
|
100
|
+
"error",
|
|
101
|
+
"front matter must start on line 1; Kiro ignores it after a blank line",
|
|
102
|
+
1,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return findings
|
|
106
|
+
if fm.error:
|
|
107
|
+
findings.append(LintFinding(doc.relative_path, "front-matter-yaml", "error", fm.error, 1))
|
|
108
|
+
return findings
|
|
109
|
+
if not fm.present:
|
|
110
|
+
return findings
|
|
111
|
+
meta = fm.metadata
|
|
112
|
+
inclusion = meta.get("inclusion", "always")
|
|
113
|
+
if inclusion not in INCLUSION_MODES:
|
|
114
|
+
findings.append(
|
|
115
|
+
LintFinding(
|
|
116
|
+
doc.relative_path,
|
|
117
|
+
"inclusion-value",
|
|
118
|
+
"error",
|
|
119
|
+
f"inclusion must be one of {', '.join(INCLUSION_MODES)}; got {inclusion!r}",
|
|
120
|
+
2,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
pattern = meta.get("fileMatchPattern")
|
|
124
|
+
if inclusion == "fileMatch":
|
|
125
|
+
if pattern is None:
|
|
126
|
+
findings.append(
|
|
127
|
+
LintFinding(
|
|
128
|
+
doc.relative_path,
|
|
129
|
+
"filematch-pattern-required",
|
|
130
|
+
"error",
|
|
131
|
+
"fileMatch requires fileMatchPattern",
|
|
132
|
+
2,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
elif not _is_pattern(pattern):
|
|
136
|
+
findings.append(
|
|
137
|
+
LintFinding(
|
|
138
|
+
doc.relative_path,
|
|
139
|
+
"filematch-pattern-type",
|
|
140
|
+
"error",
|
|
141
|
+
"fileMatchPattern must be a string or a list of strings",
|
|
142
|
+
2,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
elif pattern is not None:
|
|
146
|
+
findings.append(
|
|
147
|
+
LintFinding(
|
|
148
|
+
doc.relative_path,
|
|
149
|
+
"filematch-pattern-unused",
|
|
150
|
+
"warning",
|
|
151
|
+
"fileMatchPattern is ignored unless inclusion is fileMatch",
|
|
152
|
+
2,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
if inclusion == "auto":
|
|
156
|
+
for key in ("name", "description"):
|
|
157
|
+
value = meta.get(key)
|
|
158
|
+
if not isinstance(value, str) or not value.strip():
|
|
159
|
+
findings.append(
|
|
160
|
+
LintFinding(
|
|
161
|
+
doc.relative_path,
|
|
162
|
+
"auto-requires-metadata",
|
|
163
|
+
"error",
|
|
164
|
+
f"inclusion: auto requires a non empty {key}",
|
|
165
|
+
2,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
allowed = KNOWN_KEYS | set(config.allowed_extra_keys)
|
|
169
|
+
findings.extend(
|
|
170
|
+
LintFinding(
|
|
171
|
+
doc.relative_path,
|
|
172
|
+
"unknown-key",
|
|
173
|
+
"warning",
|
|
174
|
+
f"unknown front matter key {key!r} is ignored by Kiro",
|
|
175
|
+
2,
|
|
176
|
+
)
|
|
177
|
+
for key in meta
|
|
178
|
+
if key not in allowed
|
|
179
|
+
)
|
|
180
|
+
return findings
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _is_pattern(value: object) -> bool:
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
return bool(value.strip())
|
|
186
|
+
return (
|
|
187
|
+
isinstance(value, list)
|
|
188
|
+
and bool(value)
|
|
189
|
+
and all(isinstance(v, str) and v.strip() for v in value)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _secret_findings(doc: SteeringDocument, config: LintConfig) -> list[LintFinding]:
|
|
194
|
+
"""Scan decodable text for the configured forbidden patterns."""
|
|
195
|
+
findings: list[LintFinding] = []
|
|
196
|
+
if doc.text is None:
|
|
197
|
+
return findings
|
|
198
|
+
for item in config.forbidden_patterns:
|
|
199
|
+
for match in re.finditer(item.pattern, doc.text):
|
|
200
|
+
line = doc.text.count("\n", 0, match.start()) + 1
|
|
201
|
+
findings.append(
|
|
202
|
+
LintFinding(doc.relative_path, f"forbidden-{item.id}", "error", item.message, line)
|
|
203
|
+
)
|
|
204
|
+
return findings
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _body_findings(
|
|
208
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
209
|
+
) -> list[LintFinding]:
|
|
210
|
+
findings: list[LintFinding] = []
|
|
211
|
+
if doc.text is None:
|
|
212
|
+
return findings
|
|
213
|
+
body = doc.front_matter.body if doc.front_matter else doc.text
|
|
214
|
+
if config.require_h1 and not _H1_RE.search(body):
|
|
215
|
+
findings.append(
|
|
216
|
+
LintFinding(
|
|
217
|
+
doc.relative_path, "require-h1", "warning", "document has no level one heading"
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
findings.extend(_secret_findings(doc, config))
|
|
221
|
+
if config.file_reference_check and workspace_root is not None:
|
|
222
|
+
for kind, target in file_references(doc.text):
|
|
223
|
+
candidate = (workspace_root / target).resolve()
|
|
224
|
+
if (
|
|
225
|
+
workspace_root.resolve() not in candidate.parents
|
|
226
|
+
and candidate != workspace_root.resolve()
|
|
227
|
+
):
|
|
228
|
+
findings.append(
|
|
229
|
+
LintFinding(
|
|
230
|
+
doc.relative_path,
|
|
231
|
+
"file-reference-escape",
|
|
232
|
+
"error",
|
|
233
|
+
f"#[[{kind}:{target}]] points outside the workspace",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
elif not candidate.exists():
|
|
237
|
+
findings.append(
|
|
238
|
+
LintFinding(
|
|
239
|
+
doc.relative_path,
|
|
240
|
+
"file-reference-missing",
|
|
241
|
+
"warning",
|
|
242
|
+
f"#[[{kind}:{target}]] does not exist in this workspace",
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return findings
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _markdown_findings(doc: SteeringDocument, config: LintConfig) -> list[LintFinding]:
|
|
249
|
+
if not config.pymarkdown.enabled or doc.text is None:
|
|
250
|
+
return []
|
|
251
|
+
from pymarkdown.api import PyMarkdownApi, PyMarkdownApiException # noqa: PLC0415 (slow import)
|
|
252
|
+
|
|
253
|
+
api = PyMarkdownApi().enable_extension_by_identifier("front-matter")
|
|
254
|
+
for rule in config.pymarkdown.disabled_rules:
|
|
255
|
+
api = api.disable_rule_by_identifier(rule)
|
|
256
|
+
masked = FILE_REFERENCE_RE.sub(lambda m: "x" * len(m.group(0)), doc.text)
|
|
257
|
+
try:
|
|
258
|
+
result = api.scan_string(masked)
|
|
259
|
+
except PyMarkdownApiException as exc:
|
|
260
|
+
return [
|
|
261
|
+
LintFinding(
|
|
262
|
+
doc.relative_path, "markdown-scan", "warning", f"markdown scan failed: {exc}"
|
|
263
|
+
)
|
|
264
|
+
]
|
|
265
|
+
return [
|
|
266
|
+
LintFinding(
|
|
267
|
+
doc.relative_path,
|
|
268
|
+
f"markdown-{failure.rule_id}",
|
|
269
|
+
config.pymarkdown.severity,
|
|
270
|
+
failure.rule_description,
|
|
271
|
+
failure.line_number,
|
|
272
|
+
)
|
|
273
|
+
for failure in result.scan_failures
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _size_findings(doc: SteeringDocument, max_file_bytes: int) -> list[LintFinding]:
|
|
278
|
+
if len(doc.raw) <= max_file_bytes:
|
|
279
|
+
return []
|
|
280
|
+
return [
|
|
281
|
+
LintFinding(
|
|
282
|
+
doc.relative_path,
|
|
283
|
+
"max-size",
|
|
284
|
+
"error",
|
|
285
|
+
f"file is {len(doc.raw)} bytes; limit is {max_file_bytes}",
|
|
286
|
+
)
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _text_findings(doc: SteeringDocument) -> list[LintFinding]:
|
|
291
|
+
"""Findings for content that must be UTF-8 text without NUL bytes."""
|
|
292
|
+
if doc.text is None:
|
|
293
|
+
return [LintFinding(doc.relative_path, "encoding-utf8", "error", "file is not valid UTF-8")]
|
|
294
|
+
if "\0" in doc.text:
|
|
295
|
+
return [LintFinding(doc.relative_path, "encoding-utf8", "error", "file contains NUL bytes")]
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _is_text(doc: SteeringDocument) -> bool:
|
|
300
|
+
return doc.text is not None and "\0" not in doc.text
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_json(doc: SteeringDocument) -> tuple[object, list[LintFinding]]:
|
|
304
|
+
"""Parse the document as JSON; a syntax error is reported as a finding."""
|
|
305
|
+
if doc.text is None:
|
|
306
|
+
return None, []
|
|
307
|
+
try:
|
|
308
|
+
return json.loads(doc.text), []
|
|
309
|
+
except json.JSONDecodeError as exc:
|
|
310
|
+
finding = LintFinding(
|
|
311
|
+
doc.relative_path, "json-syntax", "error", f"invalid JSON: {exc.msg}", exc.lineno
|
|
312
|
+
)
|
|
313
|
+
return None, [finding]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _profile_steering(
|
|
317
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
318
|
+
) -> list[LintFinding]:
|
|
319
|
+
findings = _text_findings(doc)
|
|
320
|
+
if findings:
|
|
321
|
+
return findings
|
|
322
|
+
findings.extend(_front_matter_findings(doc, config))
|
|
323
|
+
findings.extend(_body_findings(doc, config, workspace_root))
|
|
324
|
+
findings.extend(_markdown_findings(doc, config))
|
|
325
|
+
return findings
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _profile_markdown(
|
|
329
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
330
|
+
) -> list[LintFinding]:
|
|
331
|
+
"""Plain markdown such as specs: text, secrets and style, no Kiro front matter rules."""
|
|
332
|
+
if PurePosixPath(doc.relative_path).suffix.lower() != ".md":
|
|
333
|
+
return _profile_generic(doc, config, workspace_root)
|
|
334
|
+
findings = _text_findings(doc)
|
|
335
|
+
if findings:
|
|
336
|
+
return findings
|
|
337
|
+
findings.extend(_secret_findings(doc, config))
|
|
338
|
+
findings.extend(_markdown_findings(doc, config))
|
|
339
|
+
return findings
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _skill_metadata_findings(doc: SteeringDocument) -> list[LintFinding]:
|
|
343
|
+
fm = doc.front_matter
|
|
344
|
+
if fm is None or fm.leading_blank or not fm.present:
|
|
345
|
+
return [
|
|
346
|
+
LintFinding(
|
|
347
|
+
doc.relative_path,
|
|
348
|
+
"skill-front-matter-required",
|
|
349
|
+
"error",
|
|
350
|
+
"SKILL.md must start with front matter containing name and description",
|
|
351
|
+
1,
|
|
352
|
+
)
|
|
353
|
+
]
|
|
354
|
+
if fm.error:
|
|
355
|
+
return [LintFinding(doc.relative_path, "front-matter-yaml", "error", fm.error, 1)]
|
|
356
|
+
findings: list[LintFinding] = []
|
|
357
|
+
for key in ("name", "description"):
|
|
358
|
+
value = fm.metadata.get(key)
|
|
359
|
+
if not isinstance(value, str) or not value.strip():
|
|
360
|
+
findings.append(
|
|
361
|
+
LintFinding(
|
|
362
|
+
doc.relative_path,
|
|
363
|
+
"skill-requires-metadata",
|
|
364
|
+
"error",
|
|
365
|
+
f"SKILL.md requires a non empty {key}",
|
|
366
|
+
2,
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
name = fm.metadata.get("name")
|
|
370
|
+
folder = PurePosixPath(doc.relative_path).parent.name
|
|
371
|
+
if isinstance(name, str) and folder and name.strip() != folder:
|
|
372
|
+
findings.append(
|
|
373
|
+
LintFinding(
|
|
374
|
+
doc.relative_path,
|
|
375
|
+
"skill-name-mismatch",
|
|
376
|
+
"warning",
|
|
377
|
+
f"skill name {name!r} does not match its directory {folder!r}",
|
|
378
|
+
2,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
return findings
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _profile_skill(
|
|
385
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
386
|
+
) -> list[LintFinding]:
|
|
387
|
+
"""Agent Skills: SKILL.md metadata, markdown style, secrets in scripts and references."""
|
|
388
|
+
if PurePosixPath(doc.relative_path).name != SKILL_FILE:
|
|
389
|
+
return _profile_markdown(doc, config, workspace_root)
|
|
390
|
+
findings = _text_findings(doc)
|
|
391
|
+
if findings:
|
|
392
|
+
return findings
|
|
393
|
+
findings.extend(_skill_metadata_findings(doc))
|
|
394
|
+
findings.extend(_secret_findings(doc, config))
|
|
395
|
+
findings.extend(_markdown_findings(doc, config))
|
|
396
|
+
return findings
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _profile_json(
|
|
400
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
401
|
+
) -> list[LintFinding]:
|
|
402
|
+
if PurePosixPath(doc.relative_path).suffix.lower() != ".json":
|
|
403
|
+
return _profile_generic(doc, config, workspace_root)
|
|
404
|
+
findings = _text_findings(doc)
|
|
405
|
+
if findings:
|
|
406
|
+
return findings
|
|
407
|
+
_, syntax = _parse_json(doc)
|
|
408
|
+
findings.extend(syntax)
|
|
409
|
+
findings.extend(_secret_findings(doc, config))
|
|
410
|
+
return findings
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _hook_shape_findings(doc: SteeringDocument, data: object) -> list[LintFinding]:
|
|
414
|
+
if not isinstance(data, dict) or not isinstance(data.get("hooks"), list):
|
|
415
|
+
return [
|
|
416
|
+
LintFinding(
|
|
417
|
+
doc.relative_path,
|
|
418
|
+
"hook-schema",
|
|
419
|
+
"error",
|
|
420
|
+
'a standalone hook file is an object with "version" and a "hooks" list',
|
|
421
|
+
)
|
|
422
|
+
]
|
|
423
|
+
findings: list[LintFinding] = []
|
|
424
|
+
if data.get("version") != "v1":
|
|
425
|
+
findings.append(
|
|
426
|
+
LintFinding(doc.relative_path, "hook-schema", "warning", 'expected "version": "v1"')
|
|
427
|
+
)
|
|
428
|
+
for index, hook in enumerate(data["hooks"]):
|
|
429
|
+
label = f"hooks[{index}]"
|
|
430
|
+
if not isinstance(hook, dict):
|
|
431
|
+
findings.append(
|
|
432
|
+
LintFinding(doc.relative_path, "hook-schema", "error", f"{label} must be an object")
|
|
433
|
+
)
|
|
434
|
+
continue
|
|
435
|
+
findings.extend(
|
|
436
|
+
LintFinding(doc.relative_path, "hook-schema", "error", f"{label} lacks {key!r}")
|
|
437
|
+
for key in ("name", "trigger", "action")
|
|
438
|
+
if key not in hook
|
|
439
|
+
)
|
|
440
|
+
trigger = hook.get("trigger")
|
|
441
|
+
if isinstance(trigger, str) and trigger not in HOOK_TRIGGERS:
|
|
442
|
+
findings.append(
|
|
443
|
+
LintFinding(
|
|
444
|
+
doc.relative_path,
|
|
445
|
+
"hook-trigger",
|
|
446
|
+
"warning",
|
|
447
|
+
f"{label}: unknown trigger {trigger!r}; Kiro V3 triggers are PascalCase",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
action = hook.get("action")
|
|
451
|
+
if isinstance(action, dict) and action.get("type") not in {"command", "agent"}:
|
|
452
|
+
findings.append(
|
|
453
|
+
LintFinding(
|
|
454
|
+
doc.relative_path,
|
|
455
|
+
"hook-action",
|
|
456
|
+
"error",
|
|
457
|
+
f'{label}: action.type must be "command" or "agent"',
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
return findings
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _profile_hooks(
|
|
464
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
465
|
+
) -> list[LintFinding]:
|
|
466
|
+
if PurePosixPath(doc.relative_path).suffix.lower() != ".json":
|
|
467
|
+
return _profile_generic(doc, config, workspace_root)
|
|
468
|
+
findings = _text_findings(doc)
|
|
469
|
+
if findings:
|
|
470
|
+
return findings
|
|
471
|
+
data, syntax = _parse_json(doc)
|
|
472
|
+
findings.extend(syntax)
|
|
473
|
+
if not syntax:
|
|
474
|
+
findings.extend(_hook_shape_findings(doc, data))
|
|
475
|
+
findings.extend(_secret_findings(doc, config))
|
|
476
|
+
return findings
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _profile_agent(
|
|
480
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
481
|
+
) -> list[LintFinding]:
|
|
482
|
+
"""Custom agents: JSON definitions need a name; prompt files are plain markdown."""
|
|
483
|
+
suffix = PurePosixPath(doc.relative_path).suffix.lower()
|
|
484
|
+
if suffix == ".md":
|
|
485
|
+
return _profile_markdown(doc, config, workspace_root)
|
|
486
|
+
if suffix != ".json":
|
|
487
|
+
return _profile_generic(doc, config, workspace_root)
|
|
488
|
+
findings = _text_findings(doc)
|
|
489
|
+
if findings:
|
|
490
|
+
return findings
|
|
491
|
+
data, syntax = _parse_json(doc)
|
|
492
|
+
findings.extend(syntax)
|
|
493
|
+
if not syntax:
|
|
494
|
+
if not isinstance(data, dict):
|
|
495
|
+
findings.append(
|
|
496
|
+
LintFinding(
|
|
497
|
+
doc.relative_path, "agent-schema", "error", "an agent file is a JSON object"
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
else:
|
|
501
|
+
name = data.get("name")
|
|
502
|
+
if not isinstance(name, str) or not name.strip():
|
|
503
|
+
findings.append(
|
|
504
|
+
LintFinding(
|
|
505
|
+
doc.relative_path, "agent-name", "warning", 'agent definition has no "name"'
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
if "toolsSettings" in data:
|
|
509
|
+
findings.append(
|
|
510
|
+
LintFinding(
|
|
511
|
+
doc.relative_path,
|
|
512
|
+
"agent-deprecated-key",
|
|
513
|
+
"warning",
|
|
514
|
+
"toolsSettings is deprecated; use permissions.rules",
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
findings.extend(_secret_findings(doc, config))
|
|
518
|
+
return findings
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _profile_generic(
|
|
522
|
+
doc: SteeringDocument, config: LintConfig, workspace_root: Path | None
|
|
523
|
+
) -> list[LintFinding]:
|
|
524
|
+
"""Any file: binary content is allowed, decodable text is scanned for secrets."""
|
|
525
|
+
del workspace_root
|
|
526
|
+
return _secret_findings(doc, config) if _is_text(doc) else []
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
Profile = Callable[[SteeringDocument, LintConfig, Path | None], list[LintFinding]]
|
|
530
|
+
|
|
531
|
+
PROFILES: dict[str, Profile] = {
|
|
532
|
+
"steering": _profile_steering,
|
|
533
|
+
"markdown": _profile_markdown,
|
|
534
|
+
"skill": _profile_skill,
|
|
535
|
+
"agent": _profile_agent,
|
|
536
|
+
"hooks": _profile_hooks,
|
|
537
|
+
"json": _profile_json,
|
|
538
|
+
"generic": _profile_generic,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def lint_document(
|
|
543
|
+
doc: SteeringDocument,
|
|
544
|
+
config: LintConfig,
|
|
545
|
+
workspace_root: Path | None = None,
|
|
546
|
+
*,
|
|
547
|
+
profile: LintProfile = "steering",
|
|
548
|
+
max_file_bytes: int | None = None,
|
|
549
|
+
) -> list[LintFinding]:
|
|
550
|
+
"""Lint one document with the given profile and return its findings."""
|
|
551
|
+
findings = _size_findings(doc, max_file_bytes or config.max_file_bytes)
|
|
552
|
+
findings.extend(PROFILES[profile](doc, config, workspace_root))
|
|
553
|
+
return list(dict.fromkeys(findings))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def lint_snapshot(
|
|
557
|
+
snapshot: Mapping[str, bytes],
|
|
558
|
+
config: LintConfig,
|
|
559
|
+
workspace_root: Path | None = None,
|
|
560
|
+
*,
|
|
561
|
+
profile: LintProfile = "steering",
|
|
562
|
+
max_file_bytes: int | None = None,
|
|
563
|
+
) -> LintReport:
|
|
564
|
+
"""Lint an in memory snapshot."""
|
|
565
|
+
findings: list[LintFinding] = []
|
|
566
|
+
for relative in sorted(snapshot):
|
|
567
|
+
findings.extend(
|
|
568
|
+
lint_document(
|
|
569
|
+
document_from_bytes(relative, snapshot[relative]),
|
|
570
|
+
config,
|
|
571
|
+
workspace_root,
|
|
572
|
+
profile=profile,
|
|
573
|
+
max_file_bytes=max_file_bytes,
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
return LintReport(tuple(findings), len(snapshot))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def lint_paths(
|
|
580
|
+
paths: Iterable[Path],
|
|
581
|
+
config: LintConfig,
|
|
582
|
+
workspace_root: Path | None = None,
|
|
583
|
+
*,
|
|
584
|
+
profile: LintProfile = "steering",
|
|
585
|
+
spec: FileSpec | None = None,
|
|
586
|
+
max_file_bytes: int | None = None,
|
|
587
|
+
) -> LintReport:
|
|
588
|
+
"""Lint files or directories on disk. Directories are expanded with ``spec``."""
|
|
589
|
+
files: list[Path] = []
|
|
590
|
+
for path in paths:
|
|
591
|
+
if path.is_dir():
|
|
592
|
+
files.extend(discover_files(path, spec) if spec else discover_files(path))
|
|
593
|
+
elif path.is_file():
|
|
594
|
+
files.append(path)
|
|
595
|
+
findings: list[LintFinding] = []
|
|
596
|
+
for file in files:
|
|
597
|
+
label = (
|
|
598
|
+
file.relative_to(workspace_root).as_posix()
|
|
599
|
+
if workspace_root and file.is_relative_to(workspace_root)
|
|
600
|
+
else str(file)
|
|
601
|
+
)
|
|
602
|
+
findings.extend(
|
|
603
|
+
lint_document(
|
|
604
|
+
document_from_bytes(label, file.read_bytes()),
|
|
605
|
+
config,
|
|
606
|
+
workspace_root,
|
|
607
|
+
profile=profile,
|
|
608
|
+
max_file_bytes=max_file_bytes,
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
return LintReport(tuple(findings), len(files))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def prefix_report(report: LintReport, prefix: str) -> LintReport:
|
|
615
|
+
"""Return ``report`` with every finding path prefixed, for combined reports."""
|
|
616
|
+
if not prefix:
|
|
617
|
+
return report
|
|
618
|
+
return LintReport(
|
|
619
|
+
tuple(f._replace(path=f"{prefix.rstrip('/')}/{f.path}") for f in report.findings),
|
|
620
|
+
report.checked,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def merge_reports(reports: Iterable[LintReport]) -> LintReport:
|
|
625
|
+
"""Combine several reports into one."""
|
|
626
|
+
findings: list[LintFinding] = []
|
|
627
|
+
checked = 0
|
|
628
|
+
for report in reports:
|
|
629
|
+
findings.extend(report.findings)
|
|
630
|
+
checked += report.checked
|
|
631
|
+
return LintReport(tuple(findings), checked)
|
steersync/locking.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Cross process exclusive file locks.
|
|
2
|
+
|
|
3
|
+
Several steersync processes can share one cache clone (two pushes in different
|
|
4
|
+
repositories, a Kiro session hook and a git hook at the same time). Every
|
|
5
|
+
operation on a cache clone holds an exclusive lock on a sibling lock file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO
|
|
17
|
+
|
|
18
|
+
from steersync.errors import LockTimeoutError
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
if sys.platform == "win32": # pragma: no cover - exercised on Windows only
|
|
23
|
+
import msvcrt
|
|
24
|
+
|
|
25
|
+
def _try_lock(handle: IO[bytes]) -> None:
|
|
26
|
+
handle.seek(0)
|
|
27
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
28
|
+
|
|
29
|
+
def _unlock(handle: IO[bytes]) -> None:
|
|
30
|
+
handle.seek(0)
|
|
31
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
32
|
+
|
|
33
|
+
else:
|
|
34
|
+
import fcntl
|
|
35
|
+
|
|
36
|
+
def _try_lock(handle: IO[bytes]) -> None:
|
|
37
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
38
|
+
|
|
39
|
+
def _unlock(handle: IO[bytes]) -> None:
|
|
40
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@contextmanager
|
|
44
|
+
def exclusive_lock(path: Path, *, timeout: float, poll_interval: float = 0.2) -> Iterator[None]:
|
|
45
|
+
"""Hold an exclusive lock on ``path`` for the duration of the block.
|
|
46
|
+
|
|
47
|
+
The lock is released automatically by the operating system if the process
|
|
48
|
+
dies, so a crashed run never leaves a stale lock behind.
|
|
49
|
+
"""
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
51
|
+
deadline = time.monotonic() + timeout
|
|
52
|
+
with path.open("a+b") as handle:
|
|
53
|
+
announced = False
|
|
54
|
+
while True:
|
|
55
|
+
try:
|
|
56
|
+
_try_lock(handle)
|
|
57
|
+
break
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
if time.monotonic() >= deadline:
|
|
60
|
+
msg = f"timed out after {timeout:.0f}s waiting for lock {path}"
|
|
61
|
+
raise LockTimeoutError(msg) from exc
|
|
62
|
+
if not announced:
|
|
63
|
+
log.info("waiting for another steersync process (%s)", path)
|
|
64
|
+
announced = True
|
|
65
|
+
time.sleep(poll_interval)
|
|
66
|
+
try:
|
|
67
|
+
yield
|
|
68
|
+
finally:
|
|
69
|
+
_unlock(handle)
|