@starci/eslint-grammar-core 0.15.34 → 0.16.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +1 -1
  3. package/dist/index.js +2 -0
  4. package/dist/lib/why.js +10 -0
  5. package/dist/mascot.d.ts +28 -0
  6. package/dist/mascot.js +166 -0
  7. package/docs/mascot.md +60 -0
  8. package/package.json +2 -2
  9. package/runtime/bundle.json +74 -0
  10. package/runtime/engine/by-code-unit.mjs +6 -6
  11. package/runtime/engine/model-config.mjs +98 -76
  12. package/runtime/engine/orca-config.mjs +22 -17
  13. package/runtime/engine/release-config.mjs +22 -18
  14. package/runtime/engine/removed-vocabulary.mjs +21 -20
  15. package/runtime/engine/resources-config.mjs +42 -35
  16. package/runtime/engine/runtime-root.mjs +11 -13
  17. package/runtime/engine/secrets.mjs +170 -102
  18. package/runtime/engine/sonar-config.mjs +16 -13
  19. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  20. package/runtime/knowledge/hfs/rules.yaml +72 -6
  21. package/runtime/knowledge/hfs/slots.yaml +2 -2
  22. package/runtime/knowledge/patterns/fe/ui.yaml +12 -1
  23. package/runtime/scripts/hfs/allows.mjs +37 -38
  24. package/runtime/scripts/hfs/architecture/config.mjs +302 -268
  25. package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
  26. package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
  27. package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
  28. package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
  29. package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
  30. package/runtime/scripts/hfs/edition-slots.mjs +43 -40
  31. package/runtime/scripts/hfs/param-names.mjs +6 -11
  32. package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
  33. package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
  34. package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
  35. package/runtime/scripts/hfs/slot-classify.mjs +42 -38
  36. package/runtime/scripts/hfs/slot-errors.mjs +8 -7
  37. package/runtime/scripts/hfs/slot-imports.mjs +70 -63
  38. package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
  39. package/runtime/scripts/hfs/slot-match.mjs +122 -111
  40. package/runtime/scripts/hfs/slot-path.mjs +0 -2
  41. package/runtime/scripts/hfs/slot-required.mjs +65 -59
  42. package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
  43. package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
  44. package/runtime/scripts/hfs/slots.mjs +175 -152
  45. package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
  46. package/runtime/scripts/hfs/view.mjs +38 -40
  47. package/runtime/scripts/lib/glob.mjs +23 -14
  48. package/runtime/scripts/lib/grammar-lint.mjs +33 -37
  49. package/runtime/scripts/lib/i18n.mjs +32 -35
  50. package/runtime/scripts/lib/json-clone.mjs +45 -36
  51. package/runtime/scripts/lib/json.mjs +26 -14
  52. package/runtime/scripts/lib/list.mjs +9 -14
  53. package/runtime/scripts/lib/path-key.mjs +53 -38
  54. package/runtime/scripts/lib/read-text.mjs +30 -17
  55. package/runtime/scripts/lib/regex.mjs +1 -2
  56. package/runtime/scripts/lib/source-phrases.mjs +13 -13
  57. package/runtime/scripts/lib/ts-analysis.mjs +28 -26
  58. package/runtime/scripts/lib/ts-ast.mjs +36 -42
  59. package/runtime/scripts/lib/walk.mjs +33 -27
  60. 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
- const absolute = path.resolve(file);
14
- try { return path.resolve(fs.realpathSync(absolute)); } catch { return absolute; }
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
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object.`);
19
- const unknown = Object.keys(value).filter(key => !allowed.has(key));
20
- if (unknown.length) {
21
- unknown.sort(byCodeUnit);
22
- throw new Error(`${label} has unsupported fields: ${unknown.join(', ')}.`);
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
- let cursor = path.resolve(root);
34
- for (;;) {
35
- if (fs.existsSync(path.join(cursor, '.git'))) return cursor;
36
- const parent = path.dirname(cursor);
37
- if (parent === cursor) return null;
38
- cursor = parent;
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
- try {
44
- if (!fs.lstatSync(absolute).isDirectory()) return false;
45
- const manifest = fs.lstatSync(path.join(absolute, 'package.json'));
46
- return manifest.isFile() && !manifest.isSymbolicLink();
47
- } catch {
48
- return false;
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
- try {
54
- return fs.lstatSync(path.join(root, relative)).isDirectory();
55
- } catch {
56
- return false;
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
- try {
62
- const stat = fs.lstatSync(path.join(root, ...relative.split('/')));
63
- return stat.isFile() && !stat.isSymbolicLink();
64
- } catch {
65
- return false;
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
- const absolute = path.resolve(candidate);
71
- if (!isInside(root, absolute) || absolute === root || !existingDirectory(root, slash(path.relative(root, absolute)))) {
72
- if (strict) throw new 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 new Error(`${label} must resolve to a regular package.json inside the repository.`);
79
- return;
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
- if (segment !== '*') return [path.join(base, segment)];
91
- if (!fs.existsSync(base) || !fs.lstatSync(base).isDirectory()) return [];
92
- return fs.readdirSync(base, { withFileTypes: true })
93
- .filter(item => item.isDirectory())
94
- .sort((a, b) => byCodeUnit(a.name, b.name))
95
- .map(item => path.join(base, item.name));
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
- const segments = normalized.split('/');
100
- if (path.isAbsolute(normalized) || segments.some(segment => segment === '..' || (segment.includes('*') && segment !== '*'))) {
101
- throw new Error(`Unsupported local workspace pattern: ${normalized}.`);
102
- }
103
- let candidates = [packageRoot];
104
- for (const segment of segments) candidates = candidates.flatMap(base => expandSegment(base, segment));
105
- return { candidates, wildcard: segments.includes('*') };
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
- const { root, appPackageRoot, packageRoot, sideRoot, repository, state } = walk;
110
- if (typeof value !== 'string' || !value.startsWith('file:')) return;
111
- const absolute = path.resolve(sideRoot ? appPackageRoot : packageRoot, value.slice('file:'.length));
112
- if (sideRoot && !isInside(root, absolute) && isInside(appPackageRoot, absolute)) return;
113
- const label = `${section}.${name} file dependency`;
114
- if (isInside(root, absolute)) { admitWorkspace(root, state.directories, state.queue, absolute, label, true); return; }
115
- if (!repository || !isInside(repository, absolute) || !existingPackageDirectory(absolute)) {
116
- throw new Error(`${label} must resolve to a package directory inside the repository.`);
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
- for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
122
- for (const [name, value] of Object.entries(pkg?.[section] ?? {}))
123
- admitFileDependency(walk, section, name, value);
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
- const { root, packageRoot, sideRoot, side, state } = walk;
129
- const patterns = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
130
- for (const pattern of Array.isArray(patterns) ? patterns : []) {
131
- if (typeof pattern !== 'string' || !pattern.trim()) throw new Error('package.json workspace entries must be non-empty paths.');
132
- const written = slash(pattern.trim()).replace(/^\.\//, '');
133
- if (sideRoot && !written.startsWith(`${side}/`)) continue;
134
- const normalized = sideRoot ? written.slice(side.length + 1) : written;
135
- const { candidates, wildcard } = workspaceCandidates(packageRoot, normalized);
136
- // npm expands a `*` segment to the directories that hold a package.json and skips the rest (an empty or untracked folder is
137
- // HFS_EMPTY_DIR / HFS_SLOT_UNDECLARED, never a reason to analyse nothing); a literal workspace path must resolve. A `*`
138
- // pattern may match nothing: every app declares the same workspaces (fe/apps/*, fe/packages/*, HFS_MONO_WORKSPACES) whether
139
- // or not it has a package yet, and the root patterns are held to that fixed list by the rule, not here.
140
- for (const candidate of candidates) admitWorkspace(root, state.directories, state.queue, candidate, `workspace ${normalized}`, !wildcard);
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
- const directories = new Set();
151
- const queue = [root];
152
- const visited = new Set();
153
- const repository = enclosingRepository(root);
154
- while (queue.length) {
155
- const packageRoot = queue.shift();
156
- if (visited.has(packageRoot)) continue;
157
- visited.add(packageRoot);
158
- const sideRoot = side !== null && packageRoot === root;
159
- const pkg = readJson(path.join(sideRoot ? appPackageRoot : packageRoot, 'package.json'));
160
- const walk = { root, appPackageRoot, packageRoot, sideRoot, side, repository, state: { directories, queue } };
161
- admitPackageWorkspaces(walk, pkg);
162
- admitFileDependencies(walk, pkg);
163
- }
164
- return [...directories].sort(byCodeUnit);
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
- const projects = [];
169
- if (fs.existsSync(path.join(root, 'tsconfig.json'))) projects.push('tsconfig.json');
170
- for (const workspace of workspaces) {
171
- for (const name of ['tsconfig.json', 'tsconfig.app.json', 'tsconfig.build.json']) {
172
- const candidate = `${workspace}/${name}`;
173
- if (fs.existsSync(path.join(root, ...candidate.split('/')))) {
174
- projects.push(candidate);
175
- break;
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
- // HFS: frontend source roots live only under workspace packages (apps/<app>/, packages/<pkg>/);
184
- // the repository root itself is never a frontend source root.
185
- // The route, feature, component, hook and module roles are app slots (fe.route, fe.feature, fe.components, fe.hooks, fe.modules:
186
- // apps/<app>/src/...); a workspace package is tier `package` and holds none of them - its own src/hooks is package source, and a
187
- // grammar package's tier folders make it a component root below.
188
- const appWorkspaces = workspaces.filter(prefix => prefix.startsWith('apps/'));
189
- const collect = suffix => appWorkspaces.map(prefix => `${prefix}/${suffix}`).filter(relative => existingDirectory(root, relative));
190
- // A design-system workspace whose src carries tier dirs (leaves/branches/…) IS a component root;
191
- // collecting its src/components too would nest two roots in one role and fail the disjoint check.
192
- const tierSources = new Set(workspaces.map(prefix => `${prefix}/src`)
193
- .filter(source => ['leaves', 'branches', 'blocks', 'overlays', 'composites', 'layouts', 'product-shells', 'pages']
194
- .some(tier => existingDirectory(root, `${source}/${tier}`))));
195
- const components = collect('src/components')
196
- .filter(relative => !tierSources.has(path.posix.dirname(relative)));
197
- components.push(...tierSources);
198
- return {
199
- routes: collect('src/app'),
200
- features: collect('src/features'),
201
- components: [...new Set(components)],
202
- hooks: collect('src/hooks'),
203
- // src/redux/ (fe.redux) is a modules-tier owner beside src/modules/ (owner ruling 2026-10-13, fe-stack state-global).
204
- modules: [...collect('src/modules'), ...collect('src/redux')],
205
- transport: collect('src/modules/api'),
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
- const roles = ['routes', 'features', 'components', 'hooks', 'modules'];
211
- const entries = roles.flatMap(role => frontend[role].map(relative => ({
212
- role,
213
- relative,
214
- absolute: path.join(root, ...relative.split('/')),
215
- })));
216
- for (let index = 0; index < entries.length; index += 1) for (let other = index + 1; other < entries.length; other += 1) {
217
- const left = entries[index];
218
- const right = entries[other];
219
- if (isInside(left.absolute, right.absolute) || isInside(right.absolute, left.absolute)) {
220
- throw new Error(`Architecture frontend role roots must be disjoint; ${left.role} ${left.relative} overlaps ${right.role} ${right.relative}.`);
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
- const out = [];
229
- const visit = (dir, left) => {
230
- let entries;
231
- try { entries = fs.readdirSync(path.join(root, ...dir.split('/').filter(Boolean)), { withFileTypes: true }); } catch { return; }
232
- for (const entry of entries) {
233
- if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) continue;
234
- const child = dir ? `${dir}/${entry.name}` : entry.name;
235
- out.push(child);
236
- if (left > 1) visit(child, left - 1);
237
- }
238
- };
239
- visit(relative, depth);
240
- return out;
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
- let candidates;
246
- if (tier === 'package') candidates = ['src/index.ts', 'src/index.tsx', 'index.ts', 'index.tsx'];
247
- else if (tier === 'app') candidates = ['app.module.ts'];
248
- else candidates = ['index.ts', 'index.tsx'];
249
- for (const candidate of candidates) {
250
- const relative = `${ownerRoot}/${candidate}`;
251
- if (existingRegularFile(root, relative)) return relative;
252
- }
253
- return null;
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
- const seen = new Map();
262
- for (const directory of directoriesUnder(root, '', 6)) {
263
- const owner = resolver.ownerOf(directory);
264
- if (!owner || owner.root !== directory || seen.has(owner.root)) continue;
265
- const slot = resolver.slot(owner.slot);
266
- const entry = ownerEntry(root, owner.root, slot.tier);
267
- if (entry) seen.set(owner.root, { id: `${owner.slot}:${owner.root}`, root: owner.root, entry, slot: owner.slot, tier: slot.tier });
268
- }
269
- return [...seen.values()].sort((a, b) => a.root.localeCompare(b.root));
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
- const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
281
- if (!styleSources.length) return null;
282
- const contract = grammarContractFor(family);
283
- // Every fe app and package is an npm workspace that declares what it imports (HFS_MONO_WORKSPACE_DEP): each workspace that
284
- // declares the Grammar package is a consumer, and so is the app root's manifest when it does.
285
- const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, contract.package)));
286
- const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
287
- const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
288
- for (const workspace of workspaces) {
289
- if (!workspace.startsWith('packages/') && !workspace.startsWith('apps/')) continue;
290
- const manifest = `${workspace}/package.json`;
291
- const pkg = readJson(path.join(root, ...manifest.split('/')));
292
- if (declaresGrammar(pkg)) consumerManifests.push(manifest);
293
- }
294
- if (!consumerManifests.length) return null;
295
- return { ...contract, styleSources, consumerManifests };
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
- const root = fs.realpathSync(path.resolve(repositoryRoot));
304
- if (!fs.lstatSync(root).isDirectory()) throw new Error('Repository root must be a directory.');
305
- const opened = hfs ?? openHfs({ repoRoot: root });
306
- const { profile, apps } = opened.repo;
307
- // 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.
308
- const side = opened.repo.side ?? null;
309
- const packageRoot = side === null ? root : path.dirname(root);
310
- const workspaces = workspaceDirectories(root, { packageRoot, side });
311
- const appDirs = apps.map(app => `apps/${app.name}`);
312
- const inferred = inferredLayout(root, [...new Set([...workspaces, ...appDirs])].sort(byCodeUnit));
313
- const kinds = [profile === 'be' ? 'backend' : 'frontend'];
314
- const projects = discoveredProjects(root, [...new Set([...workspaces, ...appDirs])].sort(byCodeUnit));
315
- if (!projects.length) throw new Error('The repository has no tsconfig.json to derive a TypeScript project from.');
316
- const backend = {
317
- modules: ['src/modules'],
318
- // The layered feature root is the api kind (src/features/api/<feature>/: application/ and transport/<protocol>/); the cli
319
- // feature root (src/features/cli/) has its own shape, held by its slot and the cli rules.
320
- features: ['src/features/api'],
321
- apps: ['apps'],
322
- moduleRegistration: { providerIdentity: 'exported-class-token', handlerDecorators: [...DECLARED_HANDLER_DECORATORS] },
323
- };
324
- const frontend = {
325
- routes: inferred.routes,
326
- features: inferred.features,
327
- components: inferred.components,
328
- hooks: inferred.hooks,
329
- modules: inferred.modules,
330
- transport: inferred.transport,
331
- grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps, opened.repo.grammar) : null,
332
- };
333
- if (profile === 'fe') assertFrontendRolesDisjoint(root, frontend);
334
- return {
335
- root,
336
- packageRoot,
337
- repository: enclosingRepository(root),
338
- kinds,
339
- projects,
340
- workspaces,
341
- apps: apps.map(app => ({ name: app.name, kind: app.kind })),
342
- hfs: opened,
343
- owners: derivedOwners(root, opened),
344
- backend,
345
- frontend,
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 };