@starci/eslint-canon-fe 8.2.13 → 8.2.15
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 +19 -1
- package/README.md +1 -0
- package/client-boundary.mjs +66 -23
- package/docs/client-boundary.md +9 -9
- package/docs/hooks-folder.md +16 -4
- package/docs/hygiene.md +2 -2
- package/docs/transport.md +26 -26
- package/docs/type-safety.md +6 -6
- package/fe-stack.mjs +86 -0
- package/formatting.mjs +12 -6
- package/hooks-folder.mjs +73 -31
- package/hygiene.mjs +4 -4
- package/icon.mjs +25 -17
- package/index.mjs +2 -0
- package/lib/fe-stack.mjs +73 -0
- package/lib/scope.mjs +23 -2
- package/lib/why.mjs +23 -13
- package/next-conventions.mjs +4 -3
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +207 -15
- package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
- package/runtime/knowledge/hfs/slots.yaml +319 -179
- package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
- package/runtime/knowledge/sonar-gate.yaml +10 -2
- 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 +3 -2
- 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/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/fe-no-tests.mjs +4 -5
- package/served-locale.mjs +10 -13
- package/the-split.mjs +5 -2
- package/transport.mjs +81 -67
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
|
@@ -1,35 +1,27 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { machineKit } from './machine-ast.mjs';
|
|
3
|
-
import {
|
|
4
|
-
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, signatureFromSupabase, supabaseClientType, SUPABASE_MODULES, unwrap } from './supabase-ast.mjs';
|
|
2
|
+
import { callName, chainParts, contextTypesClient, isDatabaseType, isSupabaseCall, moduleNameOf, reportAt, supabaseClientType, SUPABASE_MODULES } from './supabase-ast.mjs';
|
|
5
3
|
import { checkBackendJwt } from './supabase-be.mjs';
|
|
6
|
-
import { checkFrontendQueryResults, FE_DB_SLOTS, sameSymbol, symbolOf } from './supabase-results.mjs';
|
|
7
4
|
|
|
8
5
|
/**
|
|
9
|
-
* L10-L16, the slot-driven Supabase machine. Supabase is one data transport with one owner
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
6
|
+
* L10-L16, the slot-driven Supabase machine. On the back end Supabase is one data transport with one owner,
|
|
7
|
+
* `be.integrations.supabase`. On the front end Supabase is auth only (owner ruling 2026-10-13): `fe.modules.auth` is the one
|
|
8
|
+
* importer of `@supabase/*` and calls only the auth API; data, storage, rpc and realtime go through the back end (modules/api).
|
|
9
|
+
* The checks below recognise provider calls and types by their imported package declarations, not by local variable or type names.
|
|
12
10
|
*/
|
|
13
|
-
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze([
|
|
14
|
-
'FE_SUPABASE_CLIENT_OWNER',
|
|
15
|
-
'FE_DB_RESULT_TYPED',
|
|
16
|
-
'FE_DB_ERROR_HANDLED',
|
|
17
|
-
'FE_SERVICE_ROLE_FORBIDDEN',
|
|
18
|
-
'FE_AUTH_SESSION_TRUST',
|
|
19
|
-
'FE_DB_WRITE_SHAPE',
|
|
20
|
-
'FE_ROUTE_HANDLER_FORBIDDEN',
|
|
21
|
-
]);
|
|
11
|
+
export const FRONTEND_SUPABASE_RULE_IDS = Object.freeze(['FE_SUPABASE_CLIENT_OWNER', 'FE_SERVICE_ROLE_FORBIDDEN']);
|
|
22
12
|
export const BACKEND_SUPABASE_RULE_IDS = Object.freeze(['BE_SUPABASE_CLIENT_OWNER', 'BE_SUPABASE_JWT_VERIFIED']);
|
|
23
13
|
|
|
24
14
|
const FE_CLIENT_OWNER = 'FE_SUPABASE_CLIENT_OWNER';
|
|
25
15
|
const BE_CLIENT_OWNER = 'BE_SUPABASE_CLIENT_OWNER';
|
|
26
16
|
const FE_SERVICE_ROLE = 'FE_SERVICE_ROLE_FORBIDDEN';
|
|
27
|
-
const FE_SESSION_TRUST = 'FE_AUTH_SESSION_TRUST';
|
|
28
|
-
const FE_WRITE_SHAPE = 'FE_DB_WRITE_SHAPE';
|
|
29
|
-
const FE_ROUTE_HANDLER = 'FE_ROUTE_HANDLER_FORBIDDEN';
|
|
30
17
|
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
18
|
+
const FE_AUTH_SLOT = 'fe.modules.auth';
|
|
19
|
+
/** The front-end provider slot: enabled exactly when a connection declares provider supabase. */
|
|
20
|
+
const FE_SUPABASE_PROVIDER_SLOT = 'fe.route.callback';
|
|
31
21
|
const FE_CONFIG_SLOT = 'fe.modules.config';
|
|
32
22
|
const SUPABASE_CALLS = new Set(['from', 'rpc']);
|
|
23
|
+
/** The members of a Supabase client that read or write data, files or channels: never called from the front end. */
|
|
24
|
+
const FE_DATA_MEMBERS = new Set(['from', 'rpc', 'schema', 'storage', 'functions', 'realtime', 'channel', 'getChannels', 'removeChannel', 'removeAllChannels']);
|
|
33
25
|
const supabaseSlotEnabled = (input, slotId) => {
|
|
34
26
|
const slot = input.graph.resolver.slot(slotId);
|
|
35
27
|
return Boolean(slot && input.graph.resolver.slotEnabled(slot));
|
|
@@ -145,209 +137,43 @@ function checkFrontendSecretNames(input) {
|
|
|
145
137
|
return { violations, coverage: { status: 'checked', envReads: state.reads } };
|
|
146
138
|
}
|
|
147
139
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const isDirectiveStatement = (ts, statement) => ts.isExpressionStatement(statement) && ts.isStringLiteralLike(statement.expression);
|
|
152
|
-
|
|
153
|
-
const hasExportModifier = (ts, node) => node.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
|
154
|
-
|
|
155
|
-
const exportedValuesOf = (ts, statement) => {
|
|
156
|
-
if (ts.isFunctionDeclaration(statement)) {
|
|
157
|
-
return hasExportModifier(ts, statement) && statement.name && statement.body
|
|
158
|
-
? [{ name: statement.name.text, implementation: statement.body, functionNode: statement }] : [];
|
|
159
|
-
}
|
|
160
|
-
if (!ts.isVariableStatement(statement) || !hasExportModifier(ts, statement)) return [];
|
|
161
|
-
return statement.declarationList.declarations
|
|
162
|
-
.filter(declaration => ts.isIdentifier(declaration.name) && declaration.initializer)
|
|
163
|
-
.map(declaration => {
|
|
164
|
-
const functionNode = (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))
|
|
165
|
-
&& ts.isBlock(declaration.initializer.body) ? declaration.initializer : null;
|
|
166
|
-
return { name: declaration.name.text, implementation: declaration.initializer, functionNode };
|
|
167
|
-
});
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
const exportedValues = (ts, sourceFile) => sourceFile.statements.flatMap(statement => exportedValuesOf(ts, statement));
|
|
171
|
-
|
|
172
|
-
const exportedActions = (ts, sourceFile) => exportedValues(ts, sourceFile).map(value => value.functionNode).filter(Boolean);
|
|
173
|
-
const exportedImplementations = (ts, sourceFile, name) => exportedValues(ts, sourceFile)
|
|
174
|
-
.filter(value => value.name === name).map(value => value.implementation);
|
|
175
|
-
|
|
176
|
-
const contains = (kit, root, predicate) => {
|
|
177
|
-
let found = false;
|
|
178
|
-
kit.walk(root, node => {
|
|
179
|
-
if (predicate(node)) found = true;
|
|
180
|
-
return !found;
|
|
181
|
-
});
|
|
182
|
-
return found;
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const principalCall = (kit, checker, graph, node) => kit.ts.isCallExpression(node) && (() => {
|
|
186
|
-
const expression = node.expression;
|
|
187
|
-
const name = kit.ts.isPropertyAccessExpression(expression) ? expression.name : expression;
|
|
188
|
-
if (!kit.ts.isIdentifier(name) || name.text !== 'getPrincipal') return false;
|
|
189
|
-
return kit.declarationsOf(checker, name).some(declaration => {
|
|
190
|
-
const rel = kit.graphPath(declaration);
|
|
191
|
-
return rel !== null && FE_DB_SLOTS.has(graph.files.get(rel)?.slot);
|
|
192
|
-
});
|
|
193
|
-
})();
|
|
194
|
-
|
|
195
|
-
const firstPrincipal = (kit, checker, graph, statement) => {
|
|
196
|
-
let call = null;
|
|
197
|
-
kit.walk(statement, node => {
|
|
198
|
-
if (kit.ts.isAwaitExpression(node) && principalCall(kit, checker, graph, unwrap(kit.ts, node.expression))) call = unwrap(kit.ts, node.expression);
|
|
199
|
-
return call === null;
|
|
200
|
-
});
|
|
201
|
-
if (!call || !kit.ts.isVariableStatement(statement)) return { call, symbol: null };
|
|
202
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
203
|
-
if (kit.ts.isIdentifier(declaration.name) && declaration.initializer && contains(kit, declaration.initializer, node => node === call)) {
|
|
204
|
-
return { call, symbol: symbolOf(kit, checker, declaration.name) };
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return { call, symbol: null };
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const typedRefusal = (kit, node) => contains(kit, node, child => (kit.ts.isStringLiteralLike(child) && child.text === 'refused')
|
|
211
|
-
|| (kit.ts.isIdentifier(child) && /^refused(?:Outcome)?$/iu.test(child.text)));
|
|
212
|
-
|
|
213
|
-
const parseUse = (ts, identifier) => {
|
|
214
|
-
let current = identifier;
|
|
215
|
-
while (current.parent && (ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent))) current = current.parent;
|
|
216
|
-
const call = current.parent;
|
|
217
|
-
return ts.isCallExpression(call) && call.arguments.includes(current) && ts.isPropertyAccessExpression(call.expression)
|
|
218
|
-
&& ['parse', 'safeParse'].includes(call.expression.name.text);
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const parameterBindings = (ts, name) => {
|
|
222
|
-
if (ts.isIdentifier(name)) return [name];
|
|
223
|
-
if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) return name.elements.flatMap(element => ts.isBindingElement(element) ? parameterBindings(ts, element.name) : []);
|
|
224
|
-
return [];
|
|
225
|
-
};
|
|
226
|
-
|
|
227
|
-
function inspectServerSessionCalls(kit, file, checker, violations) {
|
|
140
|
+
function inspectFrontendNode(scope, node) {
|
|
141
|
+
const { kit, checker, file, violations, counters } = scope;
|
|
228
142
|
const { ts } = kit;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return true;
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function inspectPrincipalProvider(kit, file, checker, violations) {
|
|
239
|
-
const { ts } = kit;
|
|
240
|
-
if (!FE_DB_SLOTS.has(file.slot)) return;
|
|
241
|
-
for (const implementation of exportedImplementations(ts, file.sourceFile, 'getPrincipal')) {
|
|
242
|
-
const readsClaims = contains(kit, implementation, node => ts.isCallExpression(node) && callName(ts, node) === 'getClaims'
|
|
243
|
-
&& signatureFromSupabase(checker, node));
|
|
244
|
-
if (!readsClaims) reportAt(violations, kit, FE_SESSION_TRUST, file, implementation,
|
|
245
|
-
'getPrincipal() must establish the server principal through Supabase auth.getClaims().');
|
|
143
|
+
const moduleName = moduleNameOf(ts, node);
|
|
144
|
+
if (moduleName?.startsWith('@supabase/')) {
|
|
145
|
+
counters.imports += 1;
|
|
146
|
+
if (file.slot !== FE_AUTH_SLOT) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `${moduleName} is imported by ${file.rel}, whose slot is ${file.slot ?? 'unowned'}; on the front end Supabase is auth only and ${FE_AUTH_SLOT} (modules/auth) is its one importer.`, { module: moduleName, slot: file.slot ?? null });
|
|
147
|
+
return;
|
|
246
148
|
}
|
|
149
|
+
if (!ts.isCallExpression(node) || !isSupabaseCall(kit, checker, node)) return;
|
|
150
|
+
counters.calls += 1;
|
|
151
|
+
const member = callName(ts, node);
|
|
152
|
+
const parts = chainParts(ts, node);
|
|
153
|
+
let data = null;
|
|
154
|
+
if (FE_DATA_MEMBERS.has(member)) data = parts.find(name => FE_DATA_MEMBERS.has(name)) ?? member;
|
|
155
|
+
if (data) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase .${data} call is made from the front end; Supabase on the front end is auth only - read and write data, files and realtime through the back end (a modules/api GraphQL operation, an SWR hook, hooks/socketio).`, { member: data, slot: file.slot ?? null });
|
|
156
|
+
else if (file.slot !== FE_AUTH_SLOT && parts.includes('auth')) reportAt(violations, kit, FE_CLIENT_OWNER, file, node, `A Supabase auth call is made from ${file.slot ?? 'an unowned path'}; the auth client is called only inside ${FE_AUTH_SLOT}, which exports the session and the access token.`, { slot: file.slot ?? null });
|
|
247
157
|
}
|
|
248
158
|
|
|
249
|
-
function
|
|
250
|
-
const { ts } = kit;
|
|
251
|
-
const first = action.body.statements[0];
|
|
252
|
-
const functionLevel = first?.expression?.text === 'use server' && isDirectiveStatement(ts, first);
|
|
253
|
-
if (!isServerActionModule(ts, file.sourceFile) && !functionLevel) {
|
|
254
|
-
reportAt(violations, kit, FE_WRITE_SHAPE, file, action, 'An exported write function must be a Server Action through a file-level or function-level `use server` directive.');
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function inspectActionPrincipal(input, kit, file, checker, action, authenticatesAnonymous, violations) {
|
|
259
|
-
const { ts } = kit;
|
|
260
|
-
// Function directives are the prologue, not work: the principal is the first non-directive statement.
|
|
261
|
-
const work = action.body.statements.slice(action.body.statements.findIndex(statement => !isDirectiveStatement(ts, statement)) >>> 0);
|
|
262
|
-
const first = work[0];
|
|
263
|
-
const principal = first ? firstPrincipal(kit, checker, input.graph, first) : { call: null, symbol: null };
|
|
264
|
-
if (!principal.call || !principal.symbol) {
|
|
265
|
-
reportAt(violations, kit, FE_SESSION_TRUST, file, first ?? action, 'A write Server Action must begin with `const principal = await getPrincipal()` from the db owner.');
|
|
266
|
-
} else if (!authenticatesAnonymous) {
|
|
267
|
-
const refuses = work.slice(1).some(statement => ts.isIfStatement(statement)
|
|
268
|
-
&& contains(kit, statement.expression, node => ts.isIdentifier(node) && sameSymbol(kit, checker, node, principal.symbol))
|
|
269
|
-
&& typedRefusal(kit, statement.thenStatement));
|
|
270
|
-
if (!refuses) reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A write Server Action must refuse an anonymous principal with a typed `refused` Outcome before doing work.');
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function inspectActionInputs(kit, file, action, checker, violations) {
|
|
275
|
-
const { ts } = kit;
|
|
276
|
-
for (const parameter of action.parameters.flatMap(item => parameterBindings(ts, item.name))) {
|
|
277
|
-
const symbol = symbolOf(kit, checker, parameter);
|
|
278
|
-
const references = [];
|
|
279
|
-
kit.walk(action.body, node => {
|
|
280
|
-
if (ts.isIdentifier(node) && sameSymbol(kit, checker, node, symbol)) references.push(node);
|
|
281
|
-
return true;
|
|
282
|
-
});
|
|
283
|
-
references.sort((a, b) => a.getStart() - b.getStart());
|
|
284
|
-
const firstUse = references[0];
|
|
285
|
-
if (firstUse && !parseUse(ts, firstUse)) reportAt(violations, kit, FE_WRITE_SHAPE, file, firstUse,
|
|
286
|
-
`Server Action input ${parameter.text} is used before schema.parse(...) or schema.safeParse(...).`, { parameter: parameter.text });
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function inspectSensitiveAction(kit, file, checker, action, violations) {
|
|
291
|
-
const { ts } = kit;
|
|
292
|
-
const sensitive = /(?:^|\/)(?:billing|payment|payout|refund|charge|subscription|password|credential|role|permission|membership)[/.-]/iu.test(file.rel);
|
|
293
|
-
if (sensitive && !contains(kit, action.body, node => ts.isCallExpression(node) && callName(ts, node) === 'getUser' && signatureFromSupabase(checker, node))) {
|
|
294
|
-
reportAt(violations, kit, FE_SESSION_TRUST, file, action, 'A money, account-security or role-changing Server Action must call auth.getUser() before the mutation.');
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function inspectWriteAction(input, kit, file, checker, action, authenticatesAnonymous, violations) {
|
|
299
|
-
inspectActionDirective(kit, file, action, violations);
|
|
300
|
-
inspectActionPrincipal(input, kit, file, checker, action, authenticatesAnonymous, violations);
|
|
301
|
-
inspectActionInputs(kit, file, action, checker, violations);
|
|
302
|
-
inspectSensitiveAction(kit, file, checker, action, violations);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function inspectWriteModule(input, kit, file, checker, state) {
|
|
306
|
-
const { ts } = kit;
|
|
307
|
-
if (!FE_DB_SLOTS.has(file.slot) || !path.posix.basename(file.rel).startsWith('write-')) return;
|
|
308
|
-
const classified = input.graph.resolver.classifyPath(file.rel);
|
|
309
|
-
const belowSlot = classified.root && file.rel.startsWith(`${classified.root}/`) ? file.rel.slice(classified.root.length + 1) : '';
|
|
310
|
-
const authenticatesAnonymous = (input.graph.resolver.slot(file.slot)?.anonymousActions ?? []).includes(belowSlot);
|
|
311
|
-
const declared = exportedActions(ts, file.sourceFile);
|
|
312
|
-
if (!declared.length) reportAt(state.violations, kit, FE_WRITE_SHAPE, file, file.sourceFile, `${file.rel} is a write module but exports no Server Action function.`);
|
|
313
|
-
for (const action of declared) {
|
|
314
|
-
state.actions += 1;
|
|
315
|
-
inspectWriteAction(input, kit, file, checker, action, authenticatesAnonymous, state.violations);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function inspectRouteHandler(input, kit, file, state) {
|
|
320
|
-
if (!file.rel.endsWith('/route.ts')) return;
|
|
321
|
-
state.routes += 1;
|
|
322
|
-
const classified = input.graph.resolver.classifyPath(file.rel);
|
|
323
|
-
const app = classified.bindings?.app;
|
|
324
|
-
const live = app !== undefined && file.rel === `apps/${app}/src/app/health/live/route.ts`;
|
|
325
|
-
if (!live && file.slot !== 'fe.route.callback') reportAt(state.violations, kit, FE_ROUTE_HANDLER, file, file.sourceFile,
|
|
326
|
-
`${file.rel} is a front-end route handler outside health/live and the fe.route.callback slot; webhooks, cron, public APIs and third-party callbacks belong to be features.`);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function checkFrontendAuthAndRoutes(input) {
|
|
159
|
+
function checkFrontendClientOwner(input) {
|
|
330
160
|
const kit = machineKit(input);
|
|
331
|
-
const
|
|
161
|
+
const violations = [];
|
|
162
|
+
const counters = { imports: 0, calls: 0 };
|
|
332
163
|
for (const file of input.graph.files.values()) {
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
inspectPrincipalProvider(kit, file, checker, state.violations);
|
|
336
|
-
inspectWriteModule(input, kit, file, checker, state);
|
|
337
|
-
inspectRouteHandler(input, kit, file, state);
|
|
164
|
+
const scope = { kit, checker: kit.checkerOf(file.sourceFile), file, violations, counters };
|
|
165
|
+
kit.walk(file.sourceFile, node => inspectFrontendNode(scope, node));
|
|
338
166
|
}
|
|
339
|
-
return { violations
|
|
167
|
+
return { violations, coverage: { status: 'checked', ...counters, ownerSlots: [FE_AUTH_SLOT] } };
|
|
340
168
|
}
|
|
341
169
|
|
|
342
170
|
export function checkFrontendSupabase(input) {
|
|
343
|
-
if (!supabaseSlotEnabled(input,
|
|
344
|
-
const owner =
|
|
345
|
-
const results = checkFrontendQueryResults(input);
|
|
171
|
+
if (!supabaseSlotEnabled(input, FE_SUPABASE_PROVIDER_SLOT)) return { violations: [], coverage: { status: 'not-applicable', reason: 'no connection declares provider supabase' } };
|
|
172
|
+
const owner = checkFrontendClientOwner(input);
|
|
346
173
|
const secrets = checkFrontendSecretNames(input);
|
|
347
|
-
const auth = checkFrontendAuthAndRoutes(input);
|
|
348
174
|
return {
|
|
349
|
-
violations: [...owner.violations, ...
|
|
350
|
-
coverage: { status: 'checked', owner: owner.coverage,
|
|
175
|
+
violations: [...owner.violations, ...secrets.violations],
|
|
176
|
+
coverage: { status: 'checked', owner: owner.coverage, secrets: secrets.coverage },
|
|
351
177
|
};
|
|
352
178
|
}
|
|
353
179
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { isServerActionModule } from './server-action.mjs';
|
|
2
1
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
3
2
|
|
|
4
3
|
/**
|
|
@@ -102,37 +101,8 @@ const triggerOf = (resolver, file) => {
|
|
|
102
101
|
return owner ? (resolver.slot(owner.slot)?.trigger ?? null) : null;
|
|
103
102
|
};
|
|
104
103
|
|
|
105
|
-
/** The db Server Action an edge targets, or null. */
|
|
106
|
-
const serverActionTarget = (graph, edge) => {
|
|
107
|
-
if (graph.profile !== 'fe') return null;
|
|
108
|
-
const to = graph.resolver.classifyPath(edge.to);
|
|
109
|
-
return to.slot === 'fe.modules.db' && /^write-[^/]+\.ts$/u.test(edge.to.split('/').at(-1))
|
|
110
|
-
&& isServerActionModule(null, graph.files.get(edge.to).sourceFile) ? to : null;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/** A connected block may call a db writer directly when that writer is a file-level Next Server Action. */
|
|
114
|
-
const blockCallsServerAction = (graph, edge) => {
|
|
115
|
-
if (!serverActionTarget(graph, edge)) return false;
|
|
116
|
-
const from = graph.resolver.classifyPath(edge.from);
|
|
117
|
-
return from.slot === 'fe.components' && from.kind === 'blocks' && from.role === 'entry';
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
function reportDirectServerAction(graph, edge, verdict, actionTarget, tierRule, counts, violations) {
|
|
121
|
-
if (!(verdict.allowed || verdict.reason === 'notPublicEntry') || !actionTarget
|
|
122
|
-
|| graph.unit(edge.from) === graph.unit(edge.to) || blockCallsServerAction(graph, edge)) return false;
|
|
123
|
-
counts.tierDirection += 1;
|
|
124
|
-
violations.push({
|
|
125
|
-
ruleId: tierRule,
|
|
126
|
-
path: edge.from, line: edge.line, column: edge.column,
|
|
127
|
-
specifier: edge.specifier, resolvedPath: edge.to, typeOnly: !edge.runtime,
|
|
128
|
-
fromTier: graph.resolver.tierOf(edge.from), toTier: graph.resolver.tierOf(edge.to),
|
|
129
|
-
message: `${edge.from} -> ${edge.to}: a db Server Action is called directly only by a connected block entry; hooks and other tiers use their declared data path${edge.runtime ? '' : ' (type-only imports count)'}.`,
|
|
130
|
-
});
|
|
131
|
-
return true;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
104
|
function reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations) {
|
|
135
|
-
if (verdict.allowed ||
|
|
105
|
+
if (verdict.allowed || !REASON_TEXT[verdict.reason]) return;
|
|
136
106
|
counts[verdict.reason] += 1;
|
|
137
107
|
const featureToFeature = graph.profile === 'be' && verdict.reason === 'tierDirection' && verdict.fromTier === 'feature' && verdict.toTier === 'feature';
|
|
138
108
|
const fromKind = featureToFeature ? triggerOf(graph.resolver, edge.from) : null;
|
|
@@ -162,8 +132,6 @@ function inspectTierEdge(graph, edge, tierRule, counts, violations, coverage) {
|
|
|
162
132
|
return;
|
|
163
133
|
}
|
|
164
134
|
coverage.edgesChecked += 1;
|
|
165
|
-
const actionTarget = serverActionTarget(graph, edge);
|
|
166
|
-
if (reportDirectServerAction(graph, edge, verdict, actionTarget, tierRule, counts, violations)) return;
|
|
167
135
|
reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
|
|
168
136
|
}
|
|
169
137
|
|
|
@@ -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),
|
|
@@ -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
|
}
|