code-standards 7.0.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.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
import errno
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
import tokenize
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
from typing import TYPE_CHECKING, NamedTuple, TypedDict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterator, Sequence
|
|
21
|
+
from typing import TextIO
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_SUFFIXES = MappingProxyType(
|
|
25
|
+
{
|
|
26
|
+
".hcl": "iac",
|
|
27
|
+
".js": "typescript",
|
|
28
|
+
".jsx": "typescript",
|
|
29
|
+
".md": "markdown",
|
|
30
|
+
".mdx": "markdown",
|
|
31
|
+
".py": "python",
|
|
32
|
+
".sql": "sql",
|
|
33
|
+
".tf": "iac",
|
|
34
|
+
".tfvars": "iac",
|
|
35
|
+
".toml": "config",
|
|
36
|
+
".ts": "typescript",
|
|
37
|
+
".tsx": "typescript",
|
|
38
|
+
".yaml": "config",
|
|
39
|
+
".yml": "config",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
_SKIP_PARTS = frozenset(
|
|
43
|
+
{".git", ".venv", ".worktrees", "node_modules", "dist", "build", "coverage", "vendor", "vendored"}
|
|
44
|
+
)
|
|
45
|
+
_BOUNDARY_RE = re.compile(r"(?<=[.!?])[\"'`)\]]*\s+(?=[A-Z0-9`])")
|
|
46
|
+
_BULLET_RE = re.compile(r"^\s*(?:[-*+] |\d+[.)] )")
|
|
47
|
+
_SQL_DOLLAR_TAG_RE: re.Pattern[str] = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$")
|
|
48
|
+
_SECOND_SENTENCE = 2
|
|
49
|
+
_DIRECTORY_FLAGS = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
50
|
+
_READ_FLAGS = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
51
|
+
_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Record(TypedDict):
|
|
55
|
+
repository: str
|
|
56
|
+
path: str
|
|
57
|
+
line: int
|
|
58
|
+
language: str
|
|
59
|
+
kind: str
|
|
60
|
+
sentences: int
|
|
61
|
+
text: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _CommentUnit(NamedTuple):
|
|
65
|
+
line: int
|
|
66
|
+
kind: str
|
|
67
|
+
text: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _require_supported_platform() -> None:
|
|
71
|
+
if os.name == "nt" or not hasattr(os, "fwalk"):
|
|
72
|
+
msg = "comment-corpus maintenance requires POSIX descriptor-relative filesystem operations"
|
|
73
|
+
raise OSError(msg)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def records(roots: Sequence[Path]) -> Iterator[Record]:
|
|
77
|
+
_require_supported_platform()
|
|
78
|
+
for root in roots:
|
|
79
|
+
resolved_root = root.resolve(strict=True)
|
|
80
|
+
root_descriptor = os.open(resolved_root, _DIRECTORY_FLAGS)
|
|
81
|
+
try:
|
|
82
|
+
for directory, names, filenames, directory_descriptor in os.fwalk(
|
|
83
|
+
".", topdown=True, follow_symlinks=False, dir_fd=root_descriptor
|
|
84
|
+
):
|
|
85
|
+
names[:] = [name for name in names if name not in _SKIP_PARTS and not name.startswith(".")]
|
|
86
|
+
for filename in filenames:
|
|
87
|
+
relative = Path(directory, filename)
|
|
88
|
+
language = _SUFFIXES.get(relative.suffix.lower())
|
|
89
|
+
if language is None:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
source = _read_regular_file(directory_descriptor, filename)
|
|
93
|
+
except OSError:
|
|
94
|
+
continue
|
|
95
|
+
if source is None:
|
|
96
|
+
continue
|
|
97
|
+
comments = _comments(language, source)
|
|
98
|
+
for line, kind, value in comments:
|
|
99
|
+
yield {
|
|
100
|
+
"repository": resolved_root.name,
|
|
101
|
+
"path": relative.as_posix().removeprefix("./"),
|
|
102
|
+
"line": line,
|
|
103
|
+
"language": language,
|
|
104
|
+
"kind": kind,
|
|
105
|
+
"sentences": _sentence_units(value),
|
|
106
|
+
"text": value,
|
|
107
|
+
}
|
|
108
|
+
finally:
|
|
109
|
+
os.close(root_descriptor)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_regular_file(directory_descriptor: int, filename: str) -> str | None:
|
|
113
|
+
descriptor = os.open(filename, _READ_FLAGS, dir_fd=directory_descriptor)
|
|
114
|
+
try:
|
|
115
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
116
|
+
return None
|
|
117
|
+
with os.fdopen(descriptor, encoding="utf-8", errors="replace") as source:
|
|
118
|
+
descriptor = -1
|
|
119
|
+
return source.read()
|
|
120
|
+
finally:
|
|
121
|
+
if descriptor >= 0:
|
|
122
|
+
os.close(descriptor)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _comments(language: str, source: str) -> list[_CommentUnit]:
|
|
126
|
+
return {
|
|
127
|
+
"config": _hash_comments,
|
|
128
|
+
"iac": _hcl_comments,
|
|
129
|
+
"markdown": _markdown_comments,
|
|
130
|
+
"python": _python_comments,
|
|
131
|
+
"sql": _sql_comments,
|
|
132
|
+
"typescript": _javascript_comments,
|
|
133
|
+
}[language](source)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def emit_summary(roots: Sequence[Path], output: TextIO) -> int:
|
|
137
|
+
_require_supported_platform()
|
|
138
|
+
counts: Counter[tuple[str, str]] = Counter()
|
|
139
|
+
for index, root in enumerate(roots, start=1):
|
|
140
|
+
repository = f"repository-{index}"
|
|
141
|
+
for record in records([root]):
|
|
142
|
+
sentences = record["sentences"]
|
|
143
|
+
band = "0-1" if sentences <= 1 else "2" if sentences == _SECOND_SENTENCE else "3+"
|
|
144
|
+
counts[repository, band] += 1
|
|
145
|
+
output.write("repository\t0-1\t2\t3+\n")
|
|
146
|
+
output.writelines(
|
|
147
|
+
f"{repository}\t{counts[repository, '0-1']}\t{counts[repository, '2']}\t{counts[repository, '3+']}\n"
|
|
148
|
+
for repository in sorted({key[0] for key in counts})
|
|
149
|
+
)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_records(roots: Sequence[Path], destination: Path) -> int:
|
|
154
|
+
_require_supported_platform()
|
|
155
|
+
parent = destination.parent.resolve(strict=True)
|
|
156
|
+
staging = f".{destination.name}.{secrets.token_hex(8)}.tmp"
|
|
157
|
+
parent_descriptor = os.open(parent, _DIRECTORY_FLAGS)
|
|
158
|
+
staging_descriptor = -1
|
|
159
|
+
staging_status: os.stat_result | None = None
|
|
160
|
+
source_status: os.stat_result | None = None
|
|
161
|
+
source_status_box: list[os.stat_result] = []
|
|
162
|
+
records_owned = False
|
|
163
|
+
try:
|
|
164
|
+
_require_safe_output_parent(os.fstat(parent_descriptor))
|
|
165
|
+
_ = os.mkdir(staging, 0o700, dir_fd=parent_descriptor)
|
|
166
|
+
staging_status = os.stat(staging, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
167
|
+
staging_descriptor = os.open(staging, _DIRECTORY_FLAGS, dir_fd=parent_descriptor)
|
|
168
|
+
if not _same_inode(staging_status, os.fstat(staging_descriptor)):
|
|
169
|
+
message = "raw corpus staging directory changed before it was opened"
|
|
170
|
+
raise RuntimeError(message)
|
|
171
|
+
descriptor = os.open("records", _WRITE_FLAGS, 0o600, dir_fd=staging_descriptor)
|
|
172
|
+
records_owned = True
|
|
173
|
+
source_status = _write_and_publish(
|
|
174
|
+
roots,
|
|
175
|
+
destination_name=destination.name,
|
|
176
|
+
descriptor=descriptor,
|
|
177
|
+
staging_descriptor=staging_descriptor,
|
|
178
|
+
parent_descriptor=parent_descriptor,
|
|
179
|
+
source_status_box=source_status_box,
|
|
180
|
+
)
|
|
181
|
+
finally:
|
|
182
|
+
if source_status_box:
|
|
183
|
+
source_status = source_status_box[0]
|
|
184
|
+
_cleanup_staging(
|
|
185
|
+
staging=staging,
|
|
186
|
+
staging_status=staging_status,
|
|
187
|
+
staging_descriptor=staging_descriptor,
|
|
188
|
+
source_status=source_status,
|
|
189
|
+
records_owned=records_owned,
|
|
190
|
+
parent_descriptor=parent_descriptor,
|
|
191
|
+
)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _write_and_publish(
|
|
196
|
+
roots: Sequence[Path],
|
|
197
|
+
*,
|
|
198
|
+
destination_name: str,
|
|
199
|
+
descriptor: int,
|
|
200
|
+
staging_descriptor: int,
|
|
201
|
+
parent_descriptor: int,
|
|
202
|
+
source_status_box: list[os.stat_result],
|
|
203
|
+
) -> os.stat_result:
|
|
204
|
+
try:
|
|
205
|
+
source_status = os.fstat(descriptor)
|
|
206
|
+
source_status_box.append(source_status)
|
|
207
|
+
stream = os.fdopen(descriptor, "w", encoding="utf-8")
|
|
208
|
+
descriptor = -1
|
|
209
|
+
with stream as output:
|
|
210
|
+
output.writelines(json.dumps(record, ensure_ascii=False) + "\n" for record in records(roots))
|
|
211
|
+
output.flush()
|
|
212
|
+
os.fsync(output.fileno())
|
|
213
|
+
if not _path_matches("records", source_status, staging_descriptor):
|
|
214
|
+
message = "raw corpus staging file changed before publication"
|
|
215
|
+
raise RuntimeError(message)
|
|
216
|
+
os.link(
|
|
217
|
+
"records",
|
|
218
|
+
destination_name,
|
|
219
|
+
src_dir_fd=staging_descriptor,
|
|
220
|
+
dst_dir_fd=parent_descriptor,
|
|
221
|
+
follow_symlinks=False,
|
|
222
|
+
)
|
|
223
|
+
destination_status = os.stat(destination_name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
224
|
+
if not _same_inode(destination_status, source_status):
|
|
225
|
+
message = "raw corpus staging file changed before publication"
|
|
226
|
+
raise RuntimeError(message)
|
|
227
|
+
os.fsync(parent_descriptor)
|
|
228
|
+
return source_status
|
|
229
|
+
finally:
|
|
230
|
+
if descriptor >= 0:
|
|
231
|
+
os.close(descriptor)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _cleanup_staging(
|
|
235
|
+
*,
|
|
236
|
+
staging: str,
|
|
237
|
+
staging_status: os.stat_result | None,
|
|
238
|
+
staging_descriptor: int,
|
|
239
|
+
source_status: os.stat_result | None,
|
|
240
|
+
records_owned: bool,
|
|
241
|
+
parent_descriptor: int,
|
|
242
|
+
) -> None:
|
|
243
|
+
try:
|
|
244
|
+
if staging_descriptor >= 0:
|
|
245
|
+
try:
|
|
246
|
+
if source_status is not None:
|
|
247
|
+
_unlink_if_owned("records", source_status, staging_descriptor)
|
|
248
|
+
elif records_owned:
|
|
249
|
+
with suppress(FileNotFoundError):
|
|
250
|
+
os.unlink("records", dir_fd=staging_descriptor)
|
|
251
|
+
finally:
|
|
252
|
+
os.close(staging_descriptor)
|
|
253
|
+
finally:
|
|
254
|
+
try:
|
|
255
|
+
if staging_status is not None:
|
|
256
|
+
_rmdir_if_owned(staging, staging_status, parent_descriptor)
|
|
257
|
+
finally:
|
|
258
|
+
os.close(parent_descriptor)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _require_safe_output_parent(parent_status: os.stat_result) -> None:
|
|
262
|
+
writable_by_others = parent_status.st_mode & (stat.S_IWGRP | stat.S_IWOTH)
|
|
263
|
+
if writable_by_others and not parent_status.st_mode & stat.S_ISVTX:
|
|
264
|
+
message = "raw corpus output directory must not be group/world writable unless it has the sticky bit"
|
|
265
|
+
raise PermissionError(message)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _same_inode(left: os.stat_result, right: os.stat_result) -> bool:
|
|
269
|
+
return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _path_matches(name: str, expected: os.stat_result, directory_descriptor: int) -> bool:
|
|
273
|
+
try:
|
|
274
|
+
current = os.stat(name, dir_fd=directory_descriptor, follow_symlinks=False)
|
|
275
|
+
except FileNotFoundError:
|
|
276
|
+
return False
|
|
277
|
+
return _same_inode(current, expected)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _unlink_if_owned(name: str, expected: os.stat_result, directory_descriptor: int) -> None:
|
|
281
|
+
if _path_matches(name, expected, directory_descriptor):
|
|
282
|
+
os.unlink(name, dir_fd=directory_descriptor)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _rmdir_if_owned(name: str, expected: os.stat_result, directory_descriptor: int) -> None:
|
|
286
|
+
if _path_matches(name, expected, directory_descriptor):
|
|
287
|
+
try:
|
|
288
|
+
os.rmdir(name, dir_fd=directory_descriptor)
|
|
289
|
+
except OSError as error:
|
|
290
|
+
if error.errno not in {errno.EEXIST, errno.ENOTEMPTY}:
|
|
291
|
+
raise
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _python_comments(source: str) -> list[_CommentUnit]:
|
|
295
|
+
found: list[_CommentUnit] = []
|
|
296
|
+
try:
|
|
297
|
+
tree = ast.parse(source)
|
|
298
|
+
except SyntaxError:
|
|
299
|
+
tree = None
|
|
300
|
+
if tree is not None:
|
|
301
|
+
for node in ast.walk(tree):
|
|
302
|
+
if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) or not node.body:
|
|
303
|
+
continue
|
|
304
|
+
first = node.body[0]
|
|
305
|
+
if (
|
|
306
|
+
isinstance(first, ast.Expr)
|
|
307
|
+
and isinstance(first.value, ast.Constant)
|
|
308
|
+
and isinstance(first.value.value, str)
|
|
309
|
+
):
|
|
310
|
+
found.append(_CommentUnit(first.lineno, "docstring", first.value.value))
|
|
311
|
+
with suppress(tokenize.TokenError, IndentationError):
|
|
312
|
+
found.extend(
|
|
313
|
+
_CommentUnit(token.start[0], "comment", token.string.removeprefix("#").strip())
|
|
314
|
+
for token in tokenize.generate_tokens(io.StringIO(source).readline)
|
|
315
|
+
if token.type == tokenize.COMMENT
|
|
316
|
+
)
|
|
317
|
+
return found
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _sentence_units(text: str) -> int:
|
|
321
|
+
cleaned = re.sub(r"https?://\S+", "URL", text)
|
|
322
|
+
cleaned = re.sub(r"`[^`\n]+`", "CODE", cleaned)
|
|
323
|
+
cleaned = re.sub(r"\b\d+\.\d+\b", "NUMBER", cleaned)
|
|
324
|
+
cleaned = re.sub(r"\b(?:e\.g\.|i\.e\.|vs\.|etc\.)", "ABBREVIATION", cleaned, flags=re.IGNORECASE)
|
|
325
|
+
units = 0
|
|
326
|
+
prose: list[str] = []
|
|
327
|
+
for raw in cleaned.splitlines():
|
|
328
|
+
line = raw.strip().lstrip("*").strip()
|
|
329
|
+
if not line or re.fullmatch(r"[A-Za-z][A-Za-z ]+:", line):
|
|
330
|
+
continue
|
|
331
|
+
if _BULLET_RE.match(line):
|
|
332
|
+
units += 1
|
|
333
|
+
else:
|
|
334
|
+
prose.append(line)
|
|
335
|
+
paragraph = " ".join(prose).strip()
|
|
336
|
+
return units + (len(_BOUNDARY_RE.split(paragraph)) if paragraph else 0)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _javascript_comments(source: str) -> list[_CommentUnit]:
|
|
340
|
+
found: list[_CommentUnit] = []
|
|
341
|
+
index = 0
|
|
342
|
+
line = 1
|
|
343
|
+
quote: str | None = None
|
|
344
|
+
while index < len(source):
|
|
345
|
+
char = source[index]
|
|
346
|
+
following = source[index + 1] if index + 1 < len(source) else ""
|
|
347
|
+
if quote is not None:
|
|
348
|
+
if char == "\\":
|
|
349
|
+
index += 2
|
|
350
|
+
continue
|
|
351
|
+
if char == quote:
|
|
352
|
+
quote = None
|
|
353
|
+
line += char == "\n"
|
|
354
|
+
index += 1
|
|
355
|
+
continue
|
|
356
|
+
if char in {'"', "'", "`"}:
|
|
357
|
+
quote = char
|
|
358
|
+
index += 1
|
|
359
|
+
continue
|
|
360
|
+
if char == "/" and following == "/":
|
|
361
|
+
end = source.find("\n", index)
|
|
362
|
+
end = len(source) if end < 0 else end
|
|
363
|
+
found.append(_CommentUnit(line, "comment", source[index + 2 : end].strip()))
|
|
364
|
+
index = end
|
|
365
|
+
continue
|
|
366
|
+
if char == "/" and following == "*":
|
|
367
|
+
end = source.find("*/", index + 2)
|
|
368
|
+
end = len(source) - 2 if end < 0 else end
|
|
369
|
+
value = source[index + 2 : end]
|
|
370
|
+
found.append(_CommentUnit(line, "jsdoc" if value.startswith("*") else "comment", value.strip("* \n")))
|
|
371
|
+
line += value.count("\n")
|
|
372
|
+
index = end + 2
|
|
373
|
+
continue
|
|
374
|
+
line += char == "\n"
|
|
375
|
+
index += 1
|
|
376
|
+
return found
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _sql_comments(source: str) -> list[_CommentUnit]:
|
|
380
|
+
found: list[_CommentUnit] = []
|
|
381
|
+
index = 0
|
|
382
|
+
line = 1
|
|
383
|
+
quote: str | None = None
|
|
384
|
+
dollar_tag: str | None = None
|
|
385
|
+
while index < len(source):
|
|
386
|
+
char = source[index]
|
|
387
|
+
pair = source[index : index + 2]
|
|
388
|
+
if dollar_tag is not None:
|
|
389
|
+
if source.startswith(dollar_tag, index):
|
|
390
|
+
index += len(dollar_tag)
|
|
391
|
+
dollar_tag = None
|
|
392
|
+
continue
|
|
393
|
+
line += char == "\n"
|
|
394
|
+
index += 1
|
|
395
|
+
continue
|
|
396
|
+
if quote is not None:
|
|
397
|
+
if char == quote and source[index + 1 : index + 2] == quote:
|
|
398
|
+
index += 2
|
|
399
|
+
continue
|
|
400
|
+
if char == quote:
|
|
401
|
+
quote = None
|
|
402
|
+
line += char == "\n"
|
|
403
|
+
index += 1
|
|
404
|
+
continue
|
|
405
|
+
if char in {"'", '"'}:
|
|
406
|
+
quote = char
|
|
407
|
+
index += 1
|
|
408
|
+
continue
|
|
409
|
+
if char == "$" and (match := _SQL_DOLLAR_TAG_RE.match(source, index)):
|
|
410
|
+
dollar_tag = str(match.group(0))
|
|
411
|
+
index += len(dollar_tag)
|
|
412
|
+
continue
|
|
413
|
+
if pair == "--":
|
|
414
|
+
end = source.find("\n", index)
|
|
415
|
+
end = len(source) if end < 0 else end
|
|
416
|
+
found.append(_CommentUnit(line, "comment", source[index + 2 : end].strip()))
|
|
417
|
+
index = end
|
|
418
|
+
continue
|
|
419
|
+
if pair == "/*":
|
|
420
|
+
end = source.find("*/", index + 2)
|
|
421
|
+
end = len(source) if end < 0 else end
|
|
422
|
+
value = source[index + 2 : end]
|
|
423
|
+
found.append(_CommentUnit(line, "comment", value.strip("* \n")))
|
|
424
|
+
line += value.count("\n")
|
|
425
|
+
index = min(len(source), end + 2)
|
|
426
|
+
continue
|
|
427
|
+
line += char == "\n"
|
|
428
|
+
index += 1
|
|
429
|
+
return found
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _hash_comments(source: str) -> list[_CommentUnit]:
|
|
433
|
+
found: list[_CommentUnit] = []
|
|
434
|
+
block_indent: int | None = None
|
|
435
|
+
for line_number, raw in enumerate(source.splitlines(), start=1):
|
|
436
|
+
indent = len(raw) - len(raw.lstrip())
|
|
437
|
+
if block_indent is not None:
|
|
438
|
+
if raw.strip() and indent > block_indent:
|
|
439
|
+
continue
|
|
440
|
+
block_indent = None
|
|
441
|
+
if re.search(r"[>|][+-]?\s*(?:#.*)?$", raw):
|
|
442
|
+
block_indent = indent
|
|
443
|
+
marker = _hash_comment_index(raw)
|
|
444
|
+
if marker is not None:
|
|
445
|
+
found.append(_CommentUnit(line_number, "comment", raw[marker + 1 :].strip()))
|
|
446
|
+
return found
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _hcl_comments(source: str) -> list[_CommentUnit]:
|
|
450
|
+
masked = _mask_hcl_heredocs(source)
|
|
451
|
+
found = _javascript_comments(masked)
|
|
452
|
+
found.extend(_hash_comments(masked))
|
|
453
|
+
return sorted(set(found))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _mask_hcl_heredocs(source: str) -> str:
|
|
457
|
+
lines = source.splitlines(keepends=True)
|
|
458
|
+
terminator: str | None = None
|
|
459
|
+
masked: list[str] = []
|
|
460
|
+
for raw in lines:
|
|
461
|
+
stripped = raw.strip()
|
|
462
|
+
if terminator is not None:
|
|
463
|
+
masked.append(raw if stripped == terminator else "\n" if raw.endswith("\n") else "")
|
|
464
|
+
if stripped == terminator:
|
|
465
|
+
terminator = None
|
|
466
|
+
continue
|
|
467
|
+
match = re.search(r"<<-?\s*([A-Za-z_][A-Za-z0-9_]*)\s*$", raw.rstrip("\n"))
|
|
468
|
+
terminator = match.group(1) if match is not None else None
|
|
469
|
+
masked.append(raw)
|
|
470
|
+
return "".join(masked)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _hash_comment_index(line: str) -> int | None:
|
|
474
|
+
quote: str | None = None
|
|
475
|
+
escaped = False
|
|
476
|
+
for index, char in enumerate(line):
|
|
477
|
+
if escaped:
|
|
478
|
+
escaped = False
|
|
479
|
+
continue
|
|
480
|
+
if char == "\\" and quote == '"':
|
|
481
|
+
escaped = True
|
|
482
|
+
continue
|
|
483
|
+
if quote is not None:
|
|
484
|
+
if char == quote:
|
|
485
|
+
quote = None
|
|
486
|
+
continue
|
|
487
|
+
if char in {'"', "'"}:
|
|
488
|
+
quote = char
|
|
489
|
+
elif char == "#":
|
|
490
|
+
return index
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _markdown_comments(source: str) -> list[_CommentUnit]: # ruff: ignore[too-many-locals] -- scanner keeps independent fence and HTML-comment state
|
|
495
|
+
found: list[_CommentUnit] = []
|
|
496
|
+
in_fence = False
|
|
497
|
+
fence = ""
|
|
498
|
+
in_html = False
|
|
499
|
+
html_start = 0
|
|
500
|
+
html_parts: list[str] = []
|
|
501
|
+
for line_number, raw in enumerate(source.splitlines(), start=1):
|
|
502
|
+
stripped = raw.lstrip()
|
|
503
|
+
if not in_html and (match := re.match(r"(`{3,}|~{3,})", stripped)):
|
|
504
|
+
marker = match.group(1)
|
|
505
|
+
if not in_fence:
|
|
506
|
+
in_fence, fence = True, marker[0]
|
|
507
|
+
elif marker[0] == fence:
|
|
508
|
+
in_fence, fence = False, ""
|
|
509
|
+
continue
|
|
510
|
+
if in_fence:
|
|
511
|
+
continue
|
|
512
|
+
if in_html:
|
|
513
|
+
before, separator, _after = raw.partition("-->")
|
|
514
|
+
html_parts.append(before)
|
|
515
|
+
if separator:
|
|
516
|
+
found.append(_CommentUnit(html_start, "comment", "\n".join(html_parts).strip()))
|
|
517
|
+
in_html = False
|
|
518
|
+
html_parts = []
|
|
519
|
+
continue
|
|
520
|
+
if stripped.startswith("[//]:"):
|
|
521
|
+
found.append(_CommentUnit(line_number, "comment", stripped.removeprefix("[//]:").strip()))
|
|
522
|
+
continue
|
|
523
|
+
before, opener, rest = raw.partition("<!--")
|
|
524
|
+
if not opener:
|
|
525
|
+
continue
|
|
526
|
+
_ = before
|
|
527
|
+
body, closer, _after = rest.partition("-->")
|
|
528
|
+
if closer:
|
|
529
|
+
found.append(_CommentUnit(line_number, "comment", body.strip()))
|
|
530
|
+
else:
|
|
531
|
+
in_html = True
|
|
532
|
+
html_start = line_number
|
|
533
|
+
html_parts = [rest]
|
|
534
|
+
if in_html:
|
|
535
|
+
found.append(_CommentUnit(html_start, "comment", "\n".join(html_parts).strip()))
|
|
536
|
+
return found
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING, Final
|
|
5
|
+
|
|
6
|
+
from sarj_standards._meta import CONFIGS_DIR
|
|
7
|
+
from sarj_standards.libs.linting import library_policy
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_RUFF_APPLICATION: Final = CONFIGS_DIR / "ruff.application.toml"
|
|
16
|
+
_ESLINT_APPLICATION: Final = CONFIGS_DIR / "eslint.application.mjs"
|
|
17
|
+
_RUFF_MARKER: Final = "[lint.per-file-ignores]"
|
|
18
|
+
_ESLINT_MARKER: Final = " paths: [\n"
|
|
19
|
+
_ESLINT_PATTERNS_MARKER: Final = ' patterns: ["*/index", "*/index.ts"],\n'
|
|
20
|
+
_ESLINT_CONFIG_END: Final = "\n ];\n}\n\nconst config = createConfig();\nexport default config;\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render_ruff_application() -> str:
|
|
24
|
+
standard = (CONFIGS_DIR / "ruff.strict.toml").read_text(encoding="utf-8")
|
|
25
|
+
entries = "".join(
|
|
26
|
+
f"{json.dumps(name)}.msg = {json.dumps(message)}\n" for name, message in sorted(_python_bans().items())
|
|
27
|
+
)
|
|
28
|
+
addition = f"# Generated application-profile library policy. Edit the catalog, not this file.\n{entries}\n"
|
|
29
|
+
if _RUFF_MARKER not in standard:
|
|
30
|
+
msg = f"ruff.strict.toml is missing generation marker {_RUFF_MARKER!r}"
|
|
31
|
+
raise ValueError(msg)
|
|
32
|
+
return standard.replace(_RUFF_MARKER, addition + _RUFF_MARKER, 1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _python_bans() -> Mapping[str, str]:
|
|
36
|
+
return library_policy.python_banned_api()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_eslint_application() -> str:
|
|
40
|
+
standard = (CONFIGS_DIR / "eslint.strict.mjs").read_text(encoding="utf-8")
|
|
41
|
+
bans = _typescript_bans()
|
|
42
|
+
entries = "".join(f" {json.dumps(dict(entry), sort_keys=True)},\n" for entry in bans)
|
|
43
|
+
addition = (
|
|
44
|
+
f" // Generated application-profile library policy. Edit the catalog, not this file.\n{entries}"
|
|
45
|
+
)
|
|
46
|
+
if _ESLINT_MARKER not in standard:
|
|
47
|
+
msg = "eslint.strict.mjs is missing the no-restricted-imports generation marker"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
with_paths = standard.replace(_ESLINT_MARKER, _ESLINT_MARKER + addition, 1)
|
|
50
|
+
patterns = [
|
|
51
|
+
{
|
|
52
|
+
"group": [f"{name}/*"],
|
|
53
|
+
"message": entry.get("message", "Use the application profile's preferred library."),
|
|
54
|
+
}
|
|
55
|
+
for entry in bans
|
|
56
|
+
if isinstance(name := entry.get("name"), str)
|
|
57
|
+
]
|
|
58
|
+
pattern_lines = "".join(f" {json.dumps(pattern, sort_keys=True)},\n" for pattern in patterns)
|
|
59
|
+
pattern_block = (
|
|
60
|
+
f' patterns: [\n {{"group": ["*/index", "*/index.ts"]}},\n{pattern_lines} ],\n'
|
|
61
|
+
)
|
|
62
|
+
if _ESLINT_PATTERNS_MARKER not in with_paths:
|
|
63
|
+
msg = "eslint.strict.mjs is missing the no-restricted-imports patterns generation marker"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
with_static_policy = with_paths.replace(_ESLINT_PATTERNS_MARKER, pattern_block, 1)
|
|
66
|
+
runtime_policy = json.dumps(_typescript_runtime_bans(), indent=2, sort_keys=True)
|
|
67
|
+
indented_runtime_policy = runtime_policy.replace("\n", "\n ")
|
|
68
|
+
application_block = (
|
|
69
|
+
"\n {\n"
|
|
70
|
+
' files: ["**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}"],\n'
|
|
71
|
+
" rules: {\n"
|
|
72
|
+
' "@sarj/no-restricted-library-load": [\n'
|
|
73
|
+
' "error",\n'
|
|
74
|
+
f" {{ libraries: {indented_runtime_policy} }},\n"
|
|
75
|
+
" ],\n"
|
|
76
|
+
' "@sarj/prefer-native-random-uuid": "error",\n'
|
|
77
|
+
' "@sarj/prefer-shadcn-primitives": "error",\n'
|
|
78
|
+
" },\n"
|
|
79
|
+
" },\n"
|
|
80
|
+
"\n {\n"
|
|
81
|
+
" files: [\n"
|
|
82
|
+
' "**/*.{test,spec,e2e}.{js,jsx,ts,tsx}",\n'
|
|
83
|
+
' "**/test/**",\n'
|
|
84
|
+
' "**/tests/**",\n'
|
|
85
|
+
' "**/__tests__/**",\n'
|
|
86
|
+
' "**/fixtures/**",\n'
|
|
87
|
+
' "**/e2e/**",\n'
|
|
88
|
+
' "**/e2e-apps/**",\n'
|
|
89
|
+
' "**/perf-regression/**",\n'
|
|
90
|
+
' "**/components/ui/**",\n'
|
|
91
|
+
' "**/components/design-system/**",\n'
|
|
92
|
+
" ],\n"
|
|
93
|
+
" rules: {\n"
|
|
94
|
+
' "@sarj/prefer-shadcn-primitives": "off",\n'
|
|
95
|
+
" },\n"
|
|
96
|
+
" },\n"
|
|
97
|
+
)
|
|
98
|
+
if _ESLINT_CONFIG_END not in with_static_policy:
|
|
99
|
+
msg = "eslint.strict.mjs is missing its application-rule generation marker"
|
|
100
|
+
raise ValueError(msg)
|
|
101
|
+
return with_static_policy.replace(
|
|
102
|
+
_ESLINT_CONFIG_END,
|
|
103
|
+
application_block + _ESLINT_CONFIG_END,
|
|
104
|
+
1,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _typescript_bans() -> Sequence[Mapping[str, object]]:
|
|
109
|
+
return tuple(
|
|
110
|
+
{"name": entry.name, "message": entry.message} for entry in library_policy.typescript_restricted_imports()
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _typescript_runtime_bans() -> list[dict[str, str]]:
|
|
115
|
+
restrictions: list[dict[str, str]] = []
|
|
116
|
+
for entry in library_policy.catalog():
|
|
117
|
+
if entry.ecosystem != "typescript":
|
|
118
|
+
continue
|
|
119
|
+
restrictions.extend(
|
|
120
|
+
{
|
|
121
|
+
"id": entry.id,
|
|
122
|
+
"module": module,
|
|
123
|
+
"replacement": entry.replacement,
|
|
124
|
+
"note": entry.message,
|
|
125
|
+
}
|
|
126
|
+
for module in entry.imports
|
|
127
|
+
)
|
|
128
|
+
return restrictions
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def generated_configs() -> Mapping[Path, str]:
|
|
132
|
+
return {
|
|
133
|
+
_RUFF_APPLICATION: render_ruff_application(),
|
|
134
|
+
_ESLINT_APPLICATION: render_eslint_application(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sync(*, check: bool) -> bool:
|
|
139
|
+
expected = generated_configs()
|
|
140
|
+
if check:
|
|
141
|
+
return all(
|
|
142
|
+
path.is_file() and path.read_text(encoding="utf-8") == contents for path, contents in expected.items()
|
|
143
|
+
)
|
|
144
|
+
for path, contents in expected.items():
|
|
145
|
+
_ = path.write_text(contents, encoding="utf-8")
|
|
146
|
+
return True
|