wn-dev-std 2026.6.27__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.
- wn_dev_std/__init__.py +47 -0
- wn_dev_std/__main__.py +3 -0
- wn_dev_std/_version.py +3 -0
- wn_dev_std/check_profiles.py +208 -0
- wn_dev_std/checks.py +759 -0
- wn_dev_std/cli/__init__.py +1 -0
- wn_dev_std/cli/commands/__init__.py +1 -0
- wn_dev_std/cli/commands/audit.py +75 -0
- wn_dev_std/cli/commands/check.py +25 -0
- wn_dev_std/cli/commands/log.py +30 -0
- wn_dev_std/cli/commands/log_create.py +50 -0
- wn_dev_std/cli/commands/log_list.py +88 -0
- wn_dev_std/cli/commands/log_show.py +78 -0
- wn_dev_std/cli/commands/plan.py +32 -0
- wn_dev_std/cli/commands/plan_common.py +101 -0
- wn_dev_std/cli/commands/plan_create.py +59 -0
- wn_dev_std/cli/commands/plan_list.py +87 -0
- wn_dev_std/cli/commands/plan_show.py +89 -0
- wn_dev_std/cli/commands/plan_status.py +42 -0
- wn_dev_std/cli/commands/plan_step.py +111 -0
- wn_dev_std/cli/commands/standard.py +68 -0
- wn_dev_std/cli/commands/version.py +71 -0
- wn_dev_std/cli/main.py +53 -0
- wn_dev_std/cli/types.py +20 -0
- wn_dev_std/compatibility_pruning.py +233 -0
- wn_dev_std/config.py +85 -0
- wn_dev_std/cpp_policy.py +42 -0
- wn_dev_std/design_doc_status.py +98 -0
- wn_dev_std/native_complexity.py +108 -0
- wn_dev_std/plan_hygiene.py +596 -0
- wn_dev_std/plan_mutation.py +406 -0
- wn_dev_std/plan_reader.py +25 -0
- wn_dev_std/pr_hygiene.py +168 -0
- wn_dev_std/root_discovery.py +102 -0
- wn_dev_std/secret_hygiene.py +34 -0
- wn_dev_std/standards.py +756 -0
- wn_dev_std-2026.6.27.dist-info/METADATA +379 -0
- wn_dev_std-2026.6.27.dist-info/RECORD +41 -0
- wn_dev_std-2026.6.27.dist-info/WHEEL +4 -0
- wn_dev_std-2026.6.27.dist-info/entry_points.txt +3 -0
- wn_dev_std-2026.6.27.dist-info/licenses/LICENSE +21 -0
wn_dev_std/checks.py
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""Repository conformance checks used by the example CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import cast
|
|
10
|
+
from xml.etree import ElementTree
|
|
11
|
+
|
|
12
|
+
from wn_dev_std.check_profiles import (
|
|
13
|
+
CLANG_FORMAT_REQUIRED_SETTINGS,
|
|
14
|
+
CPP_REQUIRED_PATHS,
|
|
15
|
+
CSHARP_ANALYZER_PROPS,
|
|
16
|
+
CSHARP_EDITORCONFIG_RULES,
|
|
17
|
+
CSHARP_REQUIRED_PATHS,
|
|
18
|
+
JAVASCRIPT_WEB_REQUIRED_PATHS,
|
|
19
|
+
MIXED_MODE_REQUIRED_PATHS,
|
|
20
|
+
ZEPHYR_CLANG_FORMAT_REQUIRED_SETTINGS,
|
|
21
|
+
ZEPHYR_REQUIRED_PATHS,
|
|
22
|
+
ProfileName,
|
|
23
|
+
project_profile,
|
|
24
|
+
required_doc_paths,
|
|
25
|
+
required_root_files,
|
|
26
|
+
)
|
|
27
|
+
from wn_dev_std.check_profiles import (
|
|
28
|
+
REQUIRED_ROOT_FILES as REQUIRED_ROOT_FILES,
|
|
29
|
+
)
|
|
30
|
+
from wn_dev_std.compatibility_pruning import check_compatibility_pruning_policy
|
|
31
|
+
from wn_dev_std.cpp_policy import check_clang_tidy_policy
|
|
32
|
+
from wn_dev_std.design_doc_status import check_design_doc_status_policy
|
|
33
|
+
from wn_dev_std.native_complexity import check_lizard_gate, check_native_signoff_config
|
|
34
|
+
from wn_dev_std.plan_hygiene import check_plan_hygiene_policy
|
|
35
|
+
from wn_dev_std.pr_hygiene import check_pr_hygiene_policy
|
|
36
|
+
from wn_dev_std.root_discovery import load_pyproject, load_standard_config
|
|
37
|
+
from wn_dev_std.secret_hygiene import check_root_env_policy
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class CheckResult:
|
|
42
|
+
"""Single conformance check result."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
passed: bool
|
|
46
|
+
detail: str
|
|
47
|
+
scope: str = "repo"
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, object]:
|
|
50
|
+
"""Return a JSON-serializable representation."""
|
|
51
|
+
return {
|
|
52
|
+
"name": self.name,
|
|
53
|
+
"passed": self.passed,
|
|
54
|
+
"detail": self.detail,
|
|
55
|
+
"scope": self.scope,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
AUDIT_SCOPES = (
|
|
60
|
+
"all",
|
|
61
|
+
"repo",
|
|
62
|
+
"docs",
|
|
63
|
+
"docs.design",
|
|
64
|
+
"docs.plans",
|
|
65
|
+
"language",
|
|
66
|
+
"ci",
|
|
67
|
+
"compat",
|
|
68
|
+
)
|
|
69
|
+
JS_CSS_EXCLUDED_PARTS = {"vendor", "lib", "_build", "node_modules"}
|
|
70
|
+
STANDARD_COMMAND_VERBS = ("install", "update", "build", "test", "signoff")
|
|
71
|
+
JAVASCRIPT_STANDARD_DOC_PATHS = (
|
|
72
|
+
"docs/design/javascript-standard.html",
|
|
73
|
+
"docs/design/standards/javascript.html",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_basic_checks(root: Path) -> tuple[CheckResult, ...]:
|
|
78
|
+
"""Run lightweight repository checks for a Python standards project."""
|
|
79
|
+
return run_audit_checks(root, ("all",))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_audit_checks(
|
|
83
|
+
root: Path,
|
|
84
|
+
scopes: Sequence[str] = ("all",),
|
|
85
|
+
) -> tuple[CheckResult, ...]:
|
|
86
|
+
"""Run repository audit checks for the requested scopes."""
|
|
87
|
+
requested_scopes = _normalize_scopes(scopes)
|
|
88
|
+
checks = _run_all_audit_checks(root)
|
|
89
|
+
return tuple(check for check in checks if _scope_is_selected(check.scope, requested_scopes))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _run_all_audit_checks(root: Path) -> tuple[CheckResult, ...]:
|
|
93
|
+
"""Run the full repository audit set before scope filtering."""
|
|
94
|
+
resolved_root = root.resolve()
|
|
95
|
+
pyproject = load_pyproject(resolved_root)
|
|
96
|
+
config = load_standard_config(resolved_root, pyproject)
|
|
97
|
+
profile = project_profile(config)
|
|
98
|
+
checks = _common_checks(resolved_root, profile, config)
|
|
99
|
+
if _needs_python_package_checks(profile):
|
|
100
|
+
checks.extend(
|
|
101
|
+
[
|
|
102
|
+
_check_uv_lock(resolved_root),
|
|
103
|
+
_check_pyproject_backend(resolved_root, pyproject, profile),
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
checks.extend(_scoped_results(_profile_specific_checks(resolved_root, profile), "language"))
|
|
107
|
+
pruning_config = _compatibility_pruning_config(config)
|
|
108
|
+
if pruning_config is not None:
|
|
109
|
+
pruning_result = check_compatibility_pruning_policy(resolved_root, pruning_config)
|
|
110
|
+
checks.append(
|
|
111
|
+
CheckResult(
|
|
112
|
+
"compatibility pruning",
|
|
113
|
+
pruning_result.passed,
|
|
114
|
+
pruning_result.detail,
|
|
115
|
+
"compat",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
pr_hygiene_config = _pr_hygiene_config(config)
|
|
119
|
+
if pr_hygiene_config is not None:
|
|
120
|
+
pr_hygiene_result = check_pr_hygiene_policy(resolved_root, pr_hygiene_config)
|
|
121
|
+
checks.append(
|
|
122
|
+
CheckResult(
|
|
123
|
+
"public PR hygiene",
|
|
124
|
+
pr_hygiene_result.passed,
|
|
125
|
+
pr_hygiene_result.detail,
|
|
126
|
+
"ci",
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
plan_hygiene_result = check_plan_hygiene_policy(resolved_root, config)
|
|
130
|
+
checks.append(
|
|
131
|
+
CheckResult(
|
|
132
|
+
"docs.plans",
|
|
133
|
+
plan_hygiene_result.passed,
|
|
134
|
+
plan_hygiene_result.detail,
|
|
135
|
+
"docs.plans",
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
return tuple(checks)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _common_checks(
|
|
142
|
+
root: Path,
|
|
143
|
+
profile: ProfileName,
|
|
144
|
+
config: Mapping[str, object] | None,
|
|
145
|
+
) -> list[CheckResult]:
|
|
146
|
+
checks = [
|
|
147
|
+
_check_required_paths(root, "root files", required_root_files(profile)),
|
|
148
|
+
_scoped_result(_check_required_documentation_paths(root, profile, config), "docs"),
|
|
149
|
+
_scoped_result(_check_design_doc_status(root), "docs.design"),
|
|
150
|
+
_check_no_env_file(root),
|
|
151
|
+
]
|
|
152
|
+
if profile != "csharp-app":
|
|
153
|
+
checks.append(_check_required_paths(root, "rack suite", ("tests/rack.toml",)))
|
|
154
|
+
return checks
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _needs_python_package_checks(profile: ProfileName) -> bool:
|
|
158
|
+
native_or_non_python = {"cpp-library", "csharp-app", "javascript-web-app", "zephyr-firmware"}
|
|
159
|
+
return profile not in native_or_non_python
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _profile_specific_checks(root: Path, profile: ProfileName) -> list[CheckResult]:
|
|
163
|
+
if profile == "cpp-library":
|
|
164
|
+
return _cpp_checks(root)
|
|
165
|
+
if profile == "zephyr-firmware":
|
|
166
|
+
return _zephyr_checks(root)
|
|
167
|
+
if profile == "python-native-wasm":
|
|
168
|
+
return _mixed_mode_checks(root)
|
|
169
|
+
if profile == "csharp-app":
|
|
170
|
+
return _csharp_checks(root)
|
|
171
|
+
if profile in {"javascript-web-app", "python-js-app"}:
|
|
172
|
+
return _web_checks(root)
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _cpp_checks(root: Path) -> list[CheckResult]:
|
|
177
|
+
return [
|
|
178
|
+
_check_required_paths(root, "C++ files", CPP_REQUIRED_PATHS),
|
|
179
|
+
_check_clang_format_policy(root),
|
|
180
|
+
_check_clang_tidy_policy(root),
|
|
181
|
+
_check_cmake_presets_policy(root),
|
|
182
|
+
_check_native_signoff_config(root),
|
|
183
|
+
_check_lizard_complexity_policy(root),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _zephyr_checks(root: Path) -> list[CheckResult]:
|
|
188
|
+
return [
|
|
189
|
+
_check_required_paths(root, "Zephyr files", ZEPHYR_REQUIRED_PATHS),
|
|
190
|
+
_check_zephyr_clang_format_policy(root),
|
|
191
|
+
_check_clang_tidy_policy(root),
|
|
192
|
+
_check_native_signoff_config(root),
|
|
193
|
+
_check_lizard_complexity_policy(root),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _mixed_mode_checks(root: Path) -> list[CheckResult]:
|
|
198
|
+
return [
|
|
199
|
+
_check_required_paths(root, "mixed-mode files", MIXED_MODE_REQUIRED_PATHS),
|
|
200
|
+
_check_clang_format_policy(root),
|
|
201
|
+
_check_clang_tidy_policy(root),
|
|
202
|
+
_check_cmake_presets_policy(root),
|
|
203
|
+
_check_lizard_complexity_policy(root),
|
|
204
|
+
_check_dist_root_policy(root),
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _csharp_checks(root: Path) -> list[CheckResult]:
|
|
209
|
+
return [
|
|
210
|
+
_check_required_paths(root, "C# files", CSHARP_REQUIRED_PATHS),
|
|
211
|
+
_check_dotnet_project_policy(root),
|
|
212
|
+
_check_dotnet_analyzer_policy(root),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _web_checks(root: Path) -> list[CheckResult]:
|
|
217
|
+
return [
|
|
218
|
+
_check_required_paths(root, "web app files", JAVASCRIPT_WEB_REQUIRED_PATHS),
|
|
219
|
+
_check_web_source_policy(root),
|
|
220
|
+
_check_web_typecheck_policy(root),
|
|
221
|
+
_check_web_css_token_policy(root),
|
|
222
|
+
_check_web_command_surface_policy(root),
|
|
223
|
+
_check_web_signoff_policy(root),
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def format_results(results: tuple[CheckResult, ...], output_format: str) -> str:
|
|
228
|
+
"""Format check results as text or JSON."""
|
|
229
|
+
if output_format == "json":
|
|
230
|
+
payload: dict[str, object] = {"passed": all(result.passed for result in results)}
|
|
231
|
+
payload["checks"] = [result.to_dict() for result in results]
|
|
232
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
233
|
+
|
|
234
|
+
lines: list[str] = []
|
|
235
|
+
for result in results:
|
|
236
|
+
marker = "PASS" if result.passed else "FAIL"
|
|
237
|
+
lines.append(f"[{marker}] {result.name}: {result.detail}")
|
|
238
|
+
return "\n".join(lines)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _normalize_scopes(scopes: Sequence[str]) -> tuple[str, ...]:
|
|
242
|
+
selected = tuple(scope for scope in scopes if scope in AUDIT_SCOPES)
|
|
243
|
+
if not selected:
|
|
244
|
+
return ("all",)
|
|
245
|
+
if "all" in selected:
|
|
246
|
+
return ("all",)
|
|
247
|
+
return selected
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _scope_is_selected(scope: str, selected: Sequence[str]) -> bool:
|
|
251
|
+
if "all" in selected:
|
|
252
|
+
return True
|
|
253
|
+
return any(scope == item or scope.startswith(f"{item}.") for item in selected)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _scoped_result(result: CheckResult, scope: str) -> CheckResult:
|
|
257
|
+
return CheckResult(result.name, result.passed, result.detail, scope)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _scoped_results(results: Sequence[CheckResult], scope: str) -> list[CheckResult]:
|
|
261
|
+
return [_scoped_result(result, scope) for result in results]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _check_required_paths(root: Path, name: str, relative_paths: tuple[str, ...]) -> CheckResult:
|
|
265
|
+
missing = [
|
|
266
|
+
relative_path for relative_path in relative_paths if not (root / relative_path).exists()
|
|
267
|
+
]
|
|
268
|
+
if missing:
|
|
269
|
+
return CheckResult(name, False, "missing " + ", ".join(missing))
|
|
270
|
+
return CheckResult(name, True, "all required paths are present")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _check_required_documentation_paths(
|
|
274
|
+
root: Path,
|
|
275
|
+
profile: ProfileName,
|
|
276
|
+
config: Mapping[str, object] | None,
|
|
277
|
+
) -> CheckResult:
|
|
278
|
+
required_paths = required_doc_paths(profile)
|
|
279
|
+
if profile not in {"javascript-web-app", "python-js-app"}:
|
|
280
|
+
return _check_required_paths(root, "documentation", required_paths)
|
|
281
|
+
|
|
282
|
+
base_required = tuple(
|
|
283
|
+
path for path in required_paths if path != JAVASCRIPT_STANDARD_DOC_PATHS[0]
|
|
284
|
+
)
|
|
285
|
+
missing = [
|
|
286
|
+
relative_path for relative_path in base_required if not (root / relative_path).exists()
|
|
287
|
+
]
|
|
288
|
+
if missing:
|
|
289
|
+
return CheckResult("documentation", False, "missing " + ", ".join(missing))
|
|
290
|
+
|
|
291
|
+
standard_doc_paths = _javascript_standard_doc_paths(config)
|
|
292
|
+
if any((root / path).exists() for path in standard_doc_paths):
|
|
293
|
+
return CheckResult("documentation", True, "all required paths are present")
|
|
294
|
+
return CheckResult(
|
|
295
|
+
"documentation",
|
|
296
|
+
False,
|
|
297
|
+
"missing " + " or ".join(standard_doc_paths),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _javascript_standard_doc_paths(config: Mapping[str, object] | None) -> tuple[str, ...]:
|
|
302
|
+
configured_path = _configured_standard_doc_path(config, "javascript")
|
|
303
|
+
if configured_path:
|
|
304
|
+
return (configured_path,)
|
|
305
|
+
return JAVASCRIPT_STANDARD_DOC_PATHS
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _configured_standard_doc_path(
|
|
309
|
+
config: Mapping[str, object] | None,
|
|
310
|
+
language: str,
|
|
311
|
+
) -> str | None:
|
|
312
|
+
if config is None:
|
|
313
|
+
return None
|
|
314
|
+
documentation = config.get("documentation")
|
|
315
|
+
if not isinstance(documentation, dict):
|
|
316
|
+
return None
|
|
317
|
+
documentation_config = cast(Mapping[str, object], documentation)
|
|
318
|
+
standard_docs = documentation_config.get("standard_docs")
|
|
319
|
+
if not isinstance(standard_docs, dict):
|
|
320
|
+
return None
|
|
321
|
+
standard_doc_config = cast(Mapping[str, object], standard_docs)
|
|
322
|
+
value = standard_doc_config.get(language)
|
|
323
|
+
if not isinstance(value, str):
|
|
324
|
+
return None
|
|
325
|
+
return value.strip() or None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _check_design_doc_status(root: Path) -> CheckResult:
|
|
329
|
+
result = check_design_doc_status_policy(root)
|
|
330
|
+
return CheckResult("design doc status", result.passed, result.detail)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _check_no_env_file(root: Path) -> CheckResult:
|
|
334
|
+
passed, detail = check_root_env_policy(root)
|
|
335
|
+
return CheckResult("secret hygiene", passed, detail)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _check_uv_lock(root: Path) -> CheckResult:
|
|
339
|
+
for candidate in (root, *root.parents):
|
|
340
|
+
if (candidate / "uv.lock").exists():
|
|
341
|
+
detail = (
|
|
342
|
+
"uv.lock is committed"
|
|
343
|
+
if candidate == root
|
|
344
|
+
else f"uv.lock is committed at workspace root {candidate}"
|
|
345
|
+
)
|
|
346
|
+
return CheckResult("uv lock", True, detail)
|
|
347
|
+
if (candidate / ".git").exists():
|
|
348
|
+
break
|
|
349
|
+
return CheckResult(
|
|
350
|
+
"uv lock",
|
|
351
|
+
False,
|
|
352
|
+
"uv.lock is required at the package or workspace root",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _compatibility_pruning_config(config: Mapping[str, object] | None) -> object | None:
|
|
357
|
+
if config is None:
|
|
358
|
+
return None
|
|
359
|
+
return config.get("compatibility_pruning")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _pr_hygiene_config(config: Mapping[str, object] | None) -> object | None:
|
|
363
|
+
if config is None:
|
|
364
|
+
return None
|
|
365
|
+
return config.get("pr_hygiene")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _check_pyproject_backend(
|
|
369
|
+
root: Path,
|
|
370
|
+
pyproject: Mapping[str, object] | None,
|
|
371
|
+
profile: ProfileName,
|
|
372
|
+
) -> CheckResult:
|
|
373
|
+
if pyproject is None:
|
|
374
|
+
return CheckResult("build backend", False, "pyproject.toml is missing")
|
|
375
|
+
|
|
376
|
+
build_system_raw = pyproject.get("build-system")
|
|
377
|
+
if not isinstance(build_system_raw, dict):
|
|
378
|
+
return CheckResult("build backend", False, "pyproject.toml is missing [build-system]")
|
|
379
|
+
build_system = cast(Mapping[str, object], build_system_raw)
|
|
380
|
+
backend = build_system.get("build-backend")
|
|
381
|
+
if backend == "hatchling.build":
|
|
382
|
+
return CheckResult("build backend", True, "pure Python package uses Hatchling")
|
|
383
|
+
if profile == "python-native-wasm" and backend == "setuptools.build_meta":
|
|
384
|
+
if (root / "setup.py").exists():
|
|
385
|
+
return CheckResult(
|
|
386
|
+
"build backend", True, "mixed-mode package uses custom setuptools hook"
|
|
387
|
+
)
|
|
388
|
+
return CheckResult("build backend", False, "setuptools native wheel hook requires setup.py")
|
|
389
|
+
return CheckResult("build backend", False, "expected build-backend = hatchling.build")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _check_dist_root_policy(root: Path) -> CheckResult:
|
|
393
|
+
dist = root / "dist"
|
|
394
|
+
if not dist.exists():
|
|
395
|
+
return CheckResult("dist policy", False, "mixed-mode profile requires dist/README.md")
|
|
396
|
+
allowed_files = {".gitkeep", "README.md"}
|
|
397
|
+
allowed_dirs = {"native", "wasm"}
|
|
398
|
+
unexpected = [
|
|
399
|
+
child.name
|
|
400
|
+
for child in dist.iterdir()
|
|
401
|
+
if (child.is_file() and child.name not in allowed_files)
|
|
402
|
+
or (child.is_dir() and child.name not in allowed_dirs)
|
|
403
|
+
]
|
|
404
|
+
if unexpected:
|
|
405
|
+
return CheckResult(
|
|
406
|
+
"dist policy", False, "unexpected root dist entries: " + ", ".join(unexpected)
|
|
407
|
+
)
|
|
408
|
+
return CheckResult("dist policy", True, "dist/ uses grouped native and WASM paths")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _check_clang_format_policy(root: Path) -> CheckResult:
|
|
412
|
+
return _check_clang_format_settings(root, CLANG_FORMAT_REQUIRED_SETTINGS)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _check_zephyr_clang_format_policy(root: Path) -> CheckResult:
|
|
416
|
+
return _check_clang_format_settings(root, ZEPHYR_CLANG_FORMAT_REQUIRED_SETTINGS)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _check_clang_format_settings(
|
|
420
|
+
root: Path,
|
|
421
|
+
required_settings: Mapping[str, str],
|
|
422
|
+
) -> CheckResult:
|
|
423
|
+
path = root / ".clang-format"
|
|
424
|
+
if not path.exists():
|
|
425
|
+
return CheckResult("clang-format policy", False, ".clang-format is required")
|
|
426
|
+
settings = _read_simple_yaml_map(path)
|
|
427
|
+
missing_or_wrong = [
|
|
428
|
+
f"{key}={value}" for key, value in required_settings.items() if settings.get(key) != value
|
|
429
|
+
]
|
|
430
|
+
if missing_or_wrong:
|
|
431
|
+
return CheckResult(
|
|
432
|
+
"clang-format policy",
|
|
433
|
+
False,
|
|
434
|
+
"expected " + ", ".join(missing_or_wrong),
|
|
435
|
+
)
|
|
436
|
+
return CheckResult("clang-format policy", True, "formatter matches C++ baseline")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _check_clang_tidy_policy(root: Path) -> CheckResult:
|
|
440
|
+
passed, detail = check_clang_tidy_policy(root)
|
|
441
|
+
return CheckResult("clang-tidy policy", passed, detail)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _check_cmake_presets_policy(root: Path) -> CheckResult:
|
|
445
|
+
path = root / "CMakePresets.json"
|
|
446
|
+
if not path.exists():
|
|
447
|
+
return CheckResult("CMake presets", False, "CMakePresets.json is required")
|
|
448
|
+
raw_data: object = json.loads(path.read_text(encoding="utf-8"))
|
|
449
|
+
if not isinstance(raw_data, dict):
|
|
450
|
+
return CheckResult("CMake presets", False, "CMakePresets.json must contain an object")
|
|
451
|
+
data = cast(Mapping[str, object], raw_data)
|
|
452
|
+
raw_presets = data.get("configurePresets")
|
|
453
|
+
if not isinstance(raw_presets, list):
|
|
454
|
+
return CheckResult("CMake presets", False, "configurePresets array is required")
|
|
455
|
+
|
|
456
|
+
raw_preset_items = cast(list[object], raw_presets)
|
|
457
|
+
presets = _mapping_list(raw_preset_items)
|
|
458
|
+
generator_values = _preset_generators(presets)
|
|
459
|
+
if not generator_values:
|
|
460
|
+
return CheckResult(
|
|
461
|
+
"CMake presets", False, "at least one configure preset must set generator"
|
|
462
|
+
)
|
|
463
|
+
non_ninja = [value for value in generator_values if value != "Ninja"]
|
|
464
|
+
if non_ninja:
|
|
465
|
+
return CheckResult("CMake presets", False, "Ninja is the default generator")
|
|
466
|
+
|
|
467
|
+
if not _has_compile_commands_enabled(presets):
|
|
468
|
+
return CheckResult(
|
|
469
|
+
"CMake presets",
|
|
470
|
+
False,
|
|
471
|
+
"at least one configure preset must set CMAKE_EXPORT_COMPILE_COMMANDS=ON",
|
|
472
|
+
)
|
|
473
|
+
return CheckResult("CMake presets", True, "Ninja and compile commands are configured")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _check_lizard_complexity_policy(root: Path) -> CheckResult:
|
|
477
|
+
passed, detail = check_lizard_gate(root)
|
|
478
|
+
return CheckResult("native complexity", passed, detail)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _check_native_signoff_config(root: Path) -> CheckResult:
|
|
482
|
+
passed, detail = check_native_signoff_config(root)
|
|
483
|
+
return CheckResult("native signoff config", passed, detail)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _check_dotnet_project_policy(root: Path) -> CheckResult:
|
|
487
|
+
source_projects = sorted((root / "src").rglob("*.csproj")) if (root / "src").exists() else []
|
|
488
|
+
test_projects = sorted((root / "tests").rglob("*.csproj")) if (root / "tests").exists() else []
|
|
489
|
+
if not source_projects:
|
|
490
|
+
return CheckResult("dotnet projects", False, "at least one source .csproj is required")
|
|
491
|
+
if not test_projects:
|
|
492
|
+
return CheckResult("dotnet projects", False, "at least one test .csproj is required")
|
|
493
|
+
return CheckResult("dotnet projects", True, "source and test projects are present")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _check_dotnet_analyzer_policy(root: Path) -> CheckResult:
|
|
497
|
+
props_path = root / "Directory.Build.props"
|
|
498
|
+
editorconfig_path = root / ".editorconfig"
|
|
499
|
+
if not props_path.exists():
|
|
500
|
+
return CheckResult("dotnet analyzer policy", False, "Directory.Build.props is required")
|
|
501
|
+
if not editorconfig_path.exists():
|
|
502
|
+
return CheckResult("dotnet analyzer policy", False, ".editorconfig is required")
|
|
503
|
+
|
|
504
|
+
props = _msbuild_properties(props_path)
|
|
505
|
+
missing_props = [
|
|
506
|
+
f"{name}={expected}"
|
|
507
|
+
for name, expected in CSHARP_ANALYZER_PROPS
|
|
508
|
+
if props.get(name) != expected
|
|
509
|
+
]
|
|
510
|
+
editorconfig = editorconfig_path.read_text(encoding="utf-8")
|
|
511
|
+
missing_rules = [rule for rule in CSHARP_EDITORCONFIG_RULES if rule not in editorconfig]
|
|
512
|
+
if missing_props or missing_rules:
|
|
513
|
+
expected = missing_props + missing_rules
|
|
514
|
+
return CheckResult("dotnet analyzer policy", False, "expected " + ", ".join(expected))
|
|
515
|
+
return CheckResult(
|
|
516
|
+
"dotnet analyzer policy",
|
|
517
|
+
True,
|
|
518
|
+
"code style, analyzers, and complexity gates are configured",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _owned_web_files(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
|
|
523
|
+
src = root / "src"
|
|
524
|
+
if not src.exists():
|
|
525
|
+
return []
|
|
526
|
+
files: list[Path] = []
|
|
527
|
+
for suffix in suffixes:
|
|
528
|
+
for path in src.rglob(suffix):
|
|
529
|
+
relative_parts = set(path.relative_to(root).parts)
|
|
530
|
+
if JS_CSS_EXCLUDED_PARTS & relative_parts:
|
|
531
|
+
continue
|
|
532
|
+
if path.name.endswith(".min.js") or path.name.endswith(".min.css"):
|
|
533
|
+
continue
|
|
534
|
+
files.append(path)
|
|
535
|
+
return sorted(files)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _stray_minified_web_files(root: Path) -> list[str]:
|
|
539
|
+
stray: list[str] = []
|
|
540
|
+
for suffix in ("*.min.js", "*.min.css"):
|
|
541
|
+
for path in root.rglob(suffix):
|
|
542
|
+
relative_parts = set(path.relative_to(root).parts)
|
|
543
|
+
if JS_CSS_EXCLUDED_PARTS & relative_parts:
|
|
544
|
+
continue
|
|
545
|
+
stray.append(path.relative_to(root).as_posix())
|
|
546
|
+
return sorted(stray)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _check_web_source_policy(root: Path) -> CheckResult:
|
|
550
|
+
owned_js = _owned_web_files(root, ("*.js", "*.mjs", "*.jsx", "*.ts", "*.tsx"))
|
|
551
|
+
owned_css = _owned_web_files(root, ("*.css",))
|
|
552
|
+
if not owned_js:
|
|
553
|
+
return CheckResult(
|
|
554
|
+
"web source",
|
|
555
|
+
False,
|
|
556
|
+
"at least one owned JS/TS source file is required under src/",
|
|
557
|
+
)
|
|
558
|
+
if not owned_css:
|
|
559
|
+
return CheckResult(
|
|
560
|
+
"web source",
|
|
561
|
+
False,
|
|
562
|
+
"at least one owned CSS source file is required under src/",
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
stray_minified = _stray_minified_web_files(root)
|
|
566
|
+
if stray_minified:
|
|
567
|
+
return CheckResult(
|
|
568
|
+
"web source",
|
|
569
|
+
False,
|
|
570
|
+
"minified/generated JS/CSS must live under vendor/, lib/, _build/, "
|
|
571
|
+
"or node_modules/: " + ", ".join(stray_minified[:5]),
|
|
572
|
+
)
|
|
573
|
+
return CheckResult(
|
|
574
|
+
"web source",
|
|
575
|
+
True,
|
|
576
|
+
f"{len(owned_js)} owned JS/TS and {len(owned_css)} owned CSS file(s) found",
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _check_web_typecheck_policy(root: Path) -> CheckResult:
|
|
581
|
+
owned_js = _owned_web_files(root, ("*.js", "*.mjs", "*.jsx"))
|
|
582
|
+
owned_ts = _owned_web_files(root, ("*.ts", "*.tsx"))
|
|
583
|
+
has_jsconfig = (root / "jsconfig.json").exists()
|
|
584
|
+
has_tsconfig = (root / "tsconfig.json").exists()
|
|
585
|
+
if owned_ts and not has_tsconfig:
|
|
586
|
+
return CheckResult("web typecheck", False, "TypeScript source requires tsconfig.json")
|
|
587
|
+
if has_jsconfig or has_tsconfig:
|
|
588
|
+
config = "tsconfig.json" if has_tsconfig else "jsconfig.json"
|
|
589
|
+
return CheckResult("web typecheck", True, f"{config} is present")
|
|
590
|
+
|
|
591
|
+
missing_ts_check = [
|
|
592
|
+
path.relative_to(root).as_posix() for path in owned_js if not _has_ts_check_comment(path)
|
|
593
|
+
]
|
|
594
|
+
if missing_ts_check:
|
|
595
|
+
return CheckResult(
|
|
596
|
+
"web typecheck",
|
|
597
|
+
False,
|
|
598
|
+
"expected jsconfig.json, tsconfig.json, or // @ts-check in "
|
|
599
|
+
+ ", ".join(missing_ts_check[:5]),
|
|
600
|
+
)
|
|
601
|
+
return CheckResult("web typecheck", True, "owned JavaScript files use // @ts-check")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _has_ts_check_comment(path: Path) -> bool:
|
|
605
|
+
first_lines = path.read_text(encoding="utf-8").splitlines()[:8]
|
|
606
|
+
return any(line.strip() == "// @ts-check" for line in first_lines)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _check_web_css_token_policy(root: Path) -> CheckResult:
|
|
610
|
+
owned_css = _owned_web_files(root, ("*.css",))
|
|
611
|
+
token_files = [
|
|
612
|
+
path.relative_to(root).as_posix() for path in owned_css if _css_uses_custom_properties(path)
|
|
613
|
+
]
|
|
614
|
+
if token_files:
|
|
615
|
+
return CheckResult("web CSS tokens", True, "CSS custom properties are present")
|
|
616
|
+
return CheckResult(
|
|
617
|
+
"web CSS tokens",
|
|
618
|
+
False,
|
|
619
|
+
"owned CSS must define or consume CSS custom properties for design constants",
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _css_uses_custom_properties(path: Path) -> bool:
|
|
624
|
+
text = path.read_text(encoding="utf-8")
|
|
625
|
+
if "var(--" in text:
|
|
626
|
+
return True
|
|
627
|
+
return any(line.lstrip().startswith("--") and ":" in line for line in text.splitlines())
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _check_web_command_surface_policy(root: Path) -> CheckResult:
|
|
631
|
+
providers = {
|
|
632
|
+
"package.json scripts": _package_script_verbs(root),
|
|
633
|
+
"Makefile targets": _makefile_verbs(root),
|
|
634
|
+
"scripts/dev.py": _dev_py_verbs(root),
|
|
635
|
+
"root or scripts verb files": _script_file_verbs(root),
|
|
636
|
+
}
|
|
637
|
+
required = set(STANDARD_COMMAND_VERBS)
|
|
638
|
+
for provider, verbs in providers.items():
|
|
639
|
+
if required <= verbs:
|
|
640
|
+
return CheckResult("command surface", True, f"{provider} exposes standard verbs")
|
|
641
|
+
return CheckResult(
|
|
642
|
+
"command surface",
|
|
643
|
+
False,
|
|
644
|
+
"expected install, update, build, test, and signoff through package.json, Makefile, "
|
|
645
|
+
"scripts/dev.py, or verb-named scripts",
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _package_script_verbs(root: Path) -> set[str]:
|
|
650
|
+
path = root / "package.json"
|
|
651
|
+
if not path.exists():
|
|
652
|
+
return set()
|
|
653
|
+
raw_data: object = json.loads(path.read_text(encoding="utf-8"))
|
|
654
|
+
if not isinstance(raw_data, dict):
|
|
655
|
+
return set()
|
|
656
|
+
data = cast(Mapping[str, object], raw_data)
|
|
657
|
+
scripts = data.get("scripts")
|
|
658
|
+
if not isinstance(scripts, dict):
|
|
659
|
+
return set()
|
|
660
|
+
scripts_mapping = cast(Mapping[str, object], scripts)
|
|
661
|
+
return {key for key in scripts_mapping if key in STANDARD_COMMAND_VERBS}
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _makefile_verbs(root: Path) -> set[str]:
|
|
665
|
+
verbs: set[str] = set()
|
|
666
|
+
for path in (root / "Makefile", root / "makefile"):
|
|
667
|
+
if not path.exists():
|
|
668
|
+
continue
|
|
669
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
670
|
+
stripped = line.strip()
|
|
671
|
+
for verb in STANDARD_COMMAND_VERBS:
|
|
672
|
+
if stripped.startswith(f"{verb}:"):
|
|
673
|
+
verbs.add(verb)
|
|
674
|
+
return verbs
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _dev_py_verbs(root: Path) -> set[str]:
|
|
678
|
+
path = root / "scripts" / "dev.py"
|
|
679
|
+
if not path.exists():
|
|
680
|
+
return set()
|
|
681
|
+
text = path.read_text(encoding="utf-8")
|
|
682
|
+
return {verb for verb in STANDARD_COMMAND_VERBS if verb in text}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _script_file_verbs(root: Path) -> set[str]:
|
|
686
|
+
verbs: set[str] = set()
|
|
687
|
+
suffixes = (".ps1", ".sh", ".bat", ".cmd")
|
|
688
|
+
for verb in STANDARD_COMMAND_VERBS:
|
|
689
|
+
for directory in (root, root / "scripts"):
|
|
690
|
+
if any((directory / f"{verb}{suffix}").exists() for suffix in suffixes):
|
|
691
|
+
verbs.add(verb)
|
|
692
|
+
return verbs
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _check_web_signoff_policy(root: Path) -> CheckResult:
|
|
696
|
+
missing_scripts = [
|
|
697
|
+
relative
|
|
698
|
+
for relative in ("scripts/js_hygiene.py", "scripts/css_hygiene.py")
|
|
699
|
+
if not (root / relative).exists()
|
|
700
|
+
]
|
|
701
|
+
if not missing_scripts:
|
|
702
|
+
return CheckResult("web signoff", True, "JS and CSS hygiene scripts are present")
|
|
703
|
+
if (root / "package.json").exists():
|
|
704
|
+
return CheckResult("web signoff", True, "package.json is present for JS/CSS tooling")
|
|
705
|
+
return CheckResult(
|
|
706
|
+
"web signoff",
|
|
707
|
+
False,
|
|
708
|
+
"expected scripts/js_hygiene.py and scripts/css_hygiene.py, or package.json",
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _msbuild_properties(path: Path) -> dict[str, str]:
|
|
713
|
+
root = ElementTree.fromstring(path.read_text(encoding="utf-8"))
|
|
714
|
+
properties: dict[str, str] = {}
|
|
715
|
+
for property_group in root.findall("PropertyGroup"):
|
|
716
|
+
for child in list(property_group):
|
|
717
|
+
if child.text is not None:
|
|
718
|
+
properties[child.tag] = child.text.strip().lower()
|
|
719
|
+
return properties
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _read_simple_yaml_map(path: Path) -> dict[str, str]:
|
|
723
|
+
settings: dict[str, str] = {}
|
|
724
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
725
|
+
stripped = line.strip()
|
|
726
|
+
if not stripped or stripped.startswith("#") or ":" not in stripped:
|
|
727
|
+
continue
|
|
728
|
+
key, value = stripped.split(":", 1)
|
|
729
|
+
settings[key.strip()] = value.strip().strip('"').strip("'")
|
|
730
|
+
return settings
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _mapping_list(values: list[object]) -> list[Mapping[str, object]]:
|
|
734
|
+
items: list[Mapping[str, object]] = []
|
|
735
|
+
for value in values:
|
|
736
|
+
if isinstance(value, dict):
|
|
737
|
+
items.append(cast(Mapping[str, object], value))
|
|
738
|
+
return items
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _preset_generators(presets: list[Mapping[str, object]]) -> list[str]:
|
|
742
|
+
generators: list[str] = []
|
|
743
|
+
for preset in presets:
|
|
744
|
+
generator = preset.get("generator")
|
|
745
|
+
if isinstance(generator, str):
|
|
746
|
+
generators.append(generator)
|
|
747
|
+
return generators
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _has_compile_commands_enabled(presets: list[Mapping[str, object]]) -> bool:
|
|
751
|
+
for preset in presets:
|
|
752
|
+
cache_variables_raw = preset.get("cacheVariables")
|
|
753
|
+
if not isinstance(cache_variables_raw, dict):
|
|
754
|
+
continue
|
|
755
|
+
cache_variables = cast(Mapping[str, object], cache_variables_raw)
|
|
756
|
+
value = cache_variables.get("CMAKE_EXPORT_COMPILE_COMMANDS")
|
|
757
|
+
if value is True or value == "ON":
|
|
758
|
+
return True
|
|
759
|
+
return False
|