adopt-cli 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_cli/__init__.py +11 -0
- adopt_cli/_build_info.py +6 -0
- adopt_cli/commands/__init__.py +1 -0
- adopt_cli/commands/agent.py +193 -0
- adopt_cli/commands/boundary.py +188 -0
- adopt_cli/commands/coverage.py +85 -0
- adopt_cli/commands/detect.py +135 -0
- adopt_cli/commands/doctor.py +99 -0
- adopt_cli/commands/freshness.py +60 -0
- adopt_cli/commands/identity.py +113 -0
- adopt_cli/commands/init.py +268 -0
- adopt_cli/commands/interchange.py +92 -0
- adopt_cli/commands/policy.py +164 -0
- adopt_cli/commands/store.py +98 -0
- adopt_cli/commands/version.py +37 -0
- adopt_cli/config.py +162 -0
- adopt_cli/json_out.py +65 -0
- adopt_cli/main.py +150 -0
- adopt_cli/py.typed +0 -0
- adopt_cli/store_option.py +134 -0
- adopt_cli-0.3.0.dist-info/METADATA +27 -0
- adopt_cli-0.3.0.dist-info/RECORD +26 -0
- adopt_cli-0.3.0.dist-info/WHEEL +4 -0
- adopt_cli-0.3.0.dist-info/entry_points.txt +2 -0
- adopt_cli-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_cli-0.3.0.dist-info/licenses/NOTICE +39 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""`adopt doctor` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
Output: ``{config[{key, value, source}], environment{}, findings[]}``.
|
|
4
|
+
Exit `0` when there are no findings, `4` when there are -- degraded success, not
|
|
5
|
+
failure. `doctor` reports; it never repairs. A tool that quietly fixes what it
|
|
6
|
+
finds destroys the evidence needed to work out why the problem occurred.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from adopt_cli.config import (
|
|
16
|
+
Resolution,
|
|
17
|
+
load_config_file,
|
|
18
|
+
project_config_path,
|
|
19
|
+
resolve_all,
|
|
20
|
+
user_config_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["build_payload", "collect_findings"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def collect_findings(resolutions: list[Resolution]) -> list[dict[str, str]]:
|
|
27
|
+
"""Report, never repair."""
|
|
28
|
+
findings: list[dict[str, str]] = []
|
|
29
|
+
by_key = {r.key: r for r in resolutions}
|
|
30
|
+
|
|
31
|
+
offline = (by_key["ADOPT_OFFLINE"].value or "1").strip().lower()
|
|
32
|
+
if offline in {"0", "false", "no"}:
|
|
33
|
+
findings.append(
|
|
34
|
+
{
|
|
35
|
+
"severity": "warning",
|
|
36
|
+
"key": "ADOPT_OFFLINE",
|
|
37
|
+
"detail": (
|
|
38
|
+
"Offline mode is disabled. The default posture is offline; network "
|
|
39
|
+
"egress should be an explicit, per-invocation opt-in rather than a "
|
|
40
|
+
"standing configuration setting."
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
adapter = by_key["ADOPT_ADAPTER"].value
|
|
46
|
+
model = by_key["ADOPT_MODEL"].value
|
|
47
|
+
if adapter and not model:
|
|
48
|
+
findings.append(
|
|
49
|
+
{
|
|
50
|
+
"severity": "warning",
|
|
51
|
+
"key": "ADOPT_MODEL",
|
|
52
|
+
"detail": (
|
|
53
|
+
f"Adapter {adapter!r} is configured but ADOPT_MODEL is unset, and no "
|
|
54
|
+
"model identifier is hard-coded anywhere. The adapter will fail at "
|
|
55
|
+
"construction with AGENT_ADAPTER_UNKNOWN."
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if model and not adapter:
|
|
61
|
+
findings.append(
|
|
62
|
+
{
|
|
63
|
+
"severity": "warning",
|
|
64
|
+
"key": "ADOPT_ADAPTER",
|
|
65
|
+
"detail": "ADOPT_MODEL is set but no adapter is configured; it will be ignored.",
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return findings
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_payload(
|
|
73
|
+
*,
|
|
74
|
+
flags: Mapping[str, str] | None = None,
|
|
75
|
+
env: Mapping[str, str] | None = None,
|
|
76
|
+
cwd: Path | None = None,
|
|
77
|
+
home: Path | None = None,
|
|
78
|
+
) -> tuple[dict[str, Any], list[dict[str, str]]]:
|
|
79
|
+
project_path = project_config_path(cwd)
|
|
80
|
+
user_path = user_config_path(home)
|
|
81
|
+
resolutions = resolve_all(
|
|
82
|
+
flags=flags,
|
|
83
|
+
env=env,
|
|
84
|
+
project=load_config_file(project_path),
|
|
85
|
+
user=load_config_file(user_path),
|
|
86
|
+
)
|
|
87
|
+
findings = collect_findings(resolutions)
|
|
88
|
+
payload: dict[str, Any] = {
|
|
89
|
+
"config": [r.render() for r in resolutions],
|
|
90
|
+
"environment": {
|
|
91
|
+
"python": platform.python_version(),
|
|
92
|
+
"platform": platform.platform(terse=True),
|
|
93
|
+
"executable": sys.executable,
|
|
94
|
+
"project_config": str(project_path) if project_path.exists() else None,
|
|
95
|
+
"user_config": str(user_path) if user_path.exists() else None,
|
|
96
|
+
},
|
|
97
|
+
"findings": findings,
|
|
98
|
+
}
|
|
99
|
+
return payload, findings
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`adopt freshness resolve --item ID` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
Emits `{state, level, deciding_rule}`. **The deciding rule is not decoration.**
|
|
4
|
+
CUJ-5 requires an operator to be told *why* knowledge stopped being fresh --
|
|
5
|
+
`observation_stale` because a webhook is failing is a different problem, with a
|
|
6
|
+
different owner, from `stale` because the referent moved -- and a state with no
|
|
7
|
+
reason cannot be acted on and cannot be argued with.
|
|
8
|
+
|
|
9
|
+
Opens the store **read-only**. `resolve_freshness` writes nothing by contract
|
|
10
|
+
(contracts §6), and a read-only handle is that guarantee expressed as a property
|
|
11
|
+
of the file rather than as a rule the command has to remember.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Annotated
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from adopt_cli.json_out import emit
|
|
20
|
+
from adopt_cli.store_option import open_configured_store
|
|
21
|
+
from adopt_freshness import resolve_freshness
|
|
22
|
+
|
|
23
|
+
__all__ = ["app"]
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="freshness",
|
|
27
|
+
help="Resolve freshness across source, binding, knowledge-revision and system levels.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ItemOption = Annotated[
|
|
32
|
+
str,
|
|
33
|
+
typer.Option("--item", help="The knowledge item id.", show_default=False),
|
|
34
|
+
]
|
|
35
|
+
StoreOption = Annotated[
|
|
36
|
+
Path | None,
|
|
37
|
+
typer.Option("--store", help="Store path. Defaults to the resolved ADOPT_STORE_PATH."),
|
|
38
|
+
]
|
|
39
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command()
|
|
43
|
+
def resolve(
|
|
44
|
+
item: ItemOption,
|
|
45
|
+
store: StoreOption = None,
|
|
46
|
+
json_output: JsonOption = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Resolve one item's freshness and report the rule that decided it."""
|
|
49
|
+
with open_configured_store(store, read_only=True) as handle:
|
|
50
|
+
resolution = resolve_freshness(handle.freshness_records(), item)
|
|
51
|
+
|
|
52
|
+
emit(
|
|
53
|
+
{
|
|
54
|
+
"state": resolution.state,
|
|
55
|
+
"level": resolution.level,
|
|
56
|
+
"deciding_rule": resolution.deciding_rule,
|
|
57
|
+
},
|
|
58
|
+
as_json=json_output,
|
|
59
|
+
title="adopt freshness resolve",
|
|
60
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`adopt identity build | parse | validate` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
Three pure functions behind three commands. No store is opened and no socket is
|
|
4
|
+
touched: a URI is a deterministic function of a scope path and a referent, which
|
|
5
|
+
is what lets an integrator check one from a shell script, in a container, with no
|
|
6
|
+
`.adopt/` directory anywhere.
|
|
7
|
+
|
|
8
|
+
**Scope is given, not inferred.** `--scope firm/engagement/system/environment`
|
|
9
|
+
is required, because every alternative is worse: reading it from a store makes a
|
|
10
|
+
pure function depend on a file, and defaulting it means the tool silently builds
|
|
11
|
+
a URI for a scope the caller did not name -- and a wrong URI is not a wrong
|
|
12
|
+
answer, it is a *different referent*, which nothing downstream can detect.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Annotated
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from adopt_cli.json_out import emit
|
|
20
|
+
from adopt_identity import build_uri, parse_uri, validate_uri
|
|
21
|
+
from adopt_scope import Scope, ScopeNode, ScopePath
|
|
22
|
+
|
|
23
|
+
__all__ = ["app"]
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
name="identity",
|
|
27
|
+
help="Build, parse and validate identity URIs. Deterministic, offline, no store.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ScopeOption = Annotated[
|
|
32
|
+
str,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--scope",
|
|
35
|
+
help="firm/engagement/system/environment, as immutable slugs.",
|
|
36
|
+
show_default=False,
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
KindOption = Annotated[str, typer.Option("--kind", help="The identity_kind.", show_default=False)]
|
|
40
|
+
NamespaceOption = Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option("--namespace", help="The namespace. Omit where the kind needs none."),
|
|
43
|
+
]
|
|
44
|
+
KeyOption = Annotated[
|
|
45
|
+
list[str],
|
|
46
|
+
typer.Option(
|
|
47
|
+
"--key",
|
|
48
|
+
help="The local key. Repeat for a multi-segment key such as a symbol path; "
|
|
49
|
+
"a single value is one segment, so any slash inside it is data.",
|
|
50
|
+
show_default=False,
|
|
51
|
+
),
|
|
52
|
+
]
|
|
53
|
+
UriArgument = Annotated[str, typer.Argument(help="The identity URI.")]
|
|
54
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _scope_from(path: str) -> Scope:
|
|
58
|
+
"""A `Scope` carrying slugs only.
|
|
59
|
+
|
|
60
|
+
The ids are absent because this command never touches a store and the URI is
|
|
61
|
+
built from slugs alone (CR-05). They are set to the slug rather than to an
|
|
62
|
+
empty string so that anything which did misuse one fails visibly instead of
|
|
63
|
+
joining to a row that happens to have an empty id.
|
|
64
|
+
"""
|
|
65
|
+
parsed = ScopePath.parse(path)
|
|
66
|
+
levels = (parsed.firm, parsed.engagement, parsed.system, parsed.environment)
|
|
67
|
+
nodes = tuple(None if slug is None else ScopeNode(id=slug, slug=slug) for slug in levels)
|
|
68
|
+
return Scope(
|
|
69
|
+
firm=nodes[0] or ScopeNode(id=parsed.firm, slug=parsed.firm),
|
|
70
|
+
engagement=nodes[1],
|
|
71
|
+
system=nodes[2],
|
|
72
|
+
# const-sync: ok -- positional index into the four scope levels.
|
|
73
|
+
environment=nodes[3],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def build(
|
|
79
|
+
scope: ScopeOption,
|
|
80
|
+
kind: KindOption,
|
|
81
|
+
key: KeyOption,
|
|
82
|
+
namespace: NamespaceOption = None,
|
|
83
|
+
json_output: JsonOption = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Build the canonical URI for a referent."""
|
|
86
|
+
uri = build_uri(_scope_from(scope), kind, namespace, tuple(key))
|
|
87
|
+
emit({"uri": uri}, as_json=json_output, title="adopt identity build")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def parse(uri: UriArgument, json_output: JsonOption = False) -> None:
|
|
92
|
+
"""Parse a URI into its seven segments."""
|
|
93
|
+
parsed = parse_uri(uri)
|
|
94
|
+
emit(
|
|
95
|
+
{
|
|
96
|
+
"firm": parsed.firm,
|
|
97
|
+
"engagement": parsed.engagement,
|
|
98
|
+
"system": parsed.system,
|
|
99
|
+
"environment": parsed.environment,
|
|
100
|
+
"kind": parsed.kind,
|
|
101
|
+
"namespace": parsed.namespace,
|
|
102
|
+
"key": list(parsed.key),
|
|
103
|
+
},
|
|
104
|
+
as_json=json_output,
|
|
105
|
+
title="adopt identity parse",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command()
|
|
110
|
+
def validate(uri: UriArgument, json_output: JsonOption = False) -> None:
|
|
111
|
+
"""Check a URI against the grammar and canonical form. Exits 2 if it is not."""
|
|
112
|
+
validate_uri(uri)
|
|
113
|
+
emit({"uri": uri}, as_json=json_output, title="adopt identity validate")
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""`adopt init` -- contracts §14.
|
|
2
|
+
|
|
3
|
+
The one command that takes an FDE from "here is a client repository" to "here is
|
|
4
|
+
a store with a scope, an archetype and an honest boundary", offline, in one step.
|
|
5
|
+
|
|
6
|
+
**Scope is given, never inferred** -- the argument CR-32 already made for
|
|
7
|
+
`adopt identity build`. Slugs are permanent (CR-05) and a URI built from a
|
|
8
|
+
guessed scope is not a wrong answer but a *different referent*, which nothing
|
|
9
|
+
downstream can detect. There is no scope key in `03` §3's config registry for the
|
|
10
|
+
same reason.
|
|
11
|
+
|
|
12
|
+
**Every scope level is idempotent on its slug.** Re-running `init` reuses a firm,
|
|
13
|
+
engagement, system or environment that already exists rather than failing -- a
|
|
14
|
+
slug is never reissued (CR-05), so resolving one is the correct reading of "it is
|
|
15
|
+
already there". What is *not* reused is the boundary: re-running appends a new
|
|
16
|
+
boundary row, because the answers may have changed and a boundary is a
|
|
17
|
+
declaration with a date on it.
|
|
18
|
+
|
|
19
|
+
**Exit codes carry the finding** (contracts §14 gives `0, 2, 3, 4`):
|
|
20
|
+
|
|
21
|
+
* `2` -- detection was ambiguous. **Nothing is written**; `04` §4 forbids a guess.
|
|
22
|
+
* `3` -- `T0`. The store, the scope and the boundary are recorded and `init` then
|
|
23
|
+
**refuses to proceed to capability planning** (PRD F10.6). A policy refusal,
|
|
24
|
+
because proceeding is precisely what is being declined.
|
|
25
|
+
* `4` -- degraded with findings: an `ai` system below its `T3` floor. The setup
|
|
26
|
+
is usable and something needs a human.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Annotated, Any, get_args
|
|
32
|
+
|
|
33
|
+
import typer
|
|
34
|
+
|
|
35
|
+
from adopt_cli.commands.boundary import answers_from_file
|
|
36
|
+
from adopt_cli.json_out import emit
|
|
37
|
+
from adopt_cli.store_option import configured_store_path, open_or_create_store
|
|
38
|
+
from adopt_detect import declare_boundary, negotiate, parse_answers, render_markdown
|
|
39
|
+
from adopt_detect import detect as run_detect
|
|
40
|
+
from adopt_detect.detect import DISAMBIGUATION_FLAG
|
|
41
|
+
from adopt_model._enums import Archetype
|
|
42
|
+
from adopt_obs import AdoptError, ErrorCode, ExitCode
|
|
43
|
+
from adopt_scope import Scope, ScopeFacade, ScopePath
|
|
44
|
+
|
|
45
|
+
__all__ = ["init"]
|
|
46
|
+
|
|
47
|
+
PathArgument = Annotated[Path, typer.Argument(help="The tree to classify. Read, never executed.")]
|
|
48
|
+
ScopeOption = Annotated[
|
|
49
|
+
str,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"--scope",
|
|
52
|
+
help="firm/engagement/system/environment, as immutable slugs. All four are "
|
|
53
|
+
"required: a boundary is declared for one environment of one system.",
|
|
54
|
+
show_default=False,
|
|
55
|
+
),
|
|
56
|
+
]
|
|
57
|
+
AnswersOption = Annotated[
|
|
58
|
+
Path,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--answers",
|
|
61
|
+
help="JSON file carrying the three qualification answers.",
|
|
62
|
+
show_default=False,
|
|
63
|
+
),
|
|
64
|
+
]
|
|
65
|
+
ArchetypeOption = Annotated[
|
|
66
|
+
str | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--archetype",
|
|
69
|
+
help="Accept an archetype explicitly, when detection was ambiguous. This is the "
|
|
70
|
+
"human decision PRD §8 requires before an archetype is written -- there is no "
|
|
71
|
+
"confidence exemption and no flag that lets a proposal write itself.",
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
StoreOption = Annotated[Path | None, typer.Option("--store", help="Store path override.")]
|
|
75
|
+
StatementOption = Annotated[
|
|
76
|
+
Path | None,
|
|
77
|
+
typer.Option("--write-statement", help="Also write the human-readable boundary statement."),
|
|
78
|
+
]
|
|
79
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class _FullScope:
|
|
84
|
+
"""Four slugs, all present. `ScopePath` allows a prefix; `init` does not."""
|
|
85
|
+
|
|
86
|
+
firm: str
|
|
87
|
+
engagement: str
|
|
88
|
+
system: str
|
|
89
|
+
environment: str
|
|
90
|
+
|
|
91
|
+
def prefix(self, depth: int) -> ScopePath:
|
|
92
|
+
"""The path naming the first `depth` levels."""
|
|
93
|
+
levels: tuple[str | None, ...] = (self.firm, self.engagement, self.system, self.environment)
|
|
94
|
+
padded = tuple(level if index < depth else None for index, level in enumerate(levels))
|
|
95
|
+
return ScopePath(
|
|
96
|
+
firm=self.firm,
|
|
97
|
+
engagement=padded[1],
|
|
98
|
+
system=padded[2],
|
|
99
|
+
# const-sync: ok -- positional indexes into the four scope levels.
|
|
100
|
+
environment=padded[3],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _require_full_scope(scope: str) -> _FullScope:
|
|
105
|
+
parsed = ScopePath.parse(scope)
|
|
106
|
+
if parsed.engagement is None or parsed.system is None or parsed.environment is None:
|
|
107
|
+
raise AdoptError(
|
|
108
|
+
ErrorCode.SCOPE_SLUG_INVALID,
|
|
109
|
+
message=f"--scope {scope!r} does not name all four levels",
|
|
110
|
+
hint="A boundary is declared for one environment of one system, and every "
|
|
111
|
+
"identity URI carries an environment (contracts §4). Pass "
|
|
112
|
+
"`firm/engagement/system/environment`.",
|
|
113
|
+
)
|
|
114
|
+
return _FullScope(parsed.firm, parsed.engagement, parsed.system, parsed.environment)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolves(scopes: ScopeFacade, path: ScopePath) -> Scope | None:
|
|
118
|
+
"""The resolved scope, or `None` when a level in it does not exist yet.
|
|
119
|
+
|
|
120
|
+
Existence is tested by resolving and catching, because `ScopeFacade` exposes
|
|
121
|
+
no `find_*`. Widening the facade for a CLI convenience would put a second
|
|
122
|
+
lookup path beside the one the slug rules are enforced on, and the rules --
|
|
123
|
+
validity, immutability, no reissue -- all live on the creation path.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
return scopes.resolve(path)
|
|
127
|
+
except AdoptError as error:
|
|
128
|
+
if error.code is ErrorCode.SCOPE_SLUG_INVALID:
|
|
129
|
+
return None
|
|
130
|
+
raise
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _ensure_scope(scopes: ScopeFacade, wanted: _FullScope, archetype: Archetype | None) -> Scope:
|
|
134
|
+
"""Create whichever levels are missing, reuse the rest, return the full scope."""
|
|
135
|
+
if _resolves(scopes, wanted.prefix(1)) is None:
|
|
136
|
+
scopes.create_firm(slug=wanted.firm, name=wanted.firm)
|
|
137
|
+
firm_scope = scopes.resolve(wanted.prefix(1))
|
|
138
|
+
|
|
139
|
+
if _resolves(scopes, wanted.prefix(2)) is None:
|
|
140
|
+
scopes.create_engagement(
|
|
141
|
+
firm_id=firm_scope.firm.id, slug=wanted.engagement, name=wanted.engagement
|
|
142
|
+
)
|
|
143
|
+
engagement_scope = scopes.resolve(wanted.prefix(2))
|
|
144
|
+
assert engagement_scope.engagement is not None # noqa: S101 -- just resolved at depth 2
|
|
145
|
+
|
|
146
|
+
# const-sync: ok -- scope depth 3 (firm/engagement/system), not SCHEMA_VERSION.
|
|
147
|
+
if _resolves(scopes, wanted.prefix(3)) is None:
|
|
148
|
+
scopes.create_system(
|
|
149
|
+
engagement_id=engagement_scope.engagement.id,
|
|
150
|
+
slug=wanted.system,
|
|
151
|
+
name=wanted.system,
|
|
152
|
+
archetype=archetype,
|
|
153
|
+
)
|
|
154
|
+
# const-sync: ok -- scope depth 3 (firm/engagement/system), not SCHEMA_VERSION.
|
|
155
|
+
system_scope = scopes.resolve(wanted.prefix(3))
|
|
156
|
+
assert system_scope.system is not None # noqa: S101 -- just resolved at depth 3
|
|
157
|
+
|
|
158
|
+
if _resolves(scopes, wanted.prefix(4)) is None:
|
|
159
|
+
scopes.create_environment(
|
|
160
|
+
system_id=system_scope.system.id, slug=wanted.environment, name=wanted.environment
|
|
161
|
+
)
|
|
162
|
+
return scopes.resolve(wanted.prefix(4))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _accepted_archetype(named: str | None) -> Archetype | None:
|
|
166
|
+
"""Validate an operator-supplied archetype against the canonical vocabulary.
|
|
167
|
+
|
|
168
|
+
Checked against the **generated** `Archetype` literal rather than a list held
|
|
169
|
+
here, so the vocabulary has one home (`02` §2.1) and adding an archetype to the
|
|
170
|
+
manifest cannot leave this command rejecting it.
|
|
171
|
+
|
|
172
|
+
A typo is refused rather than stored: `system.archetype` is what decides which
|
|
173
|
+
extractors a downstream item runs, so `--archetype wbe` writing an unknown
|
|
174
|
+
value would be a *different referent* rather than a slightly wrong one -- the
|
|
175
|
+
argument CR-32 made for scope, applied to the one other field an operator
|
|
176
|
+
types by hand.
|
|
177
|
+
"""
|
|
178
|
+
if named is None:
|
|
179
|
+
return None
|
|
180
|
+
canonical = get_args(Archetype)
|
|
181
|
+
if named not in canonical:
|
|
182
|
+
raise AdoptError(
|
|
183
|
+
ErrorCode.DETECT_AMBIGUOUS,
|
|
184
|
+
message=f"--archetype {named!r} is not one of {', '.join(canonical)}",
|
|
185
|
+
hint="`02` §2.1 fixes the archetype vocabulary, and a value outside it "
|
|
186
|
+
"would decide which extractors run for this system. Nothing was created.",
|
|
187
|
+
)
|
|
188
|
+
accepted: Archetype = named # type: ignore[assignment] # membership just checked
|
|
189
|
+
return accepted
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def init(
|
|
193
|
+
path: PathArgument = Path(),
|
|
194
|
+
scope: ScopeOption = "",
|
|
195
|
+
answers: AnswersOption = Path("answers.json"),
|
|
196
|
+
archetype: ArchetypeOption = None,
|
|
197
|
+
store: StoreOption = None,
|
|
198
|
+
write_statement: StatementOption = None,
|
|
199
|
+
json_output: JsonOption = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Create a store, resolve a scope, detect the archetype and declare the boundary."""
|
|
202
|
+
wanted = _require_full_scope(scope)
|
|
203
|
+
decision = negotiate(parse_answers(answers_from_file(answers)))
|
|
204
|
+
|
|
205
|
+
# Detection runs **before** the store is created, so an ambiguous tree leaves
|
|
206
|
+
# nothing behind. A store created and then abandoned is exactly the empty
|
|
207
|
+
# database beside the real one that `store_option` already refuses to make.
|
|
208
|
+
result = run_detect(path)
|
|
209
|
+
accepted = _accepted_archetype(archetype)
|
|
210
|
+
if result.ambiguous and accepted is None:
|
|
211
|
+
raise AdoptError(
|
|
212
|
+
ErrorCode.DETECT_AMBIGUOUS,
|
|
213
|
+
message=f"confidence {result.confidence} is below the threshold; nothing was created",
|
|
214
|
+
hint=f"Run `adopt detect {path}` for the ranked scores and the rules that "
|
|
215
|
+
f"fired. Narrow the path to one system, or set {DISAMBIGUATION_FLAG}=1 for a "
|
|
216
|
+
f"proposal and re-run with `--archetype <a>` to accept it. "
|
|
217
|
+
f"Detection does not guess: a wrong archetype is a different set of "
|
|
218
|
+
f"extractors, not a slightly wrong answer.",
|
|
219
|
+
)
|
|
220
|
+
# **The operator's value wins, and that is the whole human-accept step.** PRD §8
|
|
221
|
+
# requires human approval for writing an archetype with no confidence
|
|
222
|
+
# exemption, so a named `--archetype` overrules detection rather than merely
|
|
223
|
+
# unblocking it -- an operator who has read a proposal, or who simply knows the
|
|
224
|
+
# system, is the authority the matrix names. `archetype_source` records which
|
|
225
|
+
# of the two the row came from, because "who decided this" is exactly the
|
|
226
|
+
# question an audit asks of a system that can propose.
|
|
227
|
+
effective = accepted if accepted is not None else result.archetype
|
|
228
|
+
archetype_source = "operator" if accepted is not None else "detected"
|
|
229
|
+
|
|
230
|
+
store_path = configured_store_path(store)
|
|
231
|
+
with open_or_create_store(store) as handle:
|
|
232
|
+
resolved = _ensure_scope(handle.scope(), wanted, effective)
|
|
233
|
+
view = declare_boundary(
|
|
234
|
+
handle.boundary(), scope=resolved, decision=decision, archetype=effective
|
|
235
|
+
)
|
|
236
|
+
payload: dict[str, Any] = {
|
|
237
|
+
"store_path": str(store_path),
|
|
238
|
+
"scope": {
|
|
239
|
+
"firm": wanted.firm,
|
|
240
|
+
"engagement": wanted.engagement,
|
|
241
|
+
"system": wanted.system,
|
|
242
|
+
"environment": wanted.environment,
|
|
243
|
+
},
|
|
244
|
+
"archetype": effective,
|
|
245
|
+
"archetype_source": archetype_source,
|
|
246
|
+
"tier": view.tier,
|
|
247
|
+
"schema_version": handle.schema_version,
|
|
248
|
+
"boundary_id": view.boundary_id,
|
|
249
|
+
"unavailable_capabilities": list(view.unavailable_capabilities),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if write_statement is not None:
|
|
253
|
+
write_statement.write_text(render_markdown(view), encoding="utf-8", newline="\n")
|
|
254
|
+
payload["statement_path"] = str(write_statement)
|
|
255
|
+
|
|
256
|
+
emit(payload, as_json=json_output, title="adopt init")
|
|
257
|
+
|
|
258
|
+
if view.decline_recommended:
|
|
259
|
+
raise AdoptError(
|
|
260
|
+
ErrorCode.TIER_DECLINE_RECOMMENDED,
|
|
261
|
+
message="T0: no artifact access, so no claim this platform makes could be "
|
|
262
|
+
"supported by evidence from this system",
|
|
263
|
+
hint="The store, the scope and the boundary are recorded so the decision is "
|
|
264
|
+
"auditable, and setup stops here. Re-run with different answers if "
|
|
265
|
+
"artifact access is arranged.",
|
|
266
|
+
)
|
|
267
|
+
if view.archetype_floor_violated:
|
|
268
|
+
raise typer.Exit(ExitCode.DEGRADED_WITH_FINDINGS)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`adopt export DIR` and `adopt import DIR --into PATH` — contracts §14.
|
|
2
|
+
|
|
3
|
+
Both emit the one envelope §14 declares for the pair --
|
|
4
|
+
`{bundle_path, export_version, tables[], bytes}` -- because they are two
|
|
5
|
+
directions of one operation and an integrator comparing what was written against
|
|
6
|
+
what was applied should not have to reconcile two shapes.
|
|
7
|
+
|
|
8
|
+
Exit codes are §13's, derived from the error's category and never chosen here: a
|
|
9
|
+
policy refusal (`EXPORT_SCOPE_AMBIGUOUS`, `EXPORT_VERSION_UNSUPPORTED`,
|
|
10
|
+
`EXPORT_TARGET_NOT_EMPTY`) exits `3`, an integrity failure
|
|
11
|
+
(`EXPORT_DIGEST_MISMATCH`, `EXPORT_BUNDLE_MALFORMED`) exits `1`.
|
|
12
|
+
|
|
13
|
+
**Export opens the store read-only and import opens its target for writing.**
|
|
14
|
+
Neither needs saying twice: the direction of the operation decides it, and a
|
|
15
|
+
read-only export is what makes "exporting cannot change what you exported"
|
|
16
|
+
structural rather than remembered.
|
|
17
|
+
|
|
18
|
+
The two commands live in one module because they share the envelope and the
|
|
19
|
+
`--json` flag; they are registered as separate top-level commands rather than a
|
|
20
|
+
`adopt interchange` group, because §14 names them `adopt export` and
|
|
21
|
+
`adopt import`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from adopt_cli.json_out import emit
|
|
30
|
+
from adopt_cli.store_option import open_configured_store, open_named_store, writer_identity
|
|
31
|
+
from adopt_export import BundleManifest, apply_bundle, table_relative_path, write_bundle
|
|
32
|
+
|
|
33
|
+
__all__ = ["export", "import_"]
|
|
34
|
+
|
|
35
|
+
BundleArgument = Annotated[
|
|
36
|
+
Path, typer.Argument(metavar="DIR", help="The bundle directory.", show_default=False)
|
|
37
|
+
]
|
|
38
|
+
StoreOption = Annotated[
|
|
39
|
+
Path | None,
|
|
40
|
+
typer.Option("--store", help="Store path. Defaults to the resolved ADOPT_STORE_PATH."),
|
|
41
|
+
]
|
|
42
|
+
IntoOption = Annotated[
|
|
43
|
+
Path,
|
|
44
|
+
typer.Option("--into", help="The store to restore into. Created empty if absent."),
|
|
45
|
+
]
|
|
46
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit the strict JSON envelope only.")]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _bytes_on_disk(bundle_path: Path, manifest: BundleManifest) -> int:
|
|
50
|
+
"""Total size of the table files the manifest names.
|
|
51
|
+
|
|
52
|
+
The table files and nothing else: `bytes` describes the payload an
|
|
53
|
+
integrator moves, and counting the manifest and the JSON schema would make
|
|
54
|
+
the number change whenever a comment in the schema did.
|
|
55
|
+
"""
|
|
56
|
+
return sum(
|
|
57
|
+
(bundle_path / table_relative_path(entry.name)).stat().st_size for entry in manifest.tables
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _payload(bundle_path: Path, manifest: BundleManifest) -> dict[str, object]:
|
|
62
|
+
return {
|
|
63
|
+
"bundle_path": str(bundle_path),
|
|
64
|
+
"export_version": manifest.export_version,
|
|
65
|
+
"tables": [
|
|
66
|
+
{"name": entry.name, "rows": entry.rows, "sha256": entry.sha256}
|
|
67
|
+
for entry in manifest.tables
|
|
68
|
+
],
|
|
69
|
+
"bytes": _bytes_on_disk(bundle_path, manifest),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def export(
|
|
74
|
+
directory: BundleArgument,
|
|
75
|
+
store: StoreOption = None,
|
|
76
|
+
json_output: JsonOption = False,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Write a portable bundle of every exportable table."""
|
|
79
|
+
with open_configured_store(store, read_only=True) as handle:
|
|
80
|
+
manifest = write_bundle(handle.export_records(), directory, written_by=writer_identity())
|
|
81
|
+
emit(_payload(directory, manifest), as_json=json_output, title="adopt export")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def import_(
|
|
85
|
+
directory: BundleArgument,
|
|
86
|
+
into: IntoOption,
|
|
87
|
+
json_output: JsonOption = False,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Verify a bundle whole, then restore it into an empty store."""
|
|
90
|
+
with open_named_store(into, migrate=True) as handle:
|
|
91
|
+
manifest = apply_bundle(handle.import_records(), directory)
|
|
92
|
+
emit(_payload(directory, manifest), as_json=json_output, title="adopt import")
|