icdev-core 0.2.1__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.
- icdev/core/__init__.py +22 -0
- icdev/core/context.py +205 -0
- icdev/core/domain.py +265 -0
- icdev/core/paths.py +179 -0
- icdev/core/schema/tables.yaml +521 -0
- icdev/core/sensitivity.py +148 -0
- icdev_core-0.2.1.dist-info/METADATA +136 -0
- icdev_core-0.2.1.dist-info/RECORD +10 -0
- icdev_core-0.2.1.dist-info/WHEEL +5 -0
- icdev_core-0.2.1.dist-info/top_level.txt +1 -0
icdev/core/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# CUI // SP-CTI
|
|
2
|
+
"""ICDEV core contract — the seam between the domain-neutral kernel and a parent.
|
|
3
|
+
|
|
4
|
+
A parent (ICDEV[IT], ICDEV[FT], ...) declares itself in an ``icdev_domain.yaml``
|
|
5
|
+
at its repository root. This package reads that declaration and answers the
|
|
6
|
+
three questions every kernel module used to answer for itself, 2,054 times
|
|
7
|
+
over, with ``Path(__file__).resolve().parent...``:
|
|
8
|
+
|
|
9
|
+
* :mod:`icdev.core.paths` — where is the repository root and its data?
|
|
10
|
+
* :mod:`icdev.core.domain` — which domain is this, and what did it declare?
|
|
11
|
+
* :mod:`icdev.core.context` — is this process allowed to run HERE, against
|
|
12
|
+
THIS database? (``assert_identity``)
|
|
13
|
+
|
|
14
|
+
Nothing in this package imports anything outside the standard library and
|
|
15
|
+
PyYAML, so it can be imported before ``tools.db.storage`` and from either the
|
|
16
|
+
``tools.*`` shim or the ``icdev.tools.*`` canonical namespace.
|
|
17
|
+
|
|
18
|
+
Programme: docs/programmes/icdev-domain-split.md (xit-decl-01).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
__all__ = ["paths", "domain", "context"]
|
icdev/core/context.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# CUI // SP-CTI
|
|
2
|
+
"""Process identity: is THIS process allowed to run HERE, against THIS database?
|
|
3
|
+
|
|
4
|
+
Two ICDEV parents run on one machine, share one shell and one PostgreSQL
|
|
5
|
+
server, and both read ``<PREFIX>_PG_DATABASE`` / ``<PREFIX>_DATABASE_URL``
|
|
6
|
+
from the process environment. An ICDEV[FT] session that inherits ICDEV[IT]'s
|
|
7
|
+
``.env`` would open ``icdev`` and write trading rows into it; nothing today
|
|
8
|
+
would notice. :func:`assert_identity` is the refusal, called at the start of
|
|
9
|
+
every long-lived or state-changing entry point (dashboard, genesis daemon,
|
|
10
|
+
``tools/db/migrate.py``, ``tools/kanban/cli.py``).
|
|
11
|
+
|
|
12
|
+
The check is FAIL-CLOSED ON A DECLARED MISMATCH and NEVER on silence: a
|
|
13
|
+
process whose env names no database at all is reported ``unmeasured`` and
|
|
14
|
+
allowed through, because SQLite-only deployments, CI and tests legitimately
|
|
15
|
+
run without one. A declaration that lists no ``db.databases`` asserts
|
|
16
|
+
nothing and is likewise ``unmeasured``.
|
|
17
|
+
|
|
18
|
+
python -m icdev.core.context --check # human summary, exit 1 on mismatch
|
|
19
|
+
python -m icdev.core.context --check --json
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
from icdev.core import paths as core_paths
|
|
32
|
+
from icdev.core.domain import Domain, DomainError, load_domain
|
|
33
|
+
|
|
34
|
+
IDENTITY_GUARD_ENV = "ICDEV_IDENTITY_GUARD" # =0 -> report only, never refuse
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IdentityMismatch(RuntimeError):
|
|
38
|
+
"""The process environment names a database this parent did not declare."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class IdentityReport:
|
|
43
|
+
domain_key: str
|
|
44
|
+
domain_name: str
|
|
45
|
+
domain_source: str
|
|
46
|
+
root: str
|
|
47
|
+
database_declared: tuple[str, ...]
|
|
48
|
+
database_observed: str | None
|
|
49
|
+
database_source: str | None # name_env | dsn_env | None
|
|
50
|
+
verdict: str # match | mismatch | unmeasured
|
|
51
|
+
enforced: bool
|
|
52
|
+
detail: str
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
d = asdict(self)
|
|
56
|
+
d["database_declared"] = list(self.database_declared)
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _database_from_dsn(dsn: str) -> str | None:
|
|
61
|
+
"""Return the database name from a libpq URL, or None when it has none."""
|
|
62
|
+
try:
|
|
63
|
+
parts = urlsplit(dsn)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
if parts.scheme not in ("postgresql", "postgres"):
|
|
67
|
+
return None
|
|
68
|
+
name = parts.path.lstrip("/")
|
|
69
|
+
return name or None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def observed_database(domain: Domain, environ: os._Environ | dict | None = None) -> tuple[str | None, str | None]:
|
|
73
|
+
"""Return ``(database_name, which_env_var)`` as the process env declares it.
|
|
74
|
+
|
|
75
|
+
``name_env`` wins over ``dsn_env`` because that is the precedence
|
|
76
|
+
``tools/db/storage.py`` gives ``ICDEV_PG_DATABASE`` when both are set for
|
|
77
|
+
the keyword form; a DSN's path is consulted only when the name is absent.
|
|
78
|
+
"""
|
|
79
|
+
env = os.environ if environ is None else environ
|
|
80
|
+
name = (env.get(domain.db.name_env) or "").strip()
|
|
81
|
+
if name:
|
|
82
|
+
return name, domain.db.name_env
|
|
83
|
+
dsn = (env.get(domain.db.dsn_env) or "").strip()
|
|
84
|
+
if dsn:
|
|
85
|
+
parsed = _database_from_dsn(dsn)
|
|
86
|
+
if parsed:
|
|
87
|
+
return parsed, domain.db.dsn_env
|
|
88
|
+
return None, None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_env(root: Path | None = None) -> Path | None:
|
|
92
|
+
"""Load ``<root>/.env`` (the PARENT's, never cwd's). Returns the path loaded."""
|
|
93
|
+
root = root or core_paths.repo_root()
|
|
94
|
+
env_file = root / ".env"
|
|
95
|
+
if not env_file.is_file():
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
from dotenv import load_dotenv
|
|
99
|
+
except ImportError: # pragma: no cover — dotenv is a declared dependency
|
|
100
|
+
return None
|
|
101
|
+
load_dotenv(env_file, override=False)
|
|
102
|
+
return env_file
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_identity(
|
|
106
|
+
*,
|
|
107
|
+
anchor: str | os.PathLike[str] | None = None,
|
|
108
|
+
domain: Domain | None = None,
|
|
109
|
+
environ: dict | None = None,
|
|
110
|
+
) -> IdentityReport:
|
|
111
|
+
"""Compute the identity verdict without raising."""
|
|
112
|
+
dom = domain or load_domain(anchor=anchor)
|
|
113
|
+
observed, which = observed_database(dom, environ)
|
|
114
|
+
declared = tuple(dom.db.databases)
|
|
115
|
+
enforced = os.environ.get(IDENTITY_GUARD_ENV, "1").strip().lower() not in ("0", "false", "no", "monitor")
|
|
116
|
+
if not declared:
|
|
117
|
+
verdict, detail = "unmeasured", "the declaration lists no db.databases, so it asserts nothing"
|
|
118
|
+
elif observed is None:
|
|
119
|
+
verdict, detail = "unmeasured", (
|
|
120
|
+
f"neither {dom.db.name_env} nor {dom.db.dsn_env} names a database in this process"
|
|
121
|
+
)
|
|
122
|
+
elif observed in declared:
|
|
123
|
+
verdict, detail = "match", f"{which}={observed} is declared by {dom.key}"
|
|
124
|
+
else:
|
|
125
|
+
verdict, detail = "mismatch", (
|
|
126
|
+
f"{which} names database {observed!r} but domain {dom.key!r} declares only "
|
|
127
|
+
f"{list(declared)} — this process is running against another parent's database"
|
|
128
|
+
)
|
|
129
|
+
return IdentityReport(
|
|
130
|
+
domain_key=dom.key,
|
|
131
|
+
domain_name=dom.name,
|
|
132
|
+
domain_source=dom.source,
|
|
133
|
+
root=str(dom.root),
|
|
134
|
+
database_declared=declared,
|
|
135
|
+
database_observed=observed,
|
|
136
|
+
database_source=which,
|
|
137
|
+
verdict=verdict,
|
|
138
|
+
enforced=enforced,
|
|
139
|
+
detail=detail,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def assert_identity(
|
|
144
|
+
*,
|
|
145
|
+
anchor: str | os.PathLike[str] | None = None,
|
|
146
|
+
domain: Domain | None = None,
|
|
147
|
+
environ: dict | None = None,
|
|
148
|
+
) -> IdentityReport:
|
|
149
|
+
"""Refuse (``IdentityMismatch``) on a declared mismatch; return the report otherwise.
|
|
150
|
+
|
|
151
|
+
Stand it down with ``ICDEV_IDENTITY_GUARD=0`` (the report is still
|
|
152
|
+
computed and returned, so a caller can log it), never with a shell
|
|
153
|
+
neutraliser.
|
|
154
|
+
"""
|
|
155
|
+
report = check_identity(anchor=anchor, domain=domain, environ=environ)
|
|
156
|
+
if report.verdict == "mismatch" and report.enforced:
|
|
157
|
+
raise IdentityMismatch(report.detail)
|
|
158
|
+
return report
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def describe(anchor: str | os.PathLike[str] | None = None) -> dict:
|
|
162
|
+
"""Everything ``icdev status`` prints about where and who this process is."""
|
|
163
|
+
out: dict = {"paths": core_paths.describe(anchor)}
|
|
164
|
+
try:
|
|
165
|
+
dom = load_domain(anchor=anchor)
|
|
166
|
+
except DomainError as exc:
|
|
167
|
+
out["domain"] = None
|
|
168
|
+
out["error"] = str(exc)
|
|
169
|
+
return out
|
|
170
|
+
out["domain"] = dom.to_dict()
|
|
171
|
+
out["identity"] = check_identity(domain=dom).to_dict()
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: list[str] | None = None) -> int:
|
|
176
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
177
|
+
ap.add_argument("--check", action="store_true", help="exit 1 on a declared mismatch")
|
|
178
|
+
ap.add_argument("--json", action="store_true")
|
|
179
|
+
ap.add_argument("--no-env", action="store_true", help="do not load <root>/.env first")
|
|
180
|
+
args = ap.parse_args(argv)
|
|
181
|
+
|
|
182
|
+
if not args.no_env:
|
|
183
|
+
load_env()
|
|
184
|
+
try:
|
|
185
|
+
info = describe()
|
|
186
|
+
except DomainError as exc:
|
|
187
|
+
print(f"domain declaration error: {exc}", file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
if args.json:
|
|
190
|
+
print(json.dumps(info, indent=2))
|
|
191
|
+
else:
|
|
192
|
+
d = info.get("domain") or {}
|
|
193
|
+
ident = info.get("identity") or {}
|
|
194
|
+
print(f"domain : {d.get('key')} ({d.get('name')}) from {d.get('source')}")
|
|
195
|
+
print(f"root : {info['paths']['root']} [{info['paths']['source']}]")
|
|
196
|
+
print(f"database : declared {d.get('db', {}).get('databases')} observed "
|
|
197
|
+
f"{ident.get('database_observed')!r} via {ident.get('database_source')}")
|
|
198
|
+
print(f"identity : {ident.get('verdict', 'unknown').upper()} — {ident.get('detail', info.get('error'))}")
|
|
199
|
+
if args.check and (info.get("identity") or {}).get("verdict") == "mismatch":
|
|
200
|
+
return 1
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__": # pragma: no cover
|
|
205
|
+
raise SystemExit(main())
|
icdev/core/domain.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# CUI // SP-CTI
|
|
2
|
+
"""The ``icdev_domain.yaml`` declaration — what a parent tells the core.
|
|
3
|
+
|
|
4
|
+
A parent declares its key, env prefix, database, sensitivity model, component
|
|
5
|
+
registry, reflex packs, dashboard port, board identity, MCP composition, trust
|
|
6
|
+
policy and CI gate set in one file at its repository root. The core reads that
|
|
7
|
+
file and nothing else to learn whose domain it is serving.
|
|
8
|
+
|
|
9
|
+
Two deliberate properties:
|
|
10
|
+
|
|
11
|
+
* **The key comes from the FILE, never from an env var.** Two parents on one
|
|
12
|
+
machine share a shell; a ``$ICDEV_DOMAIN=ft`` exported once would make the
|
|
13
|
+
IT checkout load FT's database. The file found next to the code (or, for an
|
|
14
|
+
installed kernel, in the current directory) is the only authority.
|
|
15
|
+
* **A missing file is NOT an error by default.** The published wheel and every
|
|
16
|
+
project scaffolded by ``icdev init`` have no declaration and must keep
|
|
17
|
+
working byte-for-byte. They receive :data:`BUILTIN_DEFAULT`, which encodes
|
|
18
|
+
ICDEV[IT]'s constants as they stood on 2026-08-21 and reports
|
|
19
|
+
``source == "builtin_default"`` so ``icdev status`` can say so. Set
|
|
20
|
+
``ICDEV_REQUIRE_DOMAIN=1`` on a deployment that must refuse to start
|
|
21
|
+
undeclared.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Mapping
|
|
29
|
+
|
|
30
|
+
from icdev.core.paths import DOMAIN_FILE, repo_root
|
|
31
|
+
|
|
32
|
+
REQUIRE_DOMAIN_ENV = "ICDEV_REQUIRE_DOMAIN"
|
|
33
|
+
SCHEMA_VERSION = 1
|
|
34
|
+
|
|
35
|
+
#: ICDEV[IT] as it stood before any parent declared itself. Used when no
|
|
36
|
+
#: ``icdev_domain.yaml`` is found; MUST match the checked-in file's values.
|
|
37
|
+
BUILTIN_DEFAULT: dict[str, Any] = {
|
|
38
|
+
"schema_version": SCHEMA_VERSION,
|
|
39
|
+
"domain": {"key": "it", "name": "ICDEV[IT]", "env_prefix": "ICDEV"},
|
|
40
|
+
"paths": {"data": "data", "forge": ["goals", "args", "context", "hardprompts"]},
|
|
41
|
+
"db": {
|
|
42
|
+
"backend": "postgresql",
|
|
43
|
+
"name_env": "ICDEV_PG_DATABASE",
|
|
44
|
+
"dsn_env": "ICDEV_DATABASE_URL",
|
|
45
|
+
"sqlite_path_env": "ICDEV_DB_PATH",
|
|
46
|
+
"databases": ["icdev"],
|
|
47
|
+
"migrations": ["tools/db/migrations"],
|
|
48
|
+
},
|
|
49
|
+
"sensitivity": {
|
|
50
|
+
"column": "classification",
|
|
51
|
+
"labels_file": "args/classification_profiles.yaml",
|
|
52
|
+
"default": "public",
|
|
53
|
+
"egress_restricted": ["cui", "cui_sp_cti", "secret", "itar"],
|
|
54
|
+
"levels": ["IL2", "IL4", "IL5", "IL6"],
|
|
55
|
+
},
|
|
56
|
+
"components": "args/component_registry.yaml",
|
|
57
|
+
"reflexes": {"packs": []},
|
|
58
|
+
"dashboard": {"port": 5050, "blueprints": "registry"},
|
|
59
|
+
"kanban": {"board": "it", "external_repos": "args/kanban_external_repos.yaml"},
|
|
60
|
+
"mcp": {"servers": ["core", "compliance", "devsecops", "builder", "knowledge", "maintenance"]},
|
|
61
|
+
"trust": {"citation_required": True, "promotion_gates": ["coherence", "gated_tests"]},
|
|
62
|
+
"ci": {
|
|
63
|
+
"gated_lists": "args/ci_test_files",
|
|
64
|
+
"compat_suite": "args/ci_test_files/core_compat.txt",
|
|
65
|
+
"census_dir": "args",
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_KEY_CHARS = set("abcdefghijklmnopqrstuvwxyz0123456789_")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DomainError(RuntimeError):
|
|
73
|
+
"""The declaration is missing when required, unreadable, or invalid."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class DbDeclaration:
|
|
78
|
+
backend: str
|
|
79
|
+
name_env: str
|
|
80
|
+
dsn_env: str
|
|
81
|
+
sqlite_path_env: str
|
|
82
|
+
databases: tuple[str, ...]
|
|
83
|
+
migrations: tuple[str, ...]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class SensitivityDeclaration:
|
|
88
|
+
column: str
|
|
89
|
+
labels_file: str
|
|
90
|
+
default: str
|
|
91
|
+
egress_restricted: tuple[str, ...]
|
|
92
|
+
levels: tuple[str, ...]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Domain:
|
|
97
|
+
key: str
|
|
98
|
+
name: str
|
|
99
|
+
env_prefix: str
|
|
100
|
+
root: Path
|
|
101
|
+
source: str # "file" | "builtin_default"
|
|
102
|
+
path: Path | None
|
|
103
|
+
db: DbDeclaration
|
|
104
|
+
sensitivity: SensitivityDeclaration
|
|
105
|
+
data_dir: str = "data"
|
|
106
|
+
forge_dirs: tuple[str, ...] = ("goals", "args", "context", "hardprompts")
|
|
107
|
+
components: str = "args/component_registry.yaml"
|
|
108
|
+
reflex_packs: tuple[str, ...] = ()
|
|
109
|
+
dashboard_port: int = 5050
|
|
110
|
+
dashboard_blueprints: str = "registry"
|
|
111
|
+
kanban_board: str = "it"
|
|
112
|
+
kanban_external_repos: str = "args/kanban_external_repos.yaml"
|
|
113
|
+
mcp_servers: tuple[str, ...] = ()
|
|
114
|
+
trust: Mapping[str, Any] = field(default_factory=dict)
|
|
115
|
+
ci: Mapping[str, Any] = field(default_factory=dict)
|
|
116
|
+
raw: Mapping[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
117
|
+
|
|
118
|
+
def env(self, suffix: str) -> str:
|
|
119
|
+
"""Name of this parent's env var for ``suffix`` (``env("PG_DATABASE")``)."""
|
|
120
|
+
return f"{self.env_prefix}_{suffix}"
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
return {
|
|
124
|
+
"key": self.key,
|
|
125
|
+
"name": self.name,
|
|
126
|
+
"env_prefix": self.env_prefix,
|
|
127
|
+
"root": str(self.root),
|
|
128
|
+
"source": self.source,
|
|
129
|
+
"path": str(self.path) if self.path else None,
|
|
130
|
+
"db": {
|
|
131
|
+
"backend": self.db.backend,
|
|
132
|
+
"name_env": self.db.name_env,
|
|
133
|
+
"dsn_env": self.db.dsn_env,
|
|
134
|
+
"databases": list(self.db.databases),
|
|
135
|
+
},
|
|
136
|
+
"dashboard_port": self.dashboard_port,
|
|
137
|
+
"kanban_board": self.kanban_board,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _require(mapping: Mapping[str, Any], key: str, where: str) -> Any:
|
|
142
|
+
if key not in mapping or mapping[key] in (None, ""):
|
|
143
|
+
raise DomainError(f"{where}: missing required field {key!r}")
|
|
144
|
+
return mapping[key]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _tuple(value: Any, where: str) -> tuple[str, ...]:
|
|
148
|
+
if value is None:
|
|
149
|
+
return ()
|
|
150
|
+
if isinstance(value, (str, bytes)) or not hasattr(value, "__iter__"):
|
|
151
|
+
raise DomainError(f"{where}: expected a list, got {type(value).__name__}")
|
|
152
|
+
return tuple(str(v) for v in value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parse_domain(data: Mapping[str, Any], *, root: Path, source: str, path: Path | None) -> Domain:
|
|
156
|
+
"""Validate a declaration mapping and build a :class:`Domain`."""
|
|
157
|
+
if not isinstance(data, Mapping):
|
|
158
|
+
raise DomainError(f"{path or source}: top level must be a mapping")
|
|
159
|
+
version = data.get("schema_version", SCHEMA_VERSION)
|
|
160
|
+
if version != SCHEMA_VERSION:
|
|
161
|
+
raise DomainError(
|
|
162
|
+
f"{path or source}: schema_version {version!r} is not supported "
|
|
163
|
+
f"(this core reads {SCHEMA_VERSION})"
|
|
164
|
+
)
|
|
165
|
+
dom = _require(data, "domain", str(path or source))
|
|
166
|
+
key = str(_require(dom, "key", "domain")).strip()
|
|
167
|
+
if not key or set(key) - _KEY_CHARS or key[0].isdigit():
|
|
168
|
+
raise DomainError(
|
|
169
|
+
f"domain.key {key!r} must be lowercase [a-z0-9_] and start with a letter"
|
|
170
|
+
)
|
|
171
|
+
env_prefix = str(_require(dom, "env_prefix", "domain")).strip()
|
|
172
|
+
if not env_prefix.isidentifier() or env_prefix != env_prefix.upper():
|
|
173
|
+
raise DomainError(f"domain.env_prefix {env_prefix!r} must be an UPPERCASE identifier")
|
|
174
|
+
|
|
175
|
+
db_raw = _require(data, "db", str(path or source))
|
|
176
|
+
db = DbDeclaration(
|
|
177
|
+
backend=str(db_raw.get("backend", "postgresql")),
|
|
178
|
+
name_env=str(db_raw.get("name_env", f"{env_prefix}_PG_DATABASE")),
|
|
179
|
+
dsn_env=str(db_raw.get("dsn_env", f"{env_prefix}_DATABASE_URL")),
|
|
180
|
+
sqlite_path_env=str(db_raw.get("sqlite_path_env", f"{env_prefix}_DB_PATH")),
|
|
181
|
+
databases=_tuple(db_raw.get("databases"), "db.databases"),
|
|
182
|
+
migrations=_tuple(db_raw.get("migrations"), "db.migrations"),
|
|
183
|
+
)
|
|
184
|
+
sens_raw = data.get("sensitivity") or {}
|
|
185
|
+
sensitivity = SensitivityDeclaration(
|
|
186
|
+
column=str(sens_raw.get("column", "classification")),
|
|
187
|
+
labels_file=str(sens_raw.get("labels_file", "")),
|
|
188
|
+
default=str(sens_raw.get("default", "public")),
|
|
189
|
+
egress_restricted=_tuple(sens_raw.get("egress_restricted"), "sensitivity.egress_restricted"),
|
|
190
|
+
levels=_tuple(sens_raw.get("levels"), "sensitivity.levels"),
|
|
191
|
+
)
|
|
192
|
+
paths = data.get("paths") or {}
|
|
193
|
+
dashboard = data.get("dashboard") or {}
|
|
194
|
+
kanban = data.get("kanban") or {}
|
|
195
|
+
port = dashboard.get("port", 5050)
|
|
196
|
+
if not isinstance(port, int) or isinstance(port, bool) or not (1 <= port <= 65535):
|
|
197
|
+
raise DomainError(f"dashboard.port {port!r} must be an integer in 1..65535")
|
|
198
|
+
return Domain(
|
|
199
|
+
key=key,
|
|
200
|
+
name=str(dom.get("name", key)),
|
|
201
|
+
env_prefix=env_prefix,
|
|
202
|
+
root=root,
|
|
203
|
+
source=source,
|
|
204
|
+
path=path,
|
|
205
|
+
db=db,
|
|
206
|
+
sensitivity=sensitivity,
|
|
207
|
+
data_dir=str(paths.get("data", "data")),
|
|
208
|
+
forge_dirs=_tuple(paths.get("forge", BUILTIN_DEFAULT["paths"]["forge"]), "paths.forge"),
|
|
209
|
+
components=str(data.get("components", "args/component_registry.yaml")),
|
|
210
|
+
reflex_packs=_tuple((data.get("reflexes") or {}).get("packs"), "reflexes.packs"),
|
|
211
|
+
dashboard_port=port,
|
|
212
|
+
dashboard_blueprints=str(dashboard.get("blueprints", "registry")),
|
|
213
|
+
kanban_board=str(kanban.get("board", key)),
|
|
214
|
+
kanban_external_repos=str(kanban.get("external_repos", "args/kanban_external_repos.yaml")),
|
|
215
|
+
mcp_servers=_tuple((data.get("mcp") or {}).get("servers"), "mcp.servers"),
|
|
216
|
+
trust=dict(data.get("trust") or {}),
|
|
217
|
+
ci=dict(data.get("ci") or {}),
|
|
218
|
+
raw=dict(data),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _read_yaml(path: Path) -> Mapping[str, Any]:
|
|
223
|
+
import yaml # local import: keep module import free of third-party deps
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
227
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
228
|
+
raise DomainError(f"{path}: cannot read declaration ({exc})") from exc
|
|
229
|
+
if loaded is None:
|
|
230
|
+
raise DomainError(f"{path}: declaration is empty")
|
|
231
|
+
return loaded
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_domain(
|
|
235
|
+
path: str | os.PathLike[str] | None = None,
|
|
236
|
+
*,
|
|
237
|
+
anchor: str | os.PathLike[str] | None = None,
|
|
238
|
+
require: bool | None = None,
|
|
239
|
+
) -> Domain:
|
|
240
|
+
"""Load the declaration that governs this process.
|
|
241
|
+
|
|
242
|
+
``path`` forces a specific file (tests, tooling). Otherwise the file is
|
|
243
|
+
``<repo_root(anchor)>/icdev_domain.yaml``. When absent, the builtin IT
|
|
244
|
+
default is returned unless ``require`` (or ``$ICDEV_REQUIRE_DOMAIN``) says
|
|
245
|
+
to refuse.
|
|
246
|
+
"""
|
|
247
|
+
if path is not None:
|
|
248
|
+
p = Path(path).resolve()
|
|
249
|
+
if not p.is_file():
|
|
250
|
+
raise DomainError(f"{p}: declaration not found")
|
|
251
|
+
return parse_domain(_read_yaml(p), root=p.parent, source="file", path=p)
|
|
252
|
+
|
|
253
|
+
root = repo_root(anchor)
|
|
254
|
+
p = root / DOMAIN_FILE
|
|
255
|
+
if p.is_file():
|
|
256
|
+
return parse_domain(_read_yaml(p), root=root, source="file", path=p)
|
|
257
|
+
|
|
258
|
+
if require is None:
|
|
259
|
+
require = os.environ.get(REQUIRE_DOMAIN_ENV, "").strip().lower() in ("1", "true", "yes")
|
|
260
|
+
if require:
|
|
261
|
+
raise DomainError(
|
|
262
|
+
f"no {DOMAIN_FILE} at {root} and {REQUIRE_DOMAIN_ENV} is set — "
|
|
263
|
+
"this deployment refuses to run undeclared"
|
|
264
|
+
)
|
|
265
|
+
return parse_domain(BUILTIN_DEFAULT, root=root, source="builtin_default", path=None)
|
icdev/core/paths.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# CUI // SP-CTI
|
|
2
|
+
"""One repository-root resolver for every ICDEV parent.
|
|
3
|
+
|
|
4
|
+
Three resolvers existed before this module and agreed only by accident:
|
|
5
|
+
``icdev/_paths.py`` (``ICDEV_PROJECT_ROOT`` -> walk up for ``pyproject.toml``
|
|
6
|
+
-> ``icdev/data``), ``tools/llm/config_path.py`` (``ICDEV_LLM_CONFIG`` -> walk
|
|
7
|
+
up -> packaged copy) and ``tools/db/storage.py::_resolve_repo_base``
|
|
8
|
+
(``pyproject.toml`` / ``.git`` marker, with a name-based fallback). Each public
|
|
9
|
+
name survives as a thin delegate onto this module, so no call site changes.
|
|
10
|
+
|
|
11
|
+
Resolution order, documented once::
|
|
12
|
+
|
|
13
|
+
1. $ICDEV_PROJECT_ROOT explicit override (must be a directory)
|
|
14
|
+
2. the SOURCE checkout that holds the calling file — the nearest ancestor
|
|
15
|
+
of ``anchor`` carrying icdev_domain.yaml, pyproject.toml or .git
|
|
16
|
+
3. the nearest icdev_domain.yaml walking up from the CURRENT DIRECTORY —
|
|
17
|
+
the pip-installed case, where the kernel lives in site-packages and is
|
|
18
|
+
serving whichever parent the process was started in
|
|
19
|
+
4. the packaged fallback: the ``icdev/`` package directory itself
|
|
20
|
+
|
|
21
|
+
Step 2 comes BEFORE step 3 on purpose, and it is a deliberate deviation from
|
|
22
|
+
the first draft of the design. A session's shell resets its working directory
|
|
23
|
+
to the main checkout after every command, and several worktrees of the same
|
|
24
|
+
repository are live at once on one machine; a source checkout that took its
|
|
25
|
+
root from CWD would silently bind a worktree's code to another checkout's
|
|
26
|
+
data — the exact cross-load the identity check exists to refuse. CWD is
|
|
27
|
+
consulted only when the code itself is not in a source checkout.
|
|
28
|
+
|
|
29
|
+
A "source checkout" is recognised by a MARKER FILE, never by a directory
|
|
30
|
+
NAME: ``_resolve_repo_base`` used to compare names and over-walked on GitHub
|
|
31
|
+
Actions, whose workspace is ``/home/runner/work/icdev/icdev``.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Iterable
|
|
39
|
+
|
|
40
|
+
DOMAIN_FILE = "icdev_domain.yaml"
|
|
41
|
+
PROJECT_ROOT_ENV = "ICDEV_PROJECT_ROOT"
|
|
42
|
+
|
|
43
|
+
#: Markers that identify a source checkout, most specific first.
|
|
44
|
+
SOURCE_MARKERS: tuple[str, ...] = (DOMAIN_FILE, "pyproject.toml", ".git")
|
|
45
|
+
|
|
46
|
+
_ICDEV_PKG_DIR = Path(__file__).resolve().parent.parent # icdev/
|
|
47
|
+
_PACKAGED_DATA_DIR = _ICDEV_PKG_DIR / "data"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_installed(path: Path) -> bool:
|
|
51
|
+
"""True when ``path`` lives under site-packages or the interpreter prefix."""
|
|
52
|
+
p = str(path).replace("\\", "/").lower()
|
|
53
|
+
if "site-packages" in p or "dist-packages" in p:
|
|
54
|
+
return True
|
|
55
|
+
try:
|
|
56
|
+
prefix = str(Path(sys.prefix).resolve()).replace("\\", "/").lower()
|
|
57
|
+
except OSError: # pragma: no cover — sys.prefix always resolves in practice
|
|
58
|
+
return False
|
|
59
|
+
return bool(prefix) and p.startswith(prefix + "/")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _walk_up(start: Path, markers: Iterable[str]) -> Path | None:
|
|
63
|
+
"""Return the nearest directory at or above ``start`` holding any marker."""
|
|
64
|
+
markers = tuple(markers)
|
|
65
|
+
start = start if start.is_dir() else start.parent
|
|
66
|
+
for candidate in (start, *start.parents):
|
|
67
|
+
for m in markers:
|
|
68
|
+
if (candidate / m).exists():
|
|
69
|
+
return candidate
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_domain_file(start: Path | None = None) -> Path | None:
|
|
74
|
+
"""Return the nearest ``icdev_domain.yaml`` at or above ``start`` (default CWD)."""
|
|
75
|
+
base = _walk_up(Path(start or Path.cwd()).resolve(), (DOMAIN_FILE,))
|
|
76
|
+
return None if base is None else base / DOMAIN_FILE
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def repo_root(anchor: str | os.PathLike[str] | None = None) -> Path:
|
|
80
|
+
"""Return the repository root this process should treat as home.
|
|
81
|
+
|
|
82
|
+
``anchor`` is the calling module's ``__file__`` (or any path inside the
|
|
83
|
+
checkout). When omitted, the ``icdev`` package directory is used, which is
|
|
84
|
+
correct for the kernel's own modules and for the wheel.
|
|
85
|
+
"""
|
|
86
|
+
env = os.environ.get(PROJECT_ROOT_ENV, "").strip()
|
|
87
|
+
if env:
|
|
88
|
+
p = Path(env).expanduser()
|
|
89
|
+
if p.is_dir():
|
|
90
|
+
return p.resolve()
|
|
91
|
+
|
|
92
|
+
anchor_path = Path(anchor).resolve() if anchor is not None else _ICDEV_PKG_DIR
|
|
93
|
+
if not _is_installed(anchor_path):
|
|
94
|
+
found = _walk_up(anchor_path, SOURCE_MARKERS)
|
|
95
|
+
if found is not None:
|
|
96
|
+
return found
|
|
97
|
+
|
|
98
|
+
cwd_root = _walk_up(Path.cwd().resolve(), (DOMAIN_FILE,))
|
|
99
|
+
if cwd_root is not None:
|
|
100
|
+
return cwd_root
|
|
101
|
+
|
|
102
|
+
return _ICDEV_PKG_DIR
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def data_path(name: str, anchor: str | os.PathLike[str] | None = None) -> Path:
|
|
106
|
+
"""Resolve a FORGE data directory (args, context, goals, hardprompts, ...).
|
|
107
|
+
|
|
108
|
+
Falls back to the packaged ``icdev/data/<name>`` when the repository root
|
|
109
|
+
has no such directory (the wheel case), then to a CWD-relative path so a
|
|
110
|
+
caller that only wants to CREATE the directory still gets a sane answer.
|
|
111
|
+
"""
|
|
112
|
+
root = repo_root(anchor)
|
|
113
|
+
candidate = root / name
|
|
114
|
+
if candidate.is_dir():
|
|
115
|
+
return candidate
|
|
116
|
+
packaged = _PACKAGED_DATA_DIR / name
|
|
117
|
+
if packaged.is_dir():
|
|
118
|
+
return packaged
|
|
119
|
+
return Path(name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def config_path(
|
|
123
|
+
relpath: str | os.PathLike[str],
|
|
124
|
+
*,
|
|
125
|
+
env: str | None = None,
|
|
126
|
+
anchor: str | os.PathLike[str] | None = None,
|
|
127
|
+
packaged: str | os.PathLike[str] | None = None,
|
|
128
|
+
) -> Path:
|
|
129
|
+
"""Resolve one configuration file: ``$env`` -> ``<repo_root>/relpath`` -> packaged.
|
|
130
|
+
|
|
131
|
+
``relpath`` is relative to the repository root (``args/llm_config.yaml``).
|
|
132
|
+
``packaged`` is the last-resort copy shipped inside the package; when not
|
|
133
|
+
given, ``icdev/data/<relpath>`` is tried and finally ``<repo_root>/relpath``
|
|
134
|
+
is returned even if it does not exist, so the caller's error names the
|
|
135
|
+
path that SHOULD have been there.
|
|
136
|
+
"""
|
|
137
|
+
if env:
|
|
138
|
+
override = os.environ.get(env, "").strip()
|
|
139
|
+
if override:
|
|
140
|
+
return Path(override).expanduser().resolve()
|
|
141
|
+
rel = Path(relpath)
|
|
142
|
+
candidate = repo_root(anchor) / rel
|
|
143
|
+
if candidate.is_file():
|
|
144
|
+
return candidate
|
|
145
|
+
if packaged is not None:
|
|
146
|
+
return Path(packaged)
|
|
147
|
+
pkg = _PACKAGED_DATA_DIR / rel
|
|
148
|
+
if pkg.is_file():
|
|
149
|
+
return pkg
|
|
150
|
+
return candidate
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def describe(anchor: str | os.PathLike[str] | None = None) -> dict:
|
|
154
|
+
"""Diagnostics for ``icdev status``: which root won, and why."""
|
|
155
|
+
env = os.environ.get(PROJECT_ROOT_ENV, "").strip()
|
|
156
|
+
anchor_path = Path(anchor).resolve() if anchor is not None else _ICDEV_PKG_DIR
|
|
157
|
+
installed = _is_installed(anchor_path)
|
|
158
|
+
source = None if installed else _walk_up(anchor_path, SOURCE_MARKERS)
|
|
159
|
+
cwd_root = _walk_up(Path.cwd().resolve(), (DOMAIN_FILE,))
|
|
160
|
+
root = repo_root(anchor)
|
|
161
|
+
if env and Path(env).expanduser().is_dir():
|
|
162
|
+
how = "env"
|
|
163
|
+
elif source is not None and source == root:
|
|
164
|
+
how = "source_checkout"
|
|
165
|
+
elif cwd_root is not None and cwd_root == root:
|
|
166
|
+
how = "cwd_domain_file"
|
|
167
|
+
else:
|
|
168
|
+
how = "packaged"
|
|
169
|
+
return {
|
|
170
|
+
"root": str(root),
|
|
171
|
+
"source": how,
|
|
172
|
+
"env_var": PROJECT_ROOT_ENV,
|
|
173
|
+
"env_value": env or None,
|
|
174
|
+
"anchor": str(anchor_path),
|
|
175
|
+
"anchor_installed": installed,
|
|
176
|
+
"source_candidate": str(source) if source else None,
|
|
177
|
+
"cwd_candidate": str(cwd_root) if cwd_root else None,
|
|
178
|
+
"domain_file": str(root / DOMAIN_FILE) if (root / DOMAIN_FILE).is_file() else None,
|
|
179
|
+
}
|