aigx 1.2.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.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: aigx
3
+ Version: 1.2.0
4
+ Summary: Reference validator + resolver for AIGX (AI Genome Exchange) — the open context format for AI coding agents. Zero dependencies.
5
+ Author-email: Grégory Parisotto <gregory@feex.it>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Lolner95/AIGX
8
+ Project-URL: Specification, https://github.com/Lolner95/AIGX/blob/main/standard/AIGX-1.1.md
9
+ Project-URL: Source, https://github.com/Lolner95/AIGX
10
+ Project-URL: Issues, https://github.com/Lolner95/AIGX/issues
11
+ Keywords: aigx,ai-genome-exchange,ai,agents,context,lint,validator,llm
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Software Development :: Build Tools
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # aigx-lint
25
+
26
+ A tiny, **zero-dependency** (Python 3.8+ stdlib) validator and resolver for AIGX genomes. It exists to
27
+ kill the two most common objections to a centralized context format - *"it rots"* and *"it won't scale"* -
28
+ by making both mechanically false.
29
+
30
+ ## Why
31
+
32
+ - **It can't rot silently.** `aigx-lint` checks the genome against the **actual repository**: every
33
+ `<file path>` must still exist on disk, and every `<check>` id must resolve to a real `<rule>`. Run it in
34
+ CI or a pre-commit hook and a moved/renamed file **fails the build** until its entry is fixed - the same
35
+ discipline teams already use for `CODEOWNERS` and `tsconfig` path maps.
36
+ - **It scales by resolution, not ingestion.** `--resolve PATH` returns just one file's entry, so an agent's
37
+ context cost is **O(1) per edited file**, independent of index size. A 50,000-entry index is one lookup.
38
+ - **It understands hierarchical genomes.** Every `.aigx/` directory under the root is discovered; each
39
+ `files.aigx` indexes its own subtree (see [SPEC §8](../../SPEC.md#8-scaling-to-large-repositories--monorepos)).
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ # Validate the genome(s) under the current repo. Exits non-zero on errors (CI-friendly).
45
+ python aigx_lint.py --root .
46
+
47
+ # Print just one file's boundary entry - constant-cost lookup an agent/MCP can call.
48
+ python aigx_lint.py --resolve src/features/meetings/bookMeeting.ts --root .
49
+
50
+ # Machine-readable output for MCP servers, editor extensions, and agent wrappers.
51
+ python aigx_lint.py --resolve src/features/meetings/bookMeeting.ts --root . --format json
52
+
53
+ # Summary: genomes, rules, entries, and the all-important forbid scarcity.
54
+ python aigx_lint.py --stats --root .
55
+ ```
56
+
57
+ `--resolve` returns exit code `0` when the target file exists even if the genome has no matching
58
+ `<file>` entry; that is an informational "no boundary indexed yet" result, not a tool failure. It returns
59
+ exit code `2` when the target path itself does not exist.
60
+
61
+ ## What validation catches
62
+
63
+ | Check | Why it matters |
64
+ |---|---|
65
+ | `<file path>` exists on disk | catches renamed/moved/deleted files → the genome can't go stale unnoticed |
66
+ | every `<check>` id resolves to a `<rule>` | catches dangling references when a rule is renamed/removed |
67
+ | duplicate `<file>` entries (warning) | catches copy-paste drift across shards |
68
+
69
+ ## JSON shape
70
+
71
+ JSON output is intentionally small and stable so MCP bridges can inject AIGX context without scraping XML:
72
+
73
+ ```json
74
+ {
75
+ "found": true,
76
+ "path": "src/features/meetings/bookMeeting.ts",
77
+ "domain": "meetings",
78
+ "role": "Book a meeting (validate slot + contact)",
79
+ "forbid": { "priority": "CRIT", "text": "NEVER import internal suppliers modules" },
80
+ "gotcha": { "priority": null, "text": "Use the public suppliers API for contact email" },
81
+ "checks": ["ARCH-no-deep-imports", "DATA-integer-cents"],
82
+ "block": "<file path=\"...\">...</file>"
83
+ }
84
+ ```
85
+
86
+ When there is no indexed boundary for an existing file, `found` is `false` and `exists` is `true`.
87
+
88
+ > Try it on [`examples/sourcing-app/`](../../examples/sourcing-app/): `--stats` and `--resolve` work
89
+ > directly; `--validate` will (correctly!) report the `src/**` paths as missing, because that example ships
90
+ > only the genome, not the application source - which is exactly the "moved/missing file" signal the linter
91
+ > is built to catch. Run it against a real checkout to see it pass clean.
92
+
93
+ ## CI examples
94
+
95
+ **GitHub Actions:**
96
+
97
+ ```yaml
98
+ name: aigx
99
+ on: [push, pull_request]
100
+ jobs:
101
+ lint-genome:
102
+ runs-on: ubuntu-latest
103
+ steps:
104
+ - uses: actions/checkout@v4
105
+ - uses: actions/setup-python@v5
106
+ with: { python-version: "3.x" }
107
+ - run: python tools/aigx-lint/aigx_lint.py --root .
108
+ ```
109
+
110
+ **GitLab CI:**
111
+
112
+ ```yaml
113
+ aigx-lint:
114
+ image: python:3.12-slim
115
+ script:
116
+ - python tools/aigx-lint/aigx_lint.py --root .
117
+ rules:
118
+ - if: '$CI_PIPELINE_SOURCE == "push"'
119
+ ```
120
+
121
+ **Bitbucket Pipelines:**
122
+
123
+ ```yaml
124
+ pipelines:
125
+ default:
126
+ - step:
127
+ name: Lint AIGX genome
128
+ image: python:3.12-slim
129
+ script:
130
+ - python tools/aigx-lint/aigx_lint.py --root .
131
+ ```
132
+
133
+ **Pre-commit hook** (catches issues before they reach CI):
134
+
135
+ ```bash
136
+ # Install once: copy to .git/hooks/pre-commit and make it executable
137
+ #!/usr/bin/env bash
138
+ set -e
139
+ python tools/aigx-lint/aigx_lint.py --root .
140
+ ```
141
+
142
+ ```bash
143
+ chmod +x .git/hooks/pre-commit
144
+ ```
145
+
146
+ Or use [`pre-commit`](https://pre-commit.com) framework with a local hook:
147
+
148
+ ```yaml
149
+ # .pre-commit-config.yaml
150
+ repos:
151
+ - repo: local
152
+ hooks:
153
+ - id: aigx-lint
154
+ name: Lint AIGX genome
155
+ entry: python tools/aigx-lint/aigx_lint.py --root .
156
+ language: python
157
+ pass_filenames: false
158
+ always_run: true
159
+ ```
160
+
161
+ That's the whole answer to "decoupled docs rot": don't decouple *and walk away* - decouple *and lint*.
@@ -0,0 +1,7 @@
1
+ aigx_lint.py,sha256=rtr2g9hIAf2M8F7Rf3kC79_sDUaHMNysrPlLevFdxNQ,14051
2
+ aigx-1.2.0.dist-info/licenses/LICENSE,sha256=EGjc0Qvd9zdIa893WHxwSEcvyzp8ZJMkyg-hwQ4Fla8,1729
3
+ aigx-1.2.0.dist-info/METADATA,sha256=eX-DMjRHhYMzNTTmpHFGi4isbEuxtZsUPPZ7VibBrgI,5979
4
+ aigx-1.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ aigx-1.2.0.dist-info/entry_points.txt,sha256=ovkIxLgwsFMUQu3H_7Mj8gU4Pf3m1OUfo14PRRWAFPo,67
6
+ aigx-1.2.0.dist-info/top_level.txt,sha256=BA8UTPGqGxH_3ySRxQrvRPMzpzOZxm_uauecPVTEZPA,10
7
+ aigx-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ aigx = aigx_lint:main
3
+ aigx-lint = aigx_lint:main
@@ -0,0 +1,36 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Grégory Parisotto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ------------------------------------------------------------------------------
24
+ SCOPE OF THIS LICENSE
25
+
26
+ This MIT License covers the AIGX reference TOOLS and source code: the `aigx`
27
+ CLI (packages/ and crates/), tools/aigx-lint, tools/aigx-sync, tools/aigx-mcp,
28
+ tools/aigx-export, bin/create-aigx, pyproject.toml, and all other code in this
29
+ repository.
30
+
31
+ The AIGX SPECIFICATION TEXT — the `standard/` directory and the informal
32
+ SPEC.md — is licensed separately under Creative Commons Attribution 4.0
33
+ International (CC-BY-4.0). See standard/LICENSE.
34
+
35
+ Open specification + permissive tools: anyone may reimplement AIGX in any
36
+ language without restriction.
@@ -0,0 +1 @@
1
+ aigx_lint
aigx_lint.py ADDED
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env python3
2
+ """aigx-lint - validate and resolve AIGX genomes. Zero dependencies (Python 3.8+ stdlib).
3
+
4
+ A genome decoupled from source is only a liability if it can rot silently. aigx-lint makes that
5
+ impossible: it checks the genome against the ACTUAL repository, so a moved/renamed file or a dangling
6
+ rule reference fails the check (wire it into CI / a pre-commit hook). It also resolves a single file's
7
+ boundary entry in O(1), so an agent never has to ingest a whole index - the answer to monorepo scale.
8
+
9
+ This is one of the two reference VALIDATORS for AIGX (the other is the `aigx` CLI / @aigx/lint in
10
+ packages/). The two MUST report the same errors for the same genome (meta-genome rule ARCH-validator-parity);
11
+ the conformance suite in tests/conformance/ checks that they agree. It implements validator rules V1-V7
12
+ and security rule S2 of standard/AIGX-1.1.md.
13
+
14
+ Supports HIERARCHICAL genomes: every `.aigx/` directory under the root is discovered (honoring
15
+ `.aigxignore` and `--exclude`); each `files.aigx` indexes its own subtree; `<file path>` values resolve
16
+ relative to the repository root.
17
+
18
+ Usage:
19
+ aigx_lint.py [--root DIR] [--exclude DIR ...] [--format text|json]
20
+ # validate the genome(s) under DIR (default: cwd). Exit !=0 on errors.
21
+ aigx_lint.py --resolve PATH [--root DIR] [--format text|json]
22
+ # print just the <file> entry for PATH (constant-cost lookup)
23
+ aigx_lint.py --stats [--root DIR] [--format text|json]
24
+ # print a summary (genomes, rules, entries, forbids)
25
+ aigx_lint.py --version # print the tool version
26
+
27
+ Exit codes: 0 = ok, 1 = validation errors, 2 = usage / not found.
28
+ """
29
+
30
+ __version__ = "1.2.0"
31
+ import argparse
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ RULE_RE = re.compile(r'<rule\s+id="([^"]+)"')
38
+ FILE_BLOCK_RE = re.compile(r"<file\b[^>]*>.*?</file>", re.DOTALL)
39
+ PATH_ATTR_RE = re.compile(r'<file\b[^>]*\bpath="([^"]+)"')
40
+ DOMAIN_ATTR_RE = re.compile(r'<file\b[^>]*\bdomain="([^"]+)"')
41
+ CHECK_RE = re.compile(r"<check>(.*?)</check>", re.DOTALL)
42
+ FORBID_RE = re.compile(r"<forbid\b")
43
+ FORBID_TEXT_RE = re.compile(r"<forbid\b([^>]*)>(.*?)</forbid>", re.DOTALL)
44
+ GOTCHA_TEXT_RE = re.compile(r"<gotcha\b([^>]*)>(.*?)</gotcha>", re.DOTALL)
45
+ ROLE_TEXT_RE = re.compile(r"<role>(.*?)</role>", re.DOTALL)
46
+ PRI_ATTR_RE = re.compile(r'\bpri="([^"]+)"')
47
+ COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
48
+ SKIP_DIRS = {".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"}
49
+
50
+ # Files in a genome dir that are NOT concern files (carry no <rule id>).
51
+ NON_CONCERN = {"files.aigx", "protocol.aigx", "product.aigx"}
52
+
53
+
54
+ def to_posix(p):
55
+ return p.replace("\\", "/")
56
+
57
+
58
+ def escapes_root(p):
59
+ """True if a file-entry path tries to escape the repository root (security S2)."""
60
+ q = to_posix(p)
61
+ return bool(re.search(r"(^|/)\.\.(/|$)", q)) or q.startswith("/") or bool(re.match(r"^[A-Za-z]:", q))
62
+
63
+
64
+ def load_ignores(root, cli_excludes):
65
+ """Repo-relative dir prefixes to skip: --exclude flags plus .aigxignore lines ('#' comments)."""
66
+ ig = list(cli_excludes or [])
67
+ f = os.path.join(root, ".aigxignore")
68
+ if os.path.exists(f):
69
+ for line in read(f).splitlines():
70
+ s = line.split("#", 1)[0].strip().rstrip("/")
71
+ if s:
72
+ ig.append(s)
73
+ return [to_posix(x) for x in ig]
74
+
75
+
76
+ def find_aigx_dirs(root, ignores=()):
77
+ out = []
78
+ for dp, dn, _fn in os.walk(root):
79
+ dn[:] = [d for d in dn if d not in SKIP_DIRS]
80
+ if os.path.basename(dp) == ".aigx":
81
+ rel = to_posix(os.path.relpath(dp, root))
82
+ if any(rel == ig or rel.startswith(ig + "/") for ig in ignores):
83
+ continue
84
+ out.append(dp)
85
+ return sorted(out)
86
+
87
+
88
+ def read(path):
89
+ with open(path, encoding="utf-8") as f:
90
+ return f.read()
91
+
92
+
93
+ def semantic_text(path):
94
+ return COMMENT_RE.sub("", read(path))
95
+
96
+
97
+ def text_content(s):
98
+ return re.sub(r"\s+", " ", s).strip()
99
+
100
+
101
+ def priority(attrs):
102
+ m = PRI_ATTR_RE.search(attrs or "")
103
+ return m.group(1) if m else None
104
+
105
+
106
+ def entry_payload(entry):
107
+ block = entry["block"]
108
+ role = ROLE_TEXT_RE.search(block)
109
+ forbid = FORBID_TEXT_RE.search(block)
110
+ gotcha = GOTCHA_TEXT_RE.search(block)
111
+ return {
112
+ "path": entry["path"],
113
+ "domain": entry.get("domain"),
114
+ "role": text_content(role.group(1)) if role else None,
115
+ "forbid": {
116
+ "priority": priority(forbid.group(1)),
117
+ "text": text_content(forbid.group(2)),
118
+ } if forbid else None,
119
+ "gotcha": {
120
+ "priority": priority(gotcha.group(1)),
121
+ "text": text_content(gotcha.group(2)),
122
+ } if gotcha else None,
123
+ "checks": entry["checks"],
124
+ "block": block,
125
+ }
126
+
127
+
128
+ def collect_rules(aigx_dir):
129
+ """Return (unique_ids:set, dup_ids:list) for one genome dir, across its concern files.
130
+
131
+ A rule id repeated within a single genome (even across two concern files) is a duplicate (V4)."""
132
+ seen, dups = set(), []
133
+ for fn in sorted(os.listdir(aigx_dir)):
134
+ if fn.endswith(".aigx") and fn not in NON_CONCERN:
135
+ for rid in RULE_RE.findall(semantic_text(os.path.join(aigx_dir, fn))):
136
+ if rid in seen:
137
+ dups.append(rid)
138
+ else:
139
+ seen.add(rid)
140
+ return seen, dups
141
+
142
+
143
+ def parse_entries(files_aigx_path):
144
+ """Return list of dicts: {path, checks:[ids], has_forbid, block}."""
145
+ if not os.path.exists(files_aigx_path):
146
+ return []
147
+ text = semantic_text(files_aigx_path)
148
+ entries = []
149
+ for block in FILE_BLOCK_RE.findall(text):
150
+ m = PATH_ATTR_RE.search(block)
151
+ if not m:
152
+ continue
153
+ dm = DOMAIN_ATTR_RE.search(block)
154
+ checks = []
155
+ cm = CHECK_RE.search(block)
156
+ if cm:
157
+ # gloss form may nest <c id="X">; otherwise it's a bare id list
158
+ ids = re.findall(r'id="([^"]+)"', cm.group(1))
159
+ checks = ids if ids else cm.group(1).split()
160
+ entries.append({
161
+ "path": m.group(1).strip(),
162
+ "domain": dm.group(1).strip() if dm else None,
163
+ "checks": checks,
164
+ "has_forbid": bool(FORBID_RE.search(block)),
165
+ "block": block,
166
+ })
167
+ return entries
168
+
169
+
170
+ def gather(root, ignores=()):
171
+ """Gather genomes. Returns (genomes, all_rule_ids). genome = {dir, rules, dups, entries}."""
172
+ genomes = []
173
+ all_rules = set()
174
+ for d in find_aigx_dirs(root, ignores):
175
+ rules, dups = collect_rules(d)
176
+ all_rules |= rules
177
+ entries = parse_entries(os.path.join(d, "files.aigx"))
178
+ genomes.append({"dir": d, "rules": rules, "dups": dups, "entries": entries})
179
+ return genomes, all_rules
180
+
181
+
182
+ def cmd_validate(root, fmt="text", ignores=()):
183
+ genomes, all_rules = gather(root, ignores)
184
+ if not genomes:
185
+ print(f"aigx-lint: no .aigx/ genome found under {root}", file=sys.stderr)
186
+ return 2
187
+ errors, warnings = [], []
188
+ seen_paths = {}
189
+ n_entries = n_forbid = 0
190
+ for g in genomes:
191
+ rel = to_posix(os.path.relpath(g["dir"], root))
192
+ # V4: duplicate rule ids within a genome
193
+ for rid in g["dups"]:
194
+ errors.append(f'[{rel}] duplicate <rule id="{rid}"> (rule ids MUST be unique)')
195
+ # V1: required files / minimum content
196
+ if not g["rules"]:
197
+ errors.append(f"[{rel}] no <rule id> found in any concern file (need ≥1)")
198
+ if not os.path.exists(os.path.join(g["dir"], "protocol.aigx")):
199
+ errors.append(f"[{rel}] missing required protocol.aigx")
200
+ if not os.path.exists(os.path.join(g["dir"], "files.aigx")):
201
+ errors.append(f"[{rel}] missing required files.aigx")
202
+ elif not g["entries"]:
203
+ errors.append(f"[{rel}] files.aigx has no <file> entries (need ≥1)")
204
+
205
+ for e in g["entries"]:
206
+ n_entries += 1
207
+ if e["has_forbid"]:
208
+ n_forbid += 1
209
+ # S2: a path that escapes the repo root is rejected (and we skip its other checks)
210
+ if escapes_root(e["path"]):
211
+ errors.append(f'[{rel}] <file path="{e["path"]}"> escapes the repository root')
212
+ continue
213
+ # V3: the indexed file must still exist (catches renames/moves => no silent rot)
214
+ if not os.path.exists(os.path.join(root, e["path"])):
215
+ errors.append(f"[{rel}] file entry path does not exist: {e['path']}")
216
+ # V2: every <check> id must resolve to a real rule somewhere in the genome set
217
+ for cid in e["checks"]:
218
+ if cid not in all_rules:
219
+ errors.append(f"[{rel}] <check> id '{cid}' (in {e['path']}) does not resolve")
220
+ # V5: duplicate path entries (warning)
221
+ if e["path"] in seen_paths:
222
+ warnings.append(f"duplicate <file> entry for {e['path']} (in {rel} and {seen_paths[e['path']]})")
223
+ else:
224
+ seen_paths[e["path"]] = rel
225
+
226
+ # V6: forbid density high enough to dilute salience (warning)
227
+ forbid_percent = round(100 * n_forbid / n_entries) if n_entries else 0
228
+ if forbid_percent > 40:
229
+ warnings.append(f"<forbid> density is {forbid_percent}% of entries — scarcity preserves salience")
230
+
231
+ status = "FAIL" if errors else "ok"
232
+ if fmt == "json":
233
+ print(json.dumps({
234
+ "ok": not errors,
235
+ "status": status,
236
+ "genomes": len(genomes),
237
+ "rules": len(all_rules),
238
+ "file_entries": n_entries,
239
+ "errors": errors,
240
+ "warnings": warnings,
241
+ }, indent=2, sort_keys=True))
242
+ else:
243
+ for w in warnings:
244
+ print(f" warning: {w}")
245
+ for er in errors:
246
+ print(f" error: {er}")
247
+ print(f"\naigx-lint: {len(genomes)} genome(s), {len(all_rules)} rules, {n_entries} file entries "
248
+ f"-> {status} ({len(errors)} error(s), {len(warnings)} warning(s))")
249
+ return 1 if errors else 0
250
+
251
+
252
+ def cmd_resolve(root, target, fmt="text", ignores=()):
253
+ target = to_posix(target).lstrip("./")
254
+ genomes, _ = gather(root, ignores)
255
+ for g in genomes:
256
+ for e in g["entries"]:
257
+ if to_posix(e["path"]) == target:
258
+ if fmt == "json":
259
+ payload = entry_payload(e)
260
+ payload["found"] = True
261
+ print(json.dumps(payload, indent=2, sort_keys=True))
262
+ else:
263
+ print(e["block"])
264
+ return 0
265
+ exists = os.path.exists(os.path.join(root, target))
266
+ if fmt == "json":
267
+ print(json.dumps({
268
+ "found": False,
269
+ "path": target,
270
+ "exists": exists,
271
+ "message": f"no <file> entry for '{target}'",
272
+ }, indent=2, sort_keys=True))
273
+ else:
274
+ stream = sys.stdout if exists else sys.stderr
275
+ print(f"aigx-lint: no <file> entry for '{target}'", file=stream)
276
+ return 0 if exists else 2
277
+
278
+
279
+ def cmd_stats(root, fmt="text", ignores=()):
280
+ genomes, all_rules = gather(root, ignores)
281
+ if not genomes:
282
+ print(f"aigx-lint: no .aigx/ genome found under {root}", file=sys.stderr)
283
+ return 2
284
+ n_entries = sum(len(g["entries"]) for g in genomes)
285
+ n_forbid = sum(1 for g in genomes for e in g["entries"] if e["has_forbid"])
286
+ forbid_percent = (100 * n_forbid // n_entries) if n_entries else 0
287
+ if fmt == "json":
288
+ print(json.dumps({
289
+ "genomes": [{
290
+ "path": to_posix(os.path.relpath(g["dir"], root)),
291
+ "rules": len(g["rules"]),
292
+ "file_entries": len(g["entries"]),
293
+ } for g in genomes],
294
+ "total_rules": len(all_rules),
295
+ "file_entries": n_entries,
296
+ "forbids": n_forbid,
297
+ "forbid_percent": forbid_percent,
298
+ }, indent=2, sort_keys=True))
299
+ else:
300
+ print(f"genomes: {len(genomes)}")
301
+ for g in genomes:
302
+ print(f" - {to_posix(os.path.relpath(g['dir'], root))}: {len(g['rules'])} rules, {len(g['entries'])} entries")
303
+ print(f"total rules: {len(all_rules)}")
304
+ print(f"file entries: {n_entries}")
305
+ print(f"forbids: {n_forbid} ({forbid_percent}% of entries - keep this small)")
306
+ return 0
307
+
308
+
309
+ def main(argv=None):
310
+ if argv is None:
311
+ argv = sys.argv[1:]
312
+ # Accept subcommand-style aliases for parity with the npm/cargo `aigx` CLI
313
+ # (e.g. `aigx lint --root .`, `aigx resolve PATH`). Flag-style still works.
314
+ if argv and argv[0] in ("lint", "validate"):
315
+ argv = argv[1:]
316
+ elif len(argv) >= 2 and argv[0] == "resolve":
317
+ argv = ["--resolve", argv[1]] + argv[2:]
318
+ elif argv and argv[0] == "stats":
319
+ argv = ["--stats"] + argv[1:]
320
+ p = argparse.ArgumentParser(prog="aigx-lint", description="Validate and resolve AIGX genomes.")
321
+ p.add_argument("--root", default=".", help="repository root (default: current directory)")
322
+ p.add_argument("--exclude", action="append", default=[], metavar="DIR",
323
+ help="repo-relative dir to skip (repeatable); also reads .aigxignore")
324
+ p.add_argument("--resolve", metavar="PATH", help="print only the <file> entry for PATH (O(1) lookup)")
325
+ p.add_argument("--stats", action="store_true", help="print a summary of the genome(s)")
326
+ p.add_argument("--format", choices=("text", "json"), default="text", help="output format (default: text)")
327
+ p.add_argument("--version", action="version", version=f"aigx-lint {__version__}")
328
+ args = p.parse_args(argv)
329
+ root = os.path.abspath(args.root)
330
+ if not os.path.isdir(root):
331
+ print(f"aigx-lint: not a directory: {root}", file=sys.stderr)
332
+ return 2
333
+ ignores = load_ignores(root, args.exclude)
334
+ if args.resolve:
335
+ return cmd_resolve(root, args.resolve, args.format, ignores)
336
+ if args.stats:
337
+ return cmd_stats(root, args.format, ignores)
338
+ return cmd_validate(root, args.format, ignores)
339
+
340
+
341
+ if __name__ == "__main__":
342
+ sys.exit(main())