@starci/eslint-canon-fe 7.1.2 → 8.0.1
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 +11 -0
- package/client-boundary.mjs +70 -0
- package/docs/client-boundary.md +24 -0
- package/lib/config.mjs +1 -1
- package/lib/hfs.mjs +7 -6
- package/lib/next.mjs +2 -2
- package/lib/why.mjs +12 -2
- package/package.json +1 -1
- package/props-and-slots.mjs +120 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/runtime/scripts/lib/next-contract.mjs +2 -2
- package/transport.mjs +3 -3
- package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
- package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"scripts": {
|
|
3
|
+
"prepare": "husky",
|
|
4
|
+
{{appScripts}}
|
|
5
|
+
"build:be": "cd be && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
6
|
+
"build:fe": "{{buildFe}}",
|
|
7
|
+
"codegen": "node scripts/codegen.mjs",
|
|
8
|
+
"typecheck": "{{typecheck}}",
|
|
9
|
+
"typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
|
|
10
|
+
"lint": "npm run codegen --silent && hfs lint",
|
|
11
|
+
"lint:fix": "npm run codegen --silent && hfs lint --fix",
|
|
12
|
+
"format": "prettier --write .",
|
|
13
|
+
"format:check": "prettier --check .",
|
|
14
|
+
"test": "cd be && jest --selectProjects unit --coverage",
|
|
15
|
+
"test:affected": "cd be && jest --selectProjects unit --passWithNoTests",
|
|
16
|
+
"test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
|
|
17
|
+
"test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
|
|
18
|
+
"test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
|
|
19
|
+
"test:stack": "cd be && starci-test-stack",
|
|
20
|
+
"contract:emit": "hfs emit-contracts"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -61,7 +61,12 @@ function existingRegularFile(root, relative) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The npm workspaces below `root`. The one package.json of an app is at `packageRoot` (the app root; `root` is its side folder):
|
|
66
|
+
* its workspace patterns under `<side>/` are this side's, read relative to the side folder, and every other pattern is the other
|
|
67
|
+
* side's.
|
|
68
|
+
*/
|
|
69
|
+
function workspaceDirectories(root, { packageRoot: appPackageRoot = root, side = null } = {}) {
|
|
65
70
|
const directories = new Set();
|
|
66
71
|
const queue = [root];
|
|
67
72
|
const visited = new Set();
|
|
@@ -89,11 +94,14 @@ function workspaceDirectories(root) {
|
|
|
89
94
|
const packageRoot = queue.shift();
|
|
90
95
|
if (visited.has(packageRoot)) continue;
|
|
91
96
|
visited.add(packageRoot);
|
|
92
|
-
const
|
|
97
|
+
const sideRoot = side !== null && packageRoot === root;
|
|
98
|
+
const pkg = readJson(path.join(sideRoot ? appPackageRoot : packageRoot, 'package.json'));
|
|
93
99
|
const patterns = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
|
|
94
100
|
for (const pattern of Array.isArray(patterns) ? patterns : []) {
|
|
95
101
|
if (typeof pattern !== 'string' || !pattern.trim()) throw Error('package.json workspace entries must be non-empty paths.');
|
|
96
|
-
const
|
|
102
|
+
const written = slash(pattern.trim()).replace(/^\.\//, '');
|
|
103
|
+
if (sideRoot && !written.startsWith(`${side}/`)) continue;
|
|
104
|
+
const normalized = sideRoot ? written.slice(side.length + 1) : written;
|
|
97
105
|
const segments = normalized.split('/');
|
|
98
106
|
if (path.isAbsolute(normalized) || segments.some(segment => segment === '..' || (segment.includes('*') && segment !== '*'))) {
|
|
99
107
|
throw Error(`Unsupported local workspace pattern: ${normalized}.`);
|
|
@@ -121,7 +129,10 @@ function workspaceDirectories(root) {
|
|
|
121
129
|
for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
122
130
|
for (const [name, value] of Object.entries(pkg?.[section] ?? {})) {
|
|
123
131
|
if (typeof value !== 'string' || !value.startsWith('file:')) continue;
|
|
124
|
-
const absolute = path.resolve(packageRoot, value.slice('file:'.length));
|
|
132
|
+
const absolute = path.resolve(sideRoot ? appPackageRoot : packageRoot, value.slice('file:'.length));
|
|
133
|
+
// The app's one package.json also lists the other side's file dependencies (inside the app, outside this side): not this
|
|
134
|
+
// side's. A path that leaves the app is judged like any other: a package of the same repository, or refused.
|
|
135
|
+
if (sideRoot && !isInside(root, absolute) && isInside(appPackageRoot, absolute)) continue;
|
|
125
136
|
const label = `${section}.${name} file dependency`;
|
|
126
137
|
if (isInside(root, absolute)) { admit(absolute, label, true); continue; }
|
|
127
138
|
// A sibling package of the same repository: this project consumes it, so the path is real and
|
|
@@ -248,15 +259,19 @@ const GRAMMAR_PACKAGE = '@starci/grammar';
|
|
|
248
259
|
* source, every app and every workspace package that depends on the Grammar package is a consumer. null when no app has
|
|
249
260
|
* a globals.css to judge (the contract is then reported unavailable, never passed).
|
|
250
261
|
*/
|
|
251
|
-
function derivedGrammar(root,
|
|
262
|
+
function derivedGrammar(root, packageRoot, workspaces, apps = []) {
|
|
252
263
|
const styleSources = apps.map(app => `apps/${app.name}/src/app/globals.css`).filter(relative => existingRegularFile(root, relative));
|
|
253
264
|
if (!styleSources.length) return null;
|
|
254
|
-
|
|
265
|
+
// The apps have no package.json of their own: the app root's one manifest declares their dependencies, and it is a consumer
|
|
266
|
+
// when it declares the Grammar package; so is every workspace package that does.
|
|
267
|
+
const declaresGrammar = (pkg) => Boolean(pkg && [pkg.dependencies, pkg.peerDependencies, pkg.devDependencies].some(section => section && Object.hasOwn(section, GRAMMAR_PACKAGE)));
|
|
268
|
+
const appManifest = slash(path.relative(root, path.join(packageRoot, 'package.json')));
|
|
269
|
+
const consumerManifests = declaresGrammar(readJson(path.join(packageRoot, 'package.json'))) ? [appManifest] : [];
|
|
255
270
|
for (const workspace of workspaces) {
|
|
256
271
|
if (!workspace.startsWith('packages/')) continue;
|
|
257
272
|
const manifest = `${workspace}/package.json`;
|
|
258
273
|
const pkg = readJson(path.join(root, ...manifest.split('/')));
|
|
259
|
-
if (pkg
|
|
274
|
+
if (declaresGrammar(pkg)) consumerManifests.push(manifest);
|
|
260
275
|
}
|
|
261
276
|
if (!consumerManifests.length) return null;
|
|
262
277
|
return { package: GRAMMAR_PACKAGE, entry: `${GRAMMAR_PACKAGE}/common`, styleEntry: `${GRAMMAR_PACKAGE}/common.css`, styleSources, consumerManifests, peers: ['react', '@heroui/react'] };
|
|
@@ -272,10 +287,14 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
|
|
|
272
287
|
if (!fs.lstatSync(root).isDirectory()) throw Error('Repository root must be a directory.');
|
|
273
288
|
const opened = hfs ?? openHfs({ repoRoot: root });
|
|
274
289
|
const { profile, apps } = opened.repo;
|
|
275
|
-
|
|
276
|
-
const
|
|
290
|
+
// 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.
|
|
291
|
+
const side = opened.repo.side ?? null;
|
|
292
|
+
const packageRoot = side === null ? root : path.dirname(root);
|
|
293
|
+
const workspaces = workspaceDirectories(root, { packageRoot, side });
|
|
294
|
+
const appDirs = apps.map(app => `apps/${app.name}`);
|
|
295
|
+
const inferred = inferredLayout(root, [...new Set([...workspaces, ...appDirs])].sort());
|
|
277
296
|
const kinds = [profile === 'be' ? 'backend' : 'frontend'];
|
|
278
|
-
const projects = discoveredProjects(root, workspaces);
|
|
297
|
+
const projects = discoveredProjects(root, [...new Set([...workspaces, ...appDirs])].sort());
|
|
279
298
|
if (!projects.length) throw Error('The repository has no tsconfig.json to derive a TypeScript project from.');
|
|
280
299
|
const backend = {
|
|
281
300
|
modules: ['src/modules'],
|
|
@@ -290,11 +309,12 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
|
|
|
290
309
|
hooks: inferred.hooks,
|
|
291
310
|
modules: inferred.modules,
|
|
292
311
|
transport: inferred.transport,
|
|
293
|
-
grammar: profile === 'fe' ? derivedGrammar(root,
|
|
312
|
+
grammar: profile === 'fe' ? derivedGrammar(root, packageRoot, workspaces, apps) : null,
|
|
294
313
|
};
|
|
295
314
|
if (profile === 'fe') assertFrontendRolesDisjoint(root, frontend);
|
|
296
315
|
return {
|
|
297
316
|
root,
|
|
317
|
+
packageRoot,
|
|
298
318
|
repository: enclosingRepository(root),
|
|
299
319
|
kinds,
|
|
300
320
|
projects,
|
|
@@ -276,7 +276,7 @@ function contractTypeStatusFromType(ts, checker, type, seen = new Set(), depth =
|
|
|
276
276
|
}
|
|
277
277
|
for (const anonymous of ['Record', 'Partial', 'Pick', 'Omit', 'Required']) if (builtinSymbol(ts, named, anonymous)) return 'inline';
|
|
278
278
|
if (type.aliasSymbol) return 'named';
|
|
279
|
-
if (type.symbol?.getDeclarations?.().some(declaration => ts.isEnumDeclaration(declaration))) return 'named';
|
|
279
|
+
if ((type.symbol?.getDeclarations?.() ?? []).some(declaration => ts.isEnumDeclaration(declaration))) return 'named';
|
|
280
280
|
if (type.flags & ts.TypeFlags.TypeParameter) return 'named';
|
|
281
281
|
if (type.flags & ts.TypeFlags.Boolean) return 'scalar';
|
|
282
282
|
if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) return 'inline';
|
|
@@ -590,7 +590,7 @@ function checkMessageReadonly(config, context, checker, declaration, localFiles,
|
|
|
590
590
|
}
|
|
591
591
|
const type = classInstanceType(ts, checker, selected);
|
|
592
592
|
for (const base of type && checker.getBaseTypes ? checker.getBaseTypes(type) : []) {
|
|
593
|
-
const declarations = base.symbol?.getDeclarations?.().filter(item => ts.isClassDeclaration(item)) ?? [];
|
|
593
|
+
const declarations = (base.symbol?.getDeclarations?.() ?? []).filter(item => ts.isClassDeclaration(item)) ?? [];
|
|
594
594
|
if (declarations.length !== 1) {
|
|
595
595
|
reasons.push(`${relativePath(config.root, sourceFile.fileName)} inherits message fields from a class outside the checked production program`);
|
|
596
596
|
} else if (localFiles.has(canonical(declarations[0].getSourceFile().fileName))) visitClass(declarations[0]);
|
|
@@ -654,7 +654,7 @@ function checkInjectedClass(config, context, checker, declaration, classKind, ta
|
|
|
654
654
|
if (classKind) {
|
|
655
655
|
const type = classInstanceType(ts, checker, declaration);
|
|
656
656
|
for (const base of type && checker.getBaseTypes ? checker.getBaseTypes(type) : []) {
|
|
657
|
-
const declarations = base.symbol?.getDeclarations?.().filter(item => ts.isClassDeclaration(item)) ?? [];
|
|
657
|
+
const declarations = (base.symbol?.getDeclarations?.() ?? []).filter(item => ts.isClassDeclaration(item)) ?? [];
|
|
658
658
|
if (declarations.length !== 1) {
|
|
659
659
|
reasons.push(`${relativePath(config.root, sourceFile.fileName)} inherits an injected constructor from a class outside the checked production program`);
|
|
660
660
|
} else if (localFiles.has(canonical(declarations[0].getSourceFile().fileName))) {
|
|
@@ -709,7 +709,7 @@ export function checkBackendContracts(config, context) {
|
|
|
709
709
|
publicReasons.push(`${owner.entry} uses export =, so named capability API discovery is unavailable`);
|
|
710
710
|
}
|
|
711
711
|
for (const symbol of exportedSymbols(ts, checker, entry)) {
|
|
712
|
-
const declarations = symbol.getDeclarations?.().filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName))) ?? [];
|
|
712
|
+
const declarations = (symbol.getDeclarations?.() ?? []).filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName))) ?? [];
|
|
713
713
|
if (!declarations.length) continue;
|
|
714
714
|
const representative = declarations[0];
|
|
715
715
|
if (isFrameworkHelper(representative.getSourceFile(), representative, framework)) continue;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { treeOf } from './required-files.mjs';
|
|
2
2
|
import { allowsFile } from '../../lib/hfs-allows.mjs';
|
|
3
|
+
import { isFeTestPath } from '../../lib/hfs-rules/fe-no-tests.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* R94 `fe-slot-allows` (FE_SLOT_FILE_ROLE). A front-end slot that owns a whole directory (`fe.route`, `fe.feature`,
|
|
6
7
|
* `fe.components`, `fe.hooks`, `fe.modules.api`) also says what its instances hold (`allows` in knowledge/hfs/slots.yaml).
|
|
7
8
|
* Every tracked file of such a slot is matched against the `requires` and `allows` entries of its slot; a file no entry names is a
|
|
8
|
-
* finding - `page.tsx` in a hooks domain, a `[lang]` segment under `app/`, a
|
|
9
|
-
*
|
|
9
|
+
* finding - `page.tsx` in a hooks domain, a `[lang]` segment under `app/`, a stray file beside a component or at a feature
|
|
10
|
+
* root. A file that sits where the slot expects a folder (`hooks/useX.ts`, so the "domain" is a file name) is one too.
|
|
10
11
|
* Files no slot owns are HFS_PATH_NO_SLOT's (`hfs check`), so together every front-end file is placed by exactly one slot and
|
|
11
|
-
* named by it.
|
|
12
|
+
* named by it. A test path (a `.spec.tsx` or `.test.tsx` beside a component, a test directory or test tooling) is FE_NO_TESTS's
|
|
13
|
+
* (R97, contract-change fe-no-tests): that rule is the one finding of such a file, so this check leaves it alone, as
|
|
14
|
+
* HFS_PATH_NO_SLOT does (scripts/lib/hfs-path-findings.mjs).
|
|
12
15
|
*/
|
|
13
16
|
export const FE_SLOT_ALLOWS_RULE_IDS = ['FE_SLOT_FILE_ROLE'];
|
|
14
17
|
|
|
@@ -21,6 +24,7 @@ export function checkFeSlotAllows({ config, graph }) {
|
|
|
21
24
|
let files = 0;
|
|
22
25
|
const report = (file, message, extra = {}) => violations.push({ ruleId: RULE, path: file, line: 1, column: 1, message, ...extra });
|
|
23
26
|
for (const file of [...tree.files].sort()) {
|
|
27
|
+
if (isFeTestPath(file)) continue; // FE_NO_TESTS's, the one finding of that file
|
|
24
28
|
const classified = resolver.classifyPath(file);
|
|
25
29
|
if (classified.status !== 'owned') continue;
|
|
26
30
|
const slot = resolver.slot(classified.slot);
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// framework-pinned.mjs - the Next.js files that only load from a source root (the directory holding
|
|
2
|
-
// app/)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// inc-846867b9a34e). Read by the architecture check (FE_SOURCE_LAYOUT_INVALID) and the Next name-shape
|
|
6
|
-
// check (FE_SOURCE_NAME_SHAPE).
|
|
2
|
+
// app/). Authored once in knowledge/patterns/fe/folder.yaml FE-FOLDER-1 (frameworkPinnedRootFiles), never
|
|
3
|
+
// hard-coded here (supervisor ruling, nivo wf-nivo-fe-debt-mug06w7h inc-2e42a24b74e4). Read by the architecture
|
|
4
|
+
// check (FE_SOURCE_LAYOUT_INVALID).
|
|
7
5
|
//
|
|
8
|
-
// An unreadable or malformed list is a broken install, not "no pinned files":
|
|
6
|
+
// An unreadable or malformed list is a broken install, not "no pinned files": the reader throws
|
|
9
7
|
// ARCH_KNOWLEDGE_UNAVAILABLE so the caller reports an error instead of judging with a different contract.
|
|
10
8
|
import fs from 'node:fs';
|
|
11
|
-
import path from 'node:path';
|
|
12
9
|
import { fileURLToPath } from 'node:url';
|
|
13
10
|
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
14
11
|
|
|
@@ -21,11 +18,6 @@ function unavailable(file, detail) {
|
|
|
21
18
|
return Error(`ARCH_KNOWLEDGE_UNAVAILABLE: ${file} ${detail}`);
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
/** The file stem Next.js keys a pinned file by: `middleware.ts` -> `middleware`, `next-env.d.ts` -> `next-env`. */
|
|
25
|
-
export function pinnedStem(fileName) {
|
|
26
|
-
return path.basename(fileName).replace(/(?:\.d)?\.[cm]?[jt]sx?$/i, '');
|
|
27
|
-
}
|
|
28
|
-
|
|
29
21
|
function load(file) {
|
|
30
22
|
if (cache.has(file)) return cache.get(file);
|
|
31
23
|
let rule;
|
|
@@ -38,21 +30,7 @@ function load(file) {
|
|
|
38
30
|
if (!Array.isArray(list) || !list.length || list.some(name => typeof name !== 'string' || !EXACT_NAME.test(name))) {
|
|
39
31
|
throw unavailable(file, 'FE-FOLDER-1 frameworkPinnedRootFiles must be a nonempty list of exact file names.');
|
|
40
32
|
}
|
|
41
|
-
const
|
|
42
|
-
const stems = new Set(list.map(pinnedStem));
|
|
43
|
-
const declared = rule.frameworkPinnedRootExports;
|
|
44
|
-
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
|
|
45
|
-
throw unavailable(file, 'FE-FOLDER-1 frameworkPinnedRootExports must map a pinned file stem to its framework-mandated export names.');
|
|
46
|
-
}
|
|
47
|
-
const exports = new Map();
|
|
48
|
-
for (const [stem, names] of Object.entries(declared)) {
|
|
49
|
-
if (!stems.has(stem)) throw unavailable(file, `FE-FOLDER-1 frameworkPinnedRootExports names ${stem}, which no frameworkPinnedRootFiles entry pins.`);
|
|
50
|
-
if (!Array.isArray(names) || !names.length || names.some(name => typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name))) {
|
|
51
|
-
throw unavailable(file, `FE-FOLDER-1 frameworkPinnedRootExports.${stem} must be a nonempty list of export identifiers.`);
|
|
52
|
-
}
|
|
53
|
-
exports.set(stem, new Set(names));
|
|
54
|
-
}
|
|
55
|
-
const loaded = { files, exports };
|
|
33
|
+
const loaded = { files: new Set(list) };
|
|
56
34
|
if (file === FRAMEWORK_PINNED_KNOWLEDGE) cache.set(file, loaded);
|
|
57
35
|
return loaded;
|
|
58
36
|
}
|
|
@@ -61,23 +39,3 @@ function load(file) {
|
|
|
61
39
|
export function frameworkPinnedRootFiles(file = FRAMEWORK_PINNED_KNOWLEDGE) {
|
|
62
40
|
return load(file).files;
|
|
63
41
|
}
|
|
64
|
-
|
|
65
|
-
/** Map of pinned file stem to the export names the framework mandates there. */
|
|
66
|
-
export function frameworkPinnedRootExports(file = FRAMEWORK_PINNED_KNOWLEDGE) {
|
|
67
|
-
return load(file).exports;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The framework-mandated export names for an absolute source file when it is a pinned file sitting
|
|
72
|
-
* directly in a Next source root (its directory holds an app/ directory), else null.
|
|
73
|
-
*/
|
|
74
|
-
export function frameworkMandatedExports(absoluteFile, file = FRAMEWORK_PINNED_KNOWLEDGE) {
|
|
75
|
-
const { files, exports } = load(file);
|
|
76
|
-
if (!files.has(path.basename(absoluteFile))) return null;
|
|
77
|
-
try {
|
|
78
|
-
if (!fs.statSync(path.join(path.dirname(absoluteFile), 'app')).isDirectory()) return null;
|
|
79
|
-
} catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
return exports.get(pinnedStem(absoluteFile)) ?? new Set();
|
|
83
|
-
}
|
|
@@ -108,13 +108,15 @@ function checkGrammar(config, context) {
|
|
|
108
108
|
// Node resolution per consumer: in an npm-workspaces monorepo a consumer whose range differs from the
|
|
109
109
|
// hoisted copy gets its own apps/<app>/node_modules/<package> (nivo-fe: apps/app on 0.5.0 beside a hoisted
|
|
110
110
|
// 0.4.11). Every consumer is judged against the copy it actually resolves, never the hoisted one alone.
|
|
111
|
+
// An app installs once, at its root (config.packageRoot): a side folder has no node_modules of its own.
|
|
112
|
+
const installRoot = config.packageRoot ?? config.root;
|
|
111
113
|
const installedFrom = (from) => {
|
|
112
|
-
for (let dir = from; isInside(
|
|
114
|
+
for (let dir = from; isInside(installRoot, dir); dir = path.dirname(dir)) {
|
|
113
115
|
const candidate = path.join(dir, 'node_modules', ...grammar.package.split('/'));
|
|
114
116
|
if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate;
|
|
115
|
-
if (dir ===
|
|
117
|
+
if (dir === installRoot || path.dirname(dir) === dir) break;
|
|
116
118
|
}
|
|
117
|
-
return path.join(
|
|
119
|
+
return path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
|
|
118
120
|
};
|
|
119
121
|
const consumers = grammar.consumerManifests.map(relative => {
|
|
120
122
|
const root = path.dirname(path.join(config.root, ...relative.split('/')));
|
|
@@ -123,7 +125,7 @@ function checkGrammar(config, context) {
|
|
|
123
125
|
manifest: readJson(path.join(config.root, ...relative.split('/'))) ?? {} };
|
|
124
126
|
});
|
|
125
127
|
const installs = [...new Map(consumers.map(consumer => [consumer.packageRoot, consumer])).values()];
|
|
126
|
-
const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(
|
|
128
|
+
const packageRoot = installs[0]?.packageRoot ?? local?.root ?? path.join(installRoot, 'node_modules', ...grammar.package.split('/'));
|
|
127
129
|
const manifestFile = path.join(packageRoot, 'package.json');
|
|
128
130
|
const manifest = readJson(manifestFile);
|
|
129
131
|
const ownerOf = (fileName) => consumers.filter(consumer => isInside(consumer.root, fileName))
|
|
@@ -41,10 +41,6 @@ export const HFS_RULE_IDS = [
|
|
|
41
41
|
'HFS_WORK_IN_FE',
|
|
42
42
|
];
|
|
43
43
|
|
|
44
|
-
const REQUIRED_COMMON = ['.gitattributes', '.github', '.gitignore', '.husky', 'hfs.json',
|
|
45
|
-
'eslint.config.mjs', 'package-lock.json', 'package.json', 'README.md',
|
|
46
|
-
'sonar-project.properties', 'tsconfig.json'];
|
|
47
|
-
const REQUIRED_BACKEND = ['.sops.yaml', '.starcistacks', '.starciwork', 'jest.config.js', 'nest-cli.json', 'src'];
|
|
48
44
|
const NON_NPM_ENTRIES = new Set(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
|
|
49
45
|
const RUNTIME_ROOT_MARKDOWN = new Set(['README.md', 'CONTEXT.md', 'CONTRIBUTING.md', 'CHANGELOG.md', 'THIRD_PARTY_NOTICES.md']);
|
|
50
46
|
const PRODUCT_ROOT_MARKDOWN = new Set(['README.md']);
|
|
@@ -57,18 +53,21 @@ const RETIRED_TEST_FOLDER = /^src\/tests\/(?:harness|live|e2e\/live)(?:\/|$)/u;
|
|
|
57
53
|
const EXTRA_TEST_CONFIG = /(?:^|\/)(?:jest[.-][^/]*(?:config\.[cm]?[jt]s|\.json)|jest-(?:e2e|int|integration|harness)[^/]*)$/u;
|
|
58
54
|
const NODE_ENTRIES_SKIPPED = new Set(['node_modules', '.git']);
|
|
59
55
|
|
|
60
|
-
/** The slot resolver of a
|
|
56
|
+
/** The slot resolver of a side folder: the caller's, else the side view its app's hfs.json declares, else the profile's slots with no app declared. */
|
|
61
57
|
function resolverOf(root, profile, given) {
|
|
62
58
|
if (given) return given;
|
|
63
59
|
try { return openHfs({ repoRoot: root }); } catch { /* an invalid hfs.json is HFS_DECLARATION_INVALID's finding, judged elsewhere */ }
|
|
64
|
-
return createSlotResolver(loadSlotManifest(), { profile, apps: [], optionalSlots: [], connections: [] });
|
|
60
|
+
return createSlotResolver(loadSlotManifest(), { profile, side: profile, apps: [], optionalSlots: [], connections: [], reads: [] });
|
|
65
61
|
}
|
|
66
62
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The first path segment of every slot of the scope that may exist: the root entries of a side (contracts/, docs/, src/ ...) or of
|
|
65
|
+
* the app (be/, fe/, .github/ ...). `scope` is the profile whose slots count (the app resolver answers for the sides too).
|
|
66
|
+
*/
|
|
67
|
+
function slotRootEntries(resolver, scope) {
|
|
68
|
+
const roots = new Set(scope === 'app' ? [] : ['apps']);
|
|
70
69
|
for (const slot of resolver.slots()) {
|
|
71
|
-
if (slot.presence === 'forbidden') continue;
|
|
70
|
+
if (slot.presence === 'forbidden' || !slot.profiles.includes(scope)) continue;
|
|
72
71
|
for (const variant of braceVariants(slot.path)) {
|
|
73
72
|
const first = variant.split('/')[0];
|
|
74
73
|
if (first && !/[*?<%]/u.test(first)) roots.add(first);
|
|
@@ -77,6 +76,11 @@ function slotRootEntries(resolver) {
|
|
|
77
76
|
return roots;
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
/** The root entries the scope's required slots name (the first segment of every required path of the scope): hfs.json, be/, .starcistacks/ ... */
|
|
80
|
+
function requiredRootEntries(resolver) {
|
|
81
|
+
return new Set(resolver.requiredPaths().paths.filter((entry) => !entry.side).map((entry) => entry.path.split('/')[0]));
|
|
82
|
+
}
|
|
83
|
+
|
|
80
84
|
/** The folders directly below src/tests/ that the be.tests.* slots declare (world, fixtures, integration, e2e, contract). */
|
|
81
85
|
function slotTestChildren(resolver) {
|
|
82
86
|
const children = new Set();
|
|
@@ -145,25 +149,28 @@ function fsFiles(root, relative = '') {
|
|
|
145
149
|
return out;
|
|
146
150
|
}
|
|
147
151
|
|
|
152
|
+
/** The tree view of a list of tracked paths (posix, root-relative): what the git view answers, for a list already in hand. */
|
|
153
|
+
export function trackedTreeView(tracked) {
|
|
154
|
+
const files = new Set(tracked);
|
|
155
|
+
return {
|
|
156
|
+
source: 'git',
|
|
157
|
+
top: [...new Set(tracked.map(file => file.split('/')[0]))],
|
|
158
|
+
children: dir => {
|
|
159
|
+
const prefix = `${dir}/`;
|
|
160
|
+
const names = new Set();
|
|
161
|
+
for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
|
|
162
|
+
return [...names];
|
|
163
|
+
},
|
|
164
|
+
hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
|
|
165
|
+
hasFile: file => files.has(file),
|
|
166
|
+
files: () => tracked,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
148
170
|
/** Uniform tree view: top entries plus children(dir)/hasDir/hasFile answers over posix relatives. */
|
|
149
171
|
function treeView(root) {
|
|
150
172
|
const tracked = gitPaths(root);
|
|
151
|
-
if (tracked !== null)
|
|
152
|
-
const files = new Set(tracked);
|
|
153
|
-
return {
|
|
154
|
-
source: 'git',
|
|
155
|
-
top: [...new Set(tracked.map(file => file.split('/')[0]))],
|
|
156
|
-
children: dir => {
|
|
157
|
-
const prefix = `${dir}/`;
|
|
158
|
-
const names = new Set();
|
|
159
|
-
for (const file of tracked) if (file.startsWith(prefix)) names.add(file.slice(prefix.length).split('/')[0]);
|
|
160
|
-
return [...names];
|
|
161
|
-
},
|
|
162
|
-
hasDir: dir => tracked.some(file => file.startsWith(`${dir}/`)),
|
|
163
|
-
hasFile: file => files.has(file),
|
|
164
|
-
files: () => tracked,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
173
|
+
if (tracked !== null) return trackedTreeView(tracked);
|
|
167
174
|
return {
|
|
168
175
|
source: 'fs',
|
|
169
176
|
top: fsChildren(root, '.'),
|
|
@@ -211,7 +218,7 @@ function scriptCommand(name) {
|
|
|
211
218
|
}
|
|
212
219
|
|
|
213
220
|
/** Presentation checks shared by the product HFS gate and this runtime's own standalone gate. */
|
|
214
|
-
export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = '
|
|
221
|
+
export function checkRepoPresentation({ root, runtime = false, tree = treeView(root), profile = 'app' }) {
|
|
215
222
|
const violations = [];
|
|
216
223
|
const finding = (ruleId, entry, message, line = 1) => violations.push({ ruleId, path: entry, line, column: 1, message });
|
|
217
224
|
for (const entry of tree.top) {
|
|
@@ -333,7 +340,11 @@ function hooksPathNotRedirected({ root, finding }) {
|
|
|
333
340
|
return { status: 'checked' };
|
|
334
341
|
}
|
|
335
342
|
|
|
336
|
-
|
|
343
|
+
/**
|
|
344
|
+
* The app root's automatic gates never run integration, e2e or contract: the husky hooks and the root scripts they call, the unit
|
|
345
|
+
* scripts over the be side's jest configuration (`jestConfig`, app-relative), and the workflows that start on push or pull_request.
|
|
346
|
+
*/
|
|
347
|
+
function e2eInAutomaticGate({ root, tree, jestConfig, finding }) {
|
|
337
348
|
const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
|
|
338
349
|
let pkg = null;
|
|
339
350
|
try { pkg = JSON.parse(readText(root, 'package.json') ?? ''); } catch { /* the package checks own invalid JSON */ }
|
|
@@ -358,18 +369,31 @@ function e2eInAutomaticGate({ root, tree, backend, finding }) {
|
|
|
358
369
|
if (runsE2e(command)) finding(rule, 'package.json', `Script ${name} is run by a husky hook and touches integration, e2e or contract. They are manual only.`);
|
|
359
370
|
for (const match of command.matchAll(/npm\s+run\s+([\w:.-]+)/gu)) pending.push(match[1]);
|
|
360
371
|
}
|
|
361
|
-
// 2. Unit and coverage scripts
|
|
362
|
-
if (
|
|
372
|
+
// 2. Unit and coverage scripts over the be side's jest configuration select the unit project only and exclude src/tests.
|
|
373
|
+
if (tree.hasFile(jestConfig)) {
|
|
363
374
|
for (const name of UNIT_RUN_SCRIPTS) {
|
|
364
375
|
const command = scripts[name];
|
|
365
376
|
if (typeof command === 'string' && /\bjest\b/u.test(command) && !/--selectProjects\s+unit\b/u.test(command))
|
|
366
377
|
finding(rule, 'package.json', `Script ${name} runs jest without --selectProjects unit and would run the integration, e2e or contract project.`);
|
|
367
378
|
}
|
|
368
|
-
const
|
|
369
|
-
if (/collectCoverageFrom/u.test(
|
|
370
|
-
finding(rule,
|
|
379
|
+
const jestText = readText(root, jestConfig) ?? '';
|
|
380
|
+
if (/collectCoverageFrom/u.test(jestText) && !/!src\/tests\/(?:\*\*|e2e)/u.test(jestText))
|
|
381
|
+
finding(rule, jestConfig, 'collectCoverageFrom must exclude src/tests/** so no integration, e2e or contract file counts toward coverage.');
|
|
382
|
+
}
|
|
383
|
+
// 3. A workflow that starts on push or pull_request never runs e2e.
|
|
384
|
+
for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
|
|
385
|
+
const text = readText(root, file);
|
|
386
|
+
if (text === null) continue;
|
|
387
|
+
const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
|
|
388
|
+
if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
|
|
389
|
+
if (runsE2e(withoutComments(text)))
|
|
390
|
+
finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
|
|
371
391
|
}
|
|
372
|
-
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The be side's default tsconfig excludes the world, integration, e2e and contract trees (they are checked by src/tests/tsconfig.json). */
|
|
395
|
+
function testTreesOutOfDefaultProgram({ root, tree, finding }) {
|
|
396
|
+
const rule = 'HFS_E2E_IN_AUTOMATIC_GATE';
|
|
373
397
|
const tsconfigText = readText(root, 'tsconfig.json');
|
|
374
398
|
let tsconfig = null;
|
|
375
399
|
try { tsconfig = tsconfigText === null ? null : JSON.parse(tsconfigText); } catch { /* the typecheck itself owns parsing */ }
|
|
@@ -378,19 +402,10 @@ function e2eInAutomaticGate({ root, tree, backend, finding }) {
|
|
|
378
402
|
const excludesTestTrees = ['world', 'integration', 'e2e', 'contract'].every(tree => excludedText.includes(`src/tests/${tree}`));
|
|
379
403
|
const defaultAll = tsconfig.include === undefined && tsconfig.files === undefined;
|
|
380
404
|
const files = tree.files();
|
|
381
|
-
if (
|
|
405
|
+
if (files.some(file => /^src\/tests\/(?:world|integration|e2e|contract)\/.+\.[cm]?tsx?$/u.test(file)) && !excludesTestTrees &&
|
|
382
406
|
(defaultAll || JSON.stringify(tsconfig.include ?? []).includes('src')))
|
|
383
407
|
finding(rule, 'tsconfig.json', 'The default tsconfig includes src/tests/{world,integration,e2e,contract}/**. Exclude those trees and check them with src/tests/tsconfig.json (typecheck:tests).');
|
|
384
408
|
}
|
|
385
|
-
// 4. A workflow that starts on push or pull_request never runs e2e.
|
|
386
|
-
for (const file of tree.files().filter(entry => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(entry))) {
|
|
387
|
-
const text = readText(root, file);
|
|
388
|
-
if (text === null) continue;
|
|
389
|
-
const trigger = /^on:.*(?:\n(?:[ \t]+.*|)$)*/mu.exec(text)?.[0] ?? '';
|
|
390
|
-
if (!/\b(?:push|pull_request)\b/u.test(trigger)) continue;
|
|
391
|
-
if (runsE2e(withoutComments(text)))
|
|
392
|
-
finding(rule, file, `${file} runs e2e on push or pull_request. Move the e2e job to its own workflow with on: workflow_dispatch only.`);
|
|
393
|
-
}
|
|
394
409
|
}
|
|
395
410
|
|
|
396
411
|
export function checkHfs(config) {
|
|
@@ -401,29 +416,27 @@ export function checkHfs(config) {
|
|
|
401
416
|
const tree = treeView(config.root);
|
|
402
417
|
const violations = [];
|
|
403
418
|
const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
|
|
404
|
-
const
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const allowed = slotRootEntries(resolver);
|
|
419
|
+
const profile = backend ? 'be' : 'fe';
|
|
420
|
+
const resolver = resolverOf(config.root, profile, config.hfs);
|
|
421
|
+
// The README, the hooks, the workflows and the scripts are the app root's (checkAppRoot); a side folder is judged as the old
|
|
422
|
+
// repository root it stands for, less those.
|
|
423
|
+
const allowed = slotRootEntries(resolver, profile);
|
|
409
424
|
for (const entry of [...tree.top].sort()) {
|
|
410
425
|
if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
|
|
411
426
|
if (frontend && !backend) {
|
|
412
|
-
if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, '
|
|
413
|
-
if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, '
|
|
414
|
-
if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, '
|
|
427
|
+
if (entry === '.starciwork') { finding('HFS_WORK_IN_FE', entry, 'The fe side must not hold a .starciwork tree; Work records live in the app root .starciwork.'); continue; }
|
|
428
|
+
if (entry === '.starcistacks') { finding('HFS_STACKS_IN_FE', entry, 'The fe side must not hold .starcistacks; stack declarations live in be/.starcistacks.'); continue; }
|
|
429
|
+
if (entry === 'src') { finding('HFS_ROOT_SRC_FORBIDDEN_FE', entry, 'The fe side keeps source only under apps/<app>/src; the fe/src/ tree must move.'); continue; }
|
|
415
430
|
}
|
|
416
431
|
if (frontend && !backend && (isFeTestPath(entry) || isFeTestPath(`${entry}/x`))) continue; // a test entry of a front end is FE_NO_TESTS's, the one finding of that path
|
|
417
432
|
if (!allowed.has(entry)) {
|
|
418
|
-
finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the
|
|
433
|
+
finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} of the ${profile} side is not in the slots of the ${profile} side.`);
|
|
419
434
|
}
|
|
420
435
|
}
|
|
421
436
|
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (entry === 'apps' || entry === 'README.md' || entry === '.gitattributes') continue;
|
|
426
|
-
if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${backend ? 'backend' : 'frontend'} HFS tree requires root entry ${entry}.`);
|
|
437
|
+
for (const entry of [...requiredRootEntries(resolver)].sort()) {
|
|
438
|
+
if (entry === 'apps') continue;
|
|
439
|
+
if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The ${profile} side requires root entry ${entry}.`);
|
|
427
440
|
}
|
|
428
441
|
|
|
429
442
|
const apps = tree.children('apps').sort();
|
|
@@ -444,8 +457,9 @@ export function checkHfs(config) {
|
|
|
444
457
|
}
|
|
445
458
|
// next-env.d.ts is generated by Next and commonly ignored by Git; it is not
|
|
446
459
|
// a reliable tracked-tree input.
|
|
447
|
-
|
|
448
|
-
|
|
460
|
+
// A front-end app holds the files its slot requires at its own root (next.config.ts, tsconfig.json, ...; it has no package.json).
|
|
461
|
+
if (frontend) for (const required of resolver.requiredFiles(`apps/${app}/next.config.ts`).filter(entry => !entry.slice(`apps/${app}/`.length).includes('/'))) {
|
|
462
|
+
if (!tree.hasFile(required)) missing.push(required.slice(`apps/${app}/`.length));
|
|
449
463
|
}
|
|
450
464
|
if (missing.length) finding('HFS_APP_LAYOUT_INVALID', `apps/${app}`, `Application apps/${app} lacks ${missing.join(', ')} required by the HFS app layout.`);
|
|
451
465
|
}
|
|
@@ -481,15 +495,13 @@ export function checkHfs(config) {
|
|
|
481
495
|
finding('HFS_TEST_KIND_RETIRED', file, `${file} is a per-lane test config. One root jest.config.js declares exactly the unit, integration, e2e and contract projects.`);
|
|
482
496
|
}
|
|
483
497
|
|
|
484
|
-
|
|
485
|
-
const hooksPath = hooksPathNotRedirected({ root: config.root, finding });
|
|
498
|
+
if (backend) testTreesOutOfDefaultProgram({ root: config.root, tree, finding });
|
|
486
499
|
|
|
487
500
|
return {
|
|
488
501
|
violations,
|
|
489
502
|
coverage: {
|
|
490
503
|
status: 'checked',
|
|
491
504
|
source: tree.source,
|
|
492
|
-
hooksPath,
|
|
493
505
|
rootEntries: tree.top.length,
|
|
494
506
|
apps,
|
|
495
507
|
ruleIds: [...HFS_RULE_IDS],
|
|
@@ -497,6 +509,30 @@ export function checkHfs(config) {
|
|
|
497
509
|
};
|
|
498
510
|
}
|
|
499
511
|
|
|
512
|
+
/**
|
|
513
|
+
* The HFS tree check of the app root (`resolver` is the app's): the README, the root entries the app-root slots allow and require,
|
|
514
|
+
* the automatic gates (hooks, the root scripts they call, the workflows) and the hooks path. `hfs check` runs it once per app; the
|
|
515
|
+
* machine runs checkHfs once per side folder.
|
|
516
|
+
*/
|
|
517
|
+
export function checkAppRoot({ root, resolver, tree = treeView(root) }) {
|
|
518
|
+
const violations = [];
|
|
519
|
+
const finding = (ruleId, entry, message) => violations.push({ ruleId, path: entry, line: 1, column: 1, message });
|
|
520
|
+
violations.push(...checkRepoPresentation({ root, tree, profile: 'app' }).violations);
|
|
521
|
+
const allowed = slotRootEntries(resolver, 'app');
|
|
522
|
+
for (const entry of [...tree.top].sort()) {
|
|
523
|
+
if (NON_NPM_ENTRIES.has(entry) || /\.md$/iu.test(entry)) continue;
|
|
524
|
+
if (!allowed.has(entry)) finding('HFS_ROOT_ENTRY_FORBIDDEN', entry, `Root entry ${entry} is not in the slots of the app root.`);
|
|
525
|
+
}
|
|
526
|
+
for (const entry of [...requiredRootEntries(resolver)].sort()) {
|
|
527
|
+
if (entry === 'README.md' || entry === '.gitattributes') continue; // checkRepoPresentation reports these two
|
|
528
|
+
if (!tree.top.includes(entry)) finding('HFS_ROOT_ENTRY_MISSING', entry, `The app root requires root entry ${entry}.`);
|
|
529
|
+
}
|
|
530
|
+
const jestConfig = resolver.sides ? `be/${[...braceVariants(resolver.slot('be.tool-config').path)].find(file => file.startsWith('jest.config.'))}` : null;
|
|
531
|
+
e2eInAutomaticGate({ root, tree, jestConfig, finding });
|
|
532
|
+
const hooksPath = hooksPathNotRedirected({ root, finding });
|
|
533
|
+
return { violations, coverage: { status: 'checked', source: tree.source, hooksPath, rootEntries: tree.top.length } };
|
|
534
|
+
}
|
|
535
|
+
|
|
500
536
|
/** Keep tree findings visible even when the TypeScript architecture config is invalid. */
|
|
501
537
|
export function checkHfsWithoutConfig(repositoryRoot) {
|
|
502
538
|
let root;
|
|
@@ -505,9 +541,11 @@ export function checkHfsWithoutConfig(repositoryRoot) {
|
|
|
505
541
|
}
|
|
506
542
|
let kinds = [];
|
|
507
543
|
try {
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
|
|
544
|
+
const opened = openHfs({ repoRoot: root });
|
|
545
|
+
// The app root is judged by checkAppRoot; a side folder by its side's tree check.
|
|
546
|
+
if (opened.repo.profile === 'app') return checkAppRoot({ root, resolver: opened });
|
|
547
|
+
if (opened.repo.profile === 'be') kinds = ['backend'];
|
|
548
|
+
else if (opened.repo.profile === 'fe') kinds = ['frontend'];
|
|
511
549
|
} catch { /* A malformed hfs.json remains an HFS_DECLARATION_INVALID error. */ }
|
|
512
550
|
if (!kinds.length) {
|
|
513
551
|
try {
|
|
@@ -37,7 +37,8 @@ function relativeSource(repository, value, label) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function parseContract(config) {
|
|
40
|
-
|
|
40
|
+
// The contract is declared in the app's one package.json (the app root; config.root is the fe side folder).
|
|
41
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(config.packageRoot ?? config.root, 'package.json'), 'utf8'));
|
|
41
42
|
const next = manifest?.starci?.codePatterns?.next;
|
|
42
43
|
const value = next?.dataLifecycle;
|
|
43
44
|
if (value === undefined) return null;
|
|
@@ -711,7 +712,7 @@ export function checkFrontendDataLifecycle(config, context) {
|
|
|
711
712
|
details: ['package.json#starci.codePatterns.next.dataLifecycle is required when production source selects SWR', ...references.unsupported] } };
|
|
712
713
|
reasons.push(...references.unsupported);
|
|
713
714
|
let installed;
|
|
714
|
-
try { installed = installedSWR(config.root); } catch (error) {
|
|
715
|
+
try { installed = installedSWR(config.packageRoot ?? config.root); } catch (error) {
|
|
715
716
|
return { violations, coverage: { status: 'unavailable', ruleIds, details: [String(error.message ?? error)] } };
|
|
716
717
|
}
|
|
717
718
|
if (installed.major !== contract.swr.major) reasons.push(`installed swr ${installed.version} does not match declared major ${contract.swr.major}`);
|
|
@@ -115,7 +115,7 @@ function frameworkTargets(config, context, checker, localFiles) {
|
|
|
115
115
|
|
|
116
116
|
function classToken(ts, checker, node, localFiles) {
|
|
117
117
|
const symbol = valueSymbol(ts, checker, node);
|
|
118
|
-
const declarations = symbol?.getDeclarations?.().filter(declaration => ts.isClassDeclaration(declaration)) ?? [];
|
|
118
|
+
const declarations = (symbol?.getDeclarations?.() ?? []).filter(declaration => ts.isClassDeclaration(declaration)) ?? [];
|
|
119
119
|
const local = declarations.filter(declaration => localFiles.has(canonical(declaration.getSourceFile().fileName)));
|
|
120
120
|
const keys = [...new Set(local.map(declaration => `${canonical(declaration.getSourceFile().fileName)}#${declaration.getStart(declaration.getSourceFile())}`))];
|
|
121
121
|
return { key: keys.length === 1 ? keys[0] : null, external: declarations.length > 0 && local.length === 0 };
|