@starci/eslint-grammar-core 0.15.34 → 0.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/why.js +15 -0
- package/dist/mascot.d.ts +28 -0
- package/dist/mascot.js +166 -0
- package/dist/surface-depth.d.ts +7 -0
- package/dist/surface-depth.js +92 -0
- package/docs/mascot.md +60 -0
- package/docs/surface-depth.md +40 -0
- package/package.json +2 -2
- package/runtime/bundle.json +74 -0
- package/runtime/engine/by-code-unit.mjs +6 -6
- package/runtime/engine/model-config.mjs +98 -76
- package/runtime/engine/orca-config.mjs +22 -17
- package/runtime/engine/release-config.mjs +22 -18
- package/runtime/engine/removed-vocabulary.mjs +21 -20
- package/runtime/engine/resources-config.mjs +42 -35
- package/runtime/engine/runtime-root.mjs +11 -13
- package/runtime/engine/secrets.mjs +170 -102
- package/runtime/engine/sonar-config.mjs +16 -13
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +95 -6
- package/runtime/knowledge/hfs/slots.yaml +2 -2
- package/runtime/knowledge/patterns/fe/ui.yaml +13 -1
- package/runtime/scripts/hfs/allows.mjs +37 -38
- package/runtime/scripts/hfs/architecture/config.mjs +302 -268
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
- package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
- package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
- package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
- package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
- package/runtime/scripts/hfs/edition-slots.mjs +43 -40
- package/runtime/scripts/hfs/param-names.mjs +6 -11
- package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
- package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
- package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
- package/runtime/scripts/hfs/slot-classify.mjs +42 -38
- package/runtime/scripts/hfs/slot-errors.mjs +8 -7
- package/runtime/scripts/hfs/slot-imports.mjs +70 -63
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
- package/runtime/scripts/hfs/slot-match.mjs +122 -111
- package/runtime/scripts/hfs/slot-path.mjs +0 -2
- package/runtime/scripts/hfs/slot-required.mjs +65 -59
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
- package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
- package/runtime/scripts/hfs/slots.mjs +175 -152
- package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
- package/runtime/scripts/hfs/view.mjs +38 -40
- package/runtime/scripts/lib/glob.mjs +23 -14
- package/runtime/scripts/lib/grammar-lint.mjs +33 -37
- package/runtime/scripts/lib/i18n.mjs +32 -35
- package/runtime/scripts/lib/json-clone.mjs +45 -36
- package/runtime/scripts/lib/json.mjs +26 -14
- package/runtime/scripts/lib/list.mjs +9 -14
- package/runtime/scripts/lib/path-key.mjs +53 -38
- package/runtime/scripts/lib/read-text.mjs +30 -17
- package/runtime/scripts/lib/regex.mjs +1 -2
- package/runtime/scripts/lib/source-phrases.mjs +13 -13
- package/runtime/scripts/lib/ts-analysis.mjs +28 -26
- package/runtime/scripts/lib/ts-ast.mjs +36 -42
- package/runtime/scripts/lib/walk.mjs +33 -27
- package/runtime/scripts/lib/yaml-cached.mjs +4 -4
|
@@ -6,23 +6,25 @@ import { isInside } from '../../lib/walk.mjs';
|
|
|
6
6
|
import { openHfs } from '../slots.mjs';
|
|
7
7
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
8
8
|
import { grammarContractFor } from '../../lib/grammar-lint.mjs';
|
|
9
|
-
|
|
10
|
-
|
|
11
9
|
/** A file's identity: its resolved real path, or the resolved path when it does not exist. */
|
|
12
10
|
function canonical(file) {
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const absolute = path.resolve(file);
|
|
12
|
+
try {
|
|
13
|
+
return path.resolve(fs.realpathSync(absolute));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return absolute;
|
|
17
|
+
}
|
|
15
18
|
}
|
|
16
|
-
|
|
17
19
|
function exactKeys(value, allowed, label) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
unknown.
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
21
|
+
throw new Error(`${label} must be an object.`);
|
|
22
|
+
const unknown = Object.keys(value).filter(key => !allowed.has(key));
|
|
23
|
+
if (unknown.length) {
|
|
24
|
+
unknown.sort(byCodeUnit);
|
|
25
|
+
throw new Error(`${label} has unsupported fields: ${unknown.join(', ')}.`);
|
|
26
|
+
}
|
|
24
27
|
}
|
|
25
|
-
|
|
26
28
|
/**
|
|
27
29
|
* The git repository `root` sits in, found by walking up for a `.git` entry (a directory in a clone, a
|
|
28
30
|
* file in a worktree), or null when there is none. It is the outer boundary a `file:` dependency may
|
|
@@ -30,320 +32,352 @@ function exactKeys(value, allowed, label) {
|
|
|
30
32
|
* it consumes, and `--root` is the project, not the repository.
|
|
31
33
|
*/
|
|
32
34
|
function enclosingRepository(root) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
let cursor = path.resolve(root);
|
|
36
|
+
for (;;) {
|
|
37
|
+
if (fs.existsSync(path.join(cursor, '.git')))
|
|
38
|
+
return cursor;
|
|
39
|
+
const parent = path.dirname(cursor);
|
|
40
|
+
if (parent === cursor)
|
|
41
|
+
return null;
|
|
42
|
+
cursor = parent;
|
|
43
|
+
}
|
|
40
44
|
}
|
|
41
|
-
|
|
42
45
|
function existingPackageDirectory(absolute) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
try {
|
|
47
|
+
if (!fs.lstatSync(absolute).isDirectory())
|
|
48
|
+
return false;
|
|
49
|
+
const manifest = fs.lstatSync(path.join(absolute, 'package.json'));
|
|
50
|
+
return manifest.isFile() && !manifest.isSymbolicLink();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
50
55
|
}
|
|
51
|
-
|
|
52
56
|
function existingDirectory(root, relative) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
try {
|
|
58
|
+
return fs.lstatSync(path.join(root, relative)).isDirectory();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
58
63
|
}
|
|
59
|
-
|
|
60
64
|
function existingRegularFile(root, relative) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
try {
|
|
66
|
+
const stat = fs.lstatSync(path.join(root, ...relative.split('/')));
|
|
67
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
67
72
|
}
|
|
68
|
-
|
|
69
73
|
function admitWorkspace(root, directories, queue, candidate, label = 'local package', strict = false) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
const absolute = path.resolve(candidate);
|
|
75
|
+
if (!isInside(root, absolute) || absolute === root || !existingDirectory(root, slash(path.relative(root, absolute)))) {
|
|
76
|
+
if (strict)
|
|
77
|
+
throw new Error(`${label} must resolve to a package directory inside the repository.`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const manifest = path.join(absolute, 'package.json');
|
|
81
|
+
try {
|
|
82
|
+
if (!fs.lstatSync(manifest).isFile() || fs.lstatSync(manifest).isSymbolicLink()) {
|
|
83
|
+
if (strict)
|
|
84
|
+
throw new Error(`${label} must resolve to a regular package.json inside the repository.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : '';
|
|
90
|
+
if (strict)
|
|
91
|
+
throw new Error(message.startsWith(label) ? message : `${label} must resolve to a regular package.json inside the repository.`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const relative = slash(path.relative(root, absolute));
|
|
95
|
+
if (!directories.has(relative)) {
|
|
96
|
+
directories.add(relative);
|
|
97
|
+
queue.push(absolute);
|
|
80
98
|
}
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (strict) throw new 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
99
|
}
|
|
88
|
-
|
|
89
100
|
function expandSegment(base, segment) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
101
|
+
if (segment !== '*')
|
|
102
|
+
return [path.join(base, segment)];
|
|
103
|
+
if (!fs.existsSync(base) || !fs.lstatSync(base).isDirectory())
|
|
104
|
+
return [];
|
|
105
|
+
return fs.readdirSync(base, { withFileTypes: true })
|
|
106
|
+
.filter(item => item.isDirectory())
|
|
107
|
+
.sort((a, b) => byCodeUnit(a.name, b.name))
|
|
108
|
+
.map(item => path.join(base, item.name));
|
|
96
109
|
}
|
|
97
|
-
|
|
98
110
|
function workspaceCandidates(packageRoot, normalized) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
111
|
+
const segments = normalized.split('/');
|
|
112
|
+
if (path.isAbsolute(normalized) || segments.some(segment => segment === '..' || (segment.includes('*') && segment !== '*'))) {
|
|
113
|
+
throw new Error(`Unsupported local workspace pattern: ${normalized}.`);
|
|
114
|
+
}
|
|
115
|
+
let candidates = [packageRoot];
|
|
116
|
+
for (const segment of segments)
|
|
117
|
+
candidates = candidates.flatMap(base => expandSegment(base, segment));
|
|
118
|
+
return { candidates, wildcard: segments.includes('*') };
|
|
106
119
|
}
|
|
107
|
-
|
|
108
120
|
const admitFileDependency = (walk, section, name, value) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
const { root, appPackageRoot, packageRoot, sideRoot, repository, state } = walk;
|
|
122
|
+
if (typeof value !== 'string' || !value.startsWith('file:'))
|
|
123
|
+
return;
|
|
124
|
+
const absolute = path.resolve(sideRoot ? appPackageRoot : packageRoot, value.slice('file:'.length));
|
|
125
|
+
if (sideRoot && !isInside(root, absolute) && isInside(appPackageRoot, absolute))
|
|
126
|
+
return;
|
|
127
|
+
const label = `${section}.${name} file dependency`;
|
|
128
|
+
if (isInside(root, absolute)) {
|
|
129
|
+
admitWorkspace(root, state.directories, state.queue, absolute, label, true);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!repository || !isInside(repository, absolute) || !existingPackageDirectory(absolute)) {
|
|
133
|
+
throw new Error(`${label} must resolve to a package directory inside the repository.`);
|
|
134
|
+
}
|
|
118
135
|
};
|
|
119
|
-
|
|
120
136
|
function admitFileDependencies(walk, pkg) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
137
|
+
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
138
|
+
for (const [name, value] of Object.entries(pkg?.[section] ?? {}))
|
|
139
|
+
admitFileDependency(walk, section, name, value);
|
|
140
|
+
}
|
|
125
141
|
}
|
|
126
|
-
|
|
127
142
|
const admitPackageWorkspaces = (walk, pkg) => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
const { root, packageRoot, sideRoot, side, state } = walk;
|
|
144
|
+
const declared = pkg?.workspaces;
|
|
145
|
+
const patterns = Array.isArray(declared) ? declared : declared?.packages;
|
|
146
|
+
for (const pattern of Array.isArray(patterns) ? patterns : []) {
|
|
147
|
+
if (typeof pattern !== 'string' || !pattern.trim())
|
|
148
|
+
throw new Error('package.json workspace entries must be non-empty paths.');
|
|
149
|
+
const written = slash(pattern.trim()).replace(/^\.\//, '');
|
|
150
|
+
if (sideRoot && !written.startsWith(`${side}/`))
|
|
151
|
+
continue;
|
|
152
|
+
const normalized = sideRoot ? written.slice((side ?? '').length + 1) : written;
|
|
153
|
+
const { candidates, wildcard } = workspaceCandidates(packageRoot, normalized);
|
|
154
|
+
// npm expands a `*` segment to the directories that hold a package.json and skips the rest (an empty or untracked folder is
|
|
155
|
+
// HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve. A `*`
|
|
156
|
+
// pattern may match nothing: every app declares the same workspaces (fe/apps/*, fe/packages/*, HFS_MONO_WORKSPACES) whether
|
|
157
|
+
// or not it has a package yet, and the root patterns are held to that fixed list by the rule, not here.
|
|
158
|
+
for (const candidate of candidates)
|
|
159
|
+
admitWorkspace(root, state.directories, state.queue, candidate, `workspace ${normalized}`, !wildcard);
|
|
160
|
+
}
|
|
142
161
|
};
|
|
143
|
-
|
|
144
162
|
/**
|
|
145
163
|
* The npm workspaces below `root`. The one package.json of an app is at `packageRoot` (the app root; `root` is its side folder):
|
|
146
164
|
* its workspace patterns under `<side>/` are this side's, read relative to the side folder, and every other pattern is the other
|
|
147
165
|
* side's.
|
|
148
166
|
*/
|
|
149
167
|
function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side = null } = {}) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
168
|
+
const directories = new Set();
|
|
169
|
+
const queue = [root];
|
|
170
|
+
const visited = new Set();
|
|
171
|
+
const repository = enclosingRepository(root);
|
|
172
|
+
while (queue.length) {
|
|
173
|
+
const packageRoot = queue.shift(); // length checked by the loop condition
|
|
174
|
+
if (visited.has(packageRoot))
|
|
175
|
+
continue;
|
|
176
|
+
visited.add(packageRoot);
|
|
177
|
+
const sideRoot = side !== null && packageRoot === root;
|
|
178
|
+
const pkg = readJson(path.join(sideRoot ? appPackageRoot : packageRoot, 'package.json'));
|
|
179
|
+
const walk = { root, appPackageRoot, packageRoot, sideRoot, side, repository, state: { directories, queue } };
|
|
180
|
+
admitPackageWorkspaces(walk, pkg);
|
|
181
|
+
admitFileDependencies(walk, pkg);
|
|
182
|
+
}
|
|
183
|
+
return [...directories].sort(byCodeUnit);
|
|
165
184
|
}
|
|
166
|
-
|
|
167
185
|
function discoveredProjects(root, workspaces) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
for (const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
186
|
+
const projects = [];
|
|
187
|
+
if (fs.existsSync(path.join(root, 'tsconfig.json')))
|
|
188
|
+
projects.push('tsconfig.json');
|
|
189
|
+
for (const workspace of workspaces) {
|
|
190
|
+
for (const name of ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.build.json']) {
|
|
191
|
+
const candidate = `${workspace}/${name}`;
|
|
192
|
+
if (fs.existsSync(path.join(root, ...candidate.split('/')))) {
|
|
193
|
+
projects.push(candidate);
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
177
197
|
}
|
|
178
|
-
|
|
179
|
-
return [...new Set(projects)];
|
|
198
|
+
return [...new Set(projects)];
|
|
180
199
|
}
|
|
181
|
-
|
|
182
200
|
function inferredLayout(root, workspaces) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
201
|
+
// HFS: frontend source roots live only under workspace packages (apps/<app>/, packages/<pkg>/);
|
|
202
|
+
// the repository root itself is never a frontend source root.
|
|
203
|
+
// The route, feature, component, hook and module roles are app slots (fe.route, fe.feature, fe.components, fe.hooks, fe.modules:
|
|
204
|
+
// apps/<app>/src/...); a workspace package is tier `package` and holds none of them - its own src/hooks is package source, and a
|
|
205
|
+
// grammar package's tier folders make it a component root below.
|
|
206
|
+
const appWorkspaces = workspaces.filter(prefix => prefix.startsWith('apps/'));
|
|
207
|
+
const collect = (suffix) => appWorkspaces.map(prefix => `${prefix}/${suffix}`).filter(relative => existingDirectory(root, relative));
|
|
208
|
+
// A design-system workspace whose src carries tier dirs (leaves/branches/…) IS a component root;
|
|
209
|
+
// collecting its src/components too would nest two roots in one role and fail the disjoint check.
|
|
210
|
+
const tierSources = new Set(workspaces.map(prefix => `${prefix}/src`)
|
|
211
|
+
.filter(source => ['leaves', 'branches', 'blocks', 'overlays', 'composites', 'layouts', 'product-shells', 'pages']
|
|
212
|
+
.some(tier => existingDirectory(root, `${source}/${tier}`))));
|
|
213
|
+
const components = collect('src/components')
|
|
214
|
+
.filter(relative => !tierSources.has(path.posix.dirname(relative)));
|
|
215
|
+
components.push(...tierSources);
|
|
216
|
+
return {
|
|
217
|
+
routes: collect('src/app'),
|
|
218
|
+
features: collect('src/features'),
|
|
219
|
+
components: [...new Set(components)],
|
|
220
|
+
hooks: collect('src/hooks'),
|
|
221
|
+
// src/redux/ (fe.redux) is a modules-tier owner beside src/modules/ (owner ruling 2026-10-13, fe-stack state-global).
|
|
222
|
+
modules: [...collect('src/modules'), ...collect('src/redux')],
|
|
223
|
+
transport: collect('src/modules/api'),
|
|
224
|
+
};
|
|
207
225
|
}
|
|
208
|
-
|
|
209
226
|
function assertFrontendRolesDisjoint(root, frontend) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
227
|
+
const roles = ['routes', 'features', 'components', 'hooks', 'modules'];
|
|
228
|
+
const entries = roles.flatMap(role => frontend[role].map(relative => ({
|
|
229
|
+
role,
|
|
230
|
+
relative,
|
|
231
|
+
absolute: path.join(root, ...relative.split('/')),
|
|
232
|
+
})));
|
|
233
|
+
for (let index = 0; index < entries.length; index += 1)
|
|
234
|
+
for (let other = index + 1; other < entries.length; other += 1) {
|
|
235
|
+
const left = entries[index];
|
|
236
|
+
const right = entries[other];
|
|
237
|
+
if (!left || !right)
|
|
238
|
+
continue;
|
|
239
|
+
if (isInside(left.absolute, right.absolute) || isInside(right.absolute, left.absolute)) {
|
|
240
|
+
throw new Error(`Architecture frontend role roots must be disjoint; ${left.role} ${left.relative} overlaps ${right.role} ${right.relative}.`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
223
243
|
}
|
|
224
|
-
|
|
225
244
|
const SKIPPED_DIRECTORIES = new Set(['node_modules', '.git', 'dist', '.next', '.turbo', 'coverage', 'test-results', '.starciwork', '.starcistacks']);
|
|
226
|
-
|
|
227
245
|
function directoriesUnder(root, relative, depth) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
246
|
+
const out = [];
|
|
247
|
+
const visit = (dir, left) => {
|
|
248
|
+
let entries;
|
|
249
|
+
try {
|
|
250
|
+
entries = fs.readdirSync(path.join(root, ...dir.split('/').filter(Boolean)), { withFileTypes: true });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name))
|
|
257
|
+
continue;
|
|
258
|
+
const child = dir ? `${dir}/${entry.name}` : entry.name;
|
|
259
|
+
out.push(child);
|
|
260
|
+
if (left > 1)
|
|
261
|
+
visit(child, left - 1);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
visit(relative, depth);
|
|
265
|
+
return out;
|
|
241
266
|
}
|
|
242
|
-
|
|
243
267
|
/** The owner entry a slot instance exposes: index.ts/index.tsx, a package's src/index.ts, an app's app.module.ts. */
|
|
244
268
|
function ownerEntry(root, ownerRoot, tier) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
let candidates;
|
|
270
|
+
if (tier === 'package')
|
|
271
|
+
candidates = ['src/index.ts', 'src/index.tsx', 'index.ts', 'index.tsx'];
|
|
272
|
+
else if (tier === 'app')
|
|
273
|
+
candidates = ['app.module.ts'];
|
|
274
|
+
else
|
|
275
|
+
candidates = ['index.ts', 'index.tsx'];
|
|
276
|
+
for (const candidate of candidates) {
|
|
277
|
+
const relative = `${ownerRoot}/${candidate}`;
|
|
278
|
+
if (existingRegularFile(root, relative))
|
|
279
|
+
return relative;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
254
282
|
}
|
|
255
|
-
|
|
256
283
|
/**
|
|
257
284
|
* Owners are derived from the slot manifest: every directory that an `owner: true` slot matches, with its entry file.
|
|
258
285
|
* An instance with no entry file is not listed: the required-files check reports the missing entry.
|
|
259
286
|
*/
|
|
260
287
|
function derivedOwners(root, resolver) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
288
|
+
const seen = new Map();
|
|
289
|
+
for (const directory of directoriesUnder(root, '', 6)) {
|
|
290
|
+
const owner = resolver.ownerOf(directory);
|
|
291
|
+
if (!owner || owner.root !== directory || seen.has(owner.root))
|
|
292
|
+
continue;
|
|
293
|
+
const slot = resolver.slot(owner.slot);
|
|
294
|
+
if (!slot)
|
|
295
|
+
continue;
|
|
296
|
+
const entry = ownerEntry(root, owner.root, slot.tier);
|
|
297
|
+
if (entry)
|
|
298
|
+
seen.set(owner.root, { id: `${owner.slot}:${owner.root}`, root: owner.root, entry, slot: owner.slot, tier: slot.tier });
|
|
299
|
+
}
|
|
300
|
+
return [...seen.values()].sort((a, b) => a.root.localeCompare(b.root));
|
|
270
301
|
}
|
|
271
|
-
|
|
272
302
|
const DECLARED_HANDLER_DECORATORS = ['CommandHandler', 'QueryHandler'];
|
|
273
|
-
|
|
274
303
|
/**
|
|
275
304
|
* The selected family's component contract with derived consumers: every app's globals.css is a style
|
|
276
305
|
* source, every app and every workspace package that depends on its package is a consumer. null when no app has
|
|
277
306
|
* a globals.css to judge (the contract is then reported unavailable, never passed).
|
|
278
307
|
*/
|
|
279
308
|
function derivedGrammar(root, packageRoot, workspaces, apps, family) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
309
|
+
const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
|
|
310
|
+
if (!styleSources.length)
|
|
311
|
+
return null;
|
|
312
|
+
const contract = grammarContractFor(family);
|
|
313
|
+
// Every fe app and package is an npm workspace that declares what it imports (HFS_MONO_WORKSPACE_DEP): each workspace that
|
|
314
|
+
// declares the Grammar package is a consumer, and so is the app root's manifest when it does.
|
|
315
|
+
const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, contract.package)));
|
|
316
|
+
const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
|
|
317
|
+
const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
|
|
318
|
+
for (const workspace of workspaces) {
|
|
319
|
+
if (!workspace.startsWith('packages/') && !workspace.startsWith('apps/'))
|
|
320
|
+
continue;
|
|
321
|
+
const manifest = `${workspace}/package.json`;
|
|
322
|
+
const pkg = readJson(path.join(root, ...manifest.split('/')));
|
|
323
|
+
if (declaresGrammar(pkg))
|
|
324
|
+
consumerManifests.push(manifest);
|
|
325
|
+
}
|
|
326
|
+
if (!consumerManifests.length)
|
|
327
|
+
return null;
|
|
328
|
+
return { ...contract, styleSources, consumerManifests };
|
|
296
329
|
}
|
|
297
|
-
|
|
298
330
|
/**
|
|
299
331
|
* Resolve the layout contract from hfs.json and the slot manifest (knowledge/hfs/slots.yaml). A repository carries only hfs.json. Owners, roots and the tier of every path come from slots; the contract
|
|
300
332
|
* has no ignore, waiver or baseline field.
|
|
301
333
|
*/
|
|
302
334
|
export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
335
|
+
const root = fs.realpathSync(path.resolve(repositoryRoot));
|
|
336
|
+
if (!fs.lstatSync(root).isDirectory())
|
|
337
|
+
throw new Error('Repository root must be a directory.');
|
|
338
|
+
const opened = hfs ?? openHfs({ repoRoot: root });
|
|
339
|
+
const { profile, apps } = opened.repo;
|
|
340
|
+
// A side of an app (the side folder is the root the machine judges) keeps its dependencies in the app root's one package.json.
|
|
341
|
+
const side = opened.repo.side ?? null;
|
|
342
|
+
const packageRoot = side === null ? root : path.dirname(root);
|
|
343
|
+
const workspaces = workspaceDirectories(root, { packageRoot, side });
|
|
344
|
+
const appDirs = apps.map(app => `apps/${app.name}`);
|
|
345
|
+
const inferred = inferredLayout(root, [...new Set([...workspaces, ...appDirs])].sort(byCodeUnit));
|
|
346
|
+
const kinds = [profile === 'be' ? 'backend' : 'frontend'];
|
|
347
|
+
const projects = discoveredProjects(root, [...new Set([...workspaces, ...appDirs])].sort(byCodeUnit));
|
|
348
|
+
if (!projects.length)
|
|
349
|
+
throw new Error('The repository has no tsconfig.json to derive a TypeScript project from.');
|
|
350
|
+
const backend = {
|
|
351
|
+
modules: ['src/modules'],
|
|
352
|
+
// The layered feature root is the api kind (src/features/api/<feature>/: application/ and transport/<protocol>/); the cli
|
|
353
|
+
// feature root (src/features/cli/) has its own shape, held by its slot and the cli rules.
|
|
354
|
+
features: ['src/features/api'],
|
|
355
|
+
apps: ['apps'],
|
|
356
|
+
moduleRegistration: { providerIdentity: 'exported-class-token', handlerDecorators: [...DECLARED_HANDLER_DECORATORS] },
|
|
357
|
+
};
|
|
358
|
+
const frontend = {
|
|
359
|
+
routes: inferred.routes,
|
|
360
|
+
features: inferred.features,
|
|
361
|
+
components: inferred.components,
|
|
362
|
+
hooks: inferred.hooks,
|
|
363
|
+
modules: inferred.modules,
|
|
364
|
+
transport: inferred.transport,
|
|
365
|
+
grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps, opened.repo.grammar) : null,
|
|
366
|
+
};
|
|
367
|
+
if (profile === 'fe')
|
|
368
|
+
assertFrontendRolesDisjoint(root, frontend);
|
|
369
|
+
return {
|
|
370
|
+
root,
|
|
371
|
+
packageRoot,
|
|
372
|
+
repository: enclosingRepository(root),
|
|
373
|
+
kinds,
|
|
374
|
+
projects,
|
|
375
|
+
workspaces,
|
|
376
|
+
apps: apps.map(app => ({ name: app.name, kind: app.kind })),
|
|
377
|
+
hfs: opened,
|
|
378
|
+
owners: derivedOwners(root, opened),
|
|
379
|
+
backend,
|
|
380
|
+
frontend,
|
|
381
|
+
};
|
|
347
382
|
}
|
|
348
|
-
|
|
349
383
|
export { canonical, exactKeys, isInside, slash };
|