@geraldmaron/construct 1.1.1 → 1.2.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.
- package/README.md +29 -19
- package/bin/construct +311 -81
- package/bin/construct-postinstall.mjs +30 -2
- package/examples/distribution/README.md +42 -0
- package/examples/distribution/manifest.json +53 -0
- package/examples/distribution/sources/adr.md +84 -0
- package/examples/distribution/sources/deck-one-pager.md +65 -0
- package/examples/distribution/sources/prd-platform.md +161 -0
- package/examples/distribution/sources/research-brief.md +80 -0
- package/examples/distribution/sources/rfc-platform.md +82 -0
- package/examples/distribution/sources/runbook.md +103 -0
- package/examples/distribution/sources/strategy.md +88 -0
- package/lib/adapters-sync.mjs +67 -0
- package/lib/artifact-gate-notice.mjs +38 -0
- package/lib/artifact-manifest.mjs +112 -0
- package/lib/artifact-release-gate.mjs +171 -0
- package/lib/artifact-reviewers.mjs +68 -0
- package/lib/artifact-type-from-path.mjs +79 -0
- package/lib/audit-skills.mjs +4 -0
- package/lib/audit-specialists.mjs +285 -0
- package/lib/audit-trail.mjs +1 -1
- package/lib/auto-docs.mjs +87 -18
- package/lib/beads/drift.mjs +27 -6
- package/lib/beads-client.mjs +49 -121
- package/lib/beads-optimistic.mjs +5 -12
- package/lib/brand-fonts.mjs +93 -0
- package/lib/brand-prose.mjs +214 -0
- package/lib/brand-tokens.mjs +92 -0
- package/lib/bridges/copilot-proxy.mjs +13 -26
- package/lib/capability-ledger.mjs +156 -0
- package/lib/certification/artifact-fixtures.mjs +132 -0
- package/lib/certification/artifact-gates.mjs +63 -0
- package/lib/certification/artifact-provenance.mjs +97 -0
- package/lib/certification/canonical-scenarios.mjs +78 -0
- package/lib/certification/cli.mjs +191 -0
- package/lib/certification/dashboard-api.mjs +71 -0
- package/lib/certification/demo-parity.mjs +116 -0
- package/lib/certification/document-io-fixtures.mjs +246 -0
- package/lib/certification/document-workflow.mjs +97 -0
- package/lib/certification/eval-bridge.mjs +77 -0
- package/lib/certification/model-routing.mjs +149 -0
- package/lib/certification/prompt-budget.mjs +119 -0
- package/lib/certification/rc-gate.mjs +206 -0
- package/lib/certification/real-llm-scenarios.mjs +303 -0
- package/lib/certification/role-cards.mjs +113 -0
- package/lib/certification/role-overlays.mjs +117 -0
- package/lib/certification/run.mjs +122 -0
- package/lib/certification/runner.mjs +323 -0
- package/lib/certification/scenarios.mjs +51 -0
- package/lib/certification/skill-inventory.mjs +289 -0
- package/lib/certification/skill-scenarios.mjs +147 -0
- package/lib/certification/specialist-contracts.mjs +85 -0
- package/lib/certification/specialist-scenarios.mjs +175 -0
- package/lib/certification/stale-impact.mjs +146 -0
- package/lib/certification/status.mjs +252 -0
- package/lib/certification/store.mjs +77 -0
- package/lib/chat/cli.mjs +333 -0
- package/lib/chat/command-suggest.mjs +161 -0
- package/lib/chat/commands.mjs +215 -0
- package/lib/chat/config.mjs +142 -0
- package/lib/chat/context-compactor.mjs +250 -0
- package/lib/chat/context-continuation.mjs +253 -0
- package/lib/chat/continuation-source.mjs +58 -0
- package/lib/chat/demo-guide.mjs +61 -0
- package/lib/chat/design-tokens.mjs +91 -0
- package/lib/chat/desktop-binary.mjs +79 -0
- package/lib/chat/desktop-build.mjs +130 -0
- package/lib/chat/desktop-launcher.mjs +133 -0
- package/lib/chat/evidence.mjs +145 -0
- package/lib/chat/export.mjs +74 -0
- package/lib/chat/harness/driver.mjs +91 -0
- package/lib/chat/list-picker.mjs +112 -0
- package/lib/chat/model-picker.mjs +356 -0
- package/lib/chat/openrouter-fallback.mjs +151 -0
- package/lib/chat/permission-prompt.mjs +33 -0
- package/lib/chat/picker-catalog.mjs +45 -0
- package/lib/chat/policy-telemetry.mjs +34 -0
- package/lib/chat/present.mjs +245 -0
- package/lib/chat/session-context.mjs +39 -0
- package/lib/chat/session-persist.mjs +73 -0
- package/lib/chat/session-restore.mjs +71 -0
- package/lib/chat/session-settings.mjs +53 -0
- package/lib/chat/system-prompt.mjs +52 -0
- package/lib/chat/transparency.mjs +93 -0
- package/lib/chat/tui/color-scheme.mjs +42 -0
- package/lib/chat/tui/markdown.mjs +123 -0
- package/lib/chat/tui/presentation.mjs +100 -0
- package/lib/chat/tui/render.mjs +500 -0
- package/lib/chat/tui/turn-block.mjs +284 -0
- package/lib/chat/tui/turn-present.mjs +18 -0
- package/lib/chat/tui/turn-state.mjs +88 -0
- package/lib/chat/tui/usage.mjs +122 -0
- package/lib/chat/web-commands.mjs +146 -0
- package/lib/chat/web-launcher.mjs +63 -0
- package/lib/chat/web-picker-keys.mjs +46 -0
- package/lib/chat/web-session.mjs +159 -0
- package/lib/cli-commands.mjs +129 -15
- package/lib/comment-lint.mjs +6 -4
- package/lib/config/schema.mjs +36 -5
- package/lib/contract-schemas/decision.json +50 -0
- package/lib/contract-schemas/implementation.json +51 -0
- package/lib/contract-schemas/review-report.json +32 -0
- package/lib/contract-schemas/test-report.json +43 -0
- package/lib/contracts/construct-handoff.mjs +60 -0
- package/lib/contracts/validate.mjs +32 -3
- package/lib/contracts/violation-log.mjs +58 -5
- package/lib/dashboard-demo.mjs +71 -0
- package/lib/dashboard-static.mjs +19 -5
- package/lib/decisions/registry.mjs +5 -3
- package/lib/deck-export-pptx.mjs +1152 -0
- package/lib/demo-recording.mjs +142 -0
- package/lib/demo-script.mjs +114 -0
- package/lib/demo-surface.mjs +249 -0
- package/lib/demo.mjs +598 -140
- package/lib/diagram-export.mjs +192 -0
- package/lib/diagram.mjs +13 -11
- package/lib/docs-verify.mjs +25 -7
- package/lib/doctor/index.mjs +3 -1
- package/lib/doctor/project-adapters.mjs +44 -0
- package/lib/doctor/watchers/cx-budget.mjs +98 -0
- package/lib/doctor/watchers/graph-staleness.mjs +52 -0
- package/lib/doctor/watchers/process-pressure.mjs +4 -3
- package/lib/document-export.mjs +360 -36
- package/lib/document-extract.mjs +44 -4
- package/lib/document-ingest.mjs +29 -20
- package/lib/embed/cli.mjs +2 -2
- package/lib/embed/daemon.mjs +18 -46
- package/lib/embed/worker.mjs +2 -2
- package/lib/embedded-contract/model-resolve.mjs +10 -9
- package/lib/env-config.mjs +26 -0
- package/lib/evals/dataset.mjs +137 -0
- package/lib/evals/gates.mjs +175 -0
- package/lib/graph/build-co-change.mjs +54 -0
- package/lib/graph/build-from-registry.mjs +136 -0
- package/lib/graph/build-import-graph.mjs +153 -0
- package/lib/graph/cli.mjs +110 -0
- package/lib/graph/impact-cli.mjs +89 -0
- package/lib/graph/impact.mjs +108 -0
- package/lib/graph/staleness.mjs +44 -0
- package/lib/graph/store.mjs +172 -0
- package/lib/health-check.mjs +90 -76
- package/lib/hooks/artifact-release-gate.mjs +43 -0
- package/lib/hooks/brand-prose-lint.mjs +38 -0
- package/lib/hooks/graph-impact-advisory.mjs +62 -0
- package/lib/hooks/session-optimize.mjs +84 -207
- package/lib/hooks/session-start.mjs +4 -5
- package/lib/host-disposition.mjs +7 -0
- package/lib/improvement/cli.mjs +189 -0
- package/lib/improvement/controller.mjs +137 -0
- package/lib/improvement/proposal.mjs +120 -0
- package/lib/improvement/specialist-loop.mjs +192 -0
- package/lib/improvement/store.mjs +89 -0
- package/lib/improvement/surface.mjs +219 -0
- package/lib/ingest-tooling.mjs +97 -0
- package/lib/init/doc-lanes.mjs +165 -0
- package/lib/init-docs.mjs +31 -178
- package/lib/init-unified.mjs +27 -97
- package/lib/init-update-guide.mjs +102 -0
- package/lib/init-update.mjs +32 -1
- package/lib/init.mjs +8 -0
- package/lib/install/desktop-binary-download.mjs +85 -0
- package/lib/install/legacy-global-cleanup.mjs +189 -0
- package/lib/intake/daemon.mjs +2 -0
- package/lib/intake/git-queue.mjs +9 -8
- package/lib/intake/queue.mjs +2 -1
- package/lib/intake/session-prelude.mjs +90 -1
- package/lib/libreoffice-export.mjs +97 -0
- package/lib/logging/rotate.mjs +9 -0
- package/lib/maintenance/docker-reclaim.mjs +206 -0
- package/lib/mcp/external-schema-cost.mjs +74 -0
- package/lib/mcp/server.mjs +84 -9
- package/lib/mcp/stdio-mcp-probe.mjs +188 -0
- package/lib/mcp/tool-budget.mjs +55 -4
- package/lib/mcp/tools/document.mjs +18 -4
- package/lib/mcp/tools/skills.mjs +32 -3
- package/lib/mcp/tools/workflow.mjs +13 -1
- package/lib/model-free-selector.mjs +18 -0
- package/lib/model-registry.mjs +13 -229
- package/lib/model-router.mjs +373 -92
- package/lib/models/behavior-matrix.mjs +289 -0
- package/lib/models/catalog.mjs +209 -0
- package/lib/models/execution-capability-profile.mjs +196 -0
- package/lib/models/execution-policy.mjs +307 -0
- package/lib/models/provider-poll.mjs +383 -0
- package/lib/npm-spawn-env.mjs +17 -0
- package/lib/ollama/installed-models.mjs +129 -0
- package/lib/oracle/actions.mjs +187 -0
- package/lib/oracle/artifact-gate.mjs +99 -0
- package/lib/oracle/cli.mjs +204 -0
- package/lib/oracle/daemon-entry.mjs +14 -0
- package/lib/oracle/dispatch.mjs +81 -0
- package/lib/oracle/execute.mjs +143 -0
- package/lib/oracle/gaps.mjs +76 -0
- package/lib/oracle/index.mjs +84 -0
- package/lib/oracle/issues.mjs +164 -0
- package/lib/oracle/org-graph.mjs +170 -0
- package/lib/oracle/policy.mjs +80 -0
- package/lib/oracle/read-model.mjs +398 -0
- package/lib/oracle/reconcile.mjs +191 -0
- package/lib/oracle/routing.mjs +89 -0
- package/lib/oracle/synthesize.mjs +384 -0
- package/lib/oracle/verdicts.mjs +51 -0
- package/lib/orchestration/worker.mjs +88 -27
- package/lib/orchestration-policy.mjs +50 -0
- package/lib/parity.mjs +96 -2
- package/lib/playwright-demo.mjs +252 -0
- package/lib/project-init-shared.mjs +4 -1
- package/lib/prompt-composer.js +7 -3
- package/lib/prompt-validation-contract.mjs +24 -0
- package/lib/provider-capabilities.js +57 -11
- package/lib/providers/contract/adapters/confluence/index.mjs +181 -0
- package/lib/providers/contract/adapters/git/index.mjs +115 -0
- package/lib/providers/contract/adapters/github/index.mjs +166 -0
- package/lib/providers/contract/adapters/jira/index.mjs +187 -0
- package/lib/providers/contract/adapters/slack/index.mjs +175 -0
- package/lib/providers/contract/contract-tests.mjs +57 -0
- package/lib/providers/contract/errors.mjs +48 -0
- package/lib/providers/contract/interface.mjs +50 -0
- package/lib/providers/contract/registry.mjs +102 -0
- package/lib/providers/copilot-auth.mjs +297 -0
- package/lib/providers/credential-bootstrap.mjs +178 -0
- package/lib/providers/credential-catalog.mjs +46 -0
- package/lib/providers/credential-sources.mjs +63 -0
- package/lib/providers/creds.mjs +5 -2
- package/lib/providers/op-run.mjs +59 -0
- package/lib/providers/secret-resolver.mjs +159 -0
- package/lib/publish-template.mjs +163 -0
- package/lib/publish-tooling.mjs +119 -0
- package/lib/publish.mjs +305 -0
- package/lib/registry/cli.mjs +82 -0
- package/lib/registry/consolidation.mjs +147 -0
- package/lib/registry/generate-docs.mjs +75 -0
- package/lib/registry/skill-verification.mjs +57 -0
- package/lib/registry/surface-map.mjs +76 -0
- package/lib/registry/validate.mjs +135 -0
- package/lib/resources/budget.mjs +82 -0
- package/lib/resources/process-budget.mjs +45 -0
- package/lib/rules-delivery.mjs +9 -2
- package/lib/rules-read.mjs +26 -0
- package/lib/runtime-env.mjs +23 -0
- package/lib/runtime-pressure.mjs +51 -7
- package/lib/schema-infer.mjs +13 -2
- package/lib/server/chat-loop.mjs +622 -0
- package/lib/server/demo-preview.mjs +63 -0
- package/lib/server/index.mjs +228 -7
- package/lib/service-manager.mjs +60 -8
- package/lib/setup.mjs +70 -7
- package/lib/specialists/prompt-schema.mjs +19 -10
- package/lib/specialists/roster.mjs +43 -0
- package/lib/specialists/scaffold.mjs +56 -0
- package/lib/storage/backend.mjs +6 -6
- package/lib/storage/hybrid-query.mjs +7 -4
- package/lib/storage/state-source.mjs +5 -5
- package/lib/storage/sync.mjs +2 -3
- package/lib/telemetry/rule-calls.mjs +52 -0
- package/lib/template-registry.mjs +2 -2
- package/lib/templates/visual-requirements.mjs +27 -51
- package/lib/test-corpus-inventory.mjs +313 -0
- package/lib/uninstall/uninstall.mjs +19 -7
- package/lib/update.mjs +12 -0
- package/lib/upgrade.mjs +14 -0
- package/lib/wireframe.mjs +20 -14
- package/lib/worker/run.mjs +17 -4
- package/lib/worker/trace.mjs +11 -3
- package/package.json +28 -14
- package/personas/construct.md +2 -2
- package/platforms/claude/settings.template.json +36 -0
- package/rules/common/patterns.md +1 -1
- package/rules/common/release-gates.md +4 -3
- package/scripts/sync-specialists.mjs +41 -28
- package/skills/devops/data-engineering.md +1 -1
- package/skills/docs/adr-workflow.md +1 -0
- package/skills/docs/backlog-proposal-workflow.md +1 -0
- package/skills/docs/codebase-research-workflow.md +40 -0
- package/skills/docs/customer-profile-workflow.md +1 -0
- package/skills/docs/document-ingest-workflow.md +1 -0
- package/skills/docs/evidence-ingest-workflow.md +1 -0
- package/skills/docs/init-docs.md +2 -2
- package/skills/docs/init-project.md +7 -2
- package/skills/docs/prd-workflow.md +24 -1
- package/skills/docs/prfaq-workflow.md +1 -0
- package/skills/docs/product-intelligence-workflow.md +1 -0
- package/skills/docs/product-signal-workflow.md +1 -0
- package/skills/docs/research-workflow.md +54 -37
- package/skills/docs/runbook-workflow.md +1 -0
- package/skills/docs/strategy-workflow.md +1 -0
- package/skills/docs/user-research-workflow.md +40 -0
- package/skills/operating/orchestration-reference.md +1 -1
- package/skills/roles/architect.md +5 -0
- package/skills/roles/operator.docs.md +4 -0
- package/skills/routing.md +4 -2
- package/specialists/artifact-manifest.json +480 -0
- package/specialists/artifact-manifest.schema.json +53 -0
- package/specialists/audit-enrichments.json +454 -0
- package/specialists/contracts.json +37 -25
- package/specialists/prompts/_shared/validation-contract.md +26 -0
- package/specialists/prompts/cx-accessibility.md +21 -2
- package/specialists/prompts/cx-ai-engineer.md +24 -1
- package/specialists/prompts/cx-architect.md +4 -1
- package/specialists/prompts/cx-business-strategist.md +23 -2
- package/specialists/prompts/cx-data-analyst.md +22 -1
- package/specialists/prompts/cx-data-engineer.md +23 -2
- package/specialists/prompts/cx-debugger.md +21 -2
- package/specialists/prompts/cx-designer.md +26 -3
- package/specialists/prompts/cx-devil-advocate.md +19 -2
- package/specialists/prompts/cx-docs-keeper.md +28 -1
- package/specialists/prompts/cx-engineer.md +22 -1
- package/specialists/prompts/cx-evaluator.md +18 -1
- package/specialists/prompts/cx-explorer.md +21 -2
- package/specialists/prompts/cx-legal-compliance.md +21 -2
- package/specialists/prompts/cx-operations.md +21 -2
- package/specialists/prompts/cx-oracle.md +94 -0
- package/specialists/prompts/cx-orchestrator.md +20 -1
- package/specialists/prompts/cx-platform-engineer.md +25 -2
- package/specialists/prompts/cx-product-manager.md +32 -1
- package/specialists/prompts/cx-qa.md +24 -2
- package/specialists/prompts/cx-rd-lead.md +23 -2
- package/specialists/prompts/cx-release-manager.md +23 -2
- package/specialists/prompts/cx-researcher.md +22 -2
- package/specialists/prompts/cx-reviewer.md +18 -1
- package/specialists/prompts/cx-security.md +22 -2
- package/specialists/prompts/cx-sre.md +30 -3
- package/specialists/prompts/cx-test-automation.md +7 -1
- package/specialists/prompts/cx-trace-reviewer.md +22 -3
- package/specialists/prompts/cx-ux-researcher.md +21 -2
- package/specialists/registry.json +52 -173
- package/specialists/tone-profiles.json +42 -0
- package/templates/demos/playwright/demo-recording.config.mjs +47 -0
- package/templates/demos/recordings/agentic-platforms-prd.json +19 -0
- package/templates/demos/scripts/agentic-platforms-prd.json +44 -0
- package/templates/demos/specs/_helpers/scroll-artifact.ts +89 -0
- package/templates/demos/tapes/agentic-platforms-prd.tape +49 -0
- package/templates/demos/tapes/resource-guard-rails.tape +49 -0
- package/templates/demos/vhs/construct-cockpit.json +24 -0
- package/templates/distribution/construct-brand.typ +446 -0
- package/templates/distribution/construct-decision.typ +38 -0
- package/templates/distribution/construct-deck.html +95 -0
- package/templates/distribution/construct-pdf.typ +38 -0
- package/templates/distribution/construct-prd.typ +38 -0
- package/templates/distribution/construct-research.typ +38 -0
- package/templates/distribution/construct-web.html +92 -0
- package/templates/distribution/fonts/Geist-Bold.ttf +0 -0
- package/templates/distribution/fonts/Geist-Medium.ttf +0 -0
- package/templates/distribution/fonts/Geist-Regular.ttf +0 -0
- package/templates/distribution/fonts/Geist-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-Medium.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-Regular.ttf +0 -0
- package/templates/distribution/fonts/GeistMono-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/IBMPlexMono-Regular.otf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-Medium.ttf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/templates/distribution/fonts/JetBrainsMono-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Bold.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Medium.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-Regular.ttf +0 -0
- package/templates/distribution/fonts/PlusJakartaSans-SemiBold.ttf +0 -0
- package/templates/distribution/fonts/README.md +36 -0
- package/templates/distribution/fonts/SpaceGrotesk-Variable.ttf +0 -0
- package/templates/distribution/fonts/handwritten/Caveat.ttf +0 -0
- package/templates/distribution/fonts/legacy/IBMPlexMono-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-Medium.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/Inter-SemiBold.otf +0 -0
- package/templates/distribution/fonts/legacy/InterDisplay-SemiBold.otf +0 -0
- package/templates/distribution/fonts/legacy/SourceSerif4-Regular.otf +0 -0
- package/templates/distribution/fonts/legacy/SourceSerif4-Semibold.otf +0 -0
- package/templates/distribution/icons/activity.svg +15 -0
- package/templates/distribution/icons/alert-triangle.svg +17 -0
- package/templates/distribution/icons/book-open.svg +16 -0
- package/templates/distribution/icons/bot.svg +20 -0
- package/templates/distribution/icons/brain.svg +22 -0
- package/templates/distribution/icons/circle-check.svg +16 -0
- package/templates/distribution/icons/clipboard-check.svg +17 -0
- package/templates/distribution/icons/cpu.svg +28 -0
- package/templates/distribution/icons/database.svg +17 -0
- package/templates/distribution/icons/eye.svg +16 -0
- package/templates/distribution/icons/file-text.svg +19 -0
- package/templates/distribution/icons/gauge.svg +16 -0
- package/templates/distribution/icons/git-branch.svg +17 -0
- package/templates/distribution/icons/key.svg +17 -0
- package/templates/distribution/icons/layers.svg +17 -0
- package/templates/distribution/icons/list-checks.svg +19 -0
- package/templates/distribution/icons/lock.svg +16 -0
- package/templates/distribution/icons/message-square.svg +15 -0
- package/templates/distribution/icons/network.svg +19 -0
- package/templates/distribution/icons/route.svg +17 -0
- package/templates/distribution/icons/scale.svg +19 -0
- package/templates/distribution/icons/search.svg +16 -0
- package/templates/distribution/icons/send.svg +16 -0
- package/templates/distribution/icons/server.svg +18 -0
- package/templates/distribution/icons/shield-check.svg +16 -0
- package/templates/distribution/icons/users.svg +18 -0
- package/templates/distribution/icons/webhook.svg +17 -0
- package/templates/distribution/icons/workflow.svg +17 -0
- package/templates/distribution/icons/wrench.svg +15 -0
- package/templates/distribution/run.mjs +18 -1
- package/templates/docs/adr.md +7 -0
- package/templates/docs/construct_guide.md +8 -8
- package/templates/docs/customer-profile.md +4 -0
- package/templates/docs/one-pager.md +4 -0
- package/templates/docs/postmortem.md +31 -0
- package/templates/docs/prd-platform.md +19 -0
- package/templates/docs/prd.md +15 -0
- package/templates/docs/prfaq.md +7 -0
- package/templates/docs/qa-strategy.md +103 -0
- package/templates/docs/research-brief.md +8 -0
- package/templates/docs/rfc-platform.md +12 -0
- package/templates/docs/test-plan.md +7 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/graph/impact-cli.mjs — `construct impact` command surface.
|
|
3
|
+
*
|
|
4
|
+
* Maps changed files to the tests that should run and the capabilities and
|
|
5
|
+
* workflows they touch, using the living dependency graph. Advisory by default;
|
|
6
|
+
* `--run` executes the selected tests via `node --test`. Files come from
|
|
7
|
+
* positional args, or — when none are given — from `git diff --name-only HEAD`.
|
|
8
|
+
*
|
|
9
|
+
* construct impact lib/oracle/synthesize.mjs
|
|
10
|
+
* construct impact --run
|
|
11
|
+
* git diff --name-only origin/main | construct impact --stdin --json
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
import { computeImpact } from './impact.mjs';
|
|
19
|
+
|
|
20
|
+
function readStdin() {
|
|
21
|
+
try { return readFileSync(0, 'utf8'); } catch { return ''; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function gitChangedFiles(projectDir) {
|
|
25
|
+
const res = spawnSync('git', ['diff', '--name-only', 'HEAD'], { cwd: projectDir, encoding: 'utf8' });
|
|
26
|
+
if (res.status !== 0) return [];
|
|
27
|
+
return res.stdout.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string[]} args
|
|
32
|
+
* @param {{ rootDir: string, projectDir: string }} ctx
|
|
33
|
+
* @returns {Promise<number>} exit code
|
|
34
|
+
*/
|
|
35
|
+
export async function runImpactCli(args, { projectDir }) {
|
|
36
|
+
const json = args.includes('--json');
|
|
37
|
+
const run = args.includes('--run');
|
|
38
|
+
const useStdin = args.includes('--stdin');
|
|
39
|
+
let files = args.filter((a) => !a.startsWith('--'));
|
|
40
|
+
|
|
41
|
+
if (useStdin) files = readStdin().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
42
|
+
if (files.length === 0) files = gitChangedFiles(projectDir);
|
|
43
|
+
|
|
44
|
+
if (files.length === 0) {
|
|
45
|
+
process.stderr.write('No changed files given (args, --stdin, or git diff). Nothing to analyze.\n');
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const result = computeImpact({ rootDir: projectDir, changedFiles: files });
|
|
50
|
+
|
|
51
|
+
if (!result.graphPresent) {
|
|
52
|
+
process.stderr.write('No dependency graph found. Run `construct matrix build` first.\n');
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (json && !run) {
|
|
57
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!json) {
|
|
62
|
+
process.stdout.write(`Changed: ${result.changed.length} file(s)\n`);
|
|
63
|
+
process.stdout.write(`Impacted capabilities (${result.impactedCapabilities.length}): ${result.impactedCapabilities.join(', ') || '(none)'}\n`);
|
|
64
|
+
process.stdout.write(`Stale capabilities (${result.staleCapabilities.length}): ${result.staleCapabilities.join(', ') || '(none)'}\n`);
|
|
65
|
+
process.stdout.write(`Impacted workflows (${result.impactedWorkflows.length}): ${result.impactedWorkflows.join(', ') || '(none)'}\n`);
|
|
66
|
+
process.stdout.write(`Affected tests (${result.affectedTests.length}):\n`);
|
|
67
|
+
for (const t of result.affectedTests) process.stdout.write(` ${t}\n`);
|
|
68
|
+
if (result.coverageGaps.length) {
|
|
69
|
+
process.stdout.write(`\n⚠ Coverage gap — changed files realizing no capability (${result.coverageGaps.length}):\n`);
|
|
70
|
+
for (const f of result.coverageGaps) process.stdout.write(` ${f}\n`);
|
|
71
|
+
}
|
|
72
|
+
if (result.unknown.length) {
|
|
73
|
+
process.stdout.write(`\nNot in graph (${result.unknown.length}): ${result.unknown.join(', ')}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (run) {
|
|
78
|
+
const runnable = result.affectedTests.filter((t) => existsSync(path.join(projectDir, t)));
|
|
79
|
+
if (runnable.length === 0) {
|
|
80
|
+
process.stdout.write('\nNo runnable affected tests on disk.\n');
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
process.stdout.write(`\n▶ Running ${runnable.length} affected test(s)…\n`);
|
|
84
|
+
const res = spawnSync(process.execPath, ['--test', ...runnable], { cwd: projectDir, stdio: 'inherit' });
|
|
85
|
+
return res.status ?? 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/graph/impact.mjs — forward change-impact over the dependency graph.
|
|
3
|
+
*
|
|
4
|
+
* Given a set of changed files, computes the tests that should run, the
|
|
5
|
+
* capabilities and workflows impacted, and any changed file that realizes no
|
|
6
|
+
* capability (a coverage gap). Test selection is Test Impact Analysis over the
|
|
7
|
+
* static import graph: a test is affected if it transitively imports a changed
|
|
8
|
+
* file, or if it validates a capability that a changed file realizes. The
|
|
9
|
+
* union is intentionally conservative — over-selection is safe, under-selection
|
|
10
|
+
* silently ships untested change.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { loadGraph, dependentsOf, dependenciesOf } from './store.mjs';
|
|
14
|
+
import { rollupStaleImpact } from '../certification/stale-impact.mjs';
|
|
15
|
+
|
|
16
|
+
function isTestRel(rel) {
|
|
17
|
+
return rel.endsWith('.test.mjs') || rel.endsWith('.test.js');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toNodeId(rel) {
|
|
21
|
+
return isTestRel(rel) ? `test:${rel}` : `file:${rel}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function reverseImportClosure(graph, startId) {
|
|
25
|
+
const reached = new Set();
|
|
26
|
+
const stack = [startId];
|
|
27
|
+
while (stack.length) {
|
|
28
|
+
const id = stack.pop();
|
|
29
|
+
for (const importer of dependentsOf(graph, id, 'imports')) {
|
|
30
|
+
if (reached.has(importer)) continue;
|
|
31
|
+
reached.add(importer);
|
|
32
|
+
stack.push(importer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return reached;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {string} opts.rootDir — project root holding .cx/graph/.
|
|
41
|
+
* @param {string[]} opts.changedFiles — repo-relative paths.
|
|
42
|
+
* @returns {{ changed: string[], unknown: string[], affectedTests: string[], impactedCapabilities: string[], impactedWorkflows: string[], staleCapabilities: string[], coverageGaps: string[], graphPresent: boolean }}
|
|
43
|
+
*/
|
|
44
|
+
export function computeImpact({ rootDir, changedFiles }) {
|
|
45
|
+
const graph = loadGraph(rootDir);
|
|
46
|
+
const norm = [...new Set((changedFiles || []).map((f) => f.split('\\').join('/').replace(/^\.\//, '')))].filter(Boolean);
|
|
47
|
+
if (!graph.exists) {
|
|
48
|
+
return {
|
|
49
|
+
changed: norm,
|
|
50
|
+
unknown: norm,
|
|
51
|
+
affectedTests: [],
|
|
52
|
+
impactedCapabilities: [],
|
|
53
|
+
impactedWorkflows: [],
|
|
54
|
+
staleCapabilities: rollupStaleImpact({ rootDir, changedFiles: norm }),
|
|
55
|
+
coverageGaps: [],
|
|
56
|
+
graphPresent: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tests = new Set();
|
|
61
|
+
const capabilities = new Set();
|
|
62
|
+
const unknown = [];
|
|
63
|
+
const coverageGaps = [];
|
|
64
|
+
|
|
65
|
+
for (const rel of norm) {
|
|
66
|
+
const id = toNodeId(rel);
|
|
67
|
+
if (!graph.nodes.has(id)) { unknown.push(rel); continue; }
|
|
68
|
+
|
|
69
|
+
if (id.startsWith('test:')) tests.add(id);
|
|
70
|
+
|
|
71
|
+
for (const importer of reverseImportClosure(graph, id)) {
|
|
72
|
+
if (importer.startsWith('test:')) tests.add(importer);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const caps = dependenciesOf(graph, id, 'realizes');
|
|
76
|
+
for (const c of caps) capabilities.add(c);
|
|
77
|
+
if (id.startsWith('file:') && caps.length === 0) coverageGaps.push(rel);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Capabilities reached transitively also pull in their declared verification
|
|
81
|
+
// tests; their embedded workflows complete the requirement trace.
|
|
82
|
+
|
|
83
|
+
for (const cap of [...capabilities]) {
|
|
84
|
+
for (const t of dependentsOf(graph, cap, 'validates')) tests.add(t);
|
|
85
|
+
}
|
|
86
|
+
for (const t of tests) {
|
|
87
|
+
for (const cap of dependenciesOf(graph, t, 'validates')) capabilities.add(cap);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const workflows = new Set();
|
|
91
|
+
for (const cap of capabilities) {
|
|
92
|
+
for (const wf of dependenciesOf(graph, cap, 'embeds')) workflows.add(wf);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const strip = (set, prefix) => [...set].map((id) => id.slice(prefix.length)).sort();
|
|
96
|
+
const impactedCapabilityIds = strip(capabilities, 'capability:');
|
|
97
|
+
const staleCapabilities = rollupStaleImpact({ rootDir, changedFiles: norm });
|
|
98
|
+
return {
|
|
99
|
+
changed: norm,
|
|
100
|
+
unknown,
|
|
101
|
+
affectedTests: [...tests].map((id) => id.slice('test:'.length)).sort(),
|
|
102
|
+
impactedCapabilities: impactedCapabilityIds,
|
|
103
|
+
impactedWorkflows: strip(workflows, 'workflow:'),
|
|
104
|
+
staleCapabilities,
|
|
105
|
+
coverageGaps: coverageGaps.sort(),
|
|
106
|
+
graphPresent: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/graph/staleness.mjs — dependency graph seed-hash staleness checks.
|
|
3
|
+
*
|
|
4
|
+
* Compares the hash of registry/contracts/workflow seed files against the
|
|
5
|
+
* hash stored at last graph build (.cx/graph/meta.json). Shared by Oracle,
|
|
6
|
+
* the doctor graph-staleness watcher, and PostToolUse advisories.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { hashFiles } from './build-from-registry.mjs';
|
|
10
|
+
import { loadGraph } from './store.mjs';
|
|
11
|
+
|
|
12
|
+
export const GRAPH_SEED_FILES = [
|
|
13
|
+
'registry/capabilities.json',
|
|
14
|
+
'specialists/contracts.json',
|
|
15
|
+
'lib/embedded-contract/workflow-defs.mjs',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} rootDir — project root holding .cx/graph/.
|
|
20
|
+
* @returns {{ present: boolean, stale: boolean, staleReason: string|null, currentHash?: string, storedHash?: string|null }}
|
|
21
|
+
*/
|
|
22
|
+
export function checkGraphStaleness(rootDir) {
|
|
23
|
+
const graph = loadGraph(rootDir);
|
|
24
|
+
if (!graph.exists) {
|
|
25
|
+
return { present: false, stale: false, staleReason: null };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const current = hashFiles(rootDir, GRAPH_SEED_FILES);
|
|
30
|
+
const stored = graph.meta?.sourceHash ?? null;
|
|
31
|
+
if (stored && current !== stored) {
|
|
32
|
+
return {
|
|
33
|
+
present: true,
|
|
34
|
+
stale: true,
|
|
35
|
+
staleReason: 'registry/contracts/workflow seeds changed since last build',
|
|
36
|
+
currentHash: current,
|
|
37
|
+
storedHash: stored,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { present: true, stale: false, staleReason: null, currentHash: current, storedHash: stored };
|
|
41
|
+
} catch {
|
|
42
|
+
return { present: true, stale: false, staleReason: null };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/graph/store.mjs — typed, directed dependency-graph store.
|
|
3
|
+
*
|
|
4
|
+
* The substrate for Construct's living dependency matrix. Nodes are typed
|
|
5
|
+
* entities (file, module, workflow, capability, test, contract, surface,
|
|
6
|
+
* skill, rule) addressed by canonical ids of the form `type:key`. Edges are
|
|
7
|
+
* directed and carry a relation kind (imports, realizes, validates, covers,
|
|
8
|
+
* exposes, governed_by, uses, embeds, co_changes) and a provenance source
|
|
9
|
+
* (registry, import-graph, co-change, override).
|
|
10
|
+
*
|
|
11
|
+
* Persisted under `.cx/graph/` as deterministic JSONL so a graph round-trips
|
|
12
|
+
* losslessly and diffs cleanly: nodes.jsonl (sorted by id), edges.jsonl
|
|
13
|
+
* (sorted by from,rel,to), meta.json (generatedAt, sourceHash, counts).
|
|
14
|
+
* Dependency-free and pure-JS per ADR-0001.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
export const NODE_TYPES = new Set([
|
|
21
|
+
'file', 'module', 'workflow', 'capability', 'test', 'contract', 'surface', 'skill', 'rule',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const EDGE_RELS = new Set([
|
|
25
|
+
'imports', 'realizes', 'validates', 'covers', 'exposes', 'governed_by', 'uses', 'embeds', 'co_changes', 'contains',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
export const EDGE_SOURCES = new Set(['registry', 'import-graph', 'co-change', 'override']);
|
|
29
|
+
|
|
30
|
+
const STORE_SUBDIR = path.join('.cx', 'graph');
|
|
31
|
+
|
|
32
|
+
export function graphDir(rootDir) {
|
|
33
|
+
return path.join(rootDir, STORE_SUBDIR);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function nodeId(type, key) {
|
|
37
|
+
return `${type}:${key}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function nodesPath(rootDir) { return path.join(graphDir(rootDir), 'nodes.jsonl'); }
|
|
41
|
+
function edgesPath(rootDir) { return path.join(graphDir(rootDir), 'edges.jsonl'); }
|
|
42
|
+
function metaPath(rootDir) { return path.join(graphDir(rootDir), 'meta.json'); }
|
|
43
|
+
|
|
44
|
+
function readJsonl(filePath) {
|
|
45
|
+
if (!existsSync(filePath)) return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const line of readFileSync(filePath, 'utf8').split('\n')) {
|
|
48
|
+
if (!line.trim()) continue;
|
|
49
|
+
try { out.push(JSON.parse(line)); } catch { /* skip malformed line */ }
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeAtomic(filePath, contents) {
|
|
55
|
+
const tmp = `${filePath}.tmp`;
|
|
56
|
+
writeFileSync(tmp, contents, 'utf8');
|
|
57
|
+
renameSync(tmp, filePath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Canonical de-dup: nodes keyed by id (last write wins on attrs), edges keyed
|
|
61
|
+
// by from|rel|to (weights summed so repeated co-change observations accumulate).
|
|
62
|
+
|
|
63
|
+
function normalizeNodes(nodes) {
|
|
64
|
+
const byId = new Map();
|
|
65
|
+
for (const n of nodes) {
|
|
66
|
+
if (!n?.id || !n?.type) continue;
|
|
67
|
+
const prev = byId.get(n.id);
|
|
68
|
+
byId.set(n.id, prev ? { ...prev, ...n, attrs: { ...(prev.attrs || {}), ...(n.attrs || {}) } } : { id: n.id, type: n.type, name: n.name ?? n.id, attrs: n.attrs || {} });
|
|
69
|
+
}
|
|
70
|
+
return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function edgeKey(e) { return `${e.from}|${e.rel}|${e.to}`; }
|
|
74
|
+
|
|
75
|
+
function normalizeEdges(edges) {
|
|
76
|
+
const byKey = new Map();
|
|
77
|
+
for (const e of edges) {
|
|
78
|
+
if (!e?.from || !e?.to || !e?.rel) continue;
|
|
79
|
+
const key = edgeKey(e);
|
|
80
|
+
const prev = byKey.get(key);
|
|
81
|
+
if (prev) {
|
|
82
|
+
prev.weight = (prev.weight || 1) + (e.weight || 1);
|
|
83
|
+
if (e.source && !prev.sources.includes(e.source)) prev.sources.push(e.source);
|
|
84
|
+
} else {
|
|
85
|
+
byKey.set(key, { from: e.from, to: e.to, rel: e.rel, weight: e.weight || 1, sources: e.source ? [e.source] : (e.sources || []) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...byKey.values()].sort((a, b) => (edgeKey(a) < edgeKey(b) ? -1 : 1));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function countBy(items, key) {
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const it of items) { const k = it[key]; out[k] = (out[k] || 0) + 1; }
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Persist a full graph (regenerate semantics). Nodes and edges are normalized
|
|
99
|
+
* and sorted; meta records counts and the caller-supplied source hash.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} rootDir
|
|
102
|
+
* @param {{ nodes: object[], edges: object[], generatedAt?: string, sourceHash?: string }} graph
|
|
103
|
+
* @returns {{ nodeCount: number, edgeCount: number, dir: string }}
|
|
104
|
+
*/
|
|
105
|
+
export function writeGraph(rootDir, { nodes = [], edges = [], generatedAt = null, sourceHash = null } = {}) {
|
|
106
|
+
const dir = graphDir(rootDir);
|
|
107
|
+
mkdirSync(dir, { recursive: true });
|
|
108
|
+
const normNodes = normalizeNodes(nodes);
|
|
109
|
+
const normEdges = normalizeEdges(edges);
|
|
110
|
+
writeAtomic(nodesPath(rootDir), normNodes.map((n) => JSON.stringify(n)).join('\n') + (normNodes.length ? '\n' : ''));
|
|
111
|
+
writeAtomic(edgesPath(rootDir), normEdges.map((e) => JSON.stringify(e)).join('\n') + (normEdges.length ? '\n' : ''));
|
|
112
|
+
const meta = {
|
|
113
|
+
generatedAt,
|
|
114
|
+
sourceHash,
|
|
115
|
+
nodeCount: normNodes.length,
|
|
116
|
+
edgeCount: normEdges.length,
|
|
117
|
+
nodesByType: countBy(normNodes, 'type'),
|
|
118
|
+
edgesByRel: countBy(normEdges, 'rel'),
|
|
119
|
+
};
|
|
120
|
+
writeAtomic(metaPath(rootDir), JSON.stringify(meta, null, 2) + '\n');
|
|
121
|
+
return { nodeCount: normNodes.length, edgeCount: normEdges.length, dir };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load the graph into an indexed, queryable view. Out/in adjacency maps make
|
|
126
|
+
* forward (dependencies) and reverse (dependents) traversal O(degree).
|
|
127
|
+
*
|
|
128
|
+
* @param {string} rootDir
|
|
129
|
+
* @returns {{ nodes: Map<string,object>, edges: object[], out: Map<string,object[]>, in: Map<string,object[]>, meta: object|null, exists: boolean }}
|
|
130
|
+
*/
|
|
131
|
+
export function loadGraph(rootDir) {
|
|
132
|
+
const nodeList = readJsonl(nodesPath(rootDir));
|
|
133
|
+
const edgeList = readJsonl(edgesPath(rootDir));
|
|
134
|
+
let meta = null;
|
|
135
|
+
try { meta = JSON.parse(readFileSync(metaPath(rootDir), 'utf8')); } catch { /* no meta */ }
|
|
136
|
+
|
|
137
|
+
const nodes = new Map();
|
|
138
|
+
for (const n of nodeList) nodes.set(n.id, n);
|
|
139
|
+
const out = new Map();
|
|
140
|
+
const inc = new Map();
|
|
141
|
+
for (const e of edgeList) {
|
|
142
|
+
if (!out.has(e.from)) out.set(e.from, []);
|
|
143
|
+
if (!inc.has(e.to)) inc.set(e.to, []);
|
|
144
|
+
out.get(e.from).push(e);
|
|
145
|
+
inc.get(e.to).push(e);
|
|
146
|
+
}
|
|
147
|
+
return { nodes, edges: edgeList, out, in: inc, meta, exists: existsSync(nodesPath(rootDir)) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function filterRel(edges, rel) {
|
|
151
|
+
if (!rel) return edges;
|
|
152
|
+
const rels = Array.isArray(rel) ? new Set(rel) : new Set([rel]);
|
|
153
|
+
return edges.filter((e) => rels.has(e.rel));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Nodes that `id` points to along `rel` (its dependencies).
|
|
158
|
+
*/
|
|
159
|
+
export function dependenciesOf(graph, id, rel = null) {
|
|
160
|
+
return filterRel(graph.out.get(id) || [], rel).map((e) => e.to);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Nodes that point to `id` along `rel` (its dependents).
|
|
165
|
+
*/
|
|
166
|
+
export function dependentsOf(graph, id, rel = null) {
|
|
167
|
+
return filterRel(graph.in.get(id) || [], rel).map((e) => e.from);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function nodesByType(graph, type) {
|
|
171
|
+
return [...graph.nodes.values()].filter((n) => n.type === type);
|
|
172
|
+
}
|
package/lib/health-check.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { extractOpRef, hasAnySecret, hasSecret } from './providers/secret-resolver.mjs';
|
|
13
14
|
|
|
14
15
|
const isWindows = os.platform() === 'win32';
|
|
15
16
|
|
|
@@ -165,37 +166,73 @@ export async function checkPrerequisites(options = {}) {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
169
|
+
* Read a credential raw value from dotenv files and shell rc without invoking
|
|
170
|
+
* `op read`. Returns op:// references as-is so callers can persist or report
|
|
171
|
+
* presence without biometric prompts (ADR-0042).
|
|
171
172
|
*/
|
|
172
|
-
function
|
|
173
|
+
function readCredentialRaw(varName, homeDir) {
|
|
173
174
|
if (process.env[varName]) return process.env[varName];
|
|
174
175
|
|
|
175
|
-
const
|
|
176
|
-
|
|
176
|
+
for (const envPath of [
|
|
177
|
+
path.join(process.cwd(), '.env'),
|
|
178
|
+
path.join(homeDir, '.env'),
|
|
179
|
+
path.join(homeDir, '.construct', 'config.env'),
|
|
180
|
+
]) {
|
|
181
|
+
if (!existsSync(envPath)) continue;
|
|
177
182
|
try {
|
|
178
|
-
const content = readFileSync(
|
|
183
|
+
const content = readFileSync(envPath, 'utf8');
|
|
179
184
|
const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
180
185
|
if (m) return m[1].trim();
|
|
181
186
|
} catch { /* skip */ }
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
const
|
|
185
|
-
|
|
189
|
+
const shellFiles = [
|
|
190
|
+
path.join(homeDir, '.zshrc'),
|
|
191
|
+
path.join(homeDir, '.bashrc'),
|
|
192
|
+
path.join(homeDir, '.bash_profile'),
|
|
193
|
+
path.join(homeDir, '.profile'),
|
|
194
|
+
];
|
|
195
|
+
for (const rcPath of shellFiles) {
|
|
196
|
+
if (!existsSync(rcPath)) continue;
|
|
186
197
|
try {
|
|
187
|
-
const content = readFileSync(
|
|
188
|
-
const
|
|
189
|
-
|
|
198
|
+
const content = readFileSync(rcPath, 'utf8');
|
|
199
|
+
const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
|
|
200
|
+
const opMatch = content.match(opRe);
|
|
201
|
+
if (opMatch) return opMatch[1];
|
|
202
|
+
const directRe = new RegExp(`^\\s*export\\s+${varName}=(.+)$`, 'm');
|
|
203
|
+
const directMatch = content.match(directRe);
|
|
204
|
+
if (directMatch) return directMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
190
205
|
} catch { /* skip */ }
|
|
191
206
|
}
|
|
192
207
|
|
|
193
|
-
|
|
194
|
-
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Describe where a credential is configured without resolving op:// refs.
|
|
213
|
+
*/
|
|
214
|
+
export function describeCredentialPresence(varName, { homeDir = os.homedir(), cwd = process.cwd() } = {}) {
|
|
215
|
+
const sources = [];
|
|
216
|
+
const direct = process.env[varName];
|
|
217
|
+
if (direct) {
|
|
218
|
+
const ref = extractOpRef(direct);
|
|
219
|
+
sources.push(ref ? `process.env (op:// ref)` : `process.env (plain value)`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const envPath of [
|
|
223
|
+
path.join(cwd, '.env'),
|
|
224
|
+
path.join(homeDir, '.env'),
|
|
225
|
+
path.join(homeDir, '.construct', 'config.env'),
|
|
226
|
+
]) {
|
|
227
|
+
if (!existsSync(envPath)) continue;
|
|
195
228
|
try {
|
|
196
|
-
const content = readFileSync(
|
|
197
|
-
const m = content.match(new RegExp(`^${varName}=(
|
|
198
|
-
if (m)
|
|
229
|
+
const content = readFileSync(envPath, 'utf8');
|
|
230
|
+
const m = content.match(new RegExp(`^${varName}=["']?(.+?)["']?$`, 'm'));
|
|
231
|
+
if (m) {
|
|
232
|
+
const ref = extractOpRef(m[1]);
|
|
233
|
+
const label = envPath.replace(homeDir, '~');
|
|
234
|
+
sources.push(ref ? `${label} (op:// ref)` : `${label} (plain value)`);
|
|
235
|
+
}
|
|
199
236
|
} catch { /* skip */ }
|
|
200
237
|
}
|
|
201
238
|
|
|
@@ -209,47 +246,18 @@ function getEnvFromShell(varName, homeDir) {
|
|
|
209
246
|
if (!existsSync(rcPath)) continue;
|
|
210
247
|
try {
|
|
211
248
|
const content = readFileSync(rcPath, 'utf8');
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const opRef = m[1];
|
|
216
|
-
try {
|
|
217
|
-
const result = spawnSync('op', ['read', opRef], {
|
|
218
|
-
encoding: 'utf8',
|
|
219
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
220
|
-
timeout: 5000,
|
|
221
|
-
});
|
|
222
|
-
if (result.status === 0) return result.stdout.trim();
|
|
223
|
-
} catch { /* op read failed */ }
|
|
249
|
+
const opRe = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
|
|
250
|
+
if (opRe.test(content)) {
|
|
251
|
+
sources.push(`${rcPath.replace(homeDir, '~')} (op read in rc — use op:// in config.env instead)`);
|
|
224
252
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (process.env.CONSTRUCT_DEBUG_CREDENTIALS === '1') {
|
|
231
|
-
for (const rcPath of shellFiles) {
|
|
232
|
-
if (!existsSync(rcPath)) continue;
|
|
233
|
-
try {
|
|
234
|
-
const content = readFileSync(rcPath, 'utf8');
|
|
235
|
-
const re = new RegExp(`export\\s+${varName}=["']?\\$\\(op read '([^']+)'\\)["']?`, 'm');
|
|
236
|
-
const m = content.match(re);
|
|
237
|
-
if (m) {
|
|
238
|
-
try {
|
|
239
|
-
const result = spawnSync('op', ['read', m[1]], { encoding: 'utf8', timeout: 5000 });
|
|
240
|
-
if (result.status !== 0) {
|
|
241
|
-
const errMsg = (result.stderr || '').trim().slice(0, 200);
|
|
242
|
-
process.stderr.write(`[credentials] ${varName}: op read '${m[1]}' failed: ${errMsg}\n`);
|
|
243
|
-
}
|
|
244
|
-
} catch (e) {
|
|
245
|
-
process.stderr.write(`[credentials] ${varName}: op CLI error: ${e.message}\n`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
} catch { /* skip */ }
|
|
249
|
-
}
|
|
253
|
+
const directRe = new RegExp(`^\\s*export\\s+${varName}=`, 'm');
|
|
254
|
+
if (directRe.test(content) && !sources.some((s) => s.includes(rcPath.replace(homeDir, '~')))) {
|
|
255
|
+
sources.push(`${rcPath.replace(homeDir, '~')} (export found)`);
|
|
256
|
+
}
|
|
257
|
+
} catch { /* skip */ }
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
return
|
|
260
|
+
return { configured: hasSecret(varName, { env: process.env, cwd }), sources };
|
|
253
261
|
}
|
|
254
262
|
|
|
255
263
|
/**
|
|
@@ -287,30 +295,34 @@ const CREDENTIAL_VARS = [
|
|
|
287
295
|
];
|
|
288
296
|
|
|
289
297
|
/**
|
|
290
|
-
*
|
|
298
|
+
* Link discovered credential references into ~/.construct/config.env without
|
|
299
|
+
* invoking `op read`. Plain values and op:// refs are persisted as found.
|
|
291
300
|
* @param {Object} options
|
|
292
301
|
* @param {string} options.homeDir
|
|
293
|
-
* @param {boolean} options.writeConfig - Write
|
|
294
|
-
* @returns {Promise<Record<string, string>>}
|
|
302
|
+
* @param {boolean} options.writeConfig - Write keys to config.env (default: false)
|
|
303
|
+
* @returns {Promise<Record<string, string>>} raw values linked (never plaintext from op)
|
|
295
304
|
*/
|
|
296
305
|
export async function resolveCredentials(options = {}) {
|
|
297
|
-
const { homeDir = os.homedir(), writeConfig =
|
|
298
|
-
const
|
|
306
|
+
const { homeDir = os.homedir(), writeConfig = false } = options;
|
|
307
|
+
const linked = {};
|
|
308
|
+
const { parseEnvFile, getUserEnvPath, writeEnvValues } = await import('./env-config.mjs');
|
|
309
|
+
const existing = parseEnvFile(getUserEnvPath(homeDir));
|
|
299
310
|
|
|
300
311
|
for (const varName of CREDENTIAL_VARS) {
|
|
301
|
-
|
|
302
|
-
|
|
312
|
+
if (existing[varName]) continue;
|
|
313
|
+
const raw = readCredentialRaw(varName, homeDir);
|
|
314
|
+
if (!raw) continue;
|
|
315
|
+
const ref = extractOpRef(raw);
|
|
316
|
+
linked[varName] = ref || raw;
|
|
303
317
|
}
|
|
304
318
|
|
|
305
|
-
|
|
306
|
-
if (writeConfig && Object.keys(resolved).length > 0) {
|
|
319
|
+
if (writeConfig && Object.keys(linked).length > 0) {
|
|
307
320
|
try {
|
|
308
|
-
|
|
309
|
-
writeEnvValues(getUserEnvPath(homeDir), resolved);
|
|
321
|
+
writeEnvValues(getUserEnvPath(homeDir), linked);
|
|
310
322
|
} catch { /* best effort */ }
|
|
311
323
|
}
|
|
312
324
|
|
|
313
|
-
return
|
|
325
|
+
return linked;
|
|
314
326
|
}
|
|
315
327
|
|
|
316
328
|
/**
|
|
@@ -327,22 +339,24 @@ export function detectCredentials(options = {}) {
|
|
|
327
339
|
const ghAuth = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
328
340
|
found.github_cli = ghAuth.status === 0;
|
|
329
341
|
|
|
330
|
-
// GitHub Copilot (
|
|
342
|
+
// GitHub Copilot: a device-flow credential (Construct's own store or the shared
|
|
343
|
+
// github-copilot store) is the usable signal; the VS Code extension and the
|
|
344
|
+
// copilot CLI are softer fallbacks.
|
|
345
|
+
const copilotAuthStore = existsSync(path.join(homeDir, '.construct', 'auth', 'github-copilot.json')) ||
|
|
346
|
+
existsSync(path.join(homeDir, '.config', 'github-copilot', 'apps.json')) ||
|
|
347
|
+
existsSync(path.join(homeDir, '.config', 'github-copilot', 'hosts.json'));
|
|
331
348
|
const copilotDir = path.join(homeDir, '.vscode', 'extensions');
|
|
332
349
|
const copilotExists = existsSync(copilotDir) && existsSync(path.join(homeDir, '.config', 'Code', 'User', 'globalStorage', 'github.copilot'));
|
|
333
|
-
found.copilot = copilotExists || commandExists('copilot');
|
|
350
|
+
found.copilot = copilotAuthStore || copilotExists || commandExists('copilot');
|
|
334
351
|
|
|
335
352
|
// OpenRouter API key
|
|
336
|
-
found.openrouter_key =
|
|
353
|
+
found.openrouter_key = hasAnySecret(['OPENROUTER_API_KEY', 'OPEN_ROUTER_API_KEY'], { env: process.env, cwd: process.cwd() });
|
|
337
354
|
|
|
338
|
-
|
|
339
|
-
found.anthropic_key = !!getEnvFromShell('ANTHROPIC_API_KEY', homeDir);
|
|
355
|
+
found.anthropic_key = hasAnySecret(['ANTHROPIC_API_KEY'], { env: process.env, cwd: process.cwd() });
|
|
340
356
|
|
|
341
|
-
|
|
342
|
-
found.openai_key = !!getEnvFromShell('OPENAI_API_KEY', homeDir);
|
|
357
|
+
found.openai_key = hasAnySecret(['OPENAI_API_KEY'], { env: process.env, cwd: process.cwd() });
|
|
343
358
|
|
|
344
|
-
|
|
345
|
-
found.github_token = !!getEnvFromShell('GITHUB_TOKEN', homeDir) || !!getEnvFromShell('GH_TOKEN', homeDir);
|
|
359
|
+
found.github_token = hasAnySecret(['GITHUB_TOKEN', 'GH_TOKEN'], { env: process.env, cwd: process.cwd() });
|
|
346
360
|
|
|
347
361
|
// VS Code installed
|
|
348
362
|
found.vscode = commandExists('code');
|