aw-spec-cli 0.1.7 → 0.1.9
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/assets/.appweaver/DESIGN-FORMAT.md +200 -0
- package/assets/.appweaver/ORCH-DIALECT.md +156 -0
- package/assets/.appweaver/RULE-PARTITION.md +175 -0
- package/assets/.appweaver/STACK-PROFILES.md +461 -0
- package/assets/.appweaver/ai-rules.md +180 -0
- package/assets/.appweaver/domain.json +1 -0
- package/assets/.appweaver/extensions/.registry +19 -0
- package/assets/.appweaver/extensions/agent-context/README.md +57 -0
- package/assets/.appweaver/extensions/agent-context/agent-context-config.yml +4 -0
- package/assets/.appweaver/extensions/agent-context/commands/aw-spec.agent-context.update.md +26 -0
- package/assets/.appweaver/extensions/agent-context/extension.yml +37 -0
- package/assets/.appweaver/extensions/agent-context/scripts/bash/update-agent-context.sh +201 -0
- package/assets/.appweaver/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +237 -0
- package/assets/.appweaver/extensions.yml +16 -0
- package/assets/.appweaver/feature.json +3 -0
- package/assets/.appweaver/init-options.json +9 -0
- package/assets/.appweaver/integration.json +15 -0
- package/assets/.appweaver/integrations/aw-spec.manifest.json +9 -0
- package/assets/.appweaver/interpreter/run-skill.py +1176 -0
- package/assets/.appweaver/memory/constitution.md +50 -0
- package/assets/.appweaver/orchestrator/.smoke-out/smoke.md +6 -0
- package/assets/.appweaver/orchestrator/AGENT-PROFILE.md +94 -0
- package/assets/.appweaver/orchestrator/README.md +300 -0
- package/assets/.appweaver/orchestrator/adapters/claude_agent_sdk.py +74 -0
- package/assets/.appweaver/orchestrator/adapters/google_antigravity.py +74 -0
- package/assets/.appweaver/orchestrator/modules/delta-design.orch.yaml +105 -0
- package/assets/.appweaver/orchestrator/modules/delta-spec.orch.yaml +101 -0
- package/assets/.appweaver/orchestrator/modules/domain-select.orch.yaml +90 -0
- package/assets/.appweaver/orchestrator/modules/lane-gate.orch.yaml +126 -0
- package/assets/.appweaver/orchestrator/modules/tech-select.orch.yaml +123 -0
- package/assets/.appweaver/orchestrator/orchestrator.py +1721 -0
- package/assets/.appweaver/orchestrator/rev-templates/ai-rules.reference.md +40 -0
- package/assets/.appweaver/orchestrator/rev-templates/delta-design-template.skeleton.md +101 -0
- package/assets/.appweaver/orchestrator/rev-templates/design-template.skeleton.md +245 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-constitution.skeleton.md +63 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-requirements.skeleton.md +48 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-bindings.skeleton.md +59 -0
- package/assets/.appweaver/orchestrator/rev-templates/domain-rule-file.skeleton.md +39 -0
- package/assets/.appweaver/orchestrator/rev-templates/frontend-rule-file.skeleton.md +63 -0
- package/assets/.appweaver/orchestrator/rev-templates/tech-rule-bindings.skeleton.md +116 -0
- package/assets/.appweaver/orchestrator/rev-templates/technical-constitution.skeleton.md +106 -0
- package/assets/.appweaver/orchestrator/rev-templates/technical-rule-file.skeleton.md +54 -0
- package/assets/.appweaver/orchestrator/rev-templates/ui-constitution.skeleton.md +125 -0
- package/assets/.appweaver/orchestrator/rev-templates/ui-delta-design-template.skeleton.md +123 -0
- package/assets/.appweaver/orchestrator/rev-templates/ui-design-template.skeleton.md +294 -0
- package/assets/.appweaver/orchestrator/workflows/_smoke.orch.yaml +160 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-add-component.orch.yaml +270 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-adopt.orch.yaml +934 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-delta-design.orch.yaml +251 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-design.orch.yaml +265 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-analyze-slice.orch.yaml +437 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-build.orch.yaml +197 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-characterize.orch.yaml +475 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-close-delta.orch.yaml +181 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-constitution.orch.yaml +727 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-demo.orch.yaml +100 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-dependency-tree.orch.yaml +69 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-deploy.orch.yaml +85 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design-ui.orch.yaml +386 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-design.orch.yaml +382 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-finalize-delta.orch.yaml +462 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-foundation.orch.yaml +265 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-delta.orch.yaml +253 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement-ui.orch.yaml +267 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-implement.orch.yaml +287 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-inventory.orch.yaml +238 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-onboard.orch.yaml +1822 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-parity.orch.yaml +293 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-run.orch.yaml +194 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-domain.orch.yaml +83 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-feature.orch.yaml +128 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-select-stack.orch.yaml +296 -0
- package/assets/.appweaver/orchestrator/workflows/aw-spec-specify.orch.yaml +439 -0
- package/assets/.appweaver/scripts/bash/aw-inventory.sh +17 -0
- package/assets/.appweaver/scripts/bash/aw-parity.sh +13 -0
- package/assets/.appweaver/scripts/bash/aw-rev-constitution.sh +13 -0
- package/assets/.appweaver/scripts/bash/aw-spec-actions.sh +2626 -0
- package/assets/.appweaver/scripts/bash/aw-xref.sh +15 -0
- package/assets/.appweaver/scripts/bash/common.sh +533 -0
- package/assets/.appweaver/scripts/powershell/aw-inventory.ps1 +17 -0
- package/assets/.appweaver/scripts/powershell/aw-parity.ps1 +13 -0
- package/assets/.appweaver/scripts/powershell/aw-rev-constitution.ps1 +13 -0
- package/assets/.appweaver/scripts/powershell/aw-spec-actions.ps1 +2232 -0
- package/assets/.appweaver/scripts/powershell/aw-xref.ps1 +15 -0
- package/assets/.appweaver/scripts/python/aw-component-catalog.py +121 -0
- package/assets/.appweaver/scripts/python/aw-inventory.py +1180 -0
- package/assets/.appweaver/scripts/python/aw-parity.py +380 -0
- package/assets/.appweaver/scripts/python/aw-rev-constitution.py +329 -0
- package/assets/.appweaver/scripts/python/aw-stack.py +1251 -0
- package/assets/.appweaver/scripts/python/aw-xref.py +1330 -0
- package/assets/.appweaver/scripts/python/validate_okf.py +352 -0
- package/assets/.appweaver/stack-profiles/angular.json +16 -0
- package/assets/.appweaver/stack-profiles/blazor-wasm.json +17 -0
- package/assets/.appweaver/stack-profiles/dotnet-webapi.json +15 -0
- package/assets/.appweaver/stack-profiles/go-chi.json +14 -0
- package/assets/.appweaver/stack-profiles/java-spring.json +16 -0
- package/assets/.appweaver/stack-profiles/nextjs.json +16 -0
- package/assets/.appweaver/stack-profiles/node-express.json +15 -0
- package/assets/.appweaver/stack-profiles/python-fastapi.json +16 -0
- package/assets/.appweaver/stack-profiles/react-vite.json +17 -0
- package/assets/.appweaver/stack-profiles/sveltekit.json +16 -0
- package/assets/.appweaver/stack-profiles/vue-vite.json +17 -0
- package/assets/.appweaver/stacks.json +3 -0
- package/assets/.appweaver/templates/constitution-domain-template.md +116 -0
- package/assets/.appweaver/templates/constitution-technical-template.md +108 -0
- package/assets/.appweaver/templates/constitution-ui-template.md +116 -0
- package/assets/.appweaver/templates/delta-design-template.md +69 -0
- package/assets/.appweaver/templates/delta-spec-template.md +63 -0
- package/assets/.appweaver/templates/design-template.md +383 -0
- package/assets/.appweaver/templates/okf-concept-template.md +114 -0
- package/assets/.appweaver/templates/parity-manifest-template.json +49 -0
- package/assets/.appweaver/templates/source-map-template.md +85 -0
- package/assets/.appweaver/templates/spec-template.md +309 -0
- package/assets/.appweaver/templates/ui-design-template.md +273 -0
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.Reference.pack.txt +25248 -0
- package/assets/foundations/dotnet/ModularMonolith/ModularMonolith.pack.txt +5726 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-foundation.ps1 +145 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-foundation.sh +196 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.ps1 +157 -0
- package/assets/foundations/dotnet/ModularMonolith/pack-reference-foundation.sh +209 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.ps1 +126 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-foundation.sh +217 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.ps1 +129 -0
- package/assets/foundations/dotnet/ModularMonolith/unpack-reference-foundation.sh +218 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.Reference.pack.txt +19318 -0
- package/assets/foundations/dotnet/VerticalSlice/VerticalSlice.pack.txt +2833 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-foundation.ps1 +145 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-foundation.sh +196 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.ps1 +157 -0
- package/assets/foundations/dotnet/VerticalSlice/pack-reference-foundation.sh +209 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.ps1 +126 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-foundation.sh +217 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.ps1 +129 -0
- package/assets/foundations/dotnet/VerticalSlice/unpack-reference-foundation.sh +218 -0
- package/assets/foundations/dotnet/components/Email/README.md +119 -0
- package/assets/foundations/dotnet/components/Email/ReferenceEndpoints/SendEmailEndpoint.cs +84 -0
- package/assets/foundations/dotnet/components/Email/integration.md +300 -0
- package/assets/foundations/dotnet/components/Jobs/README.md +113 -0
- package/assets/foundations/dotnet/components/Jobs/integration.md +259 -0
- package/assets/foundations/dotnet/components/QrGenerator/README.md +85 -0
- package/assets/foundations/dotnet/components/QrGenerator/ReferenceEndpoints/GenerateQrEndpoint.cs +57 -0
- package/assets/foundations/dotnet/components/QrGenerator/integration.md +230 -0
- package/assets/foundations/dotnet/components/README.md +74 -0
- package/assets/guardrails/README.md +222 -0
- package/assets/guardrails/domain/healthcare/ai-rules.domain.md +34 -0
- package/assets/guardrails/domain/healthcare/api-versioning.domain.md +17 -0
- package/assets/guardrails/domain/healthcare/auditing.domain.md +97 -0
- package/assets/guardrails/domain/healthcare/authorization.domain.md +26 -0
- package/assets/guardrails/domain/healthcare/clinical-terms.md +20 -0
- package/assets/guardrails/domain/healthcare/coding-standards.domain.md +26 -0
- package/assets/guardrails/domain/healthcare/consent.md +19 -0
- package/assets/guardrails/domain/healthcare/cqrs.domain.md +12 -0
- package/assets/guardrails/domain/healthcare/data-classification.domain.md +28 -0
- package/assets/guardrails/domain/healthcare/dependency-policy.domain.md +11 -0
- package/assets/guardrails/domain/healthcare/dicom.md +15 -0
- package/assets/guardrails/domain/healthcare/domain-constitution.md +119 -0
- package/assets/guardrails/domain/healthcare/domain-driven-design.domain.md +28 -0
- package/assets/guardrails/domain/healthcare/eventing.domain.md +18 -0
- package/assets/guardrails/domain/healthcare/fhir.md +36 -0
- package/assets/guardrails/domain/healthcare/folder-structure.domain.md +25 -0
- package/assets/guardrails/domain/healthcare/hipaa.md +34 -0
- package/assets/guardrails/domain/healthcare/hl7.md +15 -0
- package/assets/guardrails/domain/healthcare/ihe-conformance.md +31 -0
- package/assets/guardrails/domain/healthcare/integration-terms.md +21 -0
- package/assets/guardrails/domain/healthcare/persistence.domain.md +20 -0
- package/assets/guardrails/domain/healthcare/terminology.md +30 -0
- package/assets/guardrails/domain/healthcare/testing.domain.md +15 -0
- package/assets/guardrails/domain/healthcare/ux/accessibility-standards.md +36 -0
- package/assets/guardrails/domain/healthcare/ux/content-design.md +32 -0
- package/assets/guardrails/domain/healthcare/ux/interaction-states.md +44 -0
- package/assets/guardrails/domain/healthcare/ux/journey-mapping.md +40 -0
- package/assets/guardrails/domain/healthcare/vertical-slices.domain.md +18 -0
- package/assets/guardrails/domain/healthcare/zero-trust.domain.md +12 -0
- package/assets/guardrails/domain/pharma/ai-rules.domain.md +32 -0
- package/assets/guardrails/domain/pharma/api-versioning.domain.md +20 -0
- package/assets/guardrails/domain/pharma/atc.md +22 -0
- package/assets/guardrails/domain/pharma/auditing.domain.md +90 -0
- package/assets/guardrails/domain/pharma/authorization.domain.md +25 -0
- package/assets/guardrails/domain/pharma/coding-standards.domain.md +28 -0
- package/assets/guardrails/domain/pharma/cqrs.domain.md +16 -0
- package/assets/guardrails/domain/pharma/data-classification.domain.md +24 -0
- package/assets/guardrails/domain/pharma/data-provenance.md +21 -0
- package/assets/guardrails/domain/pharma/dependency-policy.domain.md +11 -0
- package/assets/guardrails/domain/pharma/domain-constitution.md +113 -0
- package/assets/guardrails/domain/pharma/domain-driven-design.domain.md +36 -0
- package/assets/guardrails/domain/pharma/domain-requirements.sample.md +103 -0
- package/assets/guardrails/domain/pharma/eventing.domain.md +18 -0
- package/assets/guardrails/domain/pharma/fhir.md +31 -0
- package/assets/guardrails/domain/pharma/folder-structure.domain.md +26 -0
- package/assets/guardrails/domain/pharma/hl7.md +17 -0
- package/assets/guardrails/domain/pharma/idmp.md +26 -0
- package/assets/guardrails/domain/pharma/integration-terms.md +21 -0
- package/assets/guardrails/domain/pharma/ndc.md +28 -0
- package/assets/guardrails/domain/pharma/persistence.domain.md +23 -0
- package/assets/guardrails/domain/pharma/pharma-terms.md +27 -0
- package/assets/guardrails/domain/pharma/product-lifecycle.domain.md +30 -0
- package/assets/guardrails/domain/pharma/regulatory-compliance.md +19 -0
- package/assets/guardrails/domain/pharma/terminology.md +32 -0
- package/assets/guardrails/domain/pharma/testing.domain.md +19 -0
- package/assets/guardrails/domain/pharma/vertical-slices.domain.md +17 -0
- package/assets/guardrails/domain/pharma/zero-trust.domain.md +12 -0
- package/assets/guardrails/projects/dotnet-default-project/domain/domain-requirements.md +58 -0
- package/assets/guardrails/projects/dotnet-default-project/domain-rule-bindings.md +113 -0
- package/assets/guardrails/projects/dotnet-default-project/frontend/ui-requirements.md +43 -0
- package/assets/guardrails/projects/dotnet-default-project/service/technical-requirements.md +40 -0
- package/assets/guardrails/projects/dotnet-default-project/tech-rule-bindings.md +263 -0
- package/assets/guardrails/requirement-spec.txt +27 -0
- package/assets/guardrails/technology/dotnet-webapi/PACKAGING.md +184 -0
- package/assets/guardrails/technology/dotnet-webapi/api-versioning.md +44 -0
- package/assets/guardrails/technology/dotnet-webapi/auditing.md +96 -0
- package/assets/guardrails/technology/dotnet-webapi/authorization-matrix.md +14 -0
- package/assets/guardrails/technology/dotnet-webapi/authorization.md +39 -0
- package/assets/guardrails/technology/dotnet-webapi/clean-architecture.md +54 -0
- package/assets/guardrails/technology/dotnet-webapi/coding-standards.md +87 -0
- package/assets/guardrails/technology/dotnet-webapi/cqrs.md +63 -0
- package/assets/guardrails/technology/dotnet-webapi/data-classification.md +38 -0
- package/assets/guardrails/technology/dotnet-webapi/dependency-policy.md +40 -0
- package/assets/guardrails/technology/dotnet-webapi/domain-driven-design.md +91 -0
- package/assets/guardrails/technology/dotnet-webapi/eventing.md +57 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/api-versioning.md +75 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/auditing.md +84 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/authorization.md +68 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/clean-architecture.md +37 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/cqrs.md +97 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/domain-driven-design.md +141 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/eventing.md +46 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/persistence.md +207 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/vertical-slices.md +151 -0
- package/assets/guardrails/technology/dotnet-webapi/examples/zero-trust.md +48 -0
- package/assets/guardrails/technology/dotnet-webapi/folder-structure.md +61 -0
- package/assets/guardrails/technology/dotnet-webapi/modular-monolith.md +47 -0
- package/assets/guardrails/technology/dotnet-webapi/persistence.md +82 -0
- package/assets/guardrails/technology/dotnet-webapi/technology-constitution.md +162 -0
- package/assets/guardrails/technology/dotnet-webapi/testing.md +55 -0
- package/assets/guardrails/technology/dotnet-webapi/vertical-slices.md +70 -0
- package/assets/guardrails/technology/dotnet-webapi/zero-trust.md +48 -0
- package/assets/guardrails/technology/java-spring/README.md +62 -0
- package/assets/guardrails/technology/nextjs/accessibility-implementation.md +56 -0
- package/assets/guardrails/technology/nextjs/api-client.md +56 -0
- package/assets/guardrails/technology/nextjs/api-integration.dotnet-webapi.md +174 -0
- package/assets/guardrails/technology/nextjs/api-integration.java-spring.md +203 -0
- package/assets/guardrails/technology/nextjs/auth-session.md +68 -0
- package/assets/guardrails/technology/nextjs/client-security.md +69 -0
- package/assets/guardrails/technology/nextjs/component-architecture.md +60 -0
- package/assets/guardrails/technology/nextjs/component-conventions.md +58 -0
- package/assets/guardrails/technology/nextjs/design-system.md +60 -0
- package/assets/guardrails/technology/nextjs/error-handling.md +72 -0
- package/assets/guardrails/technology/nextjs/forms-and-validation.md +75 -0
- package/assets/guardrails/technology/nextjs/frontend-testing.md +94 -0
- package/assets/guardrails/technology/nextjs/i18n.md +57 -0
- package/assets/guardrails/technology/nextjs/observability.md +67 -0
- package/assets/guardrails/technology/nextjs/performance-budgets.md +66 -0
- package/assets/guardrails/technology/nextjs/routing-navigation.md +52 -0
- package/assets/guardrails/technology/nextjs/state-management.md +70 -0
- package/assets/guardrails/technology/nextjs/ui-constitution.md +166 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/utils/copy-assets.js +8 -8
- package/dist/utils/copy-assets.js.map +1 -1
- package/package.json +1 -1
- package/assets/docs/pharma-constitution.my-ref.md +0 -86
|
@@ -0,0 +1,1330 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""aw-xref.py — deterministic cross-reference (OKF dependency) substrate.
|
|
3
|
+
|
|
4
|
+
Maintains the cross-slice dependency edges INSIDE the OKF knowledge bundle, so a
|
|
5
|
+
change to one aggregate can see every other aggregate that uses it (its blast
|
|
6
|
+
radius) without a central graph. Two mirrored tables live in each concept:
|
|
7
|
+
|
|
8
|
+
• Depends-On (FORWARD) in the USING aggregate's concept — its own deps + the
|
|
9
|
+
prune index.
|
|
10
|
+
• Cross-References (REVERSE) in the USED aggregate's concept — its blast radius,
|
|
11
|
+
reviewed at CR time.
|
|
12
|
+
|
|
13
|
+
Edges are DERIVED deterministically from the using aggregate's design.md — the §1
|
|
14
|
+
domain/data-model section's "References (by id, not owned)" (+ `id<Other>` attribute
|
|
15
|
+
types) for entity-ref edges, and the External Integrations table (located by heading
|
|
16
|
+
keyword, so it works for both the forward and brownfield design layouts) for contract
|
|
17
|
+
edges — resolved against the project's KNOWN aggregates (a specs/<Y>/ dir or an
|
|
18
|
+
existing bundle concept). A
|
|
19
|
+
target that is not a known aggregate (an external system, an owned child entity)
|
|
20
|
+
is never written. Writers are greenfield `implement` and brownfield `adopt`; the
|
|
21
|
+
reader is the CR loop (finalize-delta gates on the Cross-References blast radius).
|
|
22
|
+
|
|
23
|
+
RECONCILE-ON-WRITE: every `record` first strips ALL rows authored by the using
|
|
24
|
+
slice from every concept, then re-adds its current edges — so a dropped
|
|
25
|
+
dependency self-heals (no stale reverse edge). Rows are keyed by the using slice.
|
|
26
|
+
|
|
27
|
+
Subcommands, each printing a single JSON object as the LAST stdout line (human
|
|
28
|
+
summary to STDERR so it never corrupts the JSON contract):
|
|
29
|
+
|
|
30
|
+
deps --design <design.md> --aggregate X [--root DIR] [--specs specs] [--bundle knowledge]
|
|
31
|
+
-> the internal edges X declares. {aggregate, edges[], edge_count, known[], root}
|
|
32
|
+
|
|
33
|
+
record --using X --design <design.md> [--origin greenfield|reverse-engineered]
|
|
34
|
+
[--date YYYY-MM-DD] [--root DIR] [--specs specs] [--bundle knowledge]
|
|
35
|
+
-> reconcile X's edges across the bundle (stub used concepts if missing, prune
|
|
36
|
+
stale rows, upsert current rows, rewrite X's Depends-On).
|
|
37
|
+
{using, origin, edges[], edge_count, wrote[], stubbed[], pruned, concept, root}
|
|
38
|
+
|
|
39
|
+
list --aggregate Y [--root DIR] [--bundle knowledge]
|
|
40
|
+
-> Y's blast radius (who uses Y). {aggregate, concept, users[], count, root}
|
|
41
|
+
|
|
42
|
+
concept-upsert --aggregate X [--source-files a,b,c] [--merge] [--title T] [--description D]
|
|
43
|
+
[--origin greenfield|reverse-engineered] [--root DIR] [--bundle knowledge]
|
|
44
|
+
-> create/update X's OWN concept (frontmatter type/title/source_files), PRESERVING
|
|
45
|
+
its body — never clobbers the Cross-References other slices wrote. --merge UNIONs
|
|
46
|
+
source_files (CR deltas) instead of replacing. source_files is written as a FLAT
|
|
47
|
+
list, path-grouped by the code's OWN folders via YAML comment headers (adaptive —
|
|
48
|
+
see _group_source_files), so the listing mirrors the real architecture.
|
|
49
|
+
{aggregate, concept, created, source_file_count, root}
|
|
50
|
+
|
|
51
|
+
concept-overview --aggregate X (--text PROSE | --text-file F) [--root DIR] [--bundle knowledge]
|
|
52
|
+
-> splice model-authored prose into X's `## Overview` ONLY (the one section a
|
|
53
|
+
deterministic script cannot write). Empty/placeholder text is a no-op.
|
|
54
|
+
Run AFTER concept-upsert. {aggregate, concept, written, root}
|
|
55
|
+
|
|
56
|
+
concept-paths --aggregate X [--root DIR] [--bundle knowledge]
|
|
57
|
+
-> READ-ONLY: the slice's source files, from its concept's `source_files`. The
|
|
58
|
+
answer the executable phases (build/run/analyze-slice) need for "which files is
|
|
59
|
+
this slice made of", replacing the retired `design-paths` fence scan. `paths` is
|
|
60
|
+
filtered to what EXISTS on disk (safe to feed build_context); `missing` reports
|
|
61
|
+
declared-but-absent files (real index drift). Never stubs a concept; no concept
|
|
62
|
+
is an empty answer, not an error.
|
|
63
|
+
{aggregate, concept, found, paths[], count, csv, declared[], declared_count,
|
|
64
|
+
missing[], missing_count, root}
|
|
65
|
+
|
|
66
|
+
validate [--prune] [--root DIR] [--bundle knowledge]
|
|
67
|
+
-> drift check beyond reconcile-on-write. reverse-orphan = a Cross-References row with
|
|
68
|
+
no backing Depends-On (stale; --prune removes it); forward-orphan = a Depends-On
|
|
69
|
+
row with no matching Cross-References (reported; fix by re-record).
|
|
70
|
+
{orphans[], orphan_count, reverse_orphans, forward_orphans, pruned, ok, root}
|
|
71
|
+
|
|
72
|
+
log [--note TEXT] [--date YYYY-MM-DD] [--root DIR] [--bundle knowledge]
|
|
73
|
+
-> append a commit-anchored entry to the bundle log.md (OKF §7 freshness journal).
|
|
74
|
+
No-op when there is no bundle or no git commit. {logged, commit, date, log, root}
|
|
75
|
+
|
|
76
|
+
Only the standard library is used. Usage: aw-xref.py <sub> [options]
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
import argparse
|
|
80
|
+
import datetime
|
|
81
|
+
import json
|
|
82
|
+
import os
|
|
83
|
+
import re
|
|
84
|
+
import subprocess
|
|
85
|
+
import sys
|
|
86
|
+
|
|
87
|
+
# ───────────────────────────── canonical shapes ────────────────────────────
|
|
88
|
+
OVERVIEW_HEADING = "## Overview"
|
|
89
|
+
DEPS_HEADING = "## Depends-On"
|
|
90
|
+
XREF_HEADING = "## Cross-References"
|
|
91
|
+
DEPS_HEADER = ["Target", "Via", "Kind"]
|
|
92
|
+
XREF_HEADER = ["Using slice", "Uses (via)", "Kind", "Origin", "Recorded"]
|
|
93
|
+
DEPS_COMMENT = (
|
|
94
|
+
"<!-- FORWARD edges — sibling aggregates this one references, derived from "
|
|
95
|
+
"design.md §1 (the model section) + its External integrations table. Prune index; "
|
|
96
|
+
"regenerated every implement/adopt. Do not hand-edit. -->"
|
|
97
|
+
)
|
|
98
|
+
XREF_COMMENT = (
|
|
99
|
+
"<!-- REVERSE edges — sibling aggregates that USE this one (its blast radius). "
|
|
100
|
+
"Each row written by the using slice's implement/adopt (reconcile-on-write). "
|
|
101
|
+
"Reviewed at CR time. Do not hand-edit. -->"
|
|
102
|
+
)
|
|
103
|
+
RESERVED = {"index.md", "log.md"}
|
|
104
|
+
|
|
105
|
+
# A Cross-References "Using slice" cell that is safe to hand to a VSTest --filter (and a
|
|
106
|
+
# real aggregate name, not the "_(none observed…)_" placeholder or a malformed cell).
|
|
107
|
+
_IDENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._-]*$")
|
|
108
|
+
|
|
109
|
+
# ─────────────────── source_files architecture grouping ────────────────────
|
|
110
|
+
# `source_files` stays a FLAT YAML list — validate_okf (E2/W6), the aw-inventory
|
|
111
|
+
# concept resolver and the source-map fidelity check all key on that shape, and
|
|
112
|
+
# both lenient frontmatter parsers in the toolchain only understand scalars +
|
|
113
|
+
# flat block lists. So the grouping is emitted as YAML comment headers, which
|
|
114
|
+
# PyYAML and both lenient parsers skip: readable grouping, zero schema change, no
|
|
115
|
+
# duplicated path list. Regenerated on every write (comments are lost on read and
|
|
116
|
+
# re-derived deterministically), so it is idempotent.
|
|
117
|
+
#
|
|
118
|
+
# ADAPTIVE, PATH-DERIVED grouping. Each header is DERIVED FROM THE CODE'S OWN
|
|
119
|
+
# FOLDERS, so the listing mirrors whatever architecture the code actually uses —
|
|
120
|
+
# the layer folders of a vertical-slice module (Domain / Features / Persistence
|
|
121
|
+
# …), the tier projects of an n-tier / layered app (DataAccess / BusinessLogic /
|
|
122
|
+
# Web …), or a legacy layout as-is. Nothing is forced into a fixed
|
|
123
|
+
# Domain/Application/UI taxonomy the code never had (this replaced the greenfield
|
|
124
|
+
# LAYER_RULES). That keeps the knowledge bundle honest for BOTH pipelines —
|
|
125
|
+
# brownfield especially, where the CODE, not a template, is the source of truth.
|
|
126
|
+
#
|
|
127
|
+
# HOW (see _group_source_files): take the longest common directory prefix across
|
|
128
|
+
# the concept's own NON-test files; each file's group is the FIRST path segment
|
|
129
|
+
# AFTER that shared prefix — i.e. what actually VARIES across the files, which is
|
|
130
|
+
# the architectural axis (the layer folder in a vertical slice, the tier project
|
|
131
|
+
# in an n-tier app). Test files are a cross-cutting concern → one `tests` bucket;
|
|
132
|
+
# repo-root files (no shared structure to derive a group from) → `(root)`.
|
|
133
|
+
|
|
134
|
+
# A source path is a test if it lives under a test root OR is a *Tests / *.spec /
|
|
135
|
+
# *.test file — held out of the common-prefix so it never distorts the axis.
|
|
136
|
+
_TEST_RE = re.compile(
|
|
137
|
+
r"(^|/)tests?/|(^|/)[^/]*Tests?\.(cs|ts|tsx|js|py)$|\.(spec|test)\.[jt]sx?$",
|
|
138
|
+
re.IGNORECASE,
|
|
139
|
+
)
|
|
140
|
+
_TESTS_GROUP = "tests"
|
|
141
|
+
_ROOT_GROUP = "(root)"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _is_test(path):
|
|
145
|
+
return bool(_TEST_RE.search(path or ""))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _dir_parts(path):
|
|
149
|
+
"""Directory segments of a repo-relative path ([] for a repo-root file)."""
|
|
150
|
+
d = os.path.dirname(path or "")
|
|
151
|
+
return [p for p in d.split("/") if p] if d else []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _common_prefix(parts_list):
|
|
155
|
+
"""Longest shared leading run of path segments across every entry ([] if none)."""
|
|
156
|
+
if not parts_list:
|
|
157
|
+
return []
|
|
158
|
+
common = list(parts_list[0])
|
|
159
|
+
for parts in parts_list[1:]:
|
|
160
|
+
i = 0
|
|
161
|
+
while i < len(common) and i < len(parts) and common[i] == parts[i]:
|
|
162
|
+
i += 1
|
|
163
|
+
del common[i:]
|
|
164
|
+
if not common:
|
|
165
|
+
break
|
|
166
|
+
return common
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _group_source_files(files):
|
|
170
|
+
"""[(group, [path, …]), …] with each group DERIVED from the file's own path
|
|
171
|
+
(see the ADAPTIVE grouping note). Files are sorted for a stable, diff-friendly
|
|
172
|
+
listing; groups appear in first-seen order over that sorted list, with the
|
|
173
|
+
cross-cutting `tests` and `(root)` buckets pinned last. Empty groups never
|
|
174
|
+
appear."""
|
|
175
|
+
files = sorted(files)
|
|
176
|
+
prod = [f for f in files if not _is_test(f)]
|
|
177
|
+
# Common prefix over files that actually sit in a directory — a repo-root file
|
|
178
|
+
# (parts == []) is a cross-cutting outlier and must not collapse the shared
|
|
179
|
+
# prefix the real module files derive their groups from.
|
|
180
|
+
prefix = _common_prefix([p for p in (_dir_parts(f) for f in prod) if p])
|
|
181
|
+
n = len(prefix)
|
|
182
|
+
|
|
183
|
+
order, buckets = [], {}
|
|
184
|
+
for f in files:
|
|
185
|
+
if _is_test(f):
|
|
186
|
+
g = _TESTS_GROUP
|
|
187
|
+
else:
|
|
188
|
+
parts = _dir_parts(f)
|
|
189
|
+
if not parts:
|
|
190
|
+
g = _ROOT_GROUP # repo-root file, no shared axis
|
|
191
|
+
else:
|
|
192
|
+
rest = parts[n:]
|
|
193
|
+
# first segment past the shared prefix = the architectural axis;
|
|
194
|
+
# a file sitting directly in the shared folder → that folder's leaf
|
|
195
|
+
g = rest[0] if rest else (prefix[-1] if prefix else _ROOT_GROUP)
|
|
196
|
+
if g not in buckets:
|
|
197
|
+
buckets[g] = []
|
|
198
|
+
order.append(g)
|
|
199
|
+
buckets[g].append(f)
|
|
200
|
+
|
|
201
|
+
tail = [g for g in (_TESTS_GROUP, _ROOT_GROUP) if g in buckets]
|
|
202
|
+
head = [g for g in order if g not in tail]
|
|
203
|
+
return [(g, buckets[g]) for g in head + tail]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ───────────────────────────── small helpers ───────────────────────────────
|
|
207
|
+
def _root(arg):
|
|
208
|
+
return os.path.abspath(arg) if arg else os.getcwd()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _rel(path, root):
|
|
212
|
+
return os.path.relpath(path, root).replace(os.sep, "/")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _emit(obj):
|
|
216
|
+
print(json.dumps(obj))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _slug(s):
|
|
220
|
+
s = re.sub(r"[^a-z0-9]+", "-", (s or "").lower())
|
|
221
|
+
return re.sub(r"-{2,}", "-", s).strip("-")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _read(path):
|
|
225
|
+
try:
|
|
226
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
227
|
+
return fh.read()
|
|
228
|
+
except OSError:
|
|
229
|
+
return ""
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _write(path, text):
|
|
233
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
234
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
235
|
+
fh.write(text)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _today(arg_date):
|
|
239
|
+
if arg_date and arg_date.strip():
|
|
240
|
+
return arg_date.strip()
|
|
241
|
+
return datetime.date.today().isoformat()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _agg_from_feature_dir(fd):
|
|
245
|
+
"""The aggregate name = the feature dir's basename (feature = aggregate). The
|
|
246
|
+
workflow passes {{feature_dir}} (absolute); feature-paths carries no name field."""
|
|
247
|
+
return os.path.basename(os.path.normpath(fd)) if fd else ""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_source_files(raw, root):
|
|
251
|
+
"""Accept the files a generate step owns, as EITHER a JSON array (how the engine
|
|
252
|
+
renders a list var: `["a.cs","b.cs"]`) OR a comma-separated string. Relativise any
|
|
253
|
+
absolute path to root and normalise to POSIX, so source_files stays repo-relative
|
|
254
|
+
(what validate_okf and the concept resolver expect)."""
|
|
255
|
+
raw = (raw or "").strip()
|
|
256
|
+
if not raw:
|
|
257
|
+
return []
|
|
258
|
+
items = []
|
|
259
|
+
if raw.startswith("["):
|
|
260
|
+
try:
|
|
261
|
+
data = json.loads(raw)
|
|
262
|
+
if isinstance(data, list):
|
|
263
|
+
for it in data:
|
|
264
|
+
if isinstance(it, str):
|
|
265
|
+
items.append(it)
|
|
266
|
+
elif isinstance(it, dict):
|
|
267
|
+
items.append(it.get("path") or it.get("file") or "")
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
items = [s for s in raw.strip("[]").split(",")]
|
|
270
|
+
else:
|
|
271
|
+
items = raw.split(",")
|
|
272
|
+
out, seen = [], set()
|
|
273
|
+
for s in items:
|
|
274
|
+
s = (s or "").strip().strip('"').strip("'")
|
|
275
|
+
if not s:
|
|
276
|
+
continue
|
|
277
|
+
if os.path.isabs(s):
|
|
278
|
+
s = os.path.relpath(s, root)
|
|
279
|
+
s = s.replace(os.sep, "/")
|
|
280
|
+
if s not in seen:
|
|
281
|
+
seen.add(s)
|
|
282
|
+
out.append(s)
|
|
283
|
+
return out
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _detect_origin(design_path):
|
|
287
|
+
"""`--origin auto`: read the slice's provenance from its design.md head — adopt
|
|
288
|
+
stamps `origin: reverse-engineered` (a CR on a brownfield slice keeps that), else
|
|
289
|
+
greenfield. Lets finalize-delta refresh edges without knowing the pipeline."""
|
|
290
|
+
head = _read(design_path)[:2048]
|
|
291
|
+
m = re.search(r"origin:\s*(reverse-engineered|migrated|greenfield)", head, re.IGNORECASE)
|
|
292
|
+
if m:
|
|
293
|
+
return m.group(1).lower()
|
|
294
|
+
if re.search(r"reverse-engineered", head, re.IGNORECASE):
|
|
295
|
+
return "reverse-engineered"
|
|
296
|
+
return "greenfield"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _render_impact_md(aggregate, users):
|
|
300
|
+
"""A human blast-radius report (cr writes it to changes/<cr>/impact.md for review)."""
|
|
301
|
+
lines = [
|
|
302
|
+
f"# Cross-Reference Impact — {aggregate}",
|
|
303
|
+
"",
|
|
304
|
+
f"Downstream slices that USE **{aggregate}** — its blast radius. A change to "
|
|
305
|
+
f"{aggregate} may ripple into these; review or re-verify each before finalizing.",
|
|
306
|
+
"",
|
|
307
|
+
"| Using slice | Uses (via) | Kind | Origin | Recorded |",
|
|
308
|
+
"| --- | --- | --- | --- | --- |",
|
|
309
|
+
]
|
|
310
|
+
for u in users:
|
|
311
|
+
lines.append(f"| {u['using']} | {u['via']} | {u['kind']} | {u['origin']} | {u['recorded']} |")
|
|
312
|
+
if not users:
|
|
313
|
+
lines.append("| _(none — no slice references this aggregate)_ | | | | |")
|
|
314
|
+
return "\n".join(lines) + "\n"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# ─────────────────────────── frontmatter I/O ───────────────────────────────
|
|
318
|
+
def _read_fm(text):
|
|
319
|
+
"""Return (frontmatter dict, body). Scalars are strings; block lists are lists.
|
|
320
|
+
Mirrors aw-inventory.py's lenient parser so the two agree without a YAML dep."""
|
|
321
|
+
if not text.startswith("---"):
|
|
322
|
+
return {}, text
|
|
323
|
+
lines = text.splitlines()
|
|
324
|
+
end = next((i for i in range(1, len(lines)) if lines[i].strip() == "---"), None)
|
|
325
|
+
if end is None:
|
|
326
|
+
return {}, text
|
|
327
|
+
fm, key = {}, None
|
|
328
|
+
for ln in lines[1:end]:
|
|
329
|
+
m = re.match(r"^([A-Za-z0-9_]+):\s*(.*)$", ln)
|
|
330
|
+
if m:
|
|
331
|
+
key = m.group(1)
|
|
332
|
+
val = m.group(2).strip()
|
|
333
|
+
fm[key] = val.strip("'\"") if val else []
|
|
334
|
+
elif key and re.match(r"^\s*-\s+", ln):
|
|
335
|
+
if not isinstance(fm.get(key), list):
|
|
336
|
+
fm[key] = []
|
|
337
|
+
fm[key].append(re.sub(r"^\s*-\s+", "", ln).strip().strip("'\""))
|
|
338
|
+
return fm, "\n".join(lines[end + 1:])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _render_fm(fm):
|
|
342
|
+
order = ["type", "title", "description", "origin", "source_files", "ui_source_files"]
|
|
343
|
+
keys = [k for k in order if k in fm] + [k for k in fm if k not in order]
|
|
344
|
+
out = ["---"]
|
|
345
|
+
for k in keys:
|
|
346
|
+
v = fm[k]
|
|
347
|
+
if isinstance(v, list):
|
|
348
|
+
out.append(f"{k}:")
|
|
349
|
+
if k in ("source_files", "ui_source_files") and v:
|
|
350
|
+
# flat list, path-grouped by the code's own folders (see _group_source_files)
|
|
351
|
+
for layer, items in _group_source_files(v):
|
|
352
|
+
out.append(f" # {layer}")
|
|
353
|
+
out.extend(f" - {it}" for it in items)
|
|
354
|
+
else:
|
|
355
|
+
for it in v:
|
|
356
|
+
out.append(f" - {it}")
|
|
357
|
+
else:
|
|
358
|
+
out.append(f"{k}: {v}")
|
|
359
|
+
out.append("---")
|
|
360
|
+
return "\n".join(out)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ─────────────────────── markdown section / table I/O ───────────────────────
|
|
364
|
+
def _render_table(header, rows):
|
|
365
|
+
out = ["| " + " | ".join(header) + " |",
|
|
366
|
+
"| " + " | ".join(["---"] * len(header)) + " |"]
|
|
367
|
+
for r in rows:
|
|
368
|
+
cells = (list(r) + [""] * len(header))[:len(header)]
|
|
369
|
+
out.append("| " + " | ".join(cells) + " |")
|
|
370
|
+
return "\n".join(out)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _render_section(heading, comment, header, rows):
|
|
374
|
+
return f"{heading}\n\n{comment}\n\n{_render_table(header, rows)}\n"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _section_text(text, heading):
|
|
378
|
+
"""The lines from `heading` up to (not including) the next `## ` heading."""
|
|
379
|
+
lines = text.splitlines()
|
|
380
|
+
start = next((i for i, ln in enumerate(lines) if ln.strip() == heading), None)
|
|
381
|
+
if start is None:
|
|
382
|
+
return ""
|
|
383
|
+
end = len(lines)
|
|
384
|
+
for j in range(start + 1, len(lines)):
|
|
385
|
+
if lines[j].startswith("## "):
|
|
386
|
+
end = j
|
|
387
|
+
break
|
|
388
|
+
return "\n".join(lines[start:end])
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _table_data_rows(section_text):
|
|
392
|
+
"""Data rows (list of trimmed cell-lists) under a section: skips the header row,
|
|
393
|
+
the |---| separator, HTML comments (incl. commented example rows), and blanks."""
|
|
394
|
+
rows, in_comment, header_seen = [], False, False
|
|
395
|
+
for ln in section_text.splitlines():
|
|
396
|
+
s = ln.strip()
|
|
397
|
+
if in_comment:
|
|
398
|
+
if "-->" in s:
|
|
399
|
+
in_comment = False
|
|
400
|
+
continue
|
|
401
|
+
if s.startswith("<!--"):
|
|
402
|
+
if "-->" not in s:
|
|
403
|
+
in_comment = True
|
|
404
|
+
continue
|
|
405
|
+
if not s.startswith("|"):
|
|
406
|
+
continue
|
|
407
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
408
|
+
joined = "".join(cells)
|
|
409
|
+
if not header_seen:
|
|
410
|
+
header_seen = True # first pipe row = header
|
|
411
|
+
continue
|
|
412
|
+
if joined == "" or set(joined) <= set("-: "):
|
|
413
|
+
continue # separator or empty
|
|
414
|
+
rows.append(cells)
|
|
415
|
+
return rows
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _replace_section(text, heading, new_body, before=None):
|
|
419
|
+
"""Replace the `heading` section (heading→next `## `) with new_body. If the heading
|
|
420
|
+
is absent, insert it before the `before` heading when given, else append. Only the
|
|
421
|
+
machine sections are ever passed here, so human prose elsewhere is preserved."""
|
|
422
|
+
lines = text.splitlines()
|
|
423
|
+
start = next((i for i, ln in enumerate(lines) if ln.strip() == heading), None)
|
|
424
|
+
new_lines = new_body.rstrip("\n").splitlines()
|
|
425
|
+
if start is None:
|
|
426
|
+
anchor = (next((i for i, ln in enumerate(lines) if ln.strip() == before), None)
|
|
427
|
+
if before else None)
|
|
428
|
+
if anchor is not None:
|
|
429
|
+
merged = lines[:anchor] + new_lines + [""] + lines[anchor:]
|
|
430
|
+
return "\n".join(merged).rstrip("\n") + "\n"
|
|
431
|
+
base = text.rstrip("\n")
|
|
432
|
+
return (base + "\n\n" + "\n".join(new_lines) + "\n") if base else ("\n".join(new_lines) + "\n")
|
|
433
|
+
end = len(lines)
|
|
434
|
+
for j in range(start + 1, len(lines)):
|
|
435
|
+
if lines[j].startswith("## "):
|
|
436
|
+
end = j
|
|
437
|
+
break
|
|
438
|
+
merged = lines[:start] + new_lines + [""] + lines[end:]
|
|
439
|
+
return "\n".join(merged).rstrip("\n") + "\n"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ───────────────────────── bundle / concept resolution ──────────────────────
|
|
443
|
+
def _ensure_bundle(bundle_abs):
|
|
444
|
+
os.makedirs(bundle_abs, exist_ok=True)
|
|
445
|
+
idx = os.path.join(bundle_abs, "index.md")
|
|
446
|
+
if not os.path.isfile(idx):
|
|
447
|
+
_write(idx, "---\nokf_version: 0.1\n---\n\n# Knowledge Bundle\n\n"
|
|
448
|
+
"OKF v0.1 knowledge bundle — one concept per aggregate.\n")
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _canonical_concept_path(bundle_abs, name):
|
|
452
|
+
slug = _slug(name)
|
|
453
|
+
return os.path.join(bundle_abs, slug, slug + ".md")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _resolve_concept(name, bundle_abs):
|
|
457
|
+
"""Find name's concept (mirrors aw-inventory.py concept): match title/base slug,
|
|
458
|
+
prefer a type containing 'aggregate'. Return abspath or None."""
|
|
459
|
+
want = _slug(name)
|
|
460
|
+
best, best_score = None, -1
|
|
461
|
+
if not os.path.isdir(bundle_abs):
|
|
462
|
+
return None
|
|
463
|
+
for dirpath, dirnames, filenames in os.walk(bundle_abs):
|
|
464
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
465
|
+
for fn in filenames:
|
|
466
|
+
if not fn.endswith(".md") or fn in RESERVED:
|
|
467
|
+
continue
|
|
468
|
+
path = os.path.join(dirpath, fn)
|
|
469
|
+
fm, _ = _read_fm(_read(path))
|
|
470
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
471
|
+
base = os.path.splitext(fn)[0]
|
|
472
|
+
if want not in (_slug(title), _slug(base)):
|
|
473
|
+
continue
|
|
474
|
+
typ = fm.get("type") if isinstance(fm.get("type"), str) else ""
|
|
475
|
+
score = (2 if "aggregate" in typ.lower() else 0) + (1 if _slug(base) == want else 0)
|
|
476
|
+
if score > best_score:
|
|
477
|
+
best, best_score = path, score
|
|
478
|
+
return best
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _skeleton_concept(name, origin, source_files, description):
|
|
482
|
+
fm = {
|
|
483
|
+
"type": "aggregate",
|
|
484
|
+
"title": name,
|
|
485
|
+
"description": description or f"{name} aggregate.",
|
|
486
|
+
"origin": origin or "greenfield",
|
|
487
|
+
"source_files": list(source_files or []),
|
|
488
|
+
}
|
|
489
|
+
overview = f"[overview of the {name} aggregate]"
|
|
490
|
+
body = (
|
|
491
|
+
f"# {name}\n\n"
|
|
492
|
+
f"## Overview\n\n{overview}\n\n"
|
|
493
|
+
+ _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, []) + "\n"
|
|
494
|
+
+ _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, [])
|
|
495
|
+
)
|
|
496
|
+
return _render_fm(fm) + "\n\n" + body.rstrip("\n") + "\n"
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _ensure_dir_index(concept_path, name):
|
|
500
|
+
d = os.path.dirname(concept_path)
|
|
501
|
+
idx = os.path.join(d, "index.md")
|
|
502
|
+
if not os.path.isfile(idx):
|
|
503
|
+
base = os.path.splitext(os.path.basename(concept_path))[0]
|
|
504
|
+
_write(idx, f"# {name}\n\n- [{name}](./{base}.md)\n") # NO frontmatter (OKF E3)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _get_or_stub_concept(name, bundle_abs, origin, stubbed):
|
|
508
|
+
"""Return the concept path for name, creating a stub (+ dir/root index) if missing."""
|
|
509
|
+
path = _resolve_concept(name, bundle_abs)
|
|
510
|
+
if path:
|
|
511
|
+
return path
|
|
512
|
+
_ensure_bundle(bundle_abs)
|
|
513
|
+
path = _canonical_concept_path(bundle_abs, name)
|
|
514
|
+
_write(path, _skeleton_concept(name, origin, [], ""))
|
|
515
|
+
_ensure_dir_index(path, name)
|
|
516
|
+
stubbed.append(name)
|
|
517
|
+
return path
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ──────────────────────────── design.md parsing ────────────────────────────
|
|
521
|
+
def _section(text, start_pat, end_pat):
|
|
522
|
+
lines, out, started = text.splitlines(), [], False
|
|
523
|
+
for ln in lines:
|
|
524
|
+
if not started:
|
|
525
|
+
if re.match(start_pat, ln):
|
|
526
|
+
started, _ = True, out.append(ln)
|
|
527
|
+
continue
|
|
528
|
+
if re.match(end_pat, ln):
|
|
529
|
+
break
|
|
530
|
+
out.append(ln)
|
|
531
|
+
return "\n".join(out)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _known_aggregates(root, specs, bundle_abs):
|
|
535
|
+
"""The project's aggregate registry: specs/<dir> names ∪ bundle concept titles/dirs.
|
|
536
|
+
Returns {slug: display_name}."""
|
|
537
|
+
known = {}
|
|
538
|
+
specs_dir = os.path.join(root, specs)
|
|
539
|
+
if os.path.isdir(specs_dir):
|
|
540
|
+
for name in os.listdir(specs_dir):
|
|
541
|
+
if os.path.isdir(os.path.join(specs_dir, name)):
|
|
542
|
+
known.setdefault(_slug(name), name)
|
|
543
|
+
if os.path.isdir(bundle_abs):
|
|
544
|
+
for dirpath, dirnames, filenames in os.walk(bundle_abs):
|
|
545
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
546
|
+
for fn in filenames:
|
|
547
|
+
if not fn.endswith(".md") or fn in RESERVED:
|
|
548
|
+
continue
|
|
549
|
+
fm, _ = _read_fm(_read(os.path.join(dirpath, fn)))
|
|
550
|
+
title = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
551
|
+
if title:
|
|
552
|
+
known.setdefault(_slug(title), title)
|
|
553
|
+
return known
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _design_edges(text, aggregate, known):
|
|
557
|
+
"""Internal edges the aggregate declares. `known` = {slug: name}; self excluded."""
|
|
558
|
+
self_slug = _slug(aggregate)
|
|
559
|
+
edges, seen = [], set()
|
|
560
|
+
|
|
561
|
+
def add(target_name, via, kind):
|
|
562
|
+
tslug = _slug(target_name)
|
|
563
|
+
key = (tslug, via, kind)
|
|
564
|
+
if tslug == self_slug or tslug not in known or key in seen:
|
|
565
|
+
return
|
|
566
|
+
seen.add(key)
|
|
567
|
+
edges.append({"target": known[tslug], "via": via, "kind": kind})
|
|
568
|
+
|
|
569
|
+
# §1 (the MODEL section) — entity-ref edges: `<Name>Id` and id<Name> attribute
|
|
570
|
+
# types. Both the forward and brownfield design templates keep the domain/data
|
|
571
|
+
# model at "## 1." (a neutral, architecture-agnostic anchor), so this scan is
|
|
572
|
+
# structure-independent.
|
|
573
|
+
sec1 = _section(text, r"^##\s+1\.", r"^##\s+\d")
|
|
574
|
+
ref_names = set(re.findall(r"`([A-Z][A-Za-z0-9]*)Id`", sec1))
|
|
575
|
+
ref_names |= set(re.findall(r"id<\s*([A-Z][A-Za-z0-9]*)\s*>", sec1))
|
|
576
|
+
for name in sorted(ref_names):
|
|
577
|
+
add(name, name + "Id", "entity-ref")
|
|
578
|
+
|
|
579
|
+
# External integrations — contract edges: External-system cell names a known
|
|
580
|
+
# aggregate, keyed by the IC- ids in the Serves column. Located by HEADING
|
|
581
|
+
# KEYWORD (any `###` heading containing "integration"), not a fixed section
|
|
582
|
+
# number, so it matches both the forward design's "### 4.2 External integrations"
|
|
583
|
+
# and a brownfield design's code-derived "### 2.x External Integrations …".
|
|
584
|
+
sec42 = _section(text, r"(?i)^###\s+.*integration", r"^(###\s|##\s)")
|
|
585
|
+
for row in _table_data_rows(sec42):
|
|
586
|
+
if len(row) < 2:
|
|
587
|
+
continue
|
|
588
|
+
external = row[1]
|
|
589
|
+
serves = row[-1]
|
|
590
|
+
ics = re.findall(r"IC-\d+", serves)
|
|
591
|
+
for tslug, name in known.items():
|
|
592
|
+
if tslug == self_slug:
|
|
593
|
+
continue
|
|
594
|
+
if re.search(r"\b" + re.escape(name) + r"\b", external, re.IGNORECASE):
|
|
595
|
+
for ic in (ics or ["contract"]):
|
|
596
|
+
add(name, ic, "contract")
|
|
597
|
+
return edges
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# ───────────────────────────── xref row I/O ────────────────────────────────
|
|
601
|
+
def _read_xref_rows(path):
|
|
602
|
+
return _table_data_rows(_section_text(_read(path), XREF_HEADING))
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _read_deps_rows(path):
|
|
606
|
+
return _table_data_rows(_section_text(_read(path), DEPS_HEADING))
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# ─────────────── reverse-edge closure — an aggregate's referrers ────────────
|
|
610
|
+
def _xref_users(aggregate, bundle_abs):
|
|
611
|
+
"""Direct 'who refers to me' — the first column (Using slice) of aggregate's
|
|
612
|
+
Cross-References rows, kept only when it is an identifier, so the placeholder
|
|
613
|
+
row and any malformed cell are dropped and the names are filter-safe."""
|
|
614
|
+
path = _resolve_concept(aggregate, bundle_abs)
|
|
615
|
+
if not path:
|
|
616
|
+
return []
|
|
617
|
+
return [r[0].strip() for r in _read_xref_rows(path)
|
|
618
|
+
if r and _IDENT_RE.match((r[0] or "").strip())]
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _referrer_closure(aggregate, bundle_abs):
|
|
622
|
+
"""Transitive 'who refers to me' blast radius: BFS over the reverse
|
|
623
|
+
(Cross-References) edges. self is excluded, cycles terminate via the seen set,
|
|
624
|
+
result sorted for a stable filter. Resolves each node's concept on demand —
|
|
625
|
+
O(nodes) walks, fine for a one-shot CLI even at hundreds of aggregates."""
|
|
626
|
+
seen, frontier = set(), [aggregate]
|
|
627
|
+
while frontier:
|
|
628
|
+
current = frontier.pop()
|
|
629
|
+
for user in _xref_users(current, bundle_abs):
|
|
630
|
+
if user in seen or user == aggregate:
|
|
631
|
+
continue
|
|
632
|
+
seen.add(user)
|
|
633
|
+
frontier.append(user)
|
|
634
|
+
return sorted(seen)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _concept_title(path):
|
|
638
|
+
fm, _ = _read_fm(_read(path))
|
|
639
|
+
t = fm.get("title") if isinstance(fm.get("title"), str) else ""
|
|
640
|
+
return t or os.path.splitext(os.path.basename(path))[0]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _git_head_short(root):
|
|
644
|
+
"""Short HEAD sha for the OKF log.md commit anchor (OKF §7 / validate_okf E4);
|
|
645
|
+
empty when not a git repo or no commit yet (then log is skipped, never written broken)."""
|
|
646
|
+
try:
|
|
647
|
+
r = subprocess.run(["git", "-C", root, "rev-parse", "--short", "HEAD"],
|
|
648
|
+
capture_output=True, text=True, timeout=5)
|
|
649
|
+
sha = r.stdout.strip()
|
|
650
|
+
return sha if r.returncode == 0 and re.fullmatch(r"[0-9a-fA-F]{7,40}", sha) else ""
|
|
651
|
+
except Exception:
|
|
652
|
+
return ""
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _write_xref_rows(path, rows):
|
|
656
|
+
text = _read(path)
|
|
657
|
+
body = _render_section(XREF_HEADING, XREF_COMMENT, XREF_HEADER, rows)
|
|
658
|
+
_write(path, _replace_section(text, XREF_HEADING, body))
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _write_deps_rows(path, rows):
|
|
662
|
+
text = _read(path)
|
|
663
|
+
body = _render_section(DEPS_HEADING, DEPS_COMMENT, DEPS_HEADER, rows)
|
|
664
|
+
_write(path, _replace_section(text, DEPS_HEADING, body))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _all_concepts(bundle_abs):
|
|
668
|
+
out = []
|
|
669
|
+
if not os.path.isdir(bundle_abs):
|
|
670
|
+
return out
|
|
671
|
+
for dirpath, dirnames, filenames in os.walk(bundle_abs):
|
|
672
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
673
|
+
for fn in filenames:
|
|
674
|
+
if fn.endswith(".md") and fn not in RESERVED:
|
|
675
|
+
out.append(os.path.join(dirpath, fn))
|
|
676
|
+
return out
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# ─────────────────────────────── commands ──────────────────────────────────
|
|
680
|
+
def cmd_record(args):
|
|
681
|
+
root = _root(args.root)
|
|
682
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
683
|
+
date = _today(args.date)
|
|
684
|
+
using = args.using or _agg_from_feature_dir(args.feature_dir)
|
|
685
|
+
if not using:
|
|
686
|
+
print("[xref] record: --using or --feature-dir required", file=sys.stderr)
|
|
687
|
+
_emit({"error": "using-required", "root": root})
|
|
688
|
+
return 2
|
|
689
|
+
using_slug = _slug(using)
|
|
690
|
+
|
|
691
|
+
design_arg = args.design or (os.path.join(args.feature_dir, "design.md") if args.feature_dir else "")
|
|
692
|
+
if not design_arg:
|
|
693
|
+
print("[xref] record: --design or --feature-dir required", file=sys.stderr)
|
|
694
|
+
_emit({"error": "design-required", "root": root})
|
|
695
|
+
return 2
|
|
696
|
+
design_path = design_arg if os.path.isabs(design_arg) else os.path.join(root, design_arg)
|
|
697
|
+
|
|
698
|
+
origin = args.origin or "greenfield"
|
|
699
|
+
if origin == "auto":
|
|
700
|
+
origin = _detect_origin(design_path)
|
|
701
|
+
|
|
702
|
+
known = _known_aggregates(root, args.specs, bundle_abs)
|
|
703
|
+
known.pop(using_slug, None)
|
|
704
|
+
edges = _design_edges(_read(design_path), using, known)
|
|
705
|
+
|
|
706
|
+
# RECONCILE-ON-WRITE, delete phase: strip every row this slice authored, anywhere.
|
|
707
|
+
pruned = 0
|
|
708
|
+
for cpath in _all_concepts(bundle_abs):
|
|
709
|
+
rows = _read_xref_rows(cpath)
|
|
710
|
+
kept = [r for r in rows if _slug(r[0]) != using_slug]
|
|
711
|
+
if len(kept) != len(rows):
|
|
712
|
+
pruned += len(rows) - len(kept)
|
|
713
|
+
_write_xref_rows(cpath, kept)
|
|
714
|
+
|
|
715
|
+
# Add phase: upsert this slice's current rows into each used concept (stub if missing).
|
|
716
|
+
wrote, stubbed = [], []
|
|
717
|
+
for e in edges:
|
|
718
|
+
ypath = _get_or_stub_concept(e["target"], bundle_abs, origin, stubbed)
|
|
719
|
+
rows = _read_xref_rows(ypath)
|
|
720
|
+
rows = [r for r in rows if not (_slug(r[0]) == using_slug and (len(r) > 1 and r[1] == e["via"]))]
|
|
721
|
+
rows.append([using, e["via"], e["kind"], origin, date])
|
|
722
|
+
_write_xref_rows(ypath, rows)
|
|
723
|
+
wrote.append({"target": e["target"], "concept": _rel(ypath, root), "via": e["via"], "kind": e["kind"]})
|
|
724
|
+
|
|
725
|
+
# Rewrite this slice's own Depends-On (forward prune index) — stub self if needed.
|
|
726
|
+
xpath = _get_or_stub_concept(using, bundle_abs, origin, stubbed)
|
|
727
|
+
_write_deps_rows(xpath, [[e["target"], e["via"], e["kind"]] for e in edges])
|
|
728
|
+
|
|
729
|
+
print(f"[xref] recorded {using} → {len(edges)} edge(s); "
|
|
730
|
+
f"stubbed {len(stubbed)}, pruned {pruned} stale row(s)", file=sys.stderr)
|
|
731
|
+
_emit({"using": using, "origin": origin, "edges": edges, "edge_count": len(edges),
|
|
732
|
+
"wrote": wrote, "stubbed": stubbed, "pruned": pruned,
|
|
733
|
+
"concept": _rel(xpath, root), "root": root})
|
|
734
|
+
return 0
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def cmd_list(args):
|
|
738
|
+
root = _root(args.root)
|
|
739
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
740
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
741
|
+
if not aggregate:
|
|
742
|
+
print("[xref] list: --aggregate or --feature-dir required", file=sys.stderr)
|
|
743
|
+
_emit({"error": "aggregate-required", "root": root})
|
|
744
|
+
return 2
|
|
745
|
+
path = _resolve_concept(aggregate, bundle_abs)
|
|
746
|
+
users = []
|
|
747
|
+
if path:
|
|
748
|
+
for r in _read_xref_rows(path):
|
|
749
|
+
users.append({
|
|
750
|
+
"using": r[0],
|
|
751
|
+
"via": r[1] if len(r) > 1 else "",
|
|
752
|
+
"kind": r[2] if len(r) > 2 else "",
|
|
753
|
+
"origin": r[3] if len(r) > 3 else "",
|
|
754
|
+
"recorded": r[4] if len(r) > 4 else "",
|
|
755
|
+
})
|
|
756
|
+
out_rel = ""
|
|
757
|
+
if args.out:
|
|
758
|
+
out_path = args.out if os.path.isabs(args.out) else os.path.join(root, args.out)
|
|
759
|
+
_write(out_path, _render_impact_md(aggregate, users))
|
|
760
|
+
out_rel = _rel(out_path, root)
|
|
761
|
+
# Direct referrers (identifier-clean, de-duped) + the transitive closure — the
|
|
762
|
+
# integrity blast radius a caller runs tests over. count/users stay as-is for
|
|
763
|
+
# backward-compatible readers; the referrer_* fields are the clean, filter-ready set.
|
|
764
|
+
seen_d, direct = set(), []
|
|
765
|
+
for u in users:
|
|
766
|
+
name = (u["using"] or "").strip()
|
|
767
|
+
if _IDENT_RE.match(name) and name not in seen_d:
|
|
768
|
+
seen_d.add(name); direct.append(name)
|
|
769
|
+
closure = _referrer_closure(aggregate, bundle_abs)
|
|
770
|
+
print(f"[xref] {aggregate}: {len(users)} downstream user(s) "
|
|
771
|
+
f"— {', '.join(sorted({u['using'] for u in users})) or '(none)'}", file=sys.stderr)
|
|
772
|
+
_emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
|
|
773
|
+
"users": users, "count": len(users), "impact_report": out_rel, "root": root,
|
|
774
|
+
"referrers": direct, "referrers_csv": ",".join(direct),
|
|
775
|
+
"referrer_closure": closure, "referrer_closure_csv": ",".join(closure),
|
|
776
|
+
"referrer_count": len(direct), "referrer_closure_count": len(closure),
|
|
777
|
+
"indirect_count": max(0, len(closure) - len(direct))})
|
|
778
|
+
return 0
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def cmd_concept_upsert(args):
|
|
782
|
+
root = _root(args.root)
|
|
783
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
784
|
+
origin = args.origin or "greenfield"
|
|
785
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
786
|
+
if not aggregate:
|
|
787
|
+
print("[xref] concept-upsert: --aggregate or --feature-dir required", file=sys.stderr)
|
|
788
|
+
_emit({"error": "aggregate-required", "root": root})
|
|
789
|
+
return 2
|
|
790
|
+
source_files = _parse_source_files(args.source_files, root)
|
|
791
|
+
|
|
792
|
+
path = _resolve_concept(aggregate, bundle_abs)
|
|
793
|
+
created = path is None
|
|
794
|
+
if created:
|
|
795
|
+
_ensure_bundle(bundle_abs)
|
|
796
|
+
path = _canonical_concept_path(bundle_abs, aggregate)
|
|
797
|
+
_write(path, _skeleton_concept(aggregate, origin, source_files, args.description or ""))
|
|
798
|
+
_ensure_dir_index(path, aggregate)
|
|
799
|
+
else:
|
|
800
|
+
# Update frontmatter ONLY (source_files/description/origin); PRESERVE the body,
|
|
801
|
+
# which holds the Cross-References other slices wrote.
|
|
802
|
+
fm, body = _read_fm(_read(path))
|
|
803
|
+
fm.setdefault("type", "aggregate")
|
|
804
|
+
fm["title"] = fm.get("title") or aggregate
|
|
805
|
+
if source_files:
|
|
806
|
+
if args.merge:
|
|
807
|
+
# CR mode: {{files}} is only the delta's changed files — UNION with the
|
|
808
|
+
# slice's existing source_files, never replace (that would drop the rest).
|
|
809
|
+
existing = fm.get("source_files") if isinstance(fm.get("source_files"), list) else []
|
|
810
|
+
merged = list(existing)
|
|
811
|
+
for s in source_files:
|
|
812
|
+
if s not in merged:
|
|
813
|
+
merged.append(s)
|
|
814
|
+
fm["source_files"] = merged
|
|
815
|
+
else:
|
|
816
|
+
fm["source_files"] = source_files
|
|
817
|
+
elif "source_files" not in fm:
|
|
818
|
+
fm["source_files"] = []
|
|
819
|
+
if args.description:
|
|
820
|
+
fm["description"] = args.description
|
|
821
|
+
elif "description" not in fm:
|
|
822
|
+
fm["description"] = f"{aggregate} aggregate."
|
|
823
|
+
fm.setdefault("origin", origin)
|
|
824
|
+
rebuilt = (_render_fm(fm) + "\n" + body if body.startswith("\n")
|
|
825
|
+
else _render_fm(fm) + "\n\n" + body.lstrip("\n"))
|
|
826
|
+
_write(path, rebuilt.rstrip("\n") + "\n") # _read_fm's split drops the final newline
|
|
827
|
+
_ensure_bundle(bundle_abs)
|
|
828
|
+
_ensure_dir_index(path, aggregate)
|
|
829
|
+
|
|
830
|
+
print(f"[xref] concept {aggregate}: {'created' if created else 'updated'} "
|
|
831
|
+
f"({len(source_files)} source file(s)) — {_rel(path, root)}", file=sys.stderr)
|
|
832
|
+
_emit({"aggregate": aggregate, "concept": _rel(path, root), "created": "true" if created else "false",
|
|
833
|
+
"source_file_count": len(source_files), "root": root})
|
|
834
|
+
return 0
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def cmd_concept_overview(args):
|
|
838
|
+
"""Splice model-authored PROSE into the concept's `## Overview` — the one section a
|
|
839
|
+
deterministic script cannot write. Section-scoped on purpose: the model never owns
|
|
840
|
+
the whole file, so the Cross-References rows other slices wrote can never be lost.
|
|
841
|
+
Runs AFTER concept-upsert (which owns creation + frontmatter)."""
|
|
842
|
+
root = _root(args.root)
|
|
843
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
844
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
845
|
+
if not aggregate:
|
|
846
|
+
print("[xref] concept-overview: --aggregate or --feature-dir required", file=sys.stderr)
|
|
847
|
+
_emit({"error": "aggregate-required", "root": root})
|
|
848
|
+
return 2
|
|
849
|
+
|
|
850
|
+
text = _read(args.text_file) if args.text_file else (args.text or "")
|
|
851
|
+
# Strip anything the model may have wrapped the prose in: a repeated heading,
|
|
852
|
+
# code fences, and the placeholder itself (an empty/placeholder answer = no-op).
|
|
853
|
+
text = re.sub(r"^\s*#{1,6}\s*Overview\s*$", "", text, flags=re.MULTILINE)
|
|
854
|
+
text = re.sub(r"^\s*```.*$", "", text, flags=re.MULTILINE).strip()
|
|
855
|
+
if not text or re.fullmatch(r"\[overview of the .*\]", text):
|
|
856
|
+
print(f"[xref] concept-overview {aggregate}: empty/placeholder text — left unchanged",
|
|
857
|
+
file=sys.stderr)
|
|
858
|
+
_emit({"aggregate": aggregate, "written": "false", "reason": "empty-or-placeholder",
|
|
859
|
+
"root": root})
|
|
860
|
+
return 0
|
|
861
|
+
|
|
862
|
+
path = _resolve_concept(aggregate, bundle_abs)
|
|
863
|
+
if path is None:
|
|
864
|
+
print(f"[xref] concept-overview: no concept for '{aggregate}' — run concept-upsert first",
|
|
865
|
+
file=sys.stderr)
|
|
866
|
+
_emit({"error": "concept-missing", "aggregate": aggregate, "root": root})
|
|
867
|
+
return 2
|
|
868
|
+
|
|
869
|
+
body = f"{OVERVIEW_HEADING}\n\n{text}\n"
|
|
870
|
+
_write(path, _replace_section(_read(path), OVERVIEW_HEADING, body, before=DEPS_HEADING))
|
|
871
|
+
print(f"[xref] concept-overview {aggregate}: written ({len(text)} chars) — {_rel(path, root)}",
|
|
872
|
+
file=sys.stderr)
|
|
873
|
+
_emit({"aggregate": aggregate, "concept": _rel(path, root), "written": "true", "root": root})
|
|
874
|
+
return 0
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# ──────────────────────── concept-paths (read-only) ────────────────────────
|
|
878
|
+
def cmd_concept_paths(args):
|
|
879
|
+
"""The slice's source files, read from its OKF concept — the REPLACEMENT for the
|
|
880
|
+
retired `design-paths` fence scan.
|
|
881
|
+
|
|
882
|
+
The executable phases (build / run / analyze-slice) all need the same answer: which
|
|
883
|
+
files is this slice made of, so a fix loop can be handed them. That answer used to be
|
|
884
|
+
parsed out of ```lang path=… fences in a four-document design — a convention the
|
|
885
|
+
tech-agnostic single design.md no longer carries (and whose glob never matched
|
|
886
|
+
design.md anyway). The concept's `source_files` is the maintained answer now:
|
|
887
|
+
implement / implement-delta / adopt write it, analyze-slice merges fix-loop files in.
|
|
888
|
+
|
|
889
|
+
STRICTLY READ-ONLY. Unlike record / concept-upsert this never stubs a missing concept:
|
|
890
|
+
a query must not mutate the bundle. No concept — greenfield before the first implement,
|
|
891
|
+
or no bundle at all — is a legitimate EMPTY answer, not an error. It exits 0 with
|
|
892
|
+
paths=[] so every caller soft-continues exactly as it did when the fence scan found
|
|
893
|
+
nothing, which is what keeps this greenfield-safe.
|
|
894
|
+
|
|
895
|
+
`paths` is filtered to files that exist on disk. It feeds build_context, which drops
|
|
896
|
+
missing entries silently anyway, so filtering here makes the reported count honest.
|
|
897
|
+
Declared-but-absent files are reported separately as `missing` — that is real index
|
|
898
|
+
drift (a file deleted or moved without re-running the owning slice), surfaced rather
|
|
899
|
+
than swallowed."""
|
|
900
|
+
root = _root(args.root)
|
|
901
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
902
|
+
aggregate = args.aggregate or _agg_from_feature_dir(args.feature_dir)
|
|
903
|
+
if not aggregate:
|
|
904
|
+
print("[xref] concept-paths: --aggregate or --feature-dir required", file=sys.stderr)
|
|
905
|
+
_emit({"error": "aggregate-required", "root": root})
|
|
906
|
+
return 2
|
|
907
|
+
|
|
908
|
+
path = _resolve_concept(aggregate, bundle_abs)
|
|
909
|
+
declared, existing, missing = [], [], []
|
|
910
|
+
if path:
|
|
911
|
+
fm, _ = _read_fm(_read(path))
|
|
912
|
+
raw = fm.get("source_files")
|
|
913
|
+
# _read_fm gives a list for a block list, "" for an empty scalar; the `# Layer`
|
|
914
|
+
# group headers _render_fm writes are comments and are already skipped by it.
|
|
915
|
+
items = raw if isinstance(raw, list) else ([raw] if isinstance(raw, str) and raw else [])
|
|
916
|
+
seen = set()
|
|
917
|
+
for s in items:
|
|
918
|
+
s = (s or "").strip().strip('"').strip("'").replace(os.sep, "/")
|
|
919
|
+
if not s or s in seen:
|
|
920
|
+
continue
|
|
921
|
+
seen.add(s)
|
|
922
|
+
declared.append(s)
|
|
923
|
+
target = s if os.path.isabs(s) else os.path.join(root, s)
|
|
924
|
+
(existing if os.path.isfile(target) else missing).append(s)
|
|
925
|
+
|
|
926
|
+
note = "" if path else " — no concept recorded yet"
|
|
927
|
+
drift = f", {len(missing)} declared-but-missing" if missing else ""
|
|
928
|
+
print(f"[xref] concept-paths {aggregate}: {len(existing)} file(s) on disk{drift}{note}",
|
|
929
|
+
file=sys.stderr)
|
|
930
|
+
_emit({"aggregate": aggregate, "concept": _rel(path, root) if path else "",
|
|
931
|
+
"found": bool(path), "paths": existing, "count": len(existing),
|
|
932
|
+
"csv": ",".join(existing), "declared": declared,
|
|
933
|
+
"declared_count": len(declared), "missing": missing,
|
|
934
|
+
"missing_count": len(missing), "root": root})
|
|
935
|
+
return 0
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
# ─────────────────────────────── validate ──────────────────────────────────
|
|
939
|
+
def cmd_validate(args):
|
|
940
|
+
"""Cross-reference drift check (the safety net BEYOND reconcile-on-write). Pairs the
|
|
941
|
+
forward index (Depends-On) against the reverse index (Cross-References):
|
|
942
|
+
• reverse-orphan — a Cross-References row (Y says "X uses me") with NO backing
|
|
943
|
+
Depends-On in X (X's concept gone, or X no longer declares the dep). Stale; safe
|
|
944
|
+
to prune (this is the user-facing orphan).
|
|
945
|
+
• forward-orphan — a Depends-On row (X → Y) with NO matching Cross-References in Y
|
|
946
|
+
(a missing reverse edge). Reported only; the fix is `record --using X` (re-write
|
|
947
|
+
both), never deleting X's real declaration."""
|
|
948
|
+
root = _root(args.root)
|
|
949
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
950
|
+
info = {}
|
|
951
|
+
for cpath in _all_concepts(bundle_abs):
|
|
952
|
+
title = _concept_title(cpath)
|
|
953
|
+
info[cpath] = {
|
|
954
|
+
"title": title,
|
|
955
|
+
"slug": _slug(title),
|
|
956
|
+
"deps": [(d[0], d[1] if len(d) > 1 else "") for d in _read_deps_rows(cpath)],
|
|
957
|
+
"xrefs": [(x[0], x[1] if len(x) > 1 else "") for x in _read_xref_rows(cpath)],
|
|
958
|
+
}
|
|
959
|
+
slug_to_path = {}
|
|
960
|
+
for p, i in info.items():
|
|
961
|
+
slug_to_path.setdefault(i["slug"], p)
|
|
962
|
+
|
|
963
|
+
orphans = []
|
|
964
|
+
for ypath, yi in info.items():
|
|
965
|
+
for (using, via) in yi["xrefs"]:
|
|
966
|
+
xp = slug_to_path.get(_slug(using))
|
|
967
|
+
if xp is None:
|
|
968
|
+
orphans.append({"direction": "reverse", "concept": _rel(ypath, root),
|
|
969
|
+
"aggregate": yi["title"], "using": using, "via": via,
|
|
970
|
+
"reason": "using-slice has no concept"})
|
|
971
|
+
elif not any(_slug(t) == yi["slug"] and v == via for (t, v) in info[xp]["deps"]):
|
|
972
|
+
orphans.append({"direction": "reverse", "concept": _rel(ypath, root),
|
|
973
|
+
"aggregate": yi["title"], "using": using, "via": via,
|
|
974
|
+
"reason": "using-slice no longer declares this dependency"})
|
|
975
|
+
for xpath, xi in info.items():
|
|
976
|
+
for (target, via) in xi["deps"]:
|
|
977
|
+
yp = slug_to_path.get(_slug(target))
|
|
978
|
+
if yp is None:
|
|
979
|
+
orphans.append({"direction": "forward", "concept": _rel(xpath, root),
|
|
980
|
+
"aggregate": xi["title"], "target": target, "via": via,
|
|
981
|
+
"reason": "depends-on target has no concept"})
|
|
982
|
+
elif not any(_slug(u) == xi["slug"] and v == via for (u, v) in info[yp]["xrefs"]):
|
|
983
|
+
orphans.append({"direction": "forward", "concept": _rel(xpath, root),
|
|
984
|
+
"aggregate": xi["title"], "target": target, "via": via,
|
|
985
|
+
"reason": "used slice missing the reverse cross-reference"})
|
|
986
|
+
|
|
987
|
+
pruned = 0
|
|
988
|
+
if args.prune:
|
|
989
|
+
# Only reverse-orphans are safe to auto-remove (a stale reverse row). Forward-
|
|
990
|
+
# orphans mean a MISSING reverse edge — deleting X's real Depends-On would lose a
|
|
991
|
+
# true dependency; those are fixed by re-running record, not by prune.
|
|
992
|
+
rev = {}
|
|
993
|
+
for o in orphans:
|
|
994
|
+
if o["direction"] == "reverse":
|
|
995
|
+
rev.setdefault(o["concept"], set()).add((o["using"], o["via"]))
|
|
996
|
+
for crel, keys in rev.items():
|
|
997
|
+
cpath = os.path.join(root, crel)
|
|
998
|
+
rows = _read_xref_rows(cpath)
|
|
999
|
+
keep = [r for r in rows if (r[0], r[1] if len(r) > 1 else "") not in keys]
|
|
1000
|
+
pruned += len(rows) - len(keep)
|
|
1001
|
+
_write_xref_rows(cpath, keep)
|
|
1002
|
+
|
|
1003
|
+
rev_n = sum(1 for o in orphans if o["direction"] == "reverse")
|
|
1004
|
+
fwd_n = len(orphans) - rev_n
|
|
1005
|
+
print(f"[xref] validate: {len(orphans)} orphan(s) — {rev_n} reverse, {fwd_n} forward"
|
|
1006
|
+
+ (f"; pruned {pruned} reverse" if args.prune else ""), file=sys.stderr)
|
|
1007
|
+
_emit({"orphans": orphans, "orphan_count": len(orphans), "reverse_orphans": rev_n,
|
|
1008
|
+
"forward_orphans": fwd_n, "pruned": pruned,
|
|
1009
|
+
"ok": "true" if not orphans else "false", "root": root})
|
|
1010
|
+
return 0
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
# ─────────────────────────────────── log ───────────────────────────────────
|
|
1014
|
+
def cmd_log(args):
|
|
1015
|
+
"""Append a commit-anchored entry to the bundle's log.md (OKF §7 change journal /
|
|
1016
|
+
validate_okf E4 freshness). No-op when there is no bundle or no git commit to anchor —
|
|
1017
|
+
never writes a block without a valid sha (that would make the bundle non-conformant)."""
|
|
1018
|
+
root = _root(args.root)
|
|
1019
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
1020
|
+
if not os.path.isdir(bundle_abs):
|
|
1021
|
+
print("[xref] log: no OKF bundle — skipped", file=sys.stderr)
|
|
1022
|
+
_emit({"logged": "false", "reason": "no-bundle", "root": root})
|
|
1023
|
+
return 0
|
|
1024
|
+
sha = _git_head_short(root)
|
|
1025
|
+
if not sha:
|
|
1026
|
+
print("[xref] log: no git commit to anchor — skipped", file=sys.stderr)
|
|
1027
|
+
_emit({"logged": "false", "reason": "no-commit", "root": root})
|
|
1028
|
+
return 0
|
|
1029
|
+
date = _today(args.date)
|
|
1030
|
+
note = args.note or "OKF cross-references updated."
|
|
1031
|
+
logp = os.path.join(bundle_abs, "log.md")
|
|
1032
|
+
entry = f"## {date}\n\nCommit: `{sha}`\n\n- {note}\n"
|
|
1033
|
+
if os.path.isfile(logp):
|
|
1034
|
+
_write(logp, _read(logp).rstrip("\n") + "\n\n" + entry)
|
|
1035
|
+
else:
|
|
1036
|
+
_write(logp, "# Change Log\n\nOKF bundle change journal — commit-anchored (OKF §7).\n\n" + entry)
|
|
1037
|
+
print(f"[xref] log: {date} @ {sha} — {note}", file=sys.stderr)
|
|
1038
|
+
_emit({"logged": "true", "commit": sha, "date": date, "note": note,
|
|
1039
|
+
"log": _rel(logp, root), "root": root})
|
|
1040
|
+
return 0
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
# ─────────────────────────────────── tree ───────────────────────────────────
|
|
1044
|
+
def cmd_tree(args):
|
|
1045
|
+
"""Build the global dependency hierarchy tree and topological migration wave plan
|
|
1046
|
+
from OKF concept bundles (knowledge/) and specs/."""
|
|
1047
|
+
root = _root(args.root)
|
|
1048
|
+
bundle_abs = os.path.join(root, args.bundle)
|
|
1049
|
+
specs_abs = os.path.join(root, args.specs)
|
|
1050
|
+
|
|
1051
|
+
concepts = _all_concepts(bundle_abs)
|
|
1052
|
+
aggregates = {}
|
|
1053
|
+
|
|
1054
|
+
# 1. Parse knowledge bundle concept files
|
|
1055
|
+
for cp in concepts:
|
|
1056
|
+
agg_name = os.path.splitext(os.path.basename(cp))[0]
|
|
1057
|
+
agg_slug = _slug(agg_name)
|
|
1058
|
+
if agg_slug in RESERVED:
|
|
1059
|
+
continue
|
|
1060
|
+
title = _concept_title(cp)
|
|
1061
|
+
deps_rows = _read_deps_rows(cp)
|
|
1062
|
+
xref_rows = _read_xref_rows(cp)
|
|
1063
|
+
|
|
1064
|
+
dep_targets = []
|
|
1065
|
+
for r in deps_rows:
|
|
1066
|
+
if len(r) >= 1 and r[0].strip():
|
|
1067
|
+
t = r[0].strip()
|
|
1068
|
+
t_slug = _slug(t)
|
|
1069
|
+
if t_slug and t_slug != agg_slug:
|
|
1070
|
+
dep_targets.append(t)
|
|
1071
|
+
|
|
1072
|
+
aggregates[agg_slug] = {
|
|
1073
|
+
"name": title or agg_name,
|
|
1074
|
+
"slug": agg_slug,
|
|
1075
|
+
"concept_path": _rel(cp, root),
|
|
1076
|
+
"deps": list(set(dep_targets)),
|
|
1077
|
+
"xrefs": [r[0] for r in xref_rows if len(r) >= 1]
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
# 2. Check specs/ directory for features
|
|
1081
|
+
if os.path.isdir(specs_abs):
|
|
1082
|
+
for entry in os.listdir(specs_abs):
|
|
1083
|
+
entry_path = os.path.join(specs_abs, entry)
|
|
1084
|
+
if os.path.isdir(entry_path) and not entry.startswith("."):
|
|
1085
|
+
slug = _slug(entry)
|
|
1086
|
+
if slug not in aggregates:
|
|
1087
|
+
design_p = os.path.join(entry_path, "design.md")
|
|
1088
|
+
spec_p = os.path.join(entry_path, "spec.md")
|
|
1089
|
+
if os.path.exists(design_p) or os.path.exists(spec_p):
|
|
1090
|
+
dep_targets = []
|
|
1091
|
+
if os.path.exists(design_p):
|
|
1092
|
+
known_map = {s: s for s in aggregates}
|
|
1093
|
+
edges = _design_edges(_read(design_p), entry, known_map)
|
|
1094
|
+
dep_targets = [e["target"] for e in edges]
|
|
1095
|
+
aggregates[slug] = {
|
|
1096
|
+
"name": entry,
|
|
1097
|
+
"slug": slug,
|
|
1098
|
+
"concept_path": _rel(spec_p if os.path.exists(spec_p) else entry_path, root),
|
|
1099
|
+
"deps": list(set(dep_targets)),
|
|
1100
|
+
"xrefs": []
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
# 3. Fallback scan src/ directory if no aggregates in bundle/specs
|
|
1104
|
+
if not aggregates:
|
|
1105
|
+
src_abs = os.path.join(root, "src")
|
|
1106
|
+
if os.path.isdir(src_abs):
|
|
1107
|
+
for entry in os.listdir(src_abs):
|
|
1108
|
+
ep = os.path.join(src_abs, entry)
|
|
1109
|
+
if os.path.isdir(ep) and not entry.startswith("."):
|
|
1110
|
+
slug = _slug(entry)
|
|
1111
|
+
aggregates[slug] = {
|
|
1112
|
+
"name": entry,
|
|
1113
|
+
"slug": slug,
|
|
1114
|
+
"concept_path": _rel(ep, root),
|
|
1115
|
+
"deps": [],
|
|
1116
|
+
"xrefs": []
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
# 4. Build Dependency Graph
|
|
1120
|
+
all_slugs = set(aggregates.keys())
|
|
1121
|
+
deps_graph = {}
|
|
1122
|
+
for slug, info in aggregates.items():
|
|
1123
|
+
internal_deps = sorted(list(set(_slug(d) for d in info["deps"] if _slug(d) in all_slugs and _slug(d) != slug)))
|
|
1124
|
+
deps_graph[slug] = internal_deps
|
|
1125
|
+
|
|
1126
|
+
# 5. Topological Sort into Migration Waves
|
|
1127
|
+
placed = set()
|
|
1128
|
+
waves = []
|
|
1129
|
+
has_cycles = False
|
|
1130
|
+
cycles = []
|
|
1131
|
+
remaining = set(all_slugs)
|
|
1132
|
+
|
|
1133
|
+
while remaining:
|
|
1134
|
+
next_wave_nodes = []
|
|
1135
|
+
for slug in sorted(list(remaining)):
|
|
1136
|
+
reqs = deps_graph[slug]
|
|
1137
|
+
if all(req in placed for req in reqs):
|
|
1138
|
+
next_wave_nodes.append(slug)
|
|
1139
|
+
|
|
1140
|
+
if not next_wave_nodes:
|
|
1141
|
+
has_cycles = True
|
|
1142
|
+
cycle_nodes = list(remaining)
|
|
1143
|
+
cycles.append(cycle_nodes)
|
|
1144
|
+
waves.append({
|
|
1145
|
+
"wave_id": len(waves),
|
|
1146
|
+
"name": f"Wave {len(waves)} (Cycle Remediation Required)",
|
|
1147
|
+
"aggregates": [aggregates[s]["name"] for s in cycle_nodes],
|
|
1148
|
+
"slugs": cycle_nodes,
|
|
1149
|
+
"is_cycle": True
|
|
1150
|
+
})
|
|
1151
|
+
break
|
|
1152
|
+
|
|
1153
|
+
wave_id = len(waves)
|
|
1154
|
+
wave_name = "Wave 0 (Base / Leaf Aggregates)" if wave_id == 0 else f"Wave {wave_id}"
|
|
1155
|
+
waves.append({
|
|
1156
|
+
"wave_id": wave_id,
|
|
1157
|
+
"name": wave_name,
|
|
1158
|
+
"aggregates": [aggregates[s]["name"] for s in next_wave_nodes],
|
|
1159
|
+
"slugs": next_wave_nodes,
|
|
1160
|
+
"is_cycle": False
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
placed.update(next_wave_nodes)
|
|
1164
|
+
remaining.difference_update(next_wave_nodes)
|
|
1165
|
+
|
|
1166
|
+
# 6. Format and Write Output Reports
|
|
1167
|
+
out_md_path = os.path.join(root, args.out) if args.out else os.path.join(root, "specs", "migration-tree.md")
|
|
1168
|
+
out_json_path = os.path.join(root, args.out_json) if getattr(args, "out_json", None) else os.path.join(root, "specs", "migration-sequence.json")
|
|
1169
|
+
|
|
1170
|
+
os.makedirs(os.path.dirname(out_md_path), exist_ok=True)
|
|
1171
|
+
os.makedirs(os.path.dirname(out_json_path), exist_ok=True)
|
|
1172
|
+
|
|
1173
|
+
md_lines = [
|
|
1174
|
+
"# Aggregates Dependency Hierarchy Tree & Migration Wave Plan",
|
|
1175
|
+
"",
|
|
1176
|
+
f"*Generated by `aw-xref.py tree` on {datetime.date.today().isoformat()}*",
|
|
1177
|
+
"",
|
|
1178
|
+
"## Topological Migration Waves",
|
|
1179
|
+
"",
|
|
1180
|
+
"| Wave | Aggregate / Feature | Dependencies (Internal) | Status / Seam Strategy |",
|
|
1181
|
+
"|---|---|---|---|",
|
|
1182
|
+
]
|
|
1183
|
+
|
|
1184
|
+
for w in waves:
|
|
1185
|
+
for slug in w["slugs"]:
|
|
1186
|
+
info = aggregates[slug]
|
|
1187
|
+
deps_str = ", ".join(deps_graph[slug]) if deps_graph[slug] else "*(None - Leaf)*"
|
|
1188
|
+
seam_strat = "Cycle Remediation Required" if w.get("is_cycle") else ("Pure Service / Minimal Seam" if w["wave_id"] == 0 else f"Wave {w['wave_id']} Strangler Seam")
|
|
1189
|
+
md_lines.append(f"| Wave {w['wave_id']} | `{info['name']}` | {deps_str} | {seam_strat} |")
|
|
1190
|
+
|
|
1191
|
+
if has_cycles:
|
|
1192
|
+
md_lines.extend([
|
|
1193
|
+
"",
|
|
1194
|
+
"## ⚠️ Circular Dependency Warning",
|
|
1195
|
+
"",
|
|
1196
|
+
"The following aggregates contain circular dependencies and require seam refactoring before migration:",
|
|
1197
|
+
""
|
|
1198
|
+
])
|
|
1199
|
+
for c in cycles:
|
|
1200
|
+
md_lines.append(f"- `{' ↔ '.join([aggregates[s]['name'] for s in c])}`")
|
|
1201
|
+
|
|
1202
|
+
md_lines.extend([
|
|
1203
|
+
"",
|
|
1204
|
+
"## Dependency Hierarchy (DAG)",
|
|
1205
|
+
"",
|
|
1206
|
+
"```mermaid",
|
|
1207
|
+
"flowchart TD"
|
|
1208
|
+
])
|
|
1209
|
+
|
|
1210
|
+
for slug, info in aggregates.items():
|
|
1211
|
+
md_lines.append(f' {slug}["{info["name"]}"]')
|
|
1212
|
+
for dep_slug in deps_graph[slug]:
|
|
1213
|
+
md_lines.append(f' {dep_slug} --> {slug}')
|
|
1214
|
+
|
|
1215
|
+
md_lines.extend([
|
|
1216
|
+
"```",
|
|
1217
|
+
""
|
|
1218
|
+
])
|
|
1219
|
+
|
|
1220
|
+
_write(out_md_path, "\n".join(md_lines) + "\n")
|
|
1221
|
+
|
|
1222
|
+
sequence_data = {
|
|
1223
|
+
"generated_at": datetime.date.today().isoformat(),
|
|
1224
|
+
"aggregate_count": len(aggregates),
|
|
1225
|
+
"wave_count": len(waves),
|
|
1226
|
+
"has_cycles": has_cycles,
|
|
1227
|
+
"cycles": cycles,
|
|
1228
|
+
"waves": waves,
|
|
1229
|
+
"aggregates": aggregates
|
|
1230
|
+
}
|
|
1231
|
+
_write(out_json_path, json.dumps(sequence_data, indent=2) + "\n")
|
|
1232
|
+
|
|
1233
|
+
print(f"[xref] Generated Dependency Tree: {len(aggregates)} aggregate(s) sorted into {len(waves)} wave(s). Report: {_rel(out_md_path, root)}", file=sys.stderr)
|
|
1234
|
+
|
|
1235
|
+
_emit({
|
|
1236
|
+
"aggregate_count": len(aggregates),
|
|
1237
|
+
"wave_count": len(waves),
|
|
1238
|
+
"has_cycles": has_cycles,
|
|
1239
|
+
"cycles": cycles,
|
|
1240
|
+
"tree_file": _rel(out_md_path, root),
|
|
1241
|
+
"sequence_file": _rel(out_json_path, root),
|
|
1242
|
+
"waves": waves,
|
|
1243
|
+
"root": root
|
|
1244
|
+
})
|
|
1245
|
+
return 0
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
# ─────────────────────────────── argparse ──────────────────────────────────
|
|
1249
|
+
def main(argv):
|
|
1250
|
+
ap = argparse.ArgumentParser(prog="aw-xref.py", add_help=True)
|
|
1251
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
1252
|
+
|
|
1253
|
+
r = sub.add_parser("record", help="reconcile a using slice's edges across the bundle")
|
|
1254
|
+
r.add_argument("--using", default="")
|
|
1255
|
+
r.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1256
|
+
r.add_argument("--design", default="")
|
|
1257
|
+
r.add_argument("--origin", default="greenfield", help="greenfield | reverse-engineered | migrated | auto (detect from design.md)")
|
|
1258
|
+
r.add_argument("--date", default="")
|
|
1259
|
+
r.add_argument("--root", default="")
|
|
1260
|
+
r.add_argument("--specs", default="specs")
|
|
1261
|
+
r.add_argument("--bundle", default="knowledge")
|
|
1262
|
+
r.set_defaults(func=cmd_record)
|
|
1263
|
+
|
|
1264
|
+
l = sub.add_parser("list", help="an aggregate's blast radius (who uses it)")
|
|
1265
|
+
l.add_argument("--aggregate", default="")
|
|
1266
|
+
l.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1267
|
+
l.add_argument("--out", default="", help="also write a human blast-radius report to this path")
|
|
1268
|
+
l.add_argument("--root", default="")
|
|
1269
|
+
l.add_argument("--bundle", default="knowledge")
|
|
1270
|
+
l.set_defaults(func=cmd_list)
|
|
1271
|
+
|
|
1272
|
+
c = sub.add_parser("concept-upsert", help="create/update an aggregate's own concept")
|
|
1273
|
+
c.add_argument("--aggregate", default="")
|
|
1274
|
+
c.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1275
|
+
c.add_argument("--source-files", default="")
|
|
1276
|
+
c.add_argument("--merge", action="store_true", help="union source_files with existing (CR delta) instead of replacing")
|
|
1277
|
+
c.add_argument("--title", default="")
|
|
1278
|
+
c.add_argument("--description", default="")
|
|
1279
|
+
c.add_argument("--origin", default="greenfield")
|
|
1280
|
+
c.add_argument("--root", default="")
|
|
1281
|
+
c.add_argument("--bundle", default="knowledge")
|
|
1282
|
+
c.set_defaults(func=cmd_concept_upsert)
|
|
1283
|
+
|
|
1284
|
+
co = sub.add_parser("concept-overview", help="splice model-authored prose into a concept's Overview")
|
|
1285
|
+
co.add_argument("--aggregate", default="")
|
|
1286
|
+
co.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1287
|
+
co.add_argument("--text", default="", help="the overview prose (2-4 sentences)")
|
|
1288
|
+
co.add_argument("--text-file", dest="text_file", default="", help="read the prose from this file instead")
|
|
1289
|
+
co.add_argument("--root", default="")
|
|
1290
|
+
co.add_argument("--bundle", default="knowledge")
|
|
1291
|
+
co.set_defaults(func=cmd_concept_overview)
|
|
1292
|
+
|
|
1293
|
+
cp = sub.add_parser("concept-paths", help="read-only: the slice's source_files (replaces design-paths)")
|
|
1294
|
+
cp.add_argument("--aggregate", default="")
|
|
1295
|
+
cp.add_argument("--feature-dir", dest="feature_dir", default="")
|
|
1296
|
+
cp.add_argument("--root", default="")
|
|
1297
|
+
cp.add_argument("--bundle", default="knowledge")
|
|
1298
|
+
cp.set_defaults(func=cmd_concept_paths)
|
|
1299
|
+
|
|
1300
|
+
vv = sub.add_parser("validate", help="report cross-reference drift (orphan rows)")
|
|
1301
|
+
vv.add_argument("--prune", action="store_true", help="remove stale reverse orphans (safe self-heal)")
|
|
1302
|
+
vv.add_argument("--root", default="")
|
|
1303
|
+
vv.add_argument("--bundle", default="knowledge")
|
|
1304
|
+
vv.set_defaults(func=cmd_validate)
|
|
1305
|
+
|
|
1306
|
+
lg = sub.add_parser("log", help="append a commit-anchored entry to the bundle log.md")
|
|
1307
|
+
lg.add_argument("--note", default="")
|
|
1308
|
+
lg.add_argument("--date", default="")
|
|
1309
|
+
lg.add_argument("--root", default="")
|
|
1310
|
+
lg.add_argument("--bundle", default="knowledge")
|
|
1311
|
+
lg.set_defaults(func=cmd_log)
|
|
1312
|
+
|
|
1313
|
+
tr = sub.add_parser("tree", help="build dependency hierarchy tree & topological migration waves")
|
|
1314
|
+
tr.add_argument("--out", default="", help="output markdown file (default: specs/migration-tree.md)")
|
|
1315
|
+
tr.add_argument("--out-json", dest="out_json", default="", help="output json file (default: specs/migration-sequence.json)")
|
|
1316
|
+
tr.add_argument("--root", default="")
|
|
1317
|
+
tr.add_argument("--specs", default="specs")
|
|
1318
|
+
tr.add_argument("--bundle", default="knowledge")
|
|
1319
|
+
tr.set_defaults(func=cmd_tree)
|
|
1320
|
+
|
|
1321
|
+
args = ap.parse_args(argv)
|
|
1322
|
+
if not getattr(args, "func", None):
|
|
1323
|
+
ap.print_help(sys.stderr)
|
|
1324
|
+
return 2
|
|
1325
|
+
return args.func(args)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
if __name__ == "__main__":
|
|
1329
|
+
sys.exit(main(sys.argv[1:]))
|
|
1330
|
+
|