skilldrop-cli 0.16.45 → 0.16.46
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/catalogue.json +1 -0
- package/model-routing.json +1 -0
- package/package.json +1 -1
- package/packs/solution-architect/skills/context-graph/SKILL.md +119 -0
- package/packs/solution-architect/skills/context-graph/evals/eval_queries.json +11 -0
- package/packs/solution-architect/skills/context-graph/evals/evals.json +225 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0001-record-architecture-decisions.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0002-postgres-as-primary-database.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0003-use-redis-for-session-storage.md +24 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0004-structured-json-logging.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0005-session-lifetime-eight-hours.md +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0006-move-sessions-to-postgres.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0007-stateless-signed-session-cookies.md +35 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/services/api/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/services/api/db.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/services/api/logging_setup.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/services/auth/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/services/auth/cookie_sessions.py +7 -0
- package/packs/solution-architect/skills/context-graph/evals/files/1/services/auth/redis_sessions.py +7 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/README.md +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0001-record-architecture-decisions.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0004-service-auth-hmac-shared-keys.md +21 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0007-secrets-from-vault-agent.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0011-service-auth-mtls.md +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/app.py +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/auth/__init__.py +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/auth/hmac_verify.py +31 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/config/settings.py +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/services/ledger-worker/auth/__init__.py +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/10/services/ledger-worker/auth/mtls.py +18 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/README.md +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/app/models/base.py +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/app/models/line_item.py +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/app/models/order.py +24 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-003-money-as-minor-units.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-005-orders-typed-columns-only.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-009-line-items-immutable.md +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-014-optional-order-attributes-json.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-016-order-attributes-table.md +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/migrations/0007_create_orders.sql +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/11/migrations/0012_add_order_attributes.sql +2 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/README.md +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/config/delivery.yaml +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/config/export.yaml +2 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/config/partners.yaml +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0001-record-architecture-decisions.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0002-postgres-delivery-log.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0003-redis-idempotency-keys.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0004-retry-policy.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0005-hmac-signed-payloads.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0006-postgres-idempotency.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0007-sftp-batch-export.md +12 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0008-partner-config-in-yaml.md +12 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0009-partner-config-admin-api.md +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/migrations/003_idempotency_keys.sql +6 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/migrations/004_partners.sql +7 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/delivery/dispatcher.py +29 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/delivery/retry.py +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/delivery/signing.py +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/export/s3_export.py +24 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/idempotency/pg_store.py +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/idempotency/redis_store.py +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/partners/loader.py +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/12/src/store/delivery_log.py +18 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/README.md +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/config/cache.yaml +2 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/config/settings.yaml +2 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-008-pricing-from-erp.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-012-pricing-cache.md +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-015-checkout-price-lock.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-019-pricing-service.md +17 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/catalog/views.py +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/checkout/lock.py +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/pricing/cache.py +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/pricing/client.py +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0001-idempotency-keys.md +33 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0002-processor-retry-policy.md +26 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0003-money-in-minor-units.md +31 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0004-webhook-signing.md +27 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0005-retry-with-jitter.md +30 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0006-idempotency-at-gateway.md +28 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/pr-summary.md +27 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/amount.ts +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/charge.ts +28 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/idempotency.ts +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/retry.ts +21 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/src/webhooks/send.ts +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/14/src/webhooks/sign.ts +6 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/deploy/services.yaml +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0001-record-architecture-decisions.md +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0002-use-postgresql-for-shipment-data.md +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0003-store-vehicle-telemetry-in-cassandra.md +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0004-use-redis-for-session-storage.md +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0005-use-react-for-dispatcher-dashboard.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0006-archive-delivered-shipments-to-object-storage.md +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0007-move-shipment-records-to-mongodb.md +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0008-store-vehicle-telemetry-in-timescaledb.md +28 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0009-stateless-sessions-with-signed-cookies.md +24 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/services/api/session.py +17 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/services/shipments/db.py +30 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/services/shipments/migrations.sql +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/services/telemetry/store.py +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/15/web/dashboard/App.jsx +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0001-record-architecture-decisions.md +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0002-rabbitmq-for-background-jobs.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0003-thumbnail-service.md +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0004-money-as-integer-minor-units.md +17 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0005-worker-retries.md +12 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0006-fx-amounts-with-four-decimals.md +18 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0007-feature-flags.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0008-retire-legacy-search.md +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/libs/money/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/libs/money/amount.py +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/services/checkout/flags.py +1 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/services/checkout/publish.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/services/media/resize.py +5 -0
- package/packs/solution-architect/skills/context-graph/evals/files/2/services/worker/consumer.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0001-how-we-write-rfcs.md +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0002-payments-service-boundary.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0003-webhook-retries-fixed-interval.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0004-webhook-idempotency-keys.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0005-notification-retries.md +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0006-webhook-retry-backoff.md +26 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0007-webhooks-on-the-job-queue.md +12 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/services/notifications/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/services/notifications/retry.py +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/webhooks/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/webhooks/retry.py +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/webhooks/send.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0001-record-architecture-decisions.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0002-postgres-as-system-of-record.md +18 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0004-redis-locks-for-scheduler.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0007-remove-redis.md +27 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0009-money-as-integer-minor-units.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/docs/onboarding.md +12 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/db/__init__.py +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/invoicing.py +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/money.py +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/scheduler/__init__.py +0 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/scheduler/lock.py +23 -0
- package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/scheduler/runner.py +17 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/lib/types/index.ts +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0001-service-layout.md +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0003-ingest-retry-policy.md +26 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0006-shared-retry-library.md +24 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0008-outbound-http-client.md +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/services/export/client.ts +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/services/export/retry.ts +12 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/services/ingest/client.ts +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/5/services/ingest/retry.ts +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/README.md +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/adr/0001-monorepo-layout.md +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/adr/0002-billing-on-postgres.md +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/adr/0004-ledger-sync-nightly-batch.md +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0003-invoice-ids.md +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0007-invoice-ids-ulid.md +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0009-billing-event-store.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0010-revenue-service.md +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/libs/money/ulid.py +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/services/billing/src/events.py +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/services/billing/src/store.py +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/services/invoicing/ids/generator.py +7 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/services/ledger/README.md +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/6/services/ledger/sync.py +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/README.md +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/db/queries/orders.sql +2 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/db/sharding/router.py +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-001-postgres-primary.md +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-002-kafka-event-bus.md +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-003-no-orm.md +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-006-tenant-sharding.md +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-011-orm-for-admin-console.md +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/pipelines/fanout/router.py +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/pipelines/ingest/consumer.py +5 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/pipelines/ingest/topics.yaml +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/rfcs/0004-region-sharding.md +15 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/services/admin-console/models.py +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/7/services/orders/repo.py +9 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/README.md +11 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/api/ledger.proto +30 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/config/http.yaml +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/config/messaging.yaml +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0001-record-architecture-decisions.md +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0002-postgresql-for-ledger.md +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0003-rabbitmq-domain-events.md +22 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0004-integer-minor-units.md +32 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0005-provider-http-retries.md +26 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0006-openapi-generated-clients.md +20 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0007-kafka-domain-events.md +25 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0008-structured-logging.md +18 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/docs/rfcs/RFC-012-idempotency-keys.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/api/idempotency.py +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/api/server.py +35 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/clients/provider.py +33 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/db/migrations/0001_init.sql +14 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/db/migrations/0008_idempotency_keys.sql +5 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/db/migrations/0009_fx_entries.sql +4 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/events/publisher.py +28 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/logging.py +25 -0
- package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/models/entry.py +16 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/.env.example +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/README.md +5 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/docker-compose.yml +18 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/001-session-storage.md +21 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/002-api-rate-limiting.md +32 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/003-jwt-sessions.md +29 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/004-catalog-cache.md +19 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/catalog/cache.ts +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/catalog/repository.ts +13 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/email.ts +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/export.ts +3 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/queue.ts +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/worker.ts +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/lib/db.ts +10 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/lib/redis.ts +8 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/middleware/rateLimit.ts +26 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/server.ts +17 -0
- package/packs/solution-architect/skills/context-graph/evals/files/9/src/session/store.ts +10 -0
- package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/docs/adr/0004-kafka-for-order-events.md +19 -0
- package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/docs/adr/0006-email-receipts.md +7 -0
- package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/docs/adr/0009-sqs-for-order-events.md +19 -0
- package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/services/notifications/receipts.py +0 -0
- package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/services/orders/publisher.py +0 -0
- package/packs/solution-architect/skills/context-graph/examples/order-events.md +63 -0
- package/packs/solution-architect/skills/context-graph/manifest.json +33 -0
- package/packs/solution-architect/skills/context-graph/reference.md +104 -0
- package/packs/solution-architect/skills/context-graph/scripts/context_graph.py +634 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build the graph of decisions behind a codebase, and check it is still true.
|
|
3
|
+
|
|
4
|
+
Reads decision records (ADRs, RFCs, design decisions) as Markdown, finds each one's id, title,
|
|
5
|
+
status and date, and the edges between them: what supersedes or amends what, which records cite
|
|
6
|
+
which, the files and folders each one governs, and, with --git, the commits that mention it.
|
|
7
|
+
Then it checks the graph for what goes wrong as a codebase ages: a supersession only one side
|
|
8
|
+
records, a current decision pointing at code that is gone, two current decisions governing the
|
|
9
|
+
same path, references to records that don't exist.
|
|
10
|
+
|
|
11
|
+
python3 context_graph.py # find records under ./docs, check them
|
|
12
|
+
python3 context_graph.py --root repo --git # and the commits that mention each record
|
|
13
|
+
python3 context_graph.py --why src/auth/session.py
|
|
14
|
+
python3 context_graph.py --record ADR-0004
|
|
15
|
+
python3 context_graph.py --docs architecture/decisions --json -o graph.json --mermaid graph.mmd
|
|
16
|
+
|
|
17
|
+
Exit codes: 0 the records are consistent (warnings allowed) or the query was answered; 1 the
|
|
18
|
+
check found problems; 2 the input can't be read (no records, an unknown --record id).
|
|
19
|
+
Standard library only.
|
|
20
|
+
"""
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
DEFAULT_DIRS = ["docs/adr", "docs/adrs", "docs/decisions", "docs/architecture/decisions", "doc/adr",
|
|
29
|
+
"doc/architecture/decisions", "adr", "adrs", "decisions", "architecture/decisions",
|
|
30
|
+
"docs/rfcs", "docs/rfc", "rfcs", "rfc", "docs/design", "docs/designs"]
|
|
31
|
+
CURRENT = {"accepted", "implemented", "active", "approved"}
|
|
32
|
+
STATUS_WORDS = {
|
|
33
|
+
"proposed": "proposed", "draft": "proposed", "rfc": "proposed", "open": "proposed", "review": "proposed",
|
|
34
|
+
"accepted": "accepted", "approved": "accepted", "agreed": "accepted", "adopted": "accepted", "active": "accepted",
|
|
35
|
+
"implemented": "implemented", "done": "implemented", "shipped": "implemented",
|
|
36
|
+
"rejected": "rejected", "declined": "rejected", "withdrawn": "rejected",
|
|
37
|
+
"deprecated": "deprecated", "retired": "deprecated", "obsolete": "deprecated",
|
|
38
|
+
"superseded": "superseded", "replaced": "superseded",
|
|
39
|
+
}
|
|
40
|
+
# RFC 2119 and RFC 8174 define MUST and SHOULD; decision records cite them for their wording.
|
|
41
|
+
IETF_KEYWORD_RFCS = {"RFC-2119", "RFC-8174"}
|
|
42
|
+
ID_RE = re.compile(r"\b(ADR|RFC|DR|DD)[ _-]?0*(\d{1,5})\b", re.I)
|
|
43
|
+
SUPERSEDES_RE = re.compile(r"\b(supersedes|replaces)\b[:\s]*((?:\[?(?:ADR|RFC|DR|DD)[ _-]?\d+\]?(?:\([^)]*\))?[\s,and&]*)+)", re.I)
|
|
44
|
+
SUPERSEDED_BY_RE = re.compile(r"\b(superseded|replaced)\s+by\b[:\s]*((?:\[?(?:ADR|RFC|DR|DD)[ _-]?\d+\]?(?:\([^)]*\))?[\s,and&]*)+)", re.I)
|
|
45
|
+
AMENDS_RE = re.compile(r"\b(?:amends|extends|refines|narrows|overrides|modifies)\b[:\s]*((?:\[?(?:ADR|RFC|DR|DD)[ _-]?\d+\]?(?:\([^)]*\))?[\s,and&]*)+)", re.I)
|
|
46
|
+
LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s#]+)(?:#[^)]*)?\)")
|
|
47
|
+
CODE_RE = re.compile(r"`([^`\s]{2,200})`")
|
|
48
|
+
DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
|
|
49
|
+
PATHISH = re.compile(r"^(?:\./)?[A-Za-z0-9_.@-]+(?:/[A-Za-z0-9_.@*-]+)*/?$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class InputError(Exception):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def norm_id(prefix, num):
|
|
57
|
+
return "%s-%04d" % (prefix.upper(), int(num))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ids_in(text):
|
|
61
|
+
return [norm_id(m.group(1), m.group(2)) for m in ID_RE.finditer(text)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def kind_of(path):
|
|
65
|
+
"""ADR, RFC or DR, from the folder names only (a file called use-rfc3339.md is not an RFC)."""
|
|
66
|
+
parts = [x.lower() for x in path.replace("\\", "/").split("/")[:-1]]
|
|
67
|
+
if any(re.fullmatch(r"rfcs?", x) for x in parts):
|
|
68
|
+
return "RFC"
|
|
69
|
+
if any(re.fullmatch(r"adrs?|decisions?|architecture-decisions", x) for x in parts):
|
|
70
|
+
return "ADR"
|
|
71
|
+
return "DR"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def frontmatter(text):
|
|
75
|
+
if not text.startswith("---"):
|
|
76
|
+
return {}, text
|
|
77
|
+
end = text.find("\n---", 3)
|
|
78
|
+
if end == -1:
|
|
79
|
+
return {}, text
|
|
80
|
+
meta, key = {}, None
|
|
81
|
+
for line in text[3:end].splitlines():
|
|
82
|
+
if ":" in line and not line.startswith((" ", "\t", "-")):
|
|
83
|
+
k, _, v = line.partition(":")
|
|
84
|
+
key = k.strip().lower().replace("-", "_")
|
|
85
|
+
meta[key] = v.split("#", 1)[0].strip().strip("'\"")
|
|
86
|
+
elif key and re.match(r"^\s*-\s+\S", line): # a block-style list under the last key
|
|
87
|
+
item = line.split("-", 1)[1].split("#", 1)[0].strip().strip("'\"")
|
|
88
|
+
meta[key] = (meta[key] + ", " + item) if meta[key] else item
|
|
89
|
+
return meta, text[end + 4:]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def status_of(meta, body):
|
|
93
|
+
"""(normalised status, the raw words) from frontmatter, a 'Status:' line or a ## Status section."""
|
|
94
|
+
raw = meta.get("status", "")
|
|
95
|
+
if not raw:
|
|
96
|
+
m = re.search(r"^\s*(?:[*-]\s+)?(?:\*\*|__)?status(?:\*\*|__)?\s*[:\-]\s*(?:\*\*|__)?\s*(.+)$", body, re.I | re.M)
|
|
97
|
+
if m:
|
|
98
|
+
raw = m.group(1)
|
|
99
|
+
if not raw:
|
|
100
|
+
m = re.search(r"^#{1,4}\s*status\s*$\s*^(.+)$", body, re.I | re.M)
|
|
101
|
+
if not m:
|
|
102
|
+
m = re.search(r"^#{1,4}\s*status\s*\n\s*\n?\s*(.+)$", body, re.I | re.M)
|
|
103
|
+
if m:
|
|
104
|
+
raw = m.group(1)
|
|
105
|
+
raw = re.sub(r"[*_`]", "", raw).strip()
|
|
106
|
+
first = re.split(r"[\s,;.(]+", raw.lower(), maxsplit=1)[0] if raw else ""
|
|
107
|
+
return STATUS_WORDS.get(first, "unknown" if not raw else "unrecognised"), raw
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def title_of(meta, body, fallback):
|
|
111
|
+
if meta.get("title"):
|
|
112
|
+
return meta["title"]
|
|
113
|
+
m = re.search(r"^#\s+(.+)$", body, re.M)
|
|
114
|
+
if m:
|
|
115
|
+
t = re.sub(r"^(?:ADR|RFC|DR|DD)[ _-]?\d+\s*[:.\-–—]\s*", "", m.group(1).strip(), flags=re.I)
|
|
116
|
+
return t.strip() or fallback
|
|
117
|
+
return fallback
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def date_of(meta, body):
|
|
121
|
+
for k in ("date", "created", "decided", "accepted"):
|
|
122
|
+
m = DATE_RE.search(meta.get(k, ""))
|
|
123
|
+
if m:
|
|
124
|
+
return m.group(1)
|
|
125
|
+
m = re.search(r"^\s*(?:[*-]\s+)?(?:\*\*)?date(?:\*\*)?\s*[:\-]\s*(?:\*\*)?\s*(\d{4}-\d{2}-\d{2})", body, re.I | re.M)
|
|
126
|
+
if m:
|
|
127
|
+
return m.group(1)
|
|
128
|
+
head = "\n".join(body.splitlines()[:15])
|
|
129
|
+
m = DATE_RE.search(head)
|
|
130
|
+
return m.group(1) if m else ""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def list_ids(value):
|
|
134
|
+
return ids_in(value) if value else []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read(path):
|
|
138
|
+
try:
|
|
139
|
+
with open(path, "r", encoding="utf-8-sig") as fh:
|
|
140
|
+
return fh.read(), None
|
|
141
|
+
except UnicodeDecodeError:
|
|
142
|
+
return None, "is not UTF-8 text; it was skipped"
|
|
143
|
+
except OSError as e:
|
|
144
|
+
return None, "can't be read (%s); it was skipped" % e.strerror
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def discover(root, docs):
|
|
148
|
+
dirs = [os.path.join(root, d) for d in docs] if docs else \
|
|
149
|
+
[os.path.join(root, d) for d in DEFAULT_DIRS if os.path.isdir(os.path.join(root, d))]
|
|
150
|
+
missing = [d for d in dirs if not os.path.isdir(d)]
|
|
151
|
+
if docs and missing:
|
|
152
|
+
raise InputError("not a folder: %s" % ", ".join(os.path.relpath(d, root) for d in missing))
|
|
153
|
+
files = []
|
|
154
|
+
for d in dirs:
|
|
155
|
+
for base, subdirs, names in os.walk(d):
|
|
156
|
+
subdirs[:] = sorted(s for s in subdirs if not s.startswith("."))
|
|
157
|
+
for n in sorted(names):
|
|
158
|
+
low = n.lower()
|
|
159
|
+
if not low.endswith((".md", ".markdown")) or low in ("readme.md", "index.md", "_index.md") \
|
|
160
|
+
or "template" in low or re.match(r"^0+[-_.]", low):
|
|
161
|
+
continue
|
|
162
|
+
files.append(os.path.join(base, n))
|
|
163
|
+
return dirs, files
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def parse_record(path, root, text):
|
|
167
|
+
meta, body = frontmatter(text)
|
|
168
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
169
|
+
stem = os.path.splitext(os.path.basename(path))[0]
|
|
170
|
+
kind = kind_of(rel)
|
|
171
|
+
rid = None
|
|
172
|
+
for k in ("id", "adr", "rfc"):
|
|
173
|
+
if meta.get(k):
|
|
174
|
+
found = ids_in(meta[k])
|
|
175
|
+
rid = found[0] if found else (norm_id(k.upper() if k != "id" else kind, meta[k])
|
|
176
|
+
if re.fullmatch(r"\d{1,5}", meta[k]) else None)
|
|
177
|
+
if rid:
|
|
178
|
+
break
|
|
179
|
+
if not rid and not re.match(r"^\d{4}-\d{2}-\d{2}", stem): # 2024-01-15-x.md is a date, not an id
|
|
180
|
+
m = re.match(r"^(?:(adr|rfc|dr|dd)[ _-]?)?0*(\d{1,5})(?=[-_. ]|$)", stem, re.I)
|
|
181
|
+
if m:
|
|
182
|
+
rid = norm_id(m.group(1) or kind, m.group(2))
|
|
183
|
+
if not rid:
|
|
184
|
+
h = re.search(r"^#\s+(.+)$", body, re.M)
|
|
185
|
+
found = ids_in(re.split(r"[:.\-–—]\s", h.group(1), maxsplit=1)[0]) if h else []
|
|
186
|
+
if found:
|
|
187
|
+
rid = found[0]
|
|
188
|
+
if not rid:
|
|
189
|
+
rid = "%s-%s" % (kind, re.sub(r"[^A-Za-z0-9]+", "-", stem).strip("-").lower() or "untitled")
|
|
190
|
+
status, raw_status = status_of(meta, body)
|
|
191
|
+
rec = {"id": rid, "title": title_of(meta, body, stem.replace("-", " ")), "status": status,
|
|
192
|
+
"status_text": raw_status, "date": date_of(meta, body), "path": rel,
|
|
193
|
+
"supersedes": [], "superseded_by": [], "amends": [], "cites": [], "governs": [],
|
|
194
|
+
"missing_paths": [], "commits": []}
|
|
195
|
+
rec["supersedes"] += list_ids(meta.get("supersedes", "")) + list_ids(meta.get("replaces", ""))
|
|
196
|
+
rec["superseded_by"] += list_ids(meta.get("superseded_by", "")) + list_ids(meta.get("replaced_by", ""))
|
|
197
|
+
rec["amends"] += list_ids(meta.get("amends", ""))
|
|
198
|
+
for m in SUPERSEDES_RE.finditer(body):
|
|
199
|
+
rec["supersedes"] += ids_in(m.group(2))
|
|
200
|
+
for m in SUPERSEDED_BY_RE.finditer(body + "\n" + raw_status):
|
|
201
|
+
rec["superseded_by"] += ids_in(m.group(2))
|
|
202
|
+
for m in AMENDS_RE.finditer(body):
|
|
203
|
+
rec["amends"] += ids_in(m.group(1))
|
|
204
|
+
if rec["superseded_by"] and status in ("unknown", "unrecognised", "accepted"):
|
|
205
|
+
if re.match(r"\s*(superseded|replaced)", raw_status, re.I):
|
|
206
|
+
rec["status"] = "superseded"
|
|
207
|
+
rec["cites"] = ids_in(body)
|
|
208
|
+
rec["_links"] = [l for l in LINK_RE.findall(body) if not re.match(r"^[a-z]+:", l, re.I)]
|
|
209
|
+
here = os.path.dirname(rel)
|
|
210
|
+
linked = {os.path.normpath(os.path.join(here, l)).replace(os.sep, "/") for l in rec["_links"]
|
|
211
|
+
if not l.lower().endswith((".md", ".markdown"))}
|
|
212
|
+
rec["_paths"] = sorted(set(CODE_RE.findall(body)) | {l for l in linked if not l.startswith("..")})
|
|
213
|
+
for k in ("supersedes", "superseded_by", "amends"):
|
|
214
|
+
rec[k] = sorted(set(x for x in rec[k] if x != rid))
|
|
215
|
+
return rec
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_links(records, root):
|
|
219
|
+
by_path = {r["path"]: r["id"] for r in records}
|
|
220
|
+
for r in records:
|
|
221
|
+
here = os.path.dirname(r["path"])
|
|
222
|
+
for l in r.pop("_links"):
|
|
223
|
+
target = os.path.normpath(os.path.join(here, l)).replace(os.sep, "/")
|
|
224
|
+
if target in by_path and by_path[target] != r["id"]:
|
|
225
|
+
r["cites"].append(by_path[target])
|
|
226
|
+
r["cites"] = sorted(set(c for c in r["cites"] if c != r["id"]))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def moved_to(root, missing):
|
|
230
|
+
"""Where a missing path may have gone: an existing sibling with a close name
|
|
231
|
+
(services/ledger-sync -> services/ledger), so a rename isn't reported as code that is gone."""
|
|
232
|
+
import difflib
|
|
233
|
+
parent, name = os.path.split(missing.rstrip("/"))
|
|
234
|
+
base = os.path.join(root, parent) if parent else root
|
|
235
|
+
if not os.path.isdir(base):
|
|
236
|
+
return []
|
|
237
|
+
names = [n for n in os.listdir(base) if not n.startswith(".")]
|
|
238
|
+
stem = name.split(".")[0]
|
|
239
|
+
close = difflib.get_close_matches(name, names, n=2, cutoff=0.6)
|
|
240
|
+
close += [n for n in names if n not in close and len(stem) >= 4 and (n.startswith(stem) or stem.startswith(n.split(".")[0]))
|
|
241
|
+
and len(n.split(".")[0]) >= 3]
|
|
242
|
+
return [(parent + "/" if parent else "") + n for n in close[:2]]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_paths(records, root, record_paths):
|
|
246
|
+
top = {n for n in os.listdir(root) if not n.startswith(".")}
|
|
247
|
+
for r in records:
|
|
248
|
+
for tok in r.pop("_paths"):
|
|
249
|
+
t = tok.strip().rstrip(".,;:")
|
|
250
|
+
while t.startswith("./"):
|
|
251
|
+
t = t[2:]
|
|
252
|
+
t = t.lstrip("/")
|
|
253
|
+
if not t or not PATHISH.match(t) or t.startswith(("http", "www.")) or "/" not in t and "." not in t:
|
|
254
|
+
continue
|
|
255
|
+
if t in record_paths or t.lower().endswith((".md", ".markdown")) and t not in record_paths and "/" not in t:
|
|
256
|
+
continue
|
|
257
|
+
if "*" in t:
|
|
258
|
+
t = t.split("*", 1)[0].rstrip("/")
|
|
259
|
+
if not t:
|
|
260
|
+
continue
|
|
261
|
+
full = os.path.join(root, t)
|
|
262
|
+
if os.path.exists(full):
|
|
263
|
+
r["governs"].append(t.rstrip("/") + ("/" if os.path.isdir(full) else ""))
|
|
264
|
+
elif t.split("/", 1)[0] in top and "/" in t:
|
|
265
|
+
r["missing_paths"].append(t.rstrip("/"))
|
|
266
|
+
r["governs"] = sorted(set(r["governs"]))
|
|
267
|
+
r["missing_paths"] = sorted(set(r["missing_paths"]))
|
|
268
|
+
r["moved"] = {m: moved_to(root, m) for m in r["missing_paths"] if moved_to(root, m)}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def git_commits(root, records, limit):
|
|
272
|
+
try:
|
|
273
|
+
out = subprocess.run(["git", "-C", root, "log", "-n", str(limit), "--date=short",
|
|
274
|
+
"--format=%h%x1f%ad%x1f%s%x1f%b%x1e"], capture_output=True, text=True,
|
|
275
|
+
encoding="utf-8", errors="replace", timeout=60)
|
|
276
|
+
except (OSError, subprocess.SubprocessError):
|
|
277
|
+
return "git is not available, so no commits were read"
|
|
278
|
+
if out.returncode != 0:
|
|
279
|
+
return "not a git repository, so no commits were read"
|
|
280
|
+
known = {r["id"]: r for r in records}
|
|
281
|
+
for entry in out.stdout.split("\x1e"):
|
|
282
|
+
parts = entry.strip("\n").split("\x1f")
|
|
283
|
+
if len(parts) < 3:
|
|
284
|
+
continue
|
|
285
|
+
sha, date, subject = parts[0], parts[1], parts[2]
|
|
286
|
+
body = parts[3] if len(parts) > 3 else ""
|
|
287
|
+
for rid in sorted(set(ids_in(subject + "\n" + body))):
|
|
288
|
+
if rid in known and len(known[rid]["commits"]) < 20:
|
|
289
|
+
known[rid]["commits"].append({"sha": sha, "date": date, "subject": subject[:120]})
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
SKIP_DIRS = {".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "__pycache__", "target"}
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def other_docs(root, record_paths, limit=2000):
|
|
297
|
+
"""Markdown outside the record folders (READMEs, onboarding guides) and the record ids each
|
|
298
|
+
cites, so a guide that still teaches a superseded decision is found."""
|
|
299
|
+
found = []
|
|
300
|
+
for base, dirs, names in os.walk(root):
|
|
301
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
|
|
302
|
+
for n in sorted(names):
|
|
303
|
+
if not n.lower().endswith((".md", ".markdown")):
|
|
304
|
+
continue
|
|
305
|
+
rel = os.path.relpath(os.path.join(base, n), root).replace(os.sep, "/")
|
|
306
|
+
if rel in record_paths:
|
|
307
|
+
continue
|
|
308
|
+
text, _ = read(os.path.join(base, n))
|
|
309
|
+
if text:
|
|
310
|
+
cited = sorted(set(ids_in(text)))
|
|
311
|
+
if cited:
|
|
312
|
+
found.append((rel, cited))
|
|
313
|
+
if len(found) >= limit:
|
|
314
|
+
return found
|
|
315
|
+
return found
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def check(records):
|
|
319
|
+
"""Findings: (severity, record id, message). Severity is 'problem' or 'warning'."""
|
|
320
|
+
out = []
|
|
321
|
+
by_id = {}
|
|
322
|
+
# A citation of a kind the repo has no records of ("RFC 7231" in an ADR-only repo) is to an
|
|
323
|
+
# outside document, not a missing record.
|
|
324
|
+
kinds = {r["id"].split("-")[0] for r in records}
|
|
325
|
+
for r in records:
|
|
326
|
+
if r["id"] in by_id:
|
|
327
|
+
out.append(("problem", r["id"], "two records use the id %s: %s and %s"
|
|
328
|
+
% (r["id"], by_id[r["id"]]["path"], r["path"])))
|
|
329
|
+
else:
|
|
330
|
+
by_id[r["id"]] = r
|
|
331
|
+
succ = {}
|
|
332
|
+
for r in records:
|
|
333
|
+
for old in r["supersedes"]:
|
|
334
|
+
succ.setdefault(old, set()).add(r["id"])
|
|
335
|
+
o = by_id.get(old)
|
|
336
|
+
if not o:
|
|
337
|
+
out.append(("warning", r["id"], "supersedes %s, which isn't among the records" % old))
|
|
338
|
+
elif o["status"] in CURRENT or o["status"] in ("unknown", "proposed"):
|
|
339
|
+
out.append(("problem", old, "is still marked %s, but %s supersedes it: mark it 'Superseded by %s'"
|
|
340
|
+
% (o["status_text"] or o["status"], r["id"], r["id"])))
|
|
341
|
+
for new in r["superseded_by"]:
|
|
342
|
+
succ.setdefault(r["id"], set()).add(new)
|
|
343
|
+
n = by_id.get(new)
|
|
344
|
+
if not n:
|
|
345
|
+
out.append(("problem", r["id"], "says it is superseded by %s, which isn't among the records" % new))
|
|
346
|
+
elif r["id"] not in n["supersedes"]:
|
|
347
|
+
out.append(("warning", r["id"], "says it is superseded by %s, but %s doesn't say it supersedes %s"
|
|
348
|
+
% (new, new, r["id"])))
|
|
349
|
+
if r["status"] == "superseded" and not r["superseded_by"] and r["id"] not in succ:
|
|
350
|
+
out.append(("warning", r["id"], "is marked superseded but names no successor"))
|
|
351
|
+
if r["status"] == "unknown":
|
|
352
|
+
out.append(("warning", r["id"], "has no status"))
|
|
353
|
+
elif r["status"] == "unrecognised":
|
|
354
|
+
out.append(("warning", r["id"], "has a status this doesn't recognise: %r" % r["status_text"]))
|
|
355
|
+
for c in r["cites"]:
|
|
356
|
+
if c not in by_id and c.split("-")[0] in kinds and c not in IETF_KEYWORD_RFCS:
|
|
357
|
+
out.append(("warning", r["id"], "refers to %s, which isn't among the records" % c))
|
|
358
|
+
if r["status"] in CURRENT and r["missing_paths"]:
|
|
359
|
+
gone = [m for m in r["missing_paths"] if m not in r.get("moved", {})]
|
|
360
|
+
maybe = ["%s (perhaps now %s)" % (m, " or ".join(r["moved"][m])) for m in r["missing_paths"] if m in r.get("moved", {})]
|
|
361
|
+
if gone:
|
|
362
|
+
out.append(("problem", r["id"], "is %s but points at code that is gone: %s" % (r["status"], ", ".join(gone))))
|
|
363
|
+
if maybe:
|
|
364
|
+
out.append(("warning", r["id"], "is %s but names a path that has moved or been renamed: %s; "
|
|
365
|
+
"update the record if that is where it went" % (r["status"], ", ".join(maybe))))
|
|
366
|
+
# a current record that leans on one that has since been superseded
|
|
367
|
+
for r in records:
|
|
368
|
+
if r["status"] not in CURRENT or r["id"] in succ:
|
|
369
|
+
continue
|
|
370
|
+
for c in r["cites"]:
|
|
371
|
+
if c in succ and c not in r["supersedes"] and c in by_id and r["id"] not in succ[c]:
|
|
372
|
+
out.append(("warning", r["id"], "is current but cites %s, which %s supersedes: check it still holds"
|
|
373
|
+
% (c, ", ".join(sorted(succ[c])))))
|
|
374
|
+
# supersession cycles
|
|
375
|
+
for start in by_id:
|
|
376
|
+
seen, cur = [], start
|
|
377
|
+
while cur in succ and cur not in seen:
|
|
378
|
+
seen.append(cur)
|
|
379
|
+
nxt = sorted(succ[cur])
|
|
380
|
+
cur = nxt[0] if nxt else None
|
|
381
|
+
if cur == start:
|
|
382
|
+
out.append(("problem", start, "is in a supersession cycle: %s" % " -> ".join(seen + [start])))
|
|
383
|
+
break
|
|
384
|
+
# two current records on the same path, neither replacing nor amending the other
|
|
385
|
+
current = [r for r in records if r["status"] in CURRENT]
|
|
386
|
+
seen_pairs = set()
|
|
387
|
+
for i, a in enumerate(current):
|
|
388
|
+
for b in current[i + 1:]:
|
|
389
|
+
linked = b["id"] in a["supersedes"] + a["amends"] or a["id"] in b["supersedes"] + b["amends"]
|
|
390
|
+
if linked:
|
|
391
|
+
continue
|
|
392
|
+
# The same path, not nested scopes: a folder-wide decision and one about a file inside it
|
|
393
|
+
# usually complement each other, and flagging every pair buries the real clashes.
|
|
394
|
+
shared = sorted(set(a["governs"]) & set(b["governs"]))
|
|
395
|
+
if shared and (a["id"], b["id"]) not in seen_pairs:
|
|
396
|
+
seen_pairs.add((a["id"], b["id"]))
|
|
397
|
+
out.append(("warning", a["id"], "and %s are both %s and both govern %s, and neither supersedes or "
|
|
398
|
+
"amends the other: read both before relying on either" % (b["id"], "current", ", ".join(shared))))
|
|
399
|
+
order = {"problem": 0, "warning": 1}
|
|
400
|
+
seen_msgs, uniq = set(), []
|
|
401
|
+
for f in sorted(out, key=lambda f: (order[f[0]], f[1], f[2])):
|
|
402
|
+
if f not in seen_msgs:
|
|
403
|
+
seen_msgs.add(f)
|
|
404
|
+
uniq.append(f)
|
|
405
|
+
return uniq
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def covers(gov, path):
|
|
409
|
+
"""True when a governed path (a file, or a folder ending in /) covers `path`."""
|
|
410
|
+
g, p = gov.rstrip("/"), path.rstrip("/")
|
|
411
|
+
return p == g or p.startswith(g + "/")
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def why(records, path, root):
|
|
415
|
+
p = os.path.relpath(os.path.join(root, path), root).replace(os.sep, "/") if not os.path.isabs(path) \
|
|
416
|
+
else os.path.relpath(path, root).replace(os.sep, "/")
|
|
417
|
+
hits = []
|
|
418
|
+
for r in records:
|
|
419
|
+
for g in r["governs"]:
|
|
420
|
+
if covers(g, p):
|
|
421
|
+
hits.append((r, g))
|
|
422
|
+
break
|
|
423
|
+
succ = {}
|
|
424
|
+
for r in records:
|
|
425
|
+
for old in r["supersedes"]:
|
|
426
|
+
succ.setdefault(old, []).append(r["id"])
|
|
427
|
+
for new in r["superseded_by"]:
|
|
428
|
+
succ.setdefault(r["id"], []).append(new)
|
|
429
|
+
rank = {"accepted": 0, "implemented": 0, "proposed": 1, "unknown": 2, "unrecognised": 2,
|
|
430
|
+
"deprecated": 3, "superseded": 3, "rejected": 4}
|
|
431
|
+
live = lambda r: r["status"] in CURRENT and not succ.get(r["id"])
|
|
432
|
+
hits.sort(key=lambda h: (0 if live(h[0]) else 1, rank.get(h[0]["status"], 2),
|
|
433
|
+
-int(h[0]["date"].replace("-", "") or 0)))
|
|
434
|
+
return p, [{"id": r["id"], "title": r["title"], "status": r["status"], "date": r["date"], "path": r["path"],
|
|
435
|
+
"via": g, "superseded_by": sorted(set(succ.get(r["id"], []))), "current": live(r)} for r, g in hits]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def mermaid(records):
|
|
439
|
+
lines = ["flowchart LR"]
|
|
440
|
+
safe = lambda i: re.sub(r"[^A-Za-z0-9_]", "_", i)
|
|
441
|
+
for r in records:
|
|
442
|
+
succ = r.get("successors") or []
|
|
443
|
+
state = ("superseded by %s" % ", ".join(succ)) if succ else r["status"]
|
|
444
|
+
label = ("%s %s (%s)" % (r["id"], r["title"][:48], state)).replace('"', "'")
|
|
445
|
+
style = ":::gone" if succ or r["status"] in ("superseded", "deprecated", "rejected") else ""
|
|
446
|
+
lines.append(' %s["%s"]%s' % (safe(r["id"]), label, style))
|
|
447
|
+
ids = {r["id"] for r in records}
|
|
448
|
+
for r in records:
|
|
449
|
+
for old in r["supersedes"]:
|
|
450
|
+
if old in ids:
|
|
451
|
+
lines.append(" %s -->|supersedes| %s" % (safe(r["id"]), safe(old)))
|
|
452
|
+
for a in r["amends"]:
|
|
453
|
+
if a in ids:
|
|
454
|
+
lines.append(" %s -.->|amends| %s" % (safe(r["id"]), safe(a)))
|
|
455
|
+
for c in r["cites"]:
|
|
456
|
+
if c in ids and c not in r["supersedes"] and c not in r["amends"]:
|
|
457
|
+
lines.append(" %s -.- %s" % (safe(r["id"]), safe(c)))
|
|
458
|
+
lines.append(" classDef gone fill:#eee,color:#666,stroke-dasharray: 4 3")
|
|
459
|
+
return "\n".join(lines) + "\n"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def report(records, findings, note, query, dirs, root):
|
|
463
|
+
out = []
|
|
464
|
+
probs = [f for f in findings if f[0] == "problem"]
|
|
465
|
+
warns = [f for f in findings if f[0] == "warning"]
|
|
466
|
+
if query and query["kind"] == "why":
|
|
467
|
+
out.append("# Decisions governing %s\n" % query["path"])
|
|
468
|
+
cur = [h for h in query["hits"] if h["current"]]
|
|
469
|
+
if not query["hits"]:
|
|
470
|
+
out.append("No decision record governs %s or a folder above it. Any rationale for it is "
|
|
471
|
+
"unrecorded.\n" % query["path"])
|
|
472
|
+
else:
|
|
473
|
+
for h in query["hits"]:
|
|
474
|
+
if h["superseded_by"] and h["status"] in CURRENT:
|
|
475
|
+
state = "marked %s, but superseded by %s" % (h["status"], ", ".join(h["superseded_by"]))
|
|
476
|
+
elif h["superseded_by"]:
|
|
477
|
+
state = "%s by %s" % (h["status"], ", ".join(h["superseded_by"]))
|
|
478
|
+
else:
|
|
479
|
+
state = h["status"] + (", current" if h["current"] else "")
|
|
480
|
+
out.append("- **%s %s** (%s%s), via `%s`: %s" % (
|
|
481
|
+
h["id"], h["title"], state, ", " + h["date"] if h["date"] else "", h["via"], h["path"]))
|
|
482
|
+
if not cur:
|
|
483
|
+
out.append("\nNone of these is current: the decisions that governed it were superseded, "
|
|
484
|
+
"deprecated or never accepted.")
|
|
485
|
+
out.append("")
|
|
486
|
+
elif query and query["kind"] == "record":
|
|
487
|
+
r = query["record"]
|
|
488
|
+
out.append("# %s %s\n" % (r["id"], r["title"]))
|
|
489
|
+
out.append("- Status: %s%s" % (r["status"], (" (%s)" % r["status_text"]) if r["status_text"] and
|
|
490
|
+
r["status_text"].lower() != r["status"] else ""))
|
|
491
|
+
for k, label in (("date", "Date"), ("path", "File")):
|
|
492
|
+
if r[k]:
|
|
493
|
+
out.append("- %s: %s" % (label, r[k]))
|
|
494
|
+
if r["successors"]:
|
|
495
|
+
one_sided = r["successors_only_claimed_by_successor"]
|
|
496
|
+
out.append("- Superseded by: %s%s" % (", ".join(r["successors"]),
|
|
497
|
+
(" (only %s says so; this record doesn't)" % ", ".join(one_sided)) if one_sided else ""))
|
|
498
|
+
for k, label in (("supersedes", "Supersedes"), ("amends", "Amends"),
|
|
499
|
+
("cited_by", "Cited by"), ("cites", "Cites"), ("governs", "Governs"),
|
|
500
|
+
("missing_paths", "Points at paths that are gone")):
|
|
501
|
+
if r.get(k):
|
|
502
|
+
out.append("- %s: %s" % (label, ", ".join(r[k])))
|
|
503
|
+
for c in r["commits"]:
|
|
504
|
+
out.append("- Commit %s %s: %s" % (c["sha"], c["date"], c["subject"]))
|
|
505
|
+
out.append("")
|
|
506
|
+
counts = {}
|
|
507
|
+
for r in records:
|
|
508
|
+
counts[r["status"]] = counts.get(r["status"], 0) + 1
|
|
509
|
+
out.append("## Records\n")
|
|
510
|
+
out.append("%d record(s) in %s: %s." % (len(records), ", ".join(os.path.relpath(d, root).replace(os.sep, "/")
|
|
511
|
+
for d in dirs) or "-",
|
|
512
|
+
", ".join("%d %s" % (n, s) for s, n in sorted(counts.items()))))
|
|
513
|
+
if note:
|
|
514
|
+
out.append(note[0].upper() + note[1:] + ".")
|
|
515
|
+
out.append("\n## Problems (%d)\n" % len(probs))
|
|
516
|
+
out += ["- %s %s" % (rid, msg) for _, rid, msg in probs] or ["None."]
|
|
517
|
+
out.append("\n## Warnings (%d)\n" % len(warns))
|
|
518
|
+
out += ["- %s %s" % (rid, msg) for _, rid, msg in warns] or ["None."]
|
|
519
|
+
if not query:
|
|
520
|
+
out.append("\n## Graph\n")
|
|
521
|
+
out.append("| Record | Status | Date | Supersedes | Superseded by | Governs |")
|
|
522
|
+
out.append("|---|---|---|---|---|---|")
|
|
523
|
+
for r in records:
|
|
524
|
+
out.append("| %s %s | %s | %s | %s | %s | %s |" % (
|
|
525
|
+
r["id"], r["title"].replace("|", "/")[:60], r["status"], r["date"] or "-",
|
|
526
|
+
", ".join(r["supersedes"]) or "-", ", ".join(r["superseded_by"]) or "-",
|
|
527
|
+
", ".join("`%s`" % g for g in r["governs"][:4]) + (" …" if len(r["governs"]) > 4 else "") or "-"))
|
|
528
|
+
return "\n".join(out) + "\n"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _main(argv=None):
|
|
532
|
+
ap = argparse.ArgumentParser(description="Build and check the graph of decisions behind a codebase.")
|
|
533
|
+
ap.add_argument("--root", default=".", help="the repository root (default: the current folder)")
|
|
534
|
+
ap.add_argument("--docs", action="append", metavar="DIR",
|
|
535
|
+
help="a folder of decision records, relative to --root; repeatable (default: common ADR/RFC folders)")
|
|
536
|
+
q = ap.add_mutually_exclusive_group()
|
|
537
|
+
q.add_argument("--why", metavar="PATH", help="list the decisions that govern a file or folder")
|
|
538
|
+
q.add_argument("--record", metavar="ID", help="show one record and its neighbours, e.g. ADR-0004")
|
|
539
|
+
ap.add_argument("--git", action="store_true", help="also read commit messages for record ids")
|
|
540
|
+
ap.add_argument("--git-limit", type=int, default=5000)
|
|
541
|
+
ap.add_argument("--json", action="store_true", help="print the graph as JSON instead of Markdown")
|
|
542
|
+
ap.add_argument("--mermaid", metavar="FILE", help="also write a Mermaid flowchart of the graph")
|
|
543
|
+
ap.add_argument("-o", "--output", metavar="FILE", help="write the report here instead of stdout")
|
|
544
|
+
a = ap.parse_args(argv)
|
|
545
|
+
|
|
546
|
+
root = os.path.abspath(a.root)
|
|
547
|
+
if not os.path.isdir(root):
|
|
548
|
+
raise InputError("--root is not a folder: %s" % a.root)
|
|
549
|
+
dirs, files = discover(root, a.docs)
|
|
550
|
+
if not files:
|
|
551
|
+
raise InputError("no decision records found%s; pass --docs <folder> where your ADRs or RFCs live"
|
|
552
|
+
% ((" in " + ", ".join(a.docs)) if a.docs else " in the usual folders (docs/adr, docs/decisions, docs/rfcs …)"))
|
|
553
|
+
records, skipped = [], []
|
|
554
|
+
for f in files:
|
|
555
|
+
text, err = read(f)
|
|
556
|
+
if text is None:
|
|
557
|
+
skipped.append("%s %s" % (os.path.relpath(f, root).replace(os.sep, "/"), err))
|
|
558
|
+
continue
|
|
559
|
+
records.append(parse_record(f, root, text))
|
|
560
|
+
if not records:
|
|
561
|
+
raise InputError("none of the %d record file(s) could be read: %s" % (len(files), "; ".join(skipped)))
|
|
562
|
+
resolve_links(records, root)
|
|
563
|
+
resolve_paths(records, root, {r["path"] for r in records})
|
|
564
|
+
notes = list(skipped)
|
|
565
|
+
if a.git:
|
|
566
|
+
n = git_commits(root, records, a.git_limit)
|
|
567
|
+
if n:
|
|
568
|
+
notes.append(n)
|
|
569
|
+
findings = check(records)
|
|
570
|
+
cited_by = {}
|
|
571
|
+
for r in records:
|
|
572
|
+
for c in r["cites"]:
|
|
573
|
+
cited_by.setdefault(c, []).append(r["id"])
|
|
574
|
+
for r in records:
|
|
575
|
+
r["cited_by"] = sorted(set(cited_by.get(r["id"], [])))
|
|
576
|
+
# Successors from both directions: a record's own "superseded by", and every record that says it
|
|
577
|
+
# supersedes this one (often only the newer record says so).
|
|
578
|
+
for r in records:
|
|
579
|
+
claimed = [o["id"] for o in records if r["id"] in o["supersedes"]]
|
|
580
|
+
r["successors"] = sorted(set(r["superseded_by"]) | set(claimed))
|
|
581
|
+
r["successors_only_claimed_by_successor"] = sorted(set(claimed) - set(r["superseded_by"]))
|
|
582
|
+
# Guides and READMEs that still cite a superseded decision may still teach it.
|
|
583
|
+
by_id = {r["id"]: r for r in records}
|
|
584
|
+
docs = other_docs(root, {r["path"] for r in records})
|
|
585
|
+
for rel, cited in docs:
|
|
586
|
+
for c in cited:
|
|
587
|
+
if c in by_id and by_id[c]["successors"]:
|
|
588
|
+
findings.append(("warning", c, "is cited by %s, which may still describe it as current: %s supersedes it"
|
|
589
|
+
% (rel, ", ".join(by_id[c]["successors"]))))
|
|
590
|
+
|
|
591
|
+
query = None
|
|
592
|
+
if a.why:
|
|
593
|
+
p, hits = why(records, a.why, root)
|
|
594
|
+
query = {"kind": "why", "path": p, "hits": hits}
|
|
595
|
+
elif a.record:
|
|
596
|
+
want = ids_in(a.record)
|
|
597
|
+
rid = want[0] if want else a.record
|
|
598
|
+
match = [r for r in records if r["id"].lower() == rid.lower()]
|
|
599
|
+
if not match:
|
|
600
|
+
raise InputError("no record %s; the records are %s" % (a.record, ", ".join(r["id"] for r in records[:30])))
|
|
601
|
+
query = {"kind": "record", "record": match[0]}
|
|
602
|
+
|
|
603
|
+
if a.mermaid:
|
|
604
|
+
with open(a.mermaid, "w", encoding="utf-8") as fh:
|
|
605
|
+
fh.write(mermaid(records))
|
|
606
|
+
if a.json:
|
|
607
|
+
text = json.dumps({"root": root, "records": records, "docs": [{"path": p, "cites": c} for p, c in docs],
|
|
608
|
+
"findings": [{"severity": s, "record": i, "message": m} for s, i, m in findings],
|
|
609
|
+
"notes": notes, "query": query}, indent=1, default=str) + "\n"
|
|
610
|
+
else:
|
|
611
|
+
text = report(records, findings, "; ".join(notes), query, dirs, root)
|
|
612
|
+
if a.output:
|
|
613
|
+
with open(a.output, "w", encoding="utf-8") as fh:
|
|
614
|
+
fh.write(text)
|
|
615
|
+
else:
|
|
616
|
+
sys.stdout.write(text)
|
|
617
|
+
if query:
|
|
618
|
+
return 0
|
|
619
|
+
return 1 if any(f[0] == "problem" for f in findings) else 0
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def main(argv=None):
|
|
623
|
+
try:
|
|
624
|
+
return _main(argv)
|
|
625
|
+
except InputError as e:
|
|
626
|
+
print("error: %s" % e, file=sys.stderr)
|
|
627
|
+
return 2
|
|
628
|
+
except OSError as e:
|
|
629
|
+
print("error: %s" % e, file=sys.stderr)
|
|
630
|
+
return 2
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
sys.exit(main())
|