@starci/hfs 4.0.10 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +221 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -14,6 +14,14 @@ const SUPPRESSION_FLAG = /--(?:suppressions-location|suppress-all|suppress-rule|
|
|
|
14
14
|
const ESLINT_CONFIG = /(?:^|\/)eslint\.config\.[cm]?[jt]s$/;
|
|
15
15
|
const CONFIG_SUPPRESSION = /suppressions/i;
|
|
16
16
|
|
|
17
|
+
function packageSuppressionFindings(file, pkg) {
|
|
18
|
+
const findings = [];
|
|
19
|
+
for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
|
|
20
|
+
if (SUPPRESSION_SCRIPT_NAME.test(name) || SUPPRESSION_FLAG.test(String(command))) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} script ${name} manages lint suppressions (${String(command).slice(0, 80)}); delete the script and fix the findings in the code`));
|
|
21
|
+
}
|
|
22
|
+
return findings;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/** The findings of R104 over the tracked paths `files` of the repository at `repoRoot`. */
|
|
18
26
|
export function lintSuppressionFindings({ repoRoot, files }) {
|
|
19
27
|
const findings = [];
|
|
@@ -25,9 +33,7 @@ export function lintSuppressionFindings({ repoRoot, files }) {
|
|
|
25
33
|
if (text !== null && CONFIG_SUPPRESSION.test(text)) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} passes a suppressions configuration to eslint; a repository suppresses nothing`));
|
|
26
34
|
} else if (file === 'package.json' || file.endsWith('/package.json')) {
|
|
27
35
|
const pkg = readJson(repoRoot, file);
|
|
28
|
-
|
|
29
|
-
if (SUPPRESSION_SCRIPT_NAME.test(name) || SUPPRESSION_FLAG.test(String(command))) findings.push(found(LINT_SUPPRESSION_FILE, file, `${file} script ${name} manages lint suppressions (${String(command).slice(0, 80)}); delete the script and fix the findings in the code`));
|
|
30
|
-
}
|
|
36
|
+
findings.push(...packageSuppressionFindings(file, pkg));
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
return findings;
|
|
@@ -15,6 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
17
17
|
import { found, readJson, readText } from './read.mjs';
|
|
18
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
18
19
|
|
|
19
20
|
const MONO_WORKSPACES = 'HFS_MONO_WORKSPACES';
|
|
20
21
|
const MONO_FE_WORKSPACE = 'HFS_MONO_FE_WORKSPACE';
|
|
@@ -51,7 +52,7 @@ function workspacesOf(files) {
|
|
|
51
52
|
const parts = file.split('/');
|
|
52
53
|
if (parts.length === 4 && parts[0] === 'fe' && (parts[1] === 'apps' || parts[1] === 'packages') && parts[3] === MANIFEST) folders.add(parts.slice(0, 3).join('/'));
|
|
53
54
|
}
|
|
54
|
-
return [...folders].sort();
|
|
55
|
+
return [...folders].sort(byCodeUnit);
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** R143: the root package.json declares the workspaces, the package manager and turbo. */
|
|
@@ -66,6 +67,24 @@ function rootFindings(repoRoot) {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/** R144: the manifest of each fe workspace. */
|
|
70
|
+
function appWorkspaceScriptFindings(file, project, name, pkg, scripts) {
|
|
71
|
+
const findings = [];
|
|
72
|
+
const expected = feAppPackageName(project, name);
|
|
73
|
+
if (pkg.name !== expected) findings.push(found(MONO_FE_WORKSPACE, file, `${file} is named ${JSON.stringify(pkg.name ?? null)}; the fe app ${name} is the workspace ${expected}.`, { expected }));
|
|
74
|
+
const want = Object.keys(FE_APP_SCRIPTS).sort(byCodeUnit);
|
|
75
|
+
const off = [...new Set([...want, ...Object.keys(scripts)])].sort(byCodeUnit).filter((key) => scripts[key] !== FE_APP_SCRIPTS[key]);
|
|
76
|
+
if (off.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has scripts ${JSON.stringify(scripts)}; an fe app workspace has exactly ${JSON.stringify(FE_APP_SCRIPTS)} (turbo runs build, dev, lint and typecheck; differs at ${off.join(', ')}).`, { scripts: off }));
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function packageWorkspaceScriptFindings(file, scripts) {
|
|
81
|
+
const findings = [];
|
|
82
|
+
const missing = FE_PACKAGE_SCRIPTS.filter((key) => typeof scripts[key] !== 'string' || !scripts[key]);
|
|
83
|
+
if (missing.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has no ${missing.join(', ')} script; an fe package workspace builds, type-checks and lints (turbo builds it before the apps that import it).`, { scripts: missing }));
|
|
84
|
+
else if (scripts.lint !== WORKSPACE_LINT) findings.push(found(MONO_FE_WORKSPACE, file, `${file} lints with ${JSON.stringify(scripts.lint)}; a workspace lints with ${JSON.stringify(WORKSPACE_LINT)}, the one lint of the app scoped to it.`, { scripts: ['lint'] }));
|
|
85
|
+
return findings;
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
function workspaceManifestFindings(repoRoot, project, workspaces) {
|
|
70
89
|
const findings = [];
|
|
71
90
|
for (const folder of workspaces) {
|
|
@@ -75,21 +94,32 @@ function workspaceManifestFindings(repoRoot, project, workspaces) {
|
|
|
75
94
|
const name = folder.split('/')[2];
|
|
76
95
|
if (pkg.private !== true) findings.push(found(MONO_FE_WORKSPACE, file, `${file} is not \`"private": true\`; a workspace of the app is never published.`));
|
|
77
96
|
const scripts = pkg.scripts ?? {};
|
|
78
|
-
if (folder.startsWith('fe/apps/'))
|
|
79
|
-
|
|
80
|
-
if (pkg.name !== expected) findings.push(found(MONO_FE_WORKSPACE, file, `${file} is named ${JSON.stringify(pkg.name ?? null)}; the fe app ${name} is the workspace ${expected}.`, { expected }));
|
|
81
|
-
const want = Object.keys(FE_APP_SCRIPTS).sort();
|
|
82
|
-
const off = [...new Set([...want, ...Object.keys(scripts)])].sort().filter((key) => scripts[key] !== FE_APP_SCRIPTS[key]);
|
|
83
|
-
if (off.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has scripts ${JSON.stringify(scripts)}; an fe app workspace has exactly ${JSON.stringify(FE_APP_SCRIPTS)} (turbo runs build, dev, lint and typecheck; differs at ${off.join(', ')}).`, { scripts: off }));
|
|
84
|
-
} else {
|
|
85
|
-
const missing = FE_PACKAGE_SCRIPTS.filter((key) => typeof scripts[key] !== 'string' || !scripts[key]);
|
|
86
|
-
if (missing.length) findings.push(found(MONO_FE_WORKSPACE, file, `${file} has no ${missing.join(', ')} script; an fe package workspace builds, type-checks and lints (turbo builds it before the apps that import it).`, { scripts: missing }));
|
|
87
|
-
else if (scripts.lint !== WORKSPACE_LINT) findings.push(found(MONO_FE_WORKSPACE, file, `${file} lints with ${JSON.stringify(scripts.lint)}; a workspace lints with ${JSON.stringify(WORKSPACE_LINT)}, the one lint of the app scoped to it.`, { scripts: ['lint'] }));
|
|
88
|
-
}
|
|
97
|
+
if (folder.startsWith('fe/apps/')) findings.push(...appWorkspaceScriptFindings(file, project, name, pkg, scripts));
|
|
98
|
+
else findings.push(...packageWorkspaceScriptFindings(file, scripts));
|
|
89
99
|
}
|
|
90
100
|
return findings;
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
const nestProjectShape = (project) => JSON.stringify({ type: project.type, root: project.root, sourceRoot: project.sourceRoot });
|
|
104
|
+
|
|
105
|
+
/** One finding per declared project whose type, root or sourceRoot departs from the application layout. */
|
|
106
|
+
function nestProjectFindings(cli, projects, declared) {
|
|
107
|
+
const findings = [];
|
|
108
|
+
for (const name of projects.filter((project) => declared.includes(project))) {
|
|
109
|
+
const project = cli.projects[name] ?? {};
|
|
110
|
+
if (project.type !== 'application' || project.root !== `apps/${name}` || project.sourceRoot !== `apps/${name}/src`) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} project ${name} is ${nestProjectShape(project)}; it is {"type":"application","root":"apps/${name}","sourceRoot":"apps/${name}/src"}.`, { project: name }));
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The finding when the default project of nest-cli.json is no declared api app, or null. */
|
|
116
|
+
function nestRootFinding(cli, beApps) {
|
|
117
|
+
const apis = beApps.filter((app) => app.kind === 'api').map((app) => app.name);
|
|
118
|
+
const rootApp = /^apps\/([^/]+)$/.exec(String(cli.root ?? ''))?.[1];
|
|
119
|
+
if (apis.includes(rootApp) && cli.sourceRoot === `apps/${rootApp}/src`) return null;
|
|
120
|
+
return found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} has root ${JSON.stringify(cli.root ?? null)} and sourceRoot ${JSON.stringify(cli.sourceRoot ?? null)}; the default project is an api app (${apis.join(', ') || 'none declared'}): root apps/<api>, sourceRoot apps/<api>/src.`, { root: cli.root ?? null });
|
|
121
|
+
}
|
|
122
|
+
|
|
93
123
|
/** R145: be/nest-cli.json is the Nest monorepo of exactly the declared be apps. */
|
|
94
124
|
function nestFindings(repoRoot, files, beApps) {
|
|
95
125
|
if (!files.includes(NEST_CLI)) return [];
|
|
@@ -97,16 +127,12 @@ function nestFindings(repoRoot, files, beApps) {
|
|
|
97
127
|
if (!cli) return [found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} is not readable JSON.`)];
|
|
98
128
|
const findings = [];
|
|
99
129
|
if (cli.monorepo !== true) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} is not \`"monorepo": true\`; the back end is a Nest monorepo of be/apps/<app>, even with one service.`));
|
|
100
|
-
const declared = beApps.map((app) => app.name).sort();
|
|
101
|
-
const projects = Object.keys(cli.projects ?? {}).sort();
|
|
130
|
+
const declared = beApps.map((app) => app.name).sort(byCodeUnit);
|
|
131
|
+
const projects = Object.keys(cli.projects ?? {}).sort(byCodeUnit);
|
|
102
132
|
if (!sameList(projects, declared)) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} has projects ${JSON.stringify(projects)}, but hfs.json declares the be apps ${JSON.stringify(declared)}; one project per declared app, no other.`, { projects, declared }));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
const apis = beApps.filter((app) => app.kind === 'api').map((app) => app.name);
|
|
108
|
-
const rootApp = /^apps\/([^/]+)$/.exec(String(cli.root ?? ''))?.[1];
|
|
109
|
-
if (!apis.includes(rootApp) || cli.sourceRoot !== `apps/${rootApp}/src`) findings.push(found(MONO_NEST_PROJECTS, NEST_CLI, `${NEST_CLI} has root ${JSON.stringify(cli.root ?? null)} and sourceRoot ${JSON.stringify(cli.sourceRoot ?? null)}; the default project is an api app (${apis.join(', ') || 'none declared'}): root apps/<api>, sourceRoot apps/<api>/src.`, { root: cli.root ?? null }));
|
|
133
|
+
findings.push(...nestProjectFindings(cli, projects, declared));
|
|
134
|
+
const rootFinding = nestRootFinding(cli, beApps);
|
|
135
|
+
if (rootFinding) findings.push(rootFinding);
|
|
110
136
|
return findings;
|
|
111
137
|
}
|
|
112
138
|
|
|
@@ -129,6 +155,40 @@ const packageOf = (specifier) => {
|
|
|
129
155
|
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
130
156
|
};
|
|
131
157
|
|
|
158
|
+
/** Records the first importing file of every package `file` imports without the workspace declaring it. */
|
|
159
|
+
function collectUndeclaredImports(ts, file, text, isAlias, pkg, declared, missing) {
|
|
160
|
+
for (const { fileName: specifier } of ts.preProcessFile(text, true, true).importedFiles) {
|
|
161
|
+
if (specifier.startsWith('.') || specifier.startsWith('/') || isBuiltin(specifier) || isAlias(specifier)) continue;
|
|
162
|
+
const name = packageOf(specifier);
|
|
163
|
+
if (declared.has(name) || name === pkg.name) continue;
|
|
164
|
+
if (!missing.has(name)) missing.set(name, file);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The first importing file of every undeclared package a workspace's sources import, by package name. */
|
|
169
|
+
function undeclaredImports(repoRoot, files, folder, pkg, declared, ts) {
|
|
170
|
+
const aliases = aliasesOf(ts, path.join(repoRoot, folder));
|
|
171
|
+
const isAlias = (specifier) => aliases.some((alias) => (alias.exact ? specifier === alias.prefix : specifier.startsWith(alias.prefix)));
|
|
172
|
+
const missing = new Map();
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
if (!file.startsWith(`${folder}/`) || !SOURCE.test(file) || file.endsWith('.d.ts')) continue;
|
|
175
|
+
const text = readText(repoRoot, file);
|
|
176
|
+
if (text !== null) collectUndeclaredImports(ts, file, text, isAlias, pkg, declared, missing);
|
|
177
|
+
}
|
|
178
|
+
return missing;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function workspaceMissingDependencies(repoRoot, files, folder, names, ts) {
|
|
182
|
+
const manifest = `${folder}/${MANIFEST}`;
|
|
183
|
+
const pkg = readJson(repoRoot, manifest);
|
|
184
|
+
if (!pkg) return [];
|
|
185
|
+
const missing = undeclaredImports(repoRoot, files, folder, pkg, declaredIn(pkg), ts);
|
|
186
|
+
return [...missing].sort(byCodeUnit).map(([name, file]) => {
|
|
187
|
+
const declaration = names.get(name) ? `declare the workspace package ${name} as "*"` : `declare ${name} in the dependencies of ${manifest} (one version across the workspace, R14)`;
|
|
188
|
+
return found(MONO_WORKSPACE_DEP, manifest, `${file} imports ${name}, which ${manifest} does not declare; ${declaration}.`, { dependency: name, importedBy: file });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
132
192
|
/** R146: what each fe workspace imports, it declares; the root declares no workspace package. */
|
|
133
193
|
function workspaceDepFindings(repoRoot, files, workspaces) {
|
|
134
194
|
const findings = [];
|
|
@@ -138,30 +198,7 @@ function workspaceDepFindings(repoRoot, files, workspaces) {
|
|
|
138
198
|
if (!workspaces.length) return findings;
|
|
139
199
|
const ts = typescriptFor(repoRoot);
|
|
140
200
|
if (!ts) return [...findings, found(MONO_WORKSPACE_DEP, MANIFEST, 'the fe workspaces cannot be judged: no TypeScript resolves from the app; install the app\'s dependencies.', { missing: ['typescript'] })];
|
|
141
|
-
for (const folder of workspaces)
|
|
142
|
-
const manifest = `${folder}/${MANIFEST}`;
|
|
143
|
-
const pkg = readJson(repoRoot, manifest);
|
|
144
|
-
if (!pkg) continue;
|
|
145
|
-
const declared = declaredIn(pkg);
|
|
146
|
-
const aliases = aliasesOf(ts, path.join(repoRoot, folder));
|
|
147
|
-
const isAlias = (specifier) => aliases.some((alias) => (alias.exact ? specifier === alias.prefix : specifier.startsWith(alias.prefix)));
|
|
148
|
-
const missing = new Map();
|
|
149
|
-
for (const file of files) {
|
|
150
|
-
if (!file.startsWith(`${folder}/`) || !SOURCE.test(file) || file.endsWith('.d.ts')) continue;
|
|
151
|
-
const text = readText(repoRoot, file);
|
|
152
|
-
if (text === null) continue;
|
|
153
|
-
for (const { fileName: specifier } of ts.preProcessFile(text, true, true).importedFiles) {
|
|
154
|
-
if (specifier.startsWith('.') || specifier.startsWith('/') || isBuiltin(specifier) || isAlias(specifier)) continue;
|
|
155
|
-
const name = packageOf(specifier);
|
|
156
|
-
if (declared.has(name) || name === pkg.name) continue;
|
|
157
|
-
if (!missing.has(name)) missing.set(name, file);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
for (const [name, file] of [...missing].sort()) {
|
|
161
|
-
const sibling = names.get(name);
|
|
162
|
-
findings.push(found(MONO_WORKSPACE_DEP, manifest, `${file} imports ${name}, which ${manifest} does not declare; ${sibling ? `declare the workspace package ${name} as "*"` : `declare ${name} in the dependencies of ${manifest} (one version across the workspace, R14)`}.`, { dependency: name, importedBy: file }));
|
|
163
|
-
}
|
|
164
|
-
}
|
|
201
|
+
for (const folder of workspaces) findings.push(...workspaceMissingDependencies(repoRoot, files, folder, names, ts));
|
|
165
202
|
return findings;
|
|
166
203
|
}
|
|
167
204
|
|
|
@@ -11,6 +11,21 @@ import { found, readJson } from './read.mjs';
|
|
|
11
11
|
const PEER_INTEGRATION_MISSING = 'HFS_PEER_INTEGRATION_MISSING';
|
|
12
12
|
const MANIFEST = 'package.json';
|
|
13
13
|
const CATALOG_FILE = new URL('../../../knowledge/hfs/peer-integrations.yaml', import.meta.url);
|
|
14
|
+
const WHITESPACE = /\s/;
|
|
15
|
+
const isDigit = (char) => char >= '0' && char <= '9';
|
|
16
|
+
|
|
17
|
+
/** The index of the first non-whitespace character of `text` at or after `from`. */
|
|
18
|
+
function skipWhitespace(text, from) {
|
|
19
|
+
let index = from;
|
|
20
|
+
while (index < text.length && WHITESPACE.test(text[index])) index += 1;
|
|
21
|
+
return index;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The index after an optional range operator (`^`, `~`, `>`, `>=`, `=`) at `from`. */
|
|
25
|
+
function skipOperator(text, from) {
|
|
26
|
+
if (text[from] === '>') return text[from + 1] === '=' ? from + 2 : from + 1;
|
|
27
|
+
return text[from] === '^' || text[from] === '~' || text[from] === '=' ? from + 1 : from;
|
|
28
|
+
}
|
|
14
29
|
|
|
15
30
|
/** The pairs of the catalog: [{ id, when: [{ package, major? }], requires, why }]. */
|
|
16
31
|
function peerIntegrationPairs() {
|
|
@@ -21,8 +36,14 @@ function peerIntegrationPairs() {
|
|
|
21
36
|
|
|
22
37
|
/** The major a dependency spec allows (`11.2.5`, `^11.0.0`, `~11.1`, `>=11 <12`), or null when the spec names none (a tag, a link, `*`). */
|
|
23
38
|
function majorOf(spec) {
|
|
24
|
-
const
|
|
25
|
-
|
|
39
|
+
const text = String(spec);
|
|
40
|
+
let index = skipWhitespace(text, skipOperator(text, skipWhitespace(text, 0)));
|
|
41
|
+
if (text[index] === 'v') index += 1;
|
|
42
|
+
const digits = index;
|
|
43
|
+
while (isDigit(text[index] ?? '')) index += 1;
|
|
44
|
+
if (index === digits) return null;
|
|
45
|
+
const next = text[index];
|
|
46
|
+
return next === undefined || next === '.' || WHITESPACE.test(next) ? Number(text.slice(digits, index)) : null;
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
/** The findings of R111 over the app root package.json of `repoRoot`; `pairs` defaults to the catalog. */
|
|
@@ -31,7 +52,7 @@ export function peerIntegrationFindings({ repoRoot, files, pairs = peerIntegrati
|
|
|
31
52
|
const pkg = readJson(repoRoot, MANIFEST);
|
|
32
53
|
if (!pkg) return [];
|
|
33
54
|
const runtime = pkg.dependencies ?? {};
|
|
34
|
-
const declared = { ...
|
|
55
|
+
const declared = { ...pkg.devDependencies, ...runtime };
|
|
35
56
|
const findings = [];
|
|
36
57
|
for (const pair of pairs) {
|
|
37
58
|
const applies = pair.when.every((entry) => typeof declared[entry.package] === 'string' && (entry.major === undefined || majorOf(declared[entry.package]) === entry.major));
|
|
@@ -11,16 +11,65 @@ import { found, readJson, readText } from './read.mjs';
|
|
|
11
11
|
const CI_MISSING_CANON = 'HFS_CI_MISSING_CANON';
|
|
12
12
|
const CI_FILE = '.github/workflows/ci.yml';
|
|
13
13
|
const hfsPackage = '@starci/cli';
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
14
|
+
const RUN_PREFIX = /^\s*(?:-\s+)?run:/;
|
|
15
|
+
const NPX_PREFIX = /^npx\s+(?:(?:--no-install|-y)\s+)?/;
|
|
16
|
+
const LINT_COMMAND = /^(?:@starci\/cli(?:@(\S+))?|starci)\s+app\s+lint/;
|
|
17
|
+
const NPM_RUN_PREFIX = 'npm run ';
|
|
18
|
+
const SCRIPT_NAME = /^[\w:.-]+/;
|
|
19
|
+
const ARGS_SEPARATOR = /^\s+--\s+/;
|
|
20
|
+
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
21
|
+
const WHITESPACE = /\s/;
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
/** The command of a workflow `run:` line (`run: <command>` or `- run: <command>`): the text after `run:` without surrounding whitespace, on one line. */
|
|
24
|
+
function runCommandOf(line) {
|
|
25
|
+
const prefix = RUN_PREFIX.exec(line);
|
|
26
|
+
if (!prefix) return undefined;
|
|
27
|
+
const rest = line.slice(prefix[0].length);
|
|
28
|
+
const command = rest.trim();
|
|
29
|
+
if (command) return LINE_TERMINATOR.test(command) ? undefined : command;
|
|
30
|
+
return [...rest].findLast((character) => !LINE_TERMINATOR.test(character));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const commandsOf = (text) => text.split(/\r?\n/).map(runCommandOf).filter(Boolean).map((command) => command.replace(/^["']|["']$/g, ''));
|
|
34
|
+
|
|
35
|
+
/** The commands of a `a && b` chain: the whitespace around each `&&` belongs to the separator. */
|
|
36
|
+
function chainedCommands(command) {
|
|
37
|
+
const pieces = command.split('&&');
|
|
38
|
+
const last = pieces.length - 1;
|
|
39
|
+
return pieces.map((piece, index) => {
|
|
40
|
+
const head = index > 0 ? piece.trimStart() : piece;
|
|
41
|
+
return index < last ? head.trimEnd() : head;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `npm run <script>` or `npm run <script> -- <args>` as { script, args } (args undefined without `--`), else null. */
|
|
46
|
+
function parseNpmRun(command) {
|
|
47
|
+
if (!command.startsWith(NPM_RUN_PREFIX)) return null;
|
|
48
|
+
const rest = command.slice(NPM_RUN_PREFIX.length);
|
|
49
|
+
const script = SCRIPT_NAME.exec(rest)?.[0];
|
|
50
|
+
if (!script) return null;
|
|
51
|
+
const tail = rest.slice(script.length);
|
|
52
|
+
if (!tail) return { script, args: undefined };
|
|
53
|
+
const separator = ARGS_SEPARATOR.exec(tail);
|
|
54
|
+
const args = separator ? tail.slice(separator[0].length) : null;
|
|
55
|
+
return args !== null && !LINE_TERMINATOR.test(args) ? { script, args } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** { version } of a `starci app lint` command (`version` undefined when no @starci/cli version is named), else null. */
|
|
59
|
+
function lintCommandOf(command) {
|
|
60
|
+
const rest = command.replace(NPX_PREFIX, '');
|
|
61
|
+
const match = LINT_COMMAND.exec(rest);
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const tail = rest.slice(match[0].length);
|
|
64
|
+
if (tail && !(WHITESPACE.test(tail[0]) && !LINE_TERMINATOR.test(tail.slice(1)))) return null;
|
|
65
|
+
return { version: match[1] };
|
|
66
|
+
}
|
|
19
67
|
|
|
20
68
|
/** `npm run <script> -- <args>` spelled out as the root package.json script it runs; any other command is itself. */
|
|
21
69
|
function expanded(command, scripts) {
|
|
22
|
-
const run =
|
|
23
|
-
|
|
70
|
+
const run = parseNpmRun(command);
|
|
71
|
+
if (!run || typeof scripts[run.script] !== 'string') return command;
|
|
72
|
+
return run.args ? `${scripts[run.script]} ${run.args}` : scripts[run.script];
|
|
24
73
|
}
|
|
25
74
|
|
|
26
75
|
/** The findings of R13 over the repository at `repoRoot`; `pins` is the parsed canon-pins.yaml pin map. */
|
|
@@ -30,10 +79,10 @@ export function pipelineFindings({ repoRoot, files, pins }) {
|
|
|
30
79
|
const ci = files.includes(CI_FILE) ? readText(repoRoot, CI_FILE) : null;
|
|
31
80
|
if (ci !== null) {
|
|
32
81
|
const scripts = readJson(repoRoot, 'package.json')?.scripts ?? {};
|
|
33
|
-
const lints = commandsOf(ci).flatMap((command) => expanded(command, scripts)
|
|
82
|
+
const lints = commandsOf(ci).flatMap((command) => chainedCommands(expanded(command, scripts))).map(lintCommandOf).filter(Boolean);
|
|
34
83
|
if (!lints.length) findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} has no step that runs \`starci app lint\` (directly or through \`npm run lint\`); CI runs the one lint entry, which includes the repository check`, { step: 'starci app lint' }));
|
|
35
|
-
else if (pinned && !lints.some((match) => match
|
|
36
|
-
findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs starci app lint at ${lints[0]
|
|
84
|
+
else if (pinned && !lints.some((match) => match.version === undefined || match.version === pinned)) {
|
|
85
|
+
findings.push(found(CI_MISSING_CANON, CI_FILE, `${CI_FILE} runs starci app lint at ${lints[0].version}, but ${hfsPackage} is pinned at ${pinned}; run the pinned version`, { step: 'starci app lint', pinned }));
|
|
37
86
|
}
|
|
38
87
|
}
|
|
39
88
|
return findings;
|
|
@@ -39,6 +39,19 @@ function proofCommands(record) {
|
|
|
39
39
|
return Object.entries(demands).flatMap(([kind, demand]) => (typeof demand?.command === 'string' ? [{ kind, command: demand.command }] : []));
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function recordProofFindings({ repoRoot, file, record, tracked, resolver }) {
|
|
43
|
+
const findings = [];
|
|
44
|
+
for (const { kind, command } of proofCommands(record)) {
|
|
45
|
+
for (const target of commandFiles(command)) {
|
|
46
|
+
if (tracked.has(target) || fs.existsSync(path.join(repoRoot, target))) continue;
|
|
47
|
+
const classified = resolver.classifyPath(target);
|
|
48
|
+
if (classified.status === 'owned' && classified.tracking === 'ignored') continue;
|
|
49
|
+
findings.push(found(PROOF_COMMAND_FILE_MISSING, file, `${file} requiresProof.${kind}.command runs ${target}, which the repository does not hold; a proof command is runnable as written: create the file or rewrite the proof plan`, { kind, missing: target }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return findings;
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
/**
|
|
43
56
|
* The findings of R105 over the tracked paths `files` of the app at `repoRoot`; `resolver` names the ignored slots and `sides` the side
|
|
44
57
|
* names of hfs.json (be, fe), the repositories a record of this app names.
|
|
@@ -54,14 +67,7 @@ export function proofCommandFindings({ repoRoot, files, resolver, sides }) {
|
|
|
54
67
|
let record;
|
|
55
68
|
try { record = parseYaml(text); } catch { continue; }
|
|
56
69
|
if (typeof record?.repository === 'string' && !own.has(record.repository)) continue;
|
|
57
|
-
|
|
58
|
-
for (const target of commandFiles(command)) {
|
|
59
|
-
if (tracked.has(target) || fs.existsSync(path.join(repoRoot, target))) continue;
|
|
60
|
-
const classified = resolver.classifyPath(target);
|
|
61
|
-
if (classified.status === 'owned' && classified.tracking === 'ignored') continue;
|
|
62
|
-
findings.push(found(PROOF_COMMAND_FILE_MISSING, file, `${file} requiresProof.${kind}.command runs ${target}, which the repository does not hold; a proof command is runnable as written: create the file or rewrite the proof plan`, { kind, missing: target }));
|
|
63
|
-
}
|
|
64
|
-
}
|
|
70
|
+
findings.push(...recordProofFindings({ repoRoot, file, record, tracked, resolver }));
|
|
65
71
|
}
|
|
66
72
|
return findings;
|
|
67
73
|
}
|
|
@@ -14,6 +14,16 @@ const LOCAL_RULE_FILE = /(?:^|\/)(?:eslint-local-rules|eslint-plugin|eslint-loca
|
|
|
14
14
|
const CHECK_SCRIPT = /(?:^|\/)(?:scripts|tools)\/check-[^/]+$/;
|
|
15
15
|
const RUNS_CHECK = /(?:^|[\s&|;(])(?:\.\/)?(?:scripts|tools)\/check-[^\s&|;)]+/;
|
|
16
16
|
|
|
17
|
+
/** The findings of the package.json scripts of `file` that run a repository-local check. */
|
|
18
|
+
function scriptFindings(repoRoot, file) {
|
|
19
|
+
const findings = [];
|
|
20
|
+
const pkg = readJson(repoRoot, file);
|
|
21
|
+
for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
|
|
22
|
+
if (RUNS_CHECK.test(String(command))) findings.push(found(REPO_LOCAL_CHECK, file, `${file} script ${name} runs a repository-local check (${String(command).slice(0, 80)}); delete the script, the check belongs to the .claude runtime`));
|
|
23
|
+
}
|
|
24
|
+
return findings;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
/** The findings of R103 over the tracked paths `files` of the repository at `repoRoot`. */
|
|
18
28
|
export function repoLocalCheckFindings({ repoRoot, files }) {
|
|
19
29
|
const findings = [];
|
|
@@ -21,12 +31,7 @@ export function repoLocalCheckFindings({ repoRoot, files }) {
|
|
|
21
31
|
if (file.includes('node_modules/')) continue;
|
|
22
32
|
if (LOCAL_RULE_FILE.test(file)) findings.push(found(REPO_LOCAL_CHECK, file, `${file} is a local lint rule or plugin; a repository keeps no rule of its own: the rule is proposed to the canon (@starci/eslint-canon-*) in the .claude runtime`));
|
|
23
33
|
else if (CHECK_SCRIPT.test(file)) findings.push(found(REPO_LOCAL_CHECK, file, `${file} is a check kept in the repository; every check lives in the .claude runtime (starci app check, the canons), so a repository has none to keep`));
|
|
24
|
-
else if (file === 'package.json' || file.endsWith('/package.json'))
|
|
25
|
-
const pkg = readJson(repoRoot, file);
|
|
26
|
-
for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
|
|
27
|
-
if (RUNS_CHECK.test(String(command))) findings.push(found(REPO_LOCAL_CHECK, file, `${file} script ${name} runs a repository-local check (${String(command).slice(0, 80)}); delete the script, the check belongs to the .claude runtime`));
|
|
28
|
-
}
|
|
29
|
-
}
|
|
34
|
+
else if (file === 'package.json' || file.endsWith('/package.json')) findings.push(...scriptFindings(repoRoot, file));
|
|
30
35
|
}
|
|
31
36
|
return findings;
|
|
32
37
|
}
|
|
@@ -19,6 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
21
21
|
import { found, readJson, readText } from './read.mjs';
|
|
22
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
22
23
|
|
|
23
24
|
const SAGA_STEP_COMPENSATION = 'BE_SAGA_STEP_COMPENSATION';
|
|
24
25
|
const SAGA_STATE_VERSIONED = 'BE_SAGA_STATE_VERSIONED';
|
|
@@ -70,7 +71,9 @@ function statesVersion(ts, sourceFile) {
|
|
|
70
71
|
const memberNames = (members) => new Map(members.filter((member) => ts.isPropertySignature(member) && ts.isIdentifier(member.name)).map((member) => [member.name.text, member.type]));
|
|
71
72
|
for (const statement of sourceFile.statements) {
|
|
72
73
|
if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
73
|
-
|
|
74
|
+
let members = null;
|
|
75
|
+
if (ts.isInterfaceDeclaration(statement)) members = statement.members;
|
|
76
|
+
else if (ts.isTypeAliasDeclaration(statement) && ts.isTypeLiteralNode(statement.type)) members = statement.type.members;
|
|
74
77
|
if (members === null) continue;
|
|
75
78
|
const named = memberNames(members);
|
|
76
79
|
if (named.has('status') && named.get('version')?.kind === ts.SyntaxKind.NumberKeyword) versioned = true;
|
|
@@ -90,7 +93,125 @@ function readsEventId(ts, sourceFile) {
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/** The sagas of the app: every folder of the saga kind (`be/src/features/saga/<saga>/`). */
|
|
93
|
-
const featuresOf = (files) => [...new Set(files.map((file) => SAGA_FILE.exec(file)?.[1]).filter((feature) => feature !== undefined))].sort();
|
|
96
|
+
const featuresOf = (files) => [...new Set(files.map((file) => SAGA_FILE.exec(file)?.[1]).filter((feature) => feature !== undefined))].sort(byCodeUnit);
|
|
97
|
+
|
|
98
|
+
function filesForFeature(files, pattern, feature) {
|
|
99
|
+
return files.filter((file) => pattern.test(file) && pattern.exec(file)[1] === feature);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function featureParts(files, feature) {
|
|
103
|
+
const steps = new Map(filesForFeature(files, STEP, feature).map((file) => [STEP.exec(file)[2], file]));
|
|
104
|
+
const compensations = new Map(filesForFeature(files, COMPENSATION, feature).map((file) => [COMPENSATION.exec(file)[2], file]));
|
|
105
|
+
const orchestrators = filesForFeature(files, ORCHESTRATOR, feature);
|
|
106
|
+
const states = new Map(filesForFeature(files, STATE, feature).map((file) => [STATE.exec(file)[2], file]));
|
|
107
|
+
return { steps, compensations, orchestrators, states };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function stepCompensationFindings(folder, steps, compensations, orchestrators) {
|
|
111
|
+
const findings = [];
|
|
112
|
+
for (const [stem, file] of steps) if (!compensations.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a saga step with no compensation: add ${folder}compensations/${stem}.compensation.ts, the command that undoes it; every forward step of a saga has one.`, { step: stem }));
|
|
113
|
+
for (const [stem, file] of compensations) if (!steps.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a compensation of no step: ${folder}steps/${stem}.saga-step.ts does not exist; a compensation undoes the step of the same name.`, { step: stem }));
|
|
114
|
+
if (orchestrators.length === 0 && (steps.size > 0 || compensations.size > 0)) findings.push(found(SAGA_STEP_COMPENSATION, folder, `${folder} holds steps or compensations but no orchestrator: add <saga>.saga.service.ts, the list of the steps and their compensations.`));
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function orchestratorFindings(ts, repoRoot, orchestrator, folder, steps, compensations, states) {
|
|
119
|
+
const text = readText(repoRoot, orchestrator);
|
|
120
|
+
if (text === null) return [];
|
|
121
|
+
const findings = [];
|
|
122
|
+
const sourceFile = parse(ts, text);
|
|
123
|
+
const imported = new Set(specifiersOf(ts, sourceFile).map((specifier) => path.posix.normalize(path.posix.join(path.posix.dirname(orchestrator), specifier))));
|
|
124
|
+
for (const file of [...steps.values(), ...compensations.values()]) {
|
|
125
|
+
if (!imported.has(file.replace(/\.ts$/, ''))) findings.push(found(SAGA_STEP_COMPENSATION, orchestrator, `${orchestrator} does not list ${file}: the orchestrator imports every step and every compensation of its saga, so a step nobody runs cannot hide.`, { step: file }));
|
|
126
|
+
}
|
|
127
|
+
const stem = ORCHESTRATOR.exec(orchestrator)[2];
|
|
128
|
+
const stateFile = states.get(stem);
|
|
129
|
+
if (stateFile === undefined) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} has no ${folder}${stem}.saga-state.ts: the persisted state of a run is typed there, with its status and its version fence.`, { saga: stem }));
|
|
130
|
+
if (!specifiersOf(ts, sourceFile).some((specifier) => STORE_MODULE.test(specifier))) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} does not use the fenced store of platform/saga: a run is persisted and moved only through it (every transition names the version it read), never by a write of its own.`, { saga: stem }));
|
|
131
|
+
return findings;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stateFindings(ts, repoRoot, states) {
|
|
135
|
+
const findings = [];
|
|
136
|
+
for (const [stem, file] of states) {
|
|
137
|
+
const text = readText(repoRoot, file);
|
|
138
|
+
if (text !== null && !statesVersion(ts, parse(ts, text))) findings.push(found(SAGA_STATE_VERSIONED, file, `${file} exports no state type with a \`status\` and a \`version: number\`; a saga run is persisted with a version, the fence that stops a zombie delivery from moving it twice.`, { saga: stem }));
|
|
139
|
+
}
|
|
140
|
+
return findings;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const STEP_EVENT = { label: 'step', action: 'puts on the wire', key: (stem) => `step:${stem}` };
|
|
144
|
+
const COMPENSATION_EVENT = { label: 'compensation', action: 'answers', key: (stem) => `compensation:${stem}` };
|
|
145
|
+
|
|
146
|
+
/** Findings for the steps (or compensations) that name no event or an event no vendored contract declares; fills `eventOfFile`. */
|
|
147
|
+
function declaredEventFindings(ts, repoRoot, kind, entries, events, eventOfFile) {
|
|
148
|
+
const findings = [];
|
|
149
|
+
for (const [stem, file] of entries) {
|
|
150
|
+
const text = readText(repoRoot, file);
|
|
151
|
+
const name = text === null ? null : eventOf(ts, parse(ts, text));
|
|
152
|
+
eventOfFile.set(kind.key(stem), name);
|
|
153
|
+
if (name === null) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names no event: a ${kind.label} of a saga declares \`readonly event = "<name>"\`, the event of the contract it ${kind.action}.`, { step: stem }));
|
|
154
|
+
else if (!events.has(name)) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names the event "${name}", which no be/contracts/<service>/events.json declares; every event of a saga is in a vendored contract.`, { step: stem, event: name }));
|
|
155
|
+
}
|
|
156
|
+
return findings;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Findings for compensation events whose contract does not name the step event it undoes, with the compensation paths found. */
|
|
160
|
+
function compensatesFindings(compensations, events, eventOfFile) {
|
|
161
|
+
const findings = [];
|
|
162
|
+
const compensationPaths = [];
|
|
163
|
+
for (const [stem, file] of compensations) {
|
|
164
|
+
const compensationEvent = eventOfFile.get(COMPENSATION_EVENT.key(stem));
|
|
165
|
+
const stepEvent = eventOfFile.get(STEP_EVENT.key(stem));
|
|
166
|
+
if (compensationEvent && stepEvent && events.has(compensationEvent) && events.get(compensationEvent).compensates !== stepEvent) {
|
|
167
|
+
findings.push(found(SAGA_EVENT_CONTRACT, file, `${file}: the contract of "${compensationEvent}" (${events.get(compensationEvent).contract}) does not declare \`compensates: "${stepEvent}"\`; the failure event of a compensation says which step's event it undoes.`, { step: stem, event: compensationEvent }));
|
|
168
|
+
}
|
|
169
|
+
if (compensationEvent) compensationPaths.push({ file, stem, event: compensationEvent });
|
|
170
|
+
}
|
|
171
|
+
return { findings, compensationPaths };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function eventFindings(ts, repoRoot, steps, compensations, events) {
|
|
175
|
+
const eventOfFile = new Map();
|
|
176
|
+
const findings = [
|
|
177
|
+
...declaredEventFindings(ts, repoRoot, STEP_EVENT, steps, events, eventOfFile),
|
|
178
|
+
...declaredEventFindings(ts, repoRoot, COMPENSATION_EVENT, compensations, events, eventOfFile),
|
|
179
|
+
];
|
|
180
|
+
const compensated = compensatesFindings(compensations, events, eventOfFile);
|
|
181
|
+
findings.push(...compensated.findings);
|
|
182
|
+
return { findings, compensationPaths: compensated.compensationPaths };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function consumerFindings(ts, repoRoot, feature, files) {
|
|
186
|
+
const findings = [];
|
|
187
|
+
for (const file of filesForFeature(files, CONSUMER, feature)) {
|
|
188
|
+
const text = readText(repoRoot, file);
|
|
189
|
+
if (text !== null && !readsEventId(ts, parse(ts, text))) findings.push(found(SAGA_CONSUMER_DEDUPE, file, `${file} is a consumer of the saga ${feature} but never passes the id of the delivery on (\`message.eventId\`); the saga takes every event through the inbox, so the consumer hands the id to its command.`, { feature }));
|
|
190
|
+
}
|
|
191
|
+
return findings;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function featureFindings(ts, repoRoot, files, feature, events) {
|
|
195
|
+
const { steps, compensations, orchestrators, states } = featureParts(files, feature);
|
|
196
|
+
const folder = `be/src/features/saga/${feature}/`;
|
|
197
|
+
const findings = stepCompensationFindings(folder, steps, compensations, orchestrators);
|
|
198
|
+
for (const orchestrator of orchestrators) findings.push(...orchestratorFindings(ts, repoRoot, orchestrator, folder, steps, compensations, states));
|
|
199
|
+
findings.push(...stateFindings(ts, repoRoot, states));
|
|
200
|
+
const eventResult = eventFindings(ts, repoRoot, steps, compensations, events);
|
|
201
|
+
findings.push(...eventResult.findings, ...consumerFindings(ts, repoRoot, feature, files));
|
|
202
|
+
return { findings, compensationPaths: eventResult.compensationPaths };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function compensationE2eFindings(compensationPaths, specs) {
|
|
206
|
+
const findings = [];
|
|
207
|
+
for (const { file, stem, event } of compensationPaths) {
|
|
208
|
+
const named = specs.filter(({ text }) => text.includes(event));
|
|
209
|
+
const expected = `be/src/tests/e2e/<area>/<name>.e2e-spec.ts`;
|
|
210
|
+
if (named.length === 0) findings.push(found(SAGA_E2E_MISSING, file, `${file}: the compensation path of "${event}" has no e2e spec; add ${expected}, which boots the apps with useTestWorld, drives the failure and reads the compensated state back.`, { step: stem, event }));
|
|
211
|
+
else if (!named.some(({ text }) => INJECTION.test(text))) findings.push(found(SAGA_E2E_MISSING, named[0].file, `${named[0].file} names "${event}" but injects no failure; the compensation path is proven with a fault through the world (an outage of an infra service or an app with \`during\`, \`cut\`, \`latency\`, a fake's \`failNext\`, \`interruptDatabase\`), not only the happy flow.`, { step: stem, event }));
|
|
212
|
+
}
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
94
215
|
|
|
95
216
|
/** Findings of R169 to R173 over the tracked paths `files` of the app at `repoRoot`. */
|
|
96
217
|
export function sagaFindings({ repoRoot, files }) {
|
|
@@ -98,73 +219,19 @@ export function sagaFindings({ repoRoot, files }) {
|
|
|
98
219
|
if (features.length === 0) return [];
|
|
99
220
|
const ts = typescriptFor(repoRoot);
|
|
100
221
|
if (ts === null) return [];
|
|
101
|
-
const findings = [];
|
|
102
222
|
const events = new Map();
|
|
103
223
|
for (const contract of files.filter((file) => CONTRACT.test(file))) {
|
|
104
224
|
for (const [name, event] of Object.entries(readJson(repoRoot, contract)?.events ?? {})) events.set(name, { contract, ...event });
|
|
105
225
|
}
|
|
106
226
|
const specs = files.filter((file) => E2E_SPEC.test(file)).map((file) => ({ file, text: readText(repoRoot, file) ?? '' })).filter(({ text }) => USE_TEST_WORLD.test(text));
|
|
227
|
+
const findings = [];
|
|
107
228
|
const compensationPaths = [];
|
|
108
229
|
for (const feature of features) {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const orchestrators = under(ORCHESTRATOR);
|
|
113
|
-
const states = new Map(under(STATE).map((file) => [STATE.exec(file)[2], file]));
|
|
114
|
-
const folder = `be/src/features/saga/${feature}/`;
|
|
115
|
-
// R169
|
|
116
|
-
for (const [stem, file] of steps) if (!compensations.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a saga step with no compensation: add ${folder}compensations/${stem}.compensation.ts, the command that undoes it; every forward step of a saga has one.`, { step: stem }));
|
|
117
|
-
for (const [stem, file] of compensations) if (!steps.has(stem)) findings.push(found(SAGA_STEP_COMPENSATION, file, `${file} is a compensation of no step: ${folder}steps/${stem}.saga-step.ts does not exist; a compensation undoes the step of the same name.`, { step: stem }));
|
|
118
|
-
if (orchestrators.length === 0 && (steps.size > 0 || compensations.size > 0)) findings.push(found(SAGA_STEP_COMPENSATION, folder, `${folder} holds steps or compensations but no orchestrator: add <saga>.saga.service.ts, the list of the steps and their compensations.`));
|
|
119
|
-
for (const orchestrator of orchestrators) {
|
|
120
|
-
const text = readText(repoRoot, orchestrator);
|
|
121
|
-
if (text === null) continue;
|
|
122
|
-
const sourceFile = parse(ts, text);
|
|
123
|
-
const imported = specifiersOf(ts, sourceFile).map((specifier) => path.posix.normalize(path.posix.join(path.posix.dirname(orchestrator), specifier)));
|
|
124
|
-
for (const file of [...steps.values(), ...compensations.values()]) {
|
|
125
|
-
if (!imported.includes(file.replace(/\.ts$/, ''))) findings.push(found(SAGA_STEP_COMPENSATION, orchestrator, `${orchestrator} does not list ${file}: the orchestrator imports every step and every compensation of its saga, so a step nobody runs cannot hide.`, { step: file }));
|
|
126
|
-
}
|
|
127
|
-
// R170
|
|
128
|
-
const stem = ORCHESTRATOR.exec(orchestrator)[2];
|
|
129
|
-
const stateFile = states.get(stem);
|
|
130
|
-
if (stateFile === undefined) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} has no ${folder}${stem}.saga-state.ts: the persisted state of a run is typed there, with its status and its version fence.`, { saga: stem }));
|
|
131
|
-
if (!specifiersOf(ts, sourceFile).some((specifier) => STORE_MODULE.test(specifier))) findings.push(found(SAGA_STATE_VERSIONED, orchestrator, `${orchestrator} does not use the fenced store of platform/saga: a run is persisted and moved only through it (every transition names the version it read), never by a write of its own.`, { saga: stem }));
|
|
132
|
-
}
|
|
133
|
-
for (const [stem, file] of states) {
|
|
134
|
-
const text = readText(repoRoot, file);
|
|
135
|
-
if (text !== null && !statesVersion(ts, parse(ts, text))) findings.push(found(SAGA_STATE_VERSIONED, file, `${file} exports no state type with a \`status\` and a \`version: number\`; a saga run is persisted with a version, the fence that stops a zombie delivery from moving it twice.`, { saga: stem }));
|
|
136
|
-
}
|
|
137
|
-
// R171
|
|
138
|
-
const eventOfFile = new Map();
|
|
139
|
-
for (const [kind, map] of [['step', steps], ['compensation', compensations]]) {
|
|
140
|
-
for (const [stem, file] of map) {
|
|
141
|
-
const text = readText(repoRoot, file);
|
|
142
|
-
const name = text === null ? null : eventOf(ts, parse(ts, text));
|
|
143
|
-
eventOfFile.set(`${kind}:${stem}`, name);
|
|
144
|
-
if (name === null) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names no event: a ${kind} of a saga declares \`readonly event = "<name>"\`, the event of the contract it ${kind === 'step' ? 'puts on the wire' : 'answers'}.`, { step: stem }));
|
|
145
|
-
else if (!events.has(name)) findings.push(found(SAGA_EVENT_CONTRACT, file, `${file} names the event "${name}", which no be/contracts/<service>/events.json declares; every event of a saga is in a vendored contract.`, { step: stem, event: name }));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (const [stem, file] of compensations) {
|
|
149
|
-
const compensationEvent = eventOfFile.get(`compensation:${stem}`);
|
|
150
|
-
const stepEvent = eventOfFile.get(`step:${stem}`);
|
|
151
|
-
if (compensationEvent && stepEvent && events.has(compensationEvent) && events.get(compensationEvent).compensates !== stepEvent) {
|
|
152
|
-
findings.push(found(SAGA_EVENT_CONTRACT, file, `${file}: the contract of "${compensationEvent}" (${events.get(compensationEvent).contract}) does not declare \`compensates: "${stepEvent}"\`; the failure event of a compensation says which step's event it undoes.`, { step: stem, event: compensationEvent }));
|
|
153
|
-
}
|
|
154
|
-
if (compensationEvent) compensationPaths.push({ file, stem, event: compensationEvent });
|
|
155
|
-
}
|
|
156
|
-
// R172
|
|
157
|
-
for (const file of under(CONSUMER)) {
|
|
158
|
-
const text = readText(repoRoot, file);
|
|
159
|
-
if (text !== null && !readsEventId(ts, parse(ts, text))) findings.push(found(SAGA_CONSUMER_DEDUPE, file, `${file} is a consumer of the saga ${feature} but never passes the id of the delivery on (\`message.eventId\`); the saga takes every event through the inbox, so the consumer hands the id to its command.`, { feature }));
|
|
160
|
-
}
|
|
230
|
+
const result = featureFindings(ts, repoRoot, files, feature, events);
|
|
231
|
+
findings.push(...result.findings);
|
|
232
|
+
compensationPaths.push(...result.compensationPaths);
|
|
161
233
|
}
|
|
162
234
|
// R173
|
|
163
|
-
|
|
164
|
-
const named = specs.filter(({ text }) => text.includes(event));
|
|
165
|
-
const expected = `be/src/tests/e2e/<area>/<name>.e2e-spec.ts`;
|
|
166
|
-
if (named.length === 0) findings.push(found(SAGA_E2E_MISSING, file, `${file}: the compensation path of "${event}" has no e2e spec; add ${expected}, which boots the apps with useTestWorld, drives the failure and reads the compensated state back.`, { step: stem, event }));
|
|
167
|
-
else if (!named.some(({ text }) => INJECTION.test(text))) findings.push(found(SAGA_E2E_MISSING, named[0].file, `${named[0].file} names "${event}" but injects no failure; the compensation path is proven with a fault through the world (an outage of an infra service or an app with \`during\`, \`cut\`, \`latency\`, a fake's \`failNext\`, \`interruptDatabase\`), not only the happy flow.`, { step: stem, event }));
|
|
168
|
-
}
|
|
235
|
+
findings.push(...compensationE2eFindings(compensationPaths, specs));
|
|
169
236
|
return findings;
|
|
170
237
|
}
|