@starci/eslint-canon-be 3.2.18 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +46 -4
- package/app-composition.mjs +176 -0
- package/config-owner.mjs +34 -19
- package/feature-thin.mjs +59 -17
- package/index.mjs +2 -0
- package/lib/app-roles.mjs +48 -0
- package/lib/webhook-ack.mjs +87 -0
- package/lib/webhook-handoff.mjs +138 -0
- package/lib/webhook-verifiers.mjs +147 -0
- package/module-layering.mjs +2 -2
- package/module-shape.mjs +6 -5
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/git/diff.mjs +5 -0
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/webhooks.mjs +141 -69
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
4
|
+
import { escapeRegExp } from '../../lib/regex.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* R106 `i18n-keys` (FE_I18N_KEYS). The catalogs of an app (`apps/<app>/src/modules/i18n/messages/<locale>.json`, slot fe.modules.i18n) and
|
|
@@ -12,7 +13,7 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
12
13
|
* own source (both branches of a conditional key are read). A key no locale's catalog holds is FE_I18N_KEYS, once per call. A computed
|
|
13
14
|
* key (a template with an interpolation, a variable) is not judged here; it counts as a read.
|
|
14
15
|
* - a key nobody reads is dead: a leaf of the app's catalogs that no string in the app's or the shared packages' source can be reading.
|
|
15
|
-
* A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard
|
|
16
|
+
* A leaf counts as read when a string literal or a template pattern (each interpolation a wildcard within its dotted segment) equals a
|
|
16
17
|
* tail of its dotted path (`"title"`, `"card.title"`, `"orders.card.title"` all read `orders.card.title`); the test is deliberately loose
|
|
17
18
|
* so a key handed through props or a table of keys is never reported, and only a key that appears nowhere is.
|
|
18
19
|
*
|
|
@@ -39,7 +40,10 @@ const held = (leaves, full) => leaves.has(full) || [...leaves].some(leaf => leaf
|
|
|
39
40
|
|
|
40
41
|
// A pattern that is only wildcards (`${a}`, `${a}.${b}`) names nothing: it would read every key, so it reads none.
|
|
41
42
|
const tailMatches = (pattern, leafSegments) => pattern.some(segment => segment !== '*' && segment !== '') && pattern.length <= leafSegments.length
|
|
42
|
-
&& pattern.every((segment, index) =>
|
|
43
|
+
&& pattern.every((segment, index) => {
|
|
44
|
+
const leaf = leafSegments[leafSegments.length - pattern.length + index];
|
|
45
|
+
return segment instanceof RegExp ? segment.test(leaf) : segment === '*' || segment === leaf;
|
|
46
|
+
});
|
|
43
47
|
|
|
44
48
|
function catalogsPerApp(config, graph) {
|
|
45
49
|
const catalogs = new Map();
|
|
@@ -56,12 +60,21 @@ function catalogsPerApp(config, graph) {
|
|
|
56
60
|
return catalogs;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
|
|
64
|
+
function templatePatternOf(node) {
|
|
65
|
+
return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
|
|
66
|
+
if (!segment.includes('\u0000')) return segment;
|
|
67
|
+
const parts = segment.split('\u0000');
|
|
68
|
+
return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
function patternsOf(ts, files) {
|
|
60
73
|
const patterns = [];
|
|
61
74
|
for (const file of files) {
|
|
62
75
|
const visit = node => {
|
|
63
76
|
if (ts.isStringLiteralLike(node)) patterns.push(node.text.split('.'));
|
|
64
|
-
else if (ts.isTemplateExpression(node)) patterns.push(
|
|
77
|
+
else if (ts.isTemplateExpression(node)) patterns.push(templatePatternOf(node));
|
|
65
78
|
ts.forEachChild(node, visit);
|
|
66
79
|
};
|
|
67
80
|
visit(file.sourceFile);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { canonical } from './config.mjs';
|
|
3
3
|
import { hasAnyFlag, sourceLocation } from '../../lib/ts-ast.mjs';
|
|
4
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
4
5
|
|
|
5
6
|
const HELPER_DEPTH = 4;
|
|
6
7
|
|
|
@@ -110,6 +111,21 @@ const at = (env, rel, sourceFile, node, extra = {}) => ({ path: rel, ...sourceLo
|
|
|
110
111
|
/** The app root file of an app (apps/<name>/src/app.module.ts), or null when the program does not hold it. */
|
|
111
112
|
const appRoot = (env, name) => graphFile(env, `apps/${name}/src/app.module.ts`);
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Whether a repository-relative file is a composition group of an app: `apps/<app>/src/compose/<area>.ts`, the module list of
|
|
116
|
+
* one area that the app root spreads into its imports (slot allows `compose/<area>.ts` of every be.app.* slot).
|
|
117
|
+
*/
|
|
118
|
+
export const isComposeGroup = rel => /^apps\/[^/]+\/src\/compose\/[^/]+\.ts$/.test(rel) && !/\.(?:spec|d)\.ts$/.test(rel);
|
|
119
|
+
|
|
120
|
+
/** The app root and the composition groups of an app, in path order (the root first); [] when the program holds no root. */
|
|
121
|
+
const appCompositionFiles = (env, name) => {
|
|
122
|
+
const root = appRoot(env, name);
|
|
123
|
+
if (!root) return [];
|
|
124
|
+
const prefix = `apps/${name}/src/compose/`;
|
|
125
|
+
const groups = [...env.graph.files.values()].filter(file => file.rel.startsWith(prefix) && isComposeGroup(file.rel));
|
|
126
|
+
return [root, ...groups.sort((a, b) => byCodeUnit(a.rel, b.rel))];
|
|
127
|
+
};
|
|
128
|
+
|
|
113
129
|
/** The body a call or a spread identifier resolves to when it is a function or a const declared in a file of the program (not a package), else null. */
|
|
114
130
|
function helperBodyOf(env, checker, node) {
|
|
115
131
|
const { ts } = env;
|
|
@@ -200,6 +216,7 @@ export function machineKit({ context, graph }) {
|
|
|
200
216
|
valueOfProperty: bound(valueOfProperty),
|
|
201
217
|
at: bound(at),
|
|
202
218
|
appRoot: bound(appRoot),
|
|
219
|
+
appCompositionFiles: bound(appCompositionFiles),
|
|
203
220
|
};
|
|
204
221
|
}
|
|
205
222
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { machineKit } from './machine-ast.mjs';
|
|
2
|
+
import { isComposeGroup, machineKit } from './machine-ast.mjs';
|
|
3
3
|
import { treeOf } from './required-files.mjs';
|
|
4
4
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
5
5
|
import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
@@ -13,8 +13,9 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
|
13
13
|
* anywhere in a feature, and an `@Module` class (or a `ConfigurableModuleBuilder`/`ConfigurableModuleClass` use) in a
|
|
14
14
|
* feature file that is neither the application module nor the transport module of its own folder are refused;
|
|
15
15
|
* - apps import only transport modules, of the kinds their app kind allows: the `composedBy` list of each transport slot
|
|
16
|
-
* in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root
|
|
17
|
-
* lists a feature's application module, or the transport module
|
|
16
|
+
* in knowledge/hfs/slots.yaml (api: graphql, http, websocket; worker: schedule, message). An app root (app.module.ts
|
|
17
|
+
* or a composition group of its compose/ folder) that lists a feature's application module, or the transport module
|
|
18
|
+
* of another kind, is refused;
|
|
18
19
|
* - a composed root (an owner slot with `composedBy`, be.cli) holds one static module per folder, named after the folder
|
|
19
20
|
* (cli.module.ts, <group>/<group>.module.ts); an app of a composing kind lists the root module only, and an app of any
|
|
20
21
|
* other kind lists none of its modules.
|
|
@@ -22,7 +23,6 @@ import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
|
22
23
|
export const MODULE_PER_TRANSPORT_RULE_IDS = ['BE_MODULE_SHAPE'];
|
|
23
24
|
|
|
24
25
|
const RULE = 'BE_MODULE_SHAPE';
|
|
25
|
-
const APP_ROOT_FILE = 'app.module.ts';
|
|
26
26
|
|
|
27
27
|
function featureOwnerOf(resolver, rel) {
|
|
28
28
|
const owner = resolver.ownerOf(rel);
|
|
@@ -152,28 +152,37 @@ function inspectModuleDeclarations(checker, target, node, root, app, scan, seen)
|
|
|
152
152
|
return references;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const seen = new Set();
|
|
155
|
+
/** The module references of one composition file: the root's `imports` lists, or every array of a composition group (its module list). */
|
|
156
|
+
function inspectCompositionFile(file, app, scan, seen) {
|
|
157
|
+
const { kit, ts } = scan;
|
|
158
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
159
|
+
const group = isComposeGroup(file.rel);
|
|
161
160
|
let references = 0;
|
|
162
161
|
const judge = node => {
|
|
163
162
|
const call = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register';
|
|
164
163
|
if (ts.isCallExpression(node) && !call) return;
|
|
165
164
|
const target = call ? node.expression.expression : node;
|
|
166
|
-
references += inspectModuleDeclarations(checker, target, node,
|
|
165
|
+
references += inspectModuleDeclarations(checker, target, node, file, app, scan, seen);
|
|
167
166
|
};
|
|
168
|
-
kit.walk(
|
|
167
|
+
kit.walk(file.sourceFile, node => {
|
|
169
168
|
if (ts.isPropertyAssignment(node) && kit.propertyNameText(node.name) === 'imports' && ts.isArrayLiteralExpression(node.initializer)) {
|
|
170
169
|
for (const element of node.initializer.elements) judge(element);
|
|
170
|
+
} else if (group && ts.isArrayLiteralExpression(node)) {
|
|
171
|
+
for (const element of node.elements) judge(element);
|
|
171
172
|
} else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register') judge(node);
|
|
172
173
|
return true;
|
|
173
174
|
});
|
|
174
175
|
return references;
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/** The module references of an app: its root `apps/<app>/src/app.module.ts` and the composition groups of `compose/` it spreads. */
|
|
179
|
+
function inspectAppRoot(app, scan) {
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
let references = 0;
|
|
182
|
+
for (const file of scan.kit.appCompositionFiles(app.name)) references += inspectCompositionFile(file, app, scan, seen);
|
|
183
|
+
return references;
|
|
184
|
+
}
|
|
185
|
+
|
|
177
186
|
function appModuleReferences(apps, scan) {
|
|
178
187
|
let references = 0;
|
|
179
188
|
for (const app of apps) references += inspectAppRoot(app, scan);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { machineKit } from './machine-ast.mjs';
|
|
2
|
+
import { isComposeGroup, machineKit } from './machine-ast.mjs';
|
|
3
3
|
import { callName } from './supabase-ast.mjs';
|
|
4
4
|
import { isTestWorldSlot } from '../test-world-slot.mjs';
|
|
5
5
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
@@ -7,7 +7,8 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
7
7
|
/**
|
|
8
8
|
* R45 `register-once` (BE_MODULE_SHAPE), the module graph read from the app roots (owner rule of 2026-09-30, the
|
|
9
9
|
* reference's `CodingModule imports DeviceModule` shape). Each capability has one representative module: the only module
|
|
10
|
-
* of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })
|
|
10
|
+
* of the capability an app registers, once, in `apps/<app>/src/app.module.ts` as `X.register({ isGlobal: true, ... })`, or in a
|
|
11
|
+
* composition group `apps/<app>/src/compose/<area>.ts` the root spreads (an app root here means the root and its groups).
|
|
11
12
|
* It may split itself into sub-modules imported as plain `imports: [Sub]`; a sub-module is never in an app and has one
|
|
12
13
|
* importer, its parent. Over every `@Module` class of the program:
|
|
13
14
|
*
|
|
@@ -57,6 +58,7 @@ function enclosingModule(ts, modules, node) {
|
|
|
57
58
|
/** Records the reference an element of an imports list, or a register call, makes to a module. */
|
|
58
59
|
function noteReference(scan, element, viaImports) {
|
|
59
60
|
const { ts, kit, modules, checker, file, seen, references, isAppRoot } = scan;
|
|
61
|
+
if (seen.has(element)) return;
|
|
60
62
|
const call = callName(ts, element) === 'register';
|
|
61
63
|
if (ts.isCallExpression(element) && !call) return;
|
|
62
64
|
const target = targetOf(kit, modules, checker, call ? element.expression.expression : element);
|
|
@@ -72,14 +74,19 @@ const isImportsList = (scan, node) => scan.ts.isPropertyAssignment(node) && scan
|
|
|
72
74
|
const isStrayGlobal = (scan, node) => scan.ts.isPropertyAssignment(node) && scan.kit.propertyNameText(node.name) === 'isGlobal'
|
|
73
75
|
&& node.initializer.kind === scan.ts.SyntaxKind.TrueKeyword && !scan.isAppRoot(scan.file) && !scan.isWorld(scan.file);
|
|
74
76
|
|
|
77
|
+
// A composition group's module list is any array of the group: the root spreads it into its imports.
|
|
78
|
+
const isGroupList = (scan, node) => scan.ts.isArrayLiteralExpression(node) && isComposeGroup(scan.file.rel);
|
|
79
|
+
|
|
75
80
|
function visitReferenceNode(scan, node) {
|
|
76
81
|
if (isImportsList(scan, node)) {
|
|
77
82
|
for (const element of node.initializer.elements) noteReference(scan, element, true);
|
|
83
|
+
} else if (isGroupList(scan, node)) {
|
|
84
|
+
for (const element of node.elements) noteReference(scan, element, true);
|
|
78
85
|
} else if (callName(scan.ts, node) === 'register' && !scan.seen.has(node)) {
|
|
79
86
|
noteReference(scan, node, false);
|
|
80
87
|
}
|
|
81
88
|
if (isStrayGlobal(scan, node)) {
|
|
82
|
-
scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts), where the representative module of a capability is registered; a module never decides its own globality.');
|
|
89
|
+
scan.report(scan.file, node, '`isGlobal: true` appears only in the app root (apps/<app>/src/app.module.ts or a group of apps/<app>/src/compose/), where the representative module of a capability is registered; a module never decides its own globality.');
|
|
83
90
|
}
|
|
84
91
|
return true;
|
|
85
92
|
}
|
|
@@ -170,11 +177,12 @@ export function checkRegisterOnce(input) {
|
|
|
170
177
|
const { ts } = kit;
|
|
171
178
|
const violations = [];
|
|
172
179
|
const report = (file, node, message, extra = {}) => violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
|
|
173
|
-
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && path.posix.basename(file.rel) === 'app.module.ts';
|
|
180
|
+
const isAppRoot = file => Boolean(file.slot?.startsWith('be.app.')) && (path.posix.basename(file.rel) === 'app.module.ts' || isComposeGroup(file.rel));
|
|
174
181
|
// The test world (slot be.tests.world) is the test composition root: useTestWorld registers capability modules with
|
|
175
182
|
// `isGlobal: true` the way an app root does. A spec (integration, contract, e2e) is not a composition root.
|
|
176
183
|
const isWorld = file => isTestWorldSlot(file.slot);
|
|
177
|
-
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`
|
|
184
|
+
const appOf = file => config.apps.find(app => file.rel === `apps/${app.name}/src/app.module.ts`
|
|
185
|
+
|| (isComposeGroup(file.rel) && file.rel.startsWith(`apps/${app.name}/src/compose/`)))?.name ?? null;
|
|
178
186
|
const modules = moduleDeclarations(graph, kit, ts);
|
|
179
187
|
const references = moduleReferences(graph, kit, ts, modules, isWorld, isAppRoot, report);
|
|
180
188
|
const rootReferences = references.filter(item => item.root);
|
|
@@ -4,6 +4,7 @@ import { lsFiles } from '../../api/git/ls-files.mjs';
|
|
|
4
4
|
import { revParseQuery } from '../../api/git/rev-parse-query.mjs';
|
|
5
5
|
import { gitOutputOf } from '../../lib/git.mjs';
|
|
6
6
|
import { trimTrailingSlashes } from '../trailing-slashes.mjs';
|
|
7
|
+
import { requiredFileApplies } from '../slot-required.mjs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* HFS check 5: the files and directories the slot manifest requires (knowledge/hfs/slots.yaml `requires`,
|
|
@@ -173,7 +174,7 @@ function requirementExists(tree, target) {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
function reportMissingRequirement(requirement, tree, report) {
|
|
176
|
-
if (requirementExists(tree, requirement.target)) return;
|
|
177
|
+
if (requirementExists(tree, requirement.target) || !requiredFileApplies(requirement.slot, requirement.target, tree.files)) return;
|
|
177
178
|
const rootExists = !requirement.root || tree.directories.has(requirement.root);
|
|
178
179
|
if (!rootExists) {
|
|
179
180
|
report(ruleFor(requirement.slot, requirement.target, false), requirement.root, requirement.slot, requirement.root, { kind: 'directory' },
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { catFile } from '../../api/git/cat-file.mjs';
|
|
2
|
+
import { diff } from '../../api/git/diff.mjs';
|
|
3
|
+
import { gitOutputOf } from '../../lib/git.mjs';
|
|
4
|
+
|
|
5
|
+
/** Eligible additions, copies, edits and rename destinations in the index, read in one blob batch. */
|
|
6
|
+
export function stagedConsumerSources(root, accept) {
|
|
7
|
+
try {
|
|
8
|
+
const options = { cwd: root, maxBuffer: 256 * 1024 * 1024 };
|
|
9
|
+
const files = gitOutputOf(diff(['--cached', '--relative', '--name-only', '--diff-filter=ACMR', '-z', '--', '.'], options))
|
|
10
|
+
.split('\0').filter(file => file && accept(file));
|
|
11
|
+
if (!files.length) return [];
|
|
12
|
+
// :./ keeps paths relative to this side's root even when git's top level is the parent app folder.
|
|
13
|
+
// -z terminates requests with NUL; output headers and blob trailers still use LF.
|
|
14
|
+
const bytes = gitOutputOf(catFile(['--batch', '-z'], {
|
|
15
|
+
...options, encoding: null, input: files.map(file => `:./${file}\0`).join(''),
|
|
16
|
+
}));
|
|
17
|
+
if (!Buffer.isBuffer(bytes)) return [];
|
|
18
|
+
const sources = [];
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const end = bytes.indexOf(10, cursor);
|
|
22
|
+
if (end < 0) return [];
|
|
23
|
+
const header = /^[0-9a-f]+ blob (\d+)$/u.exec(bytes.subarray(cursor, end).toString('utf8'));
|
|
24
|
+
if (!header) return []; // An index changed during the read: discard this supplement, keep disk consumers.
|
|
25
|
+
const size = Number(header[1]);
|
|
26
|
+
const start = end + 1;
|
|
27
|
+
const next = start + size;
|
|
28
|
+
if (!Number.isSafeInteger(size) || next >= bytes.length || bytes[next] !== 10) return [];
|
|
29
|
+
sources.push({ file, text: bytes.subarray(start, next).toString('utf8') });
|
|
30
|
+
cursor = next + 1;
|
|
31
|
+
}
|
|
32
|
+
return sources;
|
|
33
|
+
} catch {
|
|
34
|
+
// A non-git fixture (or an unavailable index) still uses the existing filesystem consumers.
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// supabase-ast.mjs - shared TypeScript-symbol and expression helpers for the front-end and back-end Supabase
|
|
2
2
|
// architecture checks. Recognition follows imported declarations and inferred types, never local spelling alone.
|
|
3
|
+
import { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
4
|
+
|
|
3
5
|
export const SUPABASE_MODULES = new Set(['@supabase/ssr', '@supabase/supabase-js']);
|
|
4
6
|
|
|
5
7
|
export const moduleNameOf = (ts, node) => {
|
|
@@ -38,12 +40,7 @@ const signatureFromSupabase = (checker, call) => {
|
|
|
38
40
|
try { return declarationFromSupabase(checker.getResolvedSignature(call)?.declaration); } catch { return false; }
|
|
39
41
|
};
|
|
40
42
|
|
|
41
|
-
export const unwrap = (ts, node) => {
|
|
42
|
-
let current = node;
|
|
43
|
-
while (current && (ts.isParenthesizedExpression(current) || ts.isAwaitExpression(current) || ts.isAsExpression(current)
|
|
44
|
-
|| ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current))) current = current.expression;
|
|
45
|
-
return current;
|
|
46
|
-
};
|
|
43
|
+
export const unwrap = (ts, node) => unwrapExpression(ts, node, { awaitExpressions: true, satisfiesExpressions: false });
|
|
47
44
|
|
|
48
45
|
const isSupabaseValue = (kit, checker, node) => {
|
|
49
46
|
const current = unwrap(kit.ts, node);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { machineKit } from './machine-ast.mjs';
|
|
5
5
|
import { importedFrom, reportAt, unwrap } from './supabase-ast.mjs';
|
|
6
|
+
import { accessName } from './backend-app-files.mjs';
|
|
6
7
|
|
|
7
8
|
const BE_SUPABASE_SLOT = 'be.integrations.supabase';
|
|
8
9
|
const BE_JWT_VERIFIED = 'BE_SUPABASE_JWT_VERIFIED';
|
|
@@ -47,12 +48,6 @@ const importedCall = (kit, checker, call) => {
|
|
|
47
48
|
return owner?.name === 'default' ? { module: owner.module, name: call.expression.name.text } : null;
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
const accessName = (ts, node) => {
|
|
51
|
-
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
52
|
-
if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) return node.argumentExpression.text;
|
|
53
|
-
return null;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
51
|
const requestLike = (ts, node) => {
|
|
57
52
|
const current = unwrap(ts, node);
|
|
58
53
|
if (ts.isIdentifier(current)) return /^(?:req|request)$/iu.test(current.text);
|
|
@@ -14,8 +14,15 @@ import { byCodeUnit } from '../../lib/list.mjs';
|
|
|
14
14
|
*/
|
|
15
15
|
export const TIER_RULE_IDS = ['BE_TIER_DIRECTION', 'BE_FEATURE_IMPORTS_FEATURE', 'BE_KIND_ISOLATION', 'FE_TIER_DIRECTION', 'FE_APP_ISOLATION', 'ARCH_OWNER_CYCLE'];
|
|
16
16
|
|
|
17
|
+
function tierDirectionText({ fromTier, toTier, mayImport, kindRestriction, fromKind, toKind }) {
|
|
18
|
+
if (!kindRestriction) return `a ${fromTier} may import only ${mayImport.join(', ') || 'nothing'}; it imports a ${toTier}`;
|
|
19
|
+
const source = fromKind ?? 'no folder kind';
|
|
20
|
+
const target = toKind ?? 'no folder kind';
|
|
21
|
+
return `a ${fromTier} may import ${toTier} owners only from kinds ${kindRestriction.from.join(', ')} to kinds ${kindRestriction.to.join(', ')}; it imports ${source} -> ${target}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
const REASON_TEXT = {
|
|
18
|
-
tierDirection:
|
|
25
|
+
tierDirection: tierDirectionText,
|
|
19
26
|
layerOrder: ({ fromLayer, toLayer }) => `a ${fromLayer} component may import only the layers after it; it imports a ${toLayer}`,
|
|
20
27
|
crossApp: ({ from, to }) => `app ${from} imports app ${to}; apps never import each other, shared code is a packages/<pkg> slot`,
|
|
21
28
|
};
|
|
@@ -9,6 +9,7 @@ import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule
|
|
|
9
9
|
import { readJsonFile } from '../../lib/json.mjs';
|
|
10
10
|
import { sourceLocation } from '../../lib/ts-ast.mjs';
|
|
11
11
|
import { locateDeclaration } from '../slots.mjs';
|
|
12
|
+
export { unwrapExpression } from '../../lib/ts-analysis.mjs';
|
|
12
13
|
|
|
13
14
|
const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
14
15
|
const SCRIPT_TAIL = '[cm]?[jt]sx?';
|
|
@@ -399,14 +400,6 @@ export function reachableViolation(edges, firstEdge, forbidden, { follow = () =>
|
|
|
399
400
|
/** A framework identity the checkers cannot prove statically. */
|
|
400
401
|
export const UNPROVEN_FRAMEWORK = '(unproven framework identity)';
|
|
401
402
|
|
|
402
|
-
/** The expression under parentheses, type assertions, non-null and satisfies wrappers. */
|
|
403
|
-
export function unwrapExpression(ts, expression) {
|
|
404
|
-
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
405
|
-
|| ts.isTypeAssertionExpression(expression) || ts.isNonNullExpression(expression)
|
|
406
|
-
|| (ts.isSatisfiesExpression?.(expression) ?? false))) expression = expression.expression;
|
|
407
|
-
return expression;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
403
|
/* ---- the AST walks every architecture checker shares ---- */
|
|
411
404
|
|
|
412
405
|
/** The names of `expected` an import or re-export statement binds (all of them for a namespace or star form). */
|
|
@@ -4,6 +4,7 @@ import { machineKit } from './machine-ast.mjs';
|
|
|
4
4
|
import { constructorDependencies } from './constructor-deps.mjs';
|
|
5
5
|
import { treeOf } from './required-files.mjs';
|
|
6
6
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
7
|
+
import { importsOf } from '../../lib/ts-analysis.mjs';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* R48 `unit-spec-providers` (BE_SPEC_QUALITY), the unit test standard's rule 3: the unit spec of a service builds its
|
|
@@ -25,17 +26,6 @@ export const UNIT_SPEC_PROVIDERS_RULE_IDS = ['BE_SPEC_QUALITY'];
|
|
|
25
26
|
const RULE = 'BE_SPEC_QUALITY';
|
|
26
27
|
const SPEC_ROOT = /^(?:src|apps)\//u;
|
|
27
28
|
|
|
28
|
-
/** local name -> {name, module} for every named import of a source file. */
|
|
29
|
-
function importsOf(ts, sourceFile) {
|
|
30
|
-
const imports = new Map();
|
|
31
|
-
for (const statement of sourceFile.statements) {
|
|
32
|
-
const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : null;
|
|
33
|
-
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
34
|
-
for (const element of bindings.elements) imports.set(element.name.text, { name: (element.propertyName ?? element.name).text, module: statement.moduleSpecifier.text });
|
|
35
|
-
}
|
|
36
|
-
return imports;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
29
|
/** The `Test.createTestingModule(...)` calls of a spec. */
|
|
40
30
|
function testingModules(ts, sourceFile) {
|
|
41
31
|
const calls = [];
|
|
@@ -323,6 +323,12 @@ function runtimeParamProblems(rp) {
|
|
|
323
323
|
];
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
const folderKindsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length && list.every(kind => NAME.test(kind));
|
|
327
|
+
const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCodeUnit).join() === 'from,to'
|
|
328
|
+
&& ['from', 'to'].every(side => folderKindsValid(pair[side]));
|
|
329
|
+
const kindRestrictionValid = kinds => isPlainObject(kinds) && Object.keys(kinds).length > 0
|
|
330
|
+
&& Object.entries(kinds).every(([target, pair]) => NAME.test(target) && kindPairValid(pair));
|
|
331
|
+
|
|
326
332
|
/** Shape problems of the tier map of one profile. */
|
|
327
333
|
export function tierMapProblems(profile, tiers) {
|
|
328
334
|
const bad = [];
|
|
@@ -330,14 +336,33 @@ export function tierMapProblems(profile, tiers) {
|
|
|
330
336
|
for (const [tier, def] of Object.entries(tiers)) {
|
|
331
337
|
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
332
338
|
if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
|
|
333
|
-
|
|
339
|
+
if (def?.mayImportKinds !== undefined && !kindRestrictionValid(def.mayImportKinds)) bad.push(`tiers.${profile}.${tier}.mayImportKinds must map tier names to {from, to} non-empty lists of unique folder kinds`);
|
|
340
|
+
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
|
|
341
|
+
}
|
|
342
|
+
return bad;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function tierFolderKindProblems(manifest, profile, tier, kinds, at) {
|
|
346
|
+
const known = new Set(manifest.slots.filter((slot) => slot.profiles.includes(profile) && slot.tier === tier).flatMap((slot) => [...(slot.kinds ?? []), ...(slot.layers ?? [])]));
|
|
347
|
+
return kinds.filter(kind => !known.has(kind)).map(kind => `${at} names ${kind}, which is not a folder kind of the ${tier} tier`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Kind restrictions narrow an allowed tier edge and name kinds that its source and target slots actually declare. */
|
|
351
|
+
export function tierKindProblems(manifest, profile) {
|
|
352
|
+
const bad = [];
|
|
353
|
+
for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
|
|
354
|
+
for (const [target, kinds] of Object.entries(def.mayImportKinds ?? {})) {
|
|
355
|
+
const at = `tiers.${profile}.${tier}.mayImportKinds.${target}`;
|
|
356
|
+
if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
|
|
357
|
+
bad.push(...tierFolderKindProblems(manifest, profile, tier, kinds.from, `${at}.from`), ...tierFolderKindProblems(manifest, profile, target, kinds.to, `${at}.to`));
|
|
358
|
+
}
|
|
334
359
|
}
|
|
335
360
|
return bad;
|
|
336
361
|
}
|
|
337
362
|
|
|
338
363
|
/** The semantic rules of a runtime manifest beyond the slot rules both kinds share: tier names and the schema major. */
|
|
339
364
|
export function runtimeSemanticProblems(m) {
|
|
340
|
-
const bad =
|
|
365
|
+
const bad = tierKindProblems(m, RUNTIME_KIND);
|
|
341
366
|
const tiers = m.tiers.runtime;
|
|
342
367
|
for (const [tier, def] of Object.entries(tiers)) {
|
|
343
368
|
for (const target of def.mayImport) if (!(target in tiers)) bad.push(`tiers.runtime.${tier}.mayImport names ${target}, which is not a runtime tier`);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (scripts/hfs/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
|
|
4
4
|
// HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
|
|
5
5
|
// BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
|
|
6
|
+
import fs from 'node:fs';
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { allowsFile } from './allows.mjs';
|
|
8
9
|
import { isFeTestPath } from './rules/fe-no-tests.mjs';
|
|
@@ -30,8 +31,11 @@ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
|
|
|
30
31
|
const slot = resolver.slot(c.slot);
|
|
31
32
|
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
|
|
32
33
|
const admitted = allowsFile(resolver, file);
|
|
34
|
+
// Environment parsing is an app slot role, not a source suffix admitted outside that matching app file.
|
|
35
|
+
if (c.slot.startsWith('be.app.') && admitted?.allowed && admitted.entry === '<app>.env.ts') return null;
|
|
33
36
|
if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
|
|
34
|
-
|
|
37
|
+
// A plain entry names its file by a variable only (`<x>.ts`, or `compose/<area>.ts` of an app slot): a kebab name is its form.
|
|
38
|
+
if (admitted?.allowed && PLAIN_ENTRY.test(path.posix.basename(admitted.entry ?? '')) && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
|
|
35
39
|
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
|
|
36
40
|
return { c, base, suffixes, bannedSuffixes };
|
|
37
41
|
}
|
|
@@ -54,7 +58,7 @@ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuf
|
|
|
54
58
|
|
|
55
59
|
/**
|
|
56
60
|
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
|
|
57
|
-
* be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
61
|
+
* be.persistence, the matching <app>.env.ts explicitly admitted by its app slot, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
|
|
58
62
|
* as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
|
|
59
63
|
* name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
|
|
60
64
|
*/
|
|
@@ -86,6 +90,8 @@ function forbiddenPathFinding(file, c, resolver) {
|
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
function pathStatusFinding(file, resolver) {
|
|
93
|
+
const liteWork = liteStarciworkFinding(file, resolver);
|
|
94
|
+
if (liteWork) return liteWork;
|
|
89
95
|
const c = resolver.classifyPath(file);
|
|
90
96
|
if (c.status === 'no-slot') {
|
|
91
97
|
const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
|
|
@@ -98,6 +104,30 @@ function pathStatusFinding(file, resolver) {
|
|
|
98
104
|
return null;
|
|
99
105
|
}
|
|
100
106
|
|
|
107
|
+
/** The lite Work boundary, read from app.starciwork's edition-specific admission list, including paths a narrower slot owns. */
|
|
108
|
+
export function liteStarciworkFinding(file, resolver, { dir = false } = {}) {
|
|
109
|
+
if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return null;
|
|
110
|
+
const p = resolver.classifyPath(file).path;
|
|
111
|
+
if (!p.startsWith('.starciwork/')) return null;
|
|
112
|
+
const admitted = allowsFile(resolver, dir ? `${p}/.probe` : p);
|
|
113
|
+
if (admitted?.slot === 'app.starciwork' && admitted.allowed) return null;
|
|
114
|
+
return { code: 'HFS_STARCIWORK_LITE_FORBIDDEN', level: 'error', path: p, slot: 'app.starciwork', message: `${p}: lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore); remove this path or use the full edition` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Local lite Work leftovers, including metadata ignored by an old full-edition .gitignore. */
|
|
118
|
+
export function liteStarciworkTreeFindings(repoRoot, resolver, tracked) {
|
|
119
|
+
if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return [];
|
|
120
|
+
let entries = [];
|
|
121
|
+
try { entries = fs.readdirSync(path.join(repoRoot, '.starciwork'), { withFileTypes: true }); } catch { /* Work is optional. */ }
|
|
122
|
+
const trackedSet = new Set(tracked);
|
|
123
|
+
return entries.flatMap(entry => {
|
|
124
|
+
const file = `.starciwork/${entry.name}`;
|
|
125
|
+
if (trackedSet.has(file)) return [];
|
|
126
|
+
const finding = liteStarciworkFinding(file, resolver, { dir: entry.isDirectory() });
|
|
127
|
+
return finding ? [finding] : [];
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
/** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
|
|
102
132
|
export function pathFindings({ files, resolver, profile }) {
|
|
103
133
|
const findings = [];
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
|
|
6
6
|
// - a line of any other tracked text file that matches a secret pattern.
|
|
7
7
|
// A finding names the file, the line and the pattern, never the value.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
8
10
|
import { FORBIDDEN_FILES, secretHits } from '../../lib/secret-patterns.mjs';
|
|
9
11
|
import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
|
|
10
12
|
import { found, readText } from './read.mjs';
|
|
@@ -16,6 +18,17 @@ const NOT_SCANNED = /(^|\/)package-lock\.json$/;
|
|
|
16
18
|
/** True when the slot `id` of the manifest reports HFS_PLAINTEXT_SECRET for the paths it forbids. */
|
|
17
19
|
export const slotOwnsSecrets = (slot) => slot?.rules?.includes(PLAINTEXT_SECRET) === true;
|
|
18
20
|
|
|
21
|
+
/** A legacy lite root secrets.env is reported even when ignored; its presence is enough, so no value is read. */
|
|
22
|
+
export function untrackedSecretFindings({ repoRoot, files, repo }) {
|
|
23
|
+
if (repo.profile !== 'app' || repo.edition !== 'lite' || files.includes('secrets.env')) return [];
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.statSync(path.join(repoRoot, 'secrets.env')).isFile()) return [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return [found(PLAINTEXT_SECRET, 'secrets.env', 'secrets.env is an untracked plaintext secret at the lite app root; seal it with starci app secret set into .starcistacks/<env>/secrets/<slug>.enc and remove the plaintext file; read materialized values by *_FILE', { level: 'warning', pattern: 'untracked-secrets-env' })];
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
/**
|
|
20
33
|
* The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
|
|
21
34
|
* no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
|
|
@@ -65,8 +65,10 @@ export function importAllowedIn(scope, fromPath, toPath) {
|
|
|
65
65
|
const fromOwner = ownerIn(scope, from.path);
|
|
66
66
|
const toOwner = ownerIn(scope, to.path);
|
|
67
67
|
if (sameOwnerUnit(from, fromOwner) === sameOwnerUnit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
|
|
68
|
-
const { mayImport } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
|
|
68
|
+
const { mayImport, mayImportKinds } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
|
|
69
69
|
if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
|
|
70
|
+
const kindRestriction = mayImportKinds?.[toTier];
|
|
71
|
+
if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
|
|
70
72
|
const layerProblem = lowerLayerProblem(scope, from, to, fromTier, toTier, fromOwner, toOwner);
|
|
71
73
|
if (layerProblem) return layerProblem;
|
|
72
74
|
// A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
|
|
@@ -5,6 +5,19 @@ import { braceVariants } from '../lib/glob.mjs';
|
|
|
5
5
|
import { classifyIn, slotEnabled } from './slot-classify.mjs';
|
|
6
6
|
import { fillVars, varsOf } from './slot-match.mjs';
|
|
7
7
|
|
|
8
|
+
/** A populated fe.modules instance needs its index.ts only when it contains TypeScript, including nested files. */
|
|
9
|
+
export function requiredFileApplies(slotId, target, files) {
|
|
10
|
+
if (!/^fe\.modules(?:\.|$)/u.test(slotId) || path.posix.basename(target) !== 'index.ts') return true;
|
|
11
|
+
const prefix = `${path.posix.dirname(target)}/`;
|
|
12
|
+
let populated = false;
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
if (!file.startsWith(prefix)) continue;
|
|
15
|
+
populated = true;
|
|
16
|
+
if (/\.tsx?$/u.test(file)) return true;
|
|
17
|
+
}
|
|
18
|
+
return !populated;
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
/** One `requires` entry of a slot placed under `root`: a leading / makes it repository-relative, the variables are filled from `bindings`. */
|
|
9
22
|
function requiredEntry(entry, bindings, root) {
|
|
10
23
|
const rooted = entry.startsWith('/');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
3
3
|
import { triggerProblems } from './declaration-slots.mjs';
|
|
4
4
|
import { litePresenceOf, slotInEdition } from './edition-slots.mjs';
|
|
5
|
-
import { manifestKind, RUNTIME_KIND, runtimeSemanticProblems } from './manifest-shape.mjs';
|
|
5
|
+
import { manifestKind, RUNTIME_KIND, runtimeSemanticProblems, tierKindProblems } from './manifest-shape.mjs';
|
|
6
6
|
import { APP_SCOPE, PROFILES } from './slot-manifest-shape.mjs';
|
|
7
7
|
|
|
8
8
|
function checkAppRootSlot(slot, bad) {
|
|
@@ -113,6 +113,7 @@ function checkSideReads(manifest, bad, braceVariants) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function checkProfileTierTargets(manifest, profile, bad) {
|
|
116
|
+
bad.push(...tierKindProblems(manifest, profile));
|
|
116
117
|
for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
|
|
117
118
|
for (const target of def.mayImport) {
|
|
118
119
|
if (!(target in manifest.tiers[profile])) bad.push(`tiers.${profile}.${tier}.mayImport names ${target}, which is not a ${profile} tier`);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The expression under the wrappers selected by an AST check's contract. */
|
|
2
|
+
export function unwrapExpression(ts, expression, { awaitExpressions = false, typeAssertions = true, satisfiesExpressions = true } = {}) {
|
|
3
|
+
while (expression && (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression)
|
|
4
|
+
|| (typeAssertions && ts.isTypeAssertionExpression(expression)) || ts.isNonNullExpression(expression)
|
|
5
|
+
|| (satisfiesExpressions && (ts.isSatisfiesExpression?.(expression) ?? false))
|
|
6
|
+
|| (awaitExpressions && ts.isAwaitExpression(expression)))) expression = expression.expression;
|
|
7
|
+
return expression;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Local name -> imported name and module; callers select their existing module key and literal policy. */
|
|
11
|
+
export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiteralsOnly = false } = {}) {
|
|
12
|
+
const imports = new Map();
|
|
13
|
+
for (const statement of sourceFile.statements) {
|
|
14
|
+
if (!ts.isImportDeclaration(statement) || (stringLiteralsOnly && !ts.isStringLiteralLike(statement.moduleSpecifier))) continue;
|
|
15
|
+
const bindings = statement.importClause?.namedBindings;
|
|
16
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
17
|
+
for (const binding of bindings.elements) imports.set(binding.name.text, { name: (binding.propertyName ?? binding.name).text, [moduleKey]: statement.moduleSpecifier.text });
|
|
18
|
+
}
|
|
19
|
+
return imports;
|
|
20
|
+
}
|