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,1605 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from fnmatch import fnmatch
5
+ from pathlib import Path, PurePosixPath
6
+ import re
7
+ import shlex
8
+ import sys
9
+ import tomllib
10
+ from types import MappingProxyType
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ ClassVar,
14
+ Final,
15
+ NamedTuple,
16
+ cast, # ruff: ignore[banned-api] -- typed boundary for PyYAML nodes.
17
+ )
18
+
19
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
20
+ import yaml
21
+ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode
22
+
23
+ from sarj_standards.libs.adoption.manifest import as_table, list_field, table_field
24
+ from sarj_standards.libs.rules.contracts import (
25
+ AutofixPolicy,
26
+ ExampleFile,
27
+ ExpectedOutcome,
28
+ Language,
29
+ MessageId,
30
+ RuleCategory,
31
+ RuleEngine,
32
+ RuleExample,
33
+ RuleId,
34
+ RuleSpec,
35
+ )
36
+
37
+
38
+ if TYPE_CHECKING:
39
+ from collections.abc import Mapping, Sequence
40
+
41
+
42
+ class _ShellOperands(NamedTuple):
43
+ operands: list[str]
44
+ explicit_pattern: bool
45
+
46
+
47
+ class _TextPolicy(NamedTuple):
48
+ durable: tuple[str, ...]
49
+ excluded: tuple[str, ...]
50
+
51
+
52
+ class _StandaloneComment(NamedTuple):
53
+ indent: int
54
+ body: str
55
+
56
+
57
+ class _MarkdownHtmlComment(NamedTuple):
58
+ line: int
59
+ body: str
60
+
61
+
62
+ class _ConfigScalarEntry(NamedTuple):
63
+ key: str
64
+ value: str
65
+
66
+
67
+ class _AttachedComment(NamedTuple):
68
+ line: int
69
+ owner_indent: int
70
+ weak: bool
71
+
72
+
73
+ class _YamlPair(NamedTuple):
74
+ key: Node
75
+ value: Node
76
+
77
+
78
+ class _ClaudePermissions(BaseModel):
79
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
80
+
81
+ allow: list[str] = Field(default_factory=list)
82
+
83
+
84
+ class _ClaudeSettings(BaseModel):
85
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
86
+
87
+ permissions: _ClaudePermissions = Field(default_factory=_ClaudePermissions)
88
+
89
+
90
+ _TEXT_SUFFIXES: Final = frozenset(
91
+ {
92
+ ".bash",
93
+ ".cfg",
94
+ ".conf",
95
+ ".env",
96
+ ".ini",
97
+ ".jsonc",
98
+ ".md",
99
+ ".mdx",
100
+ ".properties",
101
+ ".sh",
102
+ ".tftpl",
103
+ ".toml",
104
+ ".yaml",
105
+ ".yml",
106
+ ".zsh",
107
+ }
108
+ )
109
+ _TEXT_NAMES: Final = frozenset({"dockerfile", "gnumakefile", "justfile", "makefile"})
110
+ _MIN_EPHEMERAL_HEADINGS: Final = 2
111
+ _MIN_NUMBERED_FINDINGS: Final = 2
112
+ _LARGE_ARTIFACT_MIN_LINES: Final = 200
113
+ _LARGE_ARTIFACT_MIN_WORDS: Final = 1_500
114
+ _LARGE_ARTIFACT_MIN_SIGNALS: Final = 2
115
+ _WALL_MIN_ATTACHED: Final = 4
116
+ _WALL_MIN_WEAK: Final = 3
117
+ _WALL_MIN_WEAK_RATIO: Final = 0.75
118
+ _WALL_MAX_WORDS: Final = 18
119
+ _WALL_MIN_MATCHED_RATIO: Final = 0.5
120
+ _WALL_MAX_NOVEL_WORDS: Final = 2
121
+ _WALL_GROUP_MAX_LINES: Final = 24
122
+ _COMMENTED_CONFIG_MAX_WORDS: Final = 8
123
+ _COMMENTED_CONFIG_RUN_MIN: Final = 1
124
+ _COMMENTED_CONFIG_RUN_RATIO: Final = 0.5
125
+ _DURABLE_MARKDOWN: Final = (
126
+ "README.md",
127
+ "**/README.md",
128
+ "CHANGELOG.md",
129
+ "**/CHANGELOG.md",
130
+ "CONTRIBUTING.md",
131
+ "**/CONTRIBUTING.md",
132
+ "SECURITY.md",
133
+ "CODE_OF_CONDUCT.md",
134
+ "AGENTS.md",
135
+ "**/AGENTS.md",
136
+ "CLAUDE.md",
137
+ "**/CLAUDE.md",
138
+ "docs/**",
139
+ "**/docs/**",
140
+ ".github/**",
141
+ "adr/**",
142
+ "**/adr/**",
143
+ "architecture/**",
144
+ "**/architecture/**",
145
+ )
146
+ _ARTIFACT_NAME_RE = re.compile(
147
+ r"(?:^|[-_])(?:build|fix|implementation|qa)[-_]?(?:brief|report|"
148
+ r"log|notes?|plan|summary|results?)|^report[-_]|(?:improvement|end[-_]to[-_]end)[-_]plan|"
149
+ r"morning[-_]summary|diagnosis[-_]handoff|project[-_]status|validation[-_]report|"
150
+ r"meeting[-_]brief|merge[-_]brief|qa[-_]fixlist|debug[-_]todo|kroki[-_]notes",
151
+ re.IGNORECASE,
152
+ )
153
+ _STRONG_ARTIFACT_NAME_RE = re.compile(
154
+ r"(?:build|fix|merge|meeting)[-_]?brief|diagnosis[-_]handoff|morning[-_]summary|"
155
+ r"debug[-_]todo|project[-_]status|qa[-_]fixlist|end[-_]to[-_]end[-_]plan|"
156
+ r"clone[-_]notes|authenticity[-_]fixes[-_]prompt|fable[-_]loop[-_]findings|"
157
+ r"(?:^|[-_])bugs?[-_]found(?:[-_][a-z0-9]+)*$",
158
+ re.IGNORECASE,
159
+ )
160
+ _EPHEMERAL_HEADING_RE = re.compile(
161
+ r"^#{1,6}\s+(?:fixes?\s*[+&/]\s*learnings?|verification pass(?:es)?|what (?:i|we) "
162
+ r"changed|implementation status|(?:e2e |merged-site )?qa (?:pass|log|results?)|"
163
+ r"work completed|session summary|remaining work|changes made|bugs found \+ fixed|"
164
+ r"what'?s left|build log|issues? fixed|errors? fixed|pitfalls?\s*/\s*learnings?|"
165
+ r"what changed this session|(?:qa )?round\s+\d+)(?:\s|$)",
166
+ re.IGNORECASE,
167
+ )
168
+ _STRONG_DIARY_HEADING_RE = re.compile(
169
+ r"^#{1,6}\s+(?:fixes?\s*[+&/]\s*learnings?|build log|what changed this session|"
170
+ r"issues? fixed|errors? fixed|pitfalls?\s*/\s*learnings?)(?:\s|$)",
171
+ re.IGNORECASE,
172
+ )
173
+ _LIFECYCLE_HEADING_RE = re.compile(
174
+ r"^#{1,6}\s+(?:findings|what (?:was )?(?:actually )?changed|"
175
+ r"recommended (?:order|actions?)|post[- ]change verification|further findings|"
176
+ r"not completed|implementation status|session summary|changes made|"
177
+ r"bugs? found|issues? fixed|verification results?|action items?|deep pass)(?:\s|$)",
178
+ re.IGNORECASE,
179
+ )
180
+ _DATED_ARTIFACT_RE = re.compile(
181
+ r"\b(?:audit|report|review|assessment|findings?)\b.*\b20\d{2}(?:[-_/]\d{1,2}){1,2}\b|"
182
+ r"\b20\d{2}(?:[-_/]\d{1,2}){1,2}\b.*\b(?:audit|report|review|assessment|findings?)\b",
183
+ re.IGNORECASE,
184
+ )
185
+ _NUMBERED_FINDING_RE = re.compile(r"^\s*\*{0,2}\d+[a-z]?\.(?:\s|\*)", re.IGNORECASE)
186
+ _RESULTS_TABLE_RE = re.compile(r"^\s*\|\s*(?:check|change|finding|object|result)\s*\|", re.IGNORECASE)
187
+ _ARTIFACT_SELF_DESCRIPTION_RE = re.compile(
188
+ r"\b(?:investigation|audit|execution) log\b|\bchange diary\b|\bpoint-in-time (?:audit|report)\b",
189
+ re.IGNORECASE,
190
+ )
191
+ _AI_GENERATION_RE = re.compile(
192
+ r"generated with \[(?:claude|chatgpt|codex)|generated (?:with|by) (?:claude|chatgpt|codex)|"
193
+ r"co-authored-by:\s*(?:claude|chatgpt|codex)",
194
+ re.IGNORECASE,
195
+ )
196
+ _DIRECTIVE_RE = re.compile(
197
+ r"^(?:!|shellcheck|yamllint|markdownlint|prettier|eslint|renovate|dependabot|"
198
+ r"pragma|noqa|sarj-noqa|type:|pyright|mypy|syntax=|hadolint|nosec|note:|"
199
+ r"examples?:|flags:|format:|tool:|inputs?:|outputs?:|defaults?:|usage:|spdx)",
200
+ re.IGNORECASE,
201
+ )
202
+ _SARJ_SUPPRESSION_RE = re.compile(r"^sarj-noqa:\s*(?P<codes>SARJ\d+(?:\s*,\s*SARJ\d+)*)\s*$", re.IGNORECASE)
203
+ _MARKDOWN_HIDDEN_DIRECTIVE_RE = re.compile(
204
+ r"^(?:sarj-noqa|markdownlint|prettier|cspell|spellcheck|vale|doctoc|toc\b|more\b|"
205
+ r"begin\b|end\b|generated\b|copyright|spdx|template\b)",
206
+ re.IGNORECASE,
207
+ )
208
+ _MARKDOWN_ATX_HEADING_RE = re.compile(r"^#{1,6}\s+\S")
209
+ _FULL_GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
210
+ _FULL_IMAGE_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$", re.IGNORECASE)
211
+ _MARKDOWN_SUPPRESSION_RE = re.compile(
212
+ r"^\s*<!--\s*sarj-noqa:\s*(?P<codes>SARJ\d+(?:\s*,\s*SARJ\d+)*)\s*-->\s*$",
213
+ re.IGNORECASE | re.MULTILINE,
214
+ )
215
+ _PROTECTED_RE = re.compile(
216
+ r"https?://|\b(?:RFC|PEP|CVE)[- ]?\d+|\b[A-Z][A-Z0-9]{1,9}-\d+\b|"
217
+ r"\b(?:because|otherwise|so that|to avoid|workaround|upstream|requires?|must |"
218
+ r"intentionally|security|invariant|idempotent|race|deprecated|compatibility)\b|"
219
+ r"\d+(?:\.\d+)?\s?(?:ms|sec|minutes?|hours?|days?|KB|MB|MiB|GiB|%|rps|qps)\b",
220
+ re.IGNORECASE,
221
+ )
222
+ _CONFIG_SHAPE_RE = re.compile(
223
+ r"""^(?:-\s+)?(?:uses|run|name|if|env|with|image|services|steps|jobs|stages|"""
224
+ r"""[A-Za-z_][\w.-]*|["'][^"']+["'])\s*[:=]\s*\S""",
225
+ re.IGNORECASE,
226
+ )
227
+ _DOCKER_SHAPE_RE = re.compile(
228
+ r"^(?:ADD|ARG|CMD|COPY|ENTRYPOINT|ENV|EXPOSE|FROM|HEALTHCHECK|LABEL|RUN|SHELL|USER|VOLUME|WORKDIR)\s+"
229
+ )
230
+ _NARRATION_RE = re.compile(
231
+ r"^(?:first|then|next|now|finally|add|build|call|check|configure|create|define|"
232
+ r"deploy|fetch|get|install|load|publish|run|set|setup|test|update|validate|write)\b",
233
+ re.IGNORECASE,
234
+ )
235
+ _WORD_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]*")
236
+ _STOPWORDS: Final = frozenset({"a", "an", "and", "for", "from", "in", "of", "on", "the", "then", "this", "to", "we"})
237
+ _CONFIG_RESTATEMENT_RE = re.compile(r"^(?P<key>.+?)\s+(?:is|equals)\s+(?P<value>.+?)[.!]?\s*$", re.IGNORECASE)
238
+ _YAML_SCALAR_ENTRY_RE = re.compile(r"^\s*(?:-\s+)?(?P<key>[A-Za-z_][\w.-]*)\s*:\s*(?P<value>[^\s].*?)\s*$")
239
+ _TOML_SCALAR_ENTRY_RE = re.compile(r"^\s*(?P<key>[A-Za-z_][\w.-]*)\s*=\s*(?P<value>[^\s].*?)\s*$")
240
+ _CONFIG_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_-]*|\d+(?:\.\d+)?")
241
+ _QUOTED_SCALAR_MIN_LENGTH: Final = 2
242
+ _COMMAND_ARGUMENT_RE: Final = re.compile(r"(?<![A-Za-z0-9_])\$ARGUMENTS(?![A-Za-z0-9_])")
243
+ _QUERY_LANGUAGE_NAMES: Final = frozenset({"logql", "postgres", "postgresql", "psql", "sql"})
244
+ _SHELL_LANGUAGE_NAMES: Final = frozenset({"", "bash", "console", "sh", "shell", "zsh"})
245
+ _QUERY_TOKEN_RE: Final = re.compile(r"\b(?:SELECT|INSERT|UPDATE|DELETE|WHERE|FROM|logQl)\b", re.IGNORECASE)
246
+ _QUOTED_ARGUMENT_RE: Final = re.compile(r'(?<!\S)"\$ARGUMENTS"(?!\S)')
247
+ _MAX_MARKDOWN_FENCE_INDENT: Final = 3
248
+ _MIN_MARKDOWN_FENCE_LENGTH: Final = 3
249
+ _SECRET_READ_PERMISSION_PREFIXES: Final = (
250
+ "Bash(aws secretsmanager get-secret-value:",
251
+ "Bash(gcloud secrets versions access:",
252
+ "Bash(vault kv get:",
253
+ )
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class Finding:
258
+ path: Path
259
+ line: int
260
+ code: str
261
+ message: str
262
+
263
+ def render(self) -> str:
264
+ rollout = " warning:" if not _META_BY_CODE[self.code].blocking else ""
265
+ return f"{self.path}:{self.line}:1: {self.code}{rollout} {self.message}"
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class RuleMeta:
270
+ code: str
271
+ summary: str
272
+ rationale: str
273
+ remediation: str
274
+ category: RuleCategory
275
+ languages: frozenset[Language]
276
+ file_patterns: tuple[str, ...]
277
+ examples: tuple[RuleExample, ...]
278
+ autofix: AutofixPolicy = AutofixPolicy.NONE
279
+ aliases: tuple[str, ...] = ()
280
+ limitations: tuple[str, ...] = ()
281
+ message_ids: tuple[str, ...] = ()
282
+ references: tuple[str, ...] = ()
283
+ since: str | None = None
284
+ blocking: bool = True
285
+
286
+ @property
287
+ def description(self) -> str:
288
+ """Preserve the existing analysis adapter while ``summary`` becomes canonical."""
289
+ return self.summary
290
+
291
+ @property
292
+ def public_examples(self) -> tuple[RuleExample, ...]:
293
+ """Return only examples explicitly reviewed for public documentation."""
294
+ return tuple(example for example in self.examples if example.public)
295
+
296
+ def native_spec(self, rule_id: str) -> RuleSpec:
297
+ return RuleSpec(
298
+ engine=RuleEngine.TEXT,
299
+ rule_id=RuleId(rule_id),
300
+ code=self.code,
301
+ summary=self.summary,
302
+ rationale=self.rationale,
303
+ remediation=self.remediation,
304
+ category=self.category,
305
+ languages=self.languages,
306
+ autofix=self.autofix,
307
+ aliases=self.aliases,
308
+ examples=self.examples,
309
+ limitations=self.limitations,
310
+ file_patterns=self.file_patterns,
311
+ message_ids=tuple(MessageId(message_id) for message_id in self.message_ids),
312
+ references=self.references,
313
+ since=self.since,
314
+ )
315
+
316
+
317
+ def _public_example(
318
+ *,
319
+ example_id: str,
320
+ title: str,
321
+ outcome: ExpectedOutcome,
322
+ path: str,
323
+ source: str,
324
+ expected_count: int,
325
+ ) -> RuleExample:
326
+ focus_path = PurePosixPath(path)
327
+ return RuleExample(
328
+ example_id=example_id,
329
+ title=title,
330
+ outcome=outcome,
331
+ files=(ExampleFile(path=focus_path, source=source),),
332
+ focus_path=focus_path,
333
+ expected_count=expected_count,
334
+ public=True,
335
+ )
336
+
337
+
338
+ REGISTRY: Final[Mapping[str, RuleMeta]] = MappingProxyType(
339
+ {
340
+ "config-comment-wall": RuleMeta(
341
+ code="SARJ300",
342
+ summary="four-entry config narration wall with 75% weak restatements",
343
+ rationale=(
344
+ "Repeated comments that merely narrate adjacent configuration hide constraints and make the file harder "
345
+ "to scan."
346
+ ),
347
+ remediation=(
348
+ "Delete narration. Where names are author-controlled, clarify jobs, steps, targets, keys, or sections; "
349
+ "keep comments only for constraints or rationale."
350
+ ),
351
+ category=RuleCategory.MAINTAINABILITY,
352
+ languages=frozenset({Language.CONFIG}),
353
+ file_patterns=("**/*.{yaml,yml,toml,jsonc,ini,cfg,conf,properties,sh,zsh,bash}",),
354
+ examples=(
355
+ _public_example(
356
+ example_id="narrated-config-wall",
357
+ title="Repeated comments restate adjacent entries",
358
+ outcome=ExpectedOutcome.MATCH,
359
+ path="workflow.yml",
360
+ source="# Set build name\nname: build\n"
361
+ "# Run build command\nrun: make build\n"
362
+ "# Set deploy image\nimage: app\n"
363
+ "# Run deploy command\ncommand: deploy\n",
364
+ expected_count=1,
365
+ ),
366
+ _public_example(
367
+ example_id="self-explanatory-config",
368
+ title="Clear entries need no narration",
369
+ outcome=ExpectedOutcome.NO_MATCH,
370
+ path="workflow.yml",
371
+ source="name: build\nrun: make build\nimage: app\ncommand: deploy\n",
372
+ expected_count=0,
373
+ ),
374
+ ),
375
+ limitations=("Only groups of attached standalone comments at the same indentation level are compared.",),
376
+ ),
377
+ "commented-out-config": RuleMeta(
378
+ code="SARJ301",
379
+ summary="commented-out config syntax",
380
+ rationale="Disabled configuration becomes stale while version control already preserves its history.",
381
+ remediation="Delete disabled configuration; document a default or constraint when that information remains useful.",
382
+ category=RuleCategory.MAINTAINABILITY,
383
+ languages=frozenset({Language.CONFIG}),
384
+ file_patterns=("**/*.{yaml,yml,toml,jsonc,ini,cfg,conf,properties,sh,zsh,bash}",),
385
+ examples=(
386
+ _public_example(
387
+ example_id="disabled-config-entry",
388
+ title="A commented-out assignment is stale configuration",
389
+ outcome=ExpectedOutcome.MATCH,
390
+ path="config.toml",
391
+ source="# timeout = 30\ntimeout = 10\n",
392
+ expected_count=1,
393
+ ),
394
+ _public_example(
395
+ example_id="documented-default",
396
+ title="An explicitly labeled default is documentation",
397
+ outcome=ExpectedOutcome.NO_MATCH,
398
+ path="config.toml",
399
+ source="# Default:\n# timeout = 30\ntimeout = 10\n",
400
+ expected_count=0,
401
+ ),
402
+ ),
403
+ limitations=(
404
+ "Directive, rationale, documented-example, and YAML block-scalar comments are intentionally excluded.",
405
+ ),
406
+ ),
407
+ # New rules spend one release as visible, non-blocking findings.
408
+ "ephemeral-execution-artifact": RuleMeta(
409
+ code="SARJ302",
410
+ summary="ephemeral execution brief, audit report, or change diary",
411
+ rationale=(
412
+ "Point-in-time execution narratives quickly become misleading and obscure the durable usage or design "
413
+ "facts a repository needs."
414
+ ),
415
+ remediation="Move durable facts into maintained documentation or issues, then delete the execution artifact.",
416
+ category=RuleCategory.MAINTAINABILITY,
417
+ languages=frozenset({Language.MARKDOWN}),
418
+ file_patterns=("**/*.md", "**/*.mdx"),
419
+ aliases=("ephemeral-ai-artifact",),
420
+ examples=(
421
+ _public_example(
422
+ example_id="temporary-fix-brief",
423
+ title="A named fix brief is an execution artifact",
424
+ outcome=ExpectedOutcome.MATCH,
425
+ path="FIX-BRIEF.md",
426
+ source="# Temporary execution record\n",
427
+ expected_count=1,
428
+ ),
429
+ _public_example(
430
+ example_id="maintained-operations-guide",
431
+ title="A durable operations guide records current usage",
432
+ outcome=ExpectedOutcome.NO_MATCH,
433
+ path="docs/operations.md",
434
+ source="# Operations\n\nRun `code-standards check` before merging.\n",
435
+ expected_count=0,
436
+ ),
437
+ ),
438
+ limitations=(
439
+ "Short artifacts with neutral names and no execution-log headings are intentionally not inferred from prose alone.",
440
+ ),
441
+ blocking=False,
442
+ ),
443
+ "unpinned-github-action": RuleMeta(
444
+ code="SARJ303",
445
+ summary="remote GitHub Action or container action without an immutable digest",
446
+ rationale="Mutable action tags can resolve to different code without a reviewed repository change.",
447
+ remediation="Pin repository actions to a full commit SHA and container actions to a sha256 digest.",
448
+ category=RuleCategory.SECURITY,
449
+ languages=frozenset({Language.CONFIG}),
450
+ file_patterns=(".github/workflows/**/*.yaml", ".github/workflows/**/*.yml"),
451
+ examples=(
452
+ _public_example(
453
+ example_id="mutable-action-tag",
454
+ title="A version tag is mutable",
455
+ outcome=ExpectedOutcome.MATCH,
456
+ path=".github/workflows/ci.yml",
457
+ source="jobs:\n test:\n steps:\n - uses: actions/checkout@v4\n",
458
+ expected_count=1,
459
+ ),
460
+ _public_example(
461
+ example_id="immutable-action-commit",
462
+ title="A full action commit SHA is immutable",
463
+ outcome=ExpectedOutcome.NO_MATCH,
464
+ path=".github/workflows/ci.yml",
465
+ source="jobs:\n test:\n steps:\n"
466
+ " - uses: actions/checkout@0123456789abcdef0123456789abcdef01234567\n",
467
+ expected_count=0,
468
+ ),
469
+ ),
470
+ limitations=(
471
+ "Only remote uses entries in .github/workflows YAML files are checked; local actions are excluded.",
472
+ ),
473
+ references=(
474
+ "https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions",
475
+ ),
476
+ ),
477
+ "iac-source-coupled-test": RuleMeta(
478
+ code="SARJ304",
479
+ summary="shell test asserts on raw IaC source text",
480
+ rationale=(
481
+ "Text searches can pass on comments, formatting, or unreachable Terraform configuration without proving provider or runtime behavior."
482
+ ),
483
+ remediation="Inspect rendered plan JSON, provider state, or deployed runtime behavior instead of grepping IaC source.",
484
+ category=RuleCategory.TESTING,
485
+ languages=frozenset({Language.CONFIG}),
486
+ file_patterns=("**/*.sh", "**/*.bash", "**/*.zsh"),
487
+ examples=(
488
+ _public_example(
489
+ example_id="terraform-source-grep",
490
+ title="Do not grep Terraform source in a shell test",
491
+ outcome=ExpectedOutcome.MATCH,
492
+ path="tests/observability.test.sh",
493
+ source="#!/bin/sh\ngrep -q 'alert_policy' iac/alerts.tf\n",
494
+ expected_count=1,
495
+ ),
496
+ _public_example(
497
+ example_id="rendered-plan-query",
498
+ title="Query structured rendered plan output",
499
+ outcome=ExpectedOutcome.NO_MATCH,
500
+ path="tests/observability.test.sh",
501
+ source="#!/bin/sh\nterraform show -json plan.out | jq -e '.resource_changes | length > 0'\n",
502
+ expected_count=0,
503
+ ),
504
+ ),
505
+ limitations=(
506
+ "The scanner tokenizes shell quoting, comments, pipelines, direct command substitutions, and local variable flows; sourced helpers and eval remain unreported.",
507
+ "Only test-named shell files or shell files below a tests directory are checked.",
508
+ ),
509
+ ),
510
+ "hidden-markdown-heading": RuleMeta(
511
+ code="SARJ305",
512
+ summary="HTML comment hides a Markdown heading",
513
+ rationale=(
514
+ "A heading hidden from rendered documentation is disabled documentation that silently drifts while "
515
+ "version control already preserves removed sections."
516
+ ),
517
+ remediation="Delete the hidden section, or restore it as maintained rendered documentation.",
518
+ category=RuleCategory.MAINTAINABILITY,
519
+ languages=frozenset({Language.MARKDOWN}),
520
+ file_patterns=("**/*.md", "**/*.mdx"),
521
+ examples=(
522
+ _public_example(
523
+ example_id="hidden-obsolete-section",
524
+ title="A hidden heading disables a documentation section",
525
+ outcome=ExpectedOutcome.MATCH,
526
+ path="README.md",
527
+ source="<!--\n## Legacy setup\nUse the retired command.\n-->\n",
528
+ expected_count=1,
529
+ ),
530
+ _public_example(
531
+ example_id="visible-current-section",
532
+ title="Current documentation stays rendered",
533
+ outcome=ExpectedOutcome.NO_MATCH,
534
+ path="README.md",
535
+ source="## Setup\n\nRun the current command.\n",
536
+ expected_count=0,
537
+ ),
538
+ ),
539
+ limitations=(
540
+ "Only standalone, closed HTML comments containing an ATX heading outside Markdown code are checked; template instructions and protected rationale are preserved.",
541
+ ),
542
+ blocking=False,
543
+ ),
544
+ "exact-config-comment-restatement": RuleMeta(
545
+ code="SARJ306",
546
+ summary="YAML or TOML comment exactly repeats the adjacent scalar assignment",
547
+ rationale=(
548
+ "A comment that repeats the key and scalar value adds no information and can drift independently from "
549
+ "the configuration it narrates."
550
+ ),
551
+ remediation=(
552
+ "Delete the restatement. If the entry is author-controlled and unclear, clarify its key or section; "
553
+ "keep comments only for constraints or rationale absent from the value."
554
+ ),
555
+ category=RuleCategory.MAINTAINABILITY,
556
+ languages=frozenset({Language.CONFIG}),
557
+ file_patterns=("**/*.yaml", "**/*.yml", "**/*.toml"),
558
+ examples=(
559
+ _public_example(
560
+ example_id="scalar-value-restatement",
561
+ title="A prose comment repeats the assignment",
562
+ outcome=ExpectedOutcome.MATCH,
563
+ path="config.toml",
564
+ source="# Retry count is 3\nretry_count = 3\n",
565
+ expected_count=1,
566
+ ),
567
+ _public_example(
568
+ example_id="scalar-value-rationale",
569
+ title="A rationale adds information absent from the assignment",
570
+ outcome=ExpectedOutcome.NO_MATCH,
571
+ path="config.toml",
572
+ source="# Keep three retries because the upstream API is eventually consistent.\nretry_count = 3\n",
573
+ expected_count=0,
574
+ ),
575
+ ),
576
+ limitations=(
577
+ "Only an immediately adjacent standalone comment using exact `key is value` or `key equals value` wording over a simple scalar entry is checked.",
578
+ ),
579
+ blocking=False,
580
+ ),
581
+ "no-unsafe-command-argument-interpolation": RuleMeta(
582
+ code="SARJ307",
583
+ summary="raw Claude command argument interpolated into an executable shell or query fence",
584
+ rationale=(
585
+ "Slash-command arguments are user-controlled. Embedding them into a shell token or query string can "
586
+ "change command structure or query semantics when the documented command is executed."
587
+ ),
588
+ remediation=(
589
+ "Pass the argument as its own quoted shell token to a wrapper that validates or parameterizes it; never "
590
+ "splice it into SQL, LogQL, or another query string."
591
+ ),
592
+ category=RuleCategory.SECURITY,
593
+ languages=frozenset({Language.MARKDOWN}),
594
+ file_patterns=(".claude/commands/*.md",),
595
+ examples=(
596
+ _public_example(
597
+ example_id="query-interpolation",
598
+ title="Do not splice command arguments into queries",
599
+ outcome=ExpectedOutcome.MATCH,
600
+ path=".claude/commands/lookup.md",
601
+ source="```sql\nSELECT id FROM records WHERE id = '$ARGUMENTS';\n```\n",
602
+ expected_count=1,
603
+ ),
604
+ _public_example(
605
+ example_id="quoted-wrapper-argument",
606
+ title="Pass an opaque argument to a validating wrapper",
607
+ outcome=ExpectedOutcome.NO_MATCH,
608
+ path=".claude/commands/lookup.md",
609
+ source='```bash\nscripts/lookup.sh "$ARGUMENTS"\n```\n',
610
+ expected_count=0,
611
+ ),
612
+ ),
613
+ limitations=(
614
+ "Only fenced executable examples in .claude/commands Markdown are checked.",
615
+ "A standalone quoted shell argument is accepted on the assumption that the called wrapper validates or parameterizes it.",
616
+ ),
617
+ blocking=False,
618
+ ),
619
+ "no-wildcard-secret-read-permission": RuleMeta(
620
+ code="SARJ308",
621
+ summary="Claude settings grant wildcard access to secret values",
622
+ rationale=(
623
+ "A wildcard allow entry for a secret-read command lets an agent retrieve every secret visible to the "
624
+ "developer's cloud credentials without a per-command approval boundary."
625
+ ),
626
+ remediation=(
627
+ "Remove the wildcard permission. Allow a narrowly scoped wrapper that validates an explicit secret "
628
+ "name, or require interactive approval for each secret-value read."
629
+ ),
630
+ category=RuleCategory.SECURITY,
631
+ languages=frozenset({Language.CONFIG}),
632
+ file_patterns=(".claude/settings*.json", "**/.claude/settings*.json"),
633
+ examples=(
634
+ _public_example(
635
+ example_id="wildcard-secret-read",
636
+ title="Do not preapprove every secret-value read",
637
+ outcome=ExpectedOutcome.MATCH,
638
+ path=".claude/settings.json",
639
+ source='{"permissions":{"allow":["Bash(gcloud secrets versions access:*)"]}}\n',
640
+ expected_count=1,
641
+ ),
642
+ _public_example(
643
+ example_id="narrow-secret-wrapper",
644
+ title="Allow a validating project wrapper instead",
645
+ outcome=ExpectedOutcome.NO_MATCH,
646
+ path=".claude/settings.json",
647
+ source='{"permissions":{"allow":["Bash(make pull-development-secrets)"]}}\n',
648
+ expected_count=0,
649
+ ),
650
+ ),
651
+ limitations=(
652
+ "Only literal wildcard allow entries for recognized cloud secret-value commands in Claude settings JSON are checked.",
653
+ ),
654
+ blocking=False,
655
+ ),
656
+ }
657
+ )
658
+
659
+ _META_BY_CODE: Final[Mapping[str, RuleMeta]] = MappingProxyType({meta.code: meta for meta in REGISTRY.values()})
660
+
661
+
662
+ def is_text_path(path: Path) -> bool:
663
+ name = path.name.lower()
664
+ return (
665
+ path.suffix.lower() in _TEXT_SUFFIXES
666
+ or name in _TEXT_NAMES
667
+ or name == ".env"
668
+ or name.startswith(("dockerfile.", ".env."))
669
+ or (path.suffix.casefold() == ".json" and ".claude" in path.parts and name.startswith("settings"))
670
+ )
671
+
672
+
673
+ def check_paths(paths: Sequence[str], *, root: Path | None = None) -> list[Finding]:
674
+ base = (root or Path.cwd()).resolve()
675
+ durable_patterns, excluded_patterns = _text_policy(base)
676
+ findings: list[Finding] = []
677
+ for raw in paths:
678
+ path = Path(raw)
679
+ try:
680
+ source = path.read_text(encoding="utf-8")
681
+ except UnicodeDecodeError:
682
+ continue
683
+ relative = _relative(path.resolve(), base)
684
+ if any(fnmatch(relative, pattern) for pattern in excluded_patterns):
685
+ continue
686
+ path_findings = [
687
+ *_workflow_action_findings(path, relative, source),
688
+ *_artifact_findings(path, relative, source, durable_patterns),
689
+ *_shell_iac_source_findings(path, relative, source),
690
+ *_markdown_hidden_comment_findings(path, source),
691
+ *_markdown_command_argument_findings(path, relative, source),
692
+ *_claude_settings_secret_permission_findings(path, relative, source),
693
+ *_comment_findings(path, source),
694
+ ]
695
+ findings.extend(
696
+ finding
697
+ for finding in path_findings
698
+ if not (path.suffix.lower() in {".md", ".mdx"} and _markdown_suppresses_finding(source, finding))
699
+ )
700
+ return sorted(findings, key=lambda item: (str(item.path), item.line, item.code))
701
+
702
+
703
+ def run(paths: Sequence[str]) -> int:
704
+ findings = check_paths(paths)
705
+ for finding in findings:
706
+ _ = sys.stdout.write(f"{finding.render()}\n")
707
+ return 1 if any(_META_BY_CODE[finding.code].blocking for finding in findings) else 0
708
+
709
+
710
+ def _relative(path: Path, root: Path) -> str:
711
+ try:
712
+ return path.relative_to(root).as_posix()
713
+ except ValueError:
714
+ return path.name
715
+
716
+
717
+ def _markdown_suppresses_finding(source: str, finding: Finding) -> bool:
718
+ if finding.code == "SARJ302":
719
+ prose = "\n".join(_markdown_prose_lines(source))
720
+ return any(
721
+ finding.code in {code.strip().upper() for code in match.group("codes").split(",")}
722
+ for match in _MARKDOWN_SUPPRESSION_RE.finditer(prose)
723
+ )
724
+ lines = source.splitlines()
725
+ if finding.line <= 1 or finding.line > len(lines):
726
+ return False
727
+ match = _MARKDOWN_SUPPRESSION_RE.fullmatch(lines[finding.line - 2])
728
+ return match is not None and finding.code in {code.strip().upper() for code in match.group("codes").split(",")}
729
+
730
+
731
+ _SHELL_SUFFIXES: Final = frozenset({".bash", ".sh", ".zsh"})
732
+ _IAC_SOURCE_SUFFIXES: Final = (".hcl", ".tf", ".tf.json", ".tfvars", ".tftest.hcl", ".tftest.json")
733
+ _SHELL_SOURCE_ASSERT_COMMANDS: Final = frozenset({"awk", "grep", "rg", "sed"})
734
+ _SHELL_SOURCE_READ_COMMANDS: Final = frozenset({"cat", "read"})
735
+ _SHELL_ASSERT_TOKENS: Final = frozenset({"[", "[[", "assert", "grep", "rg", "test"})
736
+ _SHELL_SEPARATORS: Final = frozenset({"&&", ";", "|", "||"})
737
+ _GREP_VALUE_OPTIONS: Final = frozenset(
738
+ {
739
+ "--after-context",
740
+ "--before-context",
741
+ "--context",
742
+ "--file",
743
+ "--max-count",
744
+ "--regexp",
745
+ "-A",
746
+ "-B",
747
+ "-C",
748
+ "-e",
749
+ "-f",
750
+ "-m",
751
+ }
752
+ )
753
+ _SED_VALUE_OPTIONS: Final = frozenset({"--expression", "--file", "-e", "-f"})
754
+ _AWK_VALUE_OPTIONS: Final = frozenset({"--assign", "--field-separator", "--file", "-F", "-f", "-v"})
755
+
756
+
757
+ def _shell_iac_source_findings(path: Path, relative: str, source: str) -> list[Finding]:
758
+ if path.suffix.casefold() not in _SHELL_SUFFIXES or not _shell_test_path(relative):
759
+ return []
760
+ findings: list[Finding] = []
761
+ tainted: set[str] = set()
762
+ path_names: set[str] = set()
763
+ for number, line in _shell_logical_lines(source):
764
+ tokens = _shell_tokens(line)
765
+ if not tokens:
766
+ continue
767
+ assignment = _shell_assignment(tokens)
768
+ has_iac = _shell_has_iac_path(tokens, path_names)
769
+ if assignment is not None:
770
+ path_names.discard(assignment)
771
+ tainted.discard(assignment)
772
+ if has_iac and not _shell_has_source_read(tokens):
773
+ path_names.add(assignment)
774
+ elif has_iac and _shell_has_source_read(tokens):
775
+ tainted.add(assignment)
776
+ continue
777
+
778
+ direct_assert = False
779
+ pipeline_iac = False
780
+ for separator, segment in _shell_segments(tokens):
781
+ if separator != "|":
782
+ pipeline_iac = False
783
+ command = _shell_command(segment[0]) if segment else ""
784
+ if command in _SHELL_SOURCE_ASSERT_COMMANDS and (
785
+ _shell_assertion_reads_iac(segment, path_names) or pipeline_iac
786
+ ):
787
+ direct_assert = True
788
+ if command in _SHELL_ASSERT_TOKENS and any(_shell_uses_variable(segment, name) for name in tainted):
789
+ direct_assert = True
790
+ if command in _SHELL_ASSERT_TOKENS and _shell_embeds_iac_read(segment, path_names):
791
+ direct_assert = True
792
+ pipeline_iac = _shell_reads_iac(segment, path_names) or pipeline_iac
793
+
794
+ if direct_assert:
795
+ findings.append(
796
+ Finding(
797
+ path,
798
+ number,
799
+ "SARJ304",
800
+ "Raw IaC source text is the shell test oracle — inspect rendered plan JSON, provider state, or runtime behavior.",
801
+ )
802
+ )
803
+ continue
804
+ read_target = _shell_read_target(tokens) if has_iac else None
805
+ if read_target is not None:
806
+ tainted.add(read_target)
807
+ return findings
808
+
809
+
810
+ def _shell_test_path(relative: str) -> bool:
811
+ parts = PurePosixPath(relative).parts
812
+ name = parts[-1].casefold() if parts else ""
813
+ return (
814
+ any(part.casefold() in {"test", "tests"} for part in parts[:-1]) or name.startswith("test_") or ".test." in name
815
+ )
816
+
817
+
818
+ def _shell_tokens(line: str) -> list[str]:
819
+ try:
820
+ lexer = shlex.shlex(line, posix=True, punctuation_chars="|;&()<>[]")
821
+ lexer.commenters = "#"
822
+ lexer.whitespace_split = True
823
+ return list(lexer)
824
+ except ValueError:
825
+ return []
826
+
827
+
828
+ class _ShellLogicalLine(NamedTuple):
829
+ line: int
830
+ command: str
831
+
832
+
833
+ class _ShellSegment(NamedTuple):
834
+ separator: str | None
835
+ tokens: list[str]
836
+
837
+
838
+ def _shell_logical_lines(source: str) -> list[_ShellLogicalLine]:
839
+ logical: list[_ShellLogicalLine] = []
840
+ pending: list[str] = []
841
+ start = 1
842
+ for number, line in enumerate(source.splitlines(), start=1):
843
+ stripped = line.rstrip()
844
+ slash_count = len(stripped) - len(stripped.rstrip("\\"))
845
+ continued = slash_count % 2 == 1
846
+ if not pending:
847
+ start = number
848
+ pending.append(stripped[:-1] if continued else line)
849
+ if continued:
850
+ continue
851
+ logical.append(_ShellLogicalLine(start, " ".join(pending)))
852
+ pending = []
853
+ if pending:
854
+ logical.append(_ShellLogicalLine(start, " ".join(pending)))
855
+ return logical
856
+
857
+
858
+ def _shell_segments(tokens: Sequence[str]) -> list[_ShellSegment]:
859
+ segments: list[_ShellSegment] = []
860
+ current: list[str] = []
861
+ separator: str | None = None
862
+ for token in tokens:
863
+ if token in _SHELL_SEPARATORS:
864
+ if current:
865
+ segments.append(_ShellSegment(separator, current))
866
+ current = []
867
+ separator = token
868
+ continue
869
+ current.append(token)
870
+ if current:
871
+ segments.append(_ShellSegment(separator, current))
872
+ return segments
873
+
874
+
875
+ def _shell_command(token: str) -> str:
876
+ return PurePosixPath(token.casefold()).name
877
+
878
+
879
+ def _shell_has_iac_path(tokens: Sequence[str], path_names: set[str]) -> bool:
880
+ return any(_iac_source_token(token) for token in tokens) or any(
881
+ _shell_uses_variable(tokens, name) for name in path_names
882
+ )
883
+
884
+
885
+ def _iac_source_token(token: str) -> bool:
886
+ return token.strip("'\"),;:[]{}>").casefold().endswith(_IAC_SOURCE_SUFFIXES)
887
+
888
+
889
+ def _shell_assertion_reads_iac(tokens: Sequence[str], path_names: set[str]) -> bool:
890
+ if not tokens:
891
+ return False
892
+ command = _shell_command(tokens[0])
893
+ redirected = [tokens[index + 1] for index, item in enumerate(tokens[:-1]) if item == "<"]
894
+ if command in {"grep", "rg"}:
895
+ operands, explicit_pattern = _shell_operands(
896
+ tokens[1:], _GREP_VALUE_OPTIONS, {"--regexp", "-e", "--file", "-f"}
897
+ )
898
+ inputs = operands if explicit_pattern else operands[1:]
899
+ elif command == "sed":
900
+ operands, explicit_pattern = _shell_operands(
901
+ tokens[1:], _SED_VALUE_OPTIONS, {"--expression", "-e", "--file", "-f"}
902
+ )
903
+ inputs = operands if explicit_pattern else operands[1:]
904
+ elif command == "awk":
905
+ operands, explicit_pattern = _shell_operands(tokens[1:], _AWK_VALUE_OPTIONS, {"--file", "-f"})
906
+ inputs = operands if explicit_pattern else operands[1:]
907
+ else:
908
+ return False
909
+ return _shell_has_iac_path([*inputs, *redirected], path_names)
910
+
911
+
912
+ def _shell_operands(tokens: Sequence[str], value_options: frozenset[str], pattern_options: set[str]) -> _ShellOperands:
913
+ operands: list[str] = []
914
+ explicit_pattern = False
915
+ consume_value = False
916
+ options = True
917
+ for item in tokens:
918
+ if consume_value:
919
+ consume_value = False
920
+ continue
921
+ if options and item == "--":
922
+ options = False
923
+ continue
924
+ option, separator, _value = item.partition("=")
925
+ if options and option in value_options:
926
+ explicit_pattern = explicit_pattern or option in pattern_options
927
+ consume_value = not separator
928
+ continue
929
+ if options and item.startswith("-") and item != "-":
930
+ if any(
931
+ item.startswith(prefix) and len(item) > len(prefix)
932
+ for prefix in value_options
933
+ if prefix.startswith("-")
934
+ ):
935
+ explicit_pattern = explicit_pattern or any(
936
+ item.startswith(prefix) and len(item) > len(prefix) for prefix in pattern_options
937
+ )
938
+ continue
939
+ operands.append(item)
940
+ return _ShellOperands(operands, explicit_pattern)
941
+
942
+
943
+ def _shell_reads_iac(tokens: Sequence[str], path_names: set[str]) -> bool:
944
+ if not tokens:
945
+ return False
946
+ command = _shell_command(tokens[0])
947
+ if command not in _SHELL_SOURCE_READ_COMMANDS:
948
+ return False
949
+ return _shell_has_iac_path(tokens[1:], path_names)
950
+
951
+
952
+ def _shell_embeds_iac_read(tokens: Sequence[str], path_names: set[str]) -> bool:
953
+ for index, item in enumerate(tokens):
954
+ if item == "$" and tokens[index + 1 : index + 3] == ["(", "cat"]:
955
+ try:
956
+ end = tokens.index(")", index + 3)
957
+ except ValueError:
958
+ continue
959
+ if _shell_has_iac_path(tokens[index + 3 : end], path_names):
960
+ return True
961
+ match = re.search(r"\$\(cat\s+(?P<input>[^)]+)\)", item)
962
+ if match is not None and _shell_has_iac_path(_shell_tokens(match.group("input")), path_names):
963
+ return True
964
+ return False
965
+
966
+
967
+ def _shell_assignment(tokens: Sequence[str]) -> str | None:
968
+ if not tokens:
969
+ return None
970
+ match = re.match(r"^(?:local\s+)?([A-Za-z_][A-Za-z0-9_]*)=", " ".join(tokens))
971
+ return match.group(1) if match is not None else None
972
+
973
+
974
+ def _shell_has_source_read(tokens: Sequence[str]) -> bool:
975
+ if any(_shell_command(token) in _SHELL_SOURCE_READ_COMMANDS for token in tokens):
976
+ return True
977
+ return bool(re.search(r"(?:^|[$(])(?:cat|read)\s", " ".join(tokens)))
978
+
979
+
980
+ def _shell_read_target(tokens: Sequence[str]) -> str | None:
981
+ try:
982
+ index = next(index for index, token in enumerate(tokens) if _shell_command(token) == "read")
983
+ except StopIteration:
984
+ return None
985
+ for token in tokens[index + 1 :]:
986
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token):
987
+ return token
988
+ return None
989
+
990
+
991
+ def _shell_uses_variable(tokens: Sequence[str], name: str) -> bool:
992
+ patterns = {f"${name}", f"${{{name}}}"}
993
+ return any(token in patterns or any(pattern in token for pattern in patterns) for token in tokens)
994
+
995
+
996
+ def _workflow_action_findings(path: Path, relative: str, source: str) -> list[Finding]:
997
+ if not relative.startswith(".github/workflows/") or path.suffix.lower() not in {".yaml", ".yml"}:
998
+ return []
999
+ try:
1000
+ document = cast(
1001
+ "Node | None",
1002
+ yaml.compose( # pyright: ignore[reportUnknownMemberType] -- PyYAML's compose signature leaves stream unknown.
1003
+ source, Loader=yaml.SafeLoader
1004
+ ),
1005
+ )
1006
+ except yaml.YAMLError:
1007
+ return []
1008
+ lines = source.splitlines()
1009
+ findings: list[Finding] = []
1010
+ for action in _workflow_action_nodes(document):
1011
+ index = action.start_mark.line
1012
+ if _suppresses_previous_line(lines, index, "SARJ303"):
1013
+ continue
1014
+ value = cast("str", action.value)
1015
+ if value.startswith("./"):
1016
+ continue
1017
+ if value.startswith("docker://"):
1018
+ digest = value.removeprefix("docker://").partition("@")[2]
1019
+ pinned = bool(_FULL_IMAGE_DIGEST_RE.fullmatch(digest))
1020
+ else:
1021
+ reference = value.rpartition("@")[2]
1022
+ pinned = bool(_FULL_GIT_SHA_RE.fullmatch(reference))
1023
+ if not pinned:
1024
+ findings.append(
1025
+ Finding(
1026
+ path,
1027
+ index + 1,
1028
+ "SARJ303",
1029
+ "Remote action uses a mutable ref — pin it to a full commit SHA or container sha256 digest.",
1030
+ )
1031
+ )
1032
+ return findings
1033
+
1034
+
1035
+ def _workflow_action_nodes(document: Node | None) -> tuple[ScalarNode, ...]:
1036
+ if not isinstance(document, MappingNode):
1037
+ return ()
1038
+ jobs = _yaml_mapping_value(document, "jobs")
1039
+ if not isinstance(jobs, MappingNode):
1040
+ return ()
1041
+ actions: list[ScalarNode] = []
1042
+ for _, job in _yaml_pairs(jobs):
1043
+ if not isinstance(job, MappingNode):
1044
+ continue
1045
+ reusable = _yaml_mapping_value(job, "uses")
1046
+ if isinstance(reusable, ScalarNode):
1047
+ actions.append(reusable)
1048
+ steps = _yaml_mapping_value(job, "steps")
1049
+ if not isinstance(steps, SequenceNode):
1050
+ continue
1051
+ for step in cast("list[Node]", steps.value):
1052
+ if not isinstance(step, MappingNode):
1053
+ continue
1054
+ action = _yaml_mapping_value(step, "uses")
1055
+ if isinstance(action, ScalarNode):
1056
+ actions.append(action)
1057
+ return tuple(actions)
1058
+
1059
+
1060
+ def _yaml_mapping_value(mapping: MappingNode, key: str) -> Node | None:
1061
+ return next(
1062
+ (
1063
+ value
1064
+ for candidate, value in _yaml_pairs(mapping)
1065
+ if isinstance(candidate, ScalarNode) and cast("str", candidate.value) == key
1066
+ ),
1067
+ None,
1068
+ )
1069
+
1070
+
1071
+ def _yaml_pairs(mapping: MappingNode) -> list[_YamlPair]:
1072
+ return cast("list[_YamlPair]", mapping.value)
1073
+
1074
+
1075
+ def _suppresses_previous_line(lines: list[str], index: int, code: str) -> bool:
1076
+ if index == 0:
1077
+ return False
1078
+ parsed = _standalone_comment(Path("workflow.yml"), lines[index - 1])
1079
+ if parsed is None:
1080
+ return False
1081
+ match = _SARJ_SUPPRESSION_RE.fullmatch(parsed[1])
1082
+ return match is not None and code in {item.strip().upper() for item in match.group("codes").split(",")}
1083
+
1084
+
1085
+ def _artifact_findings(
1086
+ path: Path,
1087
+ relative: str,
1088
+ source: str,
1089
+ durable_patterns: tuple[str, ...],
1090
+ ) -> list[Finding]:
1091
+ if path.suffix.lower() not in {".md", ".mdx"}:
1092
+ return []
1093
+ if path.name.lower() == "changelog.md":
1094
+ return []
1095
+ if any(part.lower() in {"_backups", "backups"} for part in path.parts):
1096
+ return [
1097
+ Finding(
1098
+ path,
1099
+ 1,
1100
+ "SARJ302",
1101
+ "Backup work artifact — recover durable facts into maintained documentation and remove the backup copy.",
1102
+ )
1103
+ ]
1104
+ durable = any(fnmatch(relative, pattern) for pattern in durable_patterns)
1105
+ if _STRONG_ARTIFACT_NAME_RE.search(path.stem) or (not durable and _ARTIFACT_NAME_RE.search(path.stem)):
1106
+ return [
1107
+ Finding(
1108
+ path,
1109
+ 1,
1110
+ "SARJ302",
1111
+ "Ephemeral AI work artifact — move durable knowledge into README/docs/ADR and delete the execution brief or report.",
1112
+ )
1113
+ ]
1114
+ source_lines = _markdown_prose_lines(source)
1115
+ prose = "\n".join(source_lines)
1116
+ headings = [
1117
+ number for number, line in enumerate(source_lines, start=1) if _EPHEMERAL_HEADING_RE.match(line.strip())
1118
+ ]
1119
+ has_change_diary = any(_STRONG_DIARY_HEADING_RE.match(line.strip()) for line in source_lines)
1120
+ if len(headings) >= _MIN_EPHEMERAL_HEADINGS or has_change_diary:
1121
+ return [
1122
+ Finding(
1123
+ path,
1124
+ headings[0],
1125
+ "SARJ302",
1126
+ "Chronological AI execution log — keep current usage/design facts; remove passes, change diary, and session narration.",
1127
+ )
1128
+ ]
1129
+ if _large_artifact(prose, path, source_lines):
1130
+ line = next(
1131
+ (
1132
+ number
1133
+ for number, source_line in enumerate(source_lines, start=1)
1134
+ if _LIFECYCLE_HEADING_RE.match(source_line.strip())
1135
+ ),
1136
+ 1,
1137
+ )
1138
+ return [
1139
+ Finding(
1140
+ path,
1141
+ line,
1142
+ "SARJ302",
1143
+ "Point-in-time audit or execution report — move durable facts to maintained documentation and track findings in the issue system.",
1144
+ )
1145
+ ]
1146
+ return []
1147
+
1148
+
1149
+ def _markdown_command_argument_findings(path: Path, relative: str, source: str) -> list[Finding]:
1150
+ if not fnmatch(relative, ".claude/commands/*.md"):
1151
+ return []
1152
+ findings: list[Finding] = []
1153
+ fence: tuple[str, int, str] | None = None
1154
+ for line_number, line in enumerate(source.splitlines(), start=1):
1155
+ stripped = line.lstrip(" ") if len(line) - len(line.lstrip(" ")) <= _MAX_MARKDOWN_FENCE_INDENT else ""
1156
+ marker = stripped[:1]
1157
+ marker_length = len(stripped) - len(stripped.lstrip(marker)) if marker in {"`", "~"} else 0
1158
+ if fence is None:
1159
+ if marker_length < _MIN_MARKDOWN_FENCE_LENGTH:
1160
+ continue
1161
+ info = stripped[marker_length:].strip().split(maxsplit=1)
1162
+ language = info[0].casefold() if info else ""
1163
+ fence = (marker, marker_length, language)
1164
+ continue
1165
+ fence_marker, fence_length, language = fence
1166
+ if marker == fence_marker and marker_length >= fence_length and not stripped[marker_length:].strip():
1167
+ fence = None
1168
+ continue
1169
+ if language not in _QUERY_LANGUAGE_NAMES | _SHELL_LANGUAGE_NAMES or not _COMMAND_ARGUMENT_RE.search(line):
1170
+ continue
1171
+ unsafe = language in _QUERY_LANGUAGE_NAMES or bool(_QUERY_TOKEN_RE.search(line))
1172
+ if not unsafe:
1173
+ without_safe_arguments = _QUOTED_ARGUMENT_RE.sub("", line)
1174
+ unsafe = bool(_COMMAND_ARGUMENT_RE.search(without_safe_arguments))
1175
+ if unsafe:
1176
+ findings.append(
1177
+ Finding(
1178
+ path,
1179
+ line_number,
1180
+ "SARJ307",
1181
+ "User-controlled $ARGUMENTS is spliced into an executable command or query. Pass it as a standalone quoted argument to a validating wrapper.",
1182
+ )
1183
+ )
1184
+ return findings
1185
+
1186
+
1187
+ def _claude_settings_secret_permission_findings(path: Path, relative: str, source: str) -> list[Finding]:
1188
+ if not (fnmatch(relative, ".claude/settings*.json") or fnmatch(relative, "**/.claude/settings*.json")):
1189
+ return []
1190
+ try:
1191
+ settings = _ClaudeSettings.model_validate_json(source)
1192
+ except ValidationError:
1193
+ return []
1194
+ findings: list[Finding] = []
1195
+ lines = source.splitlines()
1196
+ for permission in settings.permissions.allow:
1197
+ if "*" not in permission:
1198
+ continue
1199
+ if not any(permission.startswith(prefix) for prefix in _SECRET_READ_PERMISSION_PREFIXES):
1200
+ continue
1201
+ line_number = next((number for number, line in enumerate(lines, start=1) if permission in line), 1)
1202
+ findings.append(
1203
+ Finding(
1204
+ path,
1205
+ line_number,
1206
+ "SARJ308",
1207
+ "Wildcard secret-value access is preapproved. Require per-read approval or a validating, narrowly scoped wrapper.",
1208
+ )
1209
+ )
1210
+ return findings
1211
+
1212
+
1213
+ def _markdown_prose_lines(source: str) -> list[str]:
1214
+ max_fence_indent = 3
1215
+ min_fence_length = 3
1216
+ indented_code_spaces = 4
1217
+ visible: list[str] = []
1218
+ fence: tuple[str, int] | None = None
1219
+ for line in source.splitlines():
1220
+ leading_spaces = len(line) - len(line.lstrip(" "))
1221
+ candidate = line[leading_spaces:] if leading_spaces <= max_fence_indent else ""
1222
+ marker = candidate[:1]
1223
+ marker_length = len(candidate) - len(candidate.lstrip(marker)) if marker in {"`", "~"} else 0
1224
+ if fence is not None:
1225
+ fence_marker, fence_length = fence
1226
+ if marker == fence_marker and marker_length >= fence_length and not candidate[marker_length:].strip():
1227
+ fence = None
1228
+ visible.append("")
1229
+ continue
1230
+ if marker_length >= min_fence_length and (marker != "`" or "`" not in candidate[marker_length:]):
1231
+ fence = (marker, marker_length)
1232
+ visible.append("")
1233
+ continue
1234
+ if leading_spaces >= indented_code_spaces or line.startswith("\t"):
1235
+ visible.append("")
1236
+ continue
1237
+ visible.append(line)
1238
+ return visible
1239
+
1240
+
1241
+ def _markdown_hidden_comment_findings(path: Path, source: str) -> list[Finding]:
1242
+ if path.suffix.casefold() not in {".md", ".mdx"}:
1243
+ return []
1244
+ return [
1245
+ Finding(
1246
+ path,
1247
+ comment.line,
1248
+ "SARJ305",
1249
+ "HTML comment hides a Markdown heading — delete the disabled section or restore it as maintained documentation.",
1250
+ )
1251
+ for comment in _markdown_html_comments(source)
1252
+ if _hidden_markdown_heading(comment.body)
1253
+ ]
1254
+
1255
+
1256
+ def _markdown_html_comments(source: str) -> list[_MarkdownHtmlComment]:
1257
+ comments: list[_MarkdownHtmlComment] = []
1258
+ pending_line: int | None = None
1259
+ pending: list[str] = []
1260
+ for line_number, line in enumerate(_markdown_prose_lines(source), start=1):
1261
+ if pending_line is None:
1262
+ match = re.fullmatch(r"\s*<!--(?P<body>.*)", line)
1263
+ if match is None:
1264
+ continue
1265
+ pending_line = line_number
1266
+ remainder = match.group("body")
1267
+ else:
1268
+ remainder = line
1269
+ before, marker, after = remainder.partition("-->")
1270
+ if marker:
1271
+ if after.strip():
1272
+ pending_line = None
1273
+ pending = []
1274
+ continue
1275
+ pending.append(before)
1276
+ comments.append(_MarkdownHtmlComment(pending_line, "\n".join(pending).strip()))
1277
+ pending_line = None
1278
+ pending = []
1279
+ continue
1280
+ pending.append(remainder)
1281
+ return comments
1282
+
1283
+
1284
+ def _hidden_markdown_heading(body: str) -> bool:
1285
+ lines = [stripped for line in body.splitlines() if (stripped := line.strip())]
1286
+ if not lines or _MARKDOWN_HIDDEN_DIRECTIVE_RE.match(lines[0]):
1287
+ return False
1288
+ prose = "\n".join(lines)
1289
+ if _PROTECTED_RE.search(prose):
1290
+ return False
1291
+ return any(_MARKDOWN_ATX_HEADING_RE.match(line) for line in lines)
1292
+
1293
+
1294
+ def _large_artifact(source: str, path: Path, lines: list[str]) -> bool:
1295
+ if len(lines) < _LARGE_ARTIFACT_MIN_LINES and not _has_word_count(source, _LARGE_ARTIFACT_MIN_WORDS):
1296
+ return False
1297
+ title = next((line.removeprefix("#").strip() for line in lines if line.startswith("#")), "")
1298
+ dated_subject = f"{path.stem} {title}"
1299
+ lifecycle_headings = {
1300
+ match.group(0).casefold() for line in lines if (match := _LIFECYCLE_HEADING_RE.match(line.strip())) is not None
1301
+ }
1302
+ has_findings_section = any(
1303
+ re.match(r"^#{1,6}\s+(?:further )?findings(?:\s|$)", line, re.IGNORECASE) for line in lines
1304
+ )
1305
+ numbered_findings = sum(bool(_NUMBERED_FINDING_RE.match(line)) for line in lines)
1306
+ dated_artifact = bool(_DATED_ARTIFACT_RE.search(dated_subject))
1307
+ ai_generation = bool(_AI_GENERATION_RE.search(source))
1308
+ self_description = bool(_ARTIFACT_SELF_DESCRIPTION_RE.search(source))
1309
+ structural_signal = len(lifecycle_headings) >= _MIN_EPHEMERAL_HEADINGS or (
1310
+ has_findings_section and numbered_findings >= _MIN_NUMBERED_FINDINGS
1311
+ )
1312
+ signals = (
1313
+ dated_artifact,
1314
+ structural_signal,
1315
+ any(_RESULTS_TABLE_RE.match(line) for line in lines),
1316
+ ai_generation,
1317
+ self_description,
1318
+ )
1319
+ has_provenance = dated_artifact or ai_generation or self_description
1320
+ return has_provenance and sum(signals) >= _LARGE_ARTIFACT_MIN_SIGNALS
1321
+
1322
+
1323
+ def _has_word_count(source: str, minimum: int) -> bool:
1324
+ return next((True for index, _match in enumerate(_WORD_RE.finditer(source), start=1) if index >= minimum), False)
1325
+
1326
+
1327
+ def _text_policy(root: Path) -> _TextPolicy:
1328
+ manifest = root / ".sarj-standards.toml"
1329
+ if not manifest.is_file():
1330
+ return _TextPolicy(_DURABLE_MARKDOWN, ())
1331
+ try:
1332
+ parsed: object = tomllib.loads(manifest.read_text(encoding="utf-8"))
1333
+ except tomllib.TOMLDecodeError:
1334
+ return _TextPolicy(_DURABLE_MARKDOWN, ())
1335
+ table = as_table(parsed)
1336
+ configured_durable = list_field(table_field(table, "artifacts"), "durable")
1337
+ durable = (
1338
+ tuple(dict.fromkeys((*_DURABLE_MARKDOWN, *(item for item in configured_durable if isinstance(item, str)))))
1339
+ if configured_durable and all(isinstance(item, str) for item in configured_durable)
1340
+ else _DURABLE_MARKDOWN
1341
+ )
1342
+ configured_excluded = list_field(table_field(table, "text"), "exclude")
1343
+ excluded = (
1344
+ tuple(item for item in configured_excluded if isinstance(item, str))
1345
+ if configured_excluded and all(isinstance(item, str) for item in configured_excluded)
1346
+ else ()
1347
+ )
1348
+ return _TextPolicy(durable, excluded)
1349
+
1350
+
1351
+ def _comment_findings(path: Path, source: str) -> list[Finding]:
1352
+ if path.suffix.lower() in {".md", ".mdx"}:
1353
+ return []
1354
+ lines = source.splitlines()
1355
+ attached: list[_AttachedComment] = []
1356
+ findings: list[Finding] = []
1357
+ config_run_lines = _commented_config_runs(path, lines)
1358
+ if config_run_lines:
1359
+ findings.extend(
1360
+ Finding(
1361
+ path,
1362
+ line,
1363
+ "SARJ301",
1364
+ "Commented-out config block — delete it; version control preserves history.",
1365
+ )
1366
+ for line in sorted(config_run_lines)
1367
+ )
1368
+ for index, line in enumerate(lines):
1369
+ parsed = _standalone_comment(path, line)
1370
+ if parsed is None:
1371
+ continue
1372
+ indent, body = parsed
1373
+ if path.suffix.lower() in {".yaml", ".yml"} and _inside_yaml_block_scalar(lines, index, indent):
1374
+ continue
1375
+ if index + 1 in config_run_lines:
1376
+ continue
1377
+ if not body or _DIRECTIVE_RE.match(body):
1378
+ continue
1379
+ protected = bool(_PROTECTED_RE.search(body))
1380
+ if not protected and _looks_commented_config(path, body) and not _inside_comment_run(path, lines, index):
1381
+ findings.append(
1382
+ Finding(
1383
+ path, index + 1, "SARJ301", "Commented-out config — delete it; version control preserves history."
1384
+ )
1385
+ )
1386
+ continue
1387
+ if (
1388
+ not protected
1389
+ and _exact_config_restatement(path, body, lines, index)
1390
+ and not _suppresses_previous_line(lines, index, "SARJ306")
1391
+ ):
1392
+ findings.append(
1393
+ Finding(
1394
+ path,
1395
+ index + 1,
1396
+ "SARJ306",
1397
+ "Comment repeats the adjacent assignment — delete it; clarify an author-controlled key or section "
1398
+ "if the entry is unclear.",
1399
+ )
1400
+ )
1401
+ continue
1402
+ next_index = _next_content_line(lines, index + 1)
1403
+ if next_index is None:
1404
+ continue
1405
+ next_line = lines[next_index]
1406
+ if len(next_line) - len(next_line.lstrip()) != indent:
1407
+ continue
1408
+ attached.append(_AttachedComment(index + 1, indent, False if protected else _weak_narration(body, next_line)))
1409
+
1410
+ for group in _attached_groups(attached):
1411
+ weak = [line for line, _indent, is_weak in group if is_weak]
1412
+ if (
1413
+ len(group) >= _WALL_MIN_ATTACHED
1414
+ and len(weak) >= _WALL_MIN_WEAK
1415
+ and len(weak) / len(group) >= _WALL_MIN_WEAK_RATIO
1416
+ ):
1417
+ findings.append(
1418
+ Finding(
1419
+ path,
1420
+ weak[0],
1421
+ "SARJ300",
1422
+ f"Config comment wall ({len(weak)} narrated entries) — where names are author-controlled, clarify "
1423
+ "jobs, steps, targets, keys, or sections; keep only constraints or rationale.",
1424
+ )
1425
+ )
1426
+ return findings
1427
+
1428
+
1429
+ def _commented_config_runs(path: Path, lines: list[str]) -> set[int]:
1430
+ leaders: set[int] = set()
1431
+ index = 0
1432
+ while index < len(lines):
1433
+ if _standalone_comment(path, lines[index]) is None:
1434
+ index += 1
1435
+ continue
1436
+ run: list[tuple[int, str]] = []
1437
+ while index < len(lines) and (parsed := _standalone_comment(path, lines[index])) is not None:
1438
+ indent, body = parsed
1439
+ if not (path.suffix.lower() in {".yaml", ".yml"} and _inside_yaml_block_scalar(lines, index, indent)):
1440
+ run.append((index + 1, body))
1441
+ index += 1
1442
+ suppressions = {
1443
+ code.upper()
1444
+ for _line, body in run
1445
+ if (match := _SARJ_SUPPRESSION_RE.match(body)) is not None
1446
+ for code in match.group("codes").split(",")
1447
+ }
1448
+ if "SARJ301" in suppressions:
1449
+ continue
1450
+ effective = [(line, body) for line, body in run if _SARJ_SUPPRESSION_RE.match(body) is None]
1451
+ if not effective:
1452
+ continue
1453
+ if any(_DIRECTIVE_RE.match(body) for _line, body in effective):
1454
+ continue
1455
+ shaped = [line for line, body in effective if _looks_commented_config(path, body)]
1456
+ if len(shaped) >= _COMMENTED_CONFIG_RUN_MIN and len(shaped) / len(effective) >= _COMMENTED_CONFIG_RUN_RATIO:
1457
+ leaders.add(shaped[0])
1458
+ return leaders
1459
+
1460
+
1461
+ def _standalone_comment(path: Path, line: str) -> _StandaloneComment | None:
1462
+ stripped = line.lstrip()
1463
+ if path.suffix.lower() == ".jsonc":
1464
+ for marker in ("//", "/*", "*"):
1465
+ if stripped.startswith(marker):
1466
+ body = stripped.removeprefix(marker).removesuffix("*/").strip()
1467
+ return _StandaloneComment(len(line) - len(stripped), body)
1468
+ return None
1469
+ if not stripped.startswith("#"):
1470
+ return None
1471
+ return _StandaloneComment(len(line) - len(stripped), stripped.removeprefix("#").strip())
1472
+
1473
+
1474
+ def _looks_commented_config(path: Path, body: str) -> bool:
1475
+ if path.name.lower().startswith("dockerfile") and _DOCKER_SHAPE_RE.match(body):
1476
+ return True
1477
+ if path.suffix.lower() == ".toml" and body.startswith("[") and body.endswith("]"):
1478
+ return True
1479
+ # Reject prose-shaped text before recognizing disabled configuration.
1480
+ if len(body.split()) > _COMMENTED_CONFIG_MAX_WORDS or ". " in body or not _CONFIG_SHAPE_RE.match(body):
1481
+ return False
1482
+ _key, separator, value = body.partition(":" if ":" in body else "=")
1483
+ if not separator:
1484
+ return False
1485
+ compact = value.strip()
1486
+ return bool(compact) and (
1487
+ not any(character.isspace() for character in compact)
1488
+ or compact.startswith(("[", "{"))
1489
+ or (compact[0] in {'"', "'"} and compact[-1] == compact[0])
1490
+ )
1491
+
1492
+
1493
+ def _exact_config_restatement(path: Path, body: str, lines: list[str], index: int) -> bool:
1494
+ if path.suffix.casefold() not in {".toml", ".yaml", ".yml"} or index + 1 >= len(lines):
1495
+ return False
1496
+ if (
1497
+ index > 0
1498
+ and (previous := _standalone_comment(path, lines[index - 1])) is not None
1499
+ and _SARJ_SUPPRESSION_RE.fullmatch(previous.body) is None
1500
+ ):
1501
+ return False
1502
+ comment = _CONFIG_RESTATEMENT_RE.fullmatch(body)
1503
+ entry = _config_scalar_entry(path, lines[index + 1])
1504
+ if comment is None or entry is None:
1505
+ return False
1506
+ return _config_words(comment.group("key")) == _config_words(entry.key) and _config_value(
1507
+ comment.group("value")
1508
+ ) == _config_value(entry.value)
1509
+
1510
+
1511
+ def _config_scalar_entry(path: Path, line: str) -> _ConfigScalarEntry | None:
1512
+ pattern = _TOML_SCALAR_ENTRY_RE if path.suffix.casefold() == ".toml" else _YAML_SCALAR_ENTRY_RE
1513
+ match = pattern.fullmatch(line)
1514
+ if match is None:
1515
+ return None
1516
+ value = match.group("value").strip()
1517
+ if value.startswith(("[", "{")) or value in {"|", ">", "|-", "|+", ">-", ">+"} or " #" in value or "${{" in value:
1518
+ return None
1519
+ if len(value) >= _QUOTED_SCALAR_MIN_LENGTH and value[0] == value[-1] and value[0] in {'"', "'"}:
1520
+ value = value[1:-1]
1521
+ return _ConfigScalarEntry(match.group("key"), value)
1522
+
1523
+
1524
+ def _config_words(text: str) -> tuple[str, ...]:
1525
+ tokens: list[str] = []
1526
+ for match in _CONFIG_TOKEN_RE.finditer(text):
1527
+ expanded = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", match.group(0))
1528
+ tokens.extend(part.casefold() for part in re.split(r"[-_]+", expanded) if part)
1529
+ return tuple(tokens)
1530
+
1531
+
1532
+ def _config_value(text: str) -> str:
1533
+ value = text.strip()
1534
+ if len(value) >= _QUOTED_SCALAR_MIN_LENGTH and value[0] == value[-1] and value[0] in {'"', "'"}:
1535
+ value = value[1:-1]
1536
+ return " ".join(value.split()).casefold()
1537
+
1538
+
1539
+ def _inside_comment_run(path: Path, lines: list[str], index: int) -> bool:
1540
+ return (index > 0 and _standalone_comment(path, lines[index - 1]) is not None) or (
1541
+ index + 1 < len(lines) and _standalone_comment(path, lines[index + 1]) is not None
1542
+ )
1543
+
1544
+
1545
+ def _inside_yaml_block_scalar(lines: list[str], index: int, indent: int) -> bool:
1546
+ for previous in range(index - 1, -1, -1):
1547
+ candidate = lines[previous]
1548
+ if not candidate.strip():
1549
+ continue
1550
+ candidate_indent = len(candidate) - len(candidate.lstrip())
1551
+ if candidate_indent >= indent:
1552
+ continue
1553
+ return bool(re.search(r"[>|][+-]?\s*$", candidate))
1554
+ return False
1555
+
1556
+
1557
+ def _attached_groups(
1558
+ attached: list[_AttachedComment],
1559
+ ) -> list[list[_AttachedComment]]:
1560
+ groups: list[list[_AttachedComment]] = []
1561
+ for entry in attached:
1562
+ if (
1563
+ groups
1564
+ and groups[-1]
1565
+ and entry[1] == groups[-1][-1][1]
1566
+ and entry[0] <= groups[-1][-1][0] + _WALL_GROUP_MAX_LINES
1567
+ ):
1568
+ groups[-1].append(entry)
1569
+ else:
1570
+ groups.append([entry])
1571
+ return groups
1572
+
1573
+
1574
+ def _next_content_line(lines: list[str], start: int) -> int | None:
1575
+ for index in range(start, len(lines)):
1576
+ stripped = lines[index].strip()
1577
+ if not stripped:
1578
+ return None
1579
+ if not stripped.startswith(("#", "//")):
1580
+ return index
1581
+ return None
1582
+
1583
+
1584
+ def _weak_narration(body: str, statement: str) -> bool:
1585
+ if len(body.split()) > _WALL_MAX_WORDS or not _NARRATION_RE.match(body):
1586
+ return False
1587
+ words = [_normalize_word(word) for word in _words(body)][1:]
1588
+ content = [word for word in words if word not in _STOPWORDS]
1589
+ if not content:
1590
+ return False
1591
+ code = {_normalize_word(word) for word in _words(statement)}
1592
+ matched = sum(word in code or word.rstrip("s") in code for word in content)
1593
+ return matched / len(content) >= _WALL_MIN_MATCHED_RATIO and len(content) - matched <= _WALL_MAX_NOVEL_WORDS
1594
+
1595
+
1596
+ def _words(text: str) -> list[str]:
1597
+ words: list[str] = []
1598
+ for match in _WORD_RE.finditer(text):
1599
+ expanded = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", match.group(0))
1600
+ words.extend(part for part in re.split(r"[-_]+", expanded) if part)
1601
+ return words
1602
+
1603
+
1604
+ def _normalize_word(word: str) -> str:
1605
+ return word.lower().replace("-", "_")