@open-mercato/cli 0.6.7 → 0.6.8-develop.6875.1.871a4afc94
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/.turbo/turbo-build.log +4 -4
- package/build.mjs +24 -7
- package/dist/agentic/claude-code/hooks/entity-migration-check.ts +32 -88
- package/dist/agentic/codex/enforcement-rules.md +2 -16
- package/dist/agentic/cursor/hooks/entity-migration-check.mjs +19 -50
- package/dist/agentic/cursor/rules/entity-guard.mdc +9 -21
- package/dist/agentic/cursor/rules/generated-guard.mdc +3 -5
- package/dist/agentic/cursor/rules/open-mercato.mdc +3 -29
- package/dist/agentic/guides/ai-workflows.md +76 -0
- package/dist/agentic/guides/architecture.md +107 -0
- package/dist/agentic/guides/backend-ui.md +80 -0
- package/dist/agentic/guides/contracts.md +92 -0
- package/dist/agentic/guides/extensions.md +121 -0
- package/dist/agentic/guides/framework-extension-points.md +36 -0
- package/dist/agentic/guides/integrations.md +81 -0
- package/dist/agentic/guides/module-facts.json +115537 -3951
- package/dist/agentic/guides/module-system.md +12 -127
- package/dist/agentic/guides/modules/ai_assistant.md +227 -28
- package/dist/agentic/guides/modules/api_docs.md +58 -2
- package/dist/agentic/guides/modules/api_keys.md +92 -7
- package/dist/agentic/guides/modules/attachments.md +119 -14
- package/dist/agentic/guides/modules/audit_logs.md +131 -10
- package/dist/agentic/guides/modules/auth.md +276 -33
- package/dist/agentic/guides/modules/business_rules.md +149 -14
- package/dist/agentic/guides/modules/catalog.md +475 -43
- package/dist/agentic/guides/modules/channel_gmail.md +82 -3
- package/dist/agentic/guides/modules/channel_imap.md +82 -3
- package/dist/agentic/guides/modules/checkout.md +299 -27
- package/dist/agentic/guides/modules/communication_channels.md +315 -33
- package/dist/agentic/guides/modules/configs.md +104 -8
- package/dist/agentic/guides/modules/content.md +57 -2
- package/dist/agentic/guides/modules/currencies.md +152 -17
- package/dist/agentic/guides/modules/customer_accounts.md +387 -45
- package/dist/agentic/guides/modules/customers.md +746 -85
- package/dist/agentic/guides/modules/dashboards.md +155 -10
- package/dist/agentic/guides/modules/data_sync.md +149 -16
- package/dist/agentic/guides/modules/design_system.md +102 -0
- package/dist/agentic/guides/modules/dictionaries.md +107 -12
- package/dist/agentic/guides/modules/directory.md +148 -15
- package/dist/agentic/guides/modules/entities.md +145 -13
- package/dist/agentic/guides/modules/events.md +59 -2
- package/dist/agentic/guides/modules/feature_toggles.md +119 -9
- package/dist/agentic/guides/modules/gateway_stripe.md +92 -4
- package/dist/agentic/guides/modules/generators.md +45 -2
- package/dist/agentic/guides/modules/inbox_ops.md +207 -27
- package/dist/agentic/guides/modules/integrations.md +146 -16
- package/dist/agentic/guides/modules/messages.md +223 -24
- package/dist/agentic/guides/modules/notifications.md +96 -8
- package/dist/agentic/guides/modules/onboarding.md +77 -6
- package/dist/agentic/guides/modules/payment_gateways.md +164 -21
- package/dist/agentic/guides/modules/perspectives.md +77 -7
- package/dist/agentic/guides/modules/planner.md +136 -18
- package/dist/agentic/guides/modules/portal.md +93 -2
- package/dist/agentic/guides/modules/progress.md +104 -14
- package/dist/agentic/guides/modules/query_index.md +154 -15
- package/dist/agentic/guides/modules/record_locks.md +206 -21
- package/dist/agentic/guides/modules/resources.md +197 -30
- package/dist/agentic/guides/modules/sales.md +678 -84
- package/dist/agentic/guides/modules/scheduler.md +133 -14
- package/dist/agentic/guides/modules/search.md +170 -6
- package/dist/agentic/guides/modules/security.md +294 -35
- package/dist/agentic/guides/modules/shipping_carriers.md +134 -18
- package/dist/agentic/guides/modules/sso.md +151 -26
- package/dist/agentic/guides/modules/staff.md +369 -57
- package/dist/agentic/guides/modules/storage_s3.md +95 -5
- package/dist/agentic/guides/modules/sync_akeneo.md +85 -3
- package/dist/agentic/guides/modules/sync_excel.md +75 -6
- package/dist/agentic/guides/modules/system_status_overlays.md +60 -2
- package/dist/agentic/guides/modules/translations.md +104 -10
- package/dist/agentic/guides/modules/webhooks.md +203 -24
- package/dist/agentic/guides/modules/wms.md +441 -46
- package/dist/agentic/guides/modules/workflows.md +302 -43
- package/dist/agentic/guides/spec-delivery.md +22 -0
- package/dist/agentic/guides/testing-debugging.md +80 -0
- package/dist/agentic/guides/upstream/AGENTS.md +299 -0
- package/dist/agentic/guides/upstream/BACKWARD_COMPATIBILITY.md +316 -0
- package/dist/agentic/guides/upstream/manifest.json +8 -0
- package/dist/agentic/shared/AGENTS.md.template +124 -311
- package/dist/agentic/shared/ai/agentic.config.json +2 -0
- package/dist/agentic/shared/ai/harness/README.md +50 -0
- package/dist/agentic/shared/ai/harness/RELEASE.md +57 -0
- package/dist/agentic/shared/ai/harness/cases.json +13680 -0
- package/dist/agentic/shared/ai/harness/cases.schema.json +133 -0
- package/dist/agentic/shared/ai/harness/fixtures/index.json +49 -0
- package/dist/agentic/shared/ai/harness/fixtures/seeds.json +162 -0
- package/dist/agentic/shared/ai/harness/generated-code-review-policy.md +21 -0
- package/dist/agentic/shared/ai/harness/generated-code-review-response.schema.json +96 -0
- package/dist/agentic/shared/ai/harness/generated-code-review-result.schema.json +172 -0
- package/dist/agentic/shared/ai/harness/release-matrix.json +158 -0
- package/dist/agentic/shared/ai/harness/release-result.schema.json +219 -0
- package/dist/agentic/shared/ai/harness/result.schema.json +76 -0
- package/dist/agentic/shared/ai/harness/routing-response.schema.json +12 -0
- package/dist/agentic/shared/ai/harness/target-validation-result.schema.json +73 -0
- package/dist/agentic/shared/ai/harness/validators.json +78 -0
- package/dist/agentic/shared/ai/harness/writable-ast-oracles.mjs +2121 -0
- package/dist/agentic/shared/ai/harness/writable-behavior-oracles.mjs +1096 -0
- package/dist/agentic/shared/ai/lessons/_template.md +16 -0
- package/dist/agentic/shared/ai/lessons.md +26 -3
- package/dist/agentic/shared/ai/review-checklist.md +36 -0
- package/dist/agentic/shared/ai/skills/om-auto-implement-spec/SKILL.md +21 -0
- package/dist/agentic/shared/ai/skills/om-auto-review-pr/SKILL.md +4 -0
- package/dist/agentic/shared/ai/skills/om-auto-upgrade-0.6.6-to-0.6.7/SKILL.md +98 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/SKILL.md +20 -241
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/crud-surfaces.md +16 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/frontend-and-design-system.md +53 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/page-and-navigation.md +13 -0
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/quality-states.md +11 -0
- package/dist/agentic/shared/ai/skills/om-build-workflow/SKILL.md +30 -0
- package/dist/agentic/shared/ai/skills/om-build-workflow/references/activity-contracts.md +12 -0
- package/dist/agentic/shared/ai/skills/om-build-workflow/references/durability-and-progress.md +13 -0
- package/dist/agentic/shared/ai/skills/om-build-workflow/references/workflow-design.md +14 -0
- package/dist/agentic/shared/ai/skills/om-create-ai-agent/SKILL.md +28 -0
- package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/attachments-and-overrides.md +12 -0
- package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/module-agents-and-tools.md +22 -0
- package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/orchestrator-agents.md +12 -0
- package/dist/agentic/shared/ai/skills/om-create-ai-agent/references/surface-selector.md +19 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/SKILL.md +16 -601
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/integrity-and-concurrency.md +19 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/migration-workflow.md +13 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/schema-design.md +14 -0
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/sensitive-data.md +10 -0
- package/dist/agentic/shared/ai/skills/om-eject-and-customize/SKILL.md +14 -310
- package/dist/agentic/shared/ai/skills/om-eject-and-customize/references/decision-and-procedure.md +17 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/SKILL.md +29 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-template.md +100 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/case-workflow.md +16 -0
- package/dist/agentic/shared/ai/skills/om-evolve-harness/references/owner-selection.md +17 -0
- package/dist/agentic/shared/ai/skills/om-framework-context/SKILL.md +25 -0
- package/dist/agentic/shared/ai/skills/om-framework-context/references/resolver-procedure.md +11 -0
- package/dist/agentic/shared/ai/skills/om-framework-context/references/skew-and-escalation.md +13 -0
- package/dist/agentic/shared/ai/skills/om-help/SKILL.md +15 -97
- package/dist/agentic/shared/ai/skills/om-help/references/delivery-workflows.md +18 -0
- package/dist/agentic/shared/ai/skills/om-help/references/task-families.md +20 -0
- package/dist/agentic/shared/ai/skills/om-implement-spec/SKILL.md +33 -152
- package/dist/agentic/shared/ai/skills/om-implement-spec/references/phases-and-gates.md +33 -0
- package/dist/agentic/shared/ai/skills/om-implement-spec/references/planning-and-progress.md +40 -0
- package/dist/agentic/shared/ai/skills/om-implement-spec/references/report-templates.md +27 -0
- package/dist/agentic/shared/ai/skills/om-implement-spec/references/spec-resolution.md +24 -0
- package/dist/agentic/shared/ai/skills/om-integration-builder/SKILL.md +24 -708
- package/dist/agentic/shared/ai/skills/om-integration-builder/references/package-and-activation.md +16 -0
- package/dist/agentic/shared/ai/skills/om-integration-builder/references/provider-families.md +14 -0
- package/dist/agentic/shared/ai/skills/om-integration-builder/references/security-and-reliability.md +15 -0
- package/dist/agentic/shared/ai/skills/om-integration-builder/references/sync-and-files.md +12 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/SKILL.md +34 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/agents/openai.yaml +4 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/agentic-setup.md +11 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/input-normalization.md +31 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/judge-workflow.md +39 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/report-template.md +43 -0
- package/dist/agentic/shared/ai/skills/om-judge-agent-session/references/rules.md +11 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/SKILL.md +20 -820
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/api-and-domain.md +19 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/business-one-shot-blueprints.md +172 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/data-and-migrations.md +12 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/discovery-surface-catalog.md +83 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/module-surfaces.md +21 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/planning.md +11 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/runtime-cache-and-queues.md +22 -0
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/verification.md +13 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/SKILL.md +41 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/references/agentic-setup.md +19 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/references/bundle-preparation.md +44 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/references/consent-and-review.md +39 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/references/publication.md +20 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/references/report-templates.md +57 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/references/rules.md +11 -0
- package/dist/agentic/shared/ai/skills/om-share-this-session/scripts/prepare-share-bundle.mjs +486 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/SKILL.md +19 -843
- package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-branches.md +18 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/references/mechanism-selector.md +27 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/references/read-write-roundtrip.md +13 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/references/unified-overrides.md +41 -0
- package/dist/agentic/shared/ai/skills/om-system-extension/references/verification.md +11 -0
- package/dist/agentic/shared/ai/skills/om-trim-unused-modules/SKILL.md +15 -63
- package/dist/agentic/shared/ai/skills/om-trim-unused-modules/references/dependency-and-redirect.md +15 -0
- package/dist/agentic/shared/ai/skills/om-troubleshooter/SKILL.md +16 -467
- package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnosis-map.md +16 -0
- package/dist/agentic/shared/ai/skills/om-troubleshooter/references/regression-oracles.md +14 -0
- package/dist/agentic/shared/ai/skills/tiers.json +143 -21
- package/dist/agentic/shared/ai/skills/tiers.schema.json +52 -40
- package/dist/agentic/shared/ai/specs/README.md +4 -3
- package/dist/agentic/shared/ai/specs/SPEC-000-template.md +226 -10
- package/dist/agentic/shared/ai/trackers/github.md +108 -4
- package/dist/agentic/shared/scripts/agent-harness-tool-server.mjs +187 -0
- package/dist/agentic/shared/scripts/check-lessons.mjs +178 -0
- package/dist/agentic/shared/scripts/evaluate-agent-harness.mjs +2958 -0
- package/dist/agentic/shared/scripts/execution-sandbox.mjs +329 -0
- package/dist/agentic/shared/scripts/framework-context.mjs +888 -0
- package/dist/agentic/shared/scripts/install-skills.mjs +1029 -0
- package/dist/agentic/shared/scripts/install-skills.sh +4 -694
- package/dist/agentic/shared/scripts/prepare-agent-harness-fixture.mjs +222 -0
- package/dist/agentic/shared/scripts/run-agent-harness-release.mjs +1774 -0
- package/dist/bin.js +13 -1
- package/dist/bin.js.map +2 -2
- package/dist/lib/__integration__/TC-INT-008.spec.js +88 -8
- package/dist/lib/__integration__/TC-INT-008.spec.js.map +2 -2
- package/dist/lib/agentic-init.js +13 -3
- package/dist/lib/agentic-init.js.map +2 -2
- package/dist/lib/agentic-setup.js +434 -186
- package/dist/lib/agentic-setup.js.map +2 -2
- package/dist/lib/generators/extensions/injection-widgets.js +20 -0
- package/dist/lib/generators/extensions/injection-widgets.js.map +2 -2
- package/dist/lib/generators/module-extension-facts.js +1840 -0
- package/dist/lib/generators/module-extension-facts.js.map +7 -0
- package/dist/lib/generators/module-fact-sources.js +70 -0
- package/dist/lib/generators/module-fact-sources.js.map +7 -0
- package/dist/lib/generators/module-facts-discovery.js +41 -7
- package/dist/lib/generators/module-facts-discovery.js.map +2 -2
- package/dist/lib/generators/module-facts.js +1387 -45
- package/dist/lib/generators/module-facts.js.map +3 -3
- package/dist/lib/generators/module-override-targets.js +688 -0
- package/dist/lib/generators/module-override-targets.js.map +7 -0
- package/dist/lib/generators/module-registry.js +11 -5
- package/dist/lib/generators/module-registry.js.map +2 -2
- package/dist/lib/load-env.js +24 -0
- package/dist/lib/load-env.js.map +7 -0
- package/dist/lib/resolver.js +2 -0
- package/dist/lib/resolver.js.map +2 -2
- package/dist/lib/telemetry-init.js +358 -0
- package/dist/lib/telemetry-init.js.map +7 -0
- package/dist/lib/testing/integration.js +5 -0
- package/dist/lib/testing/integration.js.map +2 -2
- package/dist/mercato.js +23 -1
- package/dist/mercato.js.map +2 -2
- package/package.json +13 -11
- package/src/bin.ts +33 -1
- package/src/lib/__integration__/TC-INT-008.spec.ts +107 -9
- package/src/lib/__tests__/agentic-init.test.ts +41 -5
- package/src/lib/__tests__/agentic-setup.ownership.test.ts +386 -0
- package/src/lib/__tests__/injection-widget-id-validation.test.ts +184 -0
- package/src/lib/__tests__/load-env.test.ts +65 -0
- package/src/lib/__tests__/telemetry-init.test.ts +298 -0
- package/src/lib/agentic-init.ts +15 -3
- package/src/lib/agentic-setup.ts +573 -222
- package/src/lib/generators/__tests__/activation-adapter-coverage.test.ts +92 -0
- package/src/lib/generators/__tests__/module-extension-activations.test.ts +762 -0
- package/src/lib/generators/__tests__/module-extension-facts.test.ts +404 -0
- package/src/lib/generators/__tests__/module-facts.bc-guard.test.ts +88 -2
- package/src/lib/generators/__tests__/module-facts.customers.fixture.test.ts +1 -0
- package/src/lib/generators/__tests__/module-facts.discovery.test.ts +54 -10
- package/src/lib/generators/__tests__/module-facts.extension-hosts.test.ts +108 -0
- package/src/lib/generators/__tests__/module-facts.owned-contracts.test.ts +561 -0
- package/src/lib/generators/__tests__/module-facts.source-links.test.ts +119 -0
- package/src/lib/generators/__tests__/module-override-targets.roundtrip.test.ts +149 -0
- package/src/lib/generators/__tests__/module-override-targets.test.ts +304 -0
- package/src/lib/generators/__tests__/module-subset.test.ts +18 -0
- package/src/lib/generators/extensions/injection-widgets.ts +51 -0
- package/src/lib/generators/module-extension-facts.ts +2365 -0
- package/src/lib/generators/module-fact-sources.ts +108 -0
- package/src/lib/generators/module-facts-discovery.ts +58 -7
- package/src/lib/generators/module-facts.ts +1851 -46
- package/src/lib/generators/module-override-targets.ts +863 -0
- package/src/lib/generators/module-registry.ts +16 -6
- package/src/lib/load-env.ts +30 -0
- package/src/lib/resolver.ts +3 -0
- package/src/lib/telemetry-init.ts +461 -0
- package/src/lib/testing/__tests__/integration.test.ts +4 -0
- package/src/lib/testing/integration.ts +5 -0
- package/src/mercato.ts +26 -1
- package/dist/agentic/guides/cache.md +0 -45
- package/dist/agentic/guides/core.md +0 -453
- package/dist/agentic/guides/events.md +0 -117
- package/dist/agentic/guides/queue.md +0 -53
- package/dist/agentic/guides/search.md +0 -115
- package/dist/agentic/guides/shared.md +0 -108
- package/dist/agentic/guides/ui.md +0 -347
- package/dist/agentic/shared/ai/skills/om-auto-continue-pr-loop/SKILL.md +0 -27
- package/dist/agentic/shared/ai/skills/om-auto-create-pr-loop/SKILL.md +0 -27
- package/dist/agentic/shared/ai/skills/om-backend-ui-design/references/ui-components.md +0 -260
- package/dist/agentic/shared/ai/skills/om-data-model-design/references/mikro-orm-cheatsheet.md +0 -146
- package/dist/agentic/shared/ai/skills/om-help/references/skills-catalog.md +0 -84
- package/dist/agentic/shared/ai/skills/om-help/references/workflow-sequences.md +0 -193
- package/dist/agentic/shared/ai/skills/om-integration-builder/STANDALONE.md +0 -34
- package/dist/agentic/shared/ai/skills/om-integration-builder/references/adapter-contracts.md +0 -762
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/naming-conventions.md +0 -50
- package/dist/agentic/shared/ai/skills/om-module-scaffold/references/navigation-patterns.md +0 -97
- package/dist/agentic/shared/ai/skills/om-system-extension/references/extension-contracts.md +0 -271
- package/dist/agentic/shared/ai/skills/om-troubleshooter/references/diagnostic-commands.md +0 -101
|
@@ -0,0 +1,2121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { sandboxedInvocation } from '../../scripts/execution-sandbox.mjs'
|
|
11
|
+
|
|
12
|
+
const TYPECHECK_TIMEOUT_MS = 120_000
|
|
13
|
+
const MODULE_LOCALE_DIRECTORY = 'src/modules/library/i18n'
|
|
14
|
+
const MODULE_LOCALE_NAMESPACE = 'library.'
|
|
15
|
+
const MAX_MODULE_LOCALE_KEYS = 256
|
|
16
|
+
const MAX_LOCALE_FILES = 16
|
|
17
|
+
const MAX_LOCALE_FILE_BYTES = 256 * 1024
|
|
18
|
+
const MAX_TOTAL_LOCALE_BYTES = 1024 * 1024
|
|
19
|
+
const DANGEROUS_LOCALE_KEY_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor'])
|
|
20
|
+
const LOCALIZED_METADATA_KEYS = new Set(['pageTitleKey', 'pageGroupKey', 'labelKey'])
|
|
21
|
+
|
|
22
|
+
const COMPLETE_MODULE_CASE = Object.freeze({
|
|
23
|
+
family: 'complete-module',
|
|
24
|
+
sources: ['src/modules/library', 'src/modules.ts'],
|
|
25
|
+
artifacts: [
|
|
26
|
+
'src/modules/library/index.ts',
|
|
27
|
+
'src/modules/library/acl.ts',
|
|
28
|
+
'src/modules/library/setup.ts',
|
|
29
|
+
'src/modules/library/encryption.ts',
|
|
30
|
+
'src/modules/library/search.ts',
|
|
31
|
+
'src/modules/library/data/entities.ts',
|
|
32
|
+
'src/modules/library/data/validators.ts',
|
|
33
|
+
'src/modules/library/migrations/**',
|
|
34
|
+
'src/modules/library/commands/**',
|
|
35
|
+
'src/modules/library/commands/__tests__/**',
|
|
36
|
+
'src/modules/library/api/books/route.ts',
|
|
37
|
+
'src/modules/library/backend/books/**',
|
|
38
|
+
'src/modules/library/i18n/en.json',
|
|
39
|
+
'src/modules.ts',
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const WRITABLE_CASES = Object.freeze({
|
|
44
|
+
'OMH-009': {
|
|
45
|
+
sources: ['src/modules/library'],
|
|
46
|
+
artifacts: [
|
|
47
|
+
'src/modules/library/data/entities.ts',
|
|
48
|
+
'src/modules/library/data/validators.ts',
|
|
49
|
+
'src/modules/library/migrations/**',
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
'OMH-011': {
|
|
53
|
+
sources: ['src/modules/library/api/books/route.ts'],
|
|
54
|
+
artifacts: ['src/modules/library/api/books/route.ts'],
|
|
55
|
+
},
|
|
56
|
+
'OMH-012': {
|
|
57
|
+
sources: ['src/modules/library/api/books/checkout/route.ts', 'src/modules/library/commands'],
|
|
58
|
+
artifacts: ['src/modules/library/api/books/checkout/route.ts', 'src/modules/library/commands/**'],
|
|
59
|
+
},
|
|
60
|
+
'OMH-014': {
|
|
61
|
+
sources: ['src/modules/library/backend'],
|
|
62
|
+
artifacts: ['src/modules/library/backend/**'],
|
|
63
|
+
},
|
|
64
|
+
'OMH-026': {
|
|
65
|
+
sources: ['src/modules/app_customizations'],
|
|
66
|
+
artifacts: [
|
|
67
|
+
'src/modules/app_customizations/widgets/**',
|
|
68
|
+
'src/modules/app_customizations/data/enrichers.ts',
|
|
69
|
+
'src/modules/app_customizations/api/interceptors.ts',
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
'OMH-027': {
|
|
73
|
+
sources: ['src/modules/app_customizations/widgets'],
|
|
74
|
+
artifacts: ['src/modules/app_customizations/widgets/**'],
|
|
75
|
+
},
|
|
76
|
+
'OMH-029': {
|
|
77
|
+
sources: ['src/modules/app_customizations'],
|
|
78
|
+
artifacts: ['src/modules/app_customizations/**'],
|
|
79
|
+
},
|
|
80
|
+
'OMH-031': {
|
|
81
|
+
sources: ['src/modules/app_customizations/api/interceptors.ts'],
|
|
82
|
+
artifacts: ['src/modules/app_customizations/api/interceptors.ts'],
|
|
83
|
+
},
|
|
84
|
+
'OMH-042': {
|
|
85
|
+
sources: ['src/modules/magento'],
|
|
86
|
+
artifacts: ['src/modules/magento/**'],
|
|
87
|
+
},
|
|
88
|
+
'OMH-045': {
|
|
89
|
+
sources: ['src/modules/external_sync/lib/client.ts'],
|
|
90
|
+
artifacts: ['src/modules/external_sync/lib/client.ts'],
|
|
91
|
+
},
|
|
92
|
+
'OMH-049': {
|
|
93
|
+
sources: ['src/modules/library/ai-agents.ts'],
|
|
94
|
+
artifacts: ['src/modules/library/ai-agents.ts'],
|
|
95
|
+
},
|
|
96
|
+
'OMH-054': {
|
|
97
|
+
sources: ['src/modules/automation/workflows/call-api.ts'],
|
|
98
|
+
artifacts: ['src/modules/automation/workflows/call-api.ts'],
|
|
99
|
+
},
|
|
100
|
+
'OMH-057': {
|
|
101
|
+
sources: ['src/modules/harness_fixture/api/scope/route.ts'],
|
|
102
|
+
artifacts: ['src/modules/harness_fixture/api/scope/route.ts'],
|
|
103
|
+
},
|
|
104
|
+
'OMH-060': {
|
|
105
|
+
sources: ['src/modules/harness_fixture/commands/update-record.ts'],
|
|
106
|
+
artifacts: ['src/modules/harness_fixture/commands/update-record.ts'],
|
|
107
|
+
},
|
|
108
|
+
'OMH-061': {
|
|
109
|
+
sources: ['src/modules/harness_fixture/backend/edit/page.tsx'],
|
|
110
|
+
artifacts: ['src/modules/harness_fixture/backend/edit/page.tsx'],
|
|
111
|
+
},
|
|
112
|
+
'OMH-070': {
|
|
113
|
+
sources: ['src/modules/harness_fixture/workers/sync.ts'],
|
|
114
|
+
artifacts: ['src/modules/harness_fixture/workers/sync.ts'],
|
|
115
|
+
},
|
|
116
|
+
'OMH-093': {
|
|
117
|
+
family: 'business-command',
|
|
118
|
+
seam: 'mergeContacts',
|
|
119
|
+
sources: ['src/modules/customer_merge/commands/merge-contacts.ts'],
|
|
120
|
+
artifacts: ['src/modules/customer_merge/commands/merge-contacts.ts'],
|
|
121
|
+
},
|
|
122
|
+
'OMH-105': {
|
|
123
|
+
family: 'business-command',
|
|
124
|
+
seam: 'changeDealStage',
|
|
125
|
+
sources: ['src/modules/deal_stages/commands/change-stage.ts'],
|
|
126
|
+
artifacts: ['src/modules/deal_stages/commands/change-stage.ts'],
|
|
127
|
+
},
|
|
128
|
+
'OMH-107': {
|
|
129
|
+
family: 'business-command',
|
|
130
|
+
seam: 'requestQuoteDiscount',
|
|
131
|
+
sources: ['src/modules/quote_approval/commands/request-discount.ts'],
|
|
132
|
+
artifacts: ['src/modules/quote_approval/commands/request-discount.ts'],
|
|
133
|
+
},
|
|
134
|
+
'OMH-115': {
|
|
135
|
+
family: 'ui-business-surface',
|
|
136
|
+
seam: 'moveDealAccessibly',
|
|
137
|
+
handler: 'handleDealBoardAction',
|
|
138
|
+
render: 'DealBoardPage',
|
|
139
|
+
event: 'onClick',
|
|
140
|
+
components: ['Page', 'PageHeader', 'PageBody', 'Button', 'Alert'],
|
|
141
|
+
metadata: ['requireAuth', 'requireFeatures'],
|
|
142
|
+
sources: [
|
|
143
|
+
'src/modules/deal_accessibility/backend/board/page.tsx',
|
|
144
|
+
'src/modules/deal_accessibility/backend/board/page.meta.ts',
|
|
145
|
+
'src/modules/deal_accessibility/lib/move-deal.ts',
|
|
146
|
+
],
|
|
147
|
+
artifacts: [
|
|
148
|
+
'src/modules/deal_accessibility/backend/board/page.tsx',
|
|
149
|
+
'src/modules/deal_accessibility/backend/board/page.meta.ts',
|
|
150
|
+
'src/modules/deal_accessibility/lib/move-deal.ts',
|
|
151
|
+
'src/modules/deal_accessibility/i18n/en.json',
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
'OMH-122': {
|
|
155
|
+
family: 'business-command',
|
|
156
|
+
seam: 'reserveStock',
|
|
157
|
+
sources: ['src/modules/stock_reservations/commands/reserve-stock.ts'],
|
|
158
|
+
artifacts: ['src/modules/stock_reservations/commands/reserve-stock.ts'],
|
|
159
|
+
},
|
|
160
|
+
'OMH-128': {
|
|
161
|
+
family: 'async-operation',
|
|
162
|
+
seam: 'updatePrices',
|
|
163
|
+
sources: ['src/modules/bulk_pricing/commands/update-prices.ts'],
|
|
164
|
+
artifacts: ['src/modules/bulk_pricing/commands/update-prices.ts'],
|
|
165
|
+
},
|
|
166
|
+
'OMH-130': {
|
|
167
|
+
family: 'ui-business-surface',
|
|
168
|
+
seam: 'submitDemoRequest',
|
|
169
|
+
handler: 'handleDemoRequest',
|
|
170
|
+
render: 'RequestDemoPage',
|
|
171
|
+
event: 'onSubmit',
|
|
172
|
+
components: ['FormField', 'Input', 'CheckboxField', 'Button', 'Alert'],
|
|
173
|
+
metadata: ['navHidden'],
|
|
174
|
+
allowFormTag: true,
|
|
175
|
+
sources: [
|
|
176
|
+
'src/modules/demo_requests/frontend/request-demo/page.tsx',
|
|
177
|
+
'src/modules/demo_requests/frontend/request-demo/page.meta.ts',
|
|
178
|
+
'src/modules/demo_requests/lib/submit-demo-request.ts',
|
|
179
|
+
],
|
|
180
|
+
artifacts: [
|
|
181
|
+
'src/modules/demo_requests/frontend/request-demo/page.tsx',
|
|
182
|
+
'src/modules/demo_requests/frontend/request-demo/page.meta.ts',
|
|
183
|
+
'src/modules/demo_requests/lib/submit-demo-request.ts',
|
|
184
|
+
'src/modules/demo_requests/i18n/en.json',
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
'OMH-133': {
|
|
188
|
+
family: 'business-command',
|
|
189
|
+
seam: 'approvePortalQuote',
|
|
190
|
+
sources: ['src/modules/portal_quote_approval/commands/approve-quote.ts'],
|
|
191
|
+
artifacts: ['src/modules/portal_quote_approval/commands/approve-quote.ts'],
|
|
192
|
+
},
|
|
193
|
+
'OMH-137': {
|
|
194
|
+
family: 'ui-business-surface',
|
|
195
|
+
seam: 'advanceSetupWizard',
|
|
196
|
+
handler: 'handleSetupWizardAction',
|
|
197
|
+
render: 'SetupWizardPage',
|
|
198
|
+
event: 'onClick',
|
|
199
|
+
components: ['Page', 'PageHeader', 'PageBody', 'StepIndicator', 'Button', 'Alert', 'LoadingMessage'],
|
|
200
|
+
metadata: ['requireAuth', 'requireFeatures'],
|
|
201
|
+
sources: [
|
|
202
|
+
'src/modules/setup_wizard/backend/setup/page.tsx',
|
|
203
|
+
'src/modules/setup_wizard/backend/setup/page.meta.ts',
|
|
204
|
+
'src/modules/setup_wizard/lib/advance-setup.ts',
|
|
205
|
+
],
|
|
206
|
+
artifacts: [
|
|
207
|
+
'src/modules/setup_wizard/backend/setup/page.tsx',
|
|
208
|
+
'src/modules/setup_wizard/backend/setup/page.meta.ts',
|
|
209
|
+
'src/modules/setup_wizard/lib/advance-setup.ts',
|
|
210
|
+
'src/modules/setup_wizard/i18n/en.json',
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
'OMH-140': {
|
|
214
|
+
family: 'async-operation',
|
|
215
|
+
seam: 'runInvoiceDunning',
|
|
216
|
+
sources: ['src/modules/invoice_dunning'],
|
|
217
|
+
artifacts: ['src/modules/invoice_dunning/workflows/**', 'src/modules/invoice_dunning/events.ts'],
|
|
218
|
+
},
|
|
219
|
+
'OMH-144': {
|
|
220
|
+
family: 'ai-safe-agent',
|
|
221
|
+
seam: 'saveQuoteDraftWithApproval',
|
|
222
|
+
mode: 'mutation',
|
|
223
|
+
sources: ['src/modules/quote_assistant/ai-agents.ts', 'src/modules/quote_assistant/ai-tools.ts'],
|
|
224
|
+
artifacts: ['src/modules/quote_assistant/ai-agents.ts', 'src/modules/quote_assistant/ai-tools.ts'],
|
|
225
|
+
},
|
|
226
|
+
'OMH-146': {
|
|
227
|
+
family: 'ai-safe-agent',
|
|
228
|
+
seam: 'coordinateSalesQuestion',
|
|
229
|
+
mode: 'delegate',
|
|
230
|
+
sources: ['src/modules/sales_orchestrator/ai-agents.ts'],
|
|
231
|
+
artifacts: ['src/modules/sales_orchestrator/ai-agents.ts'],
|
|
232
|
+
},
|
|
233
|
+
'OMH-149': {
|
|
234
|
+
family: 'provider-adapter',
|
|
235
|
+
seam: 'sendTransactionalEmail',
|
|
236
|
+
providerKind: 'transactional-email',
|
|
237
|
+
moduleId: 'smtp_email',
|
|
238
|
+
healthService: 'smtpEmailHealthCheck',
|
|
239
|
+
sources: ['src/modules/smtp_email', 'src/modules.ts'],
|
|
240
|
+
artifacts: [
|
|
241
|
+
'src/modules/smtp_email/index.ts',
|
|
242
|
+
'src/modules/smtp_email/integration.ts',
|
|
243
|
+
'src/modules/smtp_email/di.ts',
|
|
244
|
+
'src/modules/smtp_email/lib/client.ts',
|
|
245
|
+
'src/modules/smtp_email/lib/health.ts',
|
|
246
|
+
'src/modules.ts',
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
'OMH-150': {
|
|
250
|
+
family: 'provider-adapter',
|
|
251
|
+
seam: 'createCardPayment',
|
|
252
|
+
providerKind: 'payment',
|
|
253
|
+
moduleId: 'card_payments',
|
|
254
|
+
healthService: 'cardPaymentsHealthCheck',
|
|
255
|
+
adapterVariable: 'cardPaymentAdapter',
|
|
256
|
+
sources: ['src/modules/card_payments', 'src/modules.ts'],
|
|
257
|
+
artifacts: [
|
|
258
|
+
'src/modules/card_payments/index.ts',
|
|
259
|
+
'src/modules/card_payments/integration.ts',
|
|
260
|
+
'src/modules/card_payments/di.ts',
|
|
261
|
+
'src/modules/card_payments/acl.ts',
|
|
262
|
+
'src/modules/card_payments/setup.ts',
|
|
263
|
+
'src/modules/card_payments/lib/adapter.ts',
|
|
264
|
+
'src/modules/card_payments/lib/client.ts',
|
|
265
|
+
'src/modules/card_payments/lib/health.ts',
|
|
266
|
+
'src/modules.ts',
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
'OMH-151': {
|
|
270
|
+
family: 'provider-adapter',
|
|
271
|
+
seam: 'bookCarrierShipment',
|
|
272
|
+
providerKind: 'shipping',
|
|
273
|
+
moduleId: 'carrier_shipping',
|
|
274
|
+
healthService: 'carrierShippingHealthCheck',
|
|
275
|
+
adapterVariable: 'carrierShippingAdapter',
|
|
276
|
+
sources: ['src/modules/carrier_shipping', 'src/modules.ts'],
|
|
277
|
+
artifacts: [
|
|
278
|
+
'src/modules/carrier_shipping/index.ts',
|
|
279
|
+
'src/modules/carrier_shipping/integration.ts',
|
|
280
|
+
'src/modules/carrier_shipping/di.ts',
|
|
281
|
+
'src/modules/carrier_shipping/acl.ts',
|
|
282
|
+
'src/modules/carrier_shipping/setup.ts',
|
|
283
|
+
'src/modules/carrier_shipping/lib/adapter.ts',
|
|
284
|
+
'src/modules/carrier_shipping/lib/client.ts',
|
|
285
|
+
'src/modules/carrier_shipping/lib/health.ts',
|
|
286
|
+
'src/modules.ts',
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
'OMH-153': {
|
|
290
|
+
family: 'data-flow',
|
|
291
|
+
seam: 'synchronizeErpPage',
|
|
292
|
+
sources: ['src/modules/erp_sync'],
|
|
293
|
+
artifacts: ['src/modules/erp_sync/data-sync.ts', 'src/modules/erp_sync/backend/**', 'src/modules/erp_sync/workers/**'],
|
|
294
|
+
},
|
|
295
|
+
'OMH-156': {
|
|
296
|
+
family: 'data-flow',
|
|
297
|
+
seam: 'transferProductRows',
|
|
298
|
+
sources: ['src/modules/product_transfer/lib/flow.ts'],
|
|
299
|
+
artifacts: ['src/modules/product_transfer/lib/flow.ts'],
|
|
300
|
+
},
|
|
301
|
+
'OMH-163': {
|
|
302
|
+
family: 'test-authoring-unit',
|
|
303
|
+
sources: ['src/modules/quote_approval/commands/__tests__/approve-quote.test.ts'],
|
|
304
|
+
artifacts: ['src/modules/quote_approval/commands/__tests__/approve-quote.test.ts'],
|
|
305
|
+
},
|
|
306
|
+
'OMH-164': {
|
|
307
|
+
family: 'test-authoring-api',
|
|
308
|
+
sources: ['src/modules/customer_api/__integration__/TC-API-CUSTOMERS-001.spec.ts'],
|
|
309
|
+
artifacts: ['src/modules/customer_api/__integration__/TC-API-CUSTOMERS-001.spec.ts'],
|
|
310
|
+
},
|
|
311
|
+
'OMH-165': {
|
|
312
|
+
family: 'test-authoring-browser',
|
|
313
|
+
sources: ['src/modules/portal_quote_approval/__integration__/TC-PORTAL-QUOTE-001.spec.ts'],
|
|
314
|
+
artifacts: ['src/modules/portal_quote_approval/__integration__/TC-PORTAL-QUOTE-001.spec.ts'],
|
|
315
|
+
},
|
|
316
|
+
'OMH-171': {
|
|
317
|
+
family: 'regression',
|
|
318
|
+
seam: 'listRecords',
|
|
319
|
+
sources: ['src/modules/harness_fixture/api/scope/route.ts'],
|
|
320
|
+
artifacts: ['src/modules/harness_fixture/api/scope/route.ts'],
|
|
321
|
+
},
|
|
322
|
+
'OMH-172': {
|
|
323
|
+
family: 'regression',
|
|
324
|
+
sources: ['src/modules/harness_fixture/backend/edit/page.tsx'],
|
|
325
|
+
artifacts: ['src/modules/harness_fixture/backend/edit/page.tsx'],
|
|
326
|
+
},
|
|
327
|
+
'OMH-181': {
|
|
328
|
+
family: 'data-table-extension',
|
|
329
|
+
seam: 'reviewOrderRisk',
|
|
330
|
+
sources: [
|
|
331
|
+
'src/modules/order_risk/widgets/injection/order-risk-filter/widget.ts',
|
|
332
|
+
'src/modules/order_risk/widgets/injection/order-risk-review/widget.ts',
|
|
333
|
+
'src/modules/order_risk/widgets/injection-table.ts',
|
|
334
|
+
],
|
|
335
|
+
artifacts: [
|
|
336
|
+
'src/modules/order_risk/widgets/injection/order-risk-filter/widget.ts',
|
|
337
|
+
'src/modules/order_risk/widgets/injection/order-risk-review/widget.ts',
|
|
338
|
+
'src/modules/order_risk/widgets/injection-table.ts',
|
|
339
|
+
'src/modules/order_risk/i18n/en.json',
|
|
340
|
+
],
|
|
341
|
+
},
|
|
342
|
+
'OMH-188': {
|
|
343
|
+
family: 'booking-overlap',
|
|
344
|
+
sources: ['src/modules/room_bookings', 'src/modules.ts'],
|
|
345
|
+
artifacts: [
|
|
346
|
+
'src/modules/room_bookings/index.ts',
|
|
347
|
+
'src/modules/room_bookings/data/entities.ts',
|
|
348
|
+
'src/modules/room_bookings/data/validators.ts',
|
|
349
|
+
'src/modules/room_bookings/migrations/**',
|
|
350
|
+
'src/modules/room_bookings/commands/**',
|
|
351
|
+
'src/modules/room_bookings/api/bookings/route.ts',
|
|
352
|
+
'src/modules.ts',
|
|
353
|
+
],
|
|
354
|
+
},
|
|
355
|
+
'OMH-189': {
|
|
356
|
+
family: 'provider-transport',
|
|
357
|
+
moduleId: 'room_calendar_sync',
|
|
358
|
+
healthService: 'roomCalendarSyncHealth',
|
|
359
|
+
sources: ['src/modules/room_calendar_sync', 'src/modules.ts'],
|
|
360
|
+
artifacts: [
|
|
361
|
+
'src/modules/room_calendar_sync/index.ts',
|
|
362
|
+
'src/modules/room_calendar_sync/integration.ts',
|
|
363
|
+
'src/modules/room_calendar_sync/di.ts',
|
|
364
|
+
'src/modules/room_calendar_sync/lib/**',
|
|
365
|
+
'src/modules.ts',
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
'OMH-190': {
|
|
369
|
+
family: 'response-enricher',
|
|
370
|
+
sources: ['src/modules/room_bookings/data/enrichers.ts'],
|
|
371
|
+
artifacts: ['src/modules/room_bookings/data/enrichers.ts'],
|
|
372
|
+
},
|
|
373
|
+
'OMH-191': {
|
|
374
|
+
family: 'durable-workflow',
|
|
375
|
+
sources: ['src/modules/room_bookings/workflows.ts'],
|
|
376
|
+
artifacts: ['src/modules/room_bookings/workflows.ts'],
|
|
377
|
+
},
|
|
378
|
+
'OMH-192': {
|
|
379
|
+
family: 'crm-library-regression',
|
|
380
|
+
sources: ['src/modules/library/commands/crm-loans.ts', 'src/modules/library/api/schemas.ts', 'src/modules/library/commands/__tests__/crm-loans.test.ts'],
|
|
381
|
+
schemaSource: 'src/modules/library/api/schemas.ts',
|
|
382
|
+
testSource: 'src/modules/library/commands/__tests__/crm-loans.test.ts',
|
|
383
|
+
artifacts: ['src/modules/library/commands/crm-loans.ts', 'src/modules/library/api/schemas.ts', 'src/modules/library/commands/__tests__/crm-loans.test.ts'],
|
|
384
|
+
},
|
|
385
|
+
'OMH-185': COMPLETE_MODULE_CASE,
|
|
386
|
+
'OMH-193': COMPLETE_MODULE_CASE,
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
export const WRITABLE_CASE_IDS = Object.freeze(Object.keys(WRITABLE_CASES).sort())
|
|
390
|
+
|
|
391
|
+
function parseArgs(argv) {
|
|
392
|
+
const options = { root: undefined, caseId: undefined, phase: undefined, json: false }
|
|
393
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
394
|
+
const arg = argv[index]
|
|
395
|
+
const value = () => {
|
|
396
|
+
const next = argv[index + 1]
|
|
397
|
+
if (!next || next.startsWith('--')) throw new Error(`${arg} requires a value`)
|
|
398
|
+
index += 1
|
|
399
|
+
return next
|
|
400
|
+
}
|
|
401
|
+
if (arg === '--root') options.root = value()
|
|
402
|
+
else if (arg === '--case') options.caseId = value()
|
|
403
|
+
else if (arg === '--phase') options.phase = value()
|
|
404
|
+
else if (arg === '--json') options.json = true
|
|
405
|
+
else throw new Error(`unknown argument: ${arg}`)
|
|
406
|
+
}
|
|
407
|
+
if (!options.root || !options.caseId || !options.phase || !options.json) {
|
|
408
|
+
throw new Error('--root, --case, --phase before|after, and --json are required')
|
|
409
|
+
}
|
|
410
|
+
if (!path.isAbsolute(options.root)) throw new Error('--root must be absolute')
|
|
411
|
+
if (!WRITABLE_CASES[options.caseId]) throw new Error(`unsupported writable case: ${options.caseId}`)
|
|
412
|
+
if (!['before', 'after'].includes(options.phase)) throw new Error('--phase must be before or after')
|
|
413
|
+
return options
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function loadTargetTypeScript(root) {
|
|
417
|
+
const targetRequire = createRequire(path.join(root, 'package.json'))
|
|
418
|
+
try {
|
|
419
|
+
return targetRequire('typescript')
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw new Error(`target app cannot resolve TypeScript: ${error instanceof Error ? error.message : String(error)}`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function isTypeScriptSource(file) {
|
|
426
|
+
return /\.(?:cts|mts|ts|tsx)$/.test(file) && !/\.d\.(?:cts|mts|ts)$/.test(file)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function safeTargetEntry(root, absolute) {
|
|
430
|
+
const relative = path.relative(root, absolute)
|
|
431
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
|
|
432
|
+
throw new Error(`oracle path escapes the target: ${relative || '.'}`)
|
|
433
|
+
}
|
|
434
|
+
let current = root
|
|
435
|
+
for (const segment of relative.split(path.sep)) {
|
|
436
|
+
current = path.join(current, segment)
|
|
437
|
+
if (!fs.existsSync(current)) return undefined
|
|
438
|
+
const stat = fs.lstatSync(current)
|
|
439
|
+
if (stat.isSymbolicLink()) throw new Error(`oracle path contains a symbolic link: ${path.relative(root, current).replaceAll(path.sep, '/')}`)
|
|
440
|
+
if (!stat.isDirectory() && !stat.isFile()) throw new Error(`oracle path contains a special file: ${path.relative(root, current).replaceAll(path.sep, '/')}`)
|
|
441
|
+
}
|
|
442
|
+
if (!path.relative(root, fs.realpathSync(current)).startsWith('..')) return fs.lstatSync(current)
|
|
443
|
+
throw new Error(`oracle path resolves outside the target: ${relative.replaceAll(path.sep, '/')}`)
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function collectSourceFiles(root, relativeEntries) {
|
|
447
|
+
const found = new Set()
|
|
448
|
+
const visit = (absolute) => {
|
|
449
|
+
const stat = safeTargetEntry(root, absolute)
|
|
450
|
+
if (!stat) return
|
|
451
|
+
if (stat.isFile()) {
|
|
452
|
+
if (isTypeScriptSource(absolute)) found.add(absolute)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
if (!stat.isDirectory()) return
|
|
456
|
+
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
|
|
457
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
458
|
+
if (!entry.isDirectory() && !isTypeScriptSource(entry.name)) continue
|
|
459
|
+
visit(path.join(absolute, entry.name))
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const relative of relativeEntries) visit(path.join(root, relative))
|
|
463
|
+
return [...found].sort()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function literalString(ts, node) {
|
|
467
|
+
const expression = node ? unwrapExpression(ts, node) : undefined
|
|
468
|
+
return expression && (ts.isStringLiteralLike(expression) || ts.isNoSubstitutionTemplateLiteral(expression))
|
|
469
|
+
? expression.text
|
|
470
|
+
: undefined
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isModuleLocaleReferenceSource(file) {
|
|
474
|
+
const normalized = file.replaceAll(path.sep, '/')
|
|
475
|
+
if (normalized.includes('/__tests__/') || /\.(?:test|spec)\.(?:cts|mts|ts|tsx)$/.test(normalized)) return false
|
|
476
|
+
if (normalized.includes('/migrations/')) return false
|
|
477
|
+
const isRoutedUi = ['/backend/', '/frontend/', '/widgets/'].some((segment) => normalized.includes(segment))
|
|
478
|
+
return isRoutedUi && (normalized.endsWith('.tsx') || normalized.endsWith('/page.meta.ts'))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function collectModuleLocaleKeys(ts, sourceFiles) {
|
|
482
|
+
const keys = new Set()
|
|
483
|
+
let excessive = false
|
|
484
|
+
const addKey = (value) => {
|
|
485
|
+
if (!value?.startsWith(MODULE_LOCALE_NAMESPACE)) return
|
|
486
|
+
if (keys.size >= MAX_MODULE_LOCALE_KEYS && !keys.has(value)) {
|
|
487
|
+
excessive = true
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
keys.add(value)
|
|
491
|
+
}
|
|
492
|
+
for (const file of sourceFiles.filter(isModuleLocaleReferenceSource)) {
|
|
493
|
+
const source = ts.createSourceFile(
|
|
494
|
+
file,
|
|
495
|
+
fs.readFileSync(file, 'utf8'),
|
|
496
|
+
ts.ScriptTarget.Latest,
|
|
497
|
+
true,
|
|
498
|
+
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
499
|
+
)
|
|
500
|
+
const visit = (node) => {
|
|
501
|
+
if (ts.isCallExpression(node) && expressionName(ts, node.expression) === 't') {
|
|
502
|
+
addKey(literalString(ts, node.arguments[0]))
|
|
503
|
+
}
|
|
504
|
+
if (ts.isPropertyAssignment(node)) {
|
|
505
|
+
const name = propertyName(ts, node.name)
|
|
506
|
+
if (LOCALIZED_METADATA_KEYS.has(name)) addKey(literalString(ts, node.initializer))
|
|
507
|
+
}
|
|
508
|
+
ts.forEachChild(node, visit)
|
|
509
|
+
}
|
|
510
|
+
visit(source)
|
|
511
|
+
}
|
|
512
|
+
return { keys: [...keys].sort(), excessive }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function isPlainLocaleObject(value) {
|
|
516
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
517
|
+
const prototype = Object.getPrototypeOf(value)
|
|
518
|
+
return prototype === Object.prototype || prototype === null
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function safeDiagnosticToken(value) {
|
|
522
|
+
return String(value).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 200)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function resolveLocaleValue(catalog, key) {
|
|
526
|
+
const segments = key.split('.')
|
|
527
|
+
if (segments.some((segment) => DANGEROUS_LOCALE_KEY_SEGMENTS.has(segment))) {
|
|
528
|
+
return { valid: false, reason: 'contains a dangerous path segment' }
|
|
529
|
+
}
|
|
530
|
+
let current = catalog
|
|
531
|
+
for (const segment of segments) {
|
|
532
|
+
if (!isPlainLocaleObject(current) || !Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
533
|
+
return { valid: false, reason: 'is missing' }
|
|
534
|
+
}
|
|
535
|
+
current = current[segment]
|
|
536
|
+
}
|
|
537
|
+
if (typeof current !== 'string') return { valid: false, reason: 'is not a string' }
|
|
538
|
+
if (!current.trim()) return { valid: false, reason: 'is blank' }
|
|
539
|
+
return { valid: true }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function moduleLocaleCatalogCheck(ts, root, sourceFiles) {
|
|
543
|
+
const failures = []
|
|
544
|
+
const addFailure = (message) => {
|
|
545
|
+
if (failures.length < 32) failures.push(message)
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const extracted = collectModuleLocaleKeys(ts, sourceFiles)
|
|
549
|
+
if (extracted.excessive) addFailure(`more than ${MAX_MODULE_LOCALE_KEYS} literal ${MODULE_LOCALE_NAMESPACE} keys`)
|
|
550
|
+
if (extracted.keys.length === 0) addFailure(`no literal ${MODULE_LOCALE_NAMESPACE} translation keys`)
|
|
551
|
+
const localeDirectory = path.join(root, MODULE_LOCALE_DIRECTORY)
|
|
552
|
+
const directoryStat = safeTargetEntry(root, localeDirectory)
|
|
553
|
+
if (!directoryStat?.isDirectory()) {
|
|
554
|
+
addFailure('i18n directory is missing')
|
|
555
|
+
return check('module.locale-catalog', false, `literal module locale catalogs resolve to non-empty strings (${failures.join('; ')})`)
|
|
556
|
+
}
|
|
557
|
+
const localeEntries = fs.readdirSync(localeDirectory, { withFileTypes: true })
|
|
558
|
+
.filter((entry) => entry.name.endsWith('.json'))
|
|
559
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
560
|
+
if (localeEntries.length > MAX_LOCALE_FILES) addFailure(`locale file count exceeds ${MAX_LOCALE_FILES}`)
|
|
561
|
+
const boundedEntries = localeEntries.slice(0, MAX_LOCALE_FILES)
|
|
562
|
+
if (!boundedEntries.some((entry) => entry.name === 'en.json')) addFailure('en.json is missing')
|
|
563
|
+
let totalBytes = 0
|
|
564
|
+
const catalogs = []
|
|
565
|
+
for (const entry of boundedEntries) {
|
|
566
|
+
const localeName = safeDiagnosticToken(entry.name)
|
|
567
|
+
const absolute = path.join(localeDirectory, entry.name)
|
|
568
|
+
const stat = safeTargetEntry(root, absolute)
|
|
569
|
+
if (!stat?.isFile()) {
|
|
570
|
+
addFailure(`${localeName} is not a regular file`)
|
|
571
|
+
continue
|
|
572
|
+
}
|
|
573
|
+
if (stat.size > MAX_LOCALE_FILE_BYTES) {
|
|
574
|
+
addFailure(`${localeName} exceeds ${MAX_LOCALE_FILE_BYTES} bytes`)
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
totalBytes += stat.size
|
|
578
|
+
if (totalBytes > MAX_TOTAL_LOCALE_BYTES) {
|
|
579
|
+
addFailure(`locale input exceeds ${MAX_TOTAL_LOCALE_BYTES} total bytes`)
|
|
580
|
+
break
|
|
581
|
+
}
|
|
582
|
+
let catalog
|
|
583
|
+
try {
|
|
584
|
+
catalog = JSON.parse(fs.readFileSync(absolute, 'utf8'))
|
|
585
|
+
} catch {
|
|
586
|
+
addFailure(`${localeName} contains malformed JSON`)
|
|
587
|
+
continue
|
|
588
|
+
}
|
|
589
|
+
if (!isPlainLocaleObject(catalog)) {
|
|
590
|
+
addFailure(`${localeName} root is not a plain object`)
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
catalogs.push({ name: localeName, catalog })
|
|
594
|
+
}
|
|
595
|
+
for (const { name, catalog } of catalogs) {
|
|
596
|
+
for (const key of extracted.keys) {
|
|
597
|
+
const resolution = resolveLocaleValue(catalog, key)
|
|
598
|
+
if (!resolution.valid) addFailure(`${name} ${safeDiagnosticToken(key)} ${resolution.reason}`)
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
603
|
+
addFailure(safeDiagnosticToken(message))
|
|
604
|
+
}
|
|
605
|
+
return check(
|
|
606
|
+
'module.locale-catalog',
|
|
607
|
+
failures.length === 0,
|
|
608
|
+
failures.length
|
|
609
|
+
? `literal module locale catalogs resolve to non-empty strings (${failures.join('; ')})`
|
|
610
|
+
: `en.json and every emitted sibling locale resolve every literal ${MODULE_LOCALE_NAMESPACE} key to a non-empty string`,
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function artifactExists(root, pattern) {
|
|
615
|
+
if (!pattern.endsWith('/**')) return Boolean(safeTargetEntry(root, path.join(root, pattern)))
|
|
616
|
+
const directory = path.join(root, pattern.slice(0, -3))
|
|
617
|
+
const directoryStat = safeTargetEntry(root, directory)
|
|
618
|
+
if (!directoryStat?.isDirectory()) return false
|
|
619
|
+
const pending = [directory]
|
|
620
|
+
while (pending.length) {
|
|
621
|
+
const current = pending.pop()
|
|
622
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
623
|
+
const absolute = path.join(current, entry.name)
|
|
624
|
+
const stat = safeTargetEntry(root, absolute)
|
|
625
|
+
if (stat?.isFile()) return true
|
|
626
|
+
if (stat?.isDirectory()) pending.push(absolute)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return false
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function propertyName(ts, node) {
|
|
633
|
+
if (!node) return undefined
|
|
634
|
+
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node) || ts.isStringLiteralLike(node) || ts.isNumericLiteral(node)) {
|
|
635
|
+
return node.text
|
|
636
|
+
}
|
|
637
|
+
if (ts.isComputedPropertyName(node) && ts.isStringLiteralLike(node.expression)) return node.expression.text
|
|
638
|
+
return undefined
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function expressionName(ts, expression) {
|
|
642
|
+
if (ts.isIdentifier(expression)) return expression.text
|
|
643
|
+
if (ts.isPropertyAccessExpression(expression)) return expression.name.text
|
|
644
|
+
if (ts.isElementAccessExpression(expression) && expression.argumentExpression && ts.isStringLiteralLike(expression.argumentExpression)) {
|
|
645
|
+
return expression.argumentExpression.text
|
|
646
|
+
}
|
|
647
|
+
return undefined
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function fullExpressionName(ts, expression) {
|
|
651
|
+
if (ts.isIdentifier(expression)) return expression.text
|
|
652
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
653
|
+
const left = fullExpressionName(ts, expression.expression)
|
|
654
|
+
return left ? `${left}.${expression.name.text}` : expression.name.text
|
|
655
|
+
}
|
|
656
|
+
return expressionName(ts, expression)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function expressionPath(ts, expression) {
|
|
660
|
+
if (ts.isIdentifier(expression)) return [expression.text]
|
|
661
|
+
if (ts.isPropertyAccessExpression(expression)) {
|
|
662
|
+
return [...expressionPath(ts, expression.expression), expression.name.text]
|
|
663
|
+
}
|
|
664
|
+
if (ts.isElementAccessExpression(expression) && expression.argumentExpression && ts.isStringLiteralLike(expression.argumentExpression)) {
|
|
665
|
+
return [...expressionPath(ts, expression.expression), expression.argumentExpression.text]
|
|
666
|
+
}
|
|
667
|
+
if (ts.isCallExpression(expression)) return expressionPath(ts, expression.expression)
|
|
668
|
+
if (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isNonNullExpression(expression)) {
|
|
669
|
+
return expressionPath(ts, expression.expression)
|
|
670
|
+
}
|
|
671
|
+
return []
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const FORBIDDEN_TEST_ALIASES = new Set(['fdescribe', 'fit', 'pending', 'xdescribe', 'xit', 'xtest'])
|
|
675
|
+
const TEST_RUNNER_ROOTS = new Set(['describe', 'it', 'test'])
|
|
676
|
+
const FORBIDDEN_TEST_MODIFIERS = new Set(['fail', 'failing', 'fixme', 'only', 'skip', 'todo'])
|
|
677
|
+
const TEST_INFO_MODIFIERS = new Set(['fail', 'fixme', 'skip'])
|
|
678
|
+
|
|
679
|
+
function forbiddenTestModifier(ts, expression) {
|
|
680
|
+
const parts = expressionPath(ts, expression)
|
|
681
|
+
if (FORBIDDEN_TEST_ALIASES.has(parts[0])) return parts.join('.')
|
|
682
|
+
if (parts[0] === 'testInfo' && parts.slice(1).some((part) => TEST_INFO_MODIFIERS.has(part))) return parts.join('.')
|
|
683
|
+
if (!TEST_RUNNER_ROOTS.has(parts[0])) return undefined
|
|
684
|
+
return parts.slice(1).some((part) => FORBIDDEN_TEST_MODIFIERS.has(part)) ? parts.join('.') : undefined
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function exactString(ts, expression, expected) {
|
|
688
|
+
return Boolean(expression && ts.isStringLiteralLike(expression) && expression.text === expected)
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function listenBindsLoopback(ts, call) {
|
|
692
|
+
const first = call.arguments[0]
|
|
693
|
+
if (first && ts.isObjectLiteralExpression(first)) {
|
|
694
|
+
if (first.properties.some((property) => ts.isSpreadAssignment(property))) return false
|
|
695
|
+
if (first.properties.some((property) => property.name && ts.isComputedPropertyName(property.name))) return false
|
|
696
|
+
const hosts = first.properties.filter((property) => propertyName(ts, property.name) === 'host')
|
|
697
|
+
return hosts.length === 1 && ts.isPropertyAssignment(hosts[0]) && exactString(ts, hosts[0].initializer, '127.0.0.1')
|
|
698
|
+
}
|
|
699
|
+
return exactString(ts, call.arguments[1], '127.0.0.1')
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function jsxTagName(ts, tag) {
|
|
703
|
+
if (ts.isIdentifier(tag)) return tag.text
|
|
704
|
+
if (ts.isPropertyAccessExpression(tag)) return tag.name.text
|
|
705
|
+
return tag.getText()
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function newFacts() {
|
|
709
|
+
return {
|
|
710
|
+
calls: new Map(),
|
|
711
|
+
callArgumentFacts: new Map(),
|
|
712
|
+
callArgumentExpressions: new Map(),
|
|
713
|
+
callOptions: new Map(),
|
|
714
|
+
classes: [],
|
|
715
|
+
declarations: new Set(),
|
|
716
|
+
decorators: new Set(),
|
|
717
|
+
exportedFunctions: new Map(),
|
|
718
|
+
exportedFunctionNodes: new Map(),
|
|
719
|
+
exportedObjectFacts: new Map(),
|
|
720
|
+
exportedVariables: new Set(),
|
|
721
|
+
functionFacts: new Map(),
|
|
722
|
+
functionNodes: new Map(),
|
|
723
|
+
functions: new Set(),
|
|
724
|
+
finallyBlocks: 0,
|
|
725
|
+
importedBindings: new Map(),
|
|
726
|
+
importSources: new Set(),
|
|
727
|
+
forbiddenTestModifiers: new Set(),
|
|
728
|
+
jsxLiteralAttributes: new Map(),
|
|
729
|
+
jsxAttributes: new Set(),
|
|
730
|
+
jsxTags: new Set(),
|
|
731
|
+
jsxText: [],
|
|
732
|
+
loops: 0,
|
|
733
|
+
listenBindings: [],
|
|
734
|
+
newCalls: new Set(),
|
|
735
|
+
nullNodes: 0,
|
|
736
|
+
objectProperties: new Set(),
|
|
737
|
+
referencedFunctions: new Set(),
|
|
738
|
+
objectFacts: new Map(),
|
|
739
|
+
propertyIdentifiers: new Map(),
|
|
740
|
+
propertyAccesses: new Set(),
|
|
741
|
+
strings: new Set(),
|
|
742
|
+
throwStatements: 0,
|
|
743
|
+
variables: new Map(),
|
|
744
|
+
variableExpressions: new Map(),
|
|
745
|
+
assignments: new Set(),
|
|
746
|
+
awaitedCalls: new Set(),
|
|
747
|
+
moduleEntries: [],
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isExportedFunction(ts, node) {
|
|
752
|
+
return Boolean(node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function isExportedVariable(ts, node) {
|
|
756
|
+
const statement = node.parent?.parent
|
|
757
|
+
return Boolean(statement && ts.isVariableStatement(statement) && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function addMappedValue(map, key, value) {
|
|
761
|
+
if (!map.has(key)) map.set(key, new Set())
|
|
762
|
+
map.get(key).add(value)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function jsxAttributeLiteral(ts, attribute) {
|
|
766
|
+
if (!attribute.initializer) return undefined
|
|
767
|
+
if (ts.isStringLiteral(attribute.initializer)) return attribute.initializer.text
|
|
768
|
+
if (ts.isJsxExpression(attribute.initializer) && attribute.initializer.expression && ts.isStringLiteralLike(attribute.initializer.expression)) {
|
|
769
|
+
return attribute.initializer.expression.text
|
|
770
|
+
}
|
|
771
|
+
return undefined
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function collectFunctionFact(ts, node, { skipNestedDeclarations = false, skipNestedFunctions = false } = {}) {
|
|
775
|
+
const fact = {
|
|
776
|
+
binaryOperators: new Set(),
|
|
777
|
+
calls: new Set(),
|
|
778
|
+
callOptions: new Map(),
|
|
779
|
+
conditionalExpressions: 0,
|
|
780
|
+
finallyBlocks: 0,
|
|
781
|
+
jsxAttributes: new Set(),
|
|
782
|
+
jsxLiteralAttributes: new Map(),
|
|
783
|
+
jsxTags: new Set(),
|
|
784
|
+
jsxText: [],
|
|
785
|
+
loops: 0,
|
|
786
|
+
nullNodes: 0,
|
|
787
|
+
newCalls: new Set(),
|
|
788
|
+
objectProperties: new Set(),
|
|
789
|
+
referencedFunctions: new Set(),
|
|
790
|
+
strings: new Set(),
|
|
791
|
+
throws: 0,
|
|
792
|
+
}
|
|
793
|
+
const root = node.body ?? node
|
|
794
|
+
const visit = (current) => {
|
|
795
|
+
if (skipNestedFunctions && current !== root && ts.isFunctionLike(current)) return
|
|
796
|
+
if (skipNestedDeclarations && current !== root && ts.isFunctionDeclaration(current)) return
|
|
797
|
+
if (ts.isImportDeclaration(current) || ts.isImportEqualsDeclaration(current) || ts.isExportDeclaration(current)) return
|
|
798
|
+
if (ts.isCallExpression(current)) {
|
|
799
|
+
const names = [expressionName(ts, current.expression), fullExpressionName(ts, current.expression)].filter(Boolean)
|
|
800
|
+
const optionNames = current.arguments.flatMap((argument) => ts.isObjectLiteralExpression(argument)
|
|
801
|
+
? argument.properties.map((property) => propertyName(ts, property.name)).filter(Boolean)
|
|
802
|
+
: [])
|
|
803
|
+
for (const name of names) {
|
|
804
|
+
fact.calls.add(name)
|
|
805
|
+
if (!fact.callOptions.has(name)) fact.callOptions.set(name, [])
|
|
806
|
+
fact.callOptions.get(name).push(new Set(optionNames))
|
|
807
|
+
}
|
|
808
|
+
for (const argument of current.arguments) {
|
|
809
|
+
const resolved = unwrapExpression(ts, argument)
|
|
810
|
+
if (resolved && ts.isIdentifier(resolved)) fact.referencedFunctions.add(resolved.text)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (ts.isNewExpression(current)) {
|
|
814
|
+
const name = expressionName(ts, current.expression)
|
|
815
|
+
if (name) fact.newCalls.add(name)
|
|
816
|
+
}
|
|
817
|
+
if (ts.isObjectLiteralElementLike(current)) {
|
|
818
|
+
const name = propertyName(ts, current.name)
|
|
819
|
+
if (name) fact.objectProperties.add(name)
|
|
820
|
+
}
|
|
821
|
+
if (ts.isPropertyAssignment(current) && ts.isIdentifier(current.initializer)) {
|
|
822
|
+
fact.referencedFunctions.add(current.initializer.text)
|
|
823
|
+
}
|
|
824
|
+
if (ts.isThrowStatement(current)) fact.throws += 1
|
|
825
|
+
if (ts.isBinaryExpression(current)) fact.binaryOperators.add(current.operatorToken.kind)
|
|
826
|
+
if (ts.isConditionalExpression(current)) fact.conditionalExpressions += 1
|
|
827
|
+
if (current.kind === ts.SyntaxKind.NullKeyword) fact.nullNodes += 1
|
|
828
|
+
if (ts.isStringLiteralLike(current) || ts.isNoSubstitutionTemplateLiteral(current)) fact.strings.add(current.text)
|
|
829
|
+
if (ts.isJsxText(current) && current.text.trim()) fact.jsxText.push(current.text.trim())
|
|
830
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current)) {
|
|
831
|
+
fact.jsxTags.add(jsxTagName(ts, current.tagName))
|
|
832
|
+
for (const attribute of current.attributes.properties) {
|
|
833
|
+
if (!ts.isJsxAttribute(attribute)) continue
|
|
834
|
+
const name = attribute.name.text
|
|
835
|
+
fact.jsxAttributes.add(name)
|
|
836
|
+
const literal = jsxAttributeLiteral(ts, attribute)
|
|
837
|
+
if (literal !== undefined) addMappedValue(fact.jsxLiteralAttributes, name, literal)
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (ts.isTryStatement(current) && current.finallyBlock) fact.finallyBlocks += 1
|
|
841
|
+
if (ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current)) {
|
|
842
|
+
fact.loops += 1
|
|
843
|
+
}
|
|
844
|
+
ts.forEachChild(current, visit)
|
|
845
|
+
}
|
|
846
|
+
visit(root)
|
|
847
|
+
return fact
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function addCall(facts, name, optionNames = []) {
|
|
851
|
+
facts.calls.set(name, (facts.calls.get(name) ?? 0) + 1)
|
|
852
|
+
if (!facts.callOptions.has(name)) facts.callOptions.set(name, [])
|
|
853
|
+
facts.callOptions.get(name).push(new Set(optionNames))
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function unwrapExpression(ts, node) {
|
|
857
|
+
let current = node
|
|
858
|
+
while (current && (
|
|
859
|
+
ts.isParenthesizedExpression(current)
|
|
860
|
+
|| ts.isAsExpression(current)
|
|
861
|
+
|| ts.isTypeAssertionExpression(current)
|
|
862
|
+
|| ts.isNonNullExpression(current)
|
|
863
|
+
|| (typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(current))
|
|
864
|
+
)) {
|
|
865
|
+
current = current.expression
|
|
866
|
+
}
|
|
867
|
+
return current
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function classMemberNames(ts, member) {
|
|
871
|
+
const names = new Set()
|
|
872
|
+
const identifier = propertyName(ts, member.name)
|
|
873
|
+
if (identifier) names.add(identifier)
|
|
874
|
+
const decorators = typeof ts.getDecorators === 'function' && ts.canHaveDecorators(member)
|
|
875
|
+
? ts.getDecorators(member) ?? []
|
|
876
|
+
: []
|
|
877
|
+
for (const decorator of decorators) {
|
|
878
|
+
if (!ts.isCallExpression(decorator.expression)) continue
|
|
879
|
+
if (expressionName(ts, decorator.expression.expression) !== 'Property') continue
|
|
880
|
+
for (const argument of decorator.expression.arguments) {
|
|
881
|
+
if (!ts.isObjectLiteralExpression(argument)) continue
|
|
882
|
+
for (const option of argument.properties) {
|
|
883
|
+
if (!ts.isPropertyAssignment(option)) continue
|
|
884
|
+
const optionName = propertyName(ts, option.name)
|
|
885
|
+
if (!['name', 'fieldName'].includes(optionName) || !ts.isStringLiteralLike(option.initializer)) continue
|
|
886
|
+
names.add(option.initializer.text)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return names
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function collectFacts(ts, sourceFiles) {
|
|
894
|
+
const facts = newFacts()
|
|
895
|
+
for (const file of sourceFiles) {
|
|
896
|
+
const source = ts.createSourceFile(
|
|
897
|
+
file,
|
|
898
|
+
fs.readFileSync(file, 'utf8'),
|
|
899
|
+
ts.ScriptTarget.Latest,
|
|
900
|
+
true,
|
|
901
|
+
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
902
|
+
)
|
|
903
|
+
const visit = (node) => {
|
|
904
|
+
if (ts.isImportDeclaration(node)) {
|
|
905
|
+
const source = node.moduleSpecifier.text
|
|
906
|
+
facts.importSources.add(source)
|
|
907
|
+
const clause = node.importClause
|
|
908
|
+
if (clause?.name) facts.importedBindings.set(clause.name.text, source)
|
|
909
|
+
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
910
|
+
for (const element of clause.namedBindings.elements) facts.importedBindings.set(element.name.text, source)
|
|
911
|
+
} else if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
|
|
912
|
+
facts.importedBindings.set(clause.namedBindings.name.text, source)
|
|
913
|
+
}
|
|
914
|
+
return
|
|
915
|
+
}
|
|
916
|
+
if (ts.isImportEqualsDeclaration(node) || ts.isExportDeclaration(node)) return
|
|
917
|
+
|
|
918
|
+
if (ts.isClassDeclaration(node)) {
|
|
919
|
+
const members = new Set(node.members.flatMap((member) => [...classMemberNames(ts, member)]))
|
|
920
|
+
const decorators = typeof ts.getDecorators === 'function' && ts.canHaveDecorators(node)
|
|
921
|
+
? (ts.getDecorators(node) ?? []).map((decorator) => expressionName(ts, decorator.expression.expression ?? decorator.expression)).filter(Boolean)
|
|
922
|
+
: []
|
|
923
|
+
facts.classes.push({ name: node.name?.text, members, decorators: new Set(decorators) })
|
|
924
|
+
if (node.name) facts.declarations.add(node.name.text)
|
|
925
|
+
for (const decorator of decorators) facts.decorators.add(decorator)
|
|
926
|
+
}
|
|
927
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
928
|
+
const functionFact = collectFunctionFact(ts, node)
|
|
929
|
+
facts.functions.add(node.name.text)
|
|
930
|
+
facts.declarations.add(node.name.text)
|
|
931
|
+
facts.functionFacts.set(node.name.text, functionFact)
|
|
932
|
+
facts.functionNodes.set(node.name.text, node)
|
|
933
|
+
if (isExportedFunction(ts, node)) {
|
|
934
|
+
facts.exportedFunctions.set(node.name.text, functionFact)
|
|
935
|
+
facts.exportedFunctionNodes.set(node.name.text, node)
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node)) {
|
|
939
|
+
facts.declarations.add(node.name.text)
|
|
940
|
+
}
|
|
941
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
|
|
942
|
+
facts.declarations.add(node.name.text)
|
|
943
|
+
if (isExportedVariable(ts, node)) facts.exportedVariables.add(node.name.text)
|
|
944
|
+
const properties = new Set()
|
|
945
|
+
const initializer = node.initializer ? unwrapExpression(ts, node.initializer) : undefined
|
|
946
|
+
if (initializer) facts.variableExpressions.set(node.name.text, initializer)
|
|
947
|
+
if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
|
|
948
|
+
const functionFact = collectFunctionFact(ts, initializer)
|
|
949
|
+
facts.functions.add(node.name.text)
|
|
950
|
+
facts.functionFacts.set(node.name.text, functionFact)
|
|
951
|
+
facts.functionNodes.set(node.name.text, initializer)
|
|
952
|
+
if (isExportedVariable(ts, node)) {
|
|
953
|
+
facts.exportedFunctions.set(node.name.text, functionFact)
|
|
954
|
+
facts.exportedFunctionNodes.set(node.name.text, initializer)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (initializer && ts.isObjectLiteralExpression(initializer)) {
|
|
958
|
+
const objectFact = collectFunctionFact(ts, initializer)
|
|
959
|
+
facts.objectFacts.set(node.name.text, objectFact)
|
|
960
|
+
if (isExportedVariable(ts, node)) facts.exportedObjectFacts.set(node.name.text, objectFact)
|
|
961
|
+
for (const property of initializer.properties) {
|
|
962
|
+
const name = propertyName(ts, property.name)
|
|
963
|
+
if (name) properties.add(name)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
facts.variables.set(node.name.text, properties)
|
|
967
|
+
if (node.name.text === 'enabledModules' && node.initializer && ts.isArrayLiteralExpression(node.initializer)) {
|
|
968
|
+
for (const element of node.initializer.elements) {
|
|
969
|
+
if (!ts.isObjectLiteralExpression(element)) continue
|
|
970
|
+
const entry = {}
|
|
971
|
+
for (const property of element.properties) {
|
|
972
|
+
if (!ts.isPropertyAssignment(property)) continue
|
|
973
|
+
const name = propertyName(ts, property.name)
|
|
974
|
+
if (!name || !ts.isStringLiteralLike(property.initializer)) continue
|
|
975
|
+
entry[name] = property.initializer.text
|
|
976
|
+
}
|
|
977
|
+
facts.moduleEntries.push(entry)
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
if (ts.isCallExpression(node)) {
|
|
982
|
+
const name = expressionName(ts, node.expression)
|
|
983
|
+
const fullName = fullExpressionName(ts, node.expression)
|
|
984
|
+
if (fullName === 'enabledModules.push') {
|
|
985
|
+
for (const argument of node.arguments) {
|
|
986
|
+
if (!ts.isObjectLiteralExpression(argument)) continue
|
|
987
|
+
const entry = {}
|
|
988
|
+
for (const property of argument.properties) {
|
|
989
|
+
if (!ts.isPropertyAssignment(property)) continue
|
|
990
|
+
const propertyKey = propertyName(ts, property.name)
|
|
991
|
+
if (!propertyKey || !ts.isStringLiteralLike(property.initializer)) continue
|
|
992
|
+
entry[propertyKey] = property.initializer.text
|
|
993
|
+
}
|
|
994
|
+
facts.moduleEntries.push(entry)
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
const forbiddenModifier = forbiddenTestModifier(ts, node.expression)
|
|
998
|
+
if (forbiddenModifier) facts.forbiddenTestModifiers.add(forbiddenModifier)
|
|
999
|
+
const callPath = expressionPath(ts, node.expression)
|
|
1000
|
+
if (callPath.includes('listen')) facts.listenBindings.push(callPath.at(-1) === 'listen' && listenBindsLoopback(ts, node))
|
|
1001
|
+
const optionNames = node.arguments.flatMap((argument) => ts.isObjectLiteralExpression(argument)
|
|
1002
|
+
? argument.properties.map((property) => propertyName(ts, property.name)).filter(Boolean)
|
|
1003
|
+
: [])
|
|
1004
|
+
if (name) {
|
|
1005
|
+
const expressions = facts.callArgumentExpressions.get(name) ?? []
|
|
1006
|
+
expressions.push(...node.arguments)
|
|
1007
|
+
facts.callArgumentExpressions.set(name, expressions)
|
|
1008
|
+
const argumentFacts = node.arguments
|
|
1009
|
+
.map((argument) => unwrapExpression(ts, argument))
|
|
1010
|
+
.filter((argument) => ts.isObjectLiteralExpression(argument) || ts.isArrayLiteralExpression(argument))
|
|
1011
|
+
.map((argument) => collectFunctionFact(ts, argument))
|
|
1012
|
+
if (argumentFacts.length > 0) {
|
|
1013
|
+
const existing = facts.callArgumentFacts.get(name) ?? []
|
|
1014
|
+
existing.push(...argumentFacts)
|
|
1015
|
+
facts.callArgumentFacts.set(name, existing)
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (name) addCall(facts, name, optionNames)
|
|
1019
|
+
if (fullName && fullName !== name) addCall(facts, fullName, optionNames)
|
|
1020
|
+
if (node.parent && ts.isAwaitExpression(node.parent) && name) facts.awaitedCalls.add(name)
|
|
1021
|
+
}
|
|
1022
|
+
if (ts.isNewExpression(node)) {
|
|
1023
|
+
const name = expressionName(ts, node.expression)
|
|
1024
|
+
if (name) facts.newCalls.add(name)
|
|
1025
|
+
}
|
|
1026
|
+
if (ts.isObjectLiteralElementLike(node)) {
|
|
1027
|
+
const name = propertyName(ts, node.name)
|
|
1028
|
+
if (name) facts.objectProperties.add(name)
|
|
1029
|
+
}
|
|
1030
|
+
if (ts.isPropertyAssignment(node)) {
|
|
1031
|
+
const name = propertyName(ts, node.name)
|
|
1032
|
+
if (name && ts.isIdentifier(node.initializer)) addMappedValue(facts.propertyIdentifiers, name, node.initializer.text)
|
|
1033
|
+
}
|
|
1034
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
1035
|
+
facts.propertyAccesses.add(node.name.text)
|
|
1036
|
+
const forbiddenModifier = forbiddenTestModifier(ts, node)
|
|
1037
|
+
if (forbiddenModifier) facts.forbiddenTestModifiers.add(forbiddenModifier)
|
|
1038
|
+
}
|
|
1039
|
+
if (ts.isElementAccessExpression(node)) {
|
|
1040
|
+
const forbiddenModifier = forbiddenTestModifier(ts, node)
|
|
1041
|
+
if (forbiddenModifier) facts.forbiddenTestModifiers.add(forbiddenModifier)
|
|
1042
|
+
}
|
|
1043
|
+
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) facts.strings.add(node.text)
|
|
1044
|
+
if (node.kind === ts.SyntaxKind.NullKeyword) facts.nullNodes += 1
|
|
1045
|
+
if (ts.isThrowStatement(node)) facts.throwStatements += 1
|
|
1046
|
+
if (ts.isTryStatement(node) && node.finallyBlock) facts.finallyBlocks += 1
|
|
1047
|
+
if (ts.isForStatement(node) || ts.isForInStatement(node) || ts.isForOfStatement(node) || ts.isWhileStatement(node) || ts.isDoStatement(node)) {
|
|
1048
|
+
facts.loops += 1
|
|
1049
|
+
}
|
|
1050
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
1051
|
+
const name = expressionName(ts, node.left)
|
|
1052
|
+
if (name) facts.assignments.add(name)
|
|
1053
|
+
}
|
|
1054
|
+
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
1055
|
+
facts.jsxTags.add(jsxTagName(ts, node.tagName))
|
|
1056
|
+
for (const attribute of node.attributes.properties) {
|
|
1057
|
+
if (!ts.isJsxAttribute(attribute)) continue
|
|
1058
|
+
const name = attribute.name.text
|
|
1059
|
+
facts.jsxAttributes.add(name)
|
|
1060
|
+
const literal = jsxAttributeLiteral(ts, attribute)
|
|
1061
|
+
if (literal !== undefined) addMappedValue(facts.jsxLiteralAttributes, name, literal)
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (ts.isJsxText(node) && node.text.trim()) facts.jsxText.push(node.text.trim())
|
|
1065
|
+
ts.forEachChild(node, visit)
|
|
1066
|
+
}
|
|
1067
|
+
visit(source)
|
|
1068
|
+
}
|
|
1069
|
+
return facts
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function hasCall(facts, ...names) {
|
|
1073
|
+
return names.some((name) => (facts.calls.get(name) ?? 0) > 0)
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function hasOnlyEnabledTests(facts) {
|
|
1077
|
+
return facts.forbiddenTestModifiers.size === 0
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function allServersBindLoopback(facts) {
|
|
1081
|
+
return facts.listenBindings.length > 0 && facts.listenBindings.every(Boolean)
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function hasCallOptions(facts, callName, required) {
|
|
1085
|
+
return (facts.callOptions.get(callName) ?? []).some((options) => required.every((name) => options.has(name)))
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function hasObjectVariable(facts, variableName, required) {
|
|
1089
|
+
const properties = facts.variables.get(variableName)
|
|
1090
|
+
return Boolean(properties && required.every((name) => properties.has(name)))
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
export function hasExactString(facts, value) {
|
|
1094
|
+
return facts.strings.has(value)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// SQL migrations arrive as one template-literal string, so containment is the
|
|
1098
|
+
// only way to assert a clause without hard-coding the author's formatting.
|
|
1099
|
+
function hasStringIncluding(facts, ...fragments) {
|
|
1100
|
+
return [...facts.strings].some((entry) => fragments.every((fragment) => entry.includes(fragment)))
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function hasStringPrefix(facts, value) {
|
|
1104
|
+
return [...facts.strings].some((entry) => entry.startsWith(value))
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function factReachesCalls(facts, start, requiredCalls) {
|
|
1108
|
+
if (!start) return false
|
|
1109
|
+
const reached = new Set(start.calls)
|
|
1110
|
+
const pending = [...start.calls, ...start.referencedFunctions]
|
|
1111
|
+
const visited = new Set()
|
|
1112
|
+
while (pending.length) {
|
|
1113
|
+
const name = pending.pop()
|
|
1114
|
+
if (visited.has(name)) continue
|
|
1115
|
+
visited.add(name)
|
|
1116
|
+
const local = facts.functionFacts.get(name)
|
|
1117
|
+
if (!local) continue
|
|
1118
|
+
for (const call of local.calls) {
|
|
1119
|
+
reached.add(call)
|
|
1120
|
+
pending.push(call)
|
|
1121
|
+
}
|
|
1122
|
+
for (const reference of local.referencedFunctions) pending.push(reference)
|
|
1123
|
+
}
|
|
1124
|
+
return requiredCalls.every((name) => reached.has(name))
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function reachableFunctionFacts(facts, start) {
|
|
1128
|
+
if (!start) return []
|
|
1129
|
+
const reached = [start]
|
|
1130
|
+
const pending = [...start.calls, ...start.referencedFunctions]
|
|
1131
|
+
const visited = new Set()
|
|
1132
|
+
while (pending.length) {
|
|
1133
|
+
const name = pending.pop()
|
|
1134
|
+
if (visited.has(name)) continue
|
|
1135
|
+
visited.add(name)
|
|
1136
|
+
const local = facts.functionFacts.get(name)
|
|
1137
|
+
if (!local) continue
|
|
1138
|
+
reached.push(local)
|
|
1139
|
+
for (const call of local.calls) pending.push(call)
|
|
1140
|
+
for (const reference of local.referencedFunctions) pending.push(reference)
|
|
1141
|
+
}
|
|
1142
|
+
return reached
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function factReachesContract(facts, start, predicate) {
|
|
1146
|
+
return reachableFunctionFacts(facts, start).some(predicate)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function callHasArgumentContract(facts, callName, predicate) {
|
|
1150
|
+
return (facts.callArgumentFacts.get(callName) ?? []).some(predicate)
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function resolveExpression(ts, facts, node, seen = new Set()) {
|
|
1154
|
+
const expression = unwrapExpression(ts, node)
|
|
1155
|
+
if (!expression || !ts.isIdentifier(expression) || seen.has(expression.text)) return expression
|
|
1156
|
+
const target = facts.variableExpressions.get(expression.text)
|
|
1157
|
+
if (!target) return expression
|
|
1158
|
+
const nextSeen = new Set(seen)
|
|
1159
|
+
nextSeen.add(expression.text)
|
|
1160
|
+
return resolveExpression(ts, facts, target, nextSeen)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function objectProperty(ts, object, name) {
|
|
1164
|
+
if (!object || !ts.isObjectLiteralExpression(object)) return undefined
|
|
1165
|
+
return object.properties.find((property) => propertyName(ts, property.name) === name)
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function propertyExpression(ts, facts, object, name) {
|
|
1169
|
+
const property = objectProperty(ts, object, name)
|
|
1170
|
+
if (!property) return undefined
|
|
1171
|
+
if (ts.isPropertyAssignment(property)) return resolveExpression(ts, facts, property.initializer)
|
|
1172
|
+
if (ts.isShorthandPropertyAssignment(property)) return resolveExpression(ts, facts, property.name)
|
|
1173
|
+
return undefined
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function propertyFunction(ts, facts, object, name) {
|
|
1177
|
+
const property = objectProperty(ts, object, name)
|
|
1178
|
+
if (!property) return undefined
|
|
1179
|
+
if (ts.isMethodDeclaration(property)) return property
|
|
1180
|
+
if (!ts.isPropertyAssignment(property)) return undefined
|
|
1181
|
+
const expression = resolveExpression(ts, facts, property.initializer)
|
|
1182
|
+
return expression && (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression))
|
|
1183
|
+
? expression
|
|
1184
|
+
: undefined
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function stringValue(ts, facts, node) {
|
|
1188
|
+
const expression = resolveExpression(ts, facts, node)
|
|
1189
|
+
return expression && ts.isStringLiteralLike(expression) ? expression.text : undefined
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function booleanValue(ts, facts, node) {
|
|
1193
|
+
const expression = resolveExpression(ts, facts, node)
|
|
1194
|
+
if (!expression) return undefined
|
|
1195
|
+
if (expression.kind === ts.SyntaxKind.TrueKeyword) return true
|
|
1196
|
+
if (expression.kind === ts.SyntaxKind.FalseKeyword) return false
|
|
1197
|
+
return undefined
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function numberValue(ts, facts, node) {
|
|
1201
|
+
const expression = resolveExpression(ts, facts, node)
|
|
1202
|
+
return expression && ts.isNumericLiteral(expression) ? Number(expression.text) : undefined
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function arrayElements(ts, facts, node) {
|
|
1206
|
+
const expression = resolveExpression(ts, facts, node)
|
|
1207
|
+
return expression && ts.isArrayLiteralExpression(expression)
|
|
1208
|
+
? expression.elements.map((element) => resolveExpression(ts, facts, element)).filter(Boolean)
|
|
1209
|
+
: []
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function objectPropertyString(ts, facts, object, name) {
|
|
1213
|
+
return stringValue(ts, facts, propertyExpression(ts, facts, object, name))
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function objectPropertyObject(ts, facts, object, name) {
|
|
1217
|
+
const expression = propertyExpression(ts, facts, object, name)
|
|
1218
|
+
return expression && ts.isObjectLiteralExpression(expression) ? expression : undefined
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function objectPropertyArray(ts, facts, object, name) {
|
|
1222
|
+
return arrayElements(ts, facts, propertyExpression(ts, facts, object, name))
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function resolvedCallArguments(ts, facts, callName) {
|
|
1226
|
+
return (facts.callArgumentExpressions.get(callName) ?? [])
|
|
1227
|
+
.map((argument) => resolveExpression(ts, facts, argument))
|
|
1228
|
+
.filter(Boolean)
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function functionContractFacts(ts, facts, node, options) {
|
|
1232
|
+
const direct = collectFunctionFact(ts, node, { skipNestedDeclarations: true, ...options })
|
|
1233
|
+
if (options?.skipNestedFunctions) {
|
|
1234
|
+
const reached = [direct]
|
|
1235
|
+
const pending = [...direct.calls, ...direct.referencedFunctions]
|
|
1236
|
+
const visited = new Set()
|
|
1237
|
+
while (pending.length) {
|
|
1238
|
+
const name = pending.pop()
|
|
1239
|
+
if (visited.has(name)) continue
|
|
1240
|
+
visited.add(name)
|
|
1241
|
+
const target = facts.functionNodes.get(name)
|
|
1242
|
+
if (!target) continue
|
|
1243
|
+
const local = collectFunctionFact(ts, target, { skipNestedDeclarations: true, ...options })
|
|
1244
|
+
reached.push(local)
|
|
1245
|
+
for (const call of local.calls) pending.push(call)
|
|
1246
|
+
for (const reference of local.referencedFunctions) pending.push(reference)
|
|
1247
|
+
}
|
|
1248
|
+
return reached
|
|
1249
|
+
}
|
|
1250
|
+
return reachableFunctionFacts(facts, direct)
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function returnedObjectFunctions(ts, facts, node) {
|
|
1254
|
+
const returned = []
|
|
1255
|
+
const root = node.body ?? node
|
|
1256
|
+
const collectObjectMethods = (expression) => {
|
|
1257
|
+
const object = resolveExpression(ts, facts, expression)
|
|
1258
|
+
if (!object || !ts.isObjectLiteralExpression(object)) return
|
|
1259
|
+
for (const property of object.properties) {
|
|
1260
|
+
const name = propertyName(ts, property.name)
|
|
1261
|
+
const method = name ? propertyFunction(ts, facts, object, name) : undefined
|
|
1262
|
+
if (method) returned.push(method)
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (ts.isObjectLiteralExpression(root)) collectObjectMethods(root)
|
|
1266
|
+
const visit = (current) => {
|
|
1267
|
+
if (current !== root && ts.isFunctionLike(current)) return
|
|
1268
|
+
if (ts.isReturnStatement(current) && current.expression) {
|
|
1269
|
+
collectObjectMethods(current.expression)
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
ts.forEachChild(current, visit)
|
|
1273
|
+
}
|
|
1274
|
+
visit(root)
|
|
1275
|
+
return returned
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function functionContractCalls(ts, facts, node, requiredCalls) {
|
|
1279
|
+
const reached = functionContractFacts(ts, facts, node)
|
|
1280
|
+
const calls = new Set(reached.flatMap((fact) => [...fact.calls]))
|
|
1281
|
+
return requiredCalls.every((name) => calls.has(name))
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function functionContractMatches(ts, facts, node, predicate) {
|
|
1285
|
+
return functionContractFacts(ts, facts, node).some(predicate)
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function functionHasLiteralTrueOption(ts, facts, node, callName, optionName) {
|
|
1289
|
+
const functionNodes = [node]
|
|
1290
|
+
const pending = [...collectFunctionFact(ts, node, { skipNestedDeclarations: true }).calls]
|
|
1291
|
+
const visited = new Set()
|
|
1292
|
+
while (pending.length) {
|
|
1293
|
+
const name = pending.pop()
|
|
1294
|
+
if (visited.has(name)) continue
|
|
1295
|
+
visited.add(name)
|
|
1296
|
+
const target = facts.functionNodes.get(name)
|
|
1297
|
+
if (!target) continue
|
|
1298
|
+
functionNodes.push(target)
|
|
1299
|
+
for (const call of collectFunctionFact(ts, target, { skipNestedDeclarations: true }).calls) pending.push(call)
|
|
1300
|
+
}
|
|
1301
|
+
return functionNodes.some((functionNode) => {
|
|
1302
|
+
let matched = false
|
|
1303
|
+
const root = functionNode.body ?? functionNode
|
|
1304
|
+
const visit = (current) => {
|
|
1305
|
+
if (matched) return
|
|
1306
|
+
if (current !== root && ts.isFunctionDeclaration(current)) return
|
|
1307
|
+
if (ts.isCallExpression(current) && expressionName(ts, current.expression) === callName) {
|
|
1308
|
+
for (const argument of current.arguments) {
|
|
1309
|
+
const object = resolveExpression(ts, facts, argument)
|
|
1310
|
+
const value = object && ts.isObjectLiteralExpression(object)
|
|
1311
|
+
? propertyExpression(ts, facts, object, optionName)
|
|
1312
|
+
: undefined
|
|
1313
|
+
if (booleanValue(ts, facts, value) === true) {
|
|
1314
|
+
matched = true
|
|
1315
|
+
return
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
ts.forEachChild(current, visit)
|
|
1320
|
+
}
|
|
1321
|
+
visit(root)
|
|
1322
|
+
return matched
|
|
1323
|
+
})
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function exportedFunctionCalls(facts, functionName, requiredCalls) {
|
|
1327
|
+
return factReachesCalls(facts, facts.exportedFunctions.get(functionName), requiredCalls)
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
function exportedObjectCalls(facts, variableName, requiredCalls) {
|
|
1331
|
+
return factReachesCalls(facts, facts.exportedObjectFacts.get(variableName), requiredCalls)
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function exportedCommandObjectCalls(facts, variableName, requiredCalls) {
|
|
1335
|
+
return [variableName, `${variableName}Command`].some((name) => exportedObjectCalls(facts, name, requiredCalls))
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function exportedFunctionHasCallOptions(facts, functionName, callName, requiredOptions) {
|
|
1339
|
+
const fact = facts.exportedFunctions.get(functionName)
|
|
1340
|
+
return Boolean(fact && (fact.callOptions.get(callName) ?? []).some((options) => requiredOptions.every((name) => options.has(name))))
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function importsSharedComponent(facts, name) {
|
|
1344
|
+
return facts.importedBindings.get(name)?.startsWith('@open-mercato/ui') === true
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function uiPolicyFailures(facts, { allowFormTag = false } = {}) {
|
|
1348
|
+
const rawTags = ['button', 'input', 'select', 'textarea', 'svg', ...(allowFormTag ? [] : ['form'])]
|
|
1349
|
+
const failures = []
|
|
1350
|
+
for (const tag of rawTags) if (facts.jsxTags.has(tag)) failures.push(`raw <${tag}>`)
|
|
1351
|
+
if (facts.jsxAttributes.has('style')) failures.push('inline style')
|
|
1352
|
+
const strings = [...facts.strings]
|
|
1353
|
+
if (strings.some((value) => /(?:^|\s)(?:[a-z-]+:)*(?:text|bg|border|ring)-(?:red|green|emerald|blue|amber|orange|yellow|rose|lime|cyan|teal|indigo|violet|purple|pink)-\d{2,3}(?:\/\d+)?\b/.test(value))) {
|
|
1354
|
+
failures.push('hard-coded palette class')
|
|
1355
|
+
}
|
|
1356
|
+
if (strings.some((value) => /(?:^|\s)\S*\[[^\]]+\]/.test(value))) failures.push('arbitrary Tailwind value')
|
|
1357
|
+
if (strings.some((value) => /(?:^|\s)dark:/.test(value))) failures.push('manual dark-mode override')
|
|
1358
|
+
for (const name of ['label', 'title', 'placeholder', 'aria-label', 'alt']) {
|
|
1359
|
+
if ((facts.jsxLiteralAttributes.get(name)?.size ?? 0) > 0) failures.push(`hard-coded ${name}`)
|
|
1360
|
+
}
|
|
1361
|
+
if (facts.jsxText.length > 0) failures.push('hard-coded JSX copy')
|
|
1362
|
+
return failures
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function renderedUiChecks(definition, facts) {
|
|
1366
|
+
const render = facts.exportedFunctions.get(definition.render)
|
|
1367
|
+
const handler = facts.exportedFunctions.get(definition.handler)
|
|
1368
|
+
const policyFailures = uiPolicyFailures(facts, { allowFormTag: definition.allowFormTag })
|
|
1369
|
+
const strings = [...(render?.strings ?? [])]
|
|
1370
|
+
return [
|
|
1371
|
+
check('business.ui-rendered-components', Boolean(render) && definition.components.every((name) => render.jsxTags.has(name) && importsSharedComponent(facts, name)), `exported ${definition.render} renders the required shared Open Mercato components`),
|
|
1372
|
+
check('business.ui-event-link', Boolean(render) && render.jsxAttributes.has(definition.event) && render.calls.has(definition.handler) && handler?.calls.has(definition.seam), `rendered ${definition.event} reaches ${definition.handler} and the tested ${definition.seam} seam`),
|
|
1373
|
+
check('business.ui-localized', Boolean(render) && render.calls.has('useT') && render.calls.has('t') && render.jsxText.length === 0, `exported ${definition.render} obtains all visible copy through useT`),
|
|
1374
|
+
check('business.ui-accessible-feedback', Boolean(render) && render.jsxAttributes.has('aria-live') && render.jsxTags.has('Alert') && render.jsxAttributes.has('status'), `exported ${definition.render} renders an accessible shared Alert status region`),
|
|
1375
|
+
check('business.ui-responsive', strings.some((value) => /(?:^|\s)(?:sm|md|lg|xl|2xl):/.test(value)), `exported ${definition.render} includes a standard responsive breakpoint`),
|
|
1376
|
+
check('business.ui-semantic-token', strings.some((value) => /(?:^|\s)(?:text-(?:foreground|muted-foreground)|bg-(?:background|muted)|border-border)\b/.test(value)), `exported ${definition.render} uses semantic design tokens`),
|
|
1377
|
+
check('business.ui-metadata', hasObjectVariable(facts, 'metadata', definition.metadata), `the sibling page metadata declares ${definition.metadata.join(' and ')}`),
|
|
1378
|
+
check('business.ui-policy', policyFailures.length === 0, `case-owned UI avoids raw controls/forms, inline SVG/style, hard-coded copy/colors, arbitrary Tailwind, and manual dark overrides${policyFailures.length ? ` (${policyFailures.join(', ')})` : ''}`),
|
|
1379
|
+
]
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function check(id, passed, requirement) {
|
|
1383
|
+
return { id, passed: Boolean(passed), requirement }
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function routeMetadataPath(ts, sourceFile) {
|
|
1387
|
+
const source = ts.createSourceFile(
|
|
1388
|
+
sourceFile,
|
|
1389
|
+
fs.readFileSync(sourceFile, 'utf8'),
|
|
1390
|
+
ts.ScriptTarget.Latest,
|
|
1391
|
+
true,
|
|
1392
|
+
sourceFile.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
1393
|
+
)
|
|
1394
|
+
for (const statement of source.statements) {
|
|
1395
|
+
if (!ts.isVariableStatement(statement)) continue
|
|
1396
|
+
if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
|
|
1397
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1398
|
+
if (!ts.isIdentifier(declaration.name) || declaration.name.text !== 'metadata') continue
|
|
1399
|
+
const initializer = unwrapExpression(ts, declaration.initializer)
|
|
1400
|
+
if (!initializer || !ts.isObjectLiteralExpression(initializer)) continue
|
|
1401
|
+
for (const property of initializer.properties) {
|
|
1402
|
+
if (!ts.isPropertyAssignment(property) || propertyName(ts, property.name) !== 'path') continue
|
|
1403
|
+
const value = unwrapExpression(ts, property.initializer)
|
|
1404
|
+
if (value && ts.isStringLiteralLike(value)) return value.text
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
return undefined
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function normalizeRoutePath(value) {
|
|
1412
|
+
const normalizedSegments = value
|
|
1413
|
+
.replaceAll('\\', '/')
|
|
1414
|
+
.split('/')
|
|
1415
|
+
.filter((segment) => segment && !(segment.startsWith('(') && segment.endsWith(')')) && !segment.startsWith('@'))
|
|
1416
|
+
.map((segment) => {
|
|
1417
|
+
if (/^\[\[\.\.\.[^\]]+\]\]$/.test(segment)) return '[[...]]'
|
|
1418
|
+
if (/^\[\.\.\.[^\]]+\]$/.test(segment)) return '[...]'
|
|
1419
|
+
if (/^\[[^\]]+\]$/.test(segment)) return '[]'
|
|
1420
|
+
return segment
|
|
1421
|
+
})
|
|
1422
|
+
return `/${normalizedSegments.join('/')}`
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function sourceHttpMethods(ts, sourceFile) {
|
|
1426
|
+
const source = ts.createSourceFile(sourceFile, fs.readFileSync(sourceFile, 'utf8'), ts.ScriptTarget.Latest, true)
|
|
1427
|
+
const methods = new Set()
|
|
1428
|
+
for (const statement of source.statements) {
|
|
1429
|
+
if (ts.isFunctionDeclaration(statement) && statement.name && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
1430
|
+
if (['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(statement.name.text)) methods.add(statement.name.text)
|
|
1431
|
+
}
|
|
1432
|
+
if (!ts.isVariableStatement(statement) || !statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
|
|
1433
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1434
|
+
if (ts.isIdentifier(declaration.name) && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(declaration.name.text)) methods.add(declaration.name.text)
|
|
1435
|
+
if (!ts.isObjectBindingPattern(declaration.name)) continue
|
|
1436
|
+
for (const element of declaration.name.elements) {
|
|
1437
|
+
const name = element.propertyName ?? element.name
|
|
1438
|
+
if (ts.isIdentifier(name) && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(name.text)) methods.add(name.text)
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return methods
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function pageRoutePath(moduleId, surface, pageFile) {
|
|
1446
|
+
const surfaceRoot = path.join('src', 'modules', moduleId, surface)
|
|
1447
|
+
const relative = path.relative(surfaceRoot, pageFile).replaceAll(path.sep, '/')
|
|
1448
|
+
const segments = relative.split('/')
|
|
1449
|
+
const file = segments.pop()
|
|
1450
|
+
const modern = file === 'page.ts' || file === 'page.tsx'
|
|
1451
|
+
const routeSegments = modern ? segments : [...segments, file.replace(/\.(?:ts|tsx)$/, '')]
|
|
1452
|
+
if (surface === 'frontend') return normalizeRoutePath(`/${routeSegments.join('/')}`)
|
|
1453
|
+
const backendSegments = modern
|
|
1454
|
+
? routeSegments
|
|
1455
|
+
: routeSegments[0] === moduleId ? routeSegments : [moduleId, ...routeSegments]
|
|
1456
|
+
return normalizeRoutePath(`/backend/${backendSegments.length ? backendSegments.join('/') : moduleId}`)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function installedFactRoutes(root) {
|
|
1460
|
+
const factsFile = path.join(root, '.ai', 'guides', 'module-facts.json')
|
|
1461
|
+
const factsStat = safeTargetEntry(root, factsFile)
|
|
1462
|
+
if (!factsStat) return []
|
|
1463
|
+
if (!factsStat.isFile()) throw new Error('installed module facts must be a regular file')
|
|
1464
|
+
const facts = JSON.parse(fs.readFileSync(factsFile, 'utf8'))
|
|
1465
|
+
if (!facts || typeof facts !== 'object' || Array.isArray(facts)) {
|
|
1466
|
+
throw new Error('installed module facts must contain a module object')
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const routes = []
|
|
1470
|
+
for (const [moduleId, moduleFacts] of Object.entries(facts)) {
|
|
1471
|
+
if (!moduleFacts || typeof moduleFacts !== 'object' || Array.isArray(moduleFacts)) continue
|
|
1472
|
+
const sourceRoot = typeof moduleFacts.sourceRoot === 'string'
|
|
1473
|
+
? moduleFacts.sourceRoot
|
|
1474
|
+
: `.ai/guides/module-facts.json#${moduleId}`
|
|
1475
|
+
for (const apiRoute of Array.isArray(moduleFacts.apiRoutes) ? moduleFacts.apiRoutes : []) {
|
|
1476
|
+
if (!apiRoute || typeof apiRoute !== 'object' || typeof apiRoute.path !== 'string') continue
|
|
1477
|
+
const methods = new Set(
|
|
1478
|
+
(Array.isArray(apiRoute.methods) ? apiRoute.methods : [])
|
|
1479
|
+
.filter((method) => typeof method === 'string')
|
|
1480
|
+
.map((method) => method.toUpperCase()),
|
|
1481
|
+
)
|
|
1482
|
+
if (!methods.size) continue
|
|
1483
|
+
routes.push({
|
|
1484
|
+
surface: 'api',
|
|
1485
|
+
routePath: normalizeRoutePath(apiRoute.path),
|
|
1486
|
+
sourceFile: undefined,
|
|
1487
|
+
displayPath: typeof apiRoute.sourcePath === 'string' ? apiRoute.sourcePath : `${sourceRoot}#api:${apiRoute.path}`,
|
|
1488
|
+
methods,
|
|
1489
|
+
origin: 'installed',
|
|
1490
|
+
})
|
|
1491
|
+
}
|
|
1492
|
+
for (const [surface, factKey] of [['backend', 'backendPages'], ['frontend', 'frontendPages']]) {
|
|
1493
|
+
for (const page of Array.isArray(moduleFacts[factKey]) ? moduleFacts[factKey] : []) {
|
|
1494
|
+
if (!page || typeof page !== 'object' || typeof page.path !== 'string') continue
|
|
1495
|
+
routes.push({
|
|
1496
|
+
surface,
|
|
1497
|
+
routePath: normalizeRoutePath(page.path),
|
|
1498
|
+
sourceFile: undefined,
|
|
1499
|
+
displayPath: typeof page.sourcePath === 'string' ? page.sourcePath : `${sourceRoot}#${surface}:${page.path}`,
|
|
1500
|
+
methods: new Set(),
|
|
1501
|
+
origin: 'installed',
|
|
1502
|
+
})
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return routes
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function duplicateRouteChecks(ts, root) {
|
|
1510
|
+
const modulesRoot = path.join(root, 'src', 'modules')
|
|
1511
|
+
const moduleStat = safeTargetEntry(root, modulesRoot)
|
|
1512
|
+
if (!moduleStat?.isDirectory()) return []
|
|
1513
|
+
const routes = []
|
|
1514
|
+
for (const moduleEntry of fs.readdirSync(modulesRoot, { withFileTypes: true })) {
|
|
1515
|
+
if (!moduleEntry.isDirectory() || moduleEntry.name.startsWith('.')) continue
|
|
1516
|
+
for (const surface of ['api', 'backend', 'frontend']) {
|
|
1517
|
+
const surfaceRoot = path.join(modulesRoot, moduleEntry.name, surface)
|
|
1518
|
+
const files = collectSourceFiles(root, [path.relative(root, surfaceRoot)])
|
|
1519
|
+
for (const sourceFile of files) {
|
|
1520
|
+
const relative = path.relative(surfaceRoot, sourceFile).replaceAll(path.sep, '/')
|
|
1521
|
+
if (/\.(?:test|spec)\.(?:ts|tsx)$/.test(relative) || /(?:^|\/)page\.meta\.(?:ts|tsx)$/.test(relative) || /(?:^|\/)[^/]+\.meta\.(?:ts|tsx)$/.test(relative)) continue
|
|
1522
|
+
if (surface === 'api') {
|
|
1523
|
+
const segments = relative.split('/')
|
|
1524
|
+
const file = segments.pop()
|
|
1525
|
+
const legacyMethod = ['get', 'post', 'put', 'patch', 'delete'].includes(segments[0]) ? segments.shift().toUpperCase() : undefined
|
|
1526
|
+
const modern = file === 'route.ts' || file === 'route.tsx'
|
|
1527
|
+
const routeSegments = modern ? segments : [...segments, file.replace(/\.(?:ts|tsx)$/, '')]
|
|
1528
|
+
const metadataPath = routeMetadataPath(ts, sourceFile)
|
|
1529
|
+
const routePath = normalizeRoutePath(metadataPath ?? `/${[moduleEntry.name, ...routeSegments].join('/')}`)
|
|
1530
|
+
const methods = legacyMethod ? new Set([legacyMethod]) : sourceHttpMethods(ts, sourceFile)
|
|
1531
|
+
if (methods.size) routes.push({
|
|
1532
|
+
surface,
|
|
1533
|
+
routePath,
|
|
1534
|
+
sourceFile,
|
|
1535
|
+
displayPath: path.relative(root, sourceFile).replaceAll(path.sep, '/'),
|
|
1536
|
+
methods,
|
|
1537
|
+
origin: 'app',
|
|
1538
|
+
})
|
|
1539
|
+
continue
|
|
1540
|
+
}
|
|
1541
|
+
if (!/(?:^|\/)page\.(?:ts|tsx)$/.test(relative) && !/^[^/]+\.(?:ts|tsx)$/.test(relative)) continue
|
|
1542
|
+
routes.push({
|
|
1543
|
+
surface,
|
|
1544
|
+
routePath: pageRoutePath(moduleEntry.name, surface, sourceFile),
|
|
1545
|
+
sourceFile,
|
|
1546
|
+
displayPath: path.relative(root, sourceFile).replaceAll(path.sep, '/'),
|
|
1547
|
+
methods: new Set(),
|
|
1548
|
+
origin: 'app',
|
|
1549
|
+
})
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
routes.push(...installedFactRoutes(root))
|
|
1554
|
+
|
|
1555
|
+
const duplicateGroups = new Map()
|
|
1556
|
+
for (const route of routes) {
|
|
1557
|
+
const key = `${route.surface}:${route.routePath}`
|
|
1558
|
+
const peers = duplicateGroups.get(key) ?? []
|
|
1559
|
+
peers.push(route)
|
|
1560
|
+
duplicateGroups.set(key, peers)
|
|
1561
|
+
}
|
|
1562
|
+
return [...duplicateGroups.entries()].flatMap(([key, entries]) => {
|
|
1563
|
+
if (entries.length < 2) return []
|
|
1564
|
+
if (!entries.some((entry) => entry.origin === 'app')) return []
|
|
1565
|
+
const conflicts = entries.filter((entry, index) => entries.some((peer, peerIndex) => {
|
|
1566
|
+
if (peerIndex === index) return false
|
|
1567
|
+
if (entry.surface !== 'api') return true
|
|
1568
|
+
return [...entry.methods].some((method) => peer.methods.has(method))
|
|
1569
|
+
}))
|
|
1570
|
+
if (conflicts.length < 2) return []
|
|
1571
|
+
const sources = conflicts.map((entry) => entry.displayPath).join(', ')
|
|
1572
|
+
return [`${key} (${sources})`]
|
|
1573
|
+
})
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function caseChecks(ts, caseId, facts, root, sourceFiles) {
|
|
1577
|
+
const definition = WRITABLE_CASES[caseId]
|
|
1578
|
+
if (definition.family === 'complete-module') {
|
|
1579
|
+
const scopedEntity = facts.classes.some((entry) => entry.decorators.has('Entity') && ['tenant_id', 'organization_id', 'updated_at'].every((name) => entry.members.has(name)))
|
|
1580
|
+
const uiFailures = uiPolicyFailures(facts)
|
|
1581
|
+
return [
|
|
1582
|
+
check('module.activation', facts.moduleEntries.some((entry) => entry.id === 'library' && entry.from === '@app') && facts.moduleEntries.some((entry) => entry.id === 'directory' && entry.from === '@open-mercato/core') && facts.moduleEntries.some((entry) => entry.id === 'example' && entry.from === '@app'), 'src/modules.ts preserves statically discoverable baseline entries and activates library from @app'),
|
|
1583
|
+
check('module.entity', scopedEntity, 'a scoped editable @Entity includes tenant_id, organization_id, and updated_at'),
|
|
1584
|
+
check('module.validator', hasCall(facts, 'z.object', 'object'), 'the book input boundary uses a concrete validator object'),
|
|
1585
|
+
check('module.acl', facts.exportedVariables.has('features') && hasExactString(facts, 'library.books.view') && hasExactString(facts, 'library.books.manage'), 'acl.ts exports stable view/manage features'),
|
|
1586
|
+
check('module.setup', facts.exportedVariables.has('setup') && facts.objectProperties.has('defaultRoleFeatures'), 'setup.ts grants module features through defaultRoleFeatures'),
|
|
1587
|
+
check('module.crud-host', hasCallOptions(facts, 'makeCrudRoute', ['metadata', 'orm', 'list', 'actions', 'indexer', 'enrichers']) && hasExactString(facts, 'library:book'), 'the scoped CRUD route publishes aligned indexer and enricher hosts for library:book'),
|
|
1588
|
+
check('module.crud-actions', hasCall(facts, 'registerCommand') && ['library.books.create', 'library.books.update', 'library.books.delete'].every((id) => hasExactString(facts, id)) && ['create', 'update', 'delete'].every((name) => facts.objectProperties.has(name)), 'create, update, and delete actions are backed by aligned registered commands'),
|
|
1589
|
+
check('module.api-metadata', ['GET', 'POST', 'PUT', 'DELETE', 'requireAuth', 'requireFeatures'].every((name) => facts.objectProperties.has(name)), 'CRUD API metadata declares per-method auth and features'),
|
|
1590
|
+
check('module.openapi', facts.exportedVariables.has('openApi'), 'the CRUD API exports OpenAPI documentation separately'),
|
|
1591
|
+
check('module.command-atomic', exportedCommandObjectCalls(facts, 'createBook', ['withAtomicFlush'])
|
|
1592
|
+
&& ['updateBook', 'deleteBook'].every((name) => exportedCommandObjectCalls(facts, name, ['withAtomicFlush', 'enforceCommandOptimisticLock'])),
|
|
1593
|
+
'each declared create/update/delete command reaches its own transaction seam, and update/delete each enforce their own optimistic lock'),
|
|
1594
|
+
check('module.command-undo', exportedCommandObjectCalls(facts, 'createBook', ['extractUndoPayload', 'emitCrudUndoSideEffects'])
|
|
1595
|
+
&& ['updateBook', 'deleteBook'].every((name) => exportedCommandObjectCalls(facts, name, ['extractUndoPayload', 'buildCustomFieldResetMap', 'emitCrudUndoSideEffects']))
|
|
1596
|
+
&& hasCall(facts, 'emitCrudSideEffects'),
|
|
1597
|
+
'each declared create/update/delete command owns concrete undo behavior; update/delete restore custom fields and all commands retain forward side effects'),
|
|
1598
|
+
check('module.encryption-map', facts.exportedVariables.has('defaultEncryptionMaps') && hasExactString(facts, 'library:book'), 'encryption.ts exports a library:book defaultEncryptionMaps entry'),
|
|
1599
|
+
check('module.encrypted-read', ['findWithDecryption', 'findOneWithDecryption', 'findAndCountWithDecryption'].some((name) => hasCall(facts, name)) && [...facts.importSources].includes('@open-mercato/shared/lib/encryption/find'), 'read paths use a scoped framework decryption helper'),
|
|
1600
|
+
check('module.search', facts.exportedVariables.has('searchConfig') && ['fieldPolicy', 'checksumSource', 'formatResult'].every((name) => facts.objectProperties.has(name)), 'search.ts defines policy, checksum, and presentation contracts'),
|
|
1601
|
+
check('module.table', facts.jsxTags.has('DataTable') && facts.jsxTags.has('RowActions') && facts.jsxAttributes.has('extensionTableId') && hasExactString(facts, '/backend/library/books/create') && [...facts.strings].some((value) => value === 'edit' || value.endsWith('.edit')) && [...facts.strings].some((value) => value === 'delete' || value.endsWith('.delete')), 'the extensible DataTable list exposes add, linked edit, and guarded delete actions'),
|
|
1602
|
+
check('module.list-query', facts.jsxAttributes.has('searchValue') && facts.jsxAttributes.has('onSearchChange') && ['search', 'buildFilters'].every((name) => facts.objectProperties.has(name)), 'the DataTable and scoped CRUD list connect server search and filters'),
|
|
1603
|
+
check('module.form', facts.jsxTags.has('CrudForm') && facts.jsxAttributes.has('initialValues') && (facts.jsxAttributes.has('entityId') || facts.objectProperties.has('entityIds')) && ['createCrud', 'updateCrud', 'deleteCrud'].every((name) => hasCall(facts, name)), 'CrudForm create/edit/delete binds the stable custom-field entity identity and initial values'),
|
|
1604
|
+
check('module.custom-fields', hasCall(facts, 'collectCustomFieldValues') && hasCall(facts, 'buildCustomFieldResetMap'), 'UI submission and command undo preserve custom fields'),
|
|
1605
|
+
check('module.sidebar', ['pageTitleKey', 'pageGroupKey', 'pagePriority', 'pageOrder', 'icon', 'breadcrumb'].every((name) => facts.objectProperties.has(name)), 'the Books list page metadata publishes localized main-sidebar navigation'),
|
|
1606
|
+
check('module.localized-ui', hasCall(facts, 'useT') && hasCall(facts, 't') && uiFailures.length === 0, `rendered UI uses i18n and shared design-system policy${uiFailures.length ? ` (${uiFailures.join(', ')})` : ''}`),
|
|
1607
|
+
moduleLocaleCatalogCheck(ts, root, sourceFiles),
|
|
1608
|
+
]
|
|
1609
|
+
}
|
|
1610
|
+
if (definition.family === 'booking-overlap') {
|
|
1611
|
+
const scopedEntity = facts.classes.some((entry) => entry.decorators.has('Entity') && ['tenant_id', 'organization_id', 'updated_at'].every((name) => entry.members.has(name)))
|
|
1612
|
+
const route = resolvedCallArguments(ts, facts, 'makeCrudRoute')
|
|
1613
|
+
.find((argument) => ts.isObjectLiteralExpression(argument) && objectProperty(ts, argument, 'actions'))
|
|
1614
|
+
const actions = route && ts.isObjectLiteralExpression(route)
|
|
1615
|
+
? objectPropertyObject(ts, facts, route, 'actions')
|
|
1616
|
+
: undefined
|
|
1617
|
+
const routeCommandIds = actions
|
|
1618
|
+
? ['create', 'update', 'delete'].map((action) => {
|
|
1619
|
+
const actionConfig = objectPropertyObject(ts, facts, actions, action)
|
|
1620
|
+
return actionConfig ? objectPropertyString(ts, facts, actionConfig, 'commandId') : undefined
|
|
1621
|
+
})
|
|
1622
|
+
: []
|
|
1623
|
+
const registeredCommands = resolvedCallArguments(ts, facts, 'registerCommand')
|
|
1624
|
+
.filter((argument) => ts.isObjectLiteralExpression(argument))
|
|
1625
|
+
const connectedCommands = routeCommandIds.map((commandId) =>
|
|
1626
|
+
registeredCommands.find((command) => objectPropertyString(ts, facts, command, 'id') === commandId))
|
|
1627
|
+
const commandFunctions = connectedCommands.map((command) => ({
|
|
1628
|
+
execute: command ? propertyFunction(ts, facts, command, 'execute') : undefined,
|
|
1629
|
+
undo: command ? propertyFunction(ts, facts, command, 'undo') : undefined,
|
|
1630
|
+
}))
|
|
1631
|
+
const allCommandsConnected = routeCommandIds.length === 3
|
|
1632
|
+
&& routeCommandIds.every((commandId) => typeof commandId === 'string')
|
|
1633
|
+
&& new Set(routeCommandIds).size === 3
|
|
1634
|
+
&& connectedCommands.every(Boolean)
|
|
1635
|
+
&& commandFunctions.every(({ execute, undo }) => execute && undo)
|
|
1636
|
+
const commandsAtomic = allCommandsConnected && commandFunctions.every(({ execute }) =>
|
|
1637
|
+
functionContractCalls(ts, facts, execute, ['withAtomicFlush', 'emitCrudSideEffects'])
|
|
1638
|
+
&& functionHasLiteralTrueOption(ts, facts, execute, 'withAtomicFlush', 'transaction'))
|
|
1639
|
+
const commandsUndoable = allCommandsConnected && commandFunctions.every(({ undo }) =>
|
|
1640
|
+
functionContractCalls(ts, facts, undo, ['extractUndoPayload', 'emitCrudUndoSideEffects']))
|
|
1641
|
+
const conflictMapped = allCommandsConnected && commandFunctions.slice(0, 2).every(({ execute }) =>
|
|
1642
|
+
functionContractMatches(ts, facts, execute, (reachable) =>
|
|
1643
|
+
reachable.strings.has('23P01') && reachable.newCalls.has('CrudHttpError')))
|
|
1644
|
+
return [
|
|
1645
|
+
check('overlap.exclusion-constraint', hasStringIncluding(facts, 'btree_gist') && hasStringIncluding(facts, 'exclude using gist', 'tstzrange', "'[)'"), 'the migration guards overlaps with a btree_gist exclusion constraint over a half-open tstzrange'),
|
|
1646
|
+
check('overlap.constraint-scope', hasStringIncluding(facts, 'exclude using gist', 'room_id', 'tenant_id', 'organization_id'), 'the exclusion constraint scopes by room and tenant/organization, not by period alone'),
|
|
1647
|
+
check('overlap.constraint-liveness', hasStringIncluding(facts, 'exclude using gist', 'deleted_at', 'cancelled'), 'cancelled and soft-deleted rows are excluded so they do not block the slot'),
|
|
1648
|
+
check('overlap.conflict-mapping', conflictMapped, 'the route-connected create and update commands each map SQLSTATE 23P01 to a conflict error'),
|
|
1649
|
+
check('overlap.entity-id-source', [...facts.importSources].includes('@/.mercato/generated/entities.ids.generated'), 'entity IDs come from the generated E map through the app alias, never hand-written strings'),
|
|
1650
|
+
check('overlap.scoped-entity', scopedEntity, 'the booking entity carries tenant_id, organization_id, and updated_at'),
|
|
1651
|
+
check('overlap.command-atomic', commandsAtomic, 'the route-connected create/update/delete commands each reach transactional flush and post-commit side effects'),
|
|
1652
|
+
check('overlap.command-undo', commandsUndoable, 'the route-connected create/update/delete commands each reach undo evidence and undo side effects'),
|
|
1653
|
+
check('overlap.crud-host', Boolean(route)
|
|
1654
|
+
&& ['metadata', 'orm', 'list', 'actions', 'indexer'].every((name) => objectProperty(ts, route, name)), 'the bookings CRUD route wires metadata, scoped ORM, list, command actions, and indexer'),
|
|
1655
|
+
]
|
|
1656
|
+
}
|
|
1657
|
+
if (definition.family === 'provider-transport') {
|
|
1658
|
+
const guard = facts.exportedFunctions.get('assertSafeEndpoint')
|
|
1659
|
+
const client = facts.exportedFunctions.get('createRoomCalendarClient')
|
|
1660
|
+
const clientNode = facts.exportedFunctionNodes.get('createRoomCalendarClient')
|
|
1661
|
+
const clientTree = clientNode
|
|
1662
|
+
? [clientNode, ...returnedObjectFunctions(ts, facts, clientNode)]
|
|
1663
|
+
.flatMap((node) => functionContractFacts(ts, facts, node, { skipNestedFunctions: true }))
|
|
1664
|
+
: []
|
|
1665
|
+
const clientStrings = new Set(clientTree.flatMap((fact) => [...fact.strings]))
|
|
1666
|
+
const clientCalls = new Set(clientTree.flatMap((fact) => [...fact.calls]))
|
|
1667
|
+
return [
|
|
1668
|
+
check('provider.paired-exports', ['integration', 'integrations', 'bundle', 'bundles'].every((name) => facts.exportedVariables.has(name)), 'integration.ts declares all four paired exports the generated module registry reads'),
|
|
1669
|
+
check('provider.credential-schema', hasObjectVariable(facts, 'integration', ['id', 'credentials', 'healthCheck']) && hasExactString(facts, 'secret') && hasExactString(facts, 'url'), 'the integration declares typed credential fields including a secret and the endpoint URL'),
|
|
1670
|
+
check('provider.health-di', hasExactString(facts, definition.healthService) && hasCall(facts, 'container.register'), `DI registers the exact ${definition.healthService} service declared by integration.ts`),
|
|
1671
|
+
check('provider.ssrf-guard', Boolean(guard)
|
|
1672
|
+
&& clientCalls.has('assertSafeEndpoint')
|
|
1673
|
+
&& (clientCalls.has('fetch') || clientCalls.has('fetchImpl') || clientCalls.has('request')), 'exported createRoomCalendarClient reaches both the behavior-probed guard and request seam'),
|
|
1674
|
+
check('provider.idempotency-key', clientStrings.has('idempotency-key'), 'the client request path carries a stable idempotency-key header'),
|
|
1675
|
+
check('provider.redirect-refusal', clientStrings.has('manual'), 'the client request path refuses to follow redirects'),
|
|
1676
|
+
check('provider.bounded-retry', clientTree.some((fact) => fact.loops > 0), 'exported createRoomCalendarClient retries through a bounded local call tree'),
|
|
1677
|
+
check('provider.unconfigured-degraded', hasExactString(facts, 'unconfigured'), 'a missing configuration reports a degraded state instead of failing'),
|
|
1678
|
+
]
|
|
1679
|
+
}
|
|
1680
|
+
if (definition.family === 'response-enricher') {
|
|
1681
|
+
const enrichersExpression = facts.exportedVariables.has('enrichers')
|
|
1682
|
+
? facts.variableExpressions.get('enrichers')
|
|
1683
|
+
: undefined
|
|
1684
|
+
const registeredEnrichers = arrayElements(ts, facts, enrichersExpression)
|
|
1685
|
+
.filter((entry) => ts.isObjectLiteralExpression(entry))
|
|
1686
|
+
const enricher = registeredEnrichers.find((entry) =>
|
|
1687
|
+
objectPropertyString(ts, facts, entry, 'targetEntity') === 'customers.person'
|
|
1688
|
+
&& objectPropertyArray(ts, facts, entry, 'features')
|
|
1689
|
+
.some((feature) => stringValue(ts, facts, feature) === 'room_bookings.bookings.view'))
|
|
1690
|
+
const enrichOne = enricher ? propertyFunction(ts, facts, enricher, 'enrichOne') : undefined
|
|
1691
|
+
const enrichMany = enricher ? propertyFunction(ts, facts, enricher, 'enrichMany') : undefined
|
|
1692
|
+
const fallback = enricher ? objectPropertyObject(ts, facts, enricher, 'fallback') : undefined
|
|
1693
|
+
const enrichOneNamespaced = Boolean(enrichOne) && (
|
|
1694
|
+
functionContractMatches(ts, facts, enrichOne, (fact) => fact.objectProperties.has('_room_bookings'))
|
|
1695
|
+
|| functionContractCalls(ts, facts, enrichOne, ['enrichMany'])
|
|
1696
|
+
)
|
|
1697
|
+
const enrichManyFacts = enrichMany ? functionContractFacts(ts, facts, enrichMany) : []
|
|
1698
|
+
const enrichManyNamespaced = enrichManyFacts.some((fact) => fact.objectProperties.has('_room_bookings'))
|
|
1699
|
+
const batchReadCalls = new Set(['find', 'findWithDecryption', 'findAndCountWithDecryption', 'query', 'selectFrom'])
|
|
1700
|
+
const enrichManyReadsBookings = enrichManyFacts.some((fact) =>
|
|
1701
|
+
[...fact.calls].some((call) => batchReadCalls.has(call)))
|
|
1702
|
+
const enrichManyBatched = enrichManyFacts.some((fact) =>
|
|
1703
|
+
fact.loops > 0 || fact.calls.has('map') || fact.calls.has('Promise.all'))
|
|
1704
|
+
&& enrichManyReadsBookings
|
|
1705
|
+
const resilient = Boolean(enricher && fallback)
|
|
1706
|
+
&& Boolean(objectProperty(ts, fallback, '_room_bookings'))
|
|
1707
|
+
&& (numberValue(ts, facts, propertyExpression(ts, facts, enricher, 'timeout')) ?? 0) > 0
|
|
1708
|
+
&& booleanValue(ts, facts, propertyExpression(ts, facts, enricher, 'cacheableOnListHit')) === false
|
|
1709
|
+
return [
|
|
1710
|
+
check('enricher.dot-target', Boolean(enricher) && !hasExactString(facts, 'customers:person'), 'one connected enricher object uses the exact dot-form customers.person target; the colon-form ID never matches'),
|
|
1711
|
+
check('enricher.batched', Boolean(enrichMany) && enrichManyBatched, 'the registered enricher batch-reads persisted booking data before mapping list results'),
|
|
1712
|
+
check('enricher.namespaced', enrichOneNamespaced && enrichManyNamespaced, 'the registered enricher constructs _room_bookings in detail and list paths'),
|
|
1713
|
+
check('enricher.resilience', resilient, 'the registered enricher declares a namespaced fallback, positive timeout, and cacheableOnListHit false'),
|
|
1714
|
+
check('enricher.acl', Boolean(enricher), 'that same enricher gates on room_bookings.bookings.view'),
|
|
1715
|
+
check('enricher.registration', facts.exportedVariables.has('enrichers') && facts.importedBindings.get('ResponseEnricher') === '@open-mercato/shared/lib/crud/response-enricher', 'the typed enricher list is exported for discovery'),
|
|
1716
|
+
]
|
|
1717
|
+
}
|
|
1718
|
+
if (definition.family === 'durable-workflow') {
|
|
1719
|
+
const workflow = resolvedCallArguments(ts, facts, 'defineWorkflow')
|
|
1720
|
+
.find((argument) => ts.isObjectLiteralExpression(argument)
|
|
1721
|
+
&& objectProperty(ts, argument, 'steps') && objectProperty(ts, argument, 'transitions'))
|
|
1722
|
+
const steps = workflow && ts.isObjectLiteralExpression(workflow)
|
|
1723
|
+
? objectPropertyArray(ts, facts, workflow, 'steps').filter((step) => ts.isObjectLiteralExpression(step))
|
|
1724
|
+
: []
|
|
1725
|
+
const transitions = workflow && ts.isObjectLiteralExpression(workflow)
|
|
1726
|
+
? objectPropertyArray(ts, facts, workflow, 'transitions').filter((transition) => ts.isObjectLiteralExpression(transition))
|
|
1727
|
+
: []
|
|
1728
|
+
const stepById = new Map(steps.map((step) => [objectPropertyString(ts, facts, step, 'stepId'), step]).filter(([id]) => id))
|
|
1729
|
+
const start = steps.find((step) => objectPropertyString(ts, facts, step, 'stepType') === 'START')
|
|
1730
|
+
const end = steps.find((step) => objectPropertyString(ts, facts, step, 'stepType') === 'END')
|
|
1731
|
+
const timerStep = steps.find((step) => {
|
|
1732
|
+
if (objectPropertyString(ts, facts, step, 'stepType') !== 'WAIT_FOR_TIMER') return false
|
|
1733
|
+
const config = objectPropertyObject(ts, facts, step, 'config')
|
|
1734
|
+
return config && objectPropertyString(ts, facts, config, 'duration') === 'PT15M'
|
|
1735
|
+
})
|
|
1736
|
+
const startId = start ? objectPropertyString(ts, facts, start, 'stepId') : undefined
|
|
1737
|
+
const endId = end ? objectPropertyString(ts, facts, end, 'stepId') : undefined
|
|
1738
|
+
const timerId = timerStep ? objectPropertyString(ts, facts, timerStep, 'stepId') : undefined
|
|
1739
|
+
const parsedTransitions = transitions.map((transition) => ({
|
|
1740
|
+
node: transition,
|
|
1741
|
+
from: objectPropertyString(ts, facts, transition, 'fromStepId'),
|
|
1742
|
+
to: objectPropertyString(ts, facts, transition, 'toStepId'),
|
|
1743
|
+
trigger: objectPropertyString(ts, facts, transition, 'trigger'),
|
|
1744
|
+
priority: numberValue(ts, facts, propertyExpression(ts, facts, transition, 'priority')) ?? 0,
|
|
1745
|
+
}))
|
|
1746
|
+
const endpointsValid = parsedTransitions.length > 0
|
|
1747
|
+
&& parsedTransitions.every(({ from, to }) => stepById.has(from) && stepById.has(to))
|
|
1748
|
+
const reachable = new Set(startId ? [startId] : [])
|
|
1749
|
+
const pending = startId ? [startId] : []
|
|
1750
|
+
while (pending.length) {
|
|
1751
|
+
const current = pending.pop()
|
|
1752
|
+
for (const transition of parsedTransitions.filter(({ from }) => from === current)) {
|
|
1753
|
+
if (reachable.has(transition.to)) continue
|
|
1754
|
+
reachable.add(transition.to)
|
|
1755
|
+
pending.push(transition.to)
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
const signalTransition = parsedTransitions.find(({ from, trigger }) => from === timerId && trigger === 'signal')
|
|
1759
|
+
const timerTransition = parsedTransitions.find(({ from, trigger }) => from === timerId && trigger === 'timer')
|
|
1760
|
+
const updateActivity = timerTransition
|
|
1761
|
+
? objectPropertyArray(ts, facts, timerTransition.node, 'activities')
|
|
1762
|
+
.find((activity) => {
|
|
1763
|
+
if (!ts.isObjectLiteralExpression(activity)
|
|
1764
|
+
|| objectPropertyString(ts, facts, activity, 'activityType') !== 'UPDATE_ENTITY') return false
|
|
1765
|
+
const config = objectPropertyObject(ts, facts, activity, 'config')
|
|
1766
|
+
return config && objectPropertyString(ts, facts, config, 'commandId') === 'room_bookings.bookings.update'
|
|
1767
|
+
})
|
|
1768
|
+
: undefined
|
|
1769
|
+
const safeCommand = resolvedCallArguments(ts, facts, 'registerWorkflowSafeCommands')
|
|
1770
|
+
.flatMap((argument) => arrayElements(ts, facts, argument))
|
|
1771
|
+
.find((entry) => ts.isObjectLiteralExpression(entry)
|
|
1772
|
+
&& objectPropertyString(ts, facts, entry, 'commandId') === 'room_bookings.bookings.update'
|
|
1773
|
+
&& objectPropertyArray(ts, facts, entry, 'requiredFeatures')
|
|
1774
|
+
.some((feature) => Boolean(stringValue(ts, facts, feature))))
|
|
1775
|
+
const workflowGraph = Boolean(startId && endId && timerId)
|
|
1776
|
+
&& endpointsValid && reachable.has(endId)
|
|
1777
|
+
const confirmationWins = Boolean(signalTransition && timerTransition
|
|
1778
|
+
&& signalTransition.priority > timerTransition.priority
|
|
1779
|
+
&& reachable.has(signalTransition.to) && reachable.has(timerTransition.to))
|
|
1780
|
+
return [
|
|
1781
|
+
check('workflow.timer-config', Boolean(timerStep), 'the WAIT_FOR_TIMER step owns the exact PT15M duration'),
|
|
1782
|
+
check('workflow.safe-commands', Boolean(safeCommand), 'registerWorkflowSafeCommands pairs the dispatched command with its required features'),
|
|
1783
|
+
check('workflow.builder', hasCall(facts, 'defineWorkflow') && hasCall(facts, 'createWorkflowsModuleConfig') && facts.exportedVariables.has('workflowsConfig'), 'the definition uses the typed builder and exports the discovered workflowsConfig'),
|
|
1784
|
+
check('workflow.terminal-graph', workflowGraph, 'the same graph declares explicit START and END steps'),
|
|
1785
|
+
check('workflow.confirmation-beats-expiry', confirmationWins, 'the signal confirmation has higher priority than the competing expiry timer'),
|
|
1786
|
+
check('workflow.dispatch-update-entity', Boolean(updateActivity), 'the timer release transition owns an UPDATE_ENTITY activity for the booking update command'),
|
|
1787
|
+
]
|
|
1788
|
+
}
|
|
1789
|
+
if (definition.family === 'crm-library-regression') {
|
|
1790
|
+
const schemaFacts = collectFacts(ts, collectSourceFiles(root, [definition.schemaSource]))
|
|
1791
|
+
const testFacts = collectFacts(ts, collectSourceFiles(root, [definition.testSource]))
|
|
1792
|
+
const scopedActions = ['createBook', 'undoCreateBook', 'deleteBook', 'undoDeleteBook', 'checkoutBook', 'returnLoan', 'renewLoan', 'markLoanLost']
|
|
1793
|
+
return [
|
|
1794
|
+
check('crm-library.exports', scopedActions.every((name) => facts.exportedFunctions.has(name)), 'the stable lifecycle, checkout, return, renew, and mark-lost seams remain exported'),
|
|
1795
|
+
check('crm-library.trusted-scope', scopedActions.every((name) => exportedFunctionCalls(facts, name, ['requireTrustedScope'])), 'every mutating seam fails closed through the same trusted tenant/organization scope guard'),
|
|
1796
|
+
check('crm-library.lifecycle', exportedFunctionCalls(facts, 'createBook', ['effects.createBook'])
|
|
1797
|
+
&& exportedFunctionCalls(facts, 'undoCreateBook', ['effects.softDeleteBook'])
|
|
1798
|
+
&& exportedFunctionCalls(facts, 'deleteBook', ['effects.softDeleteBook'])
|
|
1799
|
+
&& exportedFunctionCalls(facts, 'undoDeleteBook', ['effects.restoreBook']),
|
|
1800
|
+
'create undo soft-deletes, delete soft-deletes, and delete undo restores through explicit scoped effects'),
|
|
1801
|
+
check('crm-library.crm-checkout', exportedFunctionCalls(facts, 'checkoutBook', ['effects.resolveCustomer', 'effects.claimCheckout'])
|
|
1802
|
+
&& !hasExactString(facts, 'memberId'), 'checkout resolves an existing CRM customer and reaches the atomic claim seam without a duplicate local member'),
|
|
1803
|
+
check('crm-library.loan-scope', ['returnLoan', 'renewLoan', 'markLoanLost'].every((name) => exportedFunctionCalls(facts, name, ['effects.findLoan', 'effects.updateLoan'])), 'every loan action performs both lookup and mutation through scoped effects'),
|
|
1804
|
+
check('crm-library.public-schema', !schemaFacts.objectProperties.has('tenantId') && !schemaFacts.objectProperties.has('organizationId')
|
|
1805
|
+
&& schemaFacts.exportedVariables.has('createBookRequestSchema') && schemaFacts.exportedVariables.has('checkoutBookRequestSchema'),
|
|
1806
|
+
'public create/checkout request schemas omit tenantId and organizationId because runtime scope is server-derived'),
|
|
1807
|
+
check('crm-library.jest-import', [...testFacts.importSources].includes('@jest/globals') && hasOnlyEnabledTests(testFacts), 'generated Jest coverage imports its globals explicitly and contains no disabled/focused/expected-failure modifiers'),
|
|
1808
|
+
check('crm-library.semantic-tests', ['createBook', 'undoCreateBook', 'deleteBook', 'undoDeleteBook', 'returnLoan', 'renewLoan', 'markLoanLost'].every((name) => hasCall(testFacts, name))
|
|
1809
|
+
&& (testFacts.calls.get('checkoutBook') ?? 0) >= 3 && hasCall(testFacts, 'Promise.all'),
|
|
1810
|
+
'generated tests exercise lifecycle undo, every scoped loan action, concurrent checkout, and deterministic retry'),
|
|
1811
|
+
]
|
|
1812
|
+
}
|
|
1813
|
+
if (definition.family === 'business-command') {
|
|
1814
|
+
const fact = facts.exportedFunctions.get(definition.seam)
|
|
1815
|
+
return [
|
|
1816
|
+
check('business.command-seam', exportedFunctionCalls(facts, definition.seam, ['effects.reserveIdempotency', 'effects.transaction', 'effects.apply', 'effects.record']), `exported ${definition.seam} uses the idempotency, transaction, mutation, and lineage seams`),
|
|
1817
|
+
check('business.command-guard', (fact?.throws ?? 0) > 0, `exported ${definition.seam} rejects an invalid business invariant`),
|
|
1818
|
+
]
|
|
1819
|
+
}
|
|
1820
|
+
if (definition.family === 'ui-business-surface') {
|
|
1821
|
+
const fact = facts.exportedFunctions.get(definition.seam)
|
|
1822
|
+
return [
|
|
1823
|
+
check('business.ui-seam', exportedFunctionCalls(facts, definition.seam, ['effects.execute', 'effects.restoreFocus', 'effects.announce']), `exported ${definition.seam} executes, restores focus, and announces the result`),
|
|
1824
|
+
check('business.ui-guard', (fact?.throws ?? 0) > 0, `exported ${definition.seam} rejects an invalid UI business action`),
|
|
1825
|
+
check('business.ui-finally', (fact?.finallyBlocks ?? 0) > 0, `exported ${definition.seam} restores focus after both success and failure`),
|
|
1826
|
+
...renderedUiChecks(definition, facts),
|
|
1827
|
+
]
|
|
1828
|
+
}
|
|
1829
|
+
if (definition.family === 'data-table-extension') {
|
|
1830
|
+
const fact = facts.exportedFunctions.get(definition.seam)
|
|
1831
|
+
const onExecute = facts.propertyIdentifiers.get('onExecute')
|
|
1832
|
+
return [
|
|
1833
|
+
check('business.table-filter-host', hasObjectVariable(facts, 'injectionTable', ['data-table:sales.orders:filters']) && hasExactString(facts, 'order_risk.injection.order-risk-filter'), 'the exact sales.orders filters host loads the order-risk filter widget'),
|
|
1834
|
+
check('business.table-bulk-host', hasObjectVariable(facts, 'injectionTable', ['data-table:sales.orders:bulk-actions']) && hasExactString(facts, 'order_risk.injection.order-risk-review'), 'the exact sales.orders bulk-actions host loads the order-risk review widget'),
|
|
1835
|
+
check('business.table-filter', facts.exportedVariables.has('orderRiskFilterWidget') && hasObjectVariable(facts, 'orderRiskFilterWidget', ['metadata', 'filters']) && ['order-risk', 'order_risk.filters.risk.label', 'server', 'risk'].every((value) => hasExactString(facts, value)), 'an exported localized server-backed order-risk filter is registered'),
|
|
1836
|
+
check('business.table-bulk-action', facts.exportedVariables.has('orderRiskReviewWidget') && hasObjectVariable(facts, 'orderRiskReviewWidget', ['metadata', 'bulkActions']) && hasExactString(facts, 'review-order-risk') && onExecute?.has(definition.seam), `the exported bulk action links onExecute directly to ${definition.seam}`),
|
|
1837
|
+
check('business.table-safe-seam', exportedFunctionCalls(facts, definition.seam, ['effects.authorize', 'effects.checkVersion', 'effects.surfaceConflict', 'effects.execute']) && (fact?.loops ?? 0) > 0 && (fact?.throws ?? 0) > 0, `exported ${definition.seam} authorizes and version-checks every selected order before execution`),
|
|
1838
|
+
check('business.table-ui-policy', uiPolicyFailures(facts).length === 0, 'the injected table extension avoids raw UI, inline SVG/style, hard-coded copy/colors, arbitrary Tailwind, and manual dark overrides'),
|
|
1839
|
+
]
|
|
1840
|
+
}
|
|
1841
|
+
if (definition.family === 'async-operation') {
|
|
1842
|
+
const fact = facts.exportedFunctions.get(definition.seam)
|
|
1843
|
+
return [
|
|
1844
|
+
check('business.async-seam', exportedFunctionCalls(facts, definition.seam, ['effects.isCancelled', 'effects.shouldSkip', 'effects.applyChunk', 'effects.reportProgress', 'effects.registerUndo']), `exported ${definition.seam} uses cancellation, skip, mutation, progress, and undo seams`),
|
|
1845
|
+
check('business.async-loop', (fact?.loops ?? 0) > 0, `exported ${definition.seam} processes work through a bounded loop`),
|
|
1846
|
+
]
|
|
1847
|
+
}
|
|
1848
|
+
if (definition.family === 'ai-safe-agent') {
|
|
1849
|
+
const requiredCalls = definition.mode === 'mutation'
|
|
1850
|
+
? ['effects.authorize', 'effects.prepareMutation', 'effects.execute']
|
|
1851
|
+
: ['effects.authorize', 'effects.delegate']
|
|
1852
|
+
const checks = [
|
|
1853
|
+
check('business.ai-seam', exportedFunctionCalls(facts, definition.seam, requiredCalls), `exported ${definition.seam} uses the required authorization and ${definition.mode} seams`),
|
|
1854
|
+
]
|
|
1855
|
+
if (definition.mode === 'delegate') {
|
|
1856
|
+
checks.push(check('business.ai-authority', exportedFunctionHasCallOptions(facts, definition.seam, 'effects.delegate', ['authority', 'allowedTools']), `exported ${definition.seam} delegates with an explicit authority ceiling and allowlist`))
|
|
1857
|
+
}
|
|
1858
|
+
return checks
|
|
1859
|
+
}
|
|
1860
|
+
if (definition.family === 'provider-adapter') {
|
|
1861
|
+
const fact = facts.exportedFunctions.get(definition.seam)
|
|
1862
|
+
const checks = [
|
|
1863
|
+
check('business.provider-seam', exportedFunctionCalls(facts, definition.seam, ['effects.findExisting', 'effects.request', 'effects.reconcile', 'effects.redact']), `exported ${definition.seam} uses idempotency, provider, reconciliation, and redaction seams`),
|
|
1864
|
+
check('business.provider-retry', (fact?.loops ?? 0) > 0 && (fact?.throws ?? 0) > 0, `exported ${definition.seam} bounds retries and redacts terminal failure`),
|
|
1865
|
+
check('business.provider-integration', facts.importedBindings.get('IntegrationDefinition') === '@open-mercato/shared/modules/integrations/types' && facts.exportedVariables.has('integration') && facts.exportedVariables.has('integrations') && hasObjectVariable(facts, 'integration', ['id', 'category', 'providerKey', 'credentials', 'healthCheck']), 'integration.ts exports typed IntegrationDefinition metadata, credentials, and health'),
|
|
1866
|
+
check('business.provider-secret', hasExactString(facts, 'secret'), 'integration credentials include a secret-bearing field type'),
|
|
1867
|
+
check('business.provider-health', hasExactString(facts, definition.healthService) && hasCall(facts, 'container.register'), `DI registers the exact ${definition.healthService} health service declared by integration.ts`),
|
|
1868
|
+
check('business.provider-activation', facts.moduleEntries.some((entry) => entry.id === definition.moduleId && entry.from === '@app'), `src/modules.ts activates ${definition.moduleId} from @app`),
|
|
1869
|
+
]
|
|
1870
|
+
if (definition.providerKind === 'transactional-email') {
|
|
1871
|
+
checks.push(
|
|
1872
|
+
check('business.provider-transactional-di', hasObjectVariable(facts, 'smtpEmailService', ['send']) && facts.propertyIdentifiers.get('send')?.has(definition.seam), 'DI-facing SMTP sender delegates to the tested transactional client seam'),
|
|
1873
|
+
check('business.provider-not-mailbox', !hasExactString(facts, 'communication_channels') && !facts.importedBindings.has('ChannelAdapter'), 'transactional SMTP does not claim the mailbox ChannelAdapter contract'),
|
|
1874
|
+
)
|
|
1875
|
+
}
|
|
1876
|
+
if (definition.providerKind === 'payment') {
|
|
1877
|
+
checks.push(
|
|
1878
|
+
check('business.provider-payment-contract', facts.importedBindings.get('GatewayAdapter') === '@open-mercato/shared/modules/payment_gateways/types' && hasObjectVariable(facts, definition.adapterVariable, ['providerKey', 'createSession', 'capture', 'refund', 'cancel', 'getStatus', 'verifyWebhook', 'mapStatus']) && hasCall(facts, definition.seam), 'provider implements the installed GatewayAdapter surface and reaches the tested client seam'),
|
|
1879
|
+
check(
|
|
1880
|
+
'business.provider-payment-registration',
|
|
1881
|
+
hasCall(facts, 'registerGatewayAdapter')
|
|
1882
|
+
&& hasCall(facts, 'registerPaymentGatewayDescriptor')
|
|
1883
|
+
&& facts.importedBindings.get('registerWebhookHandler') === '@open-mercato/shared/modules/payment_gateways/types'
|
|
1884
|
+
&& hasCall(facts, 'registerWebhookHandler')
|
|
1885
|
+
&& facts.propertyAccesses.has('verifyWebhook'),
|
|
1886
|
+
'DI registers the gateway adapter, its verifyWebhook handler, and the payment descriptor',
|
|
1887
|
+
),
|
|
1888
|
+
)
|
|
1889
|
+
}
|
|
1890
|
+
if (definition.providerKind === 'shipping') {
|
|
1891
|
+
checks.push(
|
|
1892
|
+
check('business.provider-shipping-contract', facts.importedBindings.get('ShippingAdapter') === '@open-mercato/core/modules/shipping_carriers/lib/adapter' && hasObjectVariable(facts, definition.adapterVariable, ['providerKey', 'calculateRates', 'createShipment', 'getTracking', 'cancelShipment', 'verifyWebhook', 'mapStatus']) && hasCall(facts, definition.seam), 'provider implements the installed ShippingAdapter surface and reaches the tested client seam'),
|
|
1893
|
+
check('business.provider-shipping-registration', hasCall(facts, 'registerShippingAdapter'), 'DI registers the shipping adapter'),
|
|
1894
|
+
)
|
|
1895
|
+
}
|
|
1896
|
+
if (definition.providerKind === 'payment' || definition.providerKind === 'shipping') {
|
|
1897
|
+
checks.push(
|
|
1898
|
+
check('business.provider-acl', facts.exportedVariables.has('features') && hasExactString(facts, `${definition.moduleId}.view`) && hasExactString(facts, `${definition.moduleId}.configure`), 'provider acl.ts exports stable view/configure features'),
|
|
1899
|
+
check('business.provider-setup', facts.exportedVariables.has('setup') && hasObjectVariable(facts, 'setup', ['defaultRoleFeatures']), 'provider setup.ts grants its features through defaultRoleFeatures'),
|
|
1900
|
+
)
|
|
1901
|
+
}
|
|
1902
|
+
return checks
|
|
1903
|
+
}
|
|
1904
|
+
if (definition.family === 'data-flow') {
|
|
1905
|
+
const fact = facts.exportedFunctions.get(definition.seam)
|
|
1906
|
+
return [
|
|
1907
|
+
check('business.data-seam', exportedFunctionCalls(facts, definition.seam, ['effects.fetchPage', 'effects.sanitize', 'effects.apply', 'effects.commitCursor']), `exported ${definition.seam} uses fetch, sanitization, row mutation, and cursor seams`),
|
|
1908
|
+
check('business.data-loop', (fact?.loops ?? 0) > 0, `exported ${definition.seam} isolates rows in a loop`),
|
|
1909
|
+
]
|
|
1910
|
+
}
|
|
1911
|
+
if (definition.family === 'test-authoring-unit') {
|
|
1912
|
+
return [
|
|
1913
|
+
check('business.test-unit-runner', (facts.calls.get('test') ?? 0) >= 3 && (facts.calls.get('expect') ?? 0) >= 3, 'at least three executable Jest tests with assertions'),
|
|
1914
|
+
check('business.test-unit-enabled', hasOnlyEnabledTests(facts), 'generated Jest coverage contains no disabled, focused-only, todo, or expected-failure tests or suites'),
|
|
1915
|
+
check('business.test-unit-invariants', ['already approved', 'requester', 'injected failure'].every((value) => [...facts.strings].some((entry) => entry.toLowerCase().includes(value))), 'test titles or assertions cover duplicate approval, separation of duties, and injected failure'),
|
|
1916
|
+
check('business.test-unit-failure', hasCall(facts, 'toThrow', 'toReject', 'rejects.toThrow') && hasCall(facts, 'toHaveBeenCalledTimes', 'toEqual', 'toBe'), 'failure and rollback outcomes are asserted'),
|
|
1917
|
+
]
|
|
1918
|
+
}
|
|
1919
|
+
if (definition.family === 'test-authoring-api') {
|
|
1920
|
+
return [
|
|
1921
|
+
check('business.test-api-runner', facts.importedBindings.get('test') === '@playwright/test' && facts.importedBindings.get('expect') === '@playwright/test', 'the module-local spec uses the Playwright test runner'),
|
|
1922
|
+
check('business.test-api-enabled', hasOnlyEnabledTests(facts), 'generated API coverage contains no disabled, focused-only, todo, or expected-failure tests or suites'),
|
|
1923
|
+
check('business.test-api-http', ['request.post', 'request.patch', 'request.delete'].every((name) => hasCall(facts, name)), 'real HTTP creation, stale update, and cleanup requests are executed'),
|
|
1924
|
+
check('business.test-api-scope-conflict', hasExactString(facts, 'x-organization-id') && hasExactString(facts, 'if-match') && (facts.calls.get('toBe') ?? 0) >= 4, 'organization denial and optimistic conflict statuses are asserted'),
|
|
1925
|
+
check('business.test-api-lifecycle', allServersBindLoopback(facts) && hasCall(facts, 'close') && facts.finallyBlocks > 0, 'every ephemeral server listen call binds the literal host 127.0.0.1 and is closed in finally'),
|
|
1926
|
+
]
|
|
1927
|
+
}
|
|
1928
|
+
if (definition.family === 'test-authoring-browser') {
|
|
1929
|
+
return [
|
|
1930
|
+
check('business.test-browser-runner', facts.importedBindings.get('test') === '@playwright/test' && facts.importedBindings.get('expect') === '@playwright/test', 'the module-local spec uses the Playwright test runner'),
|
|
1931
|
+
check('business.test-browser-enabled', hasOnlyEnabledTests(facts), 'generated browser coverage contains no disabled, focused-only, todo, or expected-failure tests or suites'),
|
|
1932
|
+
check('business.test-browser-real-page', hasCall(facts, 'page.goto') && hasCall(facts, 'page.getByRole') && hasCall(facts, 'click'), 'a real browser navigates loopback HTTP and interacts through semantic roles'),
|
|
1933
|
+
check('business.test-browser-coverage', hasExactString(facts, '/portal/orders/forbidden') && hasExactString(facts, '/api/backend/quotes/quote-1') && hasCall(facts, 'toHaveText', 'toContainText', 'toBe'), 'direct-route denial, stale conflict, and backend state are asserted'),
|
|
1934
|
+
check('business.test-browser-lifecycle', allServersBindLoopback(facts) && hasCall(facts, 'close') && facts.finallyBlocks > 0, 'every ephemeral server listen call binds the literal host 127.0.0.1 and is closed in finally'),
|
|
1935
|
+
]
|
|
1936
|
+
}
|
|
1937
|
+
switch (caseId) {
|
|
1938
|
+
case 'OMH-009': {
|
|
1939
|
+
const entity = facts.classes.some((entry) => entry.decorators.has('Entity') && ['tenant_id', 'organization_id', 'updated_at'].every((name) => entry.members.has(name)))
|
|
1940
|
+
return [
|
|
1941
|
+
check('entity.declaration', entity, 'an @Entity class declaring tenant_id, organization_id, and updated_at'),
|
|
1942
|
+
check('entity.validator', hasCall(facts, 'z.object') || hasCall(facts, 'object'), 'a concrete validator object call'),
|
|
1943
|
+
check('entity.migration', facts.classes.some((entry) => entry.members.has('up')), 'a migration class with an up method'),
|
|
1944
|
+
]
|
|
1945
|
+
}
|
|
1946
|
+
case 'OMH-011':
|
|
1947
|
+
return [
|
|
1948
|
+
check('crud.factory-import', facts.importedBindings.get('makeCrudRoute') === '@open-mercato/shared/lib/crud/factory', 'makeCrudRoute imported from @open-mercato/shared/lib/crud/factory'),
|
|
1949
|
+
check('crud.route', hasCallOptions(facts, 'makeCrudRoute', ['metadata', 'orm', 'list', 'actions', 'indexer']), 'makeCrudRoute called with metadata, orm, list, actions, and indexer options'),
|
|
1950
|
+
check('crud.openapi', facts.exportedVariables.has('openApi'), 'openApi exported separately from the CRUD factory options'),
|
|
1951
|
+
]
|
|
1952
|
+
case 'OMH-012':
|
|
1953
|
+
return [
|
|
1954
|
+
check('command.guards', hasCall(facts, 'runMutationGuards'), 'a runMutationGuards call'),
|
|
1955
|
+
check('command.atomic', hasCall(facts, 'withAtomicFlush'), 'a withAtomicFlush call'),
|
|
1956
|
+
check('command.effects', hasCall(facts, 'emitCrudSideEffects'), 'an emitCrudSideEffects call'),
|
|
1957
|
+
]
|
|
1958
|
+
case 'OMH-014':
|
|
1959
|
+
return [
|
|
1960
|
+
check('ui.table', facts.jsxTags.has('DataTable') && (facts.jsxAttributes.has('extensionTableId') || facts.objectProperties.has('extensionTableId')), 'a DataTable JSX use with extensionTableId'),
|
|
1961
|
+
check('ui.form', facts.jsxTags.has('CrudForm') && facts.jsxAttributes.has('initialValues'), 'a CrudForm JSX use with initialValues'),
|
|
1962
|
+
check('ui.conflict', hasCall(facts, 'surfaceRecordConflict'), 'a surfaceRecordConflict call'),
|
|
1963
|
+
]
|
|
1964
|
+
case 'OMH-026':
|
|
1965
|
+
return [
|
|
1966
|
+
check('umes.form-spot', hasStringPrefix(facts, 'crud-form:'), 'a concrete crud-form:* spot ID literal'),
|
|
1967
|
+
check('umes.enricher', hasCall(facts, 'enrichMany'), 'an enrichMany call'),
|
|
1968
|
+
check('umes.interceptor', hasObjectVariable(facts, 'interceptors', []) || facts.declarations.has('interceptors'), 'an interceptors declaration'),
|
|
1969
|
+
]
|
|
1970
|
+
case 'OMH-027':
|
|
1971
|
+
return [
|
|
1972
|
+
check('umes.table-spot', hasStringPrefix(facts, 'data-table:'), 'a concrete data-table:* spot ID literal'),
|
|
1973
|
+
check('umes.table-id', facts.objectProperties.has('extensionTableId') || facts.jsxAttributes.has('extensionTableId'), 'an extensionTableId option or JSX attribute'),
|
|
1974
|
+
]
|
|
1975
|
+
case 'OMH-029':
|
|
1976
|
+
return [check('umes.page-override', facts.declarations.has('overrides') && facts.objectProperties.has('page'), 'an overrides declaration containing a page option')]
|
|
1977
|
+
case 'OMH-031':
|
|
1978
|
+
return [
|
|
1979
|
+
check('umes.interceptors', facts.declarations.has('interceptors'), 'an interceptors declaration'),
|
|
1980
|
+
check('umes.interceptor-scope', ['metadata', 'organizationId', 'tenantId'].every((name) => facts.propertyAccesses.has(name) || facts.objectProperties.has(name)), 'metadata, organizationId, and tenantId used in executable AST'),
|
|
1981
|
+
check('umes.interceptor-hook', facts.objectProperties.has('before') || facts.objectProperties.has('after'), 'a before or after interceptor hook'),
|
|
1982
|
+
]
|
|
1983
|
+
case 'OMH-042':
|
|
1984
|
+
return [
|
|
1985
|
+
check('provider.adapter', [...facts.variables.entries()].some(([, properties]) => properties.has('health') && (properties.has('pull') || properties.has('run'))), 'an adapter object declaration with health and pull/run methods'),
|
|
1986
|
+
check('provider.effects', hasCall(facts, 'fetchPage') && hasCall(facts, 'applyItem') && hasCall(facts, 'commitCursor'), 'fetchPage, applyItem, and commitCursor call sites'),
|
|
1987
|
+
]
|
|
1988
|
+
case 'OMH-045':
|
|
1989
|
+
return [
|
|
1990
|
+
check('rest.url', facts.newCalls.has('URL'), 'a concrete new URL(...) call'),
|
|
1991
|
+
check('rest.retry', facts.loops > 0 || hasCall(facts, 'retry'), 'a retry loop or retry(...) call'),
|
|
1992
|
+
check('rest.cursor', facts.assignments.has('cursor') && (facts.awaitedCalls.has('fetch') || facts.awaitedCalls.has('fetchImpl') || facts.awaitedCalls.has('fetchPage')), 'cursor assignment and an awaited fetch call'),
|
|
1993
|
+
]
|
|
1994
|
+
case 'OMH-049':
|
|
1995
|
+
return [check('ai.agent', hasCallOptions(facts, 'defineAiAgent', ['provider', 'model', 'allowedTools', 'requiredFeatures']), 'defineAiAgent called with provider, model, allowedTools, and requiredFeatures options')]
|
|
1996
|
+
case 'OMH-054':
|
|
1997
|
+
return [
|
|
1998
|
+
check('workflow.activity', facts.functions.has('callApiActivity') && hasExactString(facts, 'CALL_API'), 'a callApiActivity declaration and CALL_API literal'),
|
|
1999
|
+
check('workflow.transaction', hasCall(facts, 'transaction', 'transactional'), 'a transaction/transactional call'),
|
|
2000
|
+
check('workflow.idempotency', (hasCall(facts, 'fetch', 'fetchImpl') && facts.objectProperties.has('headers') && hasExactString(facts, 'Idempotency-Key')), 'a fetch call with headers and an Idempotency-Key literal'),
|
|
2001
|
+
]
|
|
2002
|
+
case 'OMH-057':
|
|
2003
|
+
case 'OMH-171':
|
|
2004
|
+
return [
|
|
2005
|
+
check('regression.fail-closed', facts.functions.has('listRecords') && facts.throwStatements > 0, 'listRecords with a fail-closed throw'),
|
|
2006
|
+
check('regression.scope', ['tenantId', 'organizationId'].every((name) => facts.propertyAccesses.has(name) || facts.objectProperties.has(name)), 'tenantId and organizationId concrete scope access'),
|
|
2007
|
+
]
|
|
2008
|
+
case 'OMH-060':
|
|
2009
|
+
return [
|
|
2010
|
+
check('regression.atomic', hasCall(facts, 'withAtomicFlush', 'transaction', 'transactional'), 'a withAtomicFlush/transaction/transactional call'),
|
|
2011
|
+
check('regression.phases', (facts.calls.get('persist') ?? 0) >= 2, 'two concrete persist call sites inside the atomic operation'),
|
|
2012
|
+
]
|
|
2013
|
+
case 'OMH-061':
|
|
2014
|
+
return [
|
|
2015
|
+
check('regression.nullable', facts.nullNodes > 0, 'a concrete nullable type or null expression'),
|
|
2016
|
+
check('regression.form', facts.jsxTags.has('CrudForm') && facts.jsxAttributes.has('initialValues'), 'CrudForm JSX with initialValues'),
|
|
2017
|
+
]
|
|
2018
|
+
case 'OMH-172': {
|
|
2019
|
+
const initialValues = facts.exportedFunctions.get('toInitialValues')
|
|
2020
|
+
const updatePayload = facts.exportedFunctions.get('toUpdatePayload')
|
|
2021
|
+
return [
|
|
2022
|
+
check('regression.null-load-seam', Boolean(initialValues) && !initialValues.binaryOperators.has(ts.SyntaxKind.QuestionQuestionToken), 'exported toInitialValues preserves explicit null instead of replacing it'),
|
|
2023
|
+
check('regression.null-clear-seam', Boolean(updatePayload) && updatePayload.nullNodes > 0 && updatePayload.conditionalExpressions > 0 && updatePayload.binaryOperators.has(ts.SyntaxKind.EqualsEqualsEqualsToken), 'exported toUpdatePayload maps an explicit clear to null'),
|
|
2024
|
+
check('regression.form', facts.jsxTags.has('CrudForm') && facts.jsxAttributes.has('initialValues'), 'CrudForm JSX with initialValues'),
|
|
2025
|
+
]
|
|
2026
|
+
}
|
|
2027
|
+
case 'OMH-070':
|
|
2028
|
+
return [
|
|
2029
|
+
check('regression.cursor-fetch', facts.functions.has('syncPage') && facts.awaitedCalls.has('fetchPage') && facts.assignments.has('cursor'), 'syncPage with awaited fetchPage and cursor assignment'),
|
|
2030
|
+
check('regression.cursor-retry', facts.loops > 0 || hasCall(facts, 'retry'), 'a retry loop or retry(...) call'),
|
|
2031
|
+
]
|
|
2032
|
+
default:
|
|
2033
|
+
return [check('case.supported', false, 'a fixed writable oracle')]
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
function runTargetTypecheck(root) {
|
|
2038
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'om-harness-oracle-'))
|
|
2039
|
+
const home = path.join(tempRoot, 'home')
|
|
2040
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
2041
|
+
const env = {
|
|
2042
|
+
PATH: process.env.PATH ?? '', HOME: home, TMPDIR: tempRoot, TEMP: tempRoot, TMP: tempRoot,
|
|
2043
|
+
XDG_CONFIG_HOME: path.join(home, '.config'), XDG_CACHE_HOME: path.join(home, '.cache'),
|
|
2044
|
+
CI: '1', NO_COLOR: '1', NEXT_TELEMETRY_DISABLED: '1', YARN_ENABLE_TELEMETRY: '0', TZ: 'UTC',
|
|
2045
|
+
}
|
|
2046
|
+
const configuredCorepackHome = process.env.COREPACK_HOME || path.join(os.homedir(), '.cache', 'node', 'corepack')
|
|
2047
|
+
const corepackHome = fs.existsSync(configuredCorepackHome) && fs.statSync(configuredCorepackHome).isDirectory()
|
|
2048
|
+
? fs.realpathSync(configuredCorepackHome)
|
|
2049
|
+
: undefined
|
|
2050
|
+
if (corepackHome) env.COREPACK_HOME = corepackHome
|
|
2051
|
+
let result
|
|
2052
|
+
try {
|
|
2053
|
+
const dependencyPath = path.join(root, 'node_modules')
|
|
2054
|
+
const invocation = sandboxedInvocation({
|
|
2055
|
+
command: process.platform === 'win32' ? 'yarn.cmd' : 'yarn',
|
|
2056
|
+
args: ['typecheck', '--tsBuildInfoFile', path.join(tempRoot, 'tsconfig.tsbuildinfo')],
|
|
2057
|
+
cwd: root,
|
|
2058
|
+
writableRoots: [root, tempRoot],
|
|
2059
|
+
readOnlyRoots: [...(fs.existsSync(dependencyPath) ? [fs.realpathSync(dependencyPath)] : []), ...(corepackHome ? [corepackHome] : [])],
|
|
2060
|
+
networkAllowed: false,
|
|
2061
|
+
env,
|
|
2062
|
+
})
|
|
2063
|
+
result = spawnSync(invocation.command, invocation.args, {
|
|
2064
|
+
cwd: invocation.cwd,
|
|
2065
|
+
env: invocation.env,
|
|
2066
|
+
encoding: 'utf8',
|
|
2067
|
+
shell: false,
|
|
2068
|
+
timeout: TYPECHECK_TIMEOUT_MS,
|
|
2069
|
+
windowsHide: true,
|
|
2070
|
+
})
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
result = { status: null, signal: null, stdout: '', stderr: '', error }
|
|
2073
|
+
} finally {
|
|
2074
|
+
fs.rmSync(tempRoot, { recursive: true, force: true })
|
|
2075
|
+
}
|
|
2076
|
+
if (result.error?.code === 'ETIMEDOUT' || result.signal) {
|
|
2077
|
+
return check('target.typecheck', false, `yarn typecheck must complete within ${TYPECHECK_TIMEOUT_MS}ms`)
|
|
2078
|
+
}
|
|
2079
|
+
if (result.error) return check('target.typecheck', false, `yarn typecheck could not start: ${result.error.message}`)
|
|
2080
|
+
if (result.status !== 0) {
|
|
2081
|
+
const summary = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.trim().replaceAll(root, '<target>').slice(0, 1000)
|
|
2082
|
+
return check('target.typecheck', false, `yarn typecheck failed${summary ? `: ${summary}` : ''}`)
|
|
2083
|
+
}
|
|
2084
|
+
return check('target.typecheck', true, 'yarn typecheck succeeds')
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
export function evaluateWritableAstOracle({ root: requestedRoot, caseId, phase }) {
|
|
2088
|
+
if (!path.isAbsolute(requestedRoot)) throw new Error('root must be absolute')
|
|
2089
|
+
if (!WRITABLE_CASES[caseId]) throw new Error(`unsupported writable case: ${caseId}`)
|
|
2090
|
+
if (!['before', 'after'].includes(phase)) throw new Error('phase must be before or after')
|
|
2091
|
+
const root = fs.realpathSync(requestedRoot)
|
|
2092
|
+
const ts = loadTargetTypeScript(root)
|
|
2093
|
+
const definition = WRITABLE_CASES[caseId]
|
|
2094
|
+
const checks = definition.artifacts.map((artifact) => check(`artifact:${artifact}`, artifactExists(root, artifact), `artifact ${artifact} exists`))
|
|
2095
|
+
const duplicateRoutes = duplicateRouteChecks(ts, root)
|
|
2096
|
+
checks.push(check('routes.unique', duplicateRoutes.length === 0, `generated API, backend, and frontend URL patterns are unique${duplicateRoutes.length ? ` (${duplicateRoutes.join('; ')})` : ''}`))
|
|
2097
|
+
const sourceFiles = collectSourceFiles(root, definition.sources)
|
|
2098
|
+
checks.push(check('source.present', sourceFiles.length > 0, 'at least one case-owned TypeScript source file'))
|
|
2099
|
+
if (sourceFiles.length) checks.push(...caseChecks(ts, caseId, collectFacts(ts, sourceFiles), root, sourceFiles))
|
|
2100
|
+
if (phase === 'after') checks.push(runTargetTypecheck(root))
|
|
2101
|
+
const failures = checks.filter((entry) => !entry.passed).map((entry) => `${entry.id}: ${entry.requirement}`)
|
|
2102
|
+
return { passed: failures.length === 0, failures, checks }
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function main() {
|
|
2106
|
+
let options
|
|
2107
|
+
try {
|
|
2108
|
+
options = parseArgs(process.argv.slice(2))
|
|
2109
|
+
const result = evaluateWritableAstOracle({ root: options.root, caseId: options.caseId, phase: options.phase })
|
|
2110
|
+
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
2111
|
+
return result.passed ? 0 : 1
|
|
2112
|
+
} catch (error) {
|
|
2113
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
2114
|
+
process.stdout.write(`${JSON.stringify({ passed: false, failures: [message], checks: [] })}\n`)
|
|
2115
|
+
return 2
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
2120
|
+
process.exitCode = main()
|
|
2121
|
+
}
|