adopt-detect 0.3.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.
- adopt_detect/__init__.py +67 -0
- adopt_detect/boundary.py +196 -0
- adopt_detect/detect.py +323 -0
- adopt_detect/disambiguate.py +196 -0
- adopt_detect/gitignore.py +164 -0
- adopt_detect/negotiate.py +210 -0
- adopt_detect/py.typed +0 -0
- adopt_detect/records.py +69 -0
- adopt_detect/render.py +98 -0
- adopt_detect/rules/ai.yaml +64 -0
- adopt_detect/rules/data.yaml +51 -0
- adopt_detect/rules/lowcode.yaml +45 -0
- adopt_detect/rules/platform.yaml +55 -0
- adopt_detect/rules/web.yaml +67 -0
- adopt_detect/rules.py +272 -0
- adopt_detect-0.3.0.dist-info/METADATA +18 -0
- adopt_detect-0.3.0.dist-info/RECORD +20 -0
- adopt_detect-0.3.0.dist-info/WHEEL +4 -0
- adopt_detect-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_detect-0.3.0.dist-info/licenses/NOTICE +39 -0
adopt_detect/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Archetype detection, tier negotiation, the observability boundary.
|
|
2
|
+
|
|
3
|
+
Implementation spec §4.11, PRD F10, sprint S6.
|
|
4
|
+
|
|
5
|
+
Four invariants hold across this package and each is a refusal rather than a
|
|
6
|
+
preference:
|
|
7
|
+
|
|
8
|
+
1. **No model call on the deterministic path.** `04` §4 runs the walk to
|
|
9
|
+
completion; below `DETECT_CONFIDENCE_MIN` the answer is *ambiguous with
|
|
10
|
+
ranked scores*, never a guess. There is no call site for a model here.
|
|
11
|
+
2. **No code execution in the target tree.** Files are read, at most
|
|
12
|
+
`DETECT_MAX_SNIFF_BYTES` each, and matched against literal substrings.
|
|
13
|
+
3. **No network.** Detection is pure filesystem, and
|
|
14
|
+
`tests/property/test_offline.py` measures that rather than asserting it.
|
|
15
|
+
4. **Archetype values are exactly `web|platform|lowcode|data|ai`**, read from
|
|
16
|
+
the generated enum rather than retyped.
|
|
17
|
+
|
|
18
|
+
The tier ladder is **CR-38**, ratified 2026-08-05: three qualification questions
|
|
19
|
+
to `T0`-`T4`, with `T0` a decline recommendation and `T3` the floor the `ai`
|
|
20
|
+
archetype requires.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from adopt_detect.boundary import (
|
|
24
|
+
DEFAULT_OUTBOUND_CATEGORIES,
|
|
25
|
+
METADATA_ONLY,
|
|
26
|
+
BoundaryView,
|
|
27
|
+
declare_boundary,
|
|
28
|
+
)
|
|
29
|
+
from adopt_detect.detect import DetectionResult, RuleHit, detect
|
|
30
|
+
from adopt_detect.negotiate import (
|
|
31
|
+
AI_MINIMUM_TIER,
|
|
32
|
+
QUESTIONS,
|
|
33
|
+
Answers,
|
|
34
|
+
TierDecision,
|
|
35
|
+
negotiate,
|
|
36
|
+
parse_answers,
|
|
37
|
+
unavailable_capabilities,
|
|
38
|
+
violates_archetype_floor,
|
|
39
|
+
)
|
|
40
|
+
from adopt_detect.records import BoundaryRecords
|
|
41
|
+
from adopt_detect.render import render_json, render_markdown
|
|
42
|
+
from adopt_detect.rules import ARCHETYPES, ArchetypeRules, Rule, load_rule_sets
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AI_MINIMUM_TIER",
|
|
46
|
+
"ARCHETYPES",
|
|
47
|
+
"DEFAULT_OUTBOUND_CATEGORIES",
|
|
48
|
+
"METADATA_ONLY",
|
|
49
|
+
"QUESTIONS",
|
|
50
|
+
"Answers",
|
|
51
|
+
"ArchetypeRules",
|
|
52
|
+
"BoundaryRecords",
|
|
53
|
+
"BoundaryView",
|
|
54
|
+
"DetectionResult",
|
|
55
|
+
"Rule",
|
|
56
|
+
"RuleHit",
|
|
57
|
+
"TierDecision",
|
|
58
|
+
"declare_boundary",
|
|
59
|
+
"detect",
|
|
60
|
+
"load_rule_sets",
|
|
61
|
+
"negotiate",
|
|
62
|
+
"parse_answers",
|
|
63
|
+
"render_json",
|
|
64
|
+
"render_markdown",
|
|
65
|
+
"unavailable_capabilities",
|
|
66
|
+
"violates_archetype_floor",
|
|
67
|
+
]
|
adopt_detect/boundary.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The observability boundary: one row, two renderings, one authority.
|
|
2
|
+
|
|
3
|
+
PRD F10.5-F10.8 and contracts §3. The boundary is the object that **hard-limits
|
|
4
|
+
every downstream claim**: what may be observed, where knowledge lives, and what
|
|
5
|
+
may leave. Three things about it are load-bearing.
|
|
6
|
+
|
|
7
|
+
**`BoundaryView` is the accessor every downstream item calls** (`05` S6). It is a
|
|
8
|
+
read-only projection of one stored row, and it is the type contracts §8 names in
|
|
9
|
+
`validate_envelope(env, boundary: BoundaryView)`. Its one home is here, so that
|
|
10
|
+
"what may leave" has one answer rather than one per caller.
|
|
11
|
+
|
|
12
|
+
**The boundary is the authority, never the caller's declaration** (contracts §8
|
|
13
|
+
rule 3, PRD F12.5). `permitted_outbound_categories` lives on the row precisely so
|
|
14
|
+
that widening it is a write to a client-signed artefact rather than a
|
|
15
|
+
configuration change -- and `contractual_approval_ref` is what records the
|
|
16
|
+
amendment that permitted it.
|
|
17
|
+
|
|
18
|
+
**Both rendered artifacts come from one row** (F10.8). `render.py` takes a
|
|
19
|
+
`BoundaryView` and nothing else; there is no path by which the human-readable
|
|
20
|
+
statement could say something the machine-readable one does not. Two renderers
|
|
21
|
+
reading two sources is how a client is shown a document that the system does not
|
|
22
|
+
enforce.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Final
|
|
29
|
+
|
|
30
|
+
from adopt_detect.negotiate import TierDecision, violates_archetype_floor
|
|
31
|
+
from adopt_detect.records import BoundaryRecords
|
|
32
|
+
from adopt_model import ObservabilityBoundary
|
|
33
|
+
from adopt_model._enums import Archetype, ControlPlane, KnowledgePlane, Tier
|
|
34
|
+
from adopt_scope import Scope
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"DEFAULT_OUTBOUND_CATEGORIES",
|
|
38
|
+
"METADATA_ONLY",
|
|
39
|
+
"BoundaryView",
|
|
40
|
+
"declare_boundary",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: Contracts §8 rule 1 and the `observability_boundary` column default. The one
|
|
44
|
+
#: policy that needs no contract amendment, and the value everything falls back
|
|
45
|
+
#: to when nothing else was agreed.
|
|
46
|
+
METADATA_ONLY: Final[str] = "metadata_only"
|
|
47
|
+
|
|
48
|
+
#: PRD F10.5. The default is a *list* because the column is a JSON list, and it
|
|
49
|
+
#: is a one-element list rather than an empty one because an empty permitted set
|
|
50
|
+
#: would make every outbound envelope invalid -- including the metadata-only ones
|
|
51
|
+
#: the product is built to send.
|
|
52
|
+
DEFAULT_OUTBOUND_CATEGORIES: Final[tuple[str, ...]] = (METADATA_ONLY,)
|
|
53
|
+
|
|
54
|
+
#: Local-first is the default posture: client knowledge stays with the customer
|
|
55
|
+
#: and only routing lives with the vendor. Both are overridable per engagement,
|
|
56
|
+
#: and both are recorded on the row rather than inferred at read time.
|
|
57
|
+
DEFAULT_KNOWLEDGE_PLANE: Final[KnowledgePlane] = "customer"
|
|
58
|
+
DEFAULT_CONTROL_PLANE: Final[ControlPlane] = "vendor"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class BoundaryView:
|
|
63
|
+
"""A read-only projection of one `observability_boundary` row.
|
|
64
|
+
|
|
65
|
+
Contracts §8's `validate_envelope` second parameter. Frozen, because a
|
|
66
|
+
validator that could widen the boundary it is validating against is not a
|
|
67
|
+
validator.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
boundary_id: str
|
|
71
|
+
system_id: str
|
|
72
|
+
environment_id: str | None
|
|
73
|
+
tier: Tier
|
|
74
|
+
archetype: Archetype | None
|
|
75
|
+
knowledge_plane_location: KnowledgePlane
|
|
76
|
+
control_plane_location: ControlPlane
|
|
77
|
+
permitted_outbound_categories: tuple[str, ...]
|
|
78
|
+
unavailable_capabilities: tuple[str, ...]
|
|
79
|
+
contractual_approval_ref: str | None
|
|
80
|
+
declared_at: _dt.datetime
|
|
81
|
+
decline_recommended: bool
|
|
82
|
+
archetype_floor_violated: bool
|
|
83
|
+
last_successful_observation_at: _dt.datetime | None = None
|
|
84
|
+
safe_probe_status: str | None = None
|
|
85
|
+
|
|
86
|
+
def permits(self, content_policy: str) -> bool:
|
|
87
|
+
"""Whether this boundary permits a policy. **The authority.**"""
|
|
88
|
+
return content_policy in self.permitted_outbound_categories
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def of(
|
|
92
|
+
cls,
|
|
93
|
+
row: ObservabilityBoundary,
|
|
94
|
+
*,
|
|
95
|
+
archetype: Archetype | None,
|
|
96
|
+
unavailable_capabilities: Sequence[str] = (),
|
|
97
|
+
decline_recommended: bool = False,
|
|
98
|
+
archetype_floor_violated: bool = False,
|
|
99
|
+
) -> "BoundaryView":
|
|
100
|
+
"""Project a stored row.
|
|
101
|
+
|
|
102
|
+
`archetype` is carried alongside rather than read from the row because
|
|
103
|
+
`observability_boundary` has no archetype column -- it is `system`'s, and
|
|
104
|
+
contracts §3 puts it there. Passing it explicitly keeps the view honest
|
|
105
|
+
about where each field came from instead of implying a column that does
|
|
106
|
+
not exist.
|
|
107
|
+
"""
|
|
108
|
+
categories = row.permitted_outbound_categories
|
|
109
|
+
return cls(
|
|
110
|
+
boundary_id=row.id,
|
|
111
|
+
system_id=row.system_id,
|
|
112
|
+
environment_id=row.environment_id,
|
|
113
|
+
tier=row.tier,
|
|
114
|
+
archetype=archetype,
|
|
115
|
+
knowledge_plane_location=row.knowledge_plane_location,
|
|
116
|
+
control_plane_location=row.control_plane_location,
|
|
117
|
+
permitted_outbound_categories=tuple(categories) if isinstance(categories, list) else (),
|
|
118
|
+
unavailable_capabilities=tuple(unavailable_capabilities),
|
|
119
|
+
contractual_approval_ref=row.contractual_approval_ref,
|
|
120
|
+
declared_at=row.declared_at,
|
|
121
|
+
decline_recommended=decline_recommended,
|
|
122
|
+
archetype_floor_violated=archetype_floor_violated,
|
|
123
|
+
last_successful_observation_at=row.last_successful_observation_at,
|
|
124
|
+
safe_probe_status=row.safe_probe_status,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def declare_boundary(
|
|
129
|
+
records: BoundaryRecords,
|
|
130
|
+
*,
|
|
131
|
+
scope: Scope,
|
|
132
|
+
decision: TierDecision,
|
|
133
|
+
archetype: Archetype | None,
|
|
134
|
+
knowledge_plane_location: KnowledgePlane = DEFAULT_KNOWLEDGE_PLANE,
|
|
135
|
+
control_plane_location: ControlPlane = DEFAULT_CONTROL_PLANE,
|
|
136
|
+
permitted_outbound_categories: Sequence[str] = DEFAULT_OUTBOUND_CATEGORIES,
|
|
137
|
+
contractual_approval_ref: str | None = None,
|
|
138
|
+
owner_actor_id: str | None = None,
|
|
139
|
+
safe_probe_status: str | None = None,
|
|
140
|
+
) -> BoundaryView:
|
|
141
|
+
"""Write the boundary a negotiation implies and return its view.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
records: The storage port. Writes the row; decides nothing.
|
|
145
|
+
scope: Must resolve at least to a system.
|
|
146
|
+
decision: The negotiated tier, from `negotiate`.
|
|
147
|
+
archetype: What detection concluded, or `None` when it was ambiguous.
|
|
148
|
+
knowledge_plane_location: Where client knowledge lives.
|
|
149
|
+
control_plane_location: Where routing and entitlement live.
|
|
150
|
+
permitted_outbound_categories: What may leave. Defaults to
|
|
151
|
+
`["metadata_only"]`; anything wider needs `contractual_approval_ref`.
|
|
152
|
+
contractual_approval_ref: The contract amendment reference.
|
|
153
|
+
owner_actor_id: Who is accountable.
|
|
154
|
+
safe_probe_status: Whether a safe execution path exists.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The `BoundaryView` for the row just written.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
AdoptError: ``SCOPE_VIOLATION`` from the facade when the scope resolves
|
|
161
|
+
no system.
|
|
162
|
+
|
|
163
|
+
Note:
|
|
164
|
+
**A `T0` decision still writes a boundary.** The decline recommendation
|
|
165
|
+
is a *finding about* the engagement, not a refusal to record what was
|
|
166
|
+
negotiated -- and an engagement that was declined is exactly the one
|
|
167
|
+
whose boundary someone will want to read six months later.
|
|
168
|
+
"""
|
|
169
|
+
categories = tuple(permitted_outbound_categories)
|
|
170
|
+
row = records.declare(
|
|
171
|
+
scope=scope,
|
|
172
|
+
tier=decision.tier,
|
|
173
|
+
knowledge_plane_location=knowledge_plane_location,
|
|
174
|
+
control_plane_location=control_plane_location,
|
|
175
|
+
permitted_outbound_categories=categories,
|
|
176
|
+
covered=decision.rationale,
|
|
177
|
+
not_covered=_not_covered(decision.unavailable),
|
|
178
|
+
safe_probe_status=safe_probe_status,
|
|
179
|
+
owner_actor_id=owner_actor_id,
|
|
180
|
+
contractual_approval_ref=contractual_approval_ref,
|
|
181
|
+
contractual=contractual_approval_ref is not None,
|
|
182
|
+
)
|
|
183
|
+
return BoundaryView.of(
|
|
184
|
+
row,
|
|
185
|
+
archetype=archetype,
|
|
186
|
+
unavailable_capabilities=decision.unavailable,
|
|
187
|
+
decline_recommended=decision.decline_recommended,
|
|
188
|
+
archetype_floor_violated=violates_archetype_floor(archetype, decision.tier),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _not_covered(unavailable: Sequence[str]) -> str:
|
|
193
|
+
"""The human-readable summary of what this tier does not grant."""
|
|
194
|
+
if not unavailable:
|
|
195
|
+
return "Nothing: every capability the platform offers is available at this tier."
|
|
196
|
+
return "Not available at this tier: " + ", ".join(unavailable) + "."
|
adopt_detect/detect.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Archetype detection: a bounded, deterministic walk over a file tree.
|
|
2
|
+
|
|
3
|
+
PRD F10.1-F10.3 and implementation spec §4.11. Four properties are load-bearing
|
|
4
|
+
and each is a refusal rather than a preference:
|
|
5
|
+
|
|
6
|
+
**No code executes in the target tree.** Nothing here imports, evaluates or runs
|
|
7
|
+
anything it finds. Files are opened for reading, at most
|
|
8
|
+
`DETECT_MAX_SNIFF_BYTES` of each, and matched against literal substrings. This
|
|
9
|
+
is what lets an FDE point the tool at a client repository they do not own.
|
|
10
|
+
|
|
11
|
+
**No network.** Detection is pure filesystem. `01` N10 makes that measurable
|
|
12
|
+
rather than asserted; `tests/property/test_offline.py` is the instrument.
|
|
13
|
+
|
|
14
|
+
**No model call in this module.** `04` §4 is explicit: the deterministic path
|
|
15
|
+
runs to completion, and below `DETECT_CONFIDENCE_MIN` the answer is *ambiguous
|
|
16
|
+
with ranked scores*, never a guess. The model-backed disambiguation pass lives in
|
|
17
|
+
`adopt_detect.disambiguate`, is reached only with `ADOPT_FEATURE_AGENT_DISAMBIGUATION`
|
|
18
|
+
on **and** a runner handed in, and **there is still no call site for it here** --
|
|
19
|
+
`detect()` cannot reach a model even transitively, which is what makes steps 1-3
|
|
20
|
+
of `04` §4 model-free by construction rather than by intent.
|
|
21
|
+
|
|
22
|
+
`bounded_listing` is the one thing this module lends that pass: the evidence a
|
|
23
|
+
proposal is allowed to see includes a directory listing, and it must be *this*
|
|
24
|
+
walk's listing -- same bounds, same `.gitignore` scope, same symlink refusal, same
|
|
25
|
+
deterministic order. A second walk written next to the prompt would be a second
|
|
26
|
+
answer to "what is in this tree", and the one that drifted would be the one that
|
|
27
|
+
decided what left the environment.
|
|
28
|
+
|
|
29
|
+
**Byte-identical across runs and machines** (`01` N2, N15). Every ordering in
|
|
30
|
+
this module is explicit: entries are sorted before the walk, rules are scored in
|
|
31
|
+
declaration order, and the archetype ranking breaks ties by the fixed
|
|
32
|
+
`ARCHETYPES` order. Nothing depends on filesystem iteration order, on a `set`'s
|
|
33
|
+
layout, or on a dict built from either.
|
|
34
|
+
|
|
35
|
+
**The walk is bounded three ways**, and hitting a bound is reported rather than
|
|
36
|
+
silently truncating the evidence: `DETECT_MAX_DEPTH` on nesting,
|
|
37
|
+
`DETECT_MAX_FILES` on files considered, `DETECT_MAX_SNIFF_BYTES` on bytes read
|
|
38
|
+
per file. A client monorepo is allowed to be enormous; it is not allowed to make
|
|
39
|
+
detection unbounded.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import os
|
|
43
|
+
from collections.abc import Iterator, Sequence
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path, PurePosixPath
|
|
46
|
+
from typing import Final
|
|
47
|
+
|
|
48
|
+
from adopt_const import DETECT_CONFIDENCE_MIN, DETECT_MAX_DEPTH, DETECT_MAX_FILES
|
|
49
|
+
from adopt_const import DETECT_MAX_SNIFF_BYTES as _SNIFF_BYTES
|
|
50
|
+
from adopt_detect.gitignore import GitignoreFilter
|
|
51
|
+
from adopt_detect.rules import ARCHETYPES, ArchetypeRules, load_rule_sets, needs_content
|
|
52
|
+
from adopt_model._enums import Archetype
|
|
53
|
+
from adopt_obs import AdoptError, ErrorCode
|
|
54
|
+
|
|
55
|
+
__all__ = ["DetectionResult", "RuleHit", "bounded_listing", "detect"]
|
|
56
|
+
|
|
57
|
+
#: The flag whose name the ambiguity report prints. It is named here, not
|
|
58
|
+
#: implemented here: `04` §4 step 4 is a later sprint's, and the re-run hint has
|
|
59
|
+
#: to name the flag a caller would actually set.
|
|
60
|
+
DISAMBIGUATION_FLAG: Final[str] = "ADOPT_FEATURE_AGENT_DISAMBIGUATION"
|
|
61
|
+
|
|
62
|
+
#: Directories never descended into. `.git` is the one that matters for
|
|
63
|
+
#: correctness rather than speed: its object store contains compressed copies of
|
|
64
|
+
#: everything, and a `contains` rule would fire on packfile bytes that no longer
|
|
65
|
+
#: reflect the working tree.
|
|
66
|
+
_ALWAYS_SKIPPED: Final[frozenset[str]] = frozenset(
|
|
67
|
+
{".git", ".hg", ".svn", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache"}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class RuleHit:
|
|
73
|
+
"""One rule that fired, and the first path that fired it."""
|
|
74
|
+
|
|
75
|
+
archetype: Archetype
|
|
76
|
+
rule_id: str
|
|
77
|
+
path: str
|
|
78
|
+
weight: float
|
|
79
|
+
why: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class DetectionResult:
|
|
84
|
+
"""What detection concluded, and everything it concluded it from.
|
|
85
|
+
|
|
86
|
+
`archetype` is `None` exactly when `ambiguous` is true. The two are kept as
|
|
87
|
+
separate fields rather than one optional because callers branch on ambiguity
|
|
88
|
+
and reading `archetype is None` as "ambiguous" invites the other reading,
|
|
89
|
+
"not yet computed".
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
root: str
|
|
93
|
+
archetype: Archetype | None
|
|
94
|
+
confidence: float
|
|
95
|
+
scores: dict[Archetype, float]
|
|
96
|
+
rules_fired: tuple[RuleHit, ...]
|
|
97
|
+
files_considered: int
|
|
98
|
+
truncated: bool
|
|
99
|
+
ambiguous: bool = field(default=False)
|
|
100
|
+
|
|
101
|
+
def ranked(self) -> tuple[tuple[Archetype, float], ...]:
|
|
102
|
+
"""Archetypes by score descending, ties broken by declaration order."""
|
|
103
|
+
return tuple(
|
|
104
|
+
sorted(self.scores.items(), key=lambda pair: (-pair[1], ARCHETYPES.index(pair[0])))
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _sorted_entries(directory: Path) -> list[os.DirEntry[str]]:
|
|
109
|
+
"""Directory entries in one fixed order, whatever the filesystem returns.
|
|
110
|
+
|
|
111
|
+
`scandir` order is filesystem- and machine-dependent. Sorting here is what
|
|
112
|
+
makes `rules_fired` -- which records the *first* path that fired each rule --
|
|
113
|
+
the same on a developer laptop and on the reference runner.
|
|
114
|
+
"""
|
|
115
|
+
with os.scandir(directory) as entries:
|
|
116
|
+
return sorted(entries, key=lambda entry: entry.name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_within(candidate: Path, root: Path) -> bool:
|
|
120
|
+
"""Whether a resolved path is inside a resolved root.
|
|
121
|
+
|
|
122
|
+
The symlink check. A client tree may legitimately contain a symlink to
|
|
123
|
+
somewhere outside itself, and following one would let detection read files
|
|
124
|
+
the operator never pointed us at -- and, under a `contains` rule, let content
|
|
125
|
+
outside the tree change the archetype. Such a link is skipped, not an error:
|
|
126
|
+
refusing the whole tree over one stray link would make the tool unusable on
|
|
127
|
+
real repositories.
|
|
128
|
+
"""
|
|
129
|
+
return candidate == root or root in candidate.parents
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _walk(root: Path, ignore: GitignoreFilter) -> Iterator[tuple[str, Path, bool]]:
|
|
133
|
+
"""Yield `(relative_posix_path, absolute_path, is_file)` breadth-first.
|
|
134
|
+
|
|
135
|
+
Breadth-first so that a bound is hit at the *bottom* of the tree rather than
|
|
136
|
+
inside whichever subdirectory happened to sort first -- a depth-first walk
|
|
137
|
+
that stops at `DETECT_MAX_FILES` on a monorepo would see all of one package
|
|
138
|
+
and none of the others, which is how a bound turns into a wrong answer
|
|
139
|
+
instead of a partial one.
|
|
140
|
+
"""
|
|
141
|
+
queue: list[tuple[Path, str, int]] = [(root, "", 0)]
|
|
142
|
+
while queue:
|
|
143
|
+
directory, prefix, depth = queue.pop(0)
|
|
144
|
+
try:
|
|
145
|
+
entries = _sorted_entries(directory)
|
|
146
|
+
except OSError:
|
|
147
|
+
# Unreadable directory: skipped, never fatal. A client checkout with
|
|
148
|
+
# one permission-denied path is still a tree we can classify.
|
|
149
|
+
continue
|
|
150
|
+
for entry in entries:
|
|
151
|
+
relative = f"{prefix}{entry.name}"
|
|
152
|
+
absolute = Path(entry.path)
|
|
153
|
+
if entry.is_symlink() and not _is_within(absolute.resolve(), root):
|
|
154
|
+
continue
|
|
155
|
+
if entry.is_dir(follow_symlinks=False):
|
|
156
|
+
if entry.name in _ALWAYS_SKIPPED or ignore.is_ignored(relative, is_dir=True):
|
|
157
|
+
continue
|
|
158
|
+
if depth < DETECT_MAX_DEPTH:
|
|
159
|
+
queue.append((absolute, f"{relative}/", depth + 1))
|
|
160
|
+
continue
|
|
161
|
+
if ignore.is_ignored(relative, is_dir=False):
|
|
162
|
+
continue
|
|
163
|
+
yield relative, absolute, True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def bounded_listing(root: Path | str, *, limit: int) -> tuple[tuple[str, ...], bool]:
|
|
167
|
+
"""The first `limit` paths this walk would consider. Returns `(paths, truncated)`.
|
|
168
|
+
|
|
169
|
+
**Paths only.** No file is opened, so nothing here can carry file contents --
|
|
170
|
+
which is the `04` §4 step 4 privacy invariant, held by the function not having
|
|
171
|
+
the capability rather than by its caller remembering not to use it.
|
|
172
|
+
|
|
173
|
+
Truncation is reported rather than silent, for the reason `DetectionResult`
|
|
174
|
+
reports it: a proposal reasoning over a listing that stopped somewhere is
|
|
175
|
+
reasoning over less evidence than it appears to have, and a model told the
|
|
176
|
+
listing was truncated can say so in its confidence.
|
|
177
|
+
"""
|
|
178
|
+
# Resolved and filtered exactly as `detect()` does it, three lines below.
|
|
179
|
+
# Copying the two lines rather than sharing them is the smaller risk: sharing
|
|
180
|
+
# would mean a helper both call, and the thing that must not drift is the
|
|
181
|
+
# *bounds and the filter*, which are named constants and one classmethod.
|
|
182
|
+
resolved = Path(root).resolve()
|
|
183
|
+
ignore = GitignoreFilter.for_tree(resolved)
|
|
184
|
+
paths: list[str] = []
|
|
185
|
+
for relative, _absolute, _is_file in _walk(resolved, ignore):
|
|
186
|
+
if len(paths) >= limit:
|
|
187
|
+
return tuple(paths), True
|
|
188
|
+
paths.append(relative)
|
|
189
|
+
return tuple(paths), False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _head(path: Path) -> bytes | None:
|
|
193
|
+
"""The first `DETECT_MAX_SNIFF_BYTES`, or `None` if unreadable."""
|
|
194
|
+
try:
|
|
195
|
+
with path.open("rb") as handle:
|
|
196
|
+
return handle.read(_SNIFF_BYTES)
|
|
197
|
+
except OSError:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def detect(
|
|
202
|
+
root: Path | str, *, rule_sets: Sequence[ArchetypeRules] | None = None
|
|
203
|
+
) -> DetectionResult:
|
|
204
|
+
"""Classify a file tree into exactly one archetype, or report ambiguity.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
root: The tree to classify. Never read as code, never executed.
|
|
208
|
+
rule_sets: Injected rule sets, for tests that need a small closed corpus.
|
|
209
|
+
Defaults to the packaged `rules/*.yaml`.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
A `DetectionResult`. When the winning score is below
|
|
213
|
+
`DETECT_CONFIDENCE_MIN` the result is `ambiguous` with `archetype` of
|
|
214
|
+
`None` and the full ranked scores -- **never a best guess**. PRD F10.3
|
|
215
|
+
makes that a product statement: a wrong archetype is not a wrong answer
|
|
216
|
+
but a different set of extractors, which nothing downstream can detect.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
AdoptError: ``DETECT_AMBIGUOUS`` is *not* raised here -- ambiguity is a
|
|
220
|
+
result, and the CLI maps it to exit `2`. This function raises only
|
|
221
|
+
``MANIFEST_INVALID``, from rule loading, and
|
|
222
|
+
``ADOPT_CONFIG_UNRESOLVED`` when `root` is not a directory.
|
|
223
|
+
"""
|
|
224
|
+
tree = Path(root)
|
|
225
|
+
if not tree.is_dir():
|
|
226
|
+
raise AdoptError(
|
|
227
|
+
ErrorCode.ADOPT_CONFIG_UNRESOLVED,
|
|
228
|
+
message=f"{tree} is not a directory",
|
|
229
|
+
hint="Detection classifies a file tree. Point it at a checkout root.",
|
|
230
|
+
)
|
|
231
|
+
resolved = tree.resolve()
|
|
232
|
+
sets = tuple(rule_sets) if rule_sets is not None else load_rule_sets()
|
|
233
|
+
read_content = needs_content(sets)
|
|
234
|
+
ignore = GitignoreFilter.for_tree(resolved)
|
|
235
|
+
|
|
236
|
+
hits: dict[tuple[Archetype, str], RuleHit] = {}
|
|
237
|
+
files_considered = 0
|
|
238
|
+
truncated = False
|
|
239
|
+
|
|
240
|
+
for relative, absolute, _ in _walk(resolved, ignore):
|
|
241
|
+
if files_considered >= DETECT_MAX_FILES:
|
|
242
|
+
truncated = True
|
|
243
|
+
break
|
|
244
|
+
files_considered += 1
|
|
245
|
+
head: bytes | None = None
|
|
246
|
+
head_read = False
|
|
247
|
+
for rule_set in sets:
|
|
248
|
+
for rule in rule_set.rules:
|
|
249
|
+
key = (rule_set.archetype, rule.id)
|
|
250
|
+
if key in hits:
|
|
251
|
+
continue
|
|
252
|
+
if rule.contains is not None:
|
|
253
|
+
if not read_content: # pragma: no cover -- defensive
|
|
254
|
+
continue
|
|
255
|
+
if not head_read:
|
|
256
|
+
head = _head(absolute)
|
|
257
|
+
head_read = True
|
|
258
|
+
if rule.matches(relative, head):
|
|
259
|
+
hits[key] = RuleHit(
|
|
260
|
+
archetype=rule_set.archetype,
|
|
261
|
+
rule_id=rule.id,
|
|
262
|
+
path=relative,
|
|
263
|
+
weight=rule.weight,
|
|
264
|
+
why=rule.why,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
scores = _score(sets, hits)
|
|
268
|
+
ranked = sorted(scores.items(), key=lambda pair: (-pair[1], ARCHETYPES.index(pair[0])))
|
|
269
|
+
winner, confidence = ranked[0]
|
|
270
|
+
ambiguous = confidence < DETECT_CONFIDENCE_MIN
|
|
271
|
+
|
|
272
|
+
return DetectionResult(
|
|
273
|
+
root=PurePosixPath(resolved.as_posix()).as_posix(),
|
|
274
|
+
archetype=None if ambiguous else winner,
|
|
275
|
+
confidence=confidence,
|
|
276
|
+
scores=scores,
|
|
277
|
+
rules_fired=tuple(
|
|
278
|
+
sorted(hits.values(), key=lambda hit: (ARCHETYPES.index(hit.archetype), hit.rule_id))
|
|
279
|
+
),
|
|
280
|
+
files_considered=files_considered,
|
|
281
|
+
truncated=truncated,
|
|
282
|
+
ambiguous=ambiguous,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _score(
|
|
287
|
+
rule_sets: Sequence[ArchetypeRules], hits: dict[tuple[Archetype, str], RuleHit]
|
|
288
|
+
) -> dict[Archetype, float]:
|
|
289
|
+
"""Each archetype's **share of the total matched weight**, rounded.
|
|
290
|
+
|
|
291
|
+
**The denominator is the evidence found, not the evidence available**, and
|
|
292
|
+
that is the whole design. Dividing by an archetype's own total weight would
|
|
293
|
+
ask "how many of the web rules did this tree fire?" -- and a Django service
|
|
294
|
+
fires none of the Rails, Go or Express rules, so a perfectly clear web system
|
|
295
|
+
would score around 0.3 and be reported ambiguous. The question worth asking
|
|
296
|
+
is "how much of what we found points one way?", which is what
|
|
297
|
+
`DETECT_CONFIDENCE_MIN = 0.70` reads as in prose: seventy per cent of the
|
|
298
|
+
evidence agrees.
|
|
299
|
+
|
|
300
|
+
Two consequences follow, both wanted. Scores sum to 1, so the ranked list in
|
|
301
|
+
an ambiguity report is a genuine distribution rather than five unrelated
|
|
302
|
+
ratios. And a tree that fires **nothing** scores 0.0 everywhere and is
|
|
303
|
+
ambiguous -- the correct answer for a directory with no signals in it, and
|
|
304
|
+
one that costs no special case downstream.
|
|
305
|
+
|
|
306
|
+
**Rounded deliberately.** The score crosses a threshold, is printed as JSON
|
|
307
|
+
and is compared byte-for-byte by `01` N2's determinism property, so a value
|
|
308
|
+
whose last bits depend on summation order would make that property fail for a
|
|
309
|
+
reason that has nothing to do with detection. Six places is far finer than
|
|
310
|
+
`DETECT_CONFIDENCE_MIN`'s two and removes the question.
|
|
311
|
+
"""
|
|
312
|
+
matched: dict[Archetype, float] = {
|
|
313
|
+
rule_set.archetype: sum(
|
|
314
|
+
hit.weight for key, hit in hits.items() if key[0] == rule_set.archetype
|
|
315
|
+
)
|
|
316
|
+
for rule_set in rule_sets
|
|
317
|
+
}
|
|
318
|
+
total = sum(matched.values())
|
|
319
|
+
if total == 0:
|
|
320
|
+
return dict.fromkeys(matched, 0.0)
|
|
321
|
+
# const-sync: ok -- 6 is JSON rendering precision for a ratio in [0,1], not a
|
|
322
|
+
# behavioural threshold; `DETECT_CONFIDENCE_MIN` is the threshold.
|
|
323
|
+
return {archetype: round(weight / total, 6) for archetype, weight in matched.items()}
|