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.
Files changed (99) hide show
  1. code_standards-7.0.0.dist-info/METADATA +53 -0
  2. code_standards-7.0.0.dist-info/RECORD +99 -0
  3. code_standards-7.0.0.dist-info/WHEEL +4 -0
  4. code_standards-7.0.0.dist-info/entry_points.txt +3 -0
  5. code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
  6. sarj_standards/__init__.py +30 -0
  7. sarj_standards/__main__.py +5 -0
  8. sarj_standards/_meta.py +22 -0
  9. sarj_standards/api.py +890 -0
  10. sarj_standards/cli/__init__.py +0 -0
  11. sarj_standards/cli/main.py +2466 -0
  12. sarj_standards/configs/cli-reference.v1.json +1 -0
  13. sarj_standards/configs/doctor.config.json +22 -0
  14. sarj_standards/configs/eslint.application.mjs +1366 -0
  15. sarj_standards/configs/eslint.peers.json +44 -0
  16. sarj_standards/configs/eslint.strict.mjs +1060 -0
  17. sarj_standards/configs/markdownlint.strict.yaml +12 -0
  18. sarj_standards/configs/pyright.strict.json +96 -0
  19. sarj_standards/configs/ruff.application.toml +363 -0
  20. sarj_standards/configs/ruff.strict.toml +338 -0
  21. sarj_standards/configs/rule-inventory.v1.json +1 -0
  22. sarj_standards/configs/rule-ledger.json +846 -0
  23. sarj_standards/configs/rule-warning-levels.v1.json +1 -0
  24. sarj_standards/configs/taplo.strict.toml +14 -0
  25. sarj_standards/configs/yamllint.strict.yaml +25 -0
  26. sarj_standards/libs/__init__.py +0 -0
  27. sarj_standards/libs/adoption/__init__.py +0 -0
  28. sarj_standards/libs/adoption/configs.py +36 -0
  29. sarj_standards/libs/adoption/doctor.py +1346 -0
  30. sarj_standards/libs/adoption/exclusions.py +66 -0
  31. sarj_standards/libs/adoption/hooks.py +423 -0
  32. sarj_standards/libs/adoption/launcher.py +240 -0
  33. sarj_standards/libs/adoption/lifecycle.py +493 -0
  34. sarj_standards/libs/adoption/manifest.py +550 -0
  35. sarj_standards/libs/adoption/packagemanager.py +285 -0
  36. sarj_standards/libs/adoption/retired_suppressions.py +371 -0
  37. sarj_standards/libs/adoption/scaffold.py +1660 -0
  38. sarj_standards/libs/adoption/service.py +441 -0
  39. sarj_standards/libs/adoption/transaction.py +274 -0
  40. sarj_standards/libs/adoption/upgrade.py +516 -0
  41. sarj_standards/libs/adoption/uvtool.py +62 -0
  42. sarj_standards/libs/catalogs/__init__.py +9 -0
  43. sarj_standards/libs/catalogs/slack_automations.py +627 -0
  44. sarj_standards/libs/corpus/__init__.py +25 -0
  45. sarj_standards/libs/corpus/manifest.py +211 -0
  46. sarj_standards/libs/corpus/snapshot.py +222 -0
  47. sarj_standards/libs/diagnostics/__init__.py +65 -0
  48. sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
  49. sarj_standards/libs/diagnostics/baseline.py +131 -0
  50. sarj_standards/libs/diagnostics/models.py +574 -0
  51. sarj_standards/libs/diagnostics/serialize.py +290 -0
  52. sarj_standards/libs/diagnostics/source.py +172 -0
  53. sarj_standards/libs/filesystem.py +11 -0
  54. sarj_standards/libs/linting/__init__.py +0 -0
  55. sarj_standards/libs/linting/analysis.py +422 -0
  56. sarj_standards/libs/linting/external.py +1454 -0
  57. sarj_standards/libs/linting/library_policy.py +688 -0
  58. sarj_standards/libs/linting/policy.py +152 -0
  59. sarj_standards/libs/linting/runner.py +442 -0
  60. sarj_standards/libs/linting/textlint.py +1605 -0
  61. sarj_standards/libs/release/__init__.py +98 -0
  62. sarj_standards/libs/release/_values.py +24 -0
  63. sarj_standards/libs/release/artifacts.py +191 -0
  64. sarj_standards/libs/release/causality.py +80 -0
  65. sarj_standards/libs/release/changes.py +48 -0
  66. sarj_standards/libs/release/process.py +128 -0
  67. sarj_standards/libs/release/publish.py +85 -0
  68. sarj_standards/libs/release/registry.py +271 -0
  69. sarj_standards/libs/release/release_age.py +218 -0
  70. sarj_standards/libs/release/rollout.py +1163 -0
  71. sarj_standards/libs/release/tags.py +373 -0
  72. sarj_standards/libs/release/typescript.py +191 -0
  73. sarj_standards/libs/repository/__init__.py +0 -0
  74. sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
  75. sarj_standards/libs/repository/comment_corpus.py +536 -0
  76. sarj_standards/libs/repository/config_generation.py +146 -0
  77. sarj_standards/libs/repository/docs.py +347 -0
  78. sarj_standards/libs/repository/hooks.py +118 -0
  79. sarj_standards/libs/repository/ledger.py +99 -0
  80. sarj_standards/libs/repository/repository.py +744 -0
  81. sarj_standards/libs/repository/rule_authoring.py +246 -0
  82. sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
  83. sarj_standards/libs/repository/rule_changes.py +318 -0
  84. sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
  85. sarj_standards/libs/repository/rule_lifecycle.py +167 -0
  86. sarj_standards/libs/repository/rule_maintenance.py +225 -0
  87. sarj_standards/libs/rules/__init__.py +74 -0
  88. sarj_standards/libs/rules/catalog.py +145 -0
  89. sarj_standards/libs/rules/contracts.py +382 -0
  90. sarj_standards/libs/rules/corpus_runner.py +365 -0
  91. sarj_standards/libs/rules/evaluation.py +177 -0
  92. sarj_standards/libs/setup/__init__.py +4 -0
  93. sarj_standards/libs/setup/repository.py +40 -0
  94. sarj_standards/py.typed +0 -0
  95. sarj_standards/schemas/__init__.py +4 -0
  96. sarj_standards/schemas/_paths.py +7 -0
  97. sarj_standards/schemas/rule-catalog.v1.json +1 -0
  98. sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
  99. 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