code-standards 7.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_standards-7.0.0.dist-info/METADATA +53 -0
- code_standards-7.0.0.dist-info/RECORD +99 -0
- code_standards-7.0.0.dist-info/WHEEL +4 -0
- code_standards-7.0.0.dist-info/entry_points.txt +3 -0
- code_standards-7.0.0.dist-info/licenses/LICENSE +21 -0
- sarj_standards/__init__.py +30 -0
- sarj_standards/__main__.py +5 -0
- sarj_standards/_meta.py +22 -0
- sarj_standards/api.py +890 -0
- sarj_standards/cli/__init__.py +0 -0
- sarj_standards/cli/main.py +2466 -0
- sarj_standards/configs/cli-reference.v1.json +1 -0
- sarj_standards/configs/doctor.config.json +22 -0
- sarj_standards/configs/eslint.application.mjs +1366 -0
- sarj_standards/configs/eslint.peers.json +44 -0
- sarj_standards/configs/eslint.strict.mjs +1060 -0
- sarj_standards/configs/markdownlint.strict.yaml +12 -0
- sarj_standards/configs/pyright.strict.json +96 -0
- sarj_standards/configs/ruff.application.toml +363 -0
- sarj_standards/configs/ruff.strict.toml +338 -0
- sarj_standards/configs/rule-inventory.v1.json +1 -0
- sarj_standards/configs/rule-ledger.json +846 -0
- sarj_standards/configs/rule-warning-levels.v1.json +1 -0
- sarj_standards/configs/taplo.strict.toml +14 -0
- sarj_standards/configs/yamllint.strict.yaml +25 -0
- sarj_standards/libs/__init__.py +0 -0
- sarj_standards/libs/adoption/__init__.py +0 -0
- sarj_standards/libs/adoption/configs.py +36 -0
- sarj_standards/libs/adoption/doctor.py +1346 -0
- sarj_standards/libs/adoption/exclusions.py +66 -0
- sarj_standards/libs/adoption/hooks.py +423 -0
- sarj_standards/libs/adoption/launcher.py +240 -0
- sarj_standards/libs/adoption/lifecycle.py +493 -0
- sarj_standards/libs/adoption/manifest.py +550 -0
- sarj_standards/libs/adoption/packagemanager.py +285 -0
- sarj_standards/libs/adoption/retired_suppressions.py +371 -0
- sarj_standards/libs/adoption/scaffold.py +1660 -0
- sarj_standards/libs/adoption/service.py +441 -0
- sarj_standards/libs/adoption/transaction.py +274 -0
- sarj_standards/libs/adoption/upgrade.py +516 -0
- sarj_standards/libs/adoption/uvtool.py +62 -0
- sarj_standards/libs/catalogs/__init__.py +9 -0
- sarj_standards/libs/catalogs/slack_automations.py +627 -0
- sarj_standards/libs/corpus/__init__.py +25 -0
- sarj_standards/libs/corpus/manifest.py +211 -0
- sarj_standards/libs/corpus/snapshot.py +222 -0
- sarj_standards/libs/diagnostics/__init__.py +65 -0
- sarj_standards/libs/diagnostics/analysis.schema.json +161 -0
- sarj_standards/libs/diagnostics/baseline.py +131 -0
- sarj_standards/libs/diagnostics/models.py +574 -0
- sarj_standards/libs/diagnostics/serialize.py +290 -0
- sarj_standards/libs/diagnostics/source.py +172 -0
- sarj_standards/libs/filesystem.py +11 -0
- sarj_standards/libs/linting/__init__.py +0 -0
- sarj_standards/libs/linting/analysis.py +422 -0
- sarj_standards/libs/linting/external.py +1454 -0
- sarj_standards/libs/linting/library_policy.py +688 -0
- sarj_standards/libs/linting/policy.py +152 -0
- sarj_standards/libs/linting/runner.py +442 -0
- sarj_standards/libs/linting/textlint.py +1605 -0
- sarj_standards/libs/release/__init__.py +98 -0
- sarj_standards/libs/release/_values.py +24 -0
- sarj_standards/libs/release/artifacts.py +191 -0
- sarj_standards/libs/release/causality.py +80 -0
- sarj_standards/libs/release/changes.py +48 -0
- sarj_standards/libs/release/process.py +128 -0
- sarj_standards/libs/release/publish.py +85 -0
- sarj_standards/libs/release/registry.py +271 -0
- sarj_standards/libs/release/release_age.py +218 -0
- sarj_standards/libs/release/rollout.py +1163 -0
- sarj_standards/libs/release/tags.py +373 -0
- sarj_standards/libs/release/typescript.py +191 -0
- sarj_standards/libs/repository/__init__.py +0 -0
- sarj_standards/libs/repository/cli_reference_artifact.py +324 -0
- sarj_standards/libs/repository/comment_corpus.py +536 -0
- sarj_standards/libs/repository/config_generation.py +146 -0
- sarj_standards/libs/repository/docs.py +347 -0
- sarj_standards/libs/repository/hooks.py +118 -0
- sarj_standards/libs/repository/ledger.py +99 -0
- sarj_standards/libs/repository/repository.py +744 -0
- sarj_standards/libs/repository/rule_authoring.py +246 -0
- sarj_standards/libs/repository/rule_catalog_artifact.py +479 -0
- sarj_standards/libs/repository/rule_changes.py +318 -0
- sarj_standards/libs/repository/rule_inventory_artifact.py +142 -0
- sarj_standards/libs/repository/rule_lifecycle.py +167 -0
- sarj_standards/libs/repository/rule_maintenance.py +225 -0
- sarj_standards/libs/rules/__init__.py +74 -0
- sarj_standards/libs/rules/catalog.py +145 -0
- sarj_standards/libs/rules/contracts.py +382 -0
- sarj_standards/libs/rules/corpus_runner.py +365 -0
- sarj_standards/libs/rules/evaluation.py +177 -0
- sarj_standards/libs/setup/__init__.py +4 -0
- sarj_standards/libs/setup/repository.py +40 -0
- sarj_standards/py.typed +0 -0
- sarj_standards/schemas/__init__.py +4 -0
- sarj_standards/schemas/_paths.py +7 -0
- sarj_standards/schemas/rule-catalog.v1.json +1 -0
- sarj_standards/schemas/rule-catalog.v1.schema.json +112 -0
- sarj_standards/schemas/slack-automations.v1.schema.json +1751 -0
|
@@ -0,0 +1,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("-", "_")
|