@starci/eslint-canon-fe 6.0.2 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +3 -5
- package/client-boundary.mjs +2 -5
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +18 -18
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +5 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +3 -68
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/project-graph.mjs +23 -0
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/knowledge/patterns/fe/folder.yaml +309 -0
- package/runtime/scripts/checks/architecture/backend.mjs +350 -0
- package/runtime/scripts/checks/architecture/background-unowned.mjs +107 -0
- package/runtime/scripts/checks/architecture/client-reaches-server.mjs +94 -0
- package/runtime/scripts/checks/architecture/clones.mjs +200 -0
- package/runtime/scripts/checks/architecture/config-unread.mjs +35 -0
- package/runtime/scripts/checks/architecture/config.mjs +310 -0
- package/runtime/scripts/checks/architecture/connection-map.mjs +208 -0
- package/runtime/scripts/checks/architecture/constructor-deps.mjs +100 -0
- package/runtime/scripts/checks/architecture/contract-fixture-guard.mjs +128 -0
- package/runtime/scripts/checks/architecture/contracts.mjs +792 -0
- package/runtime/scripts/checks/architecture/cross-app-duplicate.mjs +73 -0
- package/runtime/scripts/checks/architecture/dead-exports.mjs +265 -0
- package/runtime/scripts/checks/architecture/default-deny.mjs +129 -0
- package/runtime/scripts/checks/architecture/doc-language.mjs +39 -0
- package/runtime/scripts/checks/architecture/entrypoint.mjs +57 -0
- package/runtime/scripts/checks/architecture/error-codes.mjs +45 -0
- package/runtime/scripts/checks/architecture/error-masked.mjs +60 -0
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +38 -0
- package/runtime/scripts/checks/architecture/feature-shape.mjs +49 -0
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +83 -0
- package/runtime/scripts/checks/architecture/frontend.mjs +995 -0
- package/runtime/scripts/checks/architecture/hfs-graph.mjs +61 -0
- package/runtime/scripts/checks/architecture/hfs.mjs +521 -0
- package/runtime/scripts/checks/architecture/hooks-are-hooks.mjs +88 -0
- package/runtime/scripts/checks/architecture/i18n-keys.mjs +146 -0
- package/runtime/scripts/checks/architecture/index.mjs +321 -0
- package/runtime/scripts/checks/architecture/injection-token-exported.mjs +62 -0
- package/runtime/scripts/checks/architecture/machine-ast.mjs +138 -0
- package/runtime/scripts/checks/architecture/module-per-transport.mjs +130 -0
- package/runtime/scripts/checks/architecture/next-data.mjs +775 -0
- package/runtime/scripts/checks/architecture/owners.mjs +95 -0
- package/runtime/scripts/checks/architecture/package-shape.mjs +63 -0
- package/runtime/scripts/checks/architecture/reachability.mjs +233 -0
- package/runtime/scripts/checks/architecture/register-once.mjs +141 -0
- package/runtime/scripts/checks/architecture/registration.mjs +319 -0
- package/runtime/scripts/checks/architecture/required-files.mjs +172 -0
- package/runtime/scripts/checks/architecture/route-files-thin.mjs +97 -0
- package/runtime/scripts/checks/architecture/schema-owner.mjs +261 -0
- package/runtime/scripts/checks/architecture/source-names.mjs +607 -0
- package/runtime/scripts/checks/architecture/sql-owner.mjs +142 -0
- package/runtime/scripts/checks/architecture/sql-tokens.mjs +327 -0
- package/runtime/scripts/checks/architecture/surface.mjs +91 -0
- package/runtime/scripts/checks/architecture/symbols.mjs +193 -0
- package/runtime/scripts/checks/architecture/test-world-files.mjs +163 -0
- package/runtime/scripts/checks/architecture/tiers.mjs +130 -0
- package/runtime/scripts/checks/architecture/transport-owner.mjs +112 -0
- package/runtime/scripts/checks/architecture/typescript.mjs +573 -0
- package/runtime/scripts/checks/architecture/unit-spec-providers.mjs +122 -0
- package/runtime/scripts/checks/common.mjs +44 -0
- package/runtime/scripts/checks/typescript-programs.mjs +82 -0
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-path-findings.mjs +84 -0
- package/runtime/scripts/lib/hfs-rules/fe-no-tests.mjs +47 -0
- package/runtime/scripts/lib/hfs-rules/read.mjs +28 -0
- package/runtime/scripts/lib/hfs-rules/secrets.mjs +54 -0
- package/runtime/scripts/lib/hfs-rules/spec-placement.mjs +29 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/json.mjs +22 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/project-graph.mjs +0 -0
- package/runtime/scripts/lib/project-rule.mjs +39 -0
- package/runtime/scripts/lib/repo-identity.mjs +50 -0
- package/runtime/scripts/lib/secret-patterns.mjs +44 -0
- package/runtime/scripts/lib/stack-services.mjs +217 -0
- package/runtime/scripts/lib/test-secrets.mjs +120 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +13 -17
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { slash } from '../../lib/path-key.mjs';
|
|
4
|
+
import { readJsonFile as readJson } from '../../lib/json.mjs';
|
|
5
|
+
import { isInside } from '../common.mjs';
|
|
6
|
+
import { openHfs } from '../../lib/hfs-slots.mjs';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
/** A file's identity: its resolved real path, or the resolved path when it does not exist. */
|
|
10
|
+
function canonical(file) {
|
|
11
|
+
const absolute = path.resolve(file);
|
|
12
|
+
try { return path.resolve(fs.realpathSync(absolute)); } catch { return absolute; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function exactKeys(value, allowed, label) {
|
|
16
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error(`${label} must be an object.`);
|
|
17
|
+
const unknown = Object.keys(value).filter(key => !allowed.has(key));
|
|
18
|
+
if (unknown.length) throw Error(`${label} has unsupported fields: ${unknown.sort().join(', ')}.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The git repository `root` sits in, found by walking up for a `.git` entry (a directory in a clone, a
|
|
23
|
+
* file in a worktree), or null when there is none. It is the outer boundary a `file:` dependency may
|
|
24
|
+
* resolve into: a checked project is routinely one package of a repository that also ships the packages
|
|
25
|
+
* it consumes, and `--root` is the project, not the repository.
|
|
26
|
+
*/
|
|
27
|
+
function enclosingRepository(root) {
|
|
28
|
+
let cursor = path.resolve(root);
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (fs.existsSync(path.join(cursor, '.git'))) return cursor;
|
|
31
|
+
const parent = path.dirname(cursor);
|
|
32
|
+
if (parent === cursor) return null;
|
|
33
|
+
cursor = parent;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function existingPackageDirectory(absolute) {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.lstatSync(absolute).isDirectory()) return false;
|
|
40
|
+
const manifest = fs.lstatSync(path.join(absolute, 'package.json'));
|
|
41
|
+
return manifest.isFile() && !manifest.isSymbolicLink();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function existingDirectory(root, relative) {
|
|
48
|
+
try {
|
|
49
|
+
return fs.lstatSync(path.join(root, relative)).isDirectory();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function existingRegularFile(root, relative) {
|
|
56
|
+
try {
|
|
57
|
+
const stat = fs.lstatSync(path.join(root, ...relative.split('/')));
|
|
58
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function workspaceDirectories(root) {
|
|
65
|
+
const directories = new Set();
|
|
66
|
+
const queue = [root];
|
|
67
|
+
const visited = new Set();
|
|
68
|
+
const repository = enclosingRepository(root);
|
|
69
|
+
const admit = (candidate, label = 'local package', strict = false) => {
|
|
70
|
+
const absolute = path.resolve(candidate);
|
|
71
|
+
if (!isInside(root, absolute) || absolute === root || !existingDirectory(root, slash(path.relative(root, absolute)))) {
|
|
72
|
+
if (strict) throw Error(`${label} must resolve to a package directory inside the repository.`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const manifest = path.join(absolute, 'package.json');
|
|
76
|
+
try {
|
|
77
|
+
if (!fs.lstatSync(manifest).isFile() || fs.lstatSync(manifest).isSymbolicLink()) {
|
|
78
|
+
if (strict) throw Error(`${label} must resolve to a regular package.json inside the repository.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (strict) throw Error(error.message.startsWith(label) ? error.message : `${label} must resolve to a regular package.json inside the repository.`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const relative = slash(path.relative(root, absolute));
|
|
86
|
+
if (!directories.has(relative)) { directories.add(relative); queue.push(absolute); }
|
|
87
|
+
};
|
|
88
|
+
while (queue.length) {
|
|
89
|
+
const packageRoot = queue.shift();
|
|
90
|
+
if (visited.has(packageRoot)) continue;
|
|
91
|
+
visited.add(packageRoot);
|
|
92
|
+
const pkg = readJson(path.join(packageRoot, 'package.json'));
|
|
93
|
+
const patterns = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
|
|
94
|
+
for (const pattern of Array.isArray(patterns) ? patterns : []) {
|
|
95
|
+
if (typeof pattern !== 'string' || !pattern.trim()) throw Error('package.json workspace entries must be non-empty paths.');
|
|
96
|
+
const normalized = slash(pattern.trim()).replace(/^\.\//, '');
|
|
97
|
+
const segments = normalized.split('/');
|
|
98
|
+
if (path.isAbsolute(normalized) || segments.some(segment => segment === '..' || (segment.includes('*') && segment !== '*'))) {
|
|
99
|
+
throw Error(`Unsupported local workspace pattern: ${normalized}.`);
|
|
100
|
+
}
|
|
101
|
+
let candidates = [packageRoot];
|
|
102
|
+
for (const segment of segments) {
|
|
103
|
+
const next = [];
|
|
104
|
+
for (const base of candidates) {
|
|
105
|
+
if (segment === '*') {
|
|
106
|
+
if (!fs.existsSync(base) || !fs.lstatSync(base).isDirectory()) continue;
|
|
107
|
+
next.push(...fs.readdirSync(base, { withFileTypes: true })
|
|
108
|
+
.filter(item => item.isDirectory())
|
|
109
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
110
|
+
.map(item => path.join(base, item.name)));
|
|
111
|
+
} else next.push(path.join(base, segment));
|
|
112
|
+
}
|
|
113
|
+
candidates = next;
|
|
114
|
+
}
|
|
115
|
+
if (!candidates.length) throw Error(`Workspace pattern matched no package directories: ${normalized}.`);
|
|
116
|
+
// npm expands a `*` segment to the directories that hold a package.json and skips the rest (an empty or untracked folder is
|
|
117
|
+
// HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve.
|
|
118
|
+
const wildcard = segments.includes('*');
|
|
119
|
+
for (const candidate of candidates) admit(candidate, `workspace ${normalized}`, !wildcard);
|
|
120
|
+
}
|
|
121
|
+
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
122
|
+
for (const [name, value] of Object.entries(pkg?.[section] ?? {})) {
|
|
123
|
+
if (typeof value !== 'string' || !value.startsWith('file:')) continue;
|
|
124
|
+
const absolute = path.resolve(packageRoot, value.slice('file:'.length));
|
|
125
|
+
const label = `${section}.${name} file dependency`;
|
|
126
|
+
if (isInside(root, absolute)) { admit(absolute, label, true); continue; }
|
|
127
|
+
// A sibling package of the same repository: this project consumes it, so the path is real and
|
|
128
|
+
// resolvable, but it is not part of this project's own source layout and must not become one of
|
|
129
|
+
// its workspaces - collecting it would pull another package's src into these roots. Refusing it
|
|
130
|
+
// instead would fail the whole check closed over an ordinary monorepo shape.
|
|
131
|
+
if (!repository || !isInside(repository, absolute) || !existingPackageDirectory(absolute)) {
|
|
132
|
+
throw Error(`${label} must resolve to a package directory inside the repository.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return [...directories].sort();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function discoveredProjects(root, workspaces) {
|
|
141
|
+
const projects = [];
|
|
142
|
+
if (fs.existsSync(path.join(root, 'tsconfig.json'))) projects.push('tsconfig.json');
|
|
143
|
+
for (const workspace of workspaces) {
|
|
144
|
+
for (const name of ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.build.json']) {
|
|
145
|
+
const candidate = `${workspace}/${name}`;
|
|
146
|
+
if (fs.existsSync(path.join(root, ...candidate.split('/')))) {
|
|
147
|
+
projects.push(candidate);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return [...new Set(projects)];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function inferredLayout(root, workspaces) {
|
|
156
|
+
// HFS: frontend source roots live only under workspace packages (apps/<app>/, packages/<pkg>/);
|
|
157
|
+
// the repository root itself is never a frontend source root.
|
|
158
|
+
// The route, feature, component, hook and module roles are app slots (fe.route, fe.feature, fe.components, fe.hooks, fe.modules:
|
|
159
|
+
// apps/<app>/src/...); a workspace package is tier `package` and holds none of them - its own src/hooks is package source, and a
|
|
160
|
+
// grammar package's tier folders make it a component root below.
|
|
161
|
+
const appWorkspaces = workspaces.filter(prefix => prefix.startsWith('apps/'));
|
|
162
|
+
const collect = suffix => appWorkspaces.map(prefix => `${prefix}/${suffix}`).filter(relative => existingDirectory(root, relative));
|
|
163
|
+
// A design-system workspace whose src carries tier dirs (leaves/branches/…) IS a component root;
|
|
164
|
+
// collecting its src/components too would nest two roots in one role and fail the disjoint check.
|
|
165
|
+
const tierSources = new Set(workspaces.map(prefix => `${prefix}/src`)
|
|
166
|
+
.filter(source => ['leaves', 'branches', 'blocks', 'overlays', 'composites', 'layouts', 'product-shells', 'pages']
|
|
167
|
+
.some(tier => existingDirectory(root, `${source}/${tier}`))));
|
|
168
|
+
const components = collect('src/components')
|
|
169
|
+
.filter(relative => !tierSources.has(path.posix.dirname(relative)));
|
|
170
|
+
components.push(...tierSources);
|
|
171
|
+
return {
|
|
172
|
+
routes: collect('src/app'),
|
|
173
|
+
features: collect('src/features'),
|
|
174
|
+
components: [...new Set(components)],
|
|
175
|
+
hooks: collect('src/hooks'),
|
|
176
|
+
modules: collect('src/modules'),
|
|
177
|
+
transport: collect('src/modules/api'),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function assertFrontendRolesDisjoint(root, frontend) {
|
|
182
|
+
const roles = ['routes', 'features', 'components', 'hooks', 'modules'];
|
|
183
|
+
const entries = roles.flatMap(role => frontend[role].map(relative => ({
|
|
184
|
+
role,
|
|
185
|
+
relative,
|
|
186
|
+
absolute: path.join(root, ...relative.split('/')),
|
|
187
|
+
})));
|
|
188
|
+
for (let index = 0; index < entries.length; index += 1) for (let other = index + 1; other < entries.length; other += 1) {
|
|
189
|
+
const left = entries[index];
|
|
190
|
+
const right = entries[other];
|
|
191
|
+
if (isInside(left.absolute, right.absolute) || isInside(right.absolute, left.absolute)) {
|
|
192
|
+
throw Error(`Architecture frontend role roots must be disjoint; ${left.role} ${left.relative} overlaps ${right.role} ${right.relative}.`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'coverage', 'test-results', '.starciwork', '.starcistacks']);
|
|
198
|
+
|
|
199
|
+
function directoriesUnder(root, relative, depth) {
|
|
200
|
+
const out = [];
|
|
201
|
+
const visit = (dir, left) => {
|
|
202
|
+
let entries;
|
|
203
|
+
try { entries = fs.readdirSync(path.join(root, ...dir.split('/').filter(Boolean)), { withFileTypes: true }); } catch { return; }
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
206
|
+
const child = dir ? `${dir}/${entry.name}` : entry.name;
|
|
207
|
+
out.push(child);
|
|
208
|
+
if (left > 1) visit(child, left - 1);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
visit(relative, depth);
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The owner entry a slot instance exposes: index.ts/index.tsx, a package's src/index.ts, an app's app.module.ts. */
|
|
216
|
+
function ownerEntry(root, ownerRoot, tier) {
|
|
217
|
+
const candidates = tier === 'package' ? ['src/index.ts', 'src/index.tsx', 'index.ts', 'index.tsx']
|
|
218
|
+
: tier === 'app' ? ['app.module.ts'] : ['index.ts', 'index.tsx'];
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
const relative = `${ownerRoot}/${candidate}`;
|
|
221
|
+
if (existingRegularFile(root, relative)) return relative;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Owners are derived from the slot manifest: every directory that an `owner: true` slot matches, with its entry file.
|
|
228
|
+
* An instance with no entry file is not listed: the required-files check reports the missing entry.
|
|
229
|
+
*/
|
|
230
|
+
function derivedOwners(root, resolver) {
|
|
231
|
+
const seen = new Map();
|
|
232
|
+
for (const directory of directoriesUnder(root, '', 6)) {
|
|
233
|
+
const owner = resolver.ownerOf(directory);
|
|
234
|
+
if (!owner || owner.root !== directory || seen.has(owner.root)) continue;
|
|
235
|
+
const slot = resolver.slot(owner.slot);
|
|
236
|
+
const entry = ownerEntry(root, owner.root, slot.tier);
|
|
237
|
+
if (entry) seen.set(owner.root, { id: `${owner.slot}:${owner.root}`, root: owner.root, entry, slot: owner.slot, tier: slot.tier });
|
|
238
|
+
}
|
|
239
|
+
return [...seen.values()].sort((a, b) => a.root.localeCompare(b.root));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const DECLARED_HANDLER_DECORATORS = ['CommandHandler', 'QueryHandler'];
|
|
243
|
+
|
|
244
|
+
const GRAMMAR_PACKAGE = '@starci/grammar';
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The Grammar contract of a frontend repository, derived rather than declared: every app's globals.css is a style
|
|
248
|
+
* source, every app and every workspace package that depends on the Grammar package is a consumer. null when no app has
|
|
249
|
+
* a globals.css to judge (the contract is then reported unavailable, never passed).
|
|
250
|
+
*/
|
|
251
|
+
function derivedGrammar(root, apps, workspaces) {
|
|
252
|
+
const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
|
|
253
|
+
if (!styleSources.length) return null;
|
|
254
|
+
const consumerManifests = apps.map(app => `apps/${app.name}/package.json`).filter(relative => existingRegularFile(root, relative));
|
|
255
|
+
for (const workspace of workspaces) {
|
|
256
|
+
if (!workspace.startsWith('packages/')) continue;
|
|
257
|
+
const manifest = `${workspace}/package.json`;
|
|
258
|
+
const pkg = readJson(path.join(root, ...manifest.split('/')));
|
|
259
|
+
if (pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE))) consumerManifests.push(manifest);
|
|
260
|
+
}
|
|
261
|
+
if (!consumerManifests.length) return null;
|
|
262
|
+
return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react'] };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the layout contract from hfs.json and the slot manifest (knowledge/hfs/slots.yaml). architecture.json is
|
|
267
|
+
* retired: a repository carries only hfs.json. Owners, roots and the tier of every path come from slots; the contract
|
|
268
|
+
* has no ignore, waiver or baseline field.
|
|
269
|
+
*/
|
|
270
|
+
export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
|
|
271
|
+
const root = fs.realpathSync(path.resolve(repositoryRoot));
|
|
272
|
+
if (!fs.lstatSync(root).isDirectory()) throw Error('Repository root must be a directory.');
|
|
273
|
+
const opened = hfs ?? openHfs({ repoRoot: root });
|
|
274
|
+
const { profile, apps } = opened.repo;
|
|
275
|
+
const workspaces = workspaceDirectories(root);
|
|
276
|
+
const inferred = inferredLayout(root, [...new Set([...workspaces, ...apps.map(app => `apps/${app.name}`)])].sort());
|
|
277
|
+
const kinds = [profile === 'be' ? 'backend' : 'frontend'];
|
|
278
|
+
const projects = discoveredProjects(root, workspaces);
|
|
279
|
+
if (!projects.length) throw Error('The repository has no tsconfig.json to derive a TypeScript project from.');
|
|
280
|
+
const backend = {
|
|
281
|
+
modules: ['src/modules'],
|
|
282
|
+
features: ['src/features'],
|
|
283
|
+
apps: ['apps'],
|
|
284
|
+
moduleRegistration: { providerIdentity: 'exported-class-token', handlerDecorators: [...DECLARED_HANDLER_DECORATORS] },
|
|
285
|
+
};
|
|
286
|
+
const frontend = {
|
|
287
|
+
routes: inferred.routes,
|
|
288
|
+
features: inferred.features,
|
|
289
|
+
components: inferred.components,
|
|
290
|
+
hooks: inferred.hooks,
|
|
291
|
+
modules: inferred.modules,
|
|
292
|
+
transport: inferred.transport,
|
|
293
|
+
grammar: profile === 'fe' ? derivedGrammar(root, apps, workspaces) : null,
|
|
294
|
+
};
|
|
295
|
+
if (profile === 'fe') assertFrontendRolesDisjoint(root, frontend);
|
|
296
|
+
return {
|
|
297
|
+
root,
|
|
298
|
+
repository: enclosingRepository(root),
|
|
299
|
+
kinds,
|
|
300
|
+
projects,
|
|
301
|
+
workspaces,
|
|
302
|
+
apps: apps.map(app => ({ name: app.name, kind: app.kind })),
|
|
303
|
+
hfs: opened,
|
|
304
|
+
owners: derivedOwners(root, opened),
|
|
305
|
+
backend,
|
|
306
|
+
frontend,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export { canonical, enclosingRepository, exactKeys, isInside, slash };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { machineKit, pascal, upperSnake } from './machine-ast.mjs';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* R84 `connection-map` (BE_CONNECTION_DUPLICATE), folding RED01 and RED02. One physical database is one connection, one
|
|
7
|
+
* `Inject<Conn>EntityManager()` injector, one config; the `hfs.json` connections, the connection files, the injectors, the
|
|
8
|
+
* database module registration of every app and the env of every stack correspond one to one.
|
|
9
|
+
*
|
|
10
|
+
* - per declared connection `<name>`: `src/modules/platform/database/<name>.connection.ts` exports
|
|
11
|
+
* `<NAME>_CONNECTION = "<name>"`, `<name>.decorators.ts` exports `Inject<Pascal>EntityManager`, `<name>.config.ts`
|
|
12
|
+
* exists and reads only `<ENVPREFIX>_*` keys;
|
|
13
|
+
* - a connection file for a name hfs.json does not declare is refused;
|
|
14
|
+
* - `getEntityManagerToken(` and `InjectEntityManager(` (from @nestjs/typeorm) resolve their argument through the
|
|
15
|
+
* checker to a connection name and may be called only in that connection's decorators file, once; the DataSource
|
|
16
|
+
* forms only in platform/database, the migrate app and the test fixtures; an exported `Inject*EntityManager`
|
|
17
|
+
* anywhere else (a capability-specific injector, a duplicate) is refused;
|
|
18
|
+
* - inside one app every connection is passed to the platform/database module registration once;
|
|
19
|
+
* - in every `.starcistacks/<env>/runtime/env` two connections whose `<PREFIX>_HOST`, `<PREFIX>_PORT` and
|
|
20
|
+
* `<PREFIX>_NAME` (or `_DATABASE`, whichever the config reads) resolve to one triple are the same database.
|
|
21
|
+
*/
|
|
22
|
+
export const CONNECTION_RULE_IDS = ['BE_CONNECTION_DUPLICATE'];
|
|
23
|
+
|
|
24
|
+
const RULE = 'BE_CONNECTION_DUPLICATE';
|
|
25
|
+
export const DATABASE_DIR = 'src/modules/platform/database';
|
|
26
|
+
const TYPEORM = '@nestjs/typeorm';
|
|
27
|
+
const MANAGER_CALLS = new Set(['getEntityManagerToken', 'InjectEntityManager']);
|
|
28
|
+
const SOURCE_CALLS = new Set(['getDataSourceToken', 'InjectDataSource']);
|
|
29
|
+
const ENV_KEY = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u;
|
|
30
|
+
const INJECTOR_NAME = /^Inject[A-Za-z0-9]*EntityManager$/u;
|
|
31
|
+
|
|
32
|
+
function readEnvFiles(target) {
|
|
33
|
+
const merged = new Map();
|
|
34
|
+
const readFile = file => {
|
|
35
|
+
let text;
|
|
36
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return; }
|
|
37
|
+
for (const raw of text.split(/\r?\n/u)) {
|
|
38
|
+
const line = raw.trim();
|
|
39
|
+
if (!line || line.startsWith('#')) continue;
|
|
40
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(line);
|
|
41
|
+
if (!match) continue;
|
|
42
|
+
let value = match[2].trim();
|
|
43
|
+
if (/^(['"]).*\1$/u.test(value)) value = value.slice(1, -1);
|
|
44
|
+
merged.set(match[1], value.trim());
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
let stat;
|
|
48
|
+
try { stat = fs.statSync(target); } catch { return null; }
|
|
49
|
+
if (stat.isFile()) readFile(target);
|
|
50
|
+
else for (const entry of fs.readdirSync(target, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
51
|
+
if (entry.isFile() && !entry.name.toLowerCase().endsWith('.md')) readFile(path.join(target, entry.name));
|
|
52
|
+
}
|
|
53
|
+
return merged;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function checkConnectionMap(input) {
|
|
57
|
+
const { config, graph } = input;
|
|
58
|
+
const kit = machineKit(input);
|
|
59
|
+
const { ts } = kit;
|
|
60
|
+
const declared = config.hfs.repo.connections ?? [];
|
|
61
|
+
const byName = new Map(declared.map(connection => [connection.name, connection]));
|
|
62
|
+
const violations = [];
|
|
63
|
+
const report = (rel, message, extra = {}) => violations.push({ ruleId: RULE, path: rel, message, ...extra });
|
|
64
|
+
const decoratorsFile = name => `${DATABASE_DIR}/${name}.decorators.ts`;
|
|
65
|
+
const databaseFiles = [...graph.files.values()].filter(file => file.rel.startsWith(`${DATABASE_DIR}/`));
|
|
66
|
+
|
|
67
|
+
// 1. The three files of each declared connection, and no file of an undeclared one.
|
|
68
|
+
for (const connection of declared) {
|
|
69
|
+
const connectionRel = `${DATABASE_DIR}/${connection.name}.connection.ts`;
|
|
70
|
+
const constant = `${upperSnake(connection.name)}_CONNECTION`;
|
|
71
|
+
const connectionFile = kit.graphFile(connectionRel);
|
|
72
|
+
if (!connectionFile) report(connectionRel, `hfs.json declares connection ${connection.name} but ${connectionRel} does not exist; declare \`export const ${constant} = "${connection.name}"\` there.`, { connection: connection.name });
|
|
73
|
+
else {
|
|
74
|
+
const checker = kit.checkerOf(connectionFile.sourceFile);
|
|
75
|
+
const statement = connectionFile.sourceFile.statements.find(item => ts.isVariableStatement(item) && kit.isExported(item)
|
|
76
|
+
&& item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === constant));
|
|
77
|
+
const initializer = statement?.declarationList.declarations.find(declaration => declaration.name.text === constant)?.initializer;
|
|
78
|
+
if (!statement || kit.stringValue(checker, initializer) !== connection.name) {
|
|
79
|
+
report(connectionRel, `${connectionRel} must export \`${constant} = "${connection.name}"\` (the connection name declared in hfs.json).`, { connection: connection.name });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const decorators = kit.graphFile(decoratorsFile(connection.name));
|
|
83
|
+
const injector = `Inject${pascal(connection.name)}EntityManager`;
|
|
84
|
+
const exported = decorators?.sourceFile.statements.some(item => (ts.isVariableStatement(item) && kit.isExported(item)
|
|
85
|
+
&& item.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === injector))
|
|
86
|
+
|| (ts.isFunctionDeclaration(item) && kit.isExported(item) && item.name?.text === injector));
|
|
87
|
+
if (!exported) report(decoratorsFile(connection.name), `Connection ${connection.name} needs ${decoratorsFile(connection.name)} exporting ${injector}, the one injector of its shared EntityManager.`, { connection: connection.name });
|
|
88
|
+
const configRel = `${DATABASE_DIR}/${connection.name}.config.ts`;
|
|
89
|
+
const configFile = kit.graphFile(configRel);
|
|
90
|
+
if (!configFile) {
|
|
91
|
+
report(configRel, `Connection ${connection.name} needs ${configRel}, the one place its ${connection.envPrefix}_* keys are read.`, { connection: connection.name });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
kit.walk(configFile.sourceFile, node => {
|
|
95
|
+
let key = null;
|
|
96
|
+
if (ts.isStringLiteralLike(node) && ENV_KEY.test(node.text)) key = node.text;
|
|
97
|
+
else if (ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'env'
|
|
98
|
+
&& ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process' && ENV_KEY.test(node.name.text)) key = node.name.text;
|
|
99
|
+
if (key === null) return true;
|
|
100
|
+
const own = key.startsWith(`${connection.envPrefix}_`);
|
|
101
|
+
const foreign = declared.find(other => other !== connection && key.startsWith(`${other.envPrefix}_`));
|
|
102
|
+
if (!own || foreign) {
|
|
103
|
+
report(configRel, `${configRel} reads ${key}; the config of connection ${connection.name} reads only ${connection.envPrefix}_* keys.`, { ...kit.at(configRel, configFile.sourceFile, node), connection: connection.name });
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
for (const file of databaseFiles) {
|
|
109
|
+
const match = /^(.+)\.(connection|decorators|config)\.ts$/u.exec(path.posix.basename(file.rel));
|
|
110
|
+
if (match && path.posix.dirname(file.rel) === DATABASE_DIR && !byName.has(match[1])) {
|
|
111
|
+
report(file.rel, `${file.rel} belongs to connection ${match[1]}, which hfs.json does not declare; declare the database in hfs.json connections or delete the file.`, { connection: match[1] });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Every entity manager token and injector in the program: one home per connection.
|
|
116
|
+
const perDecorators = new Map();
|
|
117
|
+
for (const file of graph.files.values()) {
|
|
118
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
119
|
+
const slotIs = id => file.slot === id;
|
|
120
|
+
kit.walk(file.sourceFile, node => {
|
|
121
|
+
if (ts.isCallExpression(node)) {
|
|
122
|
+
const binding = kit.importBinding(checker, node.expression);
|
|
123
|
+
if (binding?.module === TYPEORM && MANAGER_CALLS.has(binding.name)) {
|
|
124
|
+
const value = kit.stringValue(checker, node.arguments[0]);
|
|
125
|
+
const home = value === null ? null : decoratorsFile(value);
|
|
126
|
+
if (value === null || !byName.has(value)) {
|
|
127
|
+
report(file.rel, `${binding.name}() names ${value === null ? 'a connection that cannot be resolved to a constant' : `connection ${value}, which hfs.json does not declare`}; only the declared connections have an entity manager token.`, kit.at(file.rel, file.sourceFile, node));
|
|
128
|
+
} else if (file.rel !== home) {
|
|
129
|
+
report(file.rel, `${binding.name}(${value}) belongs in ${home} only; inject the shared manager with Inject${pascal(value)}EntityManager() instead of a second path to connection ${value}.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
|
|
130
|
+
} else {
|
|
131
|
+
perDecorators.set(value, (perDecorators.get(value) ?? 0) + 1);
|
|
132
|
+
if (perDecorators.get(value) > 1) report(file.rel, `${home} resolves connection ${value} more than once; one connection has one injector.`, { ...kit.at(file.rel, file.sourceFile, node), connection: value });
|
|
133
|
+
}
|
|
134
|
+
} else if (binding?.module === TYPEORM && SOURCE_CALLS.has(binding.name)) {
|
|
135
|
+
const allowed = file.rel.startsWith(`${DATABASE_DIR}/`) || slotIs('be.app.migrate') || slotIs('be.tests.fixtures');
|
|
136
|
+
if (!allowed) report(file.rel, `${binding.name}() reaches the DataSource outside platform/database and the migrate app; inject the shared EntityManager of the connection instead.`, kit.at(file.rel, file.sourceFile, node));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const exportedName = (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && ts.isVariableStatement(node.parent?.parent) && kit.isExported(node.parent.parent)) ? node.name.text
|
|
140
|
+
: (ts.isFunctionDeclaration(node) && kit.isExported(node) && node.name ? node.name.text : null);
|
|
141
|
+
if (exportedName && INJECTOR_NAME.test(exportedName)) {
|
|
142
|
+
const home = [...byName.keys()].find(name => `Inject${pascal(name)}EntityManager` === exportedName);
|
|
143
|
+
if (!home || file.rel !== decoratorsFile(home)) {
|
|
144
|
+
report(file.rel, `${exportedName} is exported here, but the only injector of an entity manager is Inject<Conn>EntityManager in src/modules/platform/database/<conn>.decorators.ts, one per declared connection.`, kit.at(file.rel, file.sourceFile, node));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 3. Each connection is passed to the database module registration once per app.
|
|
152
|
+
let registrations = 0;
|
|
153
|
+
for (const app of config.apps) {
|
|
154
|
+
const root = kit.appRoot(app.name);
|
|
155
|
+
if (!root) continue;
|
|
156
|
+
const checker = kit.checkerOf(root.sourceFile);
|
|
157
|
+
const passed = new Map();
|
|
158
|
+
kit.walk(root.sourceFile, node => {
|
|
159
|
+
if (!(ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'register')) return true;
|
|
160
|
+
const owner = kit.declarationsOf(checker, node.expression.expression).map(kit.ownerOfDeclaration).find(Boolean);
|
|
161
|
+
if (!owner || owner.tier !== 'platform' || owner.name !== 'database') return true;
|
|
162
|
+
registrations += 1;
|
|
163
|
+
for (const argument of node.arguments) {
|
|
164
|
+
kit.walk(argument, inner => {
|
|
165
|
+
if (ts.isPropertyAssignment(inner.parent ?? {}) && inner.parent.name === inner) return true;
|
|
166
|
+
if (ts.isPropertyAccessExpression(inner.parent ?? {}) && inner.parent.name === inner) return true;
|
|
167
|
+
if (!(ts.isIdentifier(inner) || ts.isStringLiteralLike(inner) || ts.isPropertyAccessExpression(inner))) return true;
|
|
168
|
+
const value = kit.stringValue(checker, inner);
|
|
169
|
+
if (value === null || !byName.has(value)) return true;
|
|
170
|
+
passed.set(value, (passed.get(value) ?? 0) + 1);
|
|
171
|
+
if (passed.get(value) > 1) report(root.rel, `App ${app.name} passes connection ${value} to the database module registration more than once; one connection is registered once per app.`, { ...kit.at(root.rel, root.sourceFile, inner), connection: value, app: app.name });
|
|
172
|
+
return false;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4. Stacks: two connections that resolve to one host, port and database are one database.
|
|
180
|
+
let stacksChecked = 0;
|
|
181
|
+
let stacksSkipped = 0;
|
|
182
|
+
const stacksRoot = path.join(config.root, '.starcistacks');
|
|
183
|
+
let envs = [];
|
|
184
|
+
try { envs = fs.readdirSync(stacksRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()).map(entry => entry.name).sort(); } catch { envs = []; }
|
|
185
|
+
for (const env of envs) {
|
|
186
|
+
const rel = `.starcistacks/${env}/runtime/env`;
|
|
187
|
+
const values = readEnvFiles(path.join(stacksRoot, env, 'runtime', 'env'));
|
|
188
|
+
if (!values || values.size === 0) { stacksSkipped += 1; continue; }
|
|
189
|
+
const seen = new Map();
|
|
190
|
+
let resolved = 0;
|
|
191
|
+
for (const connection of declared) {
|
|
192
|
+
const configFile = kit.graphFile(`${DATABASE_DIR}/${connection.name}.config.ts`);
|
|
193
|
+
const nameKey = configFile?.sourceFile.text.includes(`${connection.envPrefix}_DATABASE`) ? `${connection.envPrefix}_DATABASE` : `${connection.envPrefix}_NAME`;
|
|
194
|
+
const host = values.get(`${connection.envPrefix}_HOST`);
|
|
195
|
+
const port = values.get(`${connection.envPrefix}_PORT`);
|
|
196
|
+
const database = values.get(nameKey);
|
|
197
|
+
if (!host || !port || !database) continue;
|
|
198
|
+
resolved += 1;
|
|
199
|
+
const triple = `${host.toLowerCase()}|${port}|${database}`;
|
|
200
|
+
if (seen.has(triple)) {
|
|
201
|
+
report(rel, `Connections ${seen.get(triple)} and ${connection.name} resolve to the same database (${host}:${port}/${database}) in stack ${env}; one physical database is one connection.`, { connection: connection.name, env });
|
|
202
|
+
} else seen.set(triple, connection.name);
|
|
203
|
+
}
|
|
204
|
+
if (resolved) stacksChecked += 1; else stacksSkipped += 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { violations, coverage: { status: 'checked', connections: declared.length, registrations, stacksChecked, stacksSkipped } };
|
|
208
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The constructor dependencies of a service, read the way the injection law (R85) writes them, shared by the unit-spec
|
|
5
|
+
* provider check (R48 `unit-spec-providers`) and the token export check (R85 `injection-token-exported`).
|
|
6
|
+
*
|
|
7
|
+
* A constructor parameter is one of
|
|
8
|
+
* - `token`: decorated with a custom `Inject<Thing>()` decorator. The decorator is declared in a `<cap>.decorators.ts`
|
|
9
|
+
* as `injector<T>(TOKEN)`; the dependency is the TOKEN it names, identified by the name the token is exported under
|
|
10
|
+
* (the declaration's own name), never by the decorator's or the parameter's name. A raw `@Inject(TOKEN)` is read the same way.
|
|
11
|
+
* - `class`: no injection decorator and a class type; the dependency is that class.
|
|
12
|
+
* - `unresolved`: anything else (a token expression that is not a plain identifier, a decorator the reader cannot follow,
|
|
13
|
+
* a type that is not a class).
|
|
14
|
+
*/
|
|
15
|
+
const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/u;
|
|
16
|
+
|
|
17
|
+
/** The identifier a decorator names: `@InjectFoo()` -> InjectFoo, `@Inject(TOKEN)` -> Inject; null for a non-identifier decorator. */
|
|
18
|
+
function decoratorCallee(ts, decorator) {
|
|
19
|
+
const expression = decorator.expression;
|
|
20
|
+
const callee = ts.isCallExpression(expression) ? expression.expression : expression;
|
|
21
|
+
return ts.isIdentifier(callee) ? { callee, call: ts.isCallExpression(expression) ? expression : null } : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The token a `injector(<arg>)` call names, judged where the argument is declared. */
|
|
25
|
+
function tokenOfArgument(kit, checker, argument) {
|
|
26
|
+
const { ts } = kit;
|
|
27
|
+
if (!argument || !ts.isIdentifier(argument)) return { kind: 'unresolved', reason: 'its token is not a plain identifier of an exported const', node: argument ?? null };
|
|
28
|
+
const declaration = kit.declarationsOf(checker, argument).find(item => ts.isVariableDeclaration(item) || ts.isBindingElement(item));
|
|
29
|
+
if (declaration) {
|
|
30
|
+
const name = ts.isIdentifier(declaration.name) ? declaration.name.text : argument.text;
|
|
31
|
+
return { kind: 'token', name, declaration, argument, external: false };
|
|
32
|
+
}
|
|
33
|
+
const binding = kit.importBinding(checker, argument);
|
|
34
|
+
if (binding && !binding.module.startsWith('.')) return { kind: 'token', name: binding.name, declaration: null, argument, external: true };
|
|
35
|
+
return { kind: 'unresolved', reason: `the token ${argument.text} does not resolve to a declaration`, node: argument };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The injector token of a custom `Inject<Thing>()` decorator, or an unresolved reason. */
|
|
39
|
+
function tokenOfInjectDecorator(kit, checker, parameter, decorator) {
|
|
40
|
+
const { ts } = kit;
|
|
41
|
+
const head = decoratorCallee(ts, decorator);
|
|
42
|
+
if (!head || !INJECT_NAME.test(head.callee.text)) return null;
|
|
43
|
+
if (head.callee.text === 'Inject' && head.call?.arguments.length) return { ...tokenOfArgument(kit, checker, head.call.arguments[0]), decorator: head.callee.text, decoratorDeclaration: null, decoratorFile: null };
|
|
44
|
+
const declaration = kit.declarationsOf(checker, head.callee).find(item => ts.isVariableDeclaration(item) || ts.isFunctionDeclaration(item));
|
|
45
|
+
if (!declaration) return { kind: 'unresolved', reason: `${head.callee.text}() does not resolve to a declaration`, node: parameter, decorator: head.callee.text };
|
|
46
|
+
let injectorCall = null;
|
|
47
|
+
kit.walk(declaration, node => {
|
|
48
|
+
if (injectorCall) return false;
|
|
49
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
50
|
+
const declarationChecker = kit.checkerOf(declaration.getSourceFile());
|
|
51
|
+
const binding = kit.importBinding(declarationChecker, node.expression);
|
|
52
|
+
if ((binding?.name ?? node.expression.text) === 'injector') injectorCall = node;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
if (!injectorCall) return { kind: 'unresolved', reason: `${head.callee.text}() is not built with injector<T>(TOKEN)`, node: parameter, decorator: head.callee.text };
|
|
57
|
+
const declarationChecker = kit.checkerOf(declaration.getSourceFile());
|
|
58
|
+
return { ...tokenOfArgument(kit, declarationChecker, injectorCall.arguments[0]), decorator: head.callee.text, decoratorDeclaration: declaration, injectorCall };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The class a parameter type names, judged where the type is declared; null when it is not a class type. */
|
|
62
|
+
function classOfType(kit, checker, type) {
|
|
63
|
+
const { ts } = kit;
|
|
64
|
+
if (!type || !ts.isTypeReferenceNode(type) || !ts.isIdentifier(type.typeName)) return null;
|
|
65
|
+
const declarations = kit.declarationsOf(checker, type.typeName);
|
|
66
|
+
const declared = declarations.find(item => ts.isClassDeclaration(item) && item.name);
|
|
67
|
+
if (declared) return declared.name.text;
|
|
68
|
+
if (declarations.length) return null;
|
|
69
|
+
const binding = kit.importBinding(checker, type.typeName);
|
|
70
|
+
return binding && binding.name !== 'default' && binding.name !== '*' ? binding.name : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The dependencies of one class, in constructor order: [{kind, name?, node, parameter, ...}]. */
|
|
74
|
+
export function constructorDependencies(kit, file, classDeclaration) {
|
|
75
|
+
const { ts } = kit;
|
|
76
|
+
const checker = kit.checkerOf(file.sourceFile);
|
|
77
|
+
const constructor = classDeclaration.members.find(member => ts.isConstructorDeclaration(member) && member.body !== undefined)
|
|
78
|
+
?? classDeclaration.members.find(member => ts.isConstructorDeclaration(member));
|
|
79
|
+
if (!constructor) return [];
|
|
80
|
+
return constructor.parameters.map(parameter => {
|
|
81
|
+
for (const decorator of kit.decorators(parameter)) {
|
|
82
|
+
const token = tokenOfInjectDecorator(kit, checker, parameter, decorator);
|
|
83
|
+
if (token) return { ...token, parameter, node: token.node ?? parameter };
|
|
84
|
+
}
|
|
85
|
+
const name = classOfType(kit, checker, parameter.type);
|
|
86
|
+
if (name) return { kind: 'class', name, parameter, node: parameter };
|
|
87
|
+
return { kind: 'unresolved', reason: 'it has no Inject<Thing>() decorator and its type is not a class', parameter, node: parameter };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every service class of the program: [{file, declaration}] for each class of a `*.service.ts` file. */
|
|
92
|
+
export function serviceClasses(kit, graph) {
|
|
93
|
+
const { ts } = kit;
|
|
94
|
+
const found = [];
|
|
95
|
+
for (const file of graph.files.values()) {
|
|
96
|
+
if (!path.posix.basename(file.rel).endsWith('.service.ts')) continue;
|
|
97
|
+
for (const statement of file.sourceFile.statements) if (ts.isClassDeclaration(statement) && statement.name) found.push({ file, declaration: statement });
|
|
98
|
+
}
|
|
99
|
+
return found;
|
|
100
|
+
}
|