aw-spec-cli 0.1.3 → 0.1.5
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.
- package/README.md +291 -0
- package/assets/.agents/agent.json +28 -0
- package/assets/.agents/skills/aw-spec-orch/SKILL.md +56 -0
- package/assets/.agents/skills/aw-spec-orch-demo/SKILL.md +59 -0
- package/assets/.aw-spec/DESIGN-FORMAT.md +151 -0
- package/assets/.aw-spec/ORCH-DIALECT.md +156 -0
- package/assets/.aw-spec/RULE-PARTITION.md +126 -0
- package/assets/.aw-spec/extensions/agent-context/extension.yml +12 -9
- package/assets/.aw-spec/extensions.yml +12 -19
- package/assets/.aw-spec/integrations/aw-spec.manifest.json +1 -9
- package/assets/.aw-spec/interpreter/run-skill.py +1158 -0
- package/assets/.aw-spec/orchestrator/AGENT-PROFILE.md +94 -0
- package/assets/.aw-spec/orchestrator/README.md +300 -0
- package/assets/.aw-spec/orchestrator/adapters/claude_agent_sdk.py +74 -0
- package/assets/.aw-spec/orchestrator/adapters/google_antigravity.py +74 -0
- package/assets/.aw-spec/orchestrator/modules/delta-design.orch.yaml +105 -0
- package/assets/.aw-spec/orchestrator/modules/delta-spec.orch.yaml +79 -0
- package/assets/.aw-spec/orchestrator/modules/domain-select.orch.yaml +81 -0
- package/assets/.aw-spec/orchestrator/modules/tech-select.orch.yaml +75 -0
- package/assets/.aw-spec/orchestrator/orchestrator.py +1721 -0
- package/assets/.aw-spec/orchestrator/rev-templates/ai-rules.reference.md +40 -0
- package/assets/.aw-spec/orchestrator/rev-templates/comandrulemap.skeleton.md +59 -0
- package/assets/.aw-spec/orchestrator/rev-templates/commandtechrulemap.skeleton.md +97 -0
- package/assets/.aw-spec/orchestrator/rev-templates/domain-constitution.skeleton.md +61 -0
- package/assets/.aw-spec/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
- package/assets/.aw-spec/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
- package/assets/.aw-spec/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
- package/assets/.aw-spec/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
- package/assets/.aw-spec/orchestrator/workflows/_smoke.orch.yaml +160 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-add-component.orch.yaml +244 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-adopt.orch.yaml +775 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +248 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +261 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +425 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-build.orch.yaml +188 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-characterize.orch.yaml +474 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-constitution.orch.yaml +284 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-demo.orch.yaml +103 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +382 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-foundation.orch.yaml +246 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +231 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-implement.orch.yaml +258 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-inventory.orch.yaml +236 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-onboard.orch.yaml +949 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-run.orch.yaml +177 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-domain.orch.yaml +79 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
- package/assets/.aw-spec/orchestrator/workflows/aw-spec-specify.orch.yaml +412 -0
- package/assets/.aw-spec/scripts/bash/aw-inventory.sh +14 -0
- package/assets/.aw-spec/scripts/bash/aw-parity.sh +13 -0
- package/assets/.aw-spec/scripts/bash/aw-rev-constitution.sh +13 -0
- package/assets/.aw-spec/scripts/bash/aw-spec-actions.sh +1899 -0
- package/assets/.aw-spec/scripts/bash/aw-xref.sh +15 -0
- package/assets/.aw-spec/scripts/bash/common.sh +11 -60
- package/assets/.aw-spec/scripts/powershell/aw-inventory.ps1 +14 -0
- package/assets/.aw-spec/scripts/powershell/aw-parity.ps1 +13 -0
- package/assets/.aw-spec/scripts/powershell/aw-rev-constitution.ps1 +13 -0
- package/assets/.aw-spec/scripts/powershell/aw-spec-actions.ps1 +1563 -0
- package/assets/.aw-spec/scripts/powershell/aw-xref.ps1 +15 -0
- package/assets/.aw-spec/scripts/python/aw-inventory.py +683 -0
- package/assets/.aw-spec/scripts/python/aw-parity.py +380 -0
- package/assets/.aw-spec/scripts/python/aw-rev-constitution.py +312 -0
- package/assets/.aw-spec/scripts/python/aw-xref.py +1330 -0
- package/assets/.aw-spec/scripts/python/validate_okf.py +349 -0
- package/assets/.aw-spec/templates/constitution-domain-template.md +75 -0
- package/assets/.aw-spec/templates/constitution-technical-template.md +70 -0
- package/assets/.aw-spec/templates/delta-design-template.md +69 -0
- package/assets/.aw-spec/templates/delta-spec-template.md +60 -0
- package/assets/.aw-spec/templates/design-template.md +383 -0
- package/assets/.aw-spec/templates/okf-concept-template.md +103 -0
- package/assets/.aw-spec/templates/parity-manifest-template.json +49 -0
- package/assets/.aw-spec/templates/source-map-template.md +85 -0
- package/assets/.aw-spec/templates/spec-template.md +192 -71
- package/assets/.claude/agent.json +27 -0
- package/assets/.claude/skills/aw-spec-orch/SKILL.md +55 -0
- package/assets/.claude/skills/aw-spec-orch-demo/SKILL.md +59 -0
- package/assets/.kilocode/agent.json +28 -0
- package/assets/.kilocode/workflows/aw-spec-orch-demo.md +59 -0
- package/assets/.kilocode/workflows/aw-spec-orch.md +55 -0
- package/assets/docs/architecture/clean-architecture.md +1 -1
- package/assets/docs/architecture/cqrs.md +1 -1
- package/assets/docs/architecture/eventing.md +1 -1
- package/assets/docs/architecture/folder-structure.md +1 -1
- package/assets/docs/architecture/modular-monolith.md +1 -1
- package/assets/docs/architecture/persistence.md +1 -1
- package/assets/docs/architecture/testing.md +1 -1
- package/assets/docs/architecture/vertical-slices.md +1 -1
- package/assets/docs/components/Email/README.md +1 -1
- package/assets/docs/components/Jobs/README.md +1 -1
- package/assets/docs/components/QrGenerator/README.md +1 -1
- package/assets/docs/components/README.md +1 -1
- package/assets/docs/domain/domain-driven-design.md +1 -1
- package/assets/docs/domainspecs/healthcare/api-versioning.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/auditing.domain.md +86 -18
- package/assets/docs/domainspecs/healthcare/authorization.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/clinical-terms.md +1 -1
- package/assets/docs/domainspecs/healthcare/coding-standards.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/comandrulemap.md +58 -124
- package/assets/docs/domainspecs/healthcare/commandtechrulemap.md +119 -0
- package/assets/docs/domainspecs/healthcare/consent.md +1 -1
- package/assets/docs/domainspecs/healthcare/cqrs.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/data-classification.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/dependency-policy.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/dicom.md +1 -1
- package/assets/docs/domainspecs/healthcare/domain-driven-design.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/eventing.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/fhir.md +1 -1
- package/assets/docs/domainspecs/healthcare/folder-structure.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/hipaa.md +1 -1
- package/assets/docs/domainspecs/healthcare/hl7.md +1 -1
- package/assets/docs/domainspecs/healthcare/integration-terms.md +1 -1
- package/assets/docs/domainspecs/healthcare/persistence.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/terminology.md +1 -1
- package/assets/docs/domainspecs/healthcare/testing.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/vertical-slices.domain.md +1 -1
- package/assets/docs/domainspecs/healthcare/zero-trust.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/api-versioning.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/atc.md +1 -1
- package/assets/docs/domainspecs/pharma/auditing.domain.md +80 -16
- package/assets/docs/domainspecs/pharma/authorization.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/coding-standards.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/comandrulemap.md +64 -142
- package/assets/docs/domainspecs/pharma/commandtechrulemap.md +119 -0
- package/assets/docs/domainspecs/pharma/cqrs.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/data-classification.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/data-provenance.md +1 -1
- package/assets/docs/domainspecs/pharma/dependency-policy.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/domain-driven-design.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/eventing.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/fhir.md +1 -1
- package/assets/docs/domainspecs/pharma/folder-structure.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/hl7.md +1 -1
- package/assets/docs/domainspecs/pharma/idmp.md +1 -1
- package/assets/docs/domainspecs/pharma/integration-terms.md +1 -1
- package/assets/docs/domainspecs/pharma/ndc.md +1 -1
- package/assets/docs/domainspecs/pharma/persistence.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/pharma-terms.md +1 -1
- package/assets/docs/domainspecs/pharma/product-lifecycle.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/regulatory-compliance.md +1 -1
- package/assets/docs/domainspecs/pharma/terminology.md +1 -1
- package/assets/docs/domainspecs/pharma/testing.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/vertical-slices.domain.md +1 -1
- package/assets/docs/domainspecs/pharma/zero-trust.domain.md +1 -1
- package/assets/docs/governance/api-versioning.md +1 -1
- package/assets/docs/governance/coding-standards.md +1 -1
- package/assets/docs/governance/dependency-policy.md +1 -1
- package/assets/docs/requirement-spec.txt +13 -1
- package/assets/docs/security/auditing.md +54 -11
- package/assets/docs/security/authorization.md +1 -1
- package/assets/docs/security/data-classification.md +1 -1
- package/assets/docs/security/zero-trust.md +1 -1
- package/dist/commands/update.d.ts +3 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +142 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/utils/manifest.d.ts +31 -0
- package/dist/utils/manifest.d.ts.map +1 -0
- package/dist/utils/manifest.js +91 -0
- package/dist/utils/manifest.js.map +1 -0
- package/dist/utils/paths.js +2 -2
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/update-engine.d.ts +52 -0
- package/dist/utils/update-engine.d.ts.map +1 -0
- package/dist/utils/update-engine.js +203 -0
- package/dist/utils/update-engine.js.map +1 -0
- package/package.json +1 -1
- package/assets/.agents/skills/aw-spec-addcomponent/SKILL.md +0 -129
- package/assets/.agents/skills/aw-spec-agent-context-update/SKILL.md +0 -31
- package/assets/.agents/skills/aw-spec-analyze/SKILL.md +0 -296
- package/assets/.agents/skills/aw-spec-checklist/SKILL.md +0 -374
- package/assets/.agents/skills/aw-spec-clarify/SKILL.md +0 -320
- package/assets/.agents/skills/aw-spec-constitution/SKILL.md +0 -194
- package/assets/.agents/skills/aw-spec-fast-implement/SKILL.md +0 -268
- package/assets/.agents/skills/aw-spec-foundation/SKILL.md +0 -358
- package/assets/.agents/skills/aw-spec-implement/SKILL.md +0 -279
- package/assets/.agents/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
- package/assets/.agents/skills/aw-spec-plan/SKILL.md +0 -221
- package/assets/.agents/skills/aw-spec-select-domain/SKILL.md +0 -73
- package/assets/.agents/skills/aw-spec-specify/SKILL.md +0 -382
- package/assets/.agents/skills/aw-spec-tasks/SKILL.md +0 -259
- package/assets/.agents/skills/aw-spec-taskstoissues/SKILL.md +0 -107
- package/assets/.aw-spec/integrations/claude.manifest.json +0 -17
- package/assets/.aw-spec/scripts/bash/check-prerequisites.sh +0 -189
- package/assets/.aw-spec/scripts/bash/create-new-feature.sh +0 -299
- package/assets/.aw-spec/scripts/bash/setup-plan.sh +0 -84
- package/assets/.aw-spec/scripts/bash/setup-tasks.sh +0 -91
- package/assets/.aw-spec/templates/checklist-template.md +0 -40
- package/assets/.aw-spec/templates/constitution-template.md +0 -57
- package/assets/.aw-spec/templates/plan-template.md +0 -113
- package/assets/.aw-spec/templates/tasks-template.md +0 -252
- package/assets/.aw-spec/workflows/aw-spec/workflow.yml +0 -77
- package/assets/.aw-spec/workflows/workflow-registry.json +0 -13
- package/assets/.claude/skills/aw-spec-addcomponent/SKILL.md +0 -129
- package/assets/.claude/skills/aw-spec-agent-context-update/SKILL.md +0 -31
- package/assets/.claude/skills/aw-spec-analyze/SKILL.md +0 -296
- package/assets/.claude/skills/aw-spec-checklist/SKILL.md +0 -374
- package/assets/.claude/skills/aw-spec-clarify/SKILL.md +0 -320
- package/assets/.claude/skills/aw-spec-constitution/SKILL.md +0 -194
- package/assets/.claude/skills/aw-spec-fast-implement/SKILL.md +0 -268
- package/assets/.claude/skills/aw-spec-foundation/SKILL.md +0 -358
- package/assets/.claude/skills/aw-spec-implement/SKILL.md +0 -279
- package/assets/.claude/skills/aw-spec-minimal-implementation/SKILL.md +0 -209
- package/assets/.claude/skills/aw-spec-plan/SKILL.md +0 -221
- package/assets/.claude/skills/aw-spec-select-domain/SKILL.md +0 -73
- package/assets/.claude/skills/aw-spec-specify/SKILL.md +0 -382
- package/assets/.claude/skills/aw-spec-tasks/SKILL.md +0 -259
- package/assets/.claude/skills/aw-spec-taskstoissues/SKILL.md +0 -107
- package/assets/.kilocode/rules/specify-rules.md +0 -4
- package/assets/.kilocode/workflows/aw-spec-addcomponent.md +0 -129
- package/assets/.kilocode/workflows/aw-spec-agent-context.update.md +0 -31
- package/assets/.kilocode/workflows/aw-spec-analyze.md +0 -296
- package/assets/.kilocode/workflows/aw-spec-checklist.md +0 -374
- package/assets/.kilocode/workflows/aw-spec-clarify.md +0 -320
- package/assets/.kilocode/workflows/aw-spec-constitution.md +0 -194
- package/assets/.kilocode/workflows/aw-spec-fast-implement.md +0 -263
- package/assets/.kilocode/workflows/aw-spec-foundation.md +0 -358
- package/assets/.kilocode/workflows/aw-spec-implement.md +0 -279
- package/assets/.kilocode/workflows/aw-spec-minimal-implementation.md +0 -204
- package/assets/.kilocode/workflows/aw-spec-plan.md +0 -221
- package/assets/.kilocode/workflows/aw-spec-select-domain.md +0 -73
- package/assets/.kilocode/workflows/aw-spec-specify.md +0 -382
- package/assets/.kilocode/workflows/aw-spec-tasks.md +0 -259
- package/assets/.kilocode/workflows/aw-spec-taskstoissues.md +0 -107
- package/assets/docs/command.txt +0 -295
- package/assets/docs/pharma-constitution.my-ref.md +0 -86
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""aw-inventory.py — brownfield repository inventory for the aw-spec pipeline.
|
|
3
|
+
|
|
4
|
+
The deterministic, model-free substrate for the Brownfield-Pipeline B0
|
|
5
|
+
(Inventory) phase (see platform/AppWeaver.Component.Spec/Brownfield-Pipeline.my-ref.md).
|
|
6
|
+
It scans an existing ("brownfield") codebase and emits a factual JSON substrate
|
|
7
|
+
that the /aw-spec-orch inventory workflow's enrichment agent turns into an
|
|
8
|
+
aggregate-organized OKF v0.1 knowledge bundle. The agent uses this so it never
|
|
9
|
+
has to guess the repo layout.
|
|
10
|
+
|
|
11
|
+
Ported from Reference/okf-inventory.sh (which was ~90% embedded python already)
|
|
12
|
+
into a single cross-platform python core, with two brownfield additions:
|
|
13
|
+
|
|
14
|
+
* aggregate_signals — candidate DDD aggregate roots derived from domain /
|
|
15
|
+
entity / model / route file clustering, ranked by churn + evidence. These
|
|
16
|
+
are SIGNALS, not decisions: the workflow's agent draws the real aggregate
|
|
17
|
+
boundaries. This is the "inventory following aggregate root" the pipeline
|
|
18
|
+
needs — one aggregate ≈ one candidate specs/<Feature>/ vertical slice.
|
|
19
|
+
* git history scoped to the scan subtree (not the whole monorepo), so the
|
|
20
|
+
inventory reflects only the code being adopted.
|
|
21
|
+
|
|
22
|
+
Subcommands, all printing a single JSON object as the LAST stdout line (so the
|
|
23
|
+
orchestrator's execute_script can bind it); human-readable summaries go to STDERR
|
|
24
|
+
so they never corrupt the JSON contract:
|
|
25
|
+
|
|
26
|
+
scan [--root <dir>] [--out <path>] [--bundle <dir>] [--config <yml>]
|
|
27
|
+
-> writes the full substrate JSON to --out; prints a compact summary
|
|
28
|
+
object {inventory_json, file_count, head, branch, aggregate_count,
|
|
29
|
+
bundle_dir, bundle_present, bundle_md_count}
|
|
30
|
+
concept --bundle <dir> --aggregate <name>
|
|
31
|
+
-> resolves ONE aggregate's OKF concept + source_files (the code that
|
|
32
|
+
IS the slice) — the index adopt reverse-engineers a feature from
|
|
33
|
+
list-aggregates --bundle <dir>
|
|
34
|
+
-> enumerates the bundle's Aggregate concepts (the brownfield feature
|
|
35
|
+
map) → {aggregates[{name,title,slug,concept_path,...}], count} — the
|
|
36
|
+
selectable feature list select-feature offers in a brownfield repo
|
|
37
|
+
validate --bundle <dir> [--repo-root <dir>]
|
|
38
|
+
-> runs the OKF v0.1 conformance checker (validate_okf.py, beside
|
|
39
|
+
this file) and re-emits {concepts, errors[], warnings[], result,
|
|
40
|
+
conformant}
|
|
41
|
+
|
|
42
|
+
Usage: aw-inventory.py <scan|concept|list-aggregates|validate> [options]
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
53
|
+
|
|
54
|
+
# Default excludes, aligned with okf-inventory.sh / okf-config.template.yml.
|
|
55
|
+
DEFAULT_EXCLUDE = re.compile(
|
|
56
|
+
r"^(node_modules|\.git|dist|build|vendor|\.specify|\.aw-spec|specs|knowledge)/"
|
|
57
|
+
r"|(^|/)[^/]*\.min\.js$"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
CAP = int(os.environ.get("OKF_INVENTORY_CAP", "150"))
|
|
61
|
+
HIST_COMMITS = int(os.environ.get("OKF_HISTORY_COMMITS", "2000"))
|
|
62
|
+
HIST_RECENT = int(os.environ.get("OKF_HISTORY_RECENT", "20"))
|
|
63
|
+
CHURN_TOP = int(os.environ.get("OKF_CHURN_TOP", "30"))
|
|
64
|
+
|
|
65
|
+
# --- category regexes (mirror okf-inventory.sh) ----------------------------
|
|
66
|
+
MANIFESTS_RE = re.compile(
|
|
67
|
+
r"(^|/)(package\.json|pyproject\.toml|setup\.py|requirements[^/]*\.txt|"
|
|
68
|
+
r"go\.mod|Cargo\.toml|pom\.xml|build\.gradle(\.kts)?|Gemfile|composer\.json|"
|
|
69
|
+
r"[^/]*\.csproj)$"
|
|
70
|
+
)
|
|
71
|
+
ENTRYPOINTS_RE = re.compile(
|
|
72
|
+
r"(^|/)(main|app|index|server|cli|manage|__main__|Program)\.(py|js|ts|go|rs|rb|java|cs)$"
|
|
73
|
+
)
|
|
74
|
+
API_DEFS_RE = re.compile(
|
|
75
|
+
r"\.(proto|avsc|graphql|gql)$|(^|/)(openapi|swagger)[^/]*\.(ya?ml|json)$"
|
|
76
|
+
)
|
|
77
|
+
ROUTES_RE = re.compile(
|
|
78
|
+
r"(rout|controller|endpoint|handler|view|resource)s?[^/]*\.(py|js|ts|go|rb|java|cs|php)$",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
DB_FILES_RE = re.compile(
|
|
82
|
+
r"(^|/)(migrations?|alembic|db/migrate)/|"
|
|
83
|
+
r"(model|schema|entit|aggregat)[^/]*\.(py|js|ts|go|rb|java|cs|sql|prisma)$",
|
|
84
|
+
re.IGNORECASE,
|
|
85
|
+
)
|
|
86
|
+
OPS_FILES_RE = re.compile(
|
|
87
|
+
r"(^|/)(Dockerfile[^/]*|docker-compose[^/]*\.ya?ml|Makefile|Procfile|[^/]*\.tf|"
|
|
88
|
+
r"helm/.*|k8s/.*\.ya?ml|\.github/workflows/.*\.ya?ml|\.gitlab-ci\.yml|"
|
|
89
|
+
r"Jenkinsfile|cloudbuild\.ya?ml)$"
|
|
90
|
+
)
|
|
91
|
+
DOCS_RE = re.compile(
|
|
92
|
+
r"(^|/)(README[^/]*|CONTRIBUTING[^/]*|CHANGELOG[^/]*|ARCHITECTURE[^/]*|docs?/.*)"
|
|
93
|
+
r"\.(md|rst|txt)$"
|
|
94
|
+
)
|
|
95
|
+
CONFIGS_RE = re.compile(
|
|
96
|
+
r"(^|/)(config|settings|conf|appsettings)[^/]*\.(py|js|ts|ya?ml|json|toml|ini|env\.example)$",
|
|
97
|
+
re.IGNORECASE,
|
|
98
|
+
)
|
|
99
|
+
ADR_RE = re.compile(r"(^|/)(adr|adrs|decisions?|rfcs?)/|(^|/)(ADR|RFC)[-_0-9]", re.IGNORECASE)
|
|
100
|
+
|
|
101
|
+
# Directory / filename hints that a file participates in a domain aggregate.
|
|
102
|
+
DOMAIN_DIR_RE = re.compile(r"(^|/)(domain|aggregates?|entit(y|ies)|models?)(/|$)", re.IGNORECASE)
|
|
103
|
+
# Layer folders inside a vertical slice. The PARENT of any of these is an
|
|
104
|
+
# aggregate-module candidate (e.g. .../MedicinalProducts/Domain/... -> the
|
|
105
|
+
# aggregate module is MedicinalProducts). This mirrors the aw-spec
|
|
106
|
+
# MinimalMonolith vertical-slice layout and is the strongest, lowest-noise
|
|
107
|
+
# aggregate signal — far better than clustering on file basenames.
|
|
108
|
+
LAYER_DIR_RE = re.compile(
|
|
109
|
+
r"^(domain|features?|application|use[-_]?cases?|handlers?|persistence|"
|
|
110
|
+
r"infrastructure|infra|data|repositor(y|ies)|api|endpoints?|controllers?|"
|
|
111
|
+
r"aggregates?|entit(y|ies)|models?|dtos?|contracts?)$",
|
|
112
|
+
re.IGNORECASE,
|
|
113
|
+
)
|
|
114
|
+
# Which bucket a layer folder maps to, for grouping a candidate's files.
|
|
115
|
+
LAYER_KIND = {
|
|
116
|
+
"domain": "domain", "aggregate": "domain", "aggregates": "domain",
|
|
117
|
+
"entity": "domain", "entities": "domain", "model": "domain", "models": "domain",
|
|
118
|
+
"feature": "feature", "features": "feature", "application": "feature",
|
|
119
|
+
"usecase": "feature", "usecases": "feature", "use-case": "feature",
|
|
120
|
+
"use-cases": "feature", "use_case": "feature", "use_cases": "feature",
|
|
121
|
+
"handler": "feature", "handlers": "feature",
|
|
122
|
+
"persistence": "infra", "infrastructure": "infra", "infra": "infra",
|
|
123
|
+
"data": "infra", "repository": "infra", "repositories": "infra",
|
|
124
|
+
"api": "api", "endpoint": "api", "endpoints": "api",
|
|
125
|
+
"controller": "api", "controllers": "api",
|
|
126
|
+
"dto": "api", "dtos": "api", "contract": "api", "contracts": "api",
|
|
127
|
+
}
|
|
128
|
+
# Type-role suffixes stripped to recover the underlying aggregate/entity noun
|
|
129
|
+
# (used only for the secondary within-aggregate "entities" hint, and for the
|
|
130
|
+
# flat-repo fallback when there are no layer folders).
|
|
131
|
+
ROLE_SUFFIX_RE = re.compile(
|
|
132
|
+
r"(Aggregate(Root)?|Root|Entity|Model|Repository|Repo|Controller|Service|"
|
|
133
|
+
r"Handler|Endpoint|Resource|Dto|DTO|Mapper|Schema|Table|Command|Query|"
|
|
134
|
+
r"Factory|Spec|Specification|View)$"
|
|
135
|
+
)
|
|
136
|
+
# Verb prefixes on CQRS command/use-case names, stripped so RegisterMedicinal…
|
|
137
|
+
# resolves toward its aggregate rather than becoming its own candidate.
|
|
138
|
+
VERB_PREFIX_RE = re.compile(
|
|
139
|
+
r"^(Register|Create|Add|Update|Modify|Change|Delete|Remove|Get|List|Find|"
|
|
140
|
+
r"Fetch|Set|Assign|Approve|Reject|Cancel|Submit|Publish|Archive|Restore|"
|
|
141
|
+
r"Enable|Disable|Activate|Deactivate|Send|Generate)"
|
|
142
|
+
)
|
|
143
|
+
CODE_EXT_RE = re.compile(r"\.(py|js|ts|go|rs|rb|java|cs|php|kt|scala|sql|prisma)$", re.IGNORECASE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def singularize(name):
|
|
147
|
+
"""Light singularization for a module dir name (MedicinalProducts ->
|
|
148
|
+
MedicinalProduct). Signal-grade only — never grammar-perfect."""
|
|
149
|
+
if len(name) > 4 and name.endswith("s") and not name.lower().endswith(("ss", "us", "is", "as")):
|
|
150
|
+
return name[:-1]
|
|
151
|
+
return name
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --- glob-based config excludes (best-effort, mirrors okf-inventory.sh) -----
|
|
155
|
+
def load_config_excludes(config_path):
|
|
156
|
+
if not config_path or not os.path.isfile(config_path):
|
|
157
|
+
return []
|
|
158
|
+
try:
|
|
159
|
+
with open(config_path, encoding="utf-8") as f:
|
|
160
|
+
lines = f.readlines()
|
|
161
|
+
except OSError:
|
|
162
|
+
return []
|
|
163
|
+
in_exclude = False
|
|
164
|
+
globs = []
|
|
165
|
+
for line in lines:
|
|
166
|
+
stripped = line.strip()
|
|
167
|
+
if re.match(r"^exclude\s*:\s*$", stripped):
|
|
168
|
+
in_exclude = True
|
|
169
|
+
continue
|
|
170
|
+
if in_exclude:
|
|
171
|
+
m = re.match(r"^-\s*[\"']?([^\"'#]+)[\"']?\s*(#.*)?$", stripped)
|
|
172
|
+
if m:
|
|
173
|
+
globs.append(m.group(1).strip())
|
|
174
|
+
continue
|
|
175
|
+
if stripped and not stripped.startswith("#"):
|
|
176
|
+
in_exclude = False
|
|
177
|
+
out = []
|
|
178
|
+
for g in globs:
|
|
179
|
+
parts, i = [], 0
|
|
180
|
+
while i < len(g):
|
|
181
|
+
if g[i:i + 2] == "**":
|
|
182
|
+
parts.append(".*"); i += 2
|
|
183
|
+
elif g[i] == "*":
|
|
184
|
+
parts.append("[^/]*"); i += 1
|
|
185
|
+
else:
|
|
186
|
+
parts.append(re.escape(g[i])); i += 1
|
|
187
|
+
out.append(re.compile("^" + "".join(parts) + "$"))
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def git(root, *args):
|
|
192
|
+
"""Run a git command in `root`; return stdout (stripped) or '' on failure."""
|
|
193
|
+
try:
|
|
194
|
+
p = subprocess.run(["git", "-C", root, *args], capture_output=True, text=True)
|
|
195
|
+
return p.stdout.strip() if p.returncode == 0 else ""
|
|
196
|
+
except Exception:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_excluded(path, config_excludes):
|
|
201
|
+
if DEFAULT_EXCLUDE.search(path):
|
|
202
|
+
return True
|
|
203
|
+
return any(rx.match(path) for rx in config_excludes)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def list_files(root, config_excludes):
|
|
207
|
+
"""Repo-relative-to-`root` file list, honoring .gitignore via git ls-files."""
|
|
208
|
+
if git(root, "rev-parse", "--git-dir"):
|
|
209
|
+
raw = git(root, "ls-files")
|
|
210
|
+
files = raw.splitlines() if raw else []
|
|
211
|
+
else:
|
|
212
|
+
files = []
|
|
213
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
214
|
+
dirnames[:] = [d for d in dirnames if d != ".git"]
|
|
215
|
+
for fn in filenames:
|
|
216
|
+
rel = os.path.relpath(os.path.join(dirpath, fn), root)
|
|
217
|
+
files.append(rel.replace(os.sep, "/"))
|
|
218
|
+
return [f for f in files if f.strip() and not is_excluded(f, config_excludes)]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def churn(root, config_excludes):
|
|
222
|
+
"""Commit-count per file over the last HIST_COMMITS commits, scoped to the
|
|
223
|
+
scan subtree (paths made relative to `root` via --show-prefix)."""
|
|
224
|
+
if not git(root, "rev-parse", "--git-dir"):
|
|
225
|
+
return [], 0
|
|
226
|
+
prefix = git(root, "rev-parse", "--show-prefix") # root's path under toplevel
|
|
227
|
+
raw = git(root, "log", "--no-merges", "-n", str(HIST_COMMITS),
|
|
228
|
+
"--pretty=format:", "--name-only", "--", ".")
|
|
229
|
+
counts = {}
|
|
230
|
+
for line in raw.splitlines():
|
|
231
|
+
line = line.strip()
|
|
232
|
+
if not line:
|
|
233
|
+
continue
|
|
234
|
+
rel = line[len(prefix):] if prefix and line.startswith(prefix) else line
|
|
235
|
+
if prefix and not line.startswith(prefix):
|
|
236
|
+
continue # outside the scan subtree
|
|
237
|
+
if is_excluded(rel, config_excludes):
|
|
238
|
+
continue
|
|
239
|
+
counts[rel] = counts.get(rel, 0) + 1
|
|
240
|
+
ranked = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:CHURN_TOP]
|
|
241
|
+
scanned = git(root, "rev-list", "--no-merges", "--count", "-n", str(HIST_COMMITS), "HEAD")
|
|
242
|
+
return [{"file": f, "commits": c} for f, c in ranked], int(scanned or 0)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def recent_commits(root):
|
|
246
|
+
if not git(root, "rev-parse", "--git-dir"):
|
|
247
|
+
return []
|
|
248
|
+
raw = git(root, "log", "--no-merges", "-n", str(HIST_RECENT),
|
|
249
|
+
"--pretty=format:%h%x09%cI%x09%s", "--", ".")
|
|
250
|
+
out = []
|
|
251
|
+
for line in raw.splitlines():
|
|
252
|
+
parts = line.split("\t")
|
|
253
|
+
if len(parts) >= 3:
|
|
254
|
+
out.append({"sha": parts[0], "date": parts[1], "subject": "\t".join(parts[2:])})
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def matching(files, rx):
|
|
259
|
+
return [f for f in files if rx.search(f)]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _entity_noun(path):
|
|
263
|
+
"""Recover the domain noun from a file basename (role suffixes + a CQRS verb
|
|
264
|
+
prefix stripped): RegisterMedicinalProductCommandHandler -> MedicinalProduct.
|
|
265
|
+
Signal-grade; returns None for non-nouny names."""
|
|
266
|
+
base = os.path.splitext(os.path.basename(path))[0]
|
|
267
|
+
base = re.split(r"[._-]", base)[0]
|
|
268
|
+
prev = None
|
|
269
|
+
while prev != base:
|
|
270
|
+
prev = base
|
|
271
|
+
base = ROLE_SUFFIX_RE.sub("", base)
|
|
272
|
+
base = VERB_PREFIX_RE.sub("", base)
|
|
273
|
+
return base if base and base[:1].isalpha() and len(base) > 2 else None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def aggregate_signals(files, churn_map):
|
|
277
|
+
"""Derive CANDIDATE aggregate roots — one per vertical slice, the base unit
|
|
278
|
+
the /aw-spec-orch inventory workflow turns into a specs/<Feature>/ slice.
|
|
279
|
+
|
|
280
|
+
PRIMARY signal (low-noise): a MODULE DIRECTORY that owns layer folders
|
|
281
|
+
(Domain / Features / Persistence / Api …). Its parent segment, singularized,
|
|
282
|
+
is the aggregate (…/MedicinalProducts/Domain/… -> MedicinalProduct); every
|
|
283
|
+
file under the module is grouped by layer. FALLBACK (flat repos with no
|
|
284
|
+
layer folders): cluster domain/route/api files on the basename noun.
|
|
285
|
+
|
|
286
|
+
Signals only — the workflow's agent draws the real boundaries. Empty for a
|
|
287
|
+
repo with no recognizable domain structure."""
|
|
288
|
+
modules = {} # module_dir -> candidate
|
|
289
|
+
|
|
290
|
+
def module_candidate(module_dir, name):
|
|
291
|
+
return modules.setdefault(module_dir, {
|
|
292
|
+
"name": name, "module_dir": module_dir,
|
|
293
|
+
"domain_files": [], "feature_files": [], "infra_files": [],
|
|
294
|
+
"api_files": [], "other_files": [], "entities": set(), "churn": 0,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
# PRIMARY: walk each file's path for a layer folder; its parent is the module.
|
|
298
|
+
module_files = {} # module_dir -> [(file, kind)]
|
|
299
|
+
for f in files:
|
|
300
|
+
parts = f.split("/")
|
|
301
|
+
for i in range(1, len(parts) - 1): # a layer folder needs a parent + a child
|
|
302
|
+
if LAYER_DIR_RE.match(parts[i]):
|
|
303
|
+
module_dir = "/".join(parts[:i])
|
|
304
|
+
kind = LAYER_KIND.get(parts[i].lower(), "other")
|
|
305
|
+
module_files.setdefault(module_dir, []).append((f, kind))
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
for module_dir, entries in module_files.items():
|
|
309
|
+
name = singularize(os.path.basename(module_dir)) or os.path.basename(module_dir)
|
|
310
|
+
c = module_candidate(module_dir, name)
|
|
311
|
+
for f, kind in entries:
|
|
312
|
+
c[kind + "_files"].append(f)
|
|
313
|
+
c["churn"] += churn_map.get(f, 0)
|
|
314
|
+
if kind == "domain":
|
|
315
|
+
n = _entity_noun(f)
|
|
316
|
+
if n:
|
|
317
|
+
c["entities"].add(n)
|
|
318
|
+
|
|
319
|
+
sig = []
|
|
320
|
+
for c in modules.values():
|
|
321
|
+
layers = ("domain_files", "feature_files", "infra_files", "api_files", "other_files")
|
|
322
|
+
# Drop pure-doc/config false positives (e.g. docs/domain/): a real
|
|
323
|
+
# aggregate module owns at least one source file.
|
|
324
|
+
if not any(CODE_EXT_RE.search(f) for k in layers for f in c[k]):
|
|
325
|
+
continue
|
|
326
|
+
for k in layers:
|
|
327
|
+
c[k] = sorted(set(c[k]))[:CAP]
|
|
328
|
+
c["entities"] = sorted(c["entities"])[:CAP]
|
|
329
|
+
c["file_count"] = sum(len(c[k]) for k in layers)
|
|
330
|
+
c["evidence_count"] = c["file_count"]
|
|
331
|
+
sig.append(c)
|
|
332
|
+
|
|
333
|
+
# FALLBACK: only if the module walk found nothing (flat repo, no layer dirs).
|
|
334
|
+
if not sig:
|
|
335
|
+
domain = matching(files, DB_FILES_RE) + [f for f in files if DOMAIN_DIR_RE.search(f)]
|
|
336
|
+
flat = {}
|
|
337
|
+
for f in domain + matching(files, ROUTES_RE) + matching(files, API_DEFS_RE):
|
|
338
|
+
noun = _entity_noun(f)
|
|
339
|
+
if not noun:
|
|
340
|
+
continue
|
|
341
|
+
c = flat.setdefault(noun.lower(), {
|
|
342
|
+
"name": noun, "module_dir": "", "domain_files": [], "feature_files": [],
|
|
343
|
+
"infra_files": [], "api_files": [], "other_files": [], "entities": [], "churn": 0})
|
|
344
|
+
c["other_files"].append(f)
|
|
345
|
+
c["churn"] += churn_map.get(f, 0)
|
|
346
|
+
for c in flat.values():
|
|
347
|
+
c["other_files"] = sorted(set(c["other_files"]))[:CAP]
|
|
348
|
+
c["file_count"] = len(c["other_files"])
|
|
349
|
+
c["evidence_count"] = c["file_count"]
|
|
350
|
+
if c["file_count"] >= 2:
|
|
351
|
+
sig.append(c)
|
|
352
|
+
|
|
353
|
+
sig.sort(key=lambda c: (c["evidence_count"], c["churn"]), reverse=True)
|
|
354
|
+
return sig
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def bundle_state(bundle_dir):
|
|
358
|
+
"""How many concept .md files a candidate bundle already holds (the OKF
|
|
359
|
+
generate guard: a non-empty bundle means /aw-spec-orch inventory would
|
|
360
|
+
clobber curation — the workflow should stop unless --force)."""
|
|
361
|
+
if not os.path.isdir(bundle_dir):
|
|
362
|
+
return False, 0
|
|
363
|
+
count = 0
|
|
364
|
+
for dirpath, dirnames, filenames in os.walk(bundle_dir):
|
|
365
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
366
|
+
count += sum(1 for f in filenames if f.endswith(".md") and f not in ("index.md", "log.md"))
|
|
367
|
+
return count > 0, count
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def cmd_scan(args):
|
|
371
|
+
root = os.path.abspath(args.root or os.getcwd())
|
|
372
|
+
config_excludes = load_config_excludes(args.config)
|
|
373
|
+
out = os.path.abspath(args.out) if args.out else os.path.join(root, ".aw-spec", "inventory", "inventory.json")
|
|
374
|
+
bundle_dir = os.path.abspath(args.bundle) if args.bundle else os.path.join(root, "knowledge")
|
|
375
|
+
|
|
376
|
+
files = list_files(root, config_excludes)
|
|
377
|
+
churn_list, commits_scanned = churn(root, config_excludes)
|
|
378
|
+
churn_map = {c["file"]: c["commits"] for c in churn_list}
|
|
379
|
+
|
|
380
|
+
def hist(s):
|
|
381
|
+
h = {}
|
|
382
|
+
for f in files:
|
|
383
|
+
_, dot, ext = f.rpartition(".")
|
|
384
|
+
if dot and ext:
|
|
385
|
+
h[ext] = h.get(ext, 0) + 1
|
|
386
|
+
return h
|
|
387
|
+
|
|
388
|
+
lang_hist = dict(sorted(hist(files).items(), key=lambda kv: kv[1], reverse=True)[:15])
|
|
389
|
+
topdirs = {}
|
|
390
|
+
for f in files:
|
|
391
|
+
if "/" in f:
|
|
392
|
+
top = f.split("/", 1)[0]
|
|
393
|
+
topdirs[top] = topdirs.get(top, 0) + 1
|
|
394
|
+
top_level_dirs = dict(sorted(topdirs.items(), key=lambda kv: kv[1], reverse=True)[:20])
|
|
395
|
+
|
|
396
|
+
def category(rx):
|
|
397
|
+
hits = matching(files, rx)
|
|
398
|
+
return {"items": hits[:CAP], "truncated": len(hits) > CAP}
|
|
399
|
+
|
|
400
|
+
cats = {
|
|
401
|
+
"dependency_manifests": category(MANIFESTS_RE),
|
|
402
|
+
"entrypoints": category(ENTRYPOINTS_RE),
|
|
403
|
+
"api_definitions": category(API_DEFS_RE),
|
|
404
|
+
"route_like_files": category(ROUTES_RE),
|
|
405
|
+
"data_layer_files": category(DB_FILES_RE),
|
|
406
|
+
"ops_files": category(OPS_FILES_RE),
|
|
407
|
+
"docs": category(DOCS_RE),
|
|
408
|
+
"config_files": category(CONFIGS_RE),
|
|
409
|
+
"adr_docs": category(ADR_RE),
|
|
410
|
+
}
|
|
411
|
+
signals = aggregate_signals(files, churn_map)
|
|
412
|
+
present, md_count = bundle_state(bundle_dir)
|
|
413
|
+
|
|
414
|
+
inv = {
|
|
415
|
+
"root": root,
|
|
416
|
+
"git": {
|
|
417
|
+
"remote": git(root, "remote", "get-url", "origin"),
|
|
418
|
+
"branch": git(root, "symbolic-ref", "--short", "HEAD") or "main",
|
|
419
|
+
"head": git(root, "rev-parse", "--short", "HEAD"),
|
|
420
|
+
"history": {
|
|
421
|
+
"commits_scanned": commits_scanned,
|
|
422
|
+
"churn": churn_list,
|
|
423
|
+
"recent_commits": recent_commits(root),
|
|
424
|
+
"churn_truncated": len(churn_list) >= CHURN_TOP > 0,
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
"file_count": len(files),
|
|
428
|
+
"cap": CAP,
|
|
429
|
+
"language_histogram": lang_hist,
|
|
430
|
+
"top_level_dirs": top_level_dirs,
|
|
431
|
+
**{k: v["items"] for k, v in cats.items()},
|
|
432
|
+
"aggregate_signals": signals,
|
|
433
|
+
"truncated": {k: True for k, v in cats.items() if v["truncated"]},
|
|
434
|
+
}
|
|
435
|
+
os.makedirs(os.path.dirname(out), exist_ok=True)
|
|
436
|
+
with open(out, "w", encoding="utf-8") as f:
|
|
437
|
+
json.dump(inv, f, indent=2)
|
|
438
|
+
|
|
439
|
+
# Human summary -> STDERR (must not pollute the JSON contract on stdout).
|
|
440
|
+
print(f"Inventory written to {out}", file=sys.stderr)
|
|
441
|
+
print(f" files: {inv['file_count']} head: {inv['git']['head'] or '-'} "
|
|
442
|
+
f"branch: {inv['git']['branch']}", file=sys.stderr)
|
|
443
|
+
h = inv["git"]["history"]
|
|
444
|
+
print(f" git history: {h['commits_scanned']} commits scanned, "
|
|
445
|
+
f"{len(h['churn'])} hot files, {len(h['recent_commits'])} recent subjects",
|
|
446
|
+
file=sys.stderr)
|
|
447
|
+
print(f" candidate aggregates ({len(signals)}): "
|
|
448
|
+
f"{', '.join(s['name'] for s in signals[:12])}"
|
|
449
|
+
f"{' …' if len(signals) > 12 else ''}", file=sys.stderr)
|
|
450
|
+
for k in cats:
|
|
451
|
+
flag = " (truncated)" if k in inv["truncated"] else ""
|
|
452
|
+
print(f" {k}: {len(inv[k])}{flag}", file=sys.stderr)
|
|
453
|
+
if present:
|
|
454
|
+
print(f" NOTE: bundle {bundle_dir} already holds {md_count} concept file(s)",
|
|
455
|
+
file=sys.stderr)
|
|
456
|
+
|
|
457
|
+
# Machine contract -> LAST stdout line (bound by the orchestrator).
|
|
458
|
+
print(json.dumps({
|
|
459
|
+
"inventory_json": out,
|
|
460
|
+
"root": root,
|
|
461
|
+
"file_count": inv["file_count"],
|
|
462
|
+
"head": inv["git"]["head"],
|
|
463
|
+
"branch": inv["git"]["branch"],
|
|
464
|
+
"aggregate_count": len(signals),
|
|
465
|
+
"bundle_dir": bundle_dir,
|
|
466
|
+
"bundle_present": "true" if present else "false",
|
|
467
|
+
"bundle_md_count": md_count,
|
|
468
|
+
}))
|
|
469
|
+
return 0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _slug(s):
|
|
473
|
+
return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", (s or "").lower())).strip("-")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _pascal(s):
|
|
477
|
+
"""kebab/space/anything -> PascalCase, byte-compatible with the bash/ps
|
|
478
|
+
_pascal + set-feature normalizer (uppercase each word's first char, keep the
|
|
479
|
+
rest as-is so an existing PascalCase token like MedicinalProduct is preserved)."""
|
|
480
|
+
words = re.split(r"[^A-Za-z0-9]+", s or "")
|
|
481
|
+
return "".join(w[:1].upper() + w[1:] for w in words if w)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _read_frontmatter(path):
|
|
485
|
+
"""Return the concept's frontmatter dict (title/type/source_files), best-effort
|
|
486
|
+
without a YAML dependency. Only the fields adopt needs."""
|
|
487
|
+
try:
|
|
488
|
+
with open(path, encoding="utf-8") as f:
|
|
489
|
+
text = f.read()
|
|
490
|
+
except OSError:
|
|
491
|
+
return {}
|
|
492
|
+
if not text.startswith("---"):
|
|
493
|
+
return {}
|
|
494
|
+
lines = text.splitlines()
|
|
495
|
+
end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
|
|
496
|
+
if end is None:
|
|
497
|
+
return {}
|
|
498
|
+
data, key = {}, None
|
|
499
|
+
for ln in lines[1:end]:
|
|
500
|
+
m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
|
|
501
|
+
if m:
|
|
502
|
+
key = m.group(1)
|
|
503
|
+
val = m.group(2).strip()
|
|
504
|
+
data[key] = val.strip("'\"") if val else []
|
|
505
|
+
elif key and re.match(r"^\s*-\s+", ln): # a list item under the last key
|
|
506
|
+
if not isinstance(data.get(key), list):
|
|
507
|
+
data[key] = []
|
|
508
|
+
data[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
|
|
509
|
+
return data
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def cmd_concept(args):
|
|
513
|
+
"""Resolve the OKF bundle concept for an aggregate → its path + source_files
|
|
514
|
+
(the code that IS the slice). The index adopt reverse-engineers spec.md +
|
|
515
|
+
design.md FROM. Matches on title/filename slug; prefers an Aggregate type."""
|
|
516
|
+
bundle = os.path.abspath(args.bundle)
|
|
517
|
+
want = _slug(args.aggregate)
|
|
518
|
+
best = None
|
|
519
|
+
if os.path.isdir(bundle):
|
|
520
|
+
for dirpath, dirnames, filenames in os.walk(bundle):
|
|
521
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
522
|
+
for fn in filenames:
|
|
523
|
+
if not fn.endswith(".md") or fn in ("index.md", "log.md"):
|
|
524
|
+
continue
|
|
525
|
+
path = os.path.join(dirpath, fn)
|
|
526
|
+
fm = _read_frontmatter(path)
|
|
527
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
528
|
+
base = os.path.splitext(fn)[0]
|
|
529
|
+
is_match = want in (_slug(title), _slug(base)) or (
|
|
530
|
+
want and title and want.replace("-", "") in _slug(title).replace("-", ""))
|
|
531
|
+
if not is_match:
|
|
532
|
+
continue
|
|
533
|
+
typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
|
|
534
|
+
sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
|
|
535
|
+
score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
|
|
536
|
+
cand = {"concept_path": os.path.relpath(path, os.getcwd()),
|
|
537
|
+
"title": title or base, "type": typ, "source_files": sf, "score": score}
|
|
538
|
+
if best is None or cand["score"] > best["score"]:
|
|
539
|
+
best = cand
|
|
540
|
+
if not best:
|
|
541
|
+
print(json.dumps({"exists": "false", "aggregate": args.aggregate, "concept_path": "",
|
|
542
|
+
"source_files": [], "count": 0, "module_dir": ""}))
|
|
543
|
+
return 0
|
|
544
|
+
sf = best["source_files"]
|
|
545
|
+
# Common directory prefix of the source files = the aggregate's module dir.
|
|
546
|
+
module_dir = ""
|
|
547
|
+
if sf:
|
|
548
|
+
common = os.path.commonpath([s.replace("\\", "/") for s in sf]) if len(sf) > 1 else os.path.dirname(sf[0])
|
|
549
|
+
module_dir = common
|
|
550
|
+
print(f"Concept for {args.aggregate}: {best['concept_path']} "
|
|
551
|
+
f"({len(sf)} source file(s), module {module_dir or '-'})", file=sys.stderr)
|
|
552
|
+
print(json.dumps({
|
|
553
|
+
"exists": "true", "aggregate": args.aggregate, "concept_path": best["concept_path"],
|
|
554
|
+
"title": best["title"], "type": best["type"],
|
|
555
|
+
"source_files": sf, "count": len(sf), "module_dir": module_dir,
|
|
556
|
+
}))
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def cmd_list_aggregates(args):
|
|
561
|
+
"""Enumerate the OKF bundle's Aggregate concepts — the brownfield FEATURE MAP.
|
|
562
|
+
This is the selectable feature list `select-feature` offers in a brownfield
|
|
563
|
+
repo (the discovered aggregates), as opposed to only the specs/ sub-folders.
|
|
564
|
+
|
|
565
|
+
Each aggregate → its candidate specs/<Feature> slice name (PascalCase, the id
|
|
566
|
+
set-feature would create), its title/concept_path, and source_file_count. The
|
|
567
|
+
caller merges these with the specs/ list, deduping by name (case-insensitive).
|
|
568
|
+
Empty {aggregates:[], count:0} on a greenfield repo (no bundle) — a no-op there.
|
|
569
|
+
"""
|
|
570
|
+
bundle = os.path.abspath(args.bundle)
|
|
571
|
+
present = os.path.isdir(bundle)
|
|
572
|
+
by_name = {}
|
|
573
|
+
if present:
|
|
574
|
+
for dirpath, dirnames, filenames in os.walk(bundle):
|
|
575
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
576
|
+
for fn in sorted(filenames):
|
|
577
|
+
if not fn.endswith(".md") or fn in ("index.md", "log.md"):
|
|
578
|
+
continue
|
|
579
|
+
path = os.path.join(dirpath, fn)
|
|
580
|
+
fm = _read_frontmatter(path)
|
|
581
|
+
typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
|
|
582
|
+
if "aggregate" not in typ.lower():
|
|
583
|
+
continue
|
|
584
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
585
|
+
base = os.path.splitext(fn)[0]
|
|
586
|
+
name = _pascal(title or base)
|
|
587
|
+
if not name:
|
|
588
|
+
continue
|
|
589
|
+
sf = [s for s in (fm.get("source_files") or []) if isinstance(s, str) and s.strip()]
|
|
590
|
+
cand = {
|
|
591
|
+
"name": name,
|
|
592
|
+
"title": title or base,
|
|
593
|
+
"slug": _slug(name),
|
|
594
|
+
"concept_path": os.path.relpath(path, os.getcwd()),
|
|
595
|
+
"type": typ,
|
|
596
|
+
"source_file_count": len(sf),
|
|
597
|
+
}
|
|
598
|
+
# Dedup by the case-insensitive feature name; a filename-slug match
|
|
599
|
+
# to the wanted id, or a richer source set, wins the tie.
|
|
600
|
+
key = name.lower()
|
|
601
|
+
prev = by_name.get(key)
|
|
602
|
+
if prev is None or (
|
|
603
|
+
(_slug(base) == cand["slug"]) or cand["source_file_count"] > prev["source_file_count"]):
|
|
604
|
+
by_name[key] = cand
|
|
605
|
+
aggs = sorted(by_name.values(), key=lambda a: a["name"].lower())
|
|
606
|
+
print(f"OKF aggregates in {args.bundle}: {len(aggs)} "
|
|
607
|
+
f"({', '.join(a['name'] for a in aggs) or '-'})", file=sys.stderr)
|
|
608
|
+
print(json.dumps({
|
|
609
|
+
"aggregates": aggs, "count": len(aggs),
|
|
610
|
+
"bundle": args.bundle, "bundle_present": "true" if present else "false",
|
|
611
|
+
}))
|
|
612
|
+
return 0
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def cmd_validate(args):
|
|
616
|
+
validator = os.path.join(HERE, "validate_okf.py")
|
|
617
|
+
if not os.path.isfile(validator):
|
|
618
|
+
print(json.dumps({"result": "ERROR", "conformant": "false",
|
|
619
|
+
"errors": [f"validator not found: {validator}"], "warnings": [],
|
|
620
|
+
"concepts": 0}))
|
|
621
|
+
return 1
|
|
622
|
+
if not os.path.isdir(args.bundle):
|
|
623
|
+
print(json.dumps({"result": "ERROR", "conformant": "false",
|
|
624
|
+
"errors": [f"bundle dir not found: {args.bundle}"],
|
|
625
|
+
"warnings": [], "concepts": 0}))
|
|
626
|
+
return 1
|
|
627
|
+
cmd = [sys.executable, validator, args.bundle, "--json"]
|
|
628
|
+
if args.repo_root:
|
|
629
|
+
cmd += ["--repo-root", args.repo_root]
|
|
630
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
631
|
+
try:
|
|
632
|
+
# validate_okf.py --json emits one pretty-printed (multi-line) object.
|
|
633
|
+
raw = proc.stdout.strip()
|
|
634
|
+
data = json.loads(raw[raw.index("{"):])
|
|
635
|
+
except Exception:
|
|
636
|
+
print(json.dumps({"result": "ERROR", "conformant": "false",
|
|
637
|
+
"errors": [f"validator emitted no JSON: {proc.stdout[:300]} "
|
|
638
|
+
f"{proc.stderr[:300]}"], "warnings": [], "concepts": 0}))
|
|
639
|
+
return 1
|
|
640
|
+
# Mirror validate_okf's human report to stderr for the operator.
|
|
641
|
+
print(f"OKF validation of {args.bundle}: {data.get('result')} "
|
|
642
|
+
f"({data.get('concepts')} concepts, {len(data.get('errors', []))} errors, "
|
|
643
|
+
f"{len(data.get('warnings', []))} warnings)", file=sys.stderr)
|
|
644
|
+
for e in data.get("errors", []):
|
|
645
|
+
print(f" ERROR {e}", file=sys.stderr)
|
|
646
|
+
for w in data.get("warnings", []):
|
|
647
|
+
print(f" WARNING {w}", file=sys.stderr)
|
|
648
|
+
data["conformant"] = "false" if data.get("errors") else "true"
|
|
649
|
+
print(json.dumps(data))
|
|
650
|
+
return 1 if data.get("errors") else 0
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def main():
|
|
654
|
+
ap = argparse.ArgumentParser(description="Brownfield inventory scanner + OKF validator bridge.")
|
|
655
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
656
|
+
|
|
657
|
+
ps = sub.add_parser("scan", help="Scan a brownfield repo; emit the JSON inventory substrate.")
|
|
658
|
+
ps.add_argument("--root", help="Codebase to inventory (default: CWD; git history scoped to it).")
|
|
659
|
+
ps.add_argument("--out", help="Substrate JSON path (default: <root>/.aw-spec/inventory/inventory.json).")
|
|
660
|
+
ps.add_argument("--bundle", help="OKF bundle dir to check for existing curation (default: <root>/knowledge).")
|
|
661
|
+
ps.add_argument("--config", help="okf-config.yml whose exclude: list is honored.")
|
|
662
|
+
ps.set_defaults(func=cmd_scan)
|
|
663
|
+
|
|
664
|
+
pc = sub.add_parser("concept", help="Resolve the OKF bundle concept + source_files for an aggregate.")
|
|
665
|
+
pc.add_argument("--bundle", required=True, help="OKF bundle dir to search.")
|
|
666
|
+
pc.add_argument("--aggregate", required=True, help="Aggregate name (e.g. MedicinalProduct).")
|
|
667
|
+
pc.set_defaults(func=cmd_concept)
|
|
668
|
+
|
|
669
|
+
pl = sub.add_parser("list-aggregates", help="List the OKF bundle's Aggregate concepts (the brownfield feature map).")
|
|
670
|
+
pl.add_argument("--bundle", required=True, help="OKF bundle dir to enumerate.")
|
|
671
|
+
pl.set_defaults(func=cmd_list_aggregates)
|
|
672
|
+
|
|
673
|
+
pv = sub.add_parser("validate", help="Run the OKF v0.1 conformance checker on a bundle.")
|
|
674
|
+
pv.add_argument("--bundle", required=True, help="OKF bundle dir to validate.")
|
|
675
|
+
pv.add_argument("--repo-root", help="Repo root for resolving source_files (default: nearest .git).")
|
|
676
|
+
pv.set_defaults(func=cmd_validate)
|
|
677
|
+
|
|
678
|
+
args = ap.parse_args()
|
|
679
|
+
return args.func(args)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
if __name__ == "__main__":
|
|
683
|
+
sys.exit(main())
|