code-standards 7.0.0__py3-none-any.whl

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