@starci/hfs 4.2.13 → 4.2.14
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 +14 -0
- package/README.md +7 -3
- package/emit/db-types.mjs +2 -1
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +201 -14
- package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
- package/runtime/knowledge/hfs/rules.yaml +51 -48
- package/runtime/knowledge/hfs/slots.yaml +307 -179
- package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
- package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
- package/runtime/knowledge/patterns/be/projections.yaml +10 -0
- package/runtime/knowledge/patterns/be/queues.yaml +15 -0
- package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
- package/runtime/knowledge/patterns/be/saga.yaml +13 -0
- package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
- package/runtime/knowledge/sonar-gate.yaml +10 -2
- package/runtime/modules/kernel/failure-codes.yaml +40 -60
- package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
- package/runtime/scripts/hfs/architecture/config.mjs +2 -1
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
- package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
- package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
- package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
- package/runtime/scripts/hfs/architecture/index.mjs +2 -0
- package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
- package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
- package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
- package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
- package/runtime/scripts/hfs/check.mjs +10 -6
- package/runtime/scripts/hfs/edition-slots.mjs +13 -4
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
- package/runtime/scripts/hfs/rules/database.mjs +4 -3
- package/runtime/scripts/hfs/rules/edition.mjs +49 -37
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
- package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
- package/scaffold/add-app.mjs +24 -18
- package/scaffold/add-cli-lite.mjs +33 -3
- package/scaffold/add-inbox-lite.mjs +24 -8
- package/scaffold/add-table.mjs +222 -74
- package/scaffold/add.mjs +85 -35
- package/scaffold/app.mjs +140 -36
- package/scaffold/edition-gate.mjs +21 -2
- package/scaffold/lite-exports.mjs +30 -1
- package/scaffold/service.mjs +5 -8
- package/src/main.mjs +2 -2
- package/sync/index.mjs +19 -15
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
- package/templates/app/codegen-graphql.mjs.partial +34 -0
- package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
- package/templates/app/package-scripts/core.json +1 -0
- package/templates/app/package-scripts-lite/package.json +4 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton/scripts/codegen.mjs +4 -1
- package/templates/app/skeleton-lite/README.md +7 -4
- package/templates/app/skeleton-lite/supabase/config.toml +1 -1
- package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
- package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
- package/templates/be/inbox/service.spec.ts.tpl +46 -0
- package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
- package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
- package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
- package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
- package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
- package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
- package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
- package/templates/be/platform/graphql/graphql.module.ts +49 -0
- package/templates/be/platform/graphql/graphql.options.ts +2 -0
- package/templates/be/platform/graphql/index.ts +1 -0
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
- package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
- package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
- package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
- package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
- package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
- package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/table/api/create.command.ts.tpl +10 -0
- package/templates/be/table/api/create.contracts.ts.tpl +8 -0
- package/templates/be/table/api/create.handler.ts.tpl +22 -0
- package/templates/be/table/api/create.mapper.ts.tpl +5 -0
- package/templates/be/table/api/create.resolver.ts.tpl +21 -0
- package/templates/be/table/api/create.type.ts.tpl +9 -0
- package/templates/be/table/api/feature.index.ts.tpl +1 -0
- package/templates/be/table/api/feature.module.ts.tpl +7 -0
- package/templates/be/table/api/get.contracts.ts.tpl +11 -0
- package/templates/be/table/api/get.handler.ts.tpl +22 -0
- package/templates/be/table/api/get.input.ts.tpl +12 -0
- package/templates/be/table/api/get.mapper.ts.tpl +9 -0
- package/templates/be/table/api/get.query.ts.tpl +10 -0
- package/templates/be/table/api/get.resolver.ts.tpl +27 -0
- package/templates/be/table/api/get.type.ts.tpl +9 -0
- package/templates/be/table/api/graphql.module.ts.tpl +8 -0
- package/templates/be/table/error.api.ts.tpl +19 -0
- package/templates/be/table/migration.sql.tpl +13 -21
- package/templates/be/table/service.api.ts.tpl +30 -0
- package/templates/be/table/sql.api.ts.tpl +7 -0
- package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
- package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
- package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
- package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
- package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
- package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
- package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
- package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
- package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
- package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
- package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
- package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
- package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
- package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
- package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
- package/templates/fe/table/graphql/query.ts.tpl +25 -0
- package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
- package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
- package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
- package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
- package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
- package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
- package/templates/fe/transport/graphql/clients/index.ts +2 -0
- package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
- package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
- package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
- package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
- package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
- package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
- package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
- package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
- package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
- package/templates/fe/transport/graphql/clients/response.ts +43 -0
- package/upgrade/check.mjs +1 -1
- package/upgrade/index.mjs +3 -2
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
- package/templates/app/quality-config-lite/sonar-project.properties +0 -5
- package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
- package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
- package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
- package/templates/be/patterns/api/http.module.ts.tpl +0 -7
- package/templates/be/patterns/api/http.request.ts.tpl +0 -10
- package/templates/be/patterns/api/http.response.ts.tpl +0 -5
- package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
- package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
- package/templates/fe/table/read.ts.tpl +0 -16
- package/templates/fe/table/write.ts.tpl +0 -16
- /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
- /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
- /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
|
@@ -2,36 +2,44 @@ import path from 'node:path';
|
|
|
2
2
|
import { machineKit } from './machine-ast.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint rules
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint transport rules. ESLint judges one file at a time by
|
|
6
|
+
* the spelling of a call; the machine judges the repository by what the checker resolves. The browser calls the back end through
|
|
7
|
+
* Apollo GraphQL and axios (owner ruling 2026-10-13, both editions). A repository has exactly ONE transport client folder (the auth
|
|
8
|
+
* and no-auth Apollo clients with their link chain) and ONE GraphQLResponse union, both named by slot (never by a path spelled here):
|
|
9
|
+
* the api package's (`fe.package.api.client`, `fe.package.api.outcome`) when several apps call the back end, or the app's own
|
|
10
|
+
* (`fe.transport.client`, `fe.transport.outcome`) when exactly one app does. An app calls the back end when it holds a file of the
|
|
11
|
+
* api owner (modules/api, its client and union) or imports the api package; an app that holds neither (a marketing landing) shares
|
|
12
|
+
* no transport and is not counted. The union homes are the slots the manifest marks `outcomeHome`, not a list kept here.
|
|
11
13
|
*
|
|
12
|
-
* - two or more
|
|
13
|
-
*
|
|
14
|
-
* - an app client or app
|
|
15
|
-
* - the client
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
14
|
+
* - two or more client folders, or two or more unions, in the repository (two apps each keeping one, or a package one plus an
|
|
15
|
+
* app one) are findings on every copy;
|
|
16
|
+
* - an app client or app union in a repository where more than one app calls the back end is a finding (it belongs to the package);
|
|
17
|
+
* - no file outside the client folder references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
|
|
18
|
+
* reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
|
|
19
|
+
* client at all every such reference is a finding that says the repository has no client;
|
|
20
|
+
* - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
|
|
21
|
+
* second transport wherever it is imported, by static import, `import()` or `require()`;
|
|
22
|
+
* - an `ApolloClient` is constructed only inside the client folder;
|
|
23
|
+
* - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`) imports the client (or the api
|
|
24
|
+
* package that holds it), so an operation is a caller of the one transport and not a second one.
|
|
22
25
|
*/
|
|
23
26
|
export const TRANSPORT_OWNER_RULE_IDS = ['FE_TRANSPORT_OWNER'];
|
|
24
27
|
|
|
25
28
|
const RULE = 'FE_TRANSPORT_OWNER';
|
|
26
|
-
/** Packages that send HTTP requests: a second transport when a file imports one. */
|
|
29
|
+
/** Packages that send HTTP requests: a second transport when a file imports one (axios only outside the api owner). */
|
|
27
30
|
const HTTP_LIBRARIES = new Set(['axios', 'ky', 'ky-universal', 'got', 'node-fetch', 'undici', 'cross-fetch', 'isomorphic-fetch', 'superagent', 'ofetch', 'whatwg-fetch']);
|
|
31
|
+
const REST_LIBRARY = 'axios';
|
|
28
32
|
const GLOBAL_OBJECTS = new Set(['globalThis', 'window', 'self', 'global']);
|
|
29
|
-
const API_SLOT = 'fe.modules.api';
|
|
30
33
|
const APP_CLIENT_SLOT = 'fe.transport.client';
|
|
31
34
|
const APP_OUTCOME_SLOT = 'fe.transport.outcome';
|
|
32
35
|
const PACKAGE_API_SLOT = 'fe.package.api';
|
|
33
36
|
const CLIENT_SLOTS = new Set([APP_CLIENT_SLOT, 'fe.package.api.client']);
|
|
34
|
-
|
|
37
|
+
/** The api owner: the slots whose files may import axios (the rest calls and the client's own links). */
|
|
38
|
+
const API_OWNER_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT, ...CLIENT_SLOTS]);
|
|
39
|
+
/** The slots that hold the GraphQL operations of an app or of the api package. */
|
|
40
|
+
const OPERATION_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT]);
|
|
41
|
+
const OPERATION_FILE = /^(?:query|mutation)-[^/]+\.ts$/u;
|
|
42
|
+
const NO_CLIENT = "the repository has no transport client. Create the one Apollo client folder (packages/<family>-api/src/graphql/clients/, or the only app's modules/api/graphql/clients/) and call the back end through it.";
|
|
35
43
|
|
|
36
44
|
/** True when `node` names the global fetch: no import, and no declaration outside a lib declaration file. */
|
|
37
45
|
function isGlobalObjectFetch(ts, node) {
|
|
@@ -59,12 +67,14 @@ function isGlobalFetch(ts, kit, checker, node) {
|
|
|
59
67
|
return declarations.every(declaration => declaration.getSourceFile().isDeclarationFile);
|
|
60
68
|
}
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
/** Findings on every copy when there are several, and on an app's own copy in a repository where several apps call the back end. */
|
|
71
|
+
function reportMultiplicity(items, noun, callingApps, report) {
|
|
72
|
+
const list = items.map(item => item.root).join(', ');
|
|
73
|
+
for (const item of items) {
|
|
64
74
|
if (items.length > 1) {
|
|
65
|
-
report(
|
|
66
|
-
} else if ((
|
|
67
|
-
report(
|
|
75
|
+
report(item, item.sourceFile, `The repository has ${items.length} ${noun}s (${list}); it has exactly one: the api package's (packages/<family>-api/src/graphql/) when the apps share it, or the only app's. Keep one and delete the others.`);
|
|
76
|
+
} else if ((item.slot === APP_CLIENT_SLOT || item.slot === APP_OUTCOME_SLOT) && callingApps.length > 1) {
|
|
77
|
+
report(item, item.sourceFile, `${item.root} is an app's ${noun} in a repository of ${callingApps.length} apps that call the back end (${callingApps.join(', ')}); a repository where several apps call the back end keeps its one ${noun} in the api package (packages/<family>-api/src/graphql/).`);
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
}
|
|
@@ -81,40 +91,80 @@ function moduleSpecifierOf(ts, node) {
|
|
|
81
91
|
return null;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
|
-
function
|
|
94
|
+
function isApolloClientConstruction(ts, kit, checker, node) {
|
|
95
|
+
if (!ts.isNewExpression(node)) return false;
|
|
96
|
+
const binding = kit.importBinding(checker, node.expression);
|
|
97
|
+
return Boolean(binding && binding.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function inspectLibraryImport({ ts, file, node, transport, report }) {
|
|
101
|
+
const specifier = moduleSpecifierOf(ts, node);
|
|
102
|
+
if (!specifier || !ts.isStringLiteralLike(specifier)) return;
|
|
103
|
+
const library = specifier.text.split('/')[0];
|
|
104
|
+
if (!HTTP_LIBRARIES.has(library) || (library === REST_LIBRARY && API_OWNER_SLOTS.has(file.slot))) return;
|
|
105
|
+
const where = library === REST_LIBRARY
|
|
106
|
+
? 'axios belongs to the api owner only (modules/api/rest, the api package): call its rest functions'
|
|
107
|
+
: `the transport is Apollo GraphQL (${transport}) with axios for the rest calls of modules/api/rest`;
|
|
108
|
+
report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function inspectTransportNode(scope, node) {
|
|
112
|
+
const { ts, kit, checker, file, isClient, owned, report } = scope;
|
|
85
113
|
const fetchUse = globalFetchUse(ts, kit, checker, node);
|
|
86
114
|
if (fetchUse !== null) {
|
|
87
|
-
if (isClient)
|
|
88
|
-
|
|
115
|
+
if (isClient) return false;
|
|
116
|
+
if (fetchUse === 'property') report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
|
|
89
117
|
else report(file, node, `fetch is used outside the transport client (called, aliased or passed as a value); ${owned}`);
|
|
90
118
|
return false;
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
report(file, node, `${specifier.text} is a second HTTP transport; the repository's one transport is built on fetch (${clients.length ? clients.map(item => item.rel).join(', ') : 'no client exists yet'}). Use the client.`, { library: specifier.text });
|
|
95
|
-
}
|
|
120
|
+
if (!isClient && isApolloClientConstruction(ts, kit, checker, node)) report(file, node, `${file.rel} constructs an ApolloClient outside the transport client folder; ${owned}`);
|
|
121
|
+
inspectLibraryImport({ ...scope, node });
|
|
96
122
|
return true;
|
|
97
123
|
}
|
|
98
124
|
|
|
99
|
-
|
|
125
|
+
/** True when the operation imports the client folder, or (from an app) the api package that holds it. */
|
|
126
|
+
function reachesClient(graph, file, clientRels) {
|
|
127
|
+
return graph.edges.some(edge => {
|
|
128
|
+
if (edge.from !== file.rel || !edge.runtime) return false;
|
|
129
|
+
if (clientRels.has(edge.to)) return true;
|
|
130
|
+
return file.slot !== PACKAGE_API_SLOT && (graph.files.get(edge.to)?.slot ?? '').startsWith(PACKAGE_API_SLOT);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report }) {
|
|
100
135
|
const appsWithFiles = new Set();
|
|
101
|
-
let
|
|
136
|
+
let operations = 0;
|
|
102
137
|
for (const file of files) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (isClient && clientCalls.value === 0) report(file, file.sourceFile, `${file.rel} never calls the global fetch; the client is the one module that owns fetch, with its timeout and abort signal.`);
|
|
110
|
-
if (file.slot === API_SLOT && bindings?.app && path.posix.basename(file.rel).startsWith('read-')) {
|
|
111
|
-
readers += 1;
|
|
112
|
-
const reachesClient = graph.edges.some(edge => edge.from === file.rel && edge.runtime
|
|
113
|
-
&& (clientRels.has(edge.to) || graph.files.get(edge.to)?.slot === PACKAGE_API_SLOT));
|
|
114
|
-
if (!reachesClient) report(file, file.sourceFile, `${file.rel} is a server reader that does not import the transport client; a reader fetches through the repository's one client, never through its own transport.`);
|
|
138
|
+
if (file.bindings?.app) appsWithFiles.add(file.bindings.app);
|
|
139
|
+
const scope = { ts, kit, checker: kit.checkerOf(file.sourceFile), file, isClient: clientRels.has(file.rel), transport, owned, report };
|
|
140
|
+
kit.walk(file.sourceFile, node => inspectTransportNode(scope, node));
|
|
141
|
+
if (OPERATION_SLOTS.has(file.slot) && OPERATION_FILE.test(path.posix.basename(file.rel))) {
|
|
142
|
+
operations += 1;
|
|
143
|
+
if (!reachesClient(graph, file, clientRels)) report(file, file.sourceFile, `${file.rel} is a GraphQL operation that does not import the transport client; an operation runs through the repository's one Apollo client, never through its own transport.`);
|
|
115
144
|
}
|
|
116
145
|
}
|
|
117
|
-
return { appsWithFiles,
|
|
146
|
+
return { appsWithFiles, operations };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The declared Next apps that call the back end: those holding a file of an api owner slot (modules/api, the app client, the app
|
|
151
|
+
* union) or importing a file of the api package. An app with neither shares no transport.
|
|
152
|
+
*/
|
|
153
|
+
function transportApps(nextApps, files, graph) {
|
|
154
|
+
const byRel = new Map(files.map(file => [file.rel, file]));
|
|
155
|
+
const calling = new Set(files.filter(file => API_OWNER_SLOTS.has(file.slot) && file.slot !== PACKAGE_API_SLOT).map(file => file.bindings?.app));
|
|
156
|
+
for (const edge of graph.edges) {
|
|
157
|
+
const app = byRel.get(edge.from)?.bindings?.app;
|
|
158
|
+
if (app && (byRel.get(edge.to)?.slot ?? '').startsWith(PACKAGE_API_SLOT)) calling.add(app);
|
|
159
|
+
}
|
|
160
|
+
return nextApps.map(item => item.name).filter(name => calling.has(name));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One instance per client folder (its slot root), carried by the folder's first file. */
|
|
164
|
+
function clientInstances(clients) {
|
|
165
|
+
const byRoot = new Map();
|
|
166
|
+
for (const file of [...clients].sort((a, b) => a.rel.localeCompare(b.rel))) if (!byRoot.has(file.root)) byRoot.set(file.root, file);
|
|
167
|
+
return [...byRoot.values()];
|
|
118
168
|
}
|
|
119
169
|
|
|
120
170
|
export function checkTransportOwner(input) {
|
|
@@ -124,16 +174,22 @@ export function checkTransportOwner(input) {
|
|
|
124
174
|
const violations = [];
|
|
125
175
|
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
126
176
|
const nextApps = resolver.repo.apps.filter(item => item.kind === 'next');
|
|
127
|
-
const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e')
|
|
177
|
+
const files = [...graph.files.values()].filter(file => file.slot && file.tier !== 'e2e').map(file => {
|
|
178
|
+
const classified = resolver.classifyPath(file.rel);
|
|
179
|
+
return { ...file, root: classified.root ?? file.rel, bindings: classified.bindings };
|
|
180
|
+
});
|
|
128
181
|
const clients = files.filter(file => CLIENT_SLOTS.has(file.slot));
|
|
129
|
-
const
|
|
182
|
+
const instances = clientInstances(clients);
|
|
183
|
+
const outcomes = files.filter(file => resolver.slot(file.slot)?.outcomeHome === true).map(file => ({ ...file, root: file.rel }));
|
|
130
184
|
const clientRels = new Set(clients.map(file => file.rel));
|
|
131
|
-
const
|
|
132
|
-
const owned =
|
|
185
|
+
const transport = instances.length ? instances.map(item => item.root).join(', ') : 'no client exists yet';
|
|
186
|
+
const owned = instances.length ? `the repository has one transport (${transport}). Call the back end through a modules/api operation and take its GraphQLResponse.` : NO_CLIENT;
|
|
133
187
|
|
|
134
|
-
// Exactly one client and one
|
|
135
|
-
|
|
188
|
+
// Exactly one client folder and one GraphQLResponse union per repository.
|
|
189
|
+
const calling = transportApps(nextApps, files, graph);
|
|
190
|
+
reportMultiplicity(instances, 'transport client', calling, report);
|
|
191
|
+
reportMultiplicity(outcomes, 'GraphQLResponse union', calling, report);
|
|
136
192
|
|
|
137
|
-
const { appsWithFiles,
|
|
138
|
-
return { violations, coverage: { status: 'checked', apps: appsWithFiles.size,
|
|
193
|
+
const { appsWithFiles, operations } = inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report });
|
|
194
|
+
return { violations, coverage: { status: 'checked', apps: appsWithFiles.size, operations, clients: instances.length, outcomes: outcomes.length } };
|
|
139
195
|
}
|
|
@@ -156,7 +156,10 @@ function unresolvedReferenceFinding(reference, sourceFile, project, config, work
|
|
|
156
156
|
const codeLike = !helpers.assetExtension.test(reference.specifier);
|
|
157
157
|
const workspaceImport = [...workspaceNames].some(name => helpers.sameOrUnder(reference.specifier, name));
|
|
158
158
|
const internal = reference.specifier.startsWith('.') || helpers.pathAliasMatches(reference.specifier, project.options.paths) || workspaceImport;
|
|
159
|
-
|
|
159
|
+
// A __generated__/ folder is the gitignored output of the app's codegen step (the GraphQL wire types, fe-stack codegen): it
|
|
160
|
+
// exists after `npm run codegen`, which lint, typecheck and build run first, and the compiler proves those imports there.
|
|
161
|
+
const generated = /(?:^|\/)__generated__(?:\/|$)/.test(reference.specifier);
|
|
162
|
+
if (codeLike && internal && !generated) errors.push({
|
|
160
163
|
ruleId: 'ARCH_INTERNAL_IMPORT_UNRESOLVED',
|
|
161
164
|
project: project.relative,
|
|
162
165
|
path: helpers.relativePath(config.root, sourceFile.fileName),
|
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
// HFS_CONTRACT_SNAPSHOT_DRIFT (R23, rules/contract.mjs) an uncommitted back-end snapshot, or a front-end copy that differs from it
|
|
23
23
|
// BE_TEST_TOPOLOGY (R47, rules/test-topology.mjs) a `.test` file, a testing/ folder, a second jest configuration
|
|
24
24
|
// FE_NO_TESTS (R97, rules/fe-no-tests.mjs) a front end holds a spec, e2e or test-tool file, a test script or a test dependency; no exception
|
|
25
|
+
// FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
|
|
26
|
+
// declares a package knowledge/hfs/fe-stack.yaml forbids
|
|
25
27
|
// HFS_LITE_SECRET_CUSTODY (R220, rules/supabase-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
|
|
26
|
-
// HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite:
|
|
27
|
-
//
|
|
28
|
+
// HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, stack or browser script, or a
|
|
29
|
+
// dependency of self-hosted infrastructure (test containers, fault proxy, BullMQ, Kafka, Redis)
|
|
28
30
|
// DB_MIGRATION_SHAPE ... DB_TYPES_DRIFT (R213-R216, rules/database.mjs, async: passed in as extraFindings) the Supabase migrations, policies, definer functions, buckets, config.toml and generated types
|
|
29
31
|
// BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
|
|
30
32
|
// HFS_REPO_LOCAL_CHECK (R103, rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
|
|
@@ -78,6 +80,7 @@ import { checkAppRoot, trackedTreeView } from './architecture/hfs.mjs';
|
|
|
78
80
|
import { testTopologyFindings } from './rules/test-topology.mjs';
|
|
79
81
|
import { feNoTestsFindings } from './rules/fe-no-tests.mjs';
|
|
80
82
|
import { editionFindings } from './rules/edition.mjs';
|
|
83
|
+
import { feStackFindings } from './rules/fe-stack.mjs';
|
|
81
84
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
82
85
|
import { pinFindings } from './pin-findings.mjs';
|
|
83
86
|
// The database rules (R213-R216) read SQL through a WASM parser, so they are async: `starci app check` calls them beside the other emitters and passes the findings in as `extraFindings`.
|
|
@@ -100,7 +103,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
100
103
|
'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
|
|
101
104
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
|
|
102
105
|
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
|
|
103
|
-
'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY',
|
|
106
|
+
'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY',
|
|
104
107
|
'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
|
|
105
108
|
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
|
|
106
109
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
@@ -242,11 +245,12 @@ function minimumInstanceFindings(resolver, required, instances) {
|
|
|
242
245
|
return findings;
|
|
243
246
|
}
|
|
244
247
|
|
|
245
|
-
function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration }) {
|
|
248
|
+
function contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration, manifest }) {
|
|
246
249
|
const findings = [
|
|
247
250
|
...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }),
|
|
248
251
|
...supabaseSecretFindings({ repoRoot, files: files.filter((file) => inScope(file)), resolver, repo }),
|
|
249
|
-
...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, withDeclaration: editionDeclaration }),
|
|
252
|
+
...editionFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, resolver, manifest, withDeclaration: editionDeclaration }),
|
|
253
|
+
...feStackFindings({ repoRoot, files: files.filter((file) => inScope(file)), repo, manifest }),
|
|
250
254
|
...repoLocalCheckFindings({ repoRoot, files }),
|
|
251
255
|
...lintSuppressionFindings({ repoRoot, files }),
|
|
252
256
|
];
|
|
@@ -295,7 +299,7 @@ function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, sco
|
|
|
295
299
|
if (repo.kind === RUNTIME_KIND) return findings;
|
|
296
300
|
|
|
297
301
|
const pins = readPins(root);
|
|
298
|
-
findings.push(...contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration }));
|
|
302
|
+
findings.push(...contentRuleFindings({ repoRoot, repo, resolver, files, all, scoped, pins, inScope, isRoot, editionDeclaration, manifest }));
|
|
299
303
|
if (!isRoot) findings.push(...sizeSoftFindings({ repoRoot, resolver, files, inScope }));
|
|
300
304
|
return findings;
|
|
301
305
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// edition.mjs - the one edition filter shared by the HFS slot resolver, declaration validation, rule catalog and
|
|
2
2
|
// edition-specific rule parameters. Full is the default; lite changes data views, never the checks that consume them.
|
|
3
|
+
// Lite is the full architecture deployed without self-hosted infrastructure (owner ruling 2026-10-12): its overlays swap
|
|
4
|
+
// adapters and remove the infra and e2e world, and every slot keeps its own `tests`.
|
|
3
5
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
4
6
|
import { EDITIONS } from './manifest-shape.mjs';
|
|
5
7
|
|
|
@@ -15,7 +17,7 @@ export function slotInEdition(manifest, slot, edition) {
|
|
|
15
17
|
return (slot.editions ?? manifest.editions ?? EDITIONS).includes(edition);
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
/** The slot as `edition` sees it: lite overlays its data, presence and tracking
|
|
20
|
+
/** The slot as `edition` sees it: lite overlays its data, presence and tracking; `tests` stays the slot's own (lite keeps the unit specs). */
|
|
19
21
|
export function effectiveSlot(slot, profile, edition) {
|
|
20
22
|
if (edition !== 'lite') return slot;
|
|
21
23
|
const { lite, litePresence, ...base } = slot;
|
|
@@ -23,7 +25,7 @@ export function effectiveSlot(slot, profile, edition) {
|
|
|
23
25
|
let tracked = slot.tracked;
|
|
24
26
|
if (presence === 'forbidden') tracked = 'external';
|
|
25
27
|
else if (tracked === 'external') tracked = 'tracked';
|
|
26
|
-
return { ...base, ...lite, presence, tracked
|
|
28
|
+
return { ...base, ...lite, presence, tracked };
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
/** The template group starci app sync renders `slot` from under `edition`: `liteManagedBy` (else `managedBy`) in lite, `managedBy` otherwise; undefined when unmanaged. */
|
|
@@ -59,10 +61,17 @@ export function enforcerJudgedInEdition(rules, kind, id, edition = 'full') {
|
|
|
59
61
|
return owners.every((rule) => judgedInEdition(rule, edition) && rule.enforcers.filter((enforcer) => enforcer.kind === kind && enforcer.id === id).every((enforcer) => judgedInEdition(enforcer, edition)));
|
|
60
62
|
}
|
|
61
63
|
|
|
62
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* A frozen copy of one profile's parameters, with its lite overrides applied only in the lite edition. A lite key whose base
|
|
66
|
+
* value and override are both maps merges one level deep (lite.infraOwners restates only the specifiers whose owner changes);
|
|
67
|
+
* any other value replaces the base.
|
|
68
|
+
*/
|
|
63
69
|
export function editionRuleParams(params, edition) {
|
|
64
70
|
const { lite, ...base } = params;
|
|
65
|
-
const merged = edition === 'lite' && lite ? { ...base
|
|
71
|
+
const merged = edition === 'lite' && lite ? { ...base } : base;
|
|
72
|
+
if (edition === 'lite' && lite) {
|
|
73
|
+
for (const [key, value] of Object.entries(lite)) merged[key] = isPlainObject(value) && isPlainObject(base[key]) ? { ...base[key], ...value } : value;
|
|
74
|
+
}
|
|
66
75
|
const deepFreeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); } return Object.freeze(value); };
|
|
67
76
|
return deepFreeze(structuredClone(merged));
|
|
68
77
|
}
|
|
@@ -86,9 +86,9 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
86
86
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
87
87
|
const strList = (v) => stringList(v);
|
|
88
88
|
|
|
89
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'
|
|
89
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'];
|
|
90
90
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
91
|
-
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances'];
|
|
91
|
+
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
92
92
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
93
93
|
const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'generatedBy', 'parent', 'coverage'];
|
|
94
94
|
|
|
@@ -197,6 +197,7 @@ function slotLiteOverlayProblems(slot, at) {
|
|
|
197
197
|
if (slot.lite === undefined) return [];
|
|
198
198
|
const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
|
|
199
199
|
&& (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
|
|
200
|
+
&& (slot.lite.why === undefined || (typeof slot.lite.why === 'string' && slot.lite.why.trim()))
|
|
200
201
|
&& ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
|
|
201
202
|
&& (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
|
|
202
203
|
&& (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
|
|
@@ -211,7 +212,6 @@ function slotEntryProblems(slot, at) {
|
|
|
211
212
|
else if (typeof slot.path !== 'string' || !slot.path.endsWith('/') || slot.owner === true) bad.push(`${at}: entries belong to a directory slot that is not an owner (they name extra public entries of the owner that holds it)`);
|
|
212
213
|
else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
|
|
213
214
|
}
|
|
214
|
-
if (slot.anonymousActions !== undefined && !(strList(slot.anonymousActions) && slot.anonymousActions.length > 0 && new Set(slot.anonymousActions).size === slot.anonymousActions.length && slot.anonymousActions.every(relPath) && typeof slot.path === 'string' && slot.path.endsWith('/'))) bad.push(`${at}: anonymousActions must be a non-empty list of unique relative file paths of a directory slot`);
|
|
215
215
|
if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
|
|
216
216
|
return bad;
|
|
217
217
|
}
|
|
@@ -15,7 +15,9 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
|
15
15
|
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
16
16
|
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
17
17
|
const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
|
|
18
|
-
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers']);
|
|
18
|
+
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers', 'cases']);
|
|
19
|
+
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote']);
|
|
20
|
+
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
19
21
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions']);
|
|
20
22
|
|
|
21
23
|
function vocabularyProblems(catalog, key, names, bad) {
|
|
@@ -59,6 +61,23 @@ function checkRuleText(rule, label, bad) {
|
|
|
59
61
|
if (rule.scope !== undefined && rule.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** The dated owner cases of a rule: each {id case-N, date, source owner, ruling, quote?}, ids unique (an owner ruling recorded where the law lives). */
|
|
65
|
+
function checkRuleCases(rule, label, bad) {
|
|
66
|
+
if (rule.cases === undefined) return;
|
|
67
|
+
if (!Array.isArray(rule.cases) || !rule.cases.length) { bad.push(`${label}.cases must be a non-empty list`); return; }
|
|
68
|
+
const ids = new Set();
|
|
69
|
+
rule.cases.forEach((item, index) => {
|
|
70
|
+
const at = `${label}.cases[${index}]`;
|
|
71
|
+
if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
|
|
72
|
+
for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
|
|
73
|
+
if (typeof item.id !== 'string' || !/^case-[1-9][0-9]*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
|
|
74
|
+
ids.add(item.id);
|
|
75
|
+
if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
|
|
76
|
+
if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
|
|
77
|
+
for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])]) if (typeof item[key] !== 'string' || !item[key].trim()) bad.push(`${at}.${key} must be text`);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
function checkRuleGates(label, gates, bad) {
|
|
63
82
|
if (!gates.length) return;
|
|
64
83
|
if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
|
|
@@ -120,6 +139,7 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
|
|
|
120
139
|
}
|
|
121
140
|
checkRuleIdentity(rule, index, catalog, at, label, bad);
|
|
122
141
|
checkRuleText(rule, label, bad);
|
|
142
|
+
checkRuleCases(rule, label, bad);
|
|
123
143
|
enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
|
|
124
144
|
bad.push(...ruleEditionProblems(rule, label));
|
|
125
145
|
const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
|
|
@@ -9,5 +9,9 @@ export const DB_CONFIG_POLICY = 'DB_CONFIG_POLICY';
|
|
|
9
9
|
export const DB_TYPES_DRIFT = 'DB_TYPES_DRIFT';
|
|
10
10
|
|
|
11
11
|
export const MIGRATIONS_DIR = 'supabase/migrations';
|
|
12
|
+
/** The migrations retired at a baseline cutover (slot app.supabase.migrations.archive): never applied, never judged by L02-L08. */
|
|
13
|
+
export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/_archive`;
|
|
14
|
+
/** True for a migration the Supabase CLI applies: a path under supabase/migrations/ outside the archive. */
|
|
15
|
+
export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
|
|
12
16
|
export const CONFIG_FILE = 'supabase/config.toml';
|
|
13
17
|
export const TYPES_FILE = 'supabase/types/database.types.ts';
|
|
@@ -8,7 +8,7 @@ import { withoutGitLocalEnv } from '../../lib/git.mjs';
|
|
|
8
8
|
import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
|
|
9
9
|
import { sameText } from '../../lib/same-text.mjs';
|
|
10
10
|
import { found, readText } from './read.mjs';
|
|
11
|
-
import { DB_MIGRATION_SHAPE, MIGRATIONS_DIR } from './database-constants.mjs';
|
|
11
|
+
import { DB_MIGRATION_SHAPE, isActiveMigration, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
|
|
12
12
|
const MIGRATION_NAME = /^(\d{14})_([a-z0-9]+(?:-[a-z0-9]+)*)\.sql$/;
|
|
13
13
|
|
|
14
14
|
/** `<ts14>` -> the UTC instant it names, or null when the digits are no real time (month 13, day 32, ...). */
|
|
@@ -49,7 +49,7 @@ async function baseMigrationNames(git, repoRoot, sha) {
|
|
|
49
49
|
const result = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', sha, '--', MIGRATIONS_DIR]);
|
|
50
50
|
if (!result.ok) return [];
|
|
51
51
|
const prefix = `${MIGRATIONS_DIR}/`;
|
|
52
|
-
return result.stdout.split('\0').filter(Boolean).map((entry) =>
|
|
52
|
+
return result.stdout.split('\0').filter(Boolean).filter(isActiveMigration).map((entry) => entry.slice(prefix.length));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
async function baseFileText(git, repoRoot, sha, file) {
|
|
@@ -102,16 +102,20 @@ async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase,
|
|
|
102
102
|
return [];
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function removedMigrationFindings(local, baseNames, baseSha) {
|
|
105
|
+
function removedMigrationFindings(local, retired, baseNames, baseSha) {
|
|
106
106
|
const findings = [];
|
|
107
107
|
for (const name of baseNames) {
|
|
108
|
-
if (!local.some((entry) => entry.name === name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
|
|
108
|
+
if (!local.some((entry) => entry.name === name) && !retired.has(name)) findings.push(found(DB_MIGRATION_SHAPE, `${MIGRATIONS_DIR}/${name}`, `${MIGRATIONS_DIR}/${name} exists on ${baseSha.ref} but is gone here; a migration on the base branch is immutable - restore it`, { base: baseSha.ref }));
|
|
109
109
|
}
|
|
110
110
|
return findings;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
113
|
+
/**
|
|
114
|
+
* L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. `archived` are the files of
|
|
115
|
+
* supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a base migration moved there at a baseline
|
|
116
|
+
* cutover is retired, not removed.
|
|
117
|
+
*/
|
|
118
|
+
export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base, now }) {
|
|
115
119
|
const prefix = `${MIGRATIONS_DIR}/`;
|
|
116
120
|
const local = migrations.map((file) => {
|
|
117
121
|
const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
@@ -127,6 +131,7 @@ export async function migrationShapeFindings({ repoRoot, migrations, git, base,
|
|
|
127
131
|
await eachInOrder(local, async (entry) => {
|
|
128
132
|
findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax));
|
|
129
133
|
});
|
|
130
|
-
|
|
134
|
+
const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
|
|
135
|
+
findings.push(...removedMigrationFindings(local, retired, baseNames, baseSha));
|
|
131
136
|
return findings;
|
|
132
137
|
}
|
|
@@ -4,7 +4,7 @@ import { found, readJson, readText } from './read.mjs';
|
|
|
4
4
|
import { configFindings, parseToml, typesFindings } from './database-config.mjs';
|
|
5
5
|
import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
|
|
6
6
|
import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
|
|
7
|
-
import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE,
|
|
7
|
+
import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE, isActiveMigration, MIGRATIONS_ARCHIVE_DIR, TYPES_FILE } from './database-constants.mjs';
|
|
8
8
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
9
9
|
import { eachInOrder } from '../../lib/in-order.mjs';
|
|
10
10
|
|
|
@@ -53,7 +53,8 @@ async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, find
|
|
|
53
53
|
*/
|
|
54
54
|
export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes, now = () => Date.now() } = {}) {
|
|
55
55
|
const findings = [];
|
|
56
|
-
const migrations = files.filter(
|
|
56
|
+
const migrations = files.filter(isActiveMigration).sort(byCodeUnit);
|
|
57
|
+
const archived = files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`));
|
|
57
58
|
const declaration = readJson(repoRoot, 'hfs.json');
|
|
58
59
|
const declared = supabase === undefined ? declaration?.supabase : supabase;
|
|
59
60
|
const hasConfig = files.includes(CONFIG_FILE);
|
|
@@ -70,7 +71,7 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, supab
|
|
|
70
71
|
const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
|
|
71
72
|
const exposed = new Set(['public', ...apiSchemas]);
|
|
72
73
|
const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
|
|
73
|
-
findings.push(...await migrationShapeFindings({ repoRoot, migrations, git: git ?? defaultGit, base, now }));
|
|
74
|
+
findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base, now }));
|
|
74
75
|
|
|
75
76
|
const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
|
|
76
77
|
findings.push(
|