@starci/eslint-canon-be 3.4.39 → 3.4.41
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 +10 -0
- package/README.md +2 -2
- package/boundary-schema.mjs +32 -4
- package/idempotency.mjs +3 -0
- package/index.mjs +2 -0
- package/infra-owner.mjs +4 -3
- package/lib/client-idempotency.mjs +403 -0
- package/lib/persistence.mjs +1 -1
- package/lib/schema-values.mjs +90 -0
- package/lib/unit-spec.mjs +6 -0
- package/operations.mjs +197 -32
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/rules.yaml +168 -47
- package/runtime/knowledge/hfs/slots.yaml +110 -52
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/suppression.mjs +1 -1
- package/unit-spec.mjs +66 -6
- package/uploads.mjs +112 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { machineKit } from './machine-ast.mjs';
|
|
3
|
+
import { propertyValue } from './function-spec-providers.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* R50 `transport-owner` (FE_TRANSPORT_OWNER), the machine half of the eslint transport rules. ESLint judges one file at a time by
|
|
@@ -14,14 +15,16 @@ import { machineKit } from './machine-ast.mjs';
|
|
|
14
15
|
* - 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
16
|
* app one) are findings on every copy;
|
|
16
17
|
* - 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
|
+
* - no file outside the client folder and the stream reader folder (`fe.transport.stream`, `modules/api/sse/clients/`) references the global `fetch`: a call, `globalThis.fetch`, `window.fetch`, or a
|
|
18
19
|
* reference passed as a value (`useSWR(key, fetch)`, `const send = fetch`), all of which a spelling-based rule misses; with no
|
|
19
20
|
* client at all every such reference is a finding that says the repository has no client;
|
|
20
21
|
* - axios is imported only by the api owner (modules/api, the api package) for its rest calls; every other HTTP library is a
|
|
21
22
|
* second transport wherever it is imported, by static import, `import()` or `require()`;
|
|
22
23
|
* - 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
|
+
* - every GraphQL operation (`graphql/queries/query-*.ts`, `graphql/mutations/mutation-*.ts`, `graphql/subscriptions/subscription-*.ts`) imports the client (or the api
|
|
24
25
|
* package that holds it), so an operation is a caller of the one transport and not a second one.
|
|
26
|
+
* - every stream operation (`sse/<area>/stream-*.ts`) imports the stream reader, so a server-sent-events stream is read by the
|
|
27
|
+
* transport client layer and not by a fetch of its own.
|
|
25
28
|
*/
|
|
26
29
|
export const TRANSPORT_OWNER_RULE_IDS = ['FE_TRANSPORT_OWNER'];
|
|
27
30
|
|
|
@@ -34,11 +37,14 @@ const APP_CLIENT_SLOT = 'fe.transport.client';
|
|
|
34
37
|
const APP_OUTCOME_SLOT = 'fe.transport.outcome';
|
|
35
38
|
const PACKAGE_API_SLOT = 'fe.package.api';
|
|
36
39
|
const CLIENT_SLOTS = new Set([APP_CLIENT_SLOT, 'fe.package.api.client']);
|
|
40
|
+
/** The stream reader (server-sent events) is the second half of the transport client layer: it may call `fetch`, and it is no second Apollo client folder. */
|
|
41
|
+
const STREAM_SLOT = 'fe.transport.stream';
|
|
37
42
|
/** The api owner: the slots whose files may import axios (the rest calls and the client's own links). */
|
|
38
43
|
const API_OWNER_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT, ...CLIENT_SLOTS]);
|
|
39
44
|
/** The slots that hold the GraphQL operations of an app or of the api package. */
|
|
40
45
|
const OPERATION_SLOTS = new Set(['fe.modules.api', PACKAGE_API_SLOT]);
|
|
41
|
-
const OPERATION_FILE = /^(?:query|mutation)-[^/]+\.ts$/u;
|
|
46
|
+
const OPERATION_FILE = /^(?:query|mutation|subscription)-[^/]+\.ts$/u;
|
|
47
|
+
const STREAM_OPERATION_FILE = /^stream-[^/]+\.ts$/u;
|
|
42
48
|
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.";
|
|
43
49
|
|
|
44
50
|
/** True when `node` names the global fetch: no import, and no declaration outside a lib declaration file. */
|
|
@@ -108,11 +114,25 @@ function inspectLibraryImport({ ts, file, node, transport, report }) {
|
|
|
108
114
|
report(file, node, `${specifier.text} is a second HTTP transport in ${file.rel}; ${where}.`, { library: specifier.text });
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/** True when a call of fetch declares, in its init object literal, the request header accept: "text/event-stream". */
|
|
118
|
+
function asksForEventStream(ts, node) {
|
|
119
|
+
const call = node.parent && ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null;
|
|
120
|
+
const init = call?.arguments[1];
|
|
121
|
+
if (!init || !ts.isObjectLiteralExpression(init)) return false;
|
|
122
|
+
const headers = propertyValue(ts, init, 'headers');
|
|
123
|
+
const accept = headers && ts.isObjectLiteralExpression(headers) ? propertyValue(ts, headers, 'accept') : null;
|
|
124
|
+
return Boolean(accept && ts.isStringLiteralLike(accept) && accept.text === 'text/event-stream');
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
function inspectTransportNode(scope, node) {
|
|
112
128
|
const { ts, kit, checker, file, isClient, owned, report } = scope;
|
|
113
129
|
const fetchUse = globalFetchUse(ts, kit, checker, node);
|
|
114
130
|
if (fetchUse !== null) {
|
|
115
131
|
if (isClient) return false;
|
|
132
|
+
if (file.slot === STREAM_SLOT) {
|
|
133
|
+
if (!asksForEventStream(ts, node)) report(file, node, `${file.rel} fetches something other than an event stream; the stream reader folder reads server-sent events only (accept: "text/event-stream"), every other request is an operation of modules/api run by Apollo or axios.`);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
116
136
|
if (fetchUse === 'property') report(file, node, `${node.getText(file.sourceFile)} reaches the global fetch outside the transport client; ${owned}`);
|
|
117
137
|
else report(file, node, `fetch is used outside the transport client (called, aliased or passed as a value); ${owned}`);
|
|
118
138
|
return false;
|
|
@@ -131,6 +151,27 @@ function reachesClient(graph, file, clientRels) {
|
|
|
131
151
|
});
|
|
132
152
|
}
|
|
133
153
|
|
|
154
|
+
/** True for a stream operation of the app's api layer: `modules/api/sse/<area>/stream-<channel>.ts`. */
|
|
155
|
+
function isStreamOperation(file) {
|
|
156
|
+
return file.slot === 'fe.modules.api' && STREAM_OPERATION_FILE.test(path.posix.basename(file.rel)) && path.posix.basename(path.posix.dirname(path.posix.dirname(file.rel))) === 'sse';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** True when the stream operation imports a file of the stream reader folder. */
|
|
160
|
+
function reachesStreamReader(graph, file) {
|
|
161
|
+
return graph.edges.some(edge => edge.from === file.rel && edge.runtime && graph.files.get(edge.to)?.slot === STREAM_SLOT);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Only a stream operation (or the reader folder itself) imports the stream reader: a REST call, a GraphQL operation or a hook never does. */
|
|
165
|
+
function inspectReaderImporters({ files, graph, report }) {
|
|
166
|
+
const byRel = new Map(files.map(file => [file.rel, file]));
|
|
167
|
+
for (const edge of graph.edges) {
|
|
168
|
+
if (!edge.runtime || graph.files.get(edge.to)?.slot !== STREAM_SLOT) continue;
|
|
169
|
+
const from = byRel.get(edge.from);
|
|
170
|
+
if (!from || from.slot === STREAM_SLOT || isStreamOperation(from)) continue;
|
|
171
|
+
report(from, from.sourceFile, `${from.rel} imports the stream reader (modules/api/sse/clients); only a stream operation (sse/<area>/stream-<channel>.ts) reads a server-sent-events stream.`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
134
175
|
function inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, owned, report }) {
|
|
135
176
|
const appsWithFiles = new Set();
|
|
136
177
|
let operations = 0;
|
|
@@ -142,7 +183,12 @@ function inspectTransportFiles({ files, graph, kit, ts, clientRels, transport, o
|
|
|
142
183
|
operations += 1;
|
|
143
184
|
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.`);
|
|
144
185
|
}
|
|
186
|
+
if (isStreamOperation(file)) {
|
|
187
|
+
operations += 1;
|
|
188
|
+
if (!reachesStreamReader(graph, file)) report(file, file.sourceFile, `${file.rel} is a stream operation that does not import the stream reader (modules/api/sse/clients); a stream operation runs on the transport client layer's reader, never on a fetch of its own.`);
|
|
189
|
+
}
|
|
145
190
|
}
|
|
191
|
+
inspectReaderImporters({ files, graph, report });
|
|
146
192
|
return { appsWithFiles, operations };
|
|
147
193
|
}
|
|
148
194
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// declaration-shape.mjs - the dependency-free hfs.json shape validation used by the HFS slot loader. Semantic checks
|
|
2
2
|
// against a loaded manifest remain in slots.mjs; this module mirrors modules/schemas/hfs-repo.schema.yaml only.
|
|
3
3
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
4
|
-
import { APP_KIND, connectionShapeProblems, NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
|
|
4
|
+
import { APP_KIND, connectionShapeProblems, NAME, PACKAGE_NAME, RUNTIME_KIND, SLOT_ID } from './manifest-shape.mjs';
|
|
5
5
|
import { kindShapeProblems, patternShapeProblems } from './declaration-slots.mjs';
|
|
6
6
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
7
7
|
|
|
@@ -60,8 +60,6 @@ function connectionProblems(side, value) {
|
|
|
60
60
|
/** Whether a declared list is present but not a list of distinct entries that all satisfy `valid`. */
|
|
61
61
|
const isBadUniqueList = (list, valid) => list !== undefined && (!Array.isArray(list) || !list.every((entry) => valid(entry)) || new Set(list).size !== list.length);
|
|
62
62
|
|
|
63
|
-
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
64
|
-
|
|
65
63
|
function validExtraPackage(entry) {
|
|
66
64
|
return isPlainObject(entry) && Object.keys(entry).every(key => key === 'name' || key === 'reason')
|
|
67
65
|
&& typeof entry.name === 'string' && entry.name.length <= 214 && PACKAGE_NAME.test(entry.name)
|
|
@@ -79,9 +77,11 @@ function declarationSideProblems(side, value, names) {
|
|
|
79
77
|
const bad = [];
|
|
80
78
|
const at = `sides.${side}`;
|
|
81
79
|
if (!isPlainObject(value)) return [`${at} must be an object`];
|
|
82
|
-
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
80
|
+
for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages', 'backing'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
83
81
|
bad.push(...appShapeProblems(value.apps, at, side, names));
|
|
84
82
|
if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
83
|
+
if (value.backing !== undefined && side !== 'be') bad.push('backing belongs to the be side');
|
|
84
|
+
else if (isBadUniqueList(value.backing, (name) => NAME.test(String(name)))) bad.push(`${at}.backing must be a unique list of backing service names`);
|
|
85
85
|
bad.push(...patternShapeProblems(value, at, NAME), ...kindShapeProblems(value, at, NAME));
|
|
86
86
|
if (isBadUniqueList(value.reads, (read) => typeof read === 'string' && read.length > 0)) bad.push(`${at}.reads must be a unique list of paths`);
|
|
87
87
|
if (value.connections !== undefined) bad.push(...connectionProblems(side, value));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// declaration-slots.mjs - how an app declaration (hfs.json) switches on the opt-in slots of a side: by name (`optionalSlots`), by app kind
|
|
2
2
|
// (a slot with `appKind`) and by declared pattern (a slot with `pattern: <name>` is enabled by `sides.<side>.patterns: [<name>]`, one
|
|
3
|
-
// mechanism for
|
|
4
|
-
// slot is enabled for a repository.
|
|
3
|
+
// mechanism for pattern slots). A cross-cutting pattern can instead be owned by the side's canonical proof scenario catalog.
|
|
4
|
+
// slots.mjs asks this file to judge a side's lists and to say whether a slot is enabled for a repository.
|
|
5
5
|
|
|
6
6
|
/** The problems of a side's `optionalSlots` and `patterns` against the manifest. */
|
|
7
7
|
export function optionalSlotProblems(manifest, side, s) {
|
|
@@ -13,7 +13,9 @@ export function optionalSlotProblems(manifest, side, s) {
|
|
|
13
13
|
else if (slot.appKind !== undefined) bad.push(`sides.${side}.optionalSlots names ${id}; an app of kind ${slot.appKind} enables it`);
|
|
14
14
|
}
|
|
15
15
|
for (const name of s.patterns ?? []) {
|
|
16
|
-
|
|
16
|
+
const ownsSlot = manifest.slots.some((slot) => slot.profiles.includes(side) && slot.pattern === name);
|
|
17
|
+
const ownsProof = Object.hasOwn(manifest.ruleParams?.[side]?.patternScenarios ?? {}, name);
|
|
18
|
+
if (!ownsSlot && !ownsProof) bad.push(`sides.${side}.patterns names ${name}, which neither a ${side} slot nor its proof scenario catalog declares`);
|
|
17
19
|
}
|
|
18
20
|
if (s.kinds !== undefined && side !== 'be') bad.push(`sides.${side}.kinds belongs to the be side`);
|
|
19
21
|
for (const kind of s.kinds ?? []) {
|
|
@@ -22,6 +24,13 @@ export function optionalSlotProblems(manifest, side, s) {
|
|
|
22
24
|
return bad;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** The problems of a side's `backing` list: every name is a backing service of ruleParams.be.backing (ruling:R212/case-10). */
|
|
28
|
+
export function backingProblems(manifest, side, s) {
|
|
29
|
+
const services = Object.keys(manifest.ruleParams?.be?.backing ?? {});
|
|
30
|
+
return (s.backing ?? []).filter((name) => !services.includes(name))
|
|
31
|
+
.map((name) => `sides.${side}.backing names ${name}, which is not one of ruleParams.be.backing (${services.join(', ')})`);
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
/** The problems of the shape of a side's `patterns` list (a unique list of pattern names). */
|
|
26
35
|
export function patternShapeProblems(s, at, namePattern) {
|
|
27
36
|
const list = s.patterns;
|
|
@@ -8,6 +8,8 @@ import { stringList, byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
|
|
|
8
8
|
|
|
9
9
|
export const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
10
10
|
export const NAME = /^[a-z][a-z0-9-]*$/;
|
|
11
|
+
/** An npm package name, scoped or not. */
|
|
12
|
+
export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
11
13
|
const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
12
14
|
/** How a bounded context (a connection) is isolated: its own logical database, or its own schema of a shared one. */
|
|
13
15
|
const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
|
|
@@ -66,14 +68,15 @@ export function unitRolesProblems(be) {
|
|
|
66
68
|
return [];
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
/** Shape problems of ruleParams.be.logicRoles and thinRoles: each a non-empty list of unique roles of the suffix vocabulary; every unit role is a logic role. */
|
|
71
|
+
/** Shape problems of ruleParams.be.logicRoles, valueRoles and thinRoles: each a non-empty list of unique roles of the suffix vocabulary; every unit role is a logic role. */
|
|
70
72
|
export function roleListProblems(be) {
|
|
71
73
|
const bad = [];
|
|
72
|
-
for (const key of ['logicRoles', 'thinRoles']) {
|
|
74
|
+
for (const key of ['logicRoles', 'valueRoles', 'thinRoles']) {
|
|
73
75
|
const list = be[key];
|
|
74
76
|
if (!strList(list) || !list.length || new Set(list).size !== list.length || !list.every((role) => NAME.test(role))) bad.push(`ruleParams.be.${key} must be a non-empty list of unique kebab-case roles`);
|
|
75
77
|
else if (Array.isArray(be.suffixes) && list.some((role) => !be.suffixes.includes(role))) bad.push(`ruleParams.be.${key} names a role that is not in ruleParams.be.suffixes`);
|
|
76
78
|
}
|
|
79
|
+
if (strList(be.valueRoles) && strList(be.logicRoles) && be.valueRoles.some((role) => !be.logicRoles.includes(role))) bad.push('every ruleParams.be.valueRoles role is a logic role (ruleParams.be.logicRoles)');
|
|
77
80
|
if (strList(be.logicRoles) && Array.isArray(be.unitRoles) && be.unitRoles.some((r) => r?.slot === undefined && !be.logicRoles.includes(r?.role))) bad.push('every ruleParams.be.unitRoles role that is not tied to a slot is a logic role (ruleParams.be.logicRoles)');
|
|
78
81
|
return bad;
|
|
79
82
|
}
|
|
@@ -359,13 +362,14 @@ function runtimeParamProblems(rp) {
|
|
|
359
362
|
const folderKindsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length && list.every(kind => NAME.test(kind));
|
|
360
363
|
const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCodeUnit).join() === 'from,to'
|
|
361
364
|
&& ['from', 'to'].every(side => folderKindsValid(pair[side]));
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
/** A non-empty map whose every key is a name and every value satisfies `valid`. */
|
|
366
|
+
export const namedMapValid = (map, valid) => isPlainObject(map) && Object.keys(map).length > 0
|
|
367
|
+
&& Object.entries(map).every(([name, entry]) => NAME.test(name) && valid(entry));
|
|
368
|
+
const kindRestrictionValid = kinds => namedMapValid(kinds, kindPairValid);
|
|
364
369
|
const slotIdsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length
|
|
365
370
|
&& list.every(id => SLOT_ID.test(id) || RUNTIME_SLOT_ID.test(id));
|
|
366
371
|
const slotRestrictionPairValid = pair => isPlainObject(pair) && Object.keys(pair).join() === 'from' && slotIdsValid(pair.from);
|
|
367
|
-
const slotRestrictionValid = slots =>
|
|
368
|
-
&& Object.entries(slots).every(([target, pair]) => NAME.test(target) && slotRestrictionPairValid(pair));
|
|
372
|
+
const slotRestrictionValid = slots => namedMapValid(slots, slotRestrictionPairValid);
|
|
369
373
|
|
|
370
374
|
function tierDefinitionProblems(profile, tier, def) {
|
|
371
375
|
const bad = [];
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
5
5
|
import { paramNamesOk } from './param-names.mjs';
|
|
6
6
|
import { kindParamProblems, scenarioProblem } from './declaration-slots.mjs';
|
|
7
|
-
import { NAME, roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
|
|
7
|
+
import { NAME, namedMapValid, PACKAGE_NAME, roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
|
|
8
8
|
import { byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
|
|
9
9
|
|
|
10
10
|
const PROFILES = ['be', 'fe'];
|
|
11
|
-
const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds'];
|
|
11
|
+
const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'valueRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds', 'backing'];
|
|
12
|
+
const backingEntryOk = (entry) => isPlainObject(entry) && Object.keys(entry).sort(byCodeUnit).join() === 'clients,images,patterns'
|
|
13
|
+
&& nonemptyUniqueList(entry.clients, (name) => PACKAGE_NAME.test(String(name)))
|
|
14
|
+
&& Array.isArray(entry.patterns) && entry.patterns.every((name) => NAME.test(String(name))) && new Set(entry.patterns).size === entry.patterns.length
|
|
15
|
+
&& nonemptyUniqueList(entry.images, (name) => PACKAGE_NAME.test(String(name)));
|
|
12
16
|
const FORM_NAMES = new Set(['call', 'new', 'curried', 'object', 'primitive', 'array']);
|
|
13
17
|
const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
|
|
14
18
|
const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
|
|
@@ -30,7 +34,12 @@ const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => FORM_N
|
|
|
30
34
|
const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
|
|
31
35
|
const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
|
|
32
36
|
const dbExtensionsOk = (value) => Array.isArray(value) && value.every((name) => typeof name === 'string' && name.length > 0) && new Set(value).size === value.length;
|
|
33
|
-
const
|
|
37
|
+
const SQL_NAME = /^[a-z_][a-z0-9_]*$/;
|
|
38
|
+
const queueOutboxOk = (value) => isPlainObject(value) && Object.keys(value).sort(byCodeUnit).join() === 'columns,table'
|
|
39
|
+
&& typeof value.table === 'string' && value.table.split('.').length === 2 && value.table.split('.').every((part) => SQL_NAME.test(part))
|
|
40
|
+
&& nonemptyUniqueList(value.columns, (column) => SQL_NAME.test(String(column))) && value.columns.includes('queue');
|
|
41
|
+
const DB_PARAMS = { extensions: dbExtensionsOk, queueOutbox: queueOutboxOk };
|
|
42
|
+
const dbParamsOk = (db) => db === undefined || (isPlainObject(db) && Object.entries(db).every(([key, value]) => Object.hasOwn(DB_PARAMS, key) && DB_PARAMS[key](value)));
|
|
34
43
|
|
|
35
44
|
function beSpecificProblems(be, manifest) {
|
|
36
45
|
const bad = [];
|
|
@@ -42,11 +51,12 @@ function beSpecificProblems(be, manifest) {
|
|
|
42
51
|
const eventBus = be.eventBus;
|
|
43
52
|
if (!isPlainObject(eventBus) || Object.keys(eventBus).sort(byCodeUnit).join() !== 'classes,imports' || !memberMap(eventBus.imports, true) || !memberMap(eventBus.classes, false)) bad.push('ruleParams.be.eventBus must be {imports: {<module>: [unique PascalCase members]}, classes: {<module>: [non-empty unique PascalCase classes]}}');
|
|
44
53
|
if (!paramNamesOk(be.paramNames)) bad.push('ruleParams.be.paramNames must be a non-empty list of unique {type | typeSuffix, names, nameSuffix?} entries');
|
|
54
|
+
if (!namedMapValid(be.backing, backingEntryOk)) bad.push('ruleParams.be.backing must map a backing service name to {clients: [unique package names], patterns: [unique pattern names], images: [unique image repositories]}');
|
|
45
55
|
if (!roleList(be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
|
|
46
56
|
if (!roleList(be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
|
|
47
57
|
else if (roleList(be.suffixes) && be.suffixes.some((x) => be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
|
|
48
58
|
const sd = be.specDoubles;
|
|
49
|
-
if (!isPlainObject(sd) || Object.keys(sd).sort(byCodeUnit).join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
|
|
59
|
+
if (!isPlainObject(sd) || Object.keys(sd).sort(byCodeUnit).join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && (e.from === undefined || regexOk(e.from)) && Object.keys(e).length === (e.from === undefined ? 3 : 4)) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
|
|
50
60
|
return bad;
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -59,10 +69,10 @@ export function ruleParamsProblems(m) {
|
|
|
59
69
|
// common holds the parameters both sides share; a side may restate one of them (a valid value) as an override, nothing else.
|
|
60
70
|
// Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
|
|
61
71
|
if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock)) bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
|
|
62
|
-
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns and
|
|
72
|
+
if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns, addKinds and backing (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + ') and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
|
|
63
73
|
bad.push(...beSpecificProblems(rp.be, m));
|
|
64
74
|
if (rp.fe !== undefined && (!sideOk(rp.fe, [], ['lite']) || !liteOk(rp.fe.lite, SHARED_KEYS))) bad.push('ruleParams.fe may only restate the shared parameters of ruleParams.common, and carry a lite override of them');
|
|
65
|
-
if (!dbParamsOk(rp.db)) bad.push('ruleParams.db may contain only extensions
|
|
75
|
+
if (!dbParamsOk(rp.db)) bad.push('ruleParams.db may contain only extensions (a list of unique non-empty extension names) and queueOutbox ({table: <schema>.<table>, columns: [unique column names including queue]})');
|
|
66
76
|
}
|
|
67
77
|
return bad;
|
|
68
78
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Single source of database-rule finding codes and owned Supabase paths.
|
|
2
|
+
export const DB_MIGRATION_SHAPE = 'DB_MIGRATION_SHAPE';
|
|
3
|
+
export const DB_RLS_REQUIRED = 'DB_RLS_REQUIRED';
|
|
4
|
+
export const DB_DYNAMIC_DDL = 'DB_DYNAMIC_DDL';
|
|
5
|
+
export const DB_POLICY_SHAPE = 'DB_POLICY_SHAPE';
|
|
6
|
+
export const DB_DEFINER_SAFE = 'DB_DEFINER_SAFE';
|
|
7
|
+
export const DB_STORAGE_POLICY = 'DB_STORAGE_POLICY';
|
|
8
|
+
export const DB_CONFIG_POLICY = 'DB_CONFIG_POLICY';
|
|
9
|
+
export const DB_TYPES_DRIFT = 'DB_TYPES_DRIFT';
|
|
10
|
+
export const DB_BASELINE_SHAPE = 'DB_BASELINE_SHAPE';
|
|
11
|
+
export const DB_ARCHIVE_MUTATED = 'DB_ARCHIVE_MUTATED';
|
|
12
|
+
export const DB_PGMQ_DECLARED = 'DB_PGMQ_DECLARED';
|
|
13
|
+
export const DB_CRON_OWNED = 'DB_CRON_OWNED';
|
|
14
|
+
export const DB_EXTENSION_ALLOWED = 'DB_EXTENSION_ALLOWED';
|
|
15
|
+
export const DB_API_EXPOSED = 'DB_API_EXPOSED';
|
|
16
|
+
export const DB_MIGRATION_DESTRUCTIVE = 'DB_MIGRATION_DESTRUCTIVE';
|
|
17
|
+
export const HFS_SEED_PII = 'HFS_SEED_PII';
|
|
18
|
+
export const DB_VAULT_WRITE = 'DB_VAULT_WRITE';
|
|
19
|
+
|
|
20
|
+
export const MIGRATIONS_DIR = 'supabase/migrations';
|
|
21
|
+
/** The migrations retired at a baseline cutover (slot app.supabase.migrations.archive): never applied, never judged by L02-L08. */
|
|
22
|
+
export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/_archive`;
|
|
23
|
+
/** True for a migration the Supabase CLI applies: a path under supabase/migrations/ outside the archive. */
|
|
24
|
+
export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
|
|
25
|
+
export const CONFIG_FILE = 'supabase/config.toml';
|
|
26
|
+
export const TYPES_FILE = 'supabase/types/database.types.ts';
|