skilldrop-cli 0.16.45 → 1.0.0

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.
Files changed (230) hide show
  1. package/README.md +1 -1
  2. package/catalogue.json +1 -0
  3. package/model-routing.json +1 -0
  4. package/package.json +1 -1
  5. package/packs/solution-architect/skills/context-graph/SKILL.md +119 -0
  6. package/packs/solution-architect/skills/context-graph/evals/eval_queries.json +11 -0
  7. package/packs/solution-architect/skills/context-graph/evals/evals.json +225 -0
  8. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0001-record-architecture-decisions.md +19 -0
  9. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0002-postgres-as-primary-database.md +16 -0
  10. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0003-use-redis-for-session-storage.md +24 -0
  11. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0004-structured-json-logging.md +15 -0
  12. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0005-session-lifetime-eight-hours.md +22 -0
  13. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0006-move-sessions-to-postgres.md +16 -0
  14. package/packs/solution-architect/skills/context-graph/evals/files/1/docs/adr/0007-stateless-signed-session-cookies.md +35 -0
  15. package/packs/solution-architect/skills/context-graph/evals/files/1/services/api/__init__.py +0 -0
  16. package/packs/solution-architect/skills/context-graph/evals/files/1/services/api/db.py +0 -0
  17. package/packs/solution-architect/skills/context-graph/evals/files/1/services/api/logging_setup.py +0 -0
  18. package/packs/solution-architect/skills/context-graph/evals/files/1/services/auth/__init__.py +0 -0
  19. package/packs/solution-architect/skills/context-graph/evals/files/1/services/auth/cookie_sessions.py +7 -0
  20. package/packs/solution-architect/skills/context-graph/evals/files/1/services/auth/redis_sessions.py +7 -0
  21. package/packs/solution-architect/skills/context-graph/evals/files/10/README.md +8 -0
  22. package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0001-record-architecture-decisions.md +10 -0
  23. package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0004-service-auth-hmac-shared-keys.md +21 -0
  24. package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0007-secrets-from-vault-agent.md +19 -0
  25. package/packs/solution-architect/skills/context-graph/evals/files/10/docs/adr/0011-service-auth-mtls.md +23 -0
  26. package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/app.py +16 -0
  27. package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/auth/__init__.py +4 -0
  28. package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/auth/hmac_verify.py +31 -0
  29. package/packs/solution-architect/skills/context-graph/evals/files/10/services/billing-api/config/settings.py +14 -0
  30. package/packs/solution-architect/skills/context-graph/evals/files/10/services/ledger-worker/auth/__init__.py +4 -0
  31. package/packs/solution-architect/skills/context-graph/evals/files/10/services/ledger-worker/auth/mtls.py +18 -0
  32. package/packs/solution-architect/skills/context-graph/evals/files/11/README.md +4 -0
  33. package/packs/solution-architect/skills/context-graph/evals/files/11/app/models/base.py +3 -0
  34. package/packs/solution-architect/skills/context-graph/evals/files/11/app/models/line_item.py +14 -0
  35. package/packs/solution-architect/skills/context-graph/evals/files/11/app/models/order.py +24 -0
  36. package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-003-money-as-minor-units.md +10 -0
  37. package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-005-orders-typed-columns-only.md +10 -0
  38. package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-009-line-items-immutable.md +11 -0
  39. package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-014-optional-order-attributes-json.md +16 -0
  40. package/packs/solution-architect/skills/context-graph/evals/files/11/eng/decisions/DR-016-order-attributes-table.md +11 -0
  41. package/packs/solution-architect/skills/context-graph/evals/files/11/migrations/0007_create_orders.sql +11 -0
  42. package/packs/solution-architect/skills/context-graph/evals/files/11/migrations/0012_add_order_attributes.sql +2 -0
  43. package/packs/solution-architect/skills/context-graph/evals/files/12/README.md +8 -0
  44. package/packs/solution-architect/skills/context-graph/evals/files/12/config/delivery.yaml +4 -0
  45. package/packs/solution-architect/skills/context-graph/evals/files/12/config/export.yaml +2 -0
  46. package/packs/solution-architect/skills/context-graph/evals/files/12/config/partners.yaml +10 -0
  47. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0001-record-architecture-decisions.md +10 -0
  48. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0002-postgres-delivery-log.md +15 -0
  49. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0003-redis-idempotency-keys.md +15 -0
  50. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0004-retry-policy.md +16 -0
  51. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0005-hmac-signed-payloads.md +10 -0
  52. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0006-postgres-idempotency.md +15 -0
  53. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0007-sftp-batch-export.md +12 -0
  54. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0008-partner-config-in-yaml.md +12 -0
  55. package/packs/solution-architect/skills/context-graph/evals/files/12/docs/adr/0009-partner-config-admin-api.md +13 -0
  56. package/packs/solution-architect/skills/context-graph/evals/files/12/migrations/003_idempotency_keys.sql +6 -0
  57. package/packs/solution-architect/skills/context-graph/evals/files/12/migrations/004_partners.sql +7 -0
  58. package/packs/solution-architect/skills/context-graph/evals/files/12/src/delivery/dispatcher.py +29 -0
  59. package/packs/solution-architect/skills/context-graph/evals/files/12/src/delivery/retry.py +15 -0
  60. package/packs/solution-architect/skills/context-graph/evals/files/12/src/delivery/signing.py +13 -0
  61. package/packs/solution-architect/skills/context-graph/evals/files/12/src/export/s3_export.py +24 -0
  62. package/packs/solution-architect/skills/context-graph/evals/files/12/src/idempotency/pg_store.py +11 -0
  63. package/packs/solution-architect/skills/context-graph/evals/files/12/src/idempotency/redis_store.py +8 -0
  64. package/packs/solution-architect/skills/context-graph/evals/files/12/src/partners/loader.py +15 -0
  65. package/packs/solution-architect/skills/context-graph/evals/files/12/src/store/delivery_log.py +18 -0
  66. package/packs/solution-architect/skills/context-graph/evals/files/13/README.md +3 -0
  67. package/packs/solution-architect/skills/context-graph/evals/files/13/config/cache.yaml +2 -0
  68. package/packs/solution-architect/skills/context-graph/evals/files/13/config/settings.yaml +2 -0
  69. package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-008-pricing-from-erp.md +10 -0
  70. package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-012-pricing-cache.md +23 -0
  71. package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-015-checkout-price-lock.md +15 -0
  72. package/packs/solution-architect/skills/context-graph/evals/files/13/docs/decisions/RFC-019-pricing-service.md +17 -0
  73. package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/catalog/views.py +8 -0
  74. package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/checkout/lock.py +14 -0
  75. package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/pricing/cache.py +22 -0
  76. package/packs/solution-architect/skills/context-graph/evals/files/13/shopfront/pricing/client.py +10 -0
  77. package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0001-idempotency-keys.md +33 -0
  78. package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0002-processor-retry-policy.md +26 -0
  79. package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0003-money-in-minor-units.md +31 -0
  80. package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0004-webhook-signing.md +27 -0
  81. package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0005-retry-with-jitter.md +30 -0
  82. package/packs/solution-architect/skills/context-graph/evals/files/14/docs/rfcs/0006-idempotency-at-gateway.md +28 -0
  83. package/packs/solution-architect/skills/context-graph/evals/files/14/pr-summary.md +27 -0
  84. package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/amount.ts +10 -0
  85. package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/charge.ts +28 -0
  86. package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/idempotency.ts +4 -0
  87. package/packs/solution-architect/skills/context-graph/evals/files/14/src/payments/retry.ts +21 -0
  88. package/packs/solution-architect/skills/context-graph/evals/files/14/src/webhooks/send.ts +9 -0
  89. package/packs/solution-architect/skills/context-graph/evals/files/14/src/webhooks/sign.ts +6 -0
  90. package/packs/solution-architect/skills/context-graph/evals/files/15/deploy/services.yaml +14 -0
  91. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0001-record-architecture-decisions.md +20 -0
  92. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0002-use-postgresql-for-shipment-data.md +23 -0
  93. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0003-store-vehicle-telemetry-in-cassandra.md +22 -0
  94. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0004-use-redis-for-session-storage.md +22 -0
  95. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0005-use-react-for-dispatcher-dashboard.md +19 -0
  96. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0006-archive-delivered-shipments-to-object-storage.md +23 -0
  97. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0007-move-shipment-records-to-mongodb.md +23 -0
  98. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0008-store-vehicle-telemetry-in-timescaledb.md +28 -0
  99. package/packs/solution-architect/skills/context-graph/evals/files/15/doc/adr/0009-stateless-sessions-with-signed-cookies.md +24 -0
  100. package/packs/solution-architect/skills/context-graph/evals/files/15/services/api/session.py +17 -0
  101. package/packs/solution-architect/skills/context-graph/evals/files/15/services/shipments/db.py +30 -0
  102. package/packs/solution-architect/skills/context-graph/evals/files/15/services/shipments/migrations.sql +3 -0
  103. package/packs/solution-architect/skills/context-graph/evals/files/15/services/telemetry/store.py +23 -0
  104. package/packs/solution-architect/skills/context-graph/evals/files/15/web/dashboard/App.jsx +3 -0
  105. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0001-record-architecture-decisions.md +11 -0
  106. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0002-rabbitmq-for-background-jobs.md +15 -0
  107. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0003-thumbnail-service.md +20 -0
  108. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0004-money-as-integer-minor-units.md +17 -0
  109. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0005-worker-retries.md +12 -0
  110. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0006-fx-amounts-with-four-decimals.md +18 -0
  111. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0007-feature-flags.md +16 -0
  112. package/packs/solution-architect/skills/context-graph/evals/files/2/docs/adr/0008-retire-legacy-search.md +14 -0
  113. package/packs/solution-architect/skills/context-graph/evals/files/2/libs/money/__init__.py +0 -0
  114. package/packs/solution-architect/skills/context-graph/evals/files/2/libs/money/amount.py +9 -0
  115. package/packs/solution-architect/skills/context-graph/evals/files/2/services/checkout/flags.py +1 -0
  116. package/packs/solution-architect/skills/context-graph/evals/files/2/services/checkout/publish.py +0 -0
  117. package/packs/solution-architect/skills/context-graph/evals/files/2/services/media/resize.py +5 -0
  118. package/packs/solution-architect/skills/context-graph/evals/files/2/services/worker/consumer.py +0 -0
  119. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0001-how-we-write-rfcs.md +9 -0
  120. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0002-payments-service-boundary.md +16 -0
  121. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0003-webhook-retries-fixed-interval.md +19 -0
  122. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0004-webhook-idempotency-keys.md +16 -0
  123. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0005-notification-retries.md +16 -0
  124. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0006-webhook-retry-backoff.md +26 -0
  125. package/packs/solution-architect/skills/context-graph/evals/files/3/docs/rfcs/0007-webhooks-on-the-job-queue.md +12 -0
  126. package/packs/solution-architect/skills/context-graph/evals/files/3/services/notifications/__init__.py +0 -0
  127. package/packs/solution-architect/skills/context-graph/evals/files/3/services/notifications/retry.py +4 -0
  128. package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/__init__.py +0 -0
  129. package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/webhooks/__init__.py +0 -0
  130. package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/webhooks/retry.py +15 -0
  131. package/packs/solution-architect/skills/context-graph/evals/files/3/services/payments/webhooks/send.py +0 -0
  132. package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0001-record-architecture-decisions.md +19 -0
  133. package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0002-postgres-as-system-of-record.md +18 -0
  134. package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0004-redis-locks-for-scheduler.md +19 -0
  135. package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0007-remove-redis.md +27 -0
  136. package/packs/solution-architect/skills/context-graph/evals/files/4/docs/adr/0009-money-as-integer-minor-units.md +19 -0
  137. package/packs/solution-architect/skills/context-graph/evals/files/4/docs/onboarding.md +12 -0
  138. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/__init__.py +0 -0
  139. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/db/__init__.py +22 -0
  140. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/invoicing.py +9 -0
  141. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/money.py +10 -0
  142. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/scheduler/__init__.py +0 -0
  143. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/scheduler/lock.py +23 -0
  144. package/packs/solution-architect/skills/context-graph/evals/files/4/src/ledgerline/scheduler/runner.py +17 -0
  145. package/packs/solution-architect/skills/context-graph/evals/files/5/lib/types/index.ts +13 -0
  146. package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0001-service-layout.md +20 -0
  147. package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0003-ingest-retry-policy.md +26 -0
  148. package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0006-shared-retry-library.md +24 -0
  149. package/packs/solution-architect/skills/context-graph/evals/files/5/rfcs/0008-outbound-http-client.md +22 -0
  150. package/packs/solution-architect/skills/context-graph/evals/files/5/services/export/client.ts +15 -0
  151. package/packs/solution-architect/skills/context-graph/evals/files/5/services/export/retry.ts +12 -0
  152. package/packs/solution-architect/skills/context-graph/evals/files/5/services/ingest/client.ts +14 -0
  153. package/packs/solution-architect/skills/context-graph/evals/files/5/services/ingest/retry.ts +20 -0
  154. package/packs/solution-architect/skills/context-graph/evals/files/6/README.md +4 -0
  155. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/adr/0001-monorepo-layout.md +10 -0
  156. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/adr/0002-billing-on-postgres.md +13 -0
  157. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/adr/0004-ledger-sync-nightly-batch.md +9 -0
  158. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0003-invoice-ids.md +9 -0
  159. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0007-invoice-ids-ulid.md +11 -0
  160. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0009-billing-event-store.md +15 -0
  161. package/packs/solution-architect/skills/context-graph/evals/files/6/docs/rfcs/RFC-0010-revenue-service.md +13 -0
  162. package/packs/solution-architect/skills/context-graph/evals/files/6/libs/money/ulid.py +8 -0
  163. package/packs/solution-architect/skills/context-graph/evals/files/6/services/billing/src/events.py +10 -0
  164. package/packs/solution-architect/skills/context-graph/evals/files/6/services/billing/src/store.py +10 -0
  165. package/packs/solution-architect/skills/context-graph/evals/files/6/services/invoicing/ids/generator.py +7 -0
  166. package/packs/solution-architect/skills/context-graph/evals/files/6/services/ledger/README.md +4 -0
  167. package/packs/solution-architect/skills/context-graph/evals/files/6/services/ledger/sync.py +3 -0
  168. package/packs/solution-architect/skills/context-graph/evals/files/7/README.md +4 -0
  169. package/packs/solution-architect/skills/context-graph/evals/files/7/db/queries/orders.sql +2 -0
  170. package/packs/solution-architect/skills/context-graph/evals/files/7/db/sharding/router.py +9 -0
  171. package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-001-postgres-primary.md +8 -0
  172. package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-002-kafka-event-bus.md +20 -0
  173. package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-003-no-orm.md +9 -0
  174. package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-006-tenant-sharding.md +9 -0
  175. package/packs/solution-architect/skills/context-graph/evals/files/7/design/decisions/DD-011-orm-for-admin-console.md +11 -0
  176. package/packs/solution-architect/skills/context-graph/evals/files/7/pipelines/fanout/router.py +4 -0
  177. package/packs/solution-architect/skills/context-graph/evals/files/7/pipelines/ingest/consumer.py +5 -0
  178. package/packs/solution-architect/skills/context-graph/evals/files/7/pipelines/ingest/topics.yaml +8 -0
  179. package/packs/solution-architect/skills/context-graph/evals/files/7/rfcs/0004-region-sharding.md +15 -0
  180. package/packs/solution-architect/skills/context-graph/evals/files/7/services/admin-console/models.py +11 -0
  181. package/packs/solution-architect/skills/context-graph/evals/files/7/services/orders/repo.py +9 -0
  182. package/packs/solution-architect/skills/context-graph/evals/files/8/README.md +11 -0
  183. package/packs/solution-architect/skills/context-graph/evals/files/8/api/ledger.proto +30 -0
  184. package/packs/solution-architect/skills/context-graph/evals/files/8/config/http.yaml +10 -0
  185. package/packs/solution-architect/skills/context-graph/evals/files/8/config/messaging.yaml +8 -0
  186. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0001-record-architecture-decisions.md +20 -0
  187. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0002-postgresql-for-ledger.md +22 -0
  188. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0003-rabbitmq-domain-events.md +22 -0
  189. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0004-integer-minor-units.md +32 -0
  190. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0005-provider-http-retries.md +26 -0
  191. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0006-openapi-generated-clients.md +20 -0
  192. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0007-kafka-domain-events.md +25 -0
  193. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/adr/0008-structured-logging.md +18 -0
  194. package/packs/solution-architect/skills/context-graph/evals/files/8/docs/rfcs/RFC-012-idempotency-keys.md +19 -0
  195. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/api/idempotency.py +19 -0
  196. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/api/server.py +35 -0
  197. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/clients/provider.py +33 -0
  198. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/db/migrations/0001_init.sql +14 -0
  199. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/db/migrations/0008_idempotency_keys.sql +5 -0
  200. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/db/migrations/0009_fx_entries.sql +4 -0
  201. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/events/publisher.py +28 -0
  202. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/logging.py +25 -0
  203. package/packs/solution-architect/skills/context-graph/evals/files/8/services/ledger/models/entry.py +16 -0
  204. package/packs/solution-architect/skills/context-graph/evals/files/9/.env.example +3 -0
  205. package/packs/solution-architect/skills/context-graph/evals/files/9/README.md +5 -0
  206. package/packs/solution-architect/skills/context-graph/evals/files/9/docker-compose.yml +18 -0
  207. package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/001-session-storage.md +21 -0
  208. package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/002-api-rate-limiting.md +32 -0
  209. package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/003-jwt-sessions.md +29 -0
  210. package/packs/solution-architect/skills/context-graph/evals/files/9/docs/decisions/004-catalog-cache.md +19 -0
  211. package/packs/solution-architect/skills/context-graph/evals/files/9/src/catalog/cache.ts +13 -0
  212. package/packs/solution-architect/skills/context-graph/evals/files/9/src/catalog/repository.ts +13 -0
  213. package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/email.ts +3 -0
  214. package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/export.ts +3 -0
  215. package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/queue.ts +10 -0
  216. package/packs/solution-architect/skills/context-graph/evals/files/9/src/jobs/worker.ts +8 -0
  217. package/packs/solution-architect/skills/context-graph/evals/files/9/src/lib/db.ts +10 -0
  218. package/packs/solution-architect/skills/context-graph/evals/files/9/src/lib/redis.ts +8 -0
  219. package/packs/solution-architect/skills/context-graph/evals/files/9/src/middleware/rateLimit.ts +26 -0
  220. package/packs/solution-architect/skills/context-graph/evals/files/9/src/server.ts +17 -0
  221. package/packs/solution-architect/skills/context-graph/evals/files/9/src/session/store.ts +10 -0
  222. package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/docs/adr/0004-kafka-for-order-events.md +19 -0
  223. package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/docs/adr/0006-email-receipts.md +7 -0
  224. package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/docs/adr/0009-sqs-for-order-events.md +19 -0
  225. package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/services/notifications/receipts.py +0 -0
  226. package/packs/solution-architect/skills/context-graph/examples/inputs/order-events/services/orders/publisher.py +0 -0
  227. package/packs/solution-architect/skills/context-graph/examples/order-events.md +63 -0
  228. package/packs/solution-architect/skills/context-graph/manifest.json +33 -0
  229. package/packs/solution-architect/skills/context-graph/reference.md +104 -0
  230. 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())