pytaut 0.1.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.
- pytaut-0.1.0.dist-info/METADATA +174 -0
- pytaut-0.1.0.dist-info/RECORD +88 -0
- pytaut-0.1.0.dist-info/WHEEL +4 -0
- pytaut-0.1.0.dist-info/entry_points.txt +2 -0
- pytaut-0.1.0.dist-info/licenses/LICENSE +21 -0
- taut/__init__.py +5 -0
- taut/analysis/__init__.py +3 -0
- taut/analysis/contracts.py +108 -0
- taut/analysis/module_analysis.py +31 -0
- taut/analysis/project_analyzer.py +80 -0
- taut/analysis/project_index.py +132 -0
- taut/analysis/python/__init__.py +3 -0
- taut/analysis/python/adapter.py +421 -0
- taut/analysis/python/expression_summary.py +159 -0
- taut/analysis/python/failed_analysis.py +44 -0
- taut/analysis/python/language_adapter.py +87 -0
- taut/analysis/python/symbol_resolver.py +216 -0
- taut/analysis/semantic_model.py +75 -0
- taut/cli.py +280 -0
- taut/configuration/__init__.py +3 -0
- taut/configuration/catalog.py +102 -0
- taut/configuration/effective_policy.py +362 -0
- taut/configuration/manifest.py +121 -0
- taut/configuration/model.py +51 -0
- taut/configuration/rule_standard.py +61 -0
- taut/configuration/validation.py +20 -0
- taut/domain/__init__.py +3 -0
- taut/domain/diagnostics.py +32 -0
- taut/domain/evaluations.py +81 -0
- taut/domain/facts.py +370 -0
- taut/domain/findings.py +81 -0
- taut/domain/frozen.py +48 -0
- taut/domain/ids.py +84 -0
- taut/domain/ignores.py +22 -0
- taut/domain/issues.py +33 -0
- taut/domain/location.py +81 -0
- taut/domain/provenance.py +21 -0
- taut/domain/reports.py +85 -0
- taut/domain/snapshot.py +79 -0
- taut/finding_processing/__init__.py +3 -0
- taut/finding_processing/finding_processor.py +192 -0
- taut/finding_processing/report_builder.py +55 -0
- taut/loading/__init__.py +3 -0
- taut/loading/boundary_extension_schema.py +43 -0
- taut/loading/builtin_catalog.py +49 -0
- taut/loading/code_conventions.py +136 -0
- taut/loading/config_loader.py +488 -0
- taut/loading/configuration_document.py +222 -0
- taut/loading/default_configuration.py +59 -0
- taut/loading/errors.py +2 -0
- taut/loading/inline_ignores.py +81 -0
- taut/loading/source_discovery.py +159 -0
- taut/policy/__init__.py +3 -0
- taut/policy/context.py +44 -0
- taut/policy/decision_digest.py +30 -0
- taut/policy/engine.py +187 -0
- taut/policy/indexes.py +82 -0
- taut/policy/registry.py +27 -0
- taut/policy/rule.py +75 -0
- taut/policy/rules/__init__.py +99 -0
- taut/policy/rules/api_contracts.py +484 -0
- taut/policy/rules/architecture.py +142 -0
- taut/policy/rules/async_safety.py +73 -0
- taut/policy/rules/boundary.py +164 -0
- taut/policy/rules/catalog_coverage.py +68 -0
- taut/policy/rules/classification.py +54 -0
- taut/policy/rules/construction_boundaries.py +191 -0
- taut/policy/rules/conventions.py +144 -0
- taut/policy/rules/enums.py +217 -0
- taut/policy/rules/exceptions.py +241 -0
- taut/policy/rules/external_calls.py +134 -0
- taut/policy/rules/helpers.py +48 -0
- taut/policy/rules/ignore_audit.py +35 -0
- taut/policy/rules/layer_boundaries.py +440 -0
- taut/policy/rules/model_shapes.py +366 -0
- taut/policy/rules/persistence.py +370 -0
- taut/policy/rules/responsibility_boundaries.py +227 -0
- taut/policy/rules/runtime_safety.py +168 -0
- taut/policy/rules/security.py +122 -0
- taut/policy/rules/session.py +192 -0
- taut/policy/rules/test_boundaries.py +110 -0
- taut/policy/rules/time_access.py +86 -0
- taut/policy/rules/transaction.py +106 -0
- taut/policy/scheduler.py +51 -0
- taut/py.typed +1 -0
- taut/reporting/__init__.py +3 -0
- taut/reporting/json.py +117 -0
- taut/reporting/text.py +211 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytaut
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic architecture policy checks for Python backends
|
|
5
|
+
Project-URL: Documentation, https://github.com/taewoo-dev/taut#readme
|
|
6
|
+
Project-URL: Issues, https://github.com/taewoo-dev/taut/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/taewoo-dev/taut
|
|
8
|
+
Author-email: TaeWoo Kim <173425210+taewoo-dev@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: architecture,linter,policy,python,static-analysis
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# taut
|
|
24
|
+
|
|
25
|
+
`taut` makes hidden Python backend conventions explicit and blocks only violations it can
|
|
26
|
+
determine reliably. The same source and configuration always produce the same result. It does
|
|
27
|
+
not hard-code the names or directory layout of any company or service.
|
|
28
|
+
|
|
29
|
+
The first PyPI release is being prepared. Until it is published, install directly from GitHub:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add --dev "pytaut @ git+https://github.com/taewoo-dev/taut.git"
|
|
33
|
+
uv run taut check .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
After the PyPI release, installation becomes:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv add --dev pytaut
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
Define repository roles and allowed dependencies in `pyproject.toml`. Strict mode is enabled by
|
|
45
|
+
default, and the built-in maximum file length is 700 lines.
|
|
46
|
+
|
|
47
|
+
```toml
|
|
48
|
+
[tool.taut]
|
|
49
|
+
strict = true
|
|
50
|
+
source_roots = ["."]
|
|
51
|
+
|
|
52
|
+
[tool.taut.roles]
|
|
53
|
+
router = ["app/router/*.py", "app/router/**/*.py"]
|
|
54
|
+
service = ["app/service/*.py", "app/service/**/*.py"]
|
|
55
|
+
|
|
56
|
+
[tool.taut.allow]
|
|
57
|
+
router = ["router", "service"]
|
|
58
|
+
service = ["service"]
|
|
59
|
+
|
|
60
|
+
[tool.taut.zones]
|
|
61
|
+
test = ["tests/*.py", "tests/**/*.py"]
|
|
62
|
+
|
|
63
|
+
[tool.taut.transaction]
|
|
64
|
+
owner_roles = ["service"]
|
|
65
|
+
session_providers = ["app.database.get_async_session"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Built-in policies cover external calls, databases, security, DTOs, and schemas. Add only the
|
|
69
|
+
repository-specific differences:
|
|
70
|
+
|
|
71
|
+
```toml
|
|
72
|
+
[tool.taut.external]
|
|
73
|
+
modules = ["company_sdk"]
|
|
74
|
+
wrappers = ["app.adapters.external_call"]
|
|
75
|
+
|
|
76
|
+
[tool.taut.enum]
|
|
77
|
+
shared_modules = ["app.core.enums"]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Commands
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
taut config validate .
|
|
84
|
+
taut check .
|
|
85
|
+
taut check . --verbose
|
|
86
|
+
taut check . --format json
|
|
87
|
+
taut rules
|
|
88
|
+
taut rules ASYNC001
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To check another local repository:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
uvx --no-cache --from /path/to/taut taut check /path/to/project
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For a one-time audit that must not modify the target repository, provide an absolute path to an
|
|
98
|
+
external configuration file:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
taut check /path/to/project --config /path/to/audit-policy.toml
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Role patterns and `source_roots` are resolved relative to the target project, not the
|
|
105
|
+
configuration file. The legacy `.policy/policy.toml` format and explicit external configuration
|
|
106
|
+
files remain supported.
|
|
107
|
+
|
|
108
|
+
## Results
|
|
109
|
+
|
|
110
|
+
The default terminal output prints one finding per line followed by the error and warning totals.
|
|
111
|
+
Long findings wrap to the next indented line. Non-terminal output uses a width of 120 characters;
|
|
112
|
+
override it with an option such as `--width 100`. Use `--verbose` only when you need related
|
|
113
|
+
locations, remediation guidance, decision counts, and the decision digest.
|
|
114
|
+
|
|
115
|
+
- Exit code `0`: no enforced violations
|
|
116
|
+
- Exit code `1`: one or more enforced violations
|
|
117
|
+
- Exit code `2`: invalid configuration, analysis failure, or an enforced rule that could not be
|
|
118
|
+
evaluated
|
|
119
|
+
|
|
120
|
+
When an exception is unavoidable, suppress only the exact rule on the affected line:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
legacy_call() # taut: ignore[ASYNC001]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
An ignore without a rule ID, or with an unknown rule ID, is a configuration error. An ignore that
|
|
127
|
+
does not suppress a real violation is reported as `IGNORE001`. File-wide ignores, violation
|
|
128
|
+
baselines, and expiration management are intentionally unsupported.
|
|
129
|
+
|
|
130
|
+
Raw SQL is not generally allowed. Application code should use SQLAlchemy expressions. A necessary
|
|
131
|
+
raw query must pass through a registered shared wrapper configured with `raw_query_roles` and
|
|
132
|
+
`raw_query_wrappers`. Fixed Model `server_default` expressions and partial Index predicates are
|
|
133
|
+
allowed only within `schema_sql_roles` and `schema_sql_argument_names`.
|
|
134
|
+
|
|
135
|
+
Registered raw-query calls must provide `name`, `statement`, and `parameters` as explicit keyword
|
|
136
|
+
arguments. `name` and `statement` must be string literals, preventing SQL construction through
|
|
137
|
+
f-strings or string concatenation.
|
|
138
|
+
|
|
139
|
+
## Built-in rules
|
|
140
|
+
|
|
141
|
+
With the default `strict = true`, `CAT001` is advisory and the other 47 rules are enforced.
|
|
142
|
+
Individual rules cannot be disabled. Use `strict = false` before adoption to report every finding
|
|
143
|
+
as a warning.
|
|
144
|
+
|
|
145
|
+
| Group | Rules |
|
|
146
|
+
|---|---|
|
|
147
|
+
| Architecture | `ARCH000`-`002`, `BOUNDARY001`-`003`, `ENTRY001`, `SERVICE001`, `QUERY001`, `MODEL001`, `ADAPTER001`-`002`, `WIRING001`, `CONFIG001`, `DEPENDS001` |
|
|
148
|
+
| Runtime safety | `TIME001`, `ASYNC001`, `RUNTIME001`, `IMPORT001`, `IMPORT002`, `SIZE001`, `SEC001` |
|
|
149
|
+
| Database and transactions | `TX001`, `TX002`, `SESSION001`-`003`, `ORM001`, `ORM002`, `DB001`, `SQL001` |
|
|
150
|
+
| External calls | `HTTP001`, `LOG001`, `CAT001` |
|
|
151
|
+
| Data contracts | `DTO001`, `DTO002`, `SNAPSHOT001`, `SCHEMA001`-`003`, `API001`-`003`, `ENUM001`, `EXC001` |
|
|
152
|
+
| Test boundaries | `TEST001`, `TEST002` |
|
|
153
|
+
| Inline ignores | `IGNORE001` |
|
|
154
|
+
|
|
155
|
+
Each rule declares whether it applies to `prod`, `test`, `migration`, or `script` code. Missing
|
|
156
|
+
roles, dependency cycles, import placement, file size, dynamic execution, async safety, and
|
|
157
|
+
security access are checked in every zone. API, DTO, database, and service-boundary rules apply to
|
|
158
|
+
production code.
|
|
159
|
+
|
|
160
|
+
An unregistered call that might have an external effect cannot be proven unsafe, so it is reported
|
|
161
|
+
as a `CAT001` warning. After classifying the call, add it to the project effect catalog for precise
|
|
162
|
+
enforcement.
|
|
163
|
+
|
|
164
|
+
## Development
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
bash scripts/test.sh
|
|
168
|
+
bash scripts/test.sh --only tests/unit/policy/test_builtin_rules.py -x
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The full check runs the repository's own policy rules, Ruff, mypy strict, Pyright strict, pytest
|
|
172
|
+
with at least 90% branch coverage, package builds, and an isolated wheel installation.
|
|
173
|
+
|
|
174
|
+
See [`docs/README.md`](docs/README.md) for the current design documents.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
taut/__init__.py,sha256=HnAKS-Y86m4B9dLgE-zEJW_T_BCWzrGPUqt20TKNLbs,117
|
|
2
|
+
taut/cli.py,sha256=eIHnaVokfWeD5XbmIqUMMvzrFRkC9xbhA-74fb_f38I,11088
|
|
3
|
+
taut/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
4
|
+
taut/analysis/__init__.py,sha256=vMBCVVPvRxyWpXh6eZfI96zHlBhT0FKOoKtYGP60ZyU,84
|
|
5
|
+
taut/analysis/contracts.py,sha256=meMv6Y9hdOY4tJUYyx_tnDrXMjKGv069TeZS747RwjY,3253
|
|
6
|
+
taut/analysis/module_analysis.py,sha256=g1wBRZdfdHxkHN7qKxbMQJA_cXHfZ5YYVCohC4fSk2o,1042
|
|
7
|
+
taut/analysis/project_analyzer.py,sha256=LoOJkk9lmbzcepWJnIe6hdWEqcvg4DPm9SL_DibFnrA,2959
|
|
8
|
+
taut/analysis/project_index.py,sha256=IlWHSw-MIv7520YCiuIBVGO9CrbDZ9c36iu5piJaGeo,4572
|
|
9
|
+
taut/analysis/semantic_model.py,sha256=PTHs_--Z98-bxU-WJBVpAL5IEcuVLFO8vZ9W-d9tjZk,2280
|
|
10
|
+
taut/analysis/python/__init__.py,sha256=gl5VU3-h8qWbCKoNnPJjQaU1dj6mddh751WFiEwB0wY,67
|
|
11
|
+
taut/analysis/python/adapter.py,sha256=nrnXVa1POjK6131ePDEOErMb3DngUo0DdMJ37W2UUW8,16558
|
|
12
|
+
taut/analysis/python/expression_summary.py,sha256=UttgAXYppatHOq8d9jHbUeJMnKLT4JgFGalkMzAqzao,6042
|
|
13
|
+
taut/analysis/python/failed_analysis.py,sha256=RPLjne6XBKGta6zfKUeQtMWx5TbIReUXtNExC15joJc,1217
|
|
14
|
+
taut/analysis/python/language_adapter.py,sha256=maaNu8E3uObjMWLS6O8k4O8YFOl6MJX_dyz_ArBDP20,3385
|
|
15
|
+
taut/analysis/python/symbol_resolver.py,sha256=0wontLEU9JHB34qpVf92MS6hJ2B0Jwn64wvD7FqQkpY,8367
|
|
16
|
+
taut/configuration/__init__.py,sha256=npHXSsBE-UiKtvuIDxHAhYykF119d6kRvBtAt78tqJU,96
|
|
17
|
+
taut/configuration/catalog.py,sha256=DbNTmPuD4_xGQ7wxytLaQu4gt1VhEQXMQIleDsVlnxI,3081
|
|
18
|
+
taut/configuration/effective_policy.py,sha256=PJcI99h3bmHavJRYIg3MuNk_bDIQMez5NdAdG2xA0Sg,18742
|
|
19
|
+
taut/configuration/manifest.py,sha256=GFENSLIQ7NIZ7_S0Xm-kJMvivJkLW3J742H3bZigLIE,3964
|
|
20
|
+
taut/configuration/model.py,sha256=QmfzAbOBmJq_xFlAm-jzDQfeB70a3M_RmvZ4U-ogOVU,1847
|
|
21
|
+
taut/configuration/rule_standard.py,sha256=RFlaMJsKbw2wtafpcVkK9aIzG4SpwDYREfkrw1H4d68,1293
|
|
22
|
+
taut/configuration/validation.py,sha256=bZxfxtK9wOuKBVIzE5GsLkJbJ0Y1S3egWKmfA7YXtGE,755
|
|
23
|
+
taut/domain/__init__.py,sha256=Ee4ognEKqbz7uhFLFi090lGtYW3WixnN9-T5lMfT0ec,102
|
|
24
|
+
taut/domain/diagnostics.py,sha256=vu0fMhV_C9dkT8G2QKgMK5f5fkh3Zuq4DAfQcwZ78ek,890
|
|
25
|
+
taut/domain/evaluations.py,sha256=kPJ9JuSc8NPOrukfaCWiHTazU9wtwO0BeB7yZ0rJCbA,2388
|
|
26
|
+
taut/domain/facts.py,sha256=zlnk7IaNnV_60WQPCVCwaXSLb5PqoBYPteOPucX7trE,11261
|
|
27
|
+
taut/domain/findings.py,sha256=DXmtqa2eV0QUD1Dv_R03v1thLDDh555R9zRTMCS9HD4,2166
|
|
28
|
+
taut/domain/frozen.py,sha256=l_L9QGpNZGe3rhGStmrcmA7YBA-ydkM2YboieB1HNyU,1517
|
|
29
|
+
taut/domain/ids.py,sha256=WyT3Snhm44RBIvp0TlFRHsK9riTBGPe2x_Vn-V99vQ4,2081
|
|
30
|
+
taut/domain/ignores.py,sha256=ETKQufcK1Gx96oiiPJbwNBYCjKJ-IP6vdn1NqocTaDY,545
|
|
31
|
+
taut/domain/issues.py,sha256=KYYb0qcvQsIeMPSUApszJC9nktyCOcd-v-wnZfAPhR4,1004
|
|
32
|
+
taut/domain/location.py,sha256=81N0kCUGFvfL4T5AQSJHJsXLdYy3j3mjzOJgZg5b5-E,2744
|
|
33
|
+
taut/domain/provenance.py,sha256=tMwIijPJQxTVJX_ovxdHF9al-N0pUsCfW4j6u55VllI,615
|
|
34
|
+
taut/domain/reports.py,sha256=K_Y-lkgraUJqcJcNB_AGZenKUmk-_rHaSzGFO-MzooI,2478
|
|
35
|
+
taut/domain/snapshot.py,sha256=D14UnwFigZRb33zzdZ14nog-M7bfCZF8nycAIXxAo40,2524
|
|
36
|
+
taut/finding_processing/__init__.py,sha256=zzAw-zCHHBIdoSRjBKP9-kWQq863vRAdd53fi183PGY,85
|
|
37
|
+
taut/finding_processing/finding_processor.py,sha256=zktgKM5YkKIfzE35SY-ub1luzpxTufA4XI2t7ctY7fs,11040
|
|
38
|
+
taut/finding_processing/report_builder.py,sha256=R5lK5z20bNMSLJP34K5E8PJ077jM_xF_dp3qJXIQDNM,1724
|
|
39
|
+
taut/loading/__init__.py,sha256=3VmDcvGO8Lg23_qHTvi9pu7YFghN3weLLPyVg6HREOc,92
|
|
40
|
+
taut/loading/boundary_extension_schema.py,sha256=Sh0Sin23Jt3soO9jvFgHaTcbXKgfYBHn4E6HPqrBbss,1260
|
|
41
|
+
taut/loading/builtin_catalog.py,sha256=G3akQ-j0XNkW9HJwP2MO4j8UONW5Kc6-FxpQVbzZgM8,2178
|
|
42
|
+
taut/loading/code_conventions.py,sha256=x6ZjcljlabiGsA6u5oTmPL7n6R3rl_MBIgRyVzUKkcg,5398
|
|
43
|
+
taut/loading/config_loader.py,sha256=AzPtoZOfOrNLKc7xxVRoCDRJZENQvkqZ5rs5pRiVlkk,20067
|
|
44
|
+
taut/loading/configuration_document.py,sha256=2MrtZZtL92vfg5gHzPCRXfjz39LCZtBVn0nG4g0EGPg,7743
|
|
45
|
+
taut/loading/default_configuration.py,sha256=HnCwTUMBiOhtAQnBqLaP7bCYOQ3qZHQ-vwN54Y0rReM,2271
|
|
46
|
+
taut/loading/errors.py,sha256=0pW0tXm7pMwyFG8iFMa5BQfEgAYDAJsgw_RRBs8lTsE,46
|
|
47
|
+
taut/loading/inline_ignores.py,sha256=zZto8e4ZlPGVwdQIDDbRVxUrQqTKP8LPEnaNhwPlU2k,3066
|
|
48
|
+
taut/loading/source_discovery.py,sha256=o8EmqhDrP97NwkL7Z--Q-Vsv4hQJal6QtfmkwwsNS0I,6002
|
|
49
|
+
taut/policy/__init__.py,sha256=B9COZ5C-YzbssYbmCU9kUf-e9aEYle_xs68fCwuYO7k,89
|
|
50
|
+
taut/policy/context.py,sha256=QjS4XCfHkLyOj3nhZdommCFcghA0ak6S4UVQUuG2f_c,1436
|
|
51
|
+
taut/policy/decision_digest.py,sha256=-96HZRFjsop8giUeHxx23-HdzCZddSsq7Czv5qCAZ1g,999
|
|
52
|
+
taut/policy/engine.py,sha256=DFaFXGt2BtNWEckbZ3CrRJfqSBMXJMmvbbQ8bE1Vde4,7465
|
|
53
|
+
taut/policy/indexes.py,sha256=jb0IJUeELEorWuCz22TfYMJgAykRM0-qq5S769o-YP8,3525
|
|
54
|
+
taut/policy/registry.py,sha256=KvDdrtYoWe6cjtqrL_x5yqrcO9CJUPmi3WA4pXrHmso,969
|
|
55
|
+
taut/policy/rule.py,sha256=i4Cs8ol4OkdIINUv5zulvA0J23kPqUmOs7OKLJH2oqY,2591
|
|
56
|
+
taut/policy/scheduler.py,sha256=iqZ6aHthR2XkEM7Me7jddruH5o9uFU9yi6eRHF49wnQ,2179
|
|
57
|
+
taut/policy/rules/__init__.py,sha256=QJxHOyfZ4QsSCb0b5RjRhzvi0ZfVgbm37wCCJ7EgsbM,4019
|
|
58
|
+
taut/policy/rules/api_contracts.py,sha256=_yLrkGs3WIh9URw99-jUTUegLjRxIWCwyknMON1y4qA,19021
|
|
59
|
+
taut/policy/rules/architecture.py,sha256=kDxxGxSTPE52ddWeG0VZmyO8Kkj3TQ6NajS3nNJKAPc,6226
|
|
60
|
+
taut/policy/rules/async_safety.py,sha256=NEbAhEEgSRWnxidbjFbTVLh-PqGb8Yhingy63blrBxI,3291
|
|
61
|
+
taut/policy/rules/boundary.py,sha256=AcF9g-baLVgSCSuP_gq-E8pUaiYncJl_wzyq3hvayLQ,6193
|
|
62
|
+
taut/policy/rules/catalog_coverage.py,sha256=A257BUdlRbIVAk38QcWYOJHWbDB84oTG8dUqtrU-_RU,2902
|
|
63
|
+
taut/policy/rules/classification.py,sha256=Aa-pMVDeYZikoA0UP7e4jjulUlxcyTZRQWV0q_hALaE,2389
|
|
64
|
+
taut/policy/rules/construction_boundaries.py,sha256=_T03ZpAS0Q2SfKyKwPmd06TkaNr0Oz9wy0vjWGwfGEw,7803
|
|
65
|
+
taut/policy/rules/conventions.py,sha256=pWRjjSfaKwDL9nPJVbvcyeBior5a8ls2_K3zhjHyXrE,6150
|
|
66
|
+
taut/policy/rules/enums.py,sha256=_HjDtVqxyq3W2CD0dbm0RWg_JV3M2oZvligGI3eugwA,7790
|
|
67
|
+
taut/policy/rules/exceptions.py,sha256=8G4MVyhm0ci2LLrokzxxLsoZ_PFXLRdfuLQxhp7K1tU,9317
|
|
68
|
+
taut/policy/rules/external_calls.py,sha256=q28dQOXlfPrxnmcrXt_hsj-i-l4140DfWyjy14OfMGE,5583
|
|
69
|
+
taut/policy/rules/helpers.py,sha256=s1IsXcUUhEZJJuQLG8UqIIGixeWEeKQM6CPOdvua_8o,1386
|
|
70
|
+
taut/policy/rules/ignore_audit.py,sha256=KvZ5S2D6DwKxRR-b3lQrXYEY8ZVYPm0Vikl5P8sc8cY,1486
|
|
71
|
+
taut/policy/rules/layer_boundaries.py,sha256=skOAI9NIqm981OEZHdGb8ZUlO8T1VsT8qVSbn-dJNo8,17201
|
|
72
|
+
taut/policy/rules/model_shapes.py,sha256=oR4Svg4VEwqB-_ckk-5ExSkJrVTJ_TFa26wjTLbKNWU,13919
|
|
73
|
+
taut/policy/rules/persistence.py,sha256=Y7RE25YE6ZJKXfYObTa0FzLO4S2_StabYr1RO1hrP5E,14860
|
|
74
|
+
taut/policy/rules/responsibility_boundaries.py,sha256=jh_77h7-2DXyOreNHnZxNWcqxIVIn8y4Ce8GLxUsa1w,9009
|
|
75
|
+
taut/policy/rules/runtime_safety.py,sha256=q9VW24VnOApTPAyrC7c0xXZIF9n3Q8maGQzyVa7u-pU,6934
|
|
76
|
+
taut/policy/rules/security.py,sha256=VxcDU7qubZiF1b-tEWCSJ9vKwWHl-UPpg9rstDVsLBc,5245
|
|
77
|
+
taut/policy/rules/session.py,sha256=rD91qGaD1KXyM5z0PNK7ae9rJkLiRZEp1_WNVLNy0Ow,8614
|
|
78
|
+
taut/policy/rules/test_boundaries.py,sha256=QrX9s4c2Z35kXgj2tt3dlDqb_LEKm80XqwPKPc-Y6yY,4567
|
|
79
|
+
taut/policy/rules/time_access.py,sha256=UM-5qkBgVcLkMoZy8G8zdCqCM0u6ZxdancaKTGfk7_c,3810
|
|
80
|
+
taut/policy/rules/transaction.py,sha256=rkYfQOrssp38HWb4T_5LN3bQ2W5Y5AMVGOhMX21t9aQ,4794
|
|
81
|
+
taut/reporting/__init__.py,sha256=KZxYW1rOm_idC3mb9KDNPVLcxRvC7ZejFHrgEoxOqr0,82
|
|
82
|
+
taut/reporting/json.py,sha256=VisbIGAvy_0_V1IpYyeBVnct4Zouw4HKBP5uMftElhc,4446
|
|
83
|
+
taut/reporting/text.py,sha256=sTivtti4r9guuK4ZapsGdwkGQXB2jQFrJLmLKcNe2xA,7188
|
|
84
|
+
pytaut-0.1.0.dist-info/METADATA,sha256=A12m5V0Wp9bqVwPwS6HjCDV2dZDc1ReF0v-I4fEtiLk,6355
|
|
85
|
+
pytaut-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
86
|
+
pytaut-0.1.0.dist-info/entry_points.txt,sha256=XmqwEA_0kKek9F2umLrcVkSAGv5KDfNDkQgaVwnY_68,39
|
|
87
|
+
pytaut-0.1.0.dist-info/licenses/LICENSE,sha256=GGKppGXt0fZTXrHpv0D5k0wXwExUBY2uSSmYZyGzRrs,1067
|
|
88
|
+
pytaut-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TaeWoo Kim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
taut/__init__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from taut.domain.facts import ModuleFacts, SourceKind
|
|
8
|
+
from taut.domain.frozen import FrozenMap
|
|
9
|
+
from taut.domain.ids import ModuleId, SymbolId
|
|
10
|
+
from taut.domain.issues import EngineIssue
|
|
11
|
+
from taut.domain.location import ProjectPath
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, order=True)
|
|
15
|
+
class ProjectRoot:
|
|
16
|
+
value: Path
|
|
17
|
+
|
|
18
|
+
def __post_init__(self) -> None:
|
|
19
|
+
if not self.value.is_absolute():
|
|
20
|
+
raise ValueError("project root must be an absolute path")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, order=True)
|
|
24
|
+
class LanguageSettings:
|
|
25
|
+
language: str = "python"
|
|
26
|
+
target_version: str = "3.12"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, order=True)
|
|
30
|
+
class ContextManagerProvider:
|
|
31
|
+
symbol: SymbolId
|
|
32
|
+
item_type: SymbolId
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, order=True)
|
|
36
|
+
class ResolverSettings:
|
|
37
|
+
source_roots: tuple[ProjectPath, ...] = (ProjectPath("."),)
|
|
38
|
+
context_manager_providers: tuple[ContextManagerProvider, ...] = ()
|
|
39
|
+
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
symbols = tuple(provider.symbol for provider in self.context_manager_providers)
|
|
42
|
+
if len(symbols) != len(set(symbols)):
|
|
43
|
+
raise ValueError("context manager provider symbols must be unique")
|
|
44
|
+
if self.context_manager_providers != tuple(sorted(self.context_manager_providers)):
|
|
45
|
+
raise ValueError("context manager providers must be sorted")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, order=True)
|
|
49
|
+
class SourceInput:
|
|
50
|
+
path: ProjectPath
|
|
51
|
+
module_id: ModuleId
|
|
52
|
+
kind: SourceKind
|
|
53
|
+
is_policy_target: bool
|
|
54
|
+
is_package: bool
|
|
55
|
+
content: str
|
|
56
|
+
content_hash: str
|
|
57
|
+
|
|
58
|
+
def __post_init__(self) -> None:
|
|
59
|
+
if len(self.content_hash) != 64:
|
|
60
|
+
raise ValueError("source content hash must be a sha256 digest")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AnalysisRequest:
|
|
65
|
+
project_root: ProjectRoot
|
|
66
|
+
sources: tuple[SourceInput, ...]
|
|
67
|
+
language: LanguageSettings
|
|
68
|
+
resolver: ResolverSettings
|
|
69
|
+
adapter_versions: FrozenMap[str, str]
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
identities = tuple(source.module_id for source in self.sources)
|
|
73
|
+
if len(identities) != len(set(identities)):
|
|
74
|
+
raise ValueError("analysis request contains duplicate module ids")
|
|
75
|
+
paths = tuple(source.path.value.casefold() for source in self.sources)
|
|
76
|
+
if len(paths) != len(set(paths)):
|
|
77
|
+
raise ValueError("analysis request contains duplicate or case-conflicting paths")
|
|
78
|
+
if self.sources != tuple(sorted(self.sources, key=lambda source: source.path.value)):
|
|
79
|
+
raise ValueError("analysis sources must be sorted by project path")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, order=True)
|
|
83
|
+
class AdapterIdentity:
|
|
84
|
+
name: str
|
|
85
|
+
version: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ModuleAnalysisResult:
|
|
90
|
+
facts: ModuleFacts
|
|
91
|
+
issues: tuple[EngineIssue, ...]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class LanguageAdapter(Protocol):
|
|
95
|
+
identity: AdapterIdentity
|
|
96
|
+
|
|
97
|
+
def analyze_module(
|
|
98
|
+
self,
|
|
99
|
+
source: SourceInput,
|
|
100
|
+
resolver: ResolverSettings | None = None,
|
|
101
|
+
) -> ModuleAnalysisResult: ...
|
|
102
|
+
|
|
103
|
+
def analyze_modules(
|
|
104
|
+
self,
|
|
105
|
+
sources: tuple[SourceInput, ...],
|
|
106
|
+
resolver: ResolverSettings,
|
|
107
|
+
workers: int,
|
|
108
|
+
) -> tuple[ModuleAnalysisResult, ...]: ...
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from taut.domain.facts import AnalysisStage
|
|
6
|
+
|
|
7
|
+
_NEXT_STAGE = {
|
|
8
|
+
AnalysisStage.DISCOVERED: AnalysisStage.PARSED,
|
|
9
|
+
AnalysisStage.PARSED: AnalysisStage.INDEXED,
|
|
10
|
+
AnalysisStage.INDEXED: AnalysisStage.RESOLVED,
|
|
11
|
+
AnalysisStage.RESOLVED: AnalysisStage.FACTS_READY,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ModuleAnalysis:
|
|
17
|
+
"""Mutable analysis lifecycle. This value never leaves the analyzer."""
|
|
18
|
+
|
|
19
|
+
stage: AnalysisStage = AnalysisStage.DISCOVERED
|
|
20
|
+
|
|
21
|
+
def advance(self, stage: AnalysisStage) -> None:
|
|
22
|
+
if self.stage is AnalysisStage.FAILED:
|
|
23
|
+
raise ValueError("failed analysis cannot advance")
|
|
24
|
+
if _NEXT_STAGE.get(self.stage) is not stage:
|
|
25
|
+
raise ValueError(f"cannot move analysis from {self.stage.value} to {stage.value}")
|
|
26
|
+
self.stage = stage
|
|
27
|
+
|
|
28
|
+
def fail(self) -> None:
|
|
29
|
+
if self.stage is AnalysisStage.FACTS_READY:
|
|
30
|
+
raise ValueError("completed analysis cannot be marked failed")
|
|
31
|
+
self.stage = AnalysisStage.FAILED
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from taut.analysis.contracts import AnalysisRequest, LanguageAdapter
|
|
7
|
+
from taut.analysis.project_index import build_project_index
|
|
8
|
+
from taut.domain.facts import CompletenessState, ModuleFacts
|
|
9
|
+
from taut.domain.frozen import FrozenMap
|
|
10
|
+
from taut.domain.ids import SnapshotId
|
|
11
|
+
from taut.domain.issues import EngineIssue
|
|
12
|
+
from taut.domain.snapshot import (
|
|
13
|
+
AnalysisCoverage,
|
|
14
|
+
AnalysisInputDigest,
|
|
15
|
+
AnalysisSnapshot,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProjectAnalyzer:
|
|
20
|
+
def __init__(self, adapter: LanguageAdapter) -> None:
|
|
21
|
+
self._adapter = adapter
|
|
22
|
+
|
|
23
|
+
def analyze(self, request: AnalysisRequest, *, workers: int = 1) -> AnalysisSnapshot:
|
|
24
|
+
if workers < 1:
|
|
25
|
+
raise ValueError("analysis workers must be positive")
|
|
26
|
+
modules: list[ModuleFacts] = []
|
|
27
|
+
issues: list[EngineIssue] = []
|
|
28
|
+
results = self._adapter.analyze_modules(request.sources, request.resolver, workers)
|
|
29
|
+
for result in results:
|
|
30
|
+
modules.append(result.facts)
|
|
31
|
+
issues.extend(result.issues)
|
|
32
|
+
|
|
33
|
+
module_map = FrozenMap((facts.module.id, facts) for facts in modules)
|
|
34
|
+
project = build_project_index(modules)
|
|
35
|
+
states = tuple(facts.completeness.state for facts in modules)
|
|
36
|
+
coverage = AnalysisCoverage(
|
|
37
|
+
requested_sources=len(request.sources),
|
|
38
|
+
complete_modules=states.count(CompletenessState.COMPLETE),
|
|
39
|
+
partial_modules=states.count(CompletenessState.PARTIAL),
|
|
40
|
+
failed_modules=states.count(CompletenessState.FAILED),
|
|
41
|
+
)
|
|
42
|
+
digest = _analysis_digest(request)
|
|
43
|
+
return AnalysisSnapshot(
|
|
44
|
+
id=SnapshotId(digest),
|
|
45
|
+
inputs=AnalysisInputDigest(digest),
|
|
46
|
+
modules=module_map,
|
|
47
|
+
project=project,
|
|
48
|
+
capabilities=FrozenMap(),
|
|
49
|
+
coverage=coverage,
|
|
50
|
+
issues=tuple(issues),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _analysis_digest(request: AnalysisRequest) -> str:
|
|
55
|
+
payload = {
|
|
56
|
+
"sources": [
|
|
57
|
+
{
|
|
58
|
+
"path": source.path.value,
|
|
59
|
+
"module": source.module_id.value,
|
|
60
|
+
"kind": source.kind.value,
|
|
61
|
+
"target": source.is_policy_target,
|
|
62
|
+
"hash": source.content_hash,
|
|
63
|
+
}
|
|
64
|
+
for source in request.sources
|
|
65
|
+
],
|
|
66
|
+
"language": {
|
|
67
|
+
"name": request.language.language,
|
|
68
|
+
"target_version": request.language.target_version,
|
|
69
|
+
},
|
|
70
|
+
"resolver": {
|
|
71
|
+
"source_roots": [root.value for root in request.resolver.source_roots],
|
|
72
|
+
"context_manager_providers": [
|
|
73
|
+
(provider.symbol.value, provider.item_type.value)
|
|
74
|
+
for provider in request.resolver.context_manager_providers
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
"adapters": list(request.adapter_versions.items()),
|
|
78
|
+
}
|
|
79
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
80
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from taut.domain.facts import (
|
|
6
|
+
ImportCycle,
|
|
7
|
+
ModuleFacts,
|
|
8
|
+
ProjectIndex,
|
|
9
|
+
UnresolvedImport,
|
|
10
|
+
)
|
|
11
|
+
from taut.domain.frozen import FrozenMap
|
|
12
|
+
from taut.domain.ids import ModuleId
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_project_index(modules: Iterable[ModuleFacts]) -> ProjectIndex:
|
|
16
|
+
module_list = tuple(sorted(modules, key=lambda facts: facts.module.id))
|
|
17
|
+
module_ids = frozenset(facts.module.id for facts in module_list)
|
|
18
|
+
modules_by_name = {module_id.value: module_id for module_id in module_ids}
|
|
19
|
+
internal_roots = frozenset(module_id.value.split(".")[0] for module_id in module_ids)
|
|
20
|
+
imports: dict[ModuleId, set[ModuleId]] = {module_id: set() for module_id in module_ids}
|
|
21
|
+
unresolved: list[UnresolvedImport] = []
|
|
22
|
+
|
|
23
|
+
for facts in module_list:
|
|
24
|
+
for import_fact in facts.imports:
|
|
25
|
+
target = _resolve_internal_import(
|
|
26
|
+
import_fact.imported_name,
|
|
27
|
+
import_fact.imported_module_name,
|
|
28
|
+
modules_by_name,
|
|
29
|
+
)
|
|
30
|
+
if target is not None:
|
|
31
|
+
imports[facts.module.id].add(target)
|
|
32
|
+
continue
|
|
33
|
+
root = import_fact.imported_module_name.split(".")[0]
|
|
34
|
+
possibly_internal = import_fact.relative_level > 0 or root in internal_roots
|
|
35
|
+
if possibly_internal:
|
|
36
|
+
unresolved.append(
|
|
37
|
+
UnresolvedImport(
|
|
38
|
+
importer=facts.module.id,
|
|
39
|
+
written_name=import_fact.imported_name,
|
|
40
|
+
location=import_fact.location,
|
|
41
|
+
reason="프로젝트 내부 모듈로 연결하지 못했습니다.",
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
frozen_imports = FrozenMap(
|
|
46
|
+
(source, tuple(sorted(targets))) for source, targets in imports.items()
|
|
47
|
+
)
|
|
48
|
+
reverse: dict[ModuleId, set[ModuleId]] = {module_id: set() for module_id in module_ids}
|
|
49
|
+
for source, targets in imports.items():
|
|
50
|
+
for target in targets:
|
|
51
|
+
reverse[target].add(source)
|
|
52
|
+
frozen_reverse = FrozenMap(
|
|
53
|
+
(target, tuple(sorted(sources))) for target, sources in reverse.items()
|
|
54
|
+
)
|
|
55
|
+
cycles = _find_cycles(frozen_imports)
|
|
56
|
+
return ProjectIndex(
|
|
57
|
+
imports=frozen_imports,
|
|
58
|
+
imported_by=frozen_reverse,
|
|
59
|
+
unresolved_imports=tuple(
|
|
60
|
+
sorted(
|
|
61
|
+
unresolved,
|
|
62
|
+
key=lambda item: (
|
|
63
|
+
item.location.path.value,
|
|
64
|
+
item.location.start_line,
|
|
65
|
+
item.location.start_column,
|
|
66
|
+
item.written_name,
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
),
|
|
70
|
+
cycles=cycles,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_internal_import(
|
|
75
|
+
imported_name: str,
|
|
76
|
+
imported_module_name: str,
|
|
77
|
+
modules_by_name: dict[str, ModuleId],
|
|
78
|
+
) -> ModuleId | None:
|
|
79
|
+
candidates = (imported_name, imported_module_name)
|
|
80
|
+
for candidate in candidates:
|
|
81
|
+
current = candidate
|
|
82
|
+
while current:
|
|
83
|
+
module_id = modules_by_name.get(current)
|
|
84
|
+
if module_id is not None:
|
|
85
|
+
return module_id
|
|
86
|
+
current = current.rpartition(".")[0]
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _find_cycles(
|
|
91
|
+
graph: FrozenMap[ModuleId, tuple[ModuleId, ...]],
|
|
92
|
+
) -> tuple[ImportCycle, ...]:
|
|
93
|
+
index = 0
|
|
94
|
+
stack: list[ModuleId] = []
|
|
95
|
+
on_stack: set[ModuleId] = set()
|
|
96
|
+
indexes: dict[ModuleId, int] = {}
|
|
97
|
+
low_links: dict[ModuleId, int] = {}
|
|
98
|
+
components: list[tuple[ModuleId, ...]] = []
|
|
99
|
+
|
|
100
|
+
def visit(node: ModuleId) -> None:
|
|
101
|
+
nonlocal index
|
|
102
|
+
indexes[node] = index
|
|
103
|
+
low_links[node] = index
|
|
104
|
+
index += 1
|
|
105
|
+
stack.append(node)
|
|
106
|
+
on_stack.add(node)
|
|
107
|
+
|
|
108
|
+
for target in graph[node]:
|
|
109
|
+
if target not in indexes:
|
|
110
|
+
visit(target)
|
|
111
|
+
low_links[node] = min(low_links[node], low_links[target])
|
|
112
|
+
elif target in on_stack:
|
|
113
|
+
low_links[node] = min(low_links[node], indexes[target])
|
|
114
|
+
|
|
115
|
+
if low_links[node] != indexes[node]:
|
|
116
|
+
return
|
|
117
|
+
component: list[ModuleId] = []
|
|
118
|
+
while True:
|
|
119
|
+
member = stack.pop()
|
|
120
|
+
on_stack.remove(member)
|
|
121
|
+
component.append(member)
|
|
122
|
+
if member == node:
|
|
123
|
+
break
|
|
124
|
+
ordered = tuple(sorted(component))
|
|
125
|
+
if len(ordered) > 1 or (len(ordered) == 1 and ordered[0] in graph[ordered[0]]):
|
|
126
|
+
components.append(ordered)
|
|
127
|
+
|
|
128
|
+
for module_id in graph:
|
|
129
|
+
if module_id not in indexes:
|
|
130
|
+
visit(module_id)
|
|
131
|
+
|
|
132
|
+
return tuple(ImportCycle(component) for component in sorted(components))
|