@starci/hfs 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +25 -21
- package/bin/hfs.mjs +59 -37
- package/lint/run.mjs +70 -39
- package/package.json +2 -2
- package/runtime/engine/admission.mjs +3 -3
- package/runtime/engine/ledger-db.mjs +2 -2
- package/runtime/engine/machine-db.mjs +90 -9
- package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +13 -0
- package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +93 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/peer-integrations.yaml +18 -0
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/modules/kernel/failure-codes.yaml +23 -32
- package/runtime/scripts/checks/architecture/backend.mjs +1 -1
- 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-check.mjs +156 -141
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-rules/contract.mjs +15 -42
- package/runtime/scripts/lib/hfs-rules/frontend.mjs +37 -36
- package/runtime/scripts/lib/hfs-rules/peer-integrations.mjs +44 -0
- package/runtime/scripts/lib/hfs-rules/proof-commands.mjs +11 -8
- 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/safe-remove.mjs +95 -10
- package/scaffold/app.mjs +179 -0
- package/scaffold/service.mjs +26 -16
- package/sync/cli.mjs +1 -1
- package/sync/hygiene.mjs +11 -8
- package/sync/index.mjs +109 -111
- package/sync/managed.mjs +9 -8
- package/sync/sonar-key.mjs +20 -22
- package/templates/{be → app}/ci-workflows/github/workflows/ci.yml +4 -2
- package/templates/app/gitignore +6 -0
- package/templates/app/hooks/husky/pre-commit +25 -0
- package/templates/app/hooks/husky/pre-push +7 -0
- package/templates/app/package-scripts/package.json +22 -0
- package/templates/{be → app}/quality-config/sonar-project.properties +3 -2
- package/templates/app/skeleton/.editorconfig +15 -0
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton/.nvmrc +1 -0
- package/templates/app/skeleton/.starciwork/features/index.yaml +7 -0
- package/templates/app/skeleton/.starciwork/workspace.yaml +9 -0
- package/templates/app/skeleton/README.md +36 -0
- package/templates/app/skeleton/scripts/codegen.mjs +4 -0
- package/templates/{fe → app}/tool-config/prettierignore +4 -1
- package/templates/be/skeleton/.sops.yaml +2 -0
- package/templates/be/skeleton/.starcistacks/application-stacks.yaml +10 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +3 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +27 -6
- package/templates/be/skeleton/apps/__app__/src/main.ts +4 -1
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/admission.policy.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/auth.guard.ts +25 -0
- package/templates/be/skeleton/src/modules/domain/identity/errors/identity.error.ts +16 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.contracts.ts +11 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.decorators.ts +8 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module-definition.ts +7 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.module.ts +14 -0
- package/templates/be/skeleton/src/modules/domain/identity/identity.options.ts +2 -0
- package/templates/be/skeleton/src/modules/domain/identity/index.ts +6 -0
- package/templates/be/skeleton/src/modules/domain/identity/messages/identity.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/composition/composition.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/composition/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +71 -23
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +14 -16
- package/templates/be/skeleton/src/modules/platform/config/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +2 -12
- package/templates/be/skeleton/src/modules/platform/errors/domain.error.ts +16 -7
- package/templates/be/skeleton/src/modules/platform/errors/errors/errors.error.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.contracts.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.decorators.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +32 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.log-events.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/errors/errors.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.module.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.spec.ts +94 -0
- package/templates/be/skeleton/src/modules/platform/errors/errors.service.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/errors/http-status.policy.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +4 -1
- package/templates/be/skeleton/src/modules/platform/errors/messages/errors.messages.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/http-security/errors/http-security.error.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.config.ts +16 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.module.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.options.ts +17 -0
- package/templates/be/skeleton/src/modules/platform/http-security/index.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/http-security/messages/http-security.messages.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +31 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +68 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.spec.ts +66 -0
- package/templates/be/skeleton/src/modules/platform/i18n/bundle-message-catalog.service.ts +30 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.contracts.ts +18 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.decorators.ts +23 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module-definition.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.module.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.options.ts +7 -0
- package/templates/be/skeleton/src/modules/platform/i18n/i18n.port.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/i18n/index.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.spec.ts +45 -0
- package/templates/be/skeleton/src/modules/platform/i18n/request-locale.service.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.spec.ts +85 -68
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.service.ts +14 -10
- package/templates/be/skeleton/src/modules/platform/logging/logging.decorators.ts +13 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.log-events.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.port.ts +0 -1
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.contracts.ts +25 -0
- package/templates/be/skeleton/src/modules/platform/primitives/outcome.mapper.ts +24 -0
- package/templates/fe/skeleton/apps/__app__/postcss.config.mjs +7 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +5 -17
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +11 -22
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/loading.tsx +6 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +3 -12
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +6 -24
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +4 -18
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +5 -0
- package/templates/fe/skeleton/apps/__app__/src/components/composites/FailureScreen/index.tsx +28 -0
- package/templates/fe/skeleton/apps/__app__/src/features/layouts/LocaleShell/index.tsx +35 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +21 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +17 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +26 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +25 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +32 -0
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/config/index.ts +12 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/index.ts +2 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +6 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/request.ts +1 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/routes/index.ts +4 -0
- package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/proxy.ts +1 -1
- package/sync/skeleton.mjs +0 -76
- package/templates/be/gitignore +0 -2
- package/templates/be/hooks/husky/pre-commit +0 -13
- package/templates/be/hooks/husky/pre-push +0 -6
- package/templates/be/package-scripts/package.json +0 -19
- package/templates/be/skeleton/scripts/.gitkeep +0 -0
- package/templates/be/skeleton/src/modules/platform/config/config.decorators.ts +0 -9
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +0 -20
- package/templates/be/tool-config/prettierignore +0 -8
- package/templates/fe/ci-workflows/github/workflows/ci.yml +0 -40
- package/templates/fe/gitignore +0 -3
- package/templates/fe/hooks/husky/pre-commit +0 -16
- package/templates/fe/hooks/husky/pre-push +0 -5
- package/templates/fe/package-scripts/package.json +0 -13
- package/templates/fe/parts/api-client.ts +0 -44
- package/templates/fe/parts/api-outcome.ts +0 -7
- package/templates/fe/quality-config/sonar-project.properties +0 -8
- package/templates/fe/skeleton/scripts/.gitkeep +0 -0
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/client.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/index.ts +0 -3
- package/templates/fe/skeleton-app/apps/__app__/src/modules/api/outcome.ts +0 -1
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/index.ts +0 -4
- package/templates/fe/skeleton-app/apps/__app__/src/modules/i18n/navigation.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/next.config.ts +0 -12
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/api/index.ts +0 -2
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/index.ts +0 -9
- package/templates/fe/skeleton-shared/apps/__app__/src/modules/i18n/request.ts +0 -5
- package/templates/fe/skeleton-shared/apps/__app__/src/proxy.ts +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-api/package.json +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-api/src/client.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/src/index.ts +0 -3
- package/templates/fe/skeleton-shared/packages/__family__-api/src/outcome.ts +0 -1
- package/templates/fe/skeleton-shared/packages/__family__-api/tsconfig.json +0 -5
- package/templates/fe/skeleton-shared/packages/__family__-i18n/package.json +0 -18
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/app.ts +0 -19
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/index.ts +0 -2
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/proxy.ts +0 -12
- package/templates/fe/skeleton-shared/packages/__family__-i18n/src/request.ts +0 -15
- package/templates/fe/skeleton-shared/packages/__family__-i18n/tsconfig.json +0 -5
- package/templates/fe/tool-config/prettierrc +0 -1
- /package/templates/{be → app}/ci-workflows/github/workflows/e2e.yml +0 -0
- /package/templates/{be → app}/starciwork.gitignore +0 -0
- /package/templates/{be → app}/tool-config/prettierrc +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/next.config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/config.ts +0 -0
- /package/templates/fe/{skeleton-app → skeleton}/apps/__app__/src/modules/i18n/routing.ts +0 -0
|
@@ -5,6 +5,7 @@ import { canonical, isInside, slash } from './config.mjs';
|
|
|
5
5
|
import { sameOrUnder } from '../../lib/path-key.mjs';
|
|
6
6
|
import { createTypeScriptProgram, readTypeScriptProject, resolveTypeScriptModule, sharedInProgramRun, typeScriptProjectReferencePath } from '../typescript-programs.mjs';
|
|
7
7
|
import { readJsonFile } from '../../lib/json.mjs';
|
|
8
|
+
import { locateDeclaration } from '../../lib/hfs-slots.mjs';
|
|
8
9
|
|
|
9
10
|
const CODE_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/i;
|
|
10
11
|
const TEST_FILE = /(?:^|[.-])(?:spec|test)\.[cm]?[jt]sx?$/i;
|
|
@@ -16,14 +17,14 @@ export const GENERATED_SEGMENTS = new Set(['.next', '.turbo', '.vercel', '.outpu
|
|
|
16
17
|
export function isGeneratedPath(root, fileName) {
|
|
17
18
|
return slash(path.relative(root, fileName)).split('/').slice(0, -1).some(segment => GENERATED_SEGMENTS.has(segment));
|
|
18
19
|
}
|
|
19
|
-
// A `<tool>.config.*` or `<tool>.setup.*` module beside a package manifest
|
|
20
|
-
// postcss.config.mjs
|
|
21
|
-
// (src/config/database.config.ts) has
|
|
22
|
-
// reads tooling modules (a profile may declare one as source);
|
|
23
|
-
//
|
|
20
|
+
// A `<tool>.config.*` or `<tool>.setup.*` module at the root of a project (beside a package manifest or a tsconfig.json:
|
|
21
|
+
// next.config.ts and postcss.config.mjs of an app, which has no package.json of its own) is build tooling a broad `**/*.ts`
|
|
22
|
+
// include pulls in; a `*.config.ts` inside a source tree (src/config/database.config.ts) has neither beside it and stays source.
|
|
23
|
+
// The architecture program still reads tooling modules (a profile may declare one as source); the lint (hfs lint) judges one only
|
|
24
|
+
// through the repository's own eslint.config.
|
|
24
25
|
const TOOLING_MODULE = /^[^/]+\.(?:config|setup)\.[cm]?[jt]sx?$/i;
|
|
25
26
|
export function isToolingModule(fileName) {
|
|
26
|
-
return TOOLING_MODULE.test(path.basename(fileName)) && fs.existsSync(path.join(path.dirname(fileName),
|
|
27
|
+
return TOOLING_MODULE.test(path.basename(fileName)) && ['package.json', 'tsconfig.json'].some((manifest) => fs.existsSync(path.join(path.dirname(fileName), manifest)));
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function diagnosticMessage(ts, diagnostic) {
|
|
@@ -48,9 +49,12 @@ function compilerError(ts, root, diagnostic, project, ruleId = 'ARCH_TSCONFIG_IN
|
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Load TypeScript through the target package boundary, never through StarCi's own dependency graph. A side folder of an app has no
|
|
54
|
+
* package.json of its own: the app root's one manifest is the boundary its TypeScript is installed under.
|
|
55
|
+
*/
|
|
52
56
|
export function loadTargetTypeScript(repositoryRoot) {
|
|
53
|
-
const packageFile = path.join(repositoryRoot, 'package.json');
|
|
57
|
+
const packageFile = path.join(locateDeclaration(repositoryRoot).appRoot, 'package.json');
|
|
54
58
|
if (!fs.existsSync(packageFile)) throw Error('ARCH_TYPESCRIPT_MISSING: target package.json is required to resolve target-installed TypeScript.');
|
|
55
59
|
const targetRequire = createRequire(packageFile);
|
|
56
60
|
let resolved;
|
|
@@ -189,7 +193,8 @@ function workspaceMetadata(config) {
|
|
|
189
193
|
|| backendAppRoots.some(appRoot => isInside(appRoot, root) || isInside(root, appRoot)) };
|
|
190
194
|
};
|
|
191
195
|
const roots = config.workspaces.map(create);
|
|
192
|
-
|
|
196
|
+
// A side of an app has no package.json: the app root's one manifest names the package the side's root files belong to.
|
|
197
|
+
const rootPackage = readJson(path.join(config.packageRoot ?? config.root, 'package.json')) ?? {};
|
|
193
198
|
roots.push({ root: canonical(config.root), relative: '.', name: typeof rootPackage.name === 'string' ? rootPackage.name : null, exports: rootPackage.exports,
|
|
194
199
|
app: config.frontend.routes.some(item => isInside(config.root, path.join(config.root, ...item.split('/')))) || config.kinds.includes('backend') });
|
|
195
200
|
return roots.sort((a, b) => b.root.length - a.root.length);
|
|
@@ -415,20 +420,29 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
415
420
|
.map(item => compilerError(ts, config.root, item, relative, 'ARCH_SYNTAX_INVALID')));
|
|
416
421
|
projects.push({ relative, program, options: parsed.options });
|
|
417
422
|
}
|
|
418
|
-
const fileMap = new Map();
|
|
419
|
-
const checkerByFile = new Map();
|
|
420
423
|
const occurrences = new Map();
|
|
424
|
+
const sourceIn = new Map();
|
|
421
425
|
for (const project of projects) {
|
|
422
426
|
for (const sourceFile of project.program.getSourceFiles().filter(file => isProductionSource(config.root, file))) {
|
|
423
427
|
const name = canonical(sourceFile.fileName);
|
|
424
|
-
if (!fileMap.has(name)) {
|
|
425
|
-
fileMap.set(name, sourceFile);
|
|
426
|
-
checkerByFile.set(name, project.program.getTypeChecker());
|
|
427
|
-
}
|
|
428
428
|
if (!occurrences.has(name)) occurrences.set(name, []);
|
|
429
429
|
occurrences.get(name).push(project);
|
|
430
|
+
sourceIn.set(`${project.relative}|${name}`, sourceFile);
|
|
430
431
|
}
|
|
431
432
|
}
|
|
433
|
+
// The deepest project directory holding a file owns it: its source file, its checker and its resolution. A side or repository
|
|
434
|
+
// root tsconfig without paths must not shadow the app project whose aliases (`@/*`) the file actually uses.
|
|
435
|
+
const owningProject = (candidates, file) => candidates.filter(item => isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), file))
|
|
436
|
+
.sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
|
|
437
|
+
const fileMap = new Map();
|
|
438
|
+
const checkerByFile = new Map();
|
|
439
|
+
const ownerByFile = new Map();
|
|
440
|
+
for (const [name, candidates] of occurrences) {
|
|
441
|
+
const owner = owningProject(candidates, name);
|
|
442
|
+
ownerByFile.set(name, owner);
|
|
443
|
+
fileMap.set(name, sourceIn.get(`${owner.relative}|${name}`));
|
|
444
|
+
checkerByFile.set(name, owner.program.getTypeChecker());
|
|
445
|
+
}
|
|
432
446
|
const files = [...fileMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, file]) => file);
|
|
433
447
|
const edges = new Map([...fileMap.keys()].map(file => [file, []]));
|
|
434
448
|
for (const [file, sourceFile] of fileMap) {
|
|
@@ -441,11 +455,7 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
441
455
|
const workspaceNames = new Set(workspaces.map(item => item.name).filter(Boolean));
|
|
442
456
|
for (const [from, sourceFile] of fileMap) {
|
|
443
457
|
const owningWorkspace = workspaceOf(workspaces, from);
|
|
444
|
-
const
|
|
445
|
-
// The deepest project directory holding the file owns its resolution: a root tsconfig without paths must not
|
|
446
|
-
// shadow the app project whose aliases the file actually uses.
|
|
447
|
-
const project = candidates.filter(item => isInside(path.dirname(path.join(config.root, ...item.relative.split('/'))), from))
|
|
448
|
-
.sort((x, y) => y.relative.split('/').length - x.relative.split('/').length)[0] ?? candidates[0];
|
|
458
|
+
const project = ownerByFile.get(from);
|
|
449
459
|
if (!project) continue;
|
|
450
460
|
const references = moduleReferences(ts, sourceFile, project.program.getTypeChecker());
|
|
451
461
|
for (const item of references.unproven) errors.push({
|
|
@@ -485,6 +495,21 @@ function typeScriptContext(config, loaded, paths) {
|
|
|
485
495
|
// project is then the boundary it always was.
|
|
486
496
|
const reviewable = isInside(config.root, actualTarget)
|
|
487
497
|
|| (config.repository && isInside(config.repository, actualTarget) && !slash(actualTarget).includes('/node_modules/'));
|
|
498
|
+
// A side of an app (config.root is be/ or fe/) imports nothing of the app outside itself: the root holds no source, the other
|
|
499
|
+
// side is another program, and a declared read (sides.fe.reads, be/contracts/) is codegen input, never an import.
|
|
500
|
+
const crossesSide = config.packageRoot !== undefined && config.packageRoot !== config.root
|
|
501
|
+
&& isInside(config.packageRoot, actualTarget) && !isInside(config.root, actualTarget) && !slash(actualTarget).includes('/node_modules/');
|
|
502
|
+
if (internal && crossesSide) {
|
|
503
|
+
errors.push({
|
|
504
|
+
ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
|
|
505
|
+
project: project.relative,
|
|
506
|
+
path: relativePath(config.root, sourceFile.fileName),
|
|
507
|
+
...sourceLocation(sourceFile, reference.node),
|
|
508
|
+
specifier: reference.specifier,
|
|
509
|
+
message: `Internal import ${reference.specifier} leaves the ${path.basename(config.root)} side for ${slash(path.relative(config.packageRoot, actualTarget))}; nothing crosses the sides of an app.`,
|
|
510
|
+
});
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
488
513
|
if (internal && !reviewable) {
|
|
489
514
|
errors.push({
|
|
490
515
|
ruleId: 'ARCH_INTERNAL_IMPORT_OUTSIDE',
|
|
@@ -4,8 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
// One TypeScript program per (compiler, root names, compiler options, project references) inside a program run, and
|
|
6
6
|
// one value per (kind, compiler, input) for what callers derive from those programs (the architecture context).
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// A caller (canon-scan's architecture machine, a script checker) opens one run around its work and releases it after,
|
|
8
|
+
// so nothing built here outlives the run that built it. Outside a run every call
|
|
9
9
|
// builds afresh.
|
|
10
10
|
const runs = new AsyncLocalStorage();
|
|
11
11
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// hfs-check.mjs - the HFS
|
|
2
|
-
// runtime check. It reads three things and nothing else: the
|
|
1
|
+
// hfs-check.mjs - the HFS app check, behind `hfs check | explain` (packages/hfs) and reusable by any
|
|
2
|
+
// runtime check. It reads three things and nothing else: the app's hfs.json, the slot manifest
|
|
3
3
|
// (knowledge/hfs/slots.yaml through scripts/lib/hfs-slots.mjs) and the pins (knowledge/hfs/canon-pins.yaml). It never
|
|
4
4
|
// writes to the repository it inspects.
|
|
5
5
|
//
|
|
6
|
-
// checkRepo() answers, for the tracked paths of one
|
|
6
|
+
// checkRepo() answers, for the tracked paths of one app (git ls-files), per scope (the app root and each side folder):
|
|
7
7
|
// HFS_SLOT_UNDECLARED a tracked path no slot owns (the nearest slot is named)
|
|
8
8
|
// HFS_SLOT_NOT_ENABLED a tracked path in an opt-in slot the repository did not declare
|
|
9
9
|
// HFS_SLOT_AMBIGUOUS two slots of equal specificity own the path (a manifest gap, reported not guessed)
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// HFS_REPO_LOCAL_CHECK (R103, hfs-rules/repo-local-checks.mjs) a local eslint rule or plugin, a `check-*` script, a relative import in eslint.config
|
|
27
27
|
// HFS_LINT_SUPPRESSION_FILE (R104, hfs-rules/lint-suppression.mjs) an eslint suppressions file, script or option
|
|
28
28
|
// HFS_PROOF_COMMAND_FILE_MISSING (R105, hfs-rules/proof-commands.mjs) a .starciwork proof command that runs a file the repository does not hold
|
|
29
|
+
// HFS_PEER_INTEGRATION_MISSING (R111, hfs-rules/peer-integrations.mjs) the app root package.json lacks the runtime peer a driver integration needs
|
|
29
30
|
// FE_WIRE_GENERATED, FE_I18N_PLACEMENT, FE_I18N_CATALOG (R52, R59, R60, hfs-rules/frontend.mjs) the front-end tree of each app
|
|
30
31
|
// HFS_GITIGNORE_BLOCK_DRIFT, HFS_SONAR_CONFIG (R04, R11) produced by packages/hfs/sync/managed.mjs, which renders the templates
|
|
31
32
|
// HFS_FORMAT (R19) produced by packages/hfs/sync/format.mjs, which runs the repository's own prettier
|
|
@@ -45,16 +46,16 @@ import path from 'node:path';
|
|
|
45
46
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
46
47
|
import { ARCHITECTURE_RULE_IDS, checkArchitecture } from '../checks/architecture/index.mjs';
|
|
47
48
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
48
|
-
import { HFS_DECLARATION_FILE, HfsSlotsError, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
|
|
49
|
+
import { APP_SCOPE, HFS_DECLARATION_FILE, appRelativeMessages, HfsSlotsError, SIDES, createSlotResolver, loadSlotManifest, readRepoDeclaration, resolveRepoDeclaration } from './hfs-slots.mjs';
|
|
49
50
|
import { allowsFile } from './hfs-allows.mjs';
|
|
50
|
-
import { isDir } from './fs-kind.mjs';
|
|
51
51
|
import { gitOutput } from './git.mjs';
|
|
52
52
|
import { posixPath } from './path-key.mjs';
|
|
53
53
|
import { readTree, treeFacts, untrackedEntries } from './hfs-tree.mjs';
|
|
54
54
|
import { contractFindings } from './hfs-rules/contract.mjs';
|
|
55
55
|
import { depFindings } from './hfs-rules/deps.mjs';
|
|
56
|
-
import { frontendFindings } from './hfs-rules/frontend.mjs';
|
|
56
|
+
import { appFrontendFindings, frontendFindings } from './hfs-rules/frontend.mjs';
|
|
57
57
|
import { lintSuppressionFindings } from './hfs-rules/lint-suppression.mjs';
|
|
58
|
+
import { peerIntegrationFindings } from './hfs-rules/peer-integrations.mjs';
|
|
58
59
|
import { pipelineFindings } from './hfs-rules/pipeline.mjs';
|
|
59
60
|
import { proofCommandFindings } from './hfs-rules/proof-commands.mjs';
|
|
60
61
|
import { repoLocalCheckFindings } from './hfs-rules/repo-local-checks.mjs';
|
|
@@ -62,6 +63,7 @@ import { readJson } from './hfs-rules/read.mjs';
|
|
|
62
63
|
import { secretFindings } from './hfs-rules/secrets.mjs';
|
|
63
64
|
import { pathFindings } from './hfs-path-findings.mjs';
|
|
64
65
|
import { onLintSurface } from '../checks/architecture/surface.mjs';
|
|
66
|
+
import { checkAppRoot, trackedTreeView } from '../checks/architecture/hfs.mjs';
|
|
65
67
|
import { stacksFindings } from './hfs-rules/stacks.mjs';
|
|
66
68
|
import { testTopologyFindings } from './hfs-rules/test-topology.mjs';
|
|
67
69
|
import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
|
|
@@ -69,11 +71,11 @@ import { feNoTestsFindings, isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
|
|
|
69
71
|
export const CANON_PINS_FILE = 'knowledge/hfs/canon-pins.yaml';
|
|
70
72
|
export const FAILURE_CODES_FILE = 'modules/kernel/failure-codes.yaml';
|
|
71
73
|
/**
|
|
72
|
-
* The codes `hfs check`
|
|
73
|
-
*
|
|
74
|
+
* The codes `hfs check` reports when it cannot judge (an unreadable repository, a refused declaration or manifest, a missing
|
|
75
|
+
* formatter): infrastructure refusals, never obligations, so no rule of knowledge/hfs/rules.yaml owns them.
|
|
74
76
|
*/
|
|
75
77
|
export const REFUSAL_CODES = Object.freeze([
|
|
76
|
-
'
|
|
78
|
+
'HFS_REPO_UNREADABLE',
|
|
77
79
|
'HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH', 'HFS_MANIFEST_INVALID', 'HFS_FORMAT_TOOL_MISSING',
|
|
78
80
|
]);
|
|
79
81
|
/** The codes this module emits that are not the slot loader's own: the why bundle of packages/hfs ships exactly these plus the loader's. */
|
|
@@ -82,7 +84,7 @@ export const CHECK_CODES = Object.freeze([
|
|
|
82
84
|
'HFS_SLOT_REQUIRED_MISSING', 'HFS_MIN_INSTANCES', 'HFS_CANON_PIN_DRIFT', 'HFS_SIZE_SOFT_BACKLOG', 'BE_SOURCE_FORM',
|
|
83
85
|
'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
|
|
84
86
|
'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
|
|
85
|
-
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
|
|
87
|
+
'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'FE_NO_TESTS', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG',
|
|
86
88
|
'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_FORMAT',
|
|
87
89
|
'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
|
|
88
90
|
...REFUSAL_CODES,
|
|
@@ -137,7 +139,8 @@ function pinFindings({ repoRoot, files, profile, pins, only }) {
|
|
|
137
139
|
let pkg;
|
|
138
140
|
try { pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, file), 'utf8')); } catch { continue; }
|
|
139
141
|
for (const [name, pin] of Object.entries(pins)) {
|
|
140
|
-
|
|
142
|
+
// The app root's one package.json carries the pins of both sides.
|
|
143
|
+
if (profile !== APP_SCOPE && pin.side !== 'both' && pin.side !== profile) continue;
|
|
141
144
|
for (const section of DEP_SECTIONS) {
|
|
142
145
|
const spec = pkg[section]?.[name];
|
|
143
146
|
if (spec === undefined) continue;
|
|
@@ -211,35 +214,24 @@ function treeFindings({ repoRoot, resolver }) {
|
|
|
211
214
|
return findings;
|
|
212
215
|
}
|
|
213
216
|
|
|
217
|
+
/** A side-relative path (or null) as an app-relative one. */
|
|
218
|
+
const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
|
|
219
|
+
|
|
214
220
|
/**
|
|
215
|
-
* The
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
* over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the same repository (the
|
|
220
|
-
* managed files), judged and counted with this module's own. Returns {ok, profile, apps, manifest, tracked, findings,
|
|
221
|
-
* counts}; a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never
|
|
222
|
-
* an exception.
|
|
221
|
+
* The findings of one scope: the app root (profile app; its own files, and the rules of the files only the root holds: the one
|
|
222
|
+
* package.json and lockfile, CI, hooks, .starciwork) or one side (profile be or fe; the side folder is `repoRoot` and every path is
|
|
223
|
+
* relative to it, exactly as the standalone repository root was). `files` are the scope's tracked paths; `all` (root only) every
|
|
224
|
+
* tracked path of the app, for the rules that read across it (dependency skew, proof commands).
|
|
223
225
|
*/
|
|
224
|
-
|
|
225
|
-
const why = readWhy(root);
|
|
226
|
-
let repo;
|
|
227
|
-
try {
|
|
228
|
-
repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
|
|
229
|
-
} catch (error) {
|
|
230
|
-
if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
|
|
231
|
-
const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
|
|
232
|
-
return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
|
|
233
|
-
}
|
|
234
|
-
const resolver = createSlotResolver(manifest, repo);
|
|
235
|
-
const tracked = files ?? trackedFiles(repoRoot);
|
|
236
|
-
const trackedSet = new Set(tracked);
|
|
237
|
-
const present = (p) => (p.endsWith('/') ? tracked.some((f) => f.startsWith(p)) : trackedSet.has(p));
|
|
238
|
-
const findings = [];
|
|
239
|
-
const scoped = only ? new Set(only) : null;
|
|
226
|
+
function scopeFindings({ repoRoot, root, repo, resolver, files, all = files, scoped }) {
|
|
240
227
|
const inScope = (file) => !scoped || scoped.has(file);
|
|
228
|
+
// A required directory of the root (be/, fe/) is present through the files below it, which are the sides' own.
|
|
229
|
+
const trackedSet = new Set(all);
|
|
230
|
+
const present = (p) => (p.endsWith('/') ? all.some((f) => f.startsWith(p)) : trackedSet.has(p));
|
|
231
|
+
const findings = [];
|
|
232
|
+
const isRoot = repo.profile === APP_SCOPE;
|
|
241
233
|
|
|
242
|
-
findings.push(...pathFindings({ files:
|
|
234
|
+
findings.push(...pathFindings({ files: files.filter(inScope), resolver, profile: repo.profile }));
|
|
243
235
|
|
|
244
236
|
const required = resolver.requiredPaths();
|
|
245
237
|
const missing = new Set();
|
|
@@ -250,46 +242,104 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
|
|
|
250
242
|
const app = appOf(p);
|
|
251
243
|
findings.push({ code: 'HFS_SLOT_REQUIRED_MISSING', level: 'error', path: p, slot, via, ...(app ? { app } : {}), message: `${slot} requires ${p}${app ? ` (app ${app})` : ''}, which is not tracked` });
|
|
252
244
|
};
|
|
253
|
-
|
|
254
|
-
const
|
|
245
|
+
// The app's own required paths; each side reports its own (the resolver of the app lists them too, with their side).
|
|
246
|
+
for (const entry of required.paths) if (!entry.side) missingFile(entry.slot, entry.path, entry.via);
|
|
247
|
+
const instances = instancesOf(resolver, files);
|
|
255
248
|
for (const instance of instances) for (const p of requiredOf(resolver.slot(instance.slot), instance)) missingFile(instance.slot, p, 'requires');
|
|
256
|
-
for (const { slot, min, appKind } of required.minimums) {
|
|
257
|
-
if (appKind !== undefined) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
|
|
249
|
+
for (const { slot, min, appKind, side } of required.minimums) {
|
|
250
|
+
if (appKind !== undefined || side) continue; // an app-kind minimum is checked by requiredPaths (one path set per declared app)
|
|
258
251
|
const count = instances.filter((i) => i.slot === slot).length;
|
|
259
252
|
if (count < min) findings.push({ code: 'HFS_MIN_INSTANCES', level: 'error', path: resolver.slot(slot).path, slot, min, count, message: `${slot} needs at least ${min} instance${min === 1 ? '' : 's'} (${resolver.slot(slot).path}), found ${count}` });
|
|
260
253
|
}
|
|
261
254
|
|
|
262
255
|
const pins = readPins(root);
|
|
263
|
-
findings.push(...pinFindings({ repoRoot, files
|
|
256
|
+
findings.push(...pinFindings({ repoRoot, files, profile: repo.profile, pins, only: scoped }));
|
|
264
257
|
|
|
265
|
-
// The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-
|
|
266
|
-
const secrets = secretFindings({ repoRoot, files: tracked.filter(inScope), resolver });
|
|
267
|
-
const declared = declaration === undefined ? readJson(repoRoot, 'hfs.json') : declaration;
|
|
258
|
+
// The tree checks of the rules that read file content or configuration (hfs-rules/*): whole-scope, cheap, no tool run.
|
|
268
259
|
findings.push(
|
|
269
|
-
...
|
|
270
|
-
...
|
|
271
|
-
...
|
|
272
|
-
...contractFindings({ repoRoot, files: tracked, repo, resolver, stacks: declared?.stacks }),
|
|
273
|
-
...repoLocalCheckFindings({ repoRoot, files: tracked }),
|
|
274
|
-
...lintSuppressionFindings({ repoRoot, files: tracked }),
|
|
275
|
-
...(repo.profile === 'be' ? [...stacksFindings({ repoRoot, files: tracked, resolver }), ...testTopologyFindings({ repoRoot, files: tracked }), ...proofCommandFindings({ repoRoot, files: tracked, resolver })] : [...frontendFindings({ repoRoot, files: tracked, repo }), ...feNoTestsFindings({ repoRoot, files: tracked.filter(inScope) })]),
|
|
276
|
-
...extraFindings,
|
|
260
|
+
...secretFindings({ repoRoot, files: files.filter(inScope), resolver }),
|
|
261
|
+
...repoLocalCheckFindings({ repoRoot, files }),
|
|
262
|
+
...lintSuppressionFindings({ repoRoot, files }),
|
|
277
263
|
);
|
|
264
|
+
if (isRoot) {
|
|
265
|
+
findings.push(
|
|
266
|
+
...depFindings({ repoRoot, files: all }),
|
|
267
|
+
...peerIntegrationFindings({ repoRoot, files: all }),
|
|
268
|
+
...pipelineFindings({ repoRoot, files, pins }),
|
|
269
|
+
...testTopologyFindings({ repoRoot, files }),
|
|
270
|
+
...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
|
|
271
|
+
...appFrontendFindings({ repoRoot, files: all, repo }),
|
|
272
|
+
// The tree check of the app root the machine runs per side for a side folder: README, root entries, automatic gates, hooks path.
|
|
273
|
+
...checkAppRoot({ root: repoRoot, resolver, tree: trackedTreeView(all) }).violations.map((item) => ({ code: item.ruleId, level: 'error', path: item.path, line: item.line, column: item.column, source: 'machine', message: `${item.path}: ${item.message}` })),
|
|
274
|
+
);
|
|
275
|
+
} else if (repo.profile === 'be') {
|
|
276
|
+
findings.push(...contractFindings({ files, repo, resolver }), ...stacksFindings({ repoRoot, files, resolver }), ...testTopologyFindings({ repoRoot, files }));
|
|
277
|
+
} else {
|
|
278
|
+
findings.push(...frontendFindings({ repoRoot, files, repo }), ...feNoTestsFindings({ repoRoot, files: files.filter(inScope) }));
|
|
279
|
+
}
|
|
278
280
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
281
|
+
if (!isRoot) {
|
|
282
|
+
const soft = resolver.ruleParams().fileLines.soft;
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
if (!inScope(file) || !SOURCE_EXT.test(file) || resolver.classifyPath(file).status !== 'owned') continue;
|
|
285
|
+
let lines;
|
|
286
|
+
try { lines = fs.readFileSync(path.join(repoRoot, file), 'utf8').split('\n').length; } catch { continue; }
|
|
287
|
+
if (lines > soft) findings.push({ code: 'HFS_SIZE_SOFT_BACKLOG', level: 'info', path: file, lines, soft, message: `${file} has ${lines} lines, above the soft size ${soft}; report only` });
|
|
288
|
+
}
|
|
285
289
|
}
|
|
290
|
+
return findings;
|
|
291
|
+
}
|
|
286
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The check of one app. `repoRoot` is the app root (a side folder is checked as that side alone). `declaration` overrides hfs.json
|
|
295
|
+
* (a dry run over an app that has none); `files` overrides git ls-files (specs), app-relative. `only` (a list of app-relative paths)
|
|
296
|
+
* limits the per-path checks (slot, pin, size) to those paths; the checks of the tree as a whole (required files, minimum instances,
|
|
297
|
+
* empty directories, untracked entries) are not per-path. `tree: false` skips the file-system checks (empty directories, ghosts,
|
|
298
|
+
* untracked); they also do not run over `files`, which is a dry run. `extraFindings` are findings another emitter produced for the
|
|
299
|
+
* same app (the managed files, the formatter, the contract emit), app-relative, judged and counted with this module's own.
|
|
300
|
+
* The root scope and each side run their own rules over their own files (hfs checks per side, the side folder as the old repository
|
|
301
|
+
* root, plus the root checks); every finding path is app-relative. Returns {ok, profile, apps, manifest, tracked, findings, counts};
|
|
302
|
+
* a missing or invalid hfs.json is one HFS_DECLARATION_INVALID / HFS_MANIFEST_MAJOR_MISMATCH error finding, never an exception.
|
|
303
|
+
*/
|
|
304
|
+
export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only, extraFindings = [], tree = files === undefined, manifest = loadSlotManifest({ root }), surface = 'all' }) {
|
|
305
|
+
const why = readWhy(root);
|
|
306
|
+
let repo;
|
|
307
|
+
try {
|
|
308
|
+
repo = declaration === undefined ? readRepoDeclaration(manifest, repoRoot) : resolveRepoDeclaration(manifest, declaration);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (!(error instanceof HfsSlotsError) || !['HFS_DECLARATION_INVALID', 'HFS_MANIFEST_MAJOR_MISMATCH'].includes(error.code)) throw error;
|
|
311
|
+
const findings = withWhy([{ code: error.code, level: 'error', path: HFS_DECLARATION_FILE, message: error.message.replace(/^[A-Z_]+: /, ''), problems: error.details.problems }], why);
|
|
312
|
+
return { ok: false, repoRoot, manifest: manifest.version, profile: null, apps: [], tracked: 0, findings, counts: summarize(findings) };
|
|
313
|
+
}
|
|
314
|
+
const resolver = createSlotResolver(manifest, repo);
|
|
315
|
+
const tracked = files ?? trackedFiles(repoRoot);
|
|
316
|
+
const scoped = only ? new Set(only) : null;
|
|
317
|
+
const findings = [];
|
|
318
|
+
// `hfs check` leaves to the lint canon the per-path findings that sit on an existing TypeScript file (hfs-path-findings.mjs).
|
|
319
|
+
const keep = (list, scopeRoot) => (surface === 'check' ? list.filter((finding) => !(finding.origin === 'repo' && onLintSurface(scopeRoot, finding))) : list);
|
|
320
|
+
if (repo.profile === APP_SCOPE) {
|
|
321
|
+
const own = tracked.filter((file) => resolver.sideOf(file) === null);
|
|
322
|
+
findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: own, all: tracked, scoped }), repoRoot));
|
|
323
|
+
for (const side of SIDES) {
|
|
324
|
+
const prefix = `${side}/`;
|
|
325
|
+
const sideRoot = path.join(repoRoot, side);
|
|
326
|
+
const sideFiles = tracked.filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length));
|
|
327
|
+
const sideScoped = scoped ? new Set([...scoped].filter((file) => file.startsWith(prefix)).map((file) => file.slice(prefix.length))) : null;
|
|
328
|
+
const sideFindings = scopeFindings({ repoRoot: sideRoot, root, repo: repo.sides[side], resolver: resolver.sides[side], files: sideFiles, scoped: sideScoped });
|
|
329
|
+
const message = appRelativeMessages(side, sideRoot);
|
|
330
|
+
findings.push(...keep(sideFindings, sideRoot).map((finding) => ({ ...finding, side, path: onSide(side, finding.path), message: message(finding.message) })));
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: tracked, scoped }), repoRoot));
|
|
334
|
+
}
|
|
335
|
+
findings.push(...extraFindings);
|
|
287
336
|
if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
|
|
288
337
|
|
|
289
|
-
//
|
|
290
|
-
const finished = withWhy(
|
|
338
|
+
// The app-root tree check reports machine codes: their why is read with the check's own.
|
|
339
|
+
const finished = withWhy(findings, { ...why, ...readWhy(root, [...new Set(findings.map((f) => f.code).filter((code) => !why[code]))]) });
|
|
291
340
|
const counts = summarize(finished);
|
|
292
|
-
|
|
341
|
+
const apps = repo.profile === APP_SCOPE ? SIDES.flatMap((side) => repo.sides[side].apps.map((app) => ({ ...app, side }))) : repo.apps;
|
|
342
|
+
return { ok: counts.error === 0, repoRoot, manifest: manifest.version, profile: repo.profile, apps, tracked: tracked.length, findings: finished, counts };
|
|
293
343
|
}
|
|
294
344
|
|
|
295
345
|
// ------------------------------------------------------------------------------------------ the whole check
|
|
@@ -326,45 +376,66 @@ function machineFindings(report) {
|
|
|
326
376
|
}
|
|
327
377
|
|
|
328
378
|
/**
|
|
329
|
-
* The whole `hfs check` of one
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
* paths, the machine on the owners of the changed source files
|
|
333
|
-
* checks. `extraFindings` are the findings of the emitters that render
|
|
334
|
-
* (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing
|
|
379
|
+
* The whole `hfs check` of one app: checkRepo() (the root and both sides: slots, pins, size, tree) and then the architecture machine
|
|
380
|
+
* over each side folder (the side as the repository root it judges), its violations and errors merged in as findings with the
|
|
381
|
+
* Vietnamese why of their codes and the side prefixed onto their paths. `fast` judges only what changed since the merge-base (`base`
|
|
382
|
+
* names another ref): the per-path slot and pin checks on the changed paths, the machine on the owners of the changed source files
|
|
383
|
+
* without clones and dead exports, and no file-system tree checks. `extraFindings` are the findings of the emitters that render
|
|
384
|
+
* templates or run the formatter (packages/hfs/sync), judged with checkRepo's own. `machine` is injectable for specs. A missing
|
|
385
|
+
* merge-base under `fast` is an Error, never a silent full pass. `repoRoot` may also be a side folder: that side alone is checked.
|
|
335
386
|
*/
|
|
336
387
|
export function checkRepository({ repoRoot, root = skillRoot, fast = false, base, extraFindings = [], manifest = loadSlotManifest({ root }), machine = checkArchitecture }) {
|
|
337
388
|
const changed = fast ? changedSince(repoRoot, base) : null;
|
|
338
|
-
const baseSha = changed?.base;
|
|
339
389
|
const slotResult = checkRepo({ repoRoot, root, manifest, extraFindings, surface: 'check', ...(changed ? { only: changed.files, tree: false } : {}) });
|
|
340
390
|
if (slotResult.profile === null) return { ...slotResult, machine: { status: 'skipped', reason: 'hfs.json is not valid' } };
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
} catch (error) {
|
|
352
|
-
report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
|
|
391
|
+
const repo = readRepoDeclaration(manifest, repoRoot);
|
|
392
|
+
const scopes = repo.profile === APP_SCOPE
|
|
393
|
+
? SIDES.map((side) => ({ side, repoRoot: path.join(repoRoot, side), prefix: `${side}/` }))
|
|
394
|
+
: [{ side: null, repoRoot, prefix: '' }];
|
|
395
|
+
const runs = [];
|
|
396
|
+
const found = [];
|
|
397
|
+
for (const scope of scopes) {
|
|
398
|
+
const run = machineOver({ scope, manifest, machine, changed });
|
|
399
|
+
runs.push(run.info);
|
|
400
|
+
found.push(...run.findings);
|
|
353
401
|
}
|
|
354
|
-
const found = machineFindings(report);
|
|
355
402
|
const why = readWhy(root, [...new Set(found.map((f) => f.code))]);
|
|
356
403
|
const findings = [...slotResult.findings, ...withWhy(found, why)];
|
|
357
404
|
const counts = summarize(findings);
|
|
405
|
+
const ran = runs.filter((info) => info.status === 'ran');
|
|
358
406
|
return {
|
|
359
407
|
...slotResult,
|
|
360
408
|
ok: counts.error === 0,
|
|
361
409
|
findings,
|
|
362
410
|
counts,
|
|
363
|
-
machine:
|
|
411
|
+
machine: ran.length
|
|
412
|
+
? { status: 'ran', files: ran.reduce((sum, info) => sum + info.files, 0), kinds: [...new Set(ran.flatMap((info) => info.kinds ?? []))], sides: runs, ...(changed ? { paths: ran.flatMap((info) => info.paths ?? []), base: changed.base } : {}) }
|
|
413
|
+
: { status: 'skipped', reason: runs.map((info) => info.reason).join('; '), sides: runs, ...(changed ? { base: changed.base } : {}) },
|
|
364
414
|
...(changed ? { fast: { base: changed.base, changed: changed.files.length } } : {}),
|
|
365
415
|
};
|
|
366
416
|
}
|
|
367
417
|
|
|
418
|
+
/** The architecture machine over one scope (a side folder): `{ info, findings }`, finding paths app-relative. */
|
|
419
|
+
function machineOver({ scope, manifest, machine, changed }) {
|
|
420
|
+
const { side, prefix } = scope;
|
|
421
|
+
const label = side ?? 'repository';
|
|
422
|
+
let paths;
|
|
423
|
+
if (changed) {
|
|
424
|
+
const resolver = createSlotResolver(manifest, readRepoDeclaration(manifest, scope.repoRoot));
|
|
425
|
+
const mine = changed.files.filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
|
|
426
|
+
paths = [...new Set(mine.filter((f) => SOURCE_EXT.test(f) || resolver.ownerOf(f)).map((f) => resolver.ownerOf(f)?.root ?? f))].sort();
|
|
427
|
+
if (!paths.length) return { info: { side, status: 'skipped', reason: `no changed source file in ${label}` }, findings: [] };
|
|
428
|
+
}
|
|
429
|
+
let report;
|
|
430
|
+
try {
|
|
431
|
+
report = machine({ repositoryRoot: scope.repoRoot, base: changed?.base, surface: 'check', ...(changed ? { paths, fast: true } : {}) });
|
|
432
|
+
} catch (error) {
|
|
433
|
+
report = { ok: false, files: 0, kinds: [], violations: [], errors: [{ ruleId: 'ARCH_EXECUTION_UNAVAILABLE', message: String(error?.message ?? error) }] };
|
|
434
|
+
}
|
|
435
|
+
const message = side ? appRelativeMessages(side, scope.repoRoot) : null;
|
|
436
|
+
const findings = machineFindings(report).map((finding) => (side ? { ...finding, side, message: message(finding.message), ...(finding.path ? { path: onSide(side, finding.path) } : {}) } : finding));
|
|
437
|
+
return { info: { side, status: 'ran', files: report.files, kinds: report.kinds, ...(paths ? { paths: paths.map((p) => `${prefix}${p}`) } : {}) }, findings };
|
|
438
|
+
}
|
|
368
439
|
// --------------------------------------------------------------------------------------------------- explain
|
|
369
440
|
|
|
370
441
|
const TEST_KIND = {
|
|
@@ -386,7 +457,9 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
|
|
|
386
457
|
const slot = resolver.slot(c.slot);
|
|
387
458
|
const tier = resolver.tierOf(c.path);
|
|
388
459
|
const owner = resolver.ownerOf(c.path);
|
|
389
|
-
|
|
460
|
+
// A path of a side imports by its side's direction matrix; the app root has none of its own.
|
|
461
|
+
const scope = c.side ? resolver.sides[c.side] : resolver;
|
|
462
|
+
const mayImport = tier && tier !== 'none' ? scope.allowedImports(tier) : null;
|
|
390
463
|
return {
|
|
391
464
|
path: c.path,
|
|
392
465
|
status: c.status,
|
|
@@ -407,61 +480,3 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
|
|
|
407
480
|
...(c.status === 'not-enabled' ? { code: 'HFS_SLOT_NOT_ENABLED', titleVi: why.HFS_SLOT_NOT_ENABLED.titleVi, whyVi: why.HFS_SLOT_NOT_ENABLED.whyVi } : {}),
|
|
408
481
|
};
|
|
409
482
|
}
|
|
410
|
-
|
|
411
|
-
// ------------------------------------------------------------------------------------------------- init
|
|
412
|
-
|
|
413
|
-
const SLUG_SUFFIX = /-(backend|be|frontend|fe|api|web|app)$/;
|
|
414
|
-
const exists = (p) => fs.existsSync(p);
|
|
415
|
-
const readPackage = (dir) => { try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { return null; } };
|
|
416
|
-
const depsOf = (pkg) => ({ ...pkg?.devDependencies, ...pkg?.dependencies });
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* A starter hfs.json by detection: profile from the dependencies (next -> fe, @nestjs/core -> be, over the root and
|
|
420
|
-
* every apps/<name>), apps from the apps/<name> directories (fe: kind next; be: worker/migrate/cli by name, else api).
|
|
421
|
-
* optionalSlots are the opt-in slots (not implied by an app kind) that tracked files already occupy. Connections are not guessed; a repository that keeps a database declares them by hand and gains the migrate app.
|
|
422
|
-
* A repository the detection cannot classify is HFS_INIT_UNDETECTED, never a guess.
|
|
423
|
-
*/
|
|
424
|
-
export function detectDeclaration({ repoRoot, manifest }) {
|
|
425
|
-
const appsDir = path.join(repoRoot, 'apps');
|
|
426
|
-
const names = isDir(appsDir) ? fs.readdirSync(appsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && e.name !== 'node_modules').map((e) => e.name).sort() : [];
|
|
427
|
-
const rootPkg = readPackage(repoRoot);
|
|
428
|
-
const all = { ...depsOf(rootPkg) };
|
|
429
|
-
for (const name of names) Object.assign(all, depsOf(readPackage(path.join(appsDir, name))));
|
|
430
|
-
const isFe = 'next' in all || names.some((n) => ['next.config.ts', 'next.config.js', 'next.config.mjs'].some((f) => exists(path.join(appsDir, n, f))));
|
|
431
|
-
const isBe = '@nestjs/core' in all || exists(path.join(repoRoot, 'nest-cli.json'));
|
|
432
|
-
if (isFe === isBe) refuse('HFS_INIT_UNDETECTED', `cannot tell the profile of ${repoRoot}: ${isFe ? 'both next and Nest are present' : 'neither next nor @nestjs/core is declared'}`, { repoRoot });
|
|
433
|
-
const profile = isFe ? 'fe' : 'be';
|
|
434
|
-
const apps = names.map((name) => {
|
|
435
|
-
if (profile === 'fe') return { name, kind: 'next' };
|
|
436
|
-
if (!isDir(path.join(appsDir, name, 'src'))) return null;
|
|
437
|
-
const kind = /migrat/.test(name) ? 'migrate' : (/worker/.test(name) ? 'worker' : (/(^|-)cli($|-)/.test(name) ? 'cli' : 'api'));
|
|
438
|
-
return { name, kind };
|
|
439
|
-
}).filter(Boolean).filter((a) => manifest.appKinds[profile].includes(a.kind));
|
|
440
|
-
if (!apps.length) refuse('HFS_INIT_UNDETECTED', `${repoRoot} has no apps/<name> ${profile === 'fe' ? 'Next application' : 'Nest application with a src directory'} to declare`, { repoRoot, profile });
|
|
441
|
-
const project = String(rootPkg?.name ?? path.basename(repoRoot)).replace(/^@[^/]+\//, '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').replace(SLUG_SUFFIX, '');
|
|
442
|
-
const declaration = { hfs: manifest.major, profile, project: /^[a-z]/.test(project) ? project : `p-${project}`, apps };
|
|
443
|
-
const optionalSlots = occupiedOptInSlots({ repoRoot, manifest, declaration });
|
|
444
|
-
return optionalSlots.length ? { ...declaration, optionalSlots } : declaration;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/** The opt-in slots (not enabled by an app kind) that at least one tracked file falls in, found by enabling them all for a look. */
|
|
448
|
-
function occupiedOptInSlots({ repoRoot, manifest, declaration }) {
|
|
449
|
-
let files;
|
|
450
|
-
try { files = trackedFiles(repoRoot); } catch (error) { if (error.code === 'HFS_REPO_UNREADABLE') return []; throw error; }
|
|
451
|
-
const optIn = manifest.slots.filter((s) => s.profiles.includes(declaration.profile) && s.presence === 'opt-in' && s.appKind === undefined).map((s) => s.id);
|
|
452
|
-
const everything = createSlotResolver(manifest, resolveRepoDeclaration(manifest, { ...declaration, optionalSlots: optIn }));
|
|
453
|
-
const used = new Set();
|
|
454
|
-
for (const file of files) { const c = everything.classifyPath(file); if (c.status === 'owned' && optIn.includes(c.slot)) used.add(c.slot); }
|
|
455
|
-
return optIn.filter((id) => used.has(id));
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/** Write hfs.json unless one exists (HFS_INIT_EXISTS); `write: false` returns the text only. */
|
|
459
|
-
export function initRepo({ repoRoot, root = skillRoot, write = true, manifest = loadSlotManifest({ root }) }) {
|
|
460
|
-
const file = path.join(repoRoot, HFS_DECLARATION_FILE);
|
|
461
|
-
if (write && exists(file)) refuse('HFS_INIT_EXISTS', `${file} already exists; init never rewrites a declaration`, { file });
|
|
462
|
-
const declaration = detectDeclaration({ repoRoot, manifest });
|
|
463
|
-
resolveRepoDeclaration(manifest, declaration);
|
|
464
|
-
const text = `${JSON.stringify(declaration, null, 2)}\n`;
|
|
465
|
-
if (write) fs.writeFileSync(file, text);
|
|
466
|
-
return { file, declaration, text, written: write };
|
|
467
|
-
}
|
|
@@ -8,11 +8,18 @@ import { allowsFile } from './hfs-allows.mjs';
|
|
|
8
8
|
import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
|
|
9
9
|
import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
|
|
10
10
|
import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
|
|
11
|
+
import { APP_SCOPE } from './hfs-slots.mjs';
|
|
11
12
|
|
|
12
13
|
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
13
14
|
const SOURCE_ROOT = /^(?:src|apps)\//;
|
|
14
15
|
const FREE_NAMES = new Set(['index.ts', 'main.ts']);
|
|
15
16
|
const PLAIN_ENTRY = /^<[a-z][a-z0-9-]*>.ts$/;
|
|
17
|
+
/**
|
|
18
|
+
* Where the work of a banned data-access suffix goes. The suffix is banned by the manifest (ruleParams.be.bannedSuffixes);
|
|
19
|
+
* this only adds the convention's home to the finding, so a file that is a repository or a store is told what replaces it.
|
|
20
|
+
*/
|
|
21
|
+
const DATA_ACCESS_HOME = 'SQL text is a constant in <name>.sql.ts of the capability persistence/ folder, and data access is the capability *.service.ts (or the application *.handler.ts) calling the shared EntityManager through its Inject<Conn>EntityManager()';
|
|
22
|
+
const BANNED_SUFFIX_HOME = Object.freeze({ repository: DATA_ACCESS_HOME, store: DATA_ACCESS_HOME });
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
|
|
@@ -39,14 +46,16 @@ function sourceFormFindings({ files, resolver }) {
|
|
|
39
46
|
// A literal file name the owning slot itself requires or allows (persistence/connection.ts, world/global-setup.ts) is its role.
|
|
40
47
|
const slot = resolver.slot(c.slot);
|
|
41
48
|
if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].some((entry) => entry === base)) continue;
|
|
42
|
-
//
|
|
49
|
+
// So is an allows entry below the slot root whose last segment is that literal name (be.tests.world fakes/<provider>/server.ts).
|
|
43
50
|
const admitted = allowsFile(resolver, file);
|
|
51
|
+
if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) continue;
|
|
52
|
+
// A slot whose `allows` holds a bare <name>.ts entry (be.tests.world.kit) names its files plainly, as platform/primitives does: kebab-case is the whole form.
|
|
44
53
|
if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) continue;
|
|
45
54
|
if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') continue;
|
|
46
55
|
const parts = base.slice(0, -'.ts'.length).split('.');
|
|
47
56
|
const banned = parts.slice(1).find((part) => bannedSuffixes.includes(part));
|
|
48
57
|
if (banned) {
|
|
49
|
-
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})` });
|
|
58
|
+
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: banned, message: `${file}: the suffix .${banned} is banned; use a role from the closed suffix list (${suffixes.join(', ')})${BANNED_SUFFIX_HOME[banned] ? `. ${BANNED_SUFFIX_HOME[banned]}` : ''}` });
|
|
50
59
|
} else if (boundSuffixes.has(parts.at(-1)) && parts.length >= 2 && boundSuffixes.get(parts.at(-1)).id !== c.slot) {
|
|
51
60
|
findings.push({ code: 'BE_SOURCE_FORM', level: 'error', path: file, suffix: parts.at(-1), message: `${file}: the suffix .${parts.at(-1)}.ts belongs to ${boundSuffixes.get(parts.at(-1)).path} only; move the file there` });
|
|
52
61
|
} else if (parts.length < 2 || !parts.every((part) => KEBAB.test(part)) || !suffixes.includes(parts.at(-1))) {
|
|
@@ -79,6 +88,8 @@ export function pathFindings({ files, resolver, profile }) {
|
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
if (profile === 'be') findings.push(...sourceFormFindings({ files, resolver }), ...specPlacementFindings({ files, resolver }));
|
|
91
|
+
// The app root (scripts/, CI, hooks) holds no test layer: a spec there is the same finding as a spec of an operational script.
|
|
92
|
+
else if (profile === APP_SCOPE) findings.push(...specPlacementFindings({ files, resolver }));
|
|
82
93
|
// The lint canon's project graph serves these findings on a TypeScript file; the origin keeps them apart from the machine's.
|
|
83
94
|
return findings.map((finding) => ({ ...finding, origin: 'repo' }));
|
|
84
95
|
}
|