ecoportal-api-graphql 1.3.12 → 1.3.13

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,378 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ self_docs_scan.py -- Project self-docs OBSERVE stage (deterministic, zero-LLM core).
4
+
5
+ Skill (semantic refresh + Act): skills-library/project-self-docs/SKILL.md.
6
+
7
+ Runs FROM A TARGET REPO (the repo documenting itself), not from ep-ai-standards. It does the
8
+ DETERMINISTIC half of self-documentation so no interactive Claude tokens are spent on facts the
9
+ filesystem already knows:
10
+ - detect the repo type using the same signals as skills-library/discovery-manifest.yaml
11
+ - collect a structural fingerprint (top-level dirs, key config files, git head/branch/remote,
12
+ commit-activity clusters, existing .ai-assistance/code specs, CI files, doc files)
13
+ - compute a stable content hash per self-doc so refreshes are idempotent and the changelog
14
+ only records REAL changes
15
+ - emit a structured JSON "observation" the skill consumes to fill the self-doc set
16
+
17
+ It does NOT write project prose, summarise purpose, or judge architecture -- that semantic work is
18
+ the LLM skill's job (batched to Gemini per the token-frugality rule), gated by human review before
19
+ any egress. This scanner is the thermometer; the skill is the (human-gated) actuator.
20
+
21
+ HARD CONSTRAINTS:
22
+ - READ-ONLY by default. It never writes into the target repo except the self-doc index/changelog
23
+ under --write, and even then only to the self-docs directory (default docs/self-docs/).
24
+ - NO absolute paths are emitted into any committed artefact: paths are repo-relative.
25
+ - NO Confluence / network egress. Publish target is UNDECIDED (see SKILL.md); this script only
26
+ ever touches the local filesystem.
27
+
28
+ Usage:
29
+ python self_docs_scan.py # human summary of the observation (target = cwd)
30
+ python self_docs_scan.py --json # structured observation to stdout
31
+ python self_docs_scan.py --target <path> # observe a different LOCAL repo root (convenience
32
+ # only -- NEVER to generate another repo's self-docs
33
+ # from here; run the skill in that repo's own session)
34
+ python self_docs_scan.py --out docs/self-docs # self-docs dir (default docs/self-docs)
35
+ python self_docs_scan.py --write # (re)write the machine index + changelog only
36
+ """
37
+ import argparse
38
+ import hashlib
39
+ import json
40
+ import os
41
+ import re
42
+ import subprocess
43
+ import sys
44
+ from datetime import datetime, timezone
45
+
46
+ SCHEMA_VERSION = "1.1"
47
+
48
+ # Self-doc set: stable filenames the skill fills in. Keeping this list here (not only in the
49
+ # template) lets the deterministic scanner report which docs already exist and which are missing,
50
+ # and lets the index/changelog be generated without the LLM.
51
+ SELF_DOC_SET = [
52
+ ("OVERVIEW.md", "What the project is, who it serves, current priority, discovery & access"),
53
+ ("ARCHITECTURE.md", "Top-level structure, key components, how they fit"),
54
+ ("CONVENTIONS.md", "Coding style, branch naming, commit style, test conventions"),
55
+ ("INTEGRATIONS.md", "External services + data + permissions, upstream/downstream repos"),
56
+ ("STATUS.md", "Active work, migration state, known risks, open questions"),
57
+ ("COMPLIANCE.md", "ISO-27001/audit, data classes + PII, vendors, AI content, leak controls"),
58
+ ("OPERATIONS.md", "Infrastructure, public exposure + Cloudflare, DevOps audit, usage KPIs"),
59
+ ]
60
+
61
+ # Detection signals mirror skills-library/discovery-manifest.yaml. Order matters: first match wins.
62
+ # (file, ...) => all must exist; file_any => any of the globs; not_file => must be absent.
63
+ REPO_TYPE_RULES = [
64
+ ("aws-cdk", {"file": ["cdk.json"]}),
65
+ ("browser-extension", {"file": ["manifest.json"], "file_any": ["package.json", "Gruntfile.js", "webpack.config.js"]}),
66
+ ("ruby-gem", {"file_any": ["*.gemspec"]}),
67
+ ("rails-app", {"file": ["config/application.rb"]}),
68
+ ("ruby-scripts", {"file": ["Gemfile"], "not_file": ["*.gemspec", "config/application.rb"]}),
69
+ ("typescript-lib", {"file": ["package.json"], "not_file": ["cdk.json", "manifest.json"]}),
70
+ ]
71
+
72
+ KEY_CONFIG_FILES = [
73
+ "package.json", "Gemfile", "cdk.json", "manifest.json", "go.mod", "Cargo.toml",
74
+ "pyproject.toml", "requirements.txt", "tsconfig.json", ".eslintrc", ".eslintrc.json",
75
+ ".eslintrc.js", ".prettierrc", ".rubocop.yml", ".editorconfig",
76
+ ]
77
+ CI_FILES = [".gitlab-ci.yml", "Jenkinsfile", "Makefile"]
78
+
79
+
80
+ def _iso_now():
81
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
82
+
83
+
84
+ def _exists(root, rel):
85
+ return os.path.exists(os.path.join(root, rel))
86
+
87
+
88
+ def _glob_any(root, patterns):
89
+ import fnmatch
90
+ try:
91
+ top = os.listdir(root)
92
+ except OSError:
93
+ return False
94
+ for pat in patterns:
95
+ if any(fnmatch.fnmatch(name, pat) for name in top):
96
+ return True
97
+ return False
98
+
99
+
100
+ def detect_repo_type(root):
101
+ for type_name, rule in REPO_TYPE_RULES:
102
+ ok = True
103
+ for f in rule.get("file", []):
104
+ if not _exists(root, f):
105
+ ok = False
106
+ break
107
+ if ok and rule.get("file_any"):
108
+ ok = _glob_any(root, rule["file_any"])
109
+ if ok and rule.get("not_file"):
110
+ for f in rule["not_file"]:
111
+ if "*" in f:
112
+ if _glob_any(root, [f]):
113
+ ok = False
114
+ break
115
+ elif _exists(root, f):
116
+ ok = False
117
+ break
118
+ if ok:
119
+ return type_name
120
+ return "unknown"
121
+
122
+
123
+ def _git(root, args):
124
+ try:
125
+ out = subprocess.run(
126
+ ["git", "-C", root] + args,
127
+ capture_output=True, text=True, timeout=15,
128
+ )
129
+ return out.stdout.strip() if out.returncode == 0 else ""
130
+ except (OSError, subprocess.SubprocessError):
131
+ return ""
132
+
133
+
134
+ def git_facts(root):
135
+ branch = _git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
136
+ head = _git(root, ["rev-parse", "--short", "HEAD"])
137
+ remote = _git(root, ["config", "--get", "remote.origin.url"])
138
+ # Real repo name from the remote, never a local folder name that may be renamed.
139
+ repo_name = ""
140
+ if remote and "/" in remote:
141
+ repo_name = re.sub(r"\.git$", "", remote.rstrip("/").split("/")[-1])
142
+ # Activity clusters: which top-level dirs churn most in the last 50 commits.
143
+ log = _git(root, ["log", "-50", "--name-only", "--pretty=format:"])
144
+ clusters = {}
145
+ for line in log.splitlines():
146
+ line = line.strip()
147
+ if not line:
148
+ continue
149
+ top = line.split("/")[0] if "/" in line else line
150
+ clusters[top] = clusters.get(top, 0) + 1
151
+ top_clusters = sorted(clusters.items(), key=lambda kv: kv[1], reverse=True)[:6]
152
+ return {
153
+ "branch": branch,
154
+ "head": head,
155
+ "repo_name": repo_name,
156
+ "has_remote": bool(remote),
157
+ "activity_clusters": [{"path": k, "touches": v} for k, v in top_clusters],
158
+ }
159
+
160
+
161
+ def structure(root):
162
+ dirs, files = [], []
163
+ try:
164
+ for name in sorted(os.listdir(root)):
165
+ if name.startswith(".git"):
166
+ continue
167
+ full = os.path.join(root, name)
168
+ if os.path.isdir(full):
169
+ dirs.append(name)
170
+ else:
171
+ files.append(name)
172
+ except OSError:
173
+ pass
174
+ return {
175
+ "top_level_dirs": dirs,
176
+ "key_config_files": [f for f in KEY_CONFIG_FILES if _exists(root, f)],
177
+ "ci_files": [f for f in CI_FILES if _exists(root, f)],
178
+ "has_readme": _exists(root, "README.md") or _exists(root, "README"),
179
+ "has_changelog": _exists(root, "CHANGELOG.md"),
180
+ }
181
+
182
+
183
+ def existing_context(root):
184
+ """Reusable inputs that already exist -- the skill composes from these, not from scratch."""
185
+ ctx = {"code_specs": [], "repo_context": None, "conventions": None}
186
+ code_dir = os.path.join(root, ".ai-assistance", "code")
187
+ if os.path.isdir(code_dir):
188
+ for dirpath, _, filenames in os.walk(code_dir):
189
+ for fn in filenames:
190
+ if fn.endswith(".md"):
191
+ rel = os.path.relpath(os.path.join(dirpath, fn), root).replace("\\", "/")
192
+ ctx["code_specs"].append(rel)
193
+ ctx["code_specs"].sort()
194
+ local = os.path.join(root, ".ai-assistance", "local")
195
+ if os.path.isfile(os.path.join(local, "repo-context.md")):
196
+ ctx["repo_context"] = ".ai-assistance/local/repo-context.md"
197
+ if os.path.isfile(os.path.join(local, "conventions.md")):
198
+ ctx["conventions"] = ".ai-assistance/local/conventions.md"
199
+ return ctx
200
+
201
+
202
+ def _content_hash(path):
203
+ """Hash the self-doc BODY (below the frontmatter) so index/changelog record real changes,
204
+ not just a re-run timestamp. Missing file -> None."""
205
+ if not os.path.isfile(path):
206
+ return None
207
+ with open(path, encoding="utf-8", errors="replace") as fh:
208
+ text = fh.read()
209
+ body = text
210
+ if text.startswith("---"):
211
+ end = text.find("\n---", 3)
212
+ if end != -1:
213
+ body = text[end + 4:]
214
+ # Normalise line endings + trailing whitespace so cross-platform re-runs are stable.
215
+ norm = "\n".join(line.rstrip() for line in body.replace("\r\n", "\n").split("\n")).strip()
216
+ return hashlib.sha256(norm.encode("utf-8")).hexdigest()[:16]
217
+
218
+
219
+ def self_doc_status(root, out_rel):
220
+ out_dir = os.path.join(root, out_rel)
221
+ docs = []
222
+ for fname, purpose in SELF_DOC_SET:
223
+ path = os.path.join(out_dir, fname)
224
+ docs.append({
225
+ "file": f"{out_rel}/{fname}".replace("\\", "/"),
226
+ "purpose": purpose,
227
+ "exists": os.path.isfile(path),
228
+ "content_hash": _content_hash(path),
229
+ })
230
+ return docs
231
+
232
+
233
+ def observe(root, out_rel):
234
+ return {
235
+ "schema_version": SCHEMA_VERSION,
236
+ "observed_at": _iso_now(),
237
+ "repo_type": detect_repo_type(root),
238
+ "git": git_facts(root),
239
+ "structure": structure(root),
240
+ "existing_context": existing_context(root),
241
+ "self_doc_set": self_doc_status(root, out_rel),
242
+ "out_dir": out_rel.replace("\\", "/"),
243
+ }
244
+
245
+
246
+ # == index + changelog (deterministic writers) ======================================
247
+
248
+ def _read_json(path, default):
249
+ if not os.path.isfile(path):
250
+ return default
251
+ try:
252
+ with open(path, encoding="utf-8") as fh:
253
+ return json.load(fh)
254
+ except (OSError, ValueError):
255
+ return default
256
+
257
+
258
+ def write_index_and_changelog(root, out_rel, obs):
259
+ """Rewrite the machine-readable index and append changelog deltas -- deterministically.
260
+
261
+ The index (self-docs-index.json) is the machine surface a central hub consumes: schema
262
+ version, repo identity, and a hash per doc. The changelog (CHANGES.jsonl) appends one line
263
+ per doc whose content hash CHANGED since the previous index -- so the hub sees real deltas,
264
+ and a no-op re-run appends nothing (idempotent)."""
265
+ out_dir = os.path.join(root, out_rel)
266
+ os.makedirs(out_dir, exist_ok=True)
267
+ index_path = os.path.join(out_dir, "self-docs-index.json")
268
+ changes_path = os.path.join(out_dir, "CHANGES.jsonl")
269
+
270
+ prev = _read_json(index_path, {})
271
+ prev_hashes = {d["file"]: d.get("content_hash") for d in prev.get("docs", [])}
272
+
273
+ docs = obs["self_doc_set"]
274
+ now = obs["observed_at"]
275
+ deltas = []
276
+ for d in docs:
277
+ new_h = d["content_hash"]
278
+ old_h = prev_hashes.get(d["file"])
279
+ if new_h != old_h:
280
+ deltas.append({
281
+ "ts": now,
282
+ "file": d["file"],
283
+ "change": ("created" if old_h is None and new_h is not None
284
+ else "removed" if new_h is None
285
+ else "updated"),
286
+ "from_hash": old_h,
287
+ "to_hash": new_h,
288
+ })
289
+
290
+ index = {
291
+ "schema_version": SCHEMA_VERSION,
292
+ "generated_at": now,
293
+ "repo_name": obs["git"].get("repo_name") or "",
294
+ "repo_type": obs["repo_type"],
295
+ "head": obs["git"].get("head", ""),
296
+ "docs": [
297
+ {"file": d["file"], "purpose": d["purpose"],
298
+ "exists": d["exists"], "content_hash": d["content_hash"]}
299
+ for d in docs
300
+ ],
301
+ }
302
+ with open(index_path, "w", encoding="utf-8") as fh:
303
+ json.dump(index, fh, indent=2)
304
+ fh.write("\n")
305
+
306
+ if deltas:
307
+ with open(changes_path, "a", encoding="utf-8") as fh:
308
+ for delta in deltas:
309
+ fh.write(json.dumps(delta) + "\n")
310
+
311
+ return {"index": f"{out_rel}/self-docs-index.json".replace("\\", "/"),
312
+ "changelog": f"{out_rel}/CHANGES.jsonl".replace("\\", "/"),
313
+ "deltas_appended": len(deltas)}
314
+
315
+
316
+ def _human(obs):
317
+ g = obs["git"]
318
+ print(f"self-docs observation (schema {obs['schema_version']})")
319
+ print(f" repo_type : {obs['repo_type']}")
320
+ print(f" repo_name : {g.get('repo_name') or '(no remote)'} @ {g.get('head') or '?'} "
321
+ f"on {g.get('branch') or '?'}")
322
+ print(f" out_dir : {obs['out_dir']}")
323
+ print(" top-level dirs:", ", ".join(obs["structure"]["top_level_dirs"]) or "(none)")
324
+ if obs["structure"]["key_config_files"]:
325
+ print(" config :", ", ".join(obs["structure"]["key_config_files"]))
326
+ if g.get("activity_clusters"):
327
+ hot = ", ".join(f"{c['path']}({c['touches']})" for c in g["activity_clusters"])
328
+ print(" hot paths :", hot)
329
+ ec = obs["existing_context"]
330
+ reuse = []
331
+ if ec["repo_context"]:
332
+ reuse.append("ai-discovery repo-context.md")
333
+ if ec["conventions"]:
334
+ reuse.append("ai-discovery conventions.md")
335
+ if ec["code_specs"]:
336
+ reuse.append(f"{len(ec['code_specs'])} code-specs")
337
+ print(" reuse from:", ", ".join(reuse) or "(none -- run ai-discovery/code-specs first)")
338
+ print(" self-doc set:")
339
+ for d in obs["self_doc_set"]:
340
+ state = "present" if d["exists"] else "MISSING"
341
+ print(f" [{state:7}] {d['file']} ({d['content_hash'] or '-'})")
342
+
343
+
344
+ def main():
345
+ ap = argparse.ArgumentParser(description="Deterministic project self-docs observation")
346
+ ap.add_argument("--target", default=".",
347
+ help="LOCAL repo root to observe (default: cwd). Convenience only -- do NOT use to "
348
+ "generate ANOTHER repo's self-docs from here; run the skill in that repo's own "
349
+ "session (self-docs are owned + generated by the repo that documents itself).")
350
+ ap.add_argument("--out", default="docs/self-docs", help="self-docs dir (default: docs/self-docs)")
351
+ ap.add_argument("--json", action="store_true", help="emit the structured observation")
352
+ ap.add_argument("--write", action="store_true",
353
+ help="(re)write the machine index + changelog deltas only")
354
+ args = ap.parse_args()
355
+
356
+ root = os.path.abspath(args.target)
357
+ if not os.path.isdir(root):
358
+ print(f"[self-docs] target not a directory: {root}", file=sys.stderr)
359
+ return 2
360
+
361
+ obs = observe(root, args.out)
362
+
363
+ if args.write:
364
+ result = write_index_and_changelog(root, args.out, obs)
365
+ obs["_write_result"] = result
366
+
367
+ if args.json:
368
+ print(json.dumps(obs, indent=2))
369
+ else:
370
+ _human(obs)
371
+ if args.write:
372
+ r = obs["_write_result"]
373
+ print(f" wrote index -> {r['index']}; changelog deltas appended: {r['deltas_appended']}")
374
+ return 0
375
+
376
+
377
+ if __name__ == "__main__":
378
+ sys.exit(main())
@@ -6,17 +6,17 @@
6
6
  ],
7
7
  "deferred": [],
8
8
  "installed-components": {
9
+ "skills/ai-instructions": "0.0.0",
9
10
  "skills/code-specs": "0.0.0",
10
- "skills/ep-ai-manager": "2.1.0",
11
- "skills/dep-graph": "0.0.0",
12
11
  "skills/corporate-policies": "1.0.0",
13
- "skills/ai-instructions": "0.0.0",
14
- "skills/spec-generation": "0.0.0",
12
+ "skills/dep-graph": "0.0.0",
13
+ "skills/ep-ai-manager": "2.2.0",
15
14
  "skills/gemini-assist": "0.0.0",
16
- "skills/ruby-scripting": "0.1.0",
17
- "skills/refactor": "0.0.0",
18
- "skills/project-cycle": "0.0.0",
19
15
  "skills/graphql-schema-analysis": "0.0.0",
20
- "skills/procedural-memory": "0.2.0"
16
+ "skills/project-cycle": "0.0.0",
17
+ "skills/project-self-docs": "0.2.0",
18
+ "skills/refactor": "0.0.0",
19
+ "skills/ruby-scripting": "0.1.0",
20
+ "skills/spec-generation": "0.0.0"
21
21
  }
22
- }
22
+ }