@starci/hfs 4.0.9 → 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/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- 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 +247 -273
- 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 +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- 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 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- 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/lib.mjs +6 -0
- 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 +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- 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 +3 -2
- 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 +90 -45
- 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/fs-kind.mjs +14 -4
- 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/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- 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 +103 -2
- 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
|
@@ -29,26 +29,39 @@ const TEST_CALLEES = new Set(['it', 'test']);
|
|
|
29
29
|
const typescriptFor = (repoRoot) => loadTypescript(repoRoot, HERE);
|
|
30
30
|
|
|
31
31
|
/** The event-bus pattern file name of an event name: dots become dashes (`order.placed` -> `order-placed`). */
|
|
32
|
-
export const stemOfEvent = (name) => name.
|
|
32
|
+
export const stemOfEvent = (name) => name.replaceAll('.', '-');
|
|
33
|
+
|
|
34
|
+
/** The initializer of a `static` property member with an identifier name, parentheses and `as` casts stripped, else null. */
|
|
35
|
+
function staticMemberValue(ts, member) {
|
|
36
|
+
if (!ts.isPropertyDeclaration(member) || !member.initializer || !member.name || !ts.isIdentifier(member.name)) return null;
|
|
37
|
+
const isStatic = member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword);
|
|
38
|
+
if (!isStatic) return null;
|
|
39
|
+
let value = member.initializer;
|
|
40
|
+
while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression;
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
33
43
|
|
|
34
44
|
/** The `eventName` and `version` literals a class declares as `static readonly` members, with the line of the class. */
|
|
45
|
+
function eventClassInfo(ts, node) {
|
|
46
|
+
let name = null;
|
|
47
|
+
let version = null;
|
|
48
|
+
for (const member of node.members) {
|
|
49
|
+
const value = staticMemberValue(ts, member);
|
|
50
|
+
if (!value) continue;
|
|
51
|
+
if (member.name.text === 'eventName') name = literalText(ts, value);
|
|
52
|
+
if (member.name.text === 'version' && ts.isNumericLiteral(value)) version = Number(value.text);
|
|
53
|
+
}
|
|
54
|
+
if (name !== null || version !== null || node.name) return { className: node.name?.text ?? '<anonymous>', name, version };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
function readEventClass(ts, text, file) {
|
|
36
59
|
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
37
60
|
const classes = [];
|
|
38
61
|
const visit = (node) => {
|
|
39
62
|
if (ts.isClassDeclaration(node)) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for (const member of node.members) {
|
|
43
|
-
if (!ts.isPropertyDeclaration(member) || !member.initializer || !member.name || !ts.isIdentifier(member.name)) continue;
|
|
44
|
-
const isStatic = member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword);
|
|
45
|
-
if (!isStatic) continue;
|
|
46
|
-
let value = member.initializer;
|
|
47
|
-
while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression;
|
|
48
|
-
if (member.name.text === 'eventName') name = literalText(ts, value);
|
|
49
|
-
if (member.name.text === 'version' && ts.isNumericLiteral(value)) version = Number(value.text);
|
|
50
|
-
}
|
|
51
|
-
if (name !== null || version !== null || node.name) classes.push({ className: node.name?.text ?? '<anonymous>', name, version });
|
|
63
|
+
const info = eventClassInfo(ts, node);
|
|
64
|
+
if (info) classes.push(info);
|
|
52
65
|
}
|
|
53
66
|
ts.forEachChild(node, visit);
|
|
54
67
|
};
|
|
@@ -56,34 +69,42 @@ function readEventClass(ts, text, file) {
|
|
|
56
69
|
return classes;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
/** The service and file stem of a `be/src/modules/events/<service>/<event>.event.ts` path, else null. */
|
|
73
|
+
function eventFileParts(file) {
|
|
74
|
+
if (!file.startsWith(EVENT_FILE_PREFIX) || !file.endsWith(EVENT_SUFFIX)) return null;
|
|
75
|
+
const rest = file.slice(EVENT_FILE_PREFIX.length).split('/');
|
|
76
|
+
if (rest.length !== 2) return null;
|
|
77
|
+
const [service, base] = rest;
|
|
78
|
+
return { service, stem: base.slice(0, -EVENT_SUFFIX.length) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function recordEventClass(ts, text, file, { service, stem }, findings, classesByService) {
|
|
82
|
+
const classes = readEventClass(ts, text, file).filter((c) => c.name !== null || c.version !== null);
|
|
83
|
+
if (classes.length !== 1 || classes[0].name === null || classes[0].version === null) {
|
|
84
|
+
const candidates = classes.length === 1 ? 'one without both literals' : `${classes.length} candidates`;
|
|
85
|
+
findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} must declare exactly one event class with literal \`static readonly eventName\` and \`static readonly version\` members (the typed event of be/contracts/${service}/${CONTRACT_NAME}); found ${candidates}.`, { service }));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const [event] = classes;
|
|
89
|
+
if (stemOfEvent(event.name) !== stem) findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} declares the event \`${event.name}\`, so the file is \`${stemOfEvent(event.name)}${EVENT_SUFFIX}\`: a class file is named after its event.`, { service, event: event.name }));
|
|
90
|
+
if (!classesByService.has(service)) classesByService.set(service, new Map());
|
|
91
|
+
classesByService.get(service).set(event.name, { file, version: event.version });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function eventClassesOf(repoRoot, files, ts) {
|
|
65
95
|
const findings = [];
|
|
66
96
|
const classesByService = new Map();
|
|
67
97
|
for (const file of files) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (rest.length !== 2) continue;
|
|
71
|
-
const [service, base] = rest;
|
|
72
|
-
const stem = base.slice(0, -EVENT_SUFFIX.length);
|
|
98
|
+
const parts = eventFileParts(file);
|
|
99
|
+
if (!parts) continue;
|
|
73
100
|
const text = readText(repoRoot, file);
|
|
74
101
|
if (text === null) continue;
|
|
75
|
-
|
|
76
|
-
if (classes.length !== 1 || classes[0].name === null || classes[0].version === null) {
|
|
77
|
-
findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} must declare exactly one event class with literal \`static readonly eventName\` and \`static readonly version\` members (the typed event of be/contracts/${service}/${CONTRACT_NAME}); found ${classes.length === 1 ? 'one without both literals' : `${classes.length} candidates`}.`, { service }));
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
const [event] = classes;
|
|
81
|
-
if (stemOfEvent(event.name) !== stem) {
|
|
82
|
-
findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} declares the event \`${event.name}\`, so the file is \`${stemOfEvent(event.name)}${EVENT_SUFFIX}\`: a class file is named after its event.`, { service, event: event.name }));
|
|
83
|
-
}
|
|
84
|
-
if (!classesByService.has(service)) classesByService.set(service, new Map());
|
|
85
|
-
classesByService.get(service).set(event.name, { file, version: event.version });
|
|
102
|
+
recordEventClass(ts, text, file, parts, findings, classesByService);
|
|
86
103
|
}
|
|
104
|
+
return { findings, classesByService };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function eventContractsOf(repoRoot, files) {
|
|
87
108
|
const contracts = new Map();
|
|
88
109
|
for (const file of files) {
|
|
89
110
|
if (!file.startsWith(CONTRACT_PREFIX) || !file.endsWith(`/${CONTRACT_NAME}`)) continue;
|
|
@@ -91,6 +112,11 @@ export function eventClassContractFindings({ repoRoot, files, repo }) {
|
|
|
91
112
|
if (rest.length !== 2) continue;
|
|
92
113
|
contracts.set(rest[0], { file, doc: readJson(repoRoot, file) });
|
|
93
114
|
}
|
|
115
|
+
return contracts;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function classContractFindings(classesByService, contracts) {
|
|
119
|
+
const findings = [];
|
|
94
120
|
for (const [service, byName] of classesByService) {
|
|
95
121
|
const contract = contracts.get(service);
|
|
96
122
|
for (const [name, { file, version }] of byName) {
|
|
@@ -100,6 +126,11 @@ export function eventClassContractFindings({ repoRoot, files, repo }) {
|
|
|
100
126
|
else if (entry.version !== version) findings.push(found(EVENT_CLASS_CONTRACT, file, `${file} declares \`${name}\` at version ${version} but ${contract.file} lists version ${entry.version}; a version changes in the class and the contract together.`, { service, event: name }));
|
|
101
127
|
}
|
|
102
128
|
}
|
|
129
|
+
return findings;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function contractClassFindings(classesByService, contracts) {
|
|
133
|
+
const findings = [];
|
|
103
134
|
for (const [service, contract] of contracts) {
|
|
104
135
|
const events = contract.doc?.events;
|
|
105
136
|
if (events === null || typeof events !== 'object') continue;
|
|
@@ -110,6 +141,30 @@ export function eventClassContractFindings({ repoRoot, files, repo }) {
|
|
|
110
141
|
return findings;
|
|
111
142
|
}
|
|
112
143
|
|
|
144
|
+
/** Findings of BE_EVENT_CLASS_CONTRACT. */
|
|
145
|
+
export function eventClassContractFindings({ repoRoot, files, repo }) {
|
|
146
|
+
const patterns = repo.sides?.be?.patterns ?? [];
|
|
147
|
+
if (!patterns.includes('event-bus')) return [];
|
|
148
|
+
const ts = typescriptFor(repoRoot);
|
|
149
|
+
if (!ts) return [];
|
|
150
|
+
const classes = eventClassesOf(repoRoot, files, ts);
|
|
151
|
+
const contracts = eventContractsOf(repoRoot, files);
|
|
152
|
+
return [...classes.findings, ...classContractFindings(classes.classesByService, contracts), ...contractClassFindings(classes.classesByService, contracts)];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Adds the `<pattern>` of every `<pattern>/<scenario>:` test title (the first argument of an `it(` or `test(` call) of the source. */
|
|
156
|
+
function addProvenScenarios(ts, source, proven) {
|
|
157
|
+
const visit = (node) => {
|
|
158
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && TEST_CALLEES.has(node.expression.text)) {
|
|
159
|
+
const title = literalText(ts, node.arguments[0]);
|
|
160
|
+
const colon = title === null ? -1 : title.indexOf(':');
|
|
161
|
+
if (colon > 0) proven.add(title.slice(0, colon));
|
|
162
|
+
}
|
|
163
|
+
ts.forEachChild(node, visit);
|
|
164
|
+
};
|
|
165
|
+
visit(source);
|
|
166
|
+
}
|
|
167
|
+
|
|
113
168
|
/** The scenario ids a back end proves: every `<pattern>/<scenario>:` test title of an e2e or integration spec. */
|
|
114
169
|
function provenScenarios(ts, repoRoot, files) {
|
|
115
170
|
const proven = new Set();
|
|
@@ -117,16 +172,7 @@ function provenScenarios(ts, repoRoot, files) {
|
|
|
117
172
|
if (!TEST_FOLDERS.some((folder) => file.startsWith(folder)) || !SPEC_SUFFIXES.some((suffix) => file.endsWith(suffix))) continue;
|
|
118
173
|
const text = readText(repoRoot, file);
|
|
119
174
|
if (text === null) continue;
|
|
120
|
-
|
|
121
|
-
const visit = (node) => {
|
|
122
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && TEST_CALLEES.has(node.expression.text)) {
|
|
123
|
-
const title = literalText(ts, node.arguments[0]);
|
|
124
|
-
const colon = title === null ? -1 : title.indexOf(':');
|
|
125
|
-
if (colon > 0) proven.add(title.slice(0, colon));
|
|
126
|
-
}
|
|
127
|
-
ts.forEachChild(node, visit);
|
|
128
|
-
};
|
|
129
|
-
visit(source);
|
|
175
|
+
addProvenScenarios(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), proven);
|
|
130
176
|
}
|
|
131
177
|
return proven;
|
|
132
178
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// an object without one is a finding on the document. The finding names the service and every problem.
|
|
8
8
|
import { operationProblems, parseDocument, parseSchema, rootFieldsOf } from '../../lib/graphql-contract.mjs';
|
|
9
9
|
import { found, readText } from './read.mjs';
|
|
10
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
10
11
|
|
|
11
12
|
export const FE_GRAPHQL_CONTRACT = 'FE_GRAPHQL_CONTRACT';
|
|
12
13
|
|
|
@@ -30,9 +31,23 @@ function contractsOf(repoRoot, files, findings) {
|
|
|
30
31
|
return contracts;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function operationFindings(file, document, operation, contracts) {
|
|
35
|
+
const label = operation.name ?? `anonymous ${operation.operation}`;
|
|
36
|
+
const [first] = rootFieldsOf(operation);
|
|
37
|
+
const serving = contracts.filter(({ schema }) => schema.types.get(schema.roots[operation.operation])?.fields?.has(first));
|
|
38
|
+
if (serving.length === 0) {
|
|
39
|
+
const where = contracts.length === 0 ? 'the app keeps no be/contracts/<service>/schema.graphql' : `no contract of ${contracts.map((c) => c.path).join(', ')} declares ${operation.operation} ${first}`;
|
|
40
|
+
return [found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} asks ${operation.operation} ${first}, but ${where}; the front end sends only what a back end serves.`, { operation: label, service: null })];
|
|
41
|
+
}
|
|
42
|
+
const [contract] = serving;
|
|
43
|
+
const problems = operationProblems(contract.schema, document, operation);
|
|
44
|
+
if (problems.length > 0) return [found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} does not match the ${contract.service} contract (${contract.path}): ${problems.join('; ')}.`, { operation: label, service: contract.service, problems })];
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
/** The findings of R113 over the tracked paths `files` of the app at `repoRoot`. */
|
|
34
49
|
export function feContractFindings({ repoRoot, files }) {
|
|
35
|
-
const documents = files.filter(isDocument).sort();
|
|
50
|
+
const documents = files.filter(isDocument).sort(byCodeUnit);
|
|
36
51
|
if (documents.length === 0) return [];
|
|
37
52
|
const findings = [];
|
|
38
53
|
const contracts = contractsOf(repoRoot, files, findings);
|
|
@@ -46,21 +61,7 @@ export function feContractFindings({ repoRoot, files }) {
|
|
|
46
61
|
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file} is not a GraphQL document: ${error.message}.`));
|
|
47
62
|
continue;
|
|
48
63
|
}
|
|
49
|
-
for (const operation of document.operations)
|
|
50
|
-
const label = operation.name ?? `anonymous ${operation.operation}`;
|
|
51
|
-
const [first] = rootFieldsOf(operation);
|
|
52
|
-
const serving = contracts.filter(({ schema }) => schema.types.get(schema.roots[operation.operation])?.fields?.has(first));
|
|
53
|
-
if (serving.length === 0) {
|
|
54
|
-
const where = contracts.length === 0 ? 'the app keeps no be/contracts/<service>/schema.graphql' : `no contract of ${contracts.map((c) => c.path).join(', ')} declares ${operation.operation} ${first}`;
|
|
55
|
-
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} asks ${operation.operation} ${first}, but ${where}; the front end sends only what a back end serves.`, { operation: label, service: null }));
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
const [contract] = serving;
|
|
59
|
-
const problems = operationProblems(contract.schema, document, operation);
|
|
60
|
-
if (problems.length > 0) {
|
|
61
|
-
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} does not match the ${contract.service} contract (${contract.path}): ${problems.join('; ')}.`, { operation: label, service: contract.service, problems }));
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
+
for (const operation of document.operations) findings.push(...operationFindings(file, document, operation, contracts));
|
|
64
65
|
}
|
|
65
66
|
return findings;
|
|
66
67
|
}
|
|
@@ -15,34 +15,54 @@ const TEST_DIRECTORY = /(?:^|\/)(?:e2e|__tests__|__mocks__|test-support)\//;
|
|
|
15
15
|
export const TEST_TOOL_FILE = /(?:^|\/)(?:(?:vitest|jest|playwright)\.[^/]+|cypress\.config\.[^/]+|tsconfig\.e2e\.json|e2e\.ya?ml)$/;
|
|
16
16
|
// The edition rule (rules/edition.mjs, L01) applies the same lists to the whole app tree in lite: they are the test vocabulary.
|
|
17
17
|
export const TEST_SCRIPT_NAME = /^(?:pre|post)?test(?::|$)/;
|
|
18
|
-
|
|
19
|
-
export const
|
|
18
|
+
const TEST_RUNNER_COMMAND_SOURCE = String.raw`(?:^|[\s&|;(])(?:npx\s+)?(?:vitest|jest|playwright|cypress|mocha)(?=$|[\s&|;)])|\bnode\s+(?:--\S+\s+)*--test\b`;
|
|
19
|
+
export const TEST_RUNNER_COMMAND = new RegExp(TEST_RUNNER_COMMAND_SOURCE);
|
|
20
|
+
const TEST_DEPENDENCY_SOURCE = String.raw`^(?:vitest|@vitest\/.+|playwright|playwright-core|@playwright\/.+|jest|@jest\/.+|ts-jest|babel-jest|@types\/jest|jest-[\w-]+|mocha|@types\/mocha|cypress|@testing-library\/.+|jsdom|@types\/jsdom|happy-dom|axe-core|@axe-core\/.+|vitest-axe|@starci\/(?:vitest|jest|playwright)-preset)$`;
|
|
21
|
+
export const TEST_DEPENDENCY = new RegExp(TEST_DEPENDENCY_SOURCE);
|
|
20
22
|
export const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
21
23
|
|
|
22
24
|
/** True for a path FE_NO_TESTS owns: a spec file, a test directory or a test-tool file. */
|
|
23
25
|
export const isFeTestPath = (file) => SPEC_FILE.test(file) || TEST_DIRECTORY.test(file) || TEST_TOOL_FILE.test(file);
|
|
24
26
|
|
|
27
|
+
function testPathFinding(file) {
|
|
28
|
+
let what = 'test tooling';
|
|
29
|
+
if (SPEC_FILE.test(file)) what = 'a test file';
|
|
30
|
+
else if (TEST_DIRECTORY.test(file)) what = 'inside a test directory';
|
|
31
|
+
return found(FE_NO_TESTS, file, `${file} is ${what}; a front-end repository has no tests, no test tooling and no exception: delete it`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function testDependencyFindings(file, pkg) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
37
|
+
for (const name of Object.keys(pkg[section] ?? {})) {
|
|
38
|
+
if (TEST_DEPENDENCY.test(name)) findings.push(found(FE_NO_TESTS, file, `${file} ${section} names ${name}, a test dependency; a front-end repository has no tests: delete the dependency`));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function packageTestFindings(repoRoot, file) {
|
|
45
|
+
if (file !== 'package.json' && !file.endsWith('/package.json')) return [];
|
|
46
|
+
if (file.includes('node_modules/')) return [];
|
|
47
|
+
const pkg = readJson(repoRoot, file);
|
|
48
|
+
if (!pkg || typeof pkg !== 'object') return [];
|
|
49
|
+
const findings = [];
|
|
50
|
+
for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
|
|
51
|
+
if (TEST_SCRIPT_NAME.test(name) || TEST_RUNNER_COMMAND.test(String(command))) findings.push(found(FE_NO_TESTS, file, `${file} script ${name} is a test script (${String(command).slice(0, 80)}); a front-end repository has no tests: delete the script`));
|
|
52
|
+
}
|
|
53
|
+
findings.push(...testDependencyFindings(file, pkg));
|
|
54
|
+
return findings;
|
|
55
|
+
}
|
|
56
|
+
|
|
25
57
|
/** The findings of R97 over the tracked paths `files` of a front-end repository at `repoRoot`. */
|
|
26
58
|
export function feNoTestsFindings({ repoRoot, files }) {
|
|
27
59
|
const findings = [];
|
|
28
60
|
for (const file of files) {
|
|
29
61
|
if (isFeTestPath(file)) {
|
|
30
|
-
|
|
31
|
-
findings.push(found(FE_NO_TESTS, file, `${file} is ${what}; a front-end repository has no tests, no test tooling and no exception: delete it`));
|
|
62
|
+
findings.push(testPathFinding(file));
|
|
32
63
|
continue;
|
|
33
64
|
}
|
|
34
|
-
|
|
35
|
-
if (file.includes('node_modules/')) continue;
|
|
36
|
-
const pkg = readJson(repoRoot, file);
|
|
37
|
-
if (!pkg || typeof pkg !== 'object') continue;
|
|
38
|
-
for (const [name, command] of Object.entries(pkg.scripts ?? {})) {
|
|
39
|
-
if (TEST_SCRIPT_NAME.test(name) || TEST_RUNNER_COMMAND.test(String(command))) findings.push(found(FE_NO_TESTS, file, `${file} script ${name} is a test script (${String(command).slice(0, 80)}); a front-end repository has no tests: delete the script`));
|
|
40
|
-
}
|
|
41
|
-
for (const section of DEPENDENCY_SECTIONS) {
|
|
42
|
-
for (const name of Object.keys(pkg[section] ?? {})) {
|
|
43
|
-
if (TEST_DEPENDENCY.test(name)) findings.push(found(FE_NO_TESTS, file, `${file} ${section} names ${name}, a test dependency; a front-end repository has no tests: delete the dependency`));
|
|
44
|
-
}
|
|
45
|
-
}
|
|
65
|
+
findings.push(...packageTestFindings(repoRoot, file));
|
|
46
66
|
}
|
|
47
67
|
return findings;
|
|
48
68
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { found, readJson } from './read.mjs';
|
|
16
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
16
17
|
|
|
17
18
|
const WIRE_GENERATED = 'FE_WIRE_GENERATED';
|
|
18
19
|
const I18N_PLACEMENT = 'FE_I18N_PLACEMENT';
|
|
@@ -77,7 +78,7 @@ function placementFindings({ repoRoot, app, tracked }) {
|
|
|
77
78
|
const base = `apps/${app}`;
|
|
78
79
|
const findings = [];
|
|
79
80
|
if (!tracked.includes(`${base}/src/proxy.ts`)) findings.push(found(I18N_PLACEMENT, `${base}/src/proxy.ts`, `${app} has no src/proxy.ts; locale routing lives in proxy.ts`, { app }));
|
|
80
|
-
for (const file of tracked.filter((f) => new RegExp(`^${base}/src/middleware
|
|
81
|
+
for (const file of tracked.filter((f) => new RegExp(`^${base}/src/middleware.[cm]?[jt]s$`).test(f))) findings.push(found(I18N_PLACEMENT, file, `${file} is a middleware file; Next 16 routes through src/proxy.ts`, { app }));
|
|
81
82
|
const appDir = `${base}/src/app/`;
|
|
82
83
|
for (const file of tracked.filter((f) => f.startsWith(appDir) && ROUTE_FILE.test(f))) {
|
|
83
84
|
const rel = file.slice(appDir.length);
|
|
@@ -96,7 +97,7 @@ function placementFindings({ repoRoot, app, tracked }) {
|
|
|
96
97
|
|
|
97
98
|
function catalogFindings({ repoRoot, app, tracked }) {
|
|
98
99
|
const messages = `apps/${app}/src/modules/i18n/messages/`;
|
|
99
|
-
const catalogs = tracked.filter((f) => f.startsWith(messages) && f.endsWith('.json') && f.slice(messages.length).
|
|
100
|
+
const catalogs = tracked.filter((f) => f.startsWith(messages) && f.endsWith('.json') && !f.slice(messages.length).includes('/'));
|
|
100
101
|
const keys = new Map();
|
|
101
102
|
for (const file of catalogs) {
|
|
102
103
|
const parsed = readJson(repoRoot, file);
|
|
@@ -106,7 +107,7 @@ function catalogFindings({ repoRoot, app, tracked }) {
|
|
|
106
107
|
const all = new Set([...keys.values()].flatMap((set) => [...set]));
|
|
107
108
|
const findings = [];
|
|
108
109
|
for (const [file, set] of keys) {
|
|
109
|
-
const missing = [...all].filter((key) => !set.has(key)).sort();
|
|
110
|
+
const missing = [...all].filter((key) => !set.has(key)).sort(byCodeUnit);
|
|
110
111
|
if (missing.length) findings.push(found(I18N_CATALOG, file, `${file} lacks ${missing.length} key${missing.length === 1 ? '' : 's'} another locale has (${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', ...' : ''}); every catalog holds the same keys`, { app, missing }));
|
|
111
112
|
}
|
|
112
113
|
return findings;
|
|
@@ -16,6 +16,7 @@ import path from 'node:path';
|
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
18
18
|
import { found, readText } from './read.mjs';
|
|
19
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
19
20
|
|
|
20
21
|
const INTEGRATION_SPEC_MISSING = 'BE_INTEGRATION_SPEC_MISSING';
|
|
21
22
|
|
|
@@ -70,6 +71,17 @@ function chainOf(ts, expression) {
|
|
|
70
71
|
return ts.isIdentifier(current) ? [current.text, ...names] : null;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** The initializer of `const <name> = ...` at the top level of `file`. */
|
|
75
|
+
function topLevelConst(ts, file, name) {
|
|
76
|
+
for (const statement of file.sourceFile.statements) {
|
|
77
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
78
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
79
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
/**
|
|
74
86
|
* The judge of one app: resolves an import of a parsed file to a repository-relative path, finds the top-level const of a file,
|
|
75
87
|
* and reads files once.
|
|
@@ -87,17 +99,7 @@ function judgeOf(ts, repoRoot, side) {
|
|
|
87
99
|
const rel = path.relative(repoRoot, resolved).split(path.sep).join('/');
|
|
88
100
|
return rel.startsWith('..') ? null : rel;
|
|
89
101
|
};
|
|
90
|
-
|
|
91
|
-
const constOf = (file, name) => {
|
|
92
|
-
for (const statement of file.sourceFile.statements) {
|
|
93
|
-
if (!ts.isVariableStatement(statement)) continue;
|
|
94
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
95
|
-
if (ts.isIdentifier(declaration.name) && declaration.name.text === name && declaration.initializer) return declaration.initializer;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return null;
|
|
99
|
-
};
|
|
100
|
-
return { parsed, resolve, constOf };
|
|
102
|
+
return { parsed, resolve, constOf: (file, name) => topLevelConst(ts, file, name) };
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/** Whether `rel` lies in the integration folder `folder` (its index or any file of it). */
|
|
@@ -134,53 +136,85 @@ function registersIntegration(ts, judge, element, folder) {
|
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
/** Whether `name`, exported by the module `rel`, is declared as an enum there or in the file it is re-exported from. */
|
|
139
|
+
function enumExportResult(ts, judge, rel, name, depth, statement) {
|
|
140
|
+
if (ts.isEnumDeclaration(statement) && statement.name.text === name) return true;
|
|
141
|
+
if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) return null;
|
|
142
|
+
for (const element of statement.exportClause.elements) {
|
|
143
|
+
if (element.name.text !== name) continue;
|
|
144
|
+
const original = (element.propertyName ?? element.name).text;
|
|
145
|
+
if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return exportsEnum(ts, judge, judge.resolve(rel, statement.moduleSpecifier.text), original, depth + 1);
|
|
146
|
+
return exportsEnum(ts, judge, rel, original, depth + 1);
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
137
151
|
function exportsEnum(ts, judge, rel, name, depth = 0) {
|
|
138
152
|
const file = rel ? judge.parsed(rel) : null;
|
|
139
153
|
if (!file || depth > 4) return false;
|
|
140
154
|
for (const statement of file.sourceFile.statements) {
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
for (const element of statement.exportClause.elements) {
|
|
144
|
-
if (element.name.text !== name) continue;
|
|
145
|
-
const original = (element.propertyName ?? element.name).text;
|
|
146
|
-
if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) return exportsEnum(ts, judge, judge.resolve(rel, statement.moduleSpecifier.text), original, depth + 1);
|
|
147
|
-
return exportsEnum(ts, judge, rel, original, depth + 1);
|
|
148
|
-
}
|
|
155
|
+
const result = enumExportResult(ts, judge, rel, name, depth, statement);
|
|
156
|
+
if (result !== null) return result;
|
|
149
157
|
}
|
|
150
158
|
return false;
|
|
151
159
|
}
|
|
152
160
|
|
|
153
|
-
/**
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
if (!
|
|
158
|
-
|
|
161
|
+
/** The `modules` property of a `useTestWorld({ modules })` call that `node` is, or null. */
|
|
162
|
+
function worldModulesProperty(ts, spec, node) {
|
|
163
|
+
if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression) || node.expression.text !== 'useTestWorld' || !spec.imports.has('useTestWorld')) return null;
|
|
164
|
+
const [argument] = node.arguments;
|
|
165
|
+
if (!argument || !ts.isObjectLiteralExpression(argument)) return null;
|
|
166
|
+
return argument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'modules') ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether a `useTestWorld` call of `spec` registers the integration; the variables holding a world join `worlds`. */
|
|
170
|
+
function registersInWorld(ts, judge, spec, folder, worlds) {
|
|
171
|
+
let proven = false;
|
|
159
172
|
walk(ts, spec.sourceFile, (node) => {
|
|
160
|
-
|
|
161
|
-
const [argument] = node.arguments;
|
|
162
|
-
if (!argument || !ts.isObjectLiteralExpression(argument)) return;
|
|
163
|
-
const modules = argument.properties.find((property) => ts.isPropertyAssignment(property) && ts.isIdentifier(property.name) && property.name.text === 'modules');
|
|
173
|
+
const modules = worldModulesProperty(ts, spec, node);
|
|
164
174
|
if (!modules) return;
|
|
165
175
|
if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) worlds.add(node.parent.name.text);
|
|
166
|
-
if (elementsOf(ts, judge, spec, modules.initializer).some((element) => registersIntegration(ts, judge, element, folder)))
|
|
176
|
+
if (elementsOf(ts, judge, spec, modules.initializer).some((element) => registersIntegration(ts, judge, element, folder))) proven = true;
|
|
167
177
|
});
|
|
168
|
-
|
|
178
|
+
return proven;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The local names of the enums the spec imports from the integration folder. */
|
|
182
|
+
function enumBindingsOf(ts, judge, spec, folder) {
|
|
183
|
+
return new Set(
|
|
169
184
|
[...spec.imports].filter(([, binding]) => {
|
|
170
|
-
const target = judge.resolve(rel, binding.source);
|
|
185
|
+
const target = judge.resolve(spec.rel, binding.source);
|
|
171
186
|
return insideFolder(target, folder) && exportsEnum(ts, judge, target, binding.imported);
|
|
172
187
|
}).map(([local]) => local),
|
|
173
188
|
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Whether the property access `node` of the world chain `chain` is a call of an outage verb. */
|
|
192
|
+
function isOutageCall(ts, node, chain) {
|
|
193
|
+
const [, area, name, call] = chain;
|
|
194
|
+
if (!(ts.isCallExpression(node.parent) && node.parent.expression === node)) return false;
|
|
195
|
+
return (area === 'infra' && name !== undefined && INFRA_OUTAGES.has(call))
|
|
196
|
+
|| area === 'interruptDatabase'
|
|
197
|
+
|| (OUTAGE_CALLS[area] !== undefined && name !== undefined && call === OUTAGE_CALLS[area]);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Whether `node` reaches an outage through a world variable of `worlds`. */
|
|
201
|
+
function drivesOutage(ts, node, worlds) {
|
|
202
|
+
if (!ts.isPropertyAccessExpression(node)) return false;
|
|
203
|
+
const chain = chainOf(ts, node);
|
|
204
|
+
return Boolean(chain) && worlds.has(chain[0]) && isOutageCall(ts, node, chain);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** What one spec proves about the integration in `folder`: { modules, errorCode, outage }. */
|
|
208
|
+
function judgeSpec(ts, judge, rel, folder) {
|
|
209
|
+
const spec = judge.parsed(rel);
|
|
210
|
+
const result = { modules: false, errorCode: false, outage: false };
|
|
211
|
+
if (!spec) return result;
|
|
212
|
+
const worlds = new Set();
|
|
213
|
+
result.modules = registersInWorld(ts, judge, spec, folder, worlds);
|
|
214
|
+
const enumBindings = enumBindingsOf(ts, judge, spec, folder);
|
|
174
215
|
walk(ts, spec.sourceFile, (node) => {
|
|
175
216
|
if (ts.isIdentifier(node) && enumBindings.has(node.text) && !ts.isImportSpecifier(node.parent)) result.errorCode = true;
|
|
176
|
-
if (
|
|
177
|
-
const chain = chainOf(ts, node);
|
|
178
|
-
if (!chain || !worlds.has(chain[0])) return;
|
|
179
|
-
const [, area, name, call] = chain;
|
|
180
|
-
const called = ts.isCallExpression(node.parent) && node.parent.expression === node;
|
|
181
|
-
if (area === 'infra' && name !== undefined && INFRA_OUTAGES.has(call) && called) result.outage = true;
|
|
182
|
-
else if (area === 'interruptDatabase' && called) result.outage = true;
|
|
183
|
-
else if (OUTAGE_CALLS[area] !== undefined && name !== undefined && call === OUTAGE_CALLS[area] && called) result.outage = true;
|
|
217
|
+
if (drivesOutage(ts, node, worlds)) result.outage = true;
|
|
184
218
|
});
|
|
185
219
|
return result;
|
|
186
220
|
}
|
|
@@ -199,7 +233,7 @@ export function integrationSpecFindings({ repoRoot, files }) {
|
|
|
199
233
|
const findings = [];
|
|
200
234
|
for (const { side, provider, folder } of integrations) {
|
|
201
235
|
const specDir = `${side}src/tests/integration/${provider}/`;
|
|
202
|
-
const specs = files.filter((file) => file.startsWith(specDir) && file.endsWith(SPEC_SUFFIX) && !file.slice(specDir.length).includes('/')).sort();
|
|
236
|
+
const specs = files.filter((file) => file.startsWith(specDir) && file.endsWith(SPEC_SUFFIX) && !file.slice(specDir.length).includes('/')).sort(byCodeUnit);
|
|
203
237
|
if (specs.length === 0) {
|
|
204
238
|
findings.push(found(INTEGRATION_SPEC_MISSING, `${folder}/`, `${folder}/ is an integration with no integration spec: add ${specDir}<name>${SPEC_SUFFIX} that ${MISSING_PARTS.modules}, ${MISSING_PARTS.errorCode}, and ${MISSING_PARTS.outage}.`, { provider, missing: ['spec'] }));
|
|
205
239
|
continue;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// `features/cli/` is the one cli feature root (its module and group folders), so it is in use as soon as it holds a file.
|
|
9
9
|
import { loadSlotManifest, ruleParams } from '../slots.mjs';
|
|
10
10
|
import { found } from './read.mjs';
|
|
11
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
11
12
|
|
|
12
13
|
const KIND_DECLARATION = 'BE_KIND_DECLARATION';
|
|
13
14
|
const KIND_EMPTY = 'BE_KIND_EMPTY';
|
|
@@ -32,23 +33,22 @@ const facts = () => {
|
|
|
32
33
|
return memo;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
/** The path parts below the features root of a file inside a known kind folder, else null. */
|
|
37
|
+
function kindPathParts(file, kindFolders) {
|
|
38
|
+
if (!file.startsWith(FEATURES)) return null;
|
|
39
|
+
const rest = file.slice(FEATURES.length).split('/');
|
|
40
|
+
return rest.length < 2 || !kindFolders.has(rest[0]) ? null : rest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function kindUsageOf(files, kindFolders) {
|
|
41
44
|
const findings = [];
|
|
42
45
|
const instances = new Map();
|
|
43
46
|
const sources = new Map();
|
|
44
47
|
const roots = new Set();
|
|
45
|
-
const tracked = new Set(files);
|
|
46
48
|
for (const file of files) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (rest.length < 2) continue;
|
|
49
|
+
const rest = kindPathParts(file, kindFolders);
|
|
50
|
+
if (!rest) continue;
|
|
50
51
|
const [first, second] = rest;
|
|
51
|
-
if (!kindFolders.has(first)) continue;
|
|
52
52
|
if (first === CLI) { roots.add(CLI); continue; }
|
|
53
53
|
if (rest.length === 2) {
|
|
54
54
|
findings.push(found(KIND_EMPTY, file, `${file} sits directly in the kind folder ${FEATURES}${first}/, which holds only instance folders (${first}/<name>/); an empty kind folder is never kept: run \`starci app add\` to create the first member.`, { kind: first }));
|
|
@@ -59,26 +59,60 @@ export function kindFindings({ files, repo }) {
|
|
|
59
59
|
const key = `${first}/${second}`;
|
|
60
60
|
sources.set(key, (sources.get(key) ?? 0) + (/\.[cm]?tsx?$/.test(file) ? 1 : 0));
|
|
61
61
|
}
|
|
62
|
+
return { findings, instances, sources, roots };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function emptyInstanceFindings(sources) {
|
|
66
|
+
const findings = [];
|
|
62
67
|
for (const [key, count] of sources) {
|
|
63
68
|
if (count === 0) findings.push(found(KIND_EMPTY, `${FEATURES}${key}/`, `${FEATURES}${key}/ holds no TypeScript source: a kind member without code is an empty folder; add its files with \`starci app add\` or remove the folder.`, { kind: key.split('/')[0] }));
|
|
64
69
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
return findings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function undeclaredKindFindings(present, declared) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
for (const kind of [...present].sort(byCodeUnit)) {
|
|
69
76
|
if (!declared.has(kind)) findings.push(found(KIND_DECLARATION, 'hfs.json', `hfs.json sides.be.kinds does not declare the kind \`${kind}\`, which ${FEATURES}${kind}/ uses; declare every kind in use (\`starci app add\` registers it).`, { kind }));
|
|
70
77
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
78
|
+
return findings;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function kindPatternFindings(kind, { kindPatterns, patterns, platform, tracked }) {
|
|
82
|
+
const findings = [];
|
|
83
|
+
for (const pattern of kindPatterns[kind] ?? []) {
|
|
84
|
+
if (!patterns.has(pattern)) findings.push(found(KIND_DECLARATION, 'hfs.json', `the kind \`${kind}\` needs the pattern \`${pattern}\`, which hfs.json sides.be.patterns does not declare.`, { kind, pattern }));
|
|
85
|
+
for (const capability of platform.get(pattern) ?? []) {
|
|
86
|
+
if (!tracked.has(`be/src/modules/platform/${capability}/index.ts`)) findings.push(found(KIND_DECLARATION, `be/src/modules/platform/${capability}/`, `the kind \`${kind}\` needs the platform capability \`${capability}\` (pattern ${pattern}), which is not tracked at be/src/modules/platform/${capability}/; \`starci app add\` generates it with the first member of the kind.`, { kind, pattern, capability }));
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
89
|
return findings;
|
|
84
90
|
}
|
|
91
|
+
|
|
92
|
+
function declaredKindFindings({ declared, present, kindPatterns, patterns, platform, tracked }) {
|
|
93
|
+
const findings = [];
|
|
94
|
+
for (const kind of [...declared].sort(byCodeUnit)) {
|
|
95
|
+
if (present.has(kind)) findings.push(...kindPatternFindings(kind, { kindPatterns, patterns, platform, tracked }));
|
|
96
|
+
else findings.push(found(KIND_DECLARATION, 'hfs.json', `hfs.json sides.be.kinds declares \`${kind}\` but no feature of that kind exists; a kind is declared only when it is used, so remove it or add its first member with \`starci app add\`.`, { kind }));
|
|
97
|
+
}
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Findings of BE_KIND_DECLARATION and BE_KIND_EMPTY. */
|
|
102
|
+
export function kindFindings({ files, repo }) {
|
|
103
|
+
const be = repo.sides?.be;
|
|
104
|
+
if (!be) return [];
|
|
105
|
+
const { triggerKinds, kindPatterns, platform } = facts();
|
|
106
|
+
const kindFolders = new Set(triggerKinds);
|
|
107
|
+
const tracked = new Set(files);
|
|
108
|
+
const usage = kindUsageOf(files, kindFolders);
|
|
109
|
+
const findings = usage.findings;
|
|
110
|
+
const instances = usage.instances;
|
|
111
|
+
const roots = usage.roots;
|
|
112
|
+
findings.push(...emptyInstanceFindings(usage.sources));
|
|
113
|
+
const present = new Set([...instances.keys(), ...roots]);
|
|
114
|
+
const declared = new Set(be.kinds ?? []);
|
|
115
|
+
const patterns = new Set(be.patterns ?? []);
|
|
116
|
+
findings.push(...undeclaredKindFindings(present, declared), ...declaredKindFindings({ declared, present, kindPatterns, patterns, platform, tracked }));
|
|
117
|
+
return findings;
|
|
118
|
+
}
|