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,627 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
from typing import Annotated, ClassVar, Final, Literal, Self
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, ValidationError, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONVENTIONAL_PATH: Final = Path("catalog/slack-automations.v1.json")
|
|
13
|
+
_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
14
|
+
_REPOSITORY = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$")
|
|
15
|
+
_SLASH_COMMAND = re.compile(r"^/[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
16
|
+
_RAW_SLACK_ID = re.compile(r"(?<![A-Z0-9])[ABCDEGTUVW][A-Z0-9]{8,}(?![A-Z0-9])")
|
|
17
|
+
_SECRET_VALUE = re.compile(r"(?i)(?:xox[baprs]-|bearer\s+[a-z0-9._-]+|-----BEGIN [A-Z ]+PRIVATE KEY-----)")
|
|
18
|
+
_SECRET_NAME = re.compile(r"\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|CREDENTIALS?|API_KEY)\b")
|
|
19
|
+
_CHANNEL_IDENTIFIER = re.compile(r"(?:^|[^A-Za-z0-9_])#[a-z0-9][a-z0-9_-]*|/archives/[CG][A-Z0-9]+", re.IGNORECASE)
|
|
20
|
+
_PRIVATE_CHANNEL_IDENTIFIER = re.compile(r"(?i)\bprivate[-_ ]channel[-_ ](?:id|name)\s*[:=]\s*[a-z0-9_-]+")
|
|
21
|
+
_INTERNAL_URL = re.compile(r"(?i)https?://[^\s]+/(?:admin|internal)(?:[/#?]|\b)")
|
|
22
|
+
_PATH_SCHEMA_PATTERN: Final = r"^(?!\.?\.?$)(?!\.?\.?/)(?!.*//)(?!.*(?:/\.?\.?)(?:/|$))[^/]+(?:/[^/]+)*$"
|
|
23
|
+
_PUBLIC_TEXT_SCHEMA: Final[dict[str, JsonValue]] = {
|
|
24
|
+
"allOf": [
|
|
25
|
+
{"pattern": r"^(?!\s)(?:[^\r\n]*\S)$|^\S$"},
|
|
26
|
+
{"not": {"pattern": r"(?:^|[^A-Z0-9])[ABCDEGTUVW][A-Z0-9]{8,}(?:$|[^A-Z0-9])"}},
|
|
27
|
+
{
|
|
28
|
+
"not": {
|
|
29
|
+
"pattern": (
|
|
30
|
+
r"[xX][oO][xX][bBaApPrRsS]-|"
|
|
31
|
+
r"[bB][eE][aA][rR][eE][rR]\s+[A-Za-z0-9._-]+|"
|
|
32
|
+
r"-----BEGIN [A-Z ]+PRIVATE KEY-----"
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
{"not": {"pattern": r"\b[A-Z][A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|CREDENTIALS?|API_KEY)\b"}},
|
|
37
|
+
{"not": {"pattern": r"(?:^|[^A-Za-z0-9_])#[A-Za-z0-9][A-Za-z0-9_-]*|/archives/[CGcg][A-Za-z0-9]+"}},
|
|
38
|
+
{
|
|
39
|
+
"not": {
|
|
40
|
+
"pattern": (
|
|
41
|
+
r"[pP][rR][iI][vV][aA][tT][eE][-_ ][cC][hH][aA][nN][nN][eE][lL][-_ ]"
|
|
42
|
+
r"(?:[iI][dD]|[nN][aA][mM][eE])\s*[:=]\s*[A-Za-z0-9_-]+"
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"not": {
|
|
48
|
+
"pattern": (
|
|
49
|
+
r"[hH][tT][tT][pP][sS]?://[^\s]+/"
|
|
50
|
+
r"(?:[aA][dD][mM][iI][nN]|[iI][nN][tT][eE][rR][nN][aA][lL])"
|
|
51
|
+
r"(?:[/#?]|[^A-Za-z0-9_]|$)"
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
_PUBLIC_TEXT_ITEM_SCHEMA: Final[dict[str, JsonValue]] = {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"minLength": 1,
|
|
60
|
+
"maxLength": 160,
|
|
61
|
+
**_PUBLIC_TEXT_SCHEMA,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
66
|
+
class CatalogFinding:
|
|
67
|
+
location: str
|
|
68
|
+
message: str
|
|
69
|
+
|
|
70
|
+
def render(self, path: Path) -> str:
|
|
71
|
+
return f"{path.as_posix()}:{self.location}: {self.message}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _CatalogModel(BaseModel):
|
|
75
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", strict=True, frozen=True, populate_by_name=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Named(_CatalogModel):
|
|
79
|
+
id: str = Field(min_length=1, max_length=80, pattern=_SLUG.pattern)
|
|
80
|
+
display_name: str = Field(alias="displayName", min_length=1, max_length=80, json_schema_extra=_PUBLIC_TEXT_SCHEMA)
|
|
81
|
+
|
|
82
|
+
@field_validator("id")
|
|
83
|
+
@classmethod
|
|
84
|
+
def id_is_slug(cls, value: str) -> str:
|
|
85
|
+
return _slug(value)
|
|
86
|
+
|
|
87
|
+
@field_validator("display_name")
|
|
88
|
+
@classmethod
|
|
89
|
+
def display_name_is_public(cls, value: str) -> str:
|
|
90
|
+
return _public_text(value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Summarized(_Named):
|
|
94
|
+
summary: str = Field(min_length=1, max_length=320, json_schema_extra=_PUBLIC_TEXT_SCHEMA)
|
|
95
|
+
|
|
96
|
+
@field_validator("summary")
|
|
97
|
+
@classmethod
|
|
98
|
+
def summary_is_public(cls, value: str) -> str:
|
|
99
|
+
return _public_text(value)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class SlackSystem(_Named):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Trigger(_Named):
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class SlackEventTrigger(_Trigger):
|
|
111
|
+
kind: Literal["slack-event"]
|
|
112
|
+
event_types: tuple[str, ...] = Field(
|
|
113
|
+
alias="eventTypes", min_length=1, json_schema_extra={"items": _PUBLIC_TEXT_ITEM_SCHEMA, "uniqueItems": True}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@field_validator("event_types")
|
|
117
|
+
@classmethod
|
|
118
|
+
def event_types_are_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
119
|
+
normalized = tuple(_public_text(item) for item in value)
|
|
120
|
+
_require_sorted_unique(normalized, "eventTypes")
|
|
121
|
+
return normalized
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SlackInteractionTrigger(_Trigger):
|
|
125
|
+
kind: Literal["slack-interaction"]
|
|
126
|
+
interaction_types: tuple[Literal["action", "shortcut", "view-submission"], ...] = Field(
|
|
127
|
+
alias="interactionTypes", min_length=1, json_schema_extra={"uniqueItems": True}
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@field_validator("interaction_types")
|
|
131
|
+
@classmethod
|
|
132
|
+
def interaction_types_are_sorted(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
133
|
+
_require_sorted_unique(value, "interactionTypes")
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class SlashCommandTrigger(_Trigger):
|
|
138
|
+
kind: Literal["slash-command"]
|
|
139
|
+
command: str = Field(min_length=2, max_length=82, pattern=_SLASH_COMMAND.pattern)
|
|
140
|
+
|
|
141
|
+
@field_validator("command")
|
|
142
|
+
@classmethod
|
|
143
|
+
def command_is_valid(cls, value: str) -> str:
|
|
144
|
+
if _SLASH_COMMAND.fullmatch(value) is None:
|
|
145
|
+
msg = "command must be a lowercase slash-command"
|
|
146
|
+
raise ValueError(msg)
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Cadence(_CatalogModel):
|
|
151
|
+
kind: Literal["hourly", "daily", "weekly", "biweekly", "periodic"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ScheduleTrigger(_Trigger):
|
|
155
|
+
kind: Literal["schedule"]
|
|
156
|
+
cadence: Cadence
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ExternalWebhookTrigger(_Trigger):
|
|
160
|
+
kind: Literal["external-webhook"]
|
|
161
|
+
source_system_id: str = Field(alias="sourceSystemId", min_length=1, max_length=80, pattern=_SLUG.pattern)
|
|
162
|
+
|
|
163
|
+
@field_validator("source_system_id")
|
|
164
|
+
@classmethod
|
|
165
|
+
def source_system_id_is_slug(cls, value: str) -> str:
|
|
166
|
+
return _slug(value)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class InternalEventTrigger(_Trigger):
|
|
170
|
+
kind: Literal["internal-event"]
|
|
171
|
+
event_name: str = Field(alias="eventName", min_length=1, max_length=80, pattern=_SLUG.pattern)
|
|
172
|
+
|
|
173
|
+
@field_validator("event_name")
|
|
174
|
+
@classmethod
|
|
175
|
+
def event_name_is_slug(cls, value: str) -> str:
|
|
176
|
+
return _slug(value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class ManualTrigger(_Trigger):
|
|
180
|
+
kind: Literal["manual"]
|
|
181
|
+
mode: Literal["operator", "test"]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
type Trigger = Annotated[
|
|
185
|
+
SlackEventTrigger
|
|
186
|
+
| SlackInteractionTrigger
|
|
187
|
+
| SlashCommandTrigger
|
|
188
|
+
| ScheduleTrigger
|
|
189
|
+
| ExternalWebhookTrigger
|
|
190
|
+
| InternalEventTrigger
|
|
191
|
+
| ManualTrigger,
|
|
192
|
+
Field(discriminator="kind"),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Capability(_Summarized):
|
|
197
|
+
connected_system_ids: tuple[str, ...] = Field(
|
|
198
|
+
alias="connectedSystemIds",
|
|
199
|
+
min_length=1,
|
|
200
|
+
json_schema_extra={
|
|
201
|
+
"items": {"type": "string", "minLength": 1, "maxLength": 80, "pattern": _SLUG.pattern},
|
|
202
|
+
"uniqueItems": True,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
triggers: tuple[Trigger, ...] = Field(min_length=1, json_schema_extra={"uniqueItems": True})
|
|
206
|
+
|
|
207
|
+
@field_validator("connected_system_ids")
|
|
208
|
+
@classmethod
|
|
209
|
+
def connected_system_ids_are_sorted(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
210
|
+
normalized = tuple(_slug(item) for item in value)
|
|
211
|
+
_require_sorted_unique(normalized, "connectedSystemIds")
|
|
212
|
+
return normalized
|
|
213
|
+
|
|
214
|
+
@field_validator("triggers")
|
|
215
|
+
@classmethod
|
|
216
|
+
def triggers_are_sorted(cls, value: tuple[Trigger, ...]) -> tuple[Trigger, ...]:
|
|
217
|
+
_require_sorted_unique(tuple(item.id for item in value), "triggers")
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class BotCapability(_Capability):
|
|
222
|
+
persona_ids: tuple[str, ...] = Field(
|
|
223
|
+
alias="personaIds",
|
|
224
|
+
min_length=1,
|
|
225
|
+
json_schema_extra={
|
|
226
|
+
"items": {"type": "string", "minLength": 1, "maxLength": 80, "pattern": _SLUG.pattern},
|
|
227
|
+
"uniqueItems": True,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
@field_validator("persona_ids")
|
|
232
|
+
@classmethod
|
|
233
|
+
def persona_ids_are_sorted(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
234
|
+
normalized = tuple(_slug(item) for item in value)
|
|
235
|
+
_require_sorted_unique(normalized, "personaIds")
|
|
236
|
+
return normalized
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class IntegrationCapability(_Capability):
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class Persona(_Summarized):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class RepositoryManifestConfiguration(_CatalogModel):
|
|
248
|
+
kind: Literal["repository-manifest"]
|
|
249
|
+
manifest_path: str = Field(alias="manifestPath", json_schema_extra={"pattern": _PATH_SCHEMA_PATTERN})
|
|
250
|
+
|
|
251
|
+
@field_validator("manifest_path")
|
|
252
|
+
@classmethod
|
|
253
|
+
def manifest_path_is_relative(cls, value: str) -> str:
|
|
254
|
+
return _relative_path(value)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class ExternalConfiguration(_CatalogModel):
|
|
258
|
+
kind: Literal["external"]
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
type AppConfiguration = Annotated[
|
|
262
|
+
RepositoryManifestConfiguration | ExternalConfiguration,
|
|
263
|
+
Field(discriminator="kind"),
|
|
264
|
+
]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class _Automation(_Summarized):
|
|
268
|
+
status: Literal["active", "dark", "retired"]
|
|
269
|
+
source_paths: tuple[str, ...] = Field(
|
|
270
|
+
alias="sourcePaths",
|
|
271
|
+
min_length=1,
|
|
272
|
+
json_schema_extra={"items": {"type": "string", "pattern": _PATH_SCHEMA_PATTERN}, "uniqueItems": True},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@field_validator("source_paths")
|
|
276
|
+
@classmethod
|
|
277
|
+
def source_paths_are_sorted(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
278
|
+
normalized = tuple(_relative_path(item) for item in value)
|
|
279
|
+
_require_sorted_unique(normalized, "sourcePaths")
|
|
280
|
+
return normalized
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class _BotApp(_Automation):
|
|
284
|
+
kind: Literal["bot-app"]
|
|
285
|
+
configuration: AppConfiguration
|
|
286
|
+
capabilities: tuple[BotCapability, ...] = Field(min_length=1, json_schema_extra={"uniqueItems": True})
|
|
287
|
+
personas: tuple[Persona, ...]
|
|
288
|
+
|
|
289
|
+
@field_validator("capabilities")
|
|
290
|
+
@classmethod
|
|
291
|
+
def capabilities_are_sorted(cls, value: tuple[BotCapability, ...]) -> tuple[BotCapability, ...]:
|
|
292
|
+
_require_sorted_unique(tuple(item.id for item in value), "capabilities")
|
|
293
|
+
return value
|
|
294
|
+
|
|
295
|
+
@field_validator("personas")
|
|
296
|
+
@classmethod
|
|
297
|
+
def personas_are_sorted(cls, value: tuple[Persona, ...]) -> tuple[Persona, ...]:
|
|
298
|
+
_require_sorted_unique(tuple(item.id for item in value), "personas")
|
|
299
|
+
return value
|
|
300
|
+
|
|
301
|
+
@model_validator(mode="after")
|
|
302
|
+
def persona_references_are_owned(self) -> Self:
|
|
303
|
+
persona_ids = {persona.id for persona in self.personas}
|
|
304
|
+
used_persona_ids: set[str] = set()
|
|
305
|
+
for capability in self.capabilities:
|
|
306
|
+
for persona_id in capability.persona_ids:
|
|
307
|
+
if persona_id not in persona_ids:
|
|
308
|
+
msg = f"capability {capability.id} references unknown persona {persona_id}"
|
|
309
|
+
raise ValueError(msg)
|
|
310
|
+
used_persona_ids.add(persona_id)
|
|
311
|
+
unused = persona_ids - used_persona_ids
|
|
312
|
+
if unused:
|
|
313
|
+
msg = f"personas must own at least one capability: {', '.join(sorted(unused))}"
|
|
314
|
+
raise ValueError(msg)
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class DedicatedBotApp(_BotApp):
|
|
319
|
+
identity_kind: Literal["dedicated"] = Field(alias="identityKind")
|
|
320
|
+
personas: tuple[Persona] = Field(min_length=1, max_length=1)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SharedBotApp(_BotApp):
|
|
324
|
+
identity_kind: Literal["shared"] = Field(alias="identityKind")
|
|
325
|
+
personas: tuple[Persona, ...] = Field(min_length=2)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
type BotApp = Annotated[DedicatedBotApp | SharedBotApp, Field(discriminator="identity_kind")]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
type ReadPrivilege = Literal["channels:read", "user-groups:read", "workspace-members:read"]
|
|
332
|
+
type IntegrationPrivilege = Literal[
|
|
333
|
+
"channels:read",
|
|
334
|
+
"channels:write",
|
|
335
|
+
"profiles:write",
|
|
336
|
+
"user-groups:read",
|
|
337
|
+
"user-groups:write",
|
|
338
|
+
"workspace-members:read",
|
|
339
|
+
]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class ReadUserTokenAuthorization(_CatalogModel):
|
|
343
|
+
kind: Literal["user-token"]
|
|
344
|
+
authority: Literal["read"]
|
|
345
|
+
privileges: tuple[ReadPrivilege, ...] = Field(min_length=1, json_schema_extra={"uniqueItems": True})
|
|
346
|
+
|
|
347
|
+
@field_validator("privileges")
|
|
348
|
+
@classmethod
|
|
349
|
+
def privileges_are_sorted(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
350
|
+
_require_sorted_unique(value, "privileges")
|
|
351
|
+
return value
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class WriteUserTokenAuthorization(_CatalogModel):
|
|
355
|
+
kind: Literal["user-token"]
|
|
356
|
+
authority: Literal["write"]
|
|
357
|
+
privileges: tuple[IntegrationPrivilege, ...] = Field(
|
|
358
|
+
min_length=1,
|
|
359
|
+
json_schema_extra={
|
|
360
|
+
"contains": {"enum": ["channels:write", "profiles:write", "user-groups:write"]},
|
|
361
|
+
"minContains": 1,
|
|
362
|
+
"uniqueItems": True,
|
|
363
|
+
},
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
@field_validator("privileges")
|
|
367
|
+
@classmethod
|
|
368
|
+
def privileges_are_sorted_with_write_authority(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
369
|
+
_require_sorted_unique(value, "privileges")
|
|
370
|
+
if all(privilege.endswith(":read") for privilege in value):
|
|
371
|
+
msg = "write authority requires a write privilege"
|
|
372
|
+
raise ValueError(msg)
|
|
373
|
+
return value
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
type UserTokenAuthorization = Annotated[
|
|
377
|
+
ReadUserTokenAuthorization | WriteUserTokenAuthorization,
|
|
378
|
+
Field(discriminator="authority"),
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class Integration(_Automation):
|
|
383
|
+
kind: Literal["integration"]
|
|
384
|
+
authorization: UserTokenAuthorization
|
|
385
|
+
capabilities: tuple[IntegrationCapability, ...] = Field(min_length=1, json_schema_extra={"uniqueItems": True})
|
|
386
|
+
consumer_bot_app_ids: tuple[str, ...] = Field(
|
|
387
|
+
alias="consumerBotAppIds",
|
|
388
|
+
json_schema_extra={
|
|
389
|
+
"items": {"type": "string", "minLength": 1, "maxLength": 80, "pattern": _SLUG.pattern},
|
|
390
|
+
"uniqueItems": True,
|
|
391
|
+
},
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
@field_validator("capabilities")
|
|
395
|
+
@classmethod
|
|
396
|
+
def capabilities_are_sorted(cls, value: tuple[IntegrationCapability, ...]) -> tuple[IntegrationCapability, ...]:
|
|
397
|
+
_require_sorted_unique(tuple(item.id for item in value), "capabilities")
|
|
398
|
+
return value
|
|
399
|
+
|
|
400
|
+
@field_validator("consumer_bot_app_ids")
|
|
401
|
+
@classmethod
|
|
402
|
+
def consumers_are_sorted(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
403
|
+
normalized = tuple(_slug(item) for item in value)
|
|
404
|
+
_require_sorted_unique(normalized, "consumerBotAppIds")
|
|
405
|
+
return normalized
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class SlackAutomationCatalog(_CatalogModel):
|
|
409
|
+
schema_version: Literal[1] = Field(alias="schemaVersion")
|
|
410
|
+
repository: str = Field(min_length=3, max_length=160, pattern=_REPOSITORY.pattern)
|
|
411
|
+
systems: tuple[SlackSystem, ...] = Field(min_length=1, json_schema_extra={"uniqueItems": True})
|
|
412
|
+
bot_apps: tuple[BotApp, ...] = Field(alias="botApps", json_schema_extra={"uniqueItems": True})
|
|
413
|
+
integrations: tuple[Integration, ...] = Field(json_schema_extra={"uniqueItems": True})
|
|
414
|
+
|
|
415
|
+
@field_validator("repository")
|
|
416
|
+
@classmethod
|
|
417
|
+
def repository_is_slug_pair(cls, value: str) -> str:
|
|
418
|
+
if _REPOSITORY.fullmatch(value) is None:
|
|
419
|
+
msg = "repository must be an owner/name slug"
|
|
420
|
+
raise ValueError(msg)
|
|
421
|
+
return value
|
|
422
|
+
|
|
423
|
+
@field_validator("systems")
|
|
424
|
+
@classmethod
|
|
425
|
+
def systems_are_sorted(cls, value: tuple[SlackSystem, ...]) -> tuple[SlackSystem, ...]:
|
|
426
|
+
_require_sorted_unique(tuple(item.id for item in value), "systems")
|
|
427
|
+
return value
|
|
428
|
+
|
|
429
|
+
@field_validator("bot_apps")
|
|
430
|
+
@classmethod
|
|
431
|
+
def bot_apps_are_sorted(cls, value: tuple[BotApp, ...]) -> tuple[BotApp, ...]:
|
|
432
|
+
_require_sorted_unique(tuple(item.id for item in value), "botApps")
|
|
433
|
+
return value
|
|
434
|
+
|
|
435
|
+
@field_validator("integrations")
|
|
436
|
+
@classmethod
|
|
437
|
+
def integrations_are_sorted(cls, value: tuple[Integration, ...]) -> tuple[Integration, ...]:
|
|
438
|
+
_require_sorted_unique(tuple(item.id for item in value), "integrations")
|
|
439
|
+
return value
|
|
440
|
+
|
|
441
|
+
@model_validator(mode="after")
|
|
442
|
+
def references_are_valid(self) -> Self:
|
|
443
|
+
automation_ids = tuple(item.id for item in (*self.bot_apps, *self.integrations))
|
|
444
|
+
if not automation_ids:
|
|
445
|
+
msg = "catalog must contain at least one bot app or integration"
|
|
446
|
+
raise ValueError(msg)
|
|
447
|
+
if len(automation_ids) != len(set(automation_ids)):
|
|
448
|
+
msg = "botApps and integrations must not reuse an id"
|
|
449
|
+
raise ValueError(msg)
|
|
450
|
+
system_ids = {system.id for system in self.systems}
|
|
451
|
+
bot_ids = {bot.id for bot in self.bot_apps}
|
|
452
|
+
used_system_ids: set[str] = set()
|
|
453
|
+
for automation in (*self.bot_apps, *self.integrations):
|
|
454
|
+
for capability in automation.capabilities:
|
|
455
|
+
for system_id in capability.connected_system_ids:
|
|
456
|
+
if system_id not in system_ids:
|
|
457
|
+
msg = f"{automation.id}/{capability.id} references unknown system {system_id}"
|
|
458
|
+
raise ValueError(msg)
|
|
459
|
+
used_system_ids.add(system_id)
|
|
460
|
+
for trigger in capability.triggers:
|
|
461
|
+
if isinstance(trigger, ExternalWebhookTrigger):
|
|
462
|
+
if trigger.source_system_id not in system_ids:
|
|
463
|
+
msg = f"{automation.id}/{capability.id} references unknown webhook system {trigger.source_system_id}"
|
|
464
|
+
raise ValueError(msg)
|
|
465
|
+
if trigger.source_system_id not in capability.connected_system_ids:
|
|
466
|
+
msg = f"{automation.id}/{capability.id} webhook source must be a connected system"
|
|
467
|
+
raise ValueError(msg)
|
|
468
|
+
for integration in self.integrations:
|
|
469
|
+
for consumer_id in integration.consumer_bot_app_ids:
|
|
470
|
+
if consumer_id not in bot_ids:
|
|
471
|
+
msg = f"integration {integration.id} references unknown consumer bot app {consumer_id}"
|
|
472
|
+
raise ValueError(msg)
|
|
473
|
+
unused_system_ids = system_ids - used_system_ids
|
|
474
|
+
if unused_system_ids:
|
|
475
|
+
msg = f"systems must be referenced by a capability: {', '.join(sorted(unused_system_ids))}"
|
|
476
|
+
raise ValueError(msg)
|
|
477
|
+
return self
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class _DuplicateKeyError(ValueError):
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def validate_catalog(path: Path, *, root: Path | None = None) -> tuple[CatalogFinding, ...]:
|
|
485
|
+
resolved_root = root.resolve() if root is not None else None
|
|
486
|
+
if resolved_root is not None:
|
|
487
|
+
try:
|
|
488
|
+
resolved_catalog = path.resolve(strict=True)
|
|
489
|
+
except OSError as exc:
|
|
490
|
+
return (CatalogFinding("$", f"cannot resolve catalog: {exc}"),)
|
|
491
|
+
if path.is_symlink() or not resolved_catalog.is_relative_to(resolved_root):
|
|
492
|
+
return (CatalogFinding("$", "catalog must be a regular file inside the repository"),)
|
|
493
|
+
try:
|
|
494
|
+
payload = path.read_text(encoding="utf-8")
|
|
495
|
+
except OSError as exc:
|
|
496
|
+
return (CatalogFinding("$", f"cannot read catalog: {exc}"),)
|
|
497
|
+
try:
|
|
498
|
+
json.loads(payload, object_pairs_hook=_object_without_duplicate_keys)
|
|
499
|
+
except _DuplicateKeyError as exc:
|
|
500
|
+
return (CatalogFinding("$", str(exc)),)
|
|
501
|
+
except json.JSONDecodeError as exc:
|
|
502
|
+
return (CatalogFinding(f"line {exc.lineno}, column {exc.colno}", exc.msg),)
|
|
503
|
+
try:
|
|
504
|
+
catalog = SlackAutomationCatalog.model_validate_json(payload)
|
|
505
|
+
except ValidationError as exc:
|
|
506
|
+
return tuple(
|
|
507
|
+
sorted(
|
|
508
|
+
CatalogFinding(_location(error["loc"]), str(error["msg"]))
|
|
509
|
+
for error in exc.errors(include_url=False, include_context=False)
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
return _path_findings(catalog, resolved_root) if resolved_root is not None else ()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def render_schema() -> str:
|
|
516
|
+
document = SlackAutomationCatalog.model_json_schema(by_alias=True, mode="validation")
|
|
517
|
+
document["anyOf"] = [
|
|
518
|
+
{"properties": {"botApps": {"minItems": 1}}},
|
|
519
|
+
{"properties": {"integrations": {"minItems": 1}}},
|
|
520
|
+
]
|
|
521
|
+
document["$id"] = "https://standards.sarj.ai/schemas/slack-app-catalog/v1"
|
|
522
|
+
document["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
|
523
|
+
return json.dumps(document, indent=2, sort_keys=True) + "\n"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _object_without_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
|
527
|
+
result: dict[str, object] = {}
|
|
528
|
+
for key, value in pairs:
|
|
529
|
+
if key in result:
|
|
530
|
+
msg = f"duplicate JSON key: {key}"
|
|
531
|
+
raise _DuplicateKeyError(msg)
|
|
532
|
+
result[key] = value
|
|
533
|
+
return result
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _public_text(value: str) -> str:
|
|
537
|
+
if not value or value != value.strip() or "\n" in value or "\r" in value:
|
|
538
|
+
msg = "public text must be nonempty, trimmed, and single-line"
|
|
539
|
+
raise ValueError(msg)
|
|
540
|
+
forbidden = (
|
|
541
|
+
(_RAW_SLACK_ID, "raw Slack IDs"),
|
|
542
|
+
(_SECRET_VALUE, "credentials"),
|
|
543
|
+
(_SECRET_NAME, "secret environment-variable names"),
|
|
544
|
+
(_CHANNEL_IDENTIFIER, "Slack channel identifiers"),
|
|
545
|
+
(_PRIVATE_CHANNEL_IDENTIFIER, "private channel identifiers"),
|
|
546
|
+
(_INTERNAL_URL, "admin or internal URLs"),
|
|
547
|
+
)
|
|
548
|
+
for pattern, label in forbidden:
|
|
549
|
+
if pattern.search(value) is not None:
|
|
550
|
+
msg = f"public text must not contain {label}"
|
|
551
|
+
raise ValueError(msg)
|
|
552
|
+
return value
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _slug(value: str) -> str:
|
|
556
|
+
if _SLUG.fullmatch(value) is None:
|
|
557
|
+
msg = "id must be a lowercase kebab-case slug"
|
|
558
|
+
raise ValueError(msg)
|
|
559
|
+
return value
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _relative_path(value: str) -> str:
|
|
563
|
+
candidate = Path(value)
|
|
564
|
+
if (
|
|
565
|
+
not value
|
|
566
|
+
or value != candidate.as_posix()
|
|
567
|
+
or candidate.is_absolute()
|
|
568
|
+
or value.endswith("/")
|
|
569
|
+
or any(part in {"", ".", ".."} for part in candidate.parts)
|
|
570
|
+
):
|
|
571
|
+
msg = "source paths must be normalized repository-relative POSIX paths"
|
|
572
|
+
raise ValueError(msg)
|
|
573
|
+
return value
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _require_sorted_unique(values: tuple[str, ...], label: str) -> None:
|
|
577
|
+
if tuple(sorted(values)) != values:
|
|
578
|
+
msg = f"{label} must be sorted"
|
|
579
|
+
raise ValueError(msg)
|
|
580
|
+
if len(values) != len(set(values)):
|
|
581
|
+
msg = f"{label} must not contain duplicates"
|
|
582
|
+
raise ValueError(msg)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _location(parts: tuple[int | str, ...]) -> str:
|
|
586
|
+
rendered_parts = ["$"]
|
|
587
|
+
rendered_parts.extend(f"[{part}]" if isinstance(part, int) else f".{part}" for part in parts)
|
|
588
|
+
return "".join(rendered_parts)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _path_findings(catalog: SlackAutomationCatalog, root: Path) -> tuple[CatalogFinding, ...]:
|
|
592
|
+
findings: list[CatalogFinding] = []
|
|
593
|
+
for collection_name, entries in (("botApps", catalog.bot_apps), ("integrations", catalog.integrations)):
|
|
594
|
+
for entry_index, entry in enumerate(entries):
|
|
595
|
+
for path_index, source_path in enumerate(entry.source_paths):
|
|
596
|
+
_append_path_finding(
|
|
597
|
+
findings, root, source_path, f"$.{collection_name}[{entry_index}].sourcePaths[{path_index}]"
|
|
598
|
+
)
|
|
599
|
+
if isinstance(entry, _BotApp) and isinstance(entry.configuration, RepositoryManifestConfiguration):
|
|
600
|
+
_append_path_finding(
|
|
601
|
+
findings,
|
|
602
|
+
root,
|
|
603
|
+
entry.configuration.manifest_path,
|
|
604
|
+
f"$.{collection_name}[{entry_index}].configuration.manifestPath",
|
|
605
|
+
require_file=True,
|
|
606
|
+
)
|
|
607
|
+
return tuple(sorted(findings))
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _append_path_finding(
|
|
611
|
+
findings: list[CatalogFinding],
|
|
612
|
+
root: Path,
|
|
613
|
+
value: str,
|
|
614
|
+
location: str,
|
|
615
|
+
*,
|
|
616
|
+
require_file: bool = False,
|
|
617
|
+
) -> None:
|
|
618
|
+
candidate = root / value
|
|
619
|
+
try:
|
|
620
|
+
resolved = candidate.resolve(strict=True)
|
|
621
|
+
except OSError:
|
|
622
|
+
findings.append(CatalogFinding(location, f"repository path does not exist: {value}"))
|
|
623
|
+
return
|
|
624
|
+
if not resolved.is_relative_to(root):
|
|
625
|
+
findings.append(CatalogFinding(location, f"repository path escapes through a symlink: {value}"))
|
|
626
|
+
elif require_file and not resolved.is_file():
|
|
627
|
+
findings.append(CatalogFinding(location, f"repository manifest path must be a file: {value}"))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .manifest import (
|
|
2
|
+
CorpusKind,
|
|
3
|
+
CorpusManifest,
|
|
4
|
+
CorpusSource,
|
|
5
|
+
CorpusVisibility,
|
|
6
|
+
load_manifest,
|
|
7
|
+
load_private_overlay,
|
|
8
|
+
merge_manifests,
|
|
9
|
+
)
|
|
10
|
+
from .snapshot import CorpusSnapshot, selected_files, snapshot, verify
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CorpusKind",
|
|
15
|
+
"CorpusManifest",
|
|
16
|
+
"CorpusSnapshot",
|
|
17
|
+
"CorpusSource",
|
|
18
|
+
"CorpusVisibility",
|
|
19
|
+
"load_manifest",
|
|
20
|
+
"load_private_overlay",
|
|
21
|
+
"merge_manifests",
|
|
22
|
+
"selected_files",
|
|
23
|
+
"snapshot",
|
|
24
|
+
"verify",
|
|
25
|
+
]
|