@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// hfs-slots.mjs - the HFS slot manifest and
|
|
1
|
+
// hfs-slots.mjs - the HFS slot manifest and app declaration, loaded once and asked four questions:
|
|
2
2
|
// which slot owns path P slotOf(P) / classifyPath(P) (an unknown path reports its nearest slot)
|
|
3
3
|
// is import A -> B allowed importAllowed(A, B) (tier matrix, cross-owner entry, cross-app, layers)
|
|
4
4
|
// which files are required requiredFiles(P) / requiredPaths()
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
// list. The manifest shape is modules/schemas/hfs-slots.schema.yaml and hfs.json is modules/schemas/hfs-repo.schema.yaml;
|
|
9
9
|
// the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv
|
|
10
10
|
// (tests/hfs-slots.spec.mjs proves the two agree).
|
|
11
|
+
//
|
|
12
|
+
// A product is ONE app repository: hfs.json at the app root has kind `app` and declares its two sides, `be` and `fe`, each in
|
|
13
|
+
// the folder of that name. The resolver of the app answers for the whole tree: a root path with the slots of profile `app`,
|
|
14
|
+
// a path below a side folder with that side's resolver (profile be or fe, the side folder as its root: the side view), so
|
|
15
|
+
// every check and lint rule runs unchanged with a side folder as its repository root. Nothing crosses sides except the
|
|
16
|
+
// paths the declaration lists in `sides.<side>.reads` (a subset of the manifest's `sides.<side>.reads`).
|
|
11
17
|
import fs from 'node:fs';
|
|
12
18
|
import path from 'node:path';
|
|
13
19
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
@@ -32,11 +38,36 @@ export class HfsSlotsError extends Error {
|
|
|
32
38
|
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
33
39
|
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
34
40
|
const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
35
|
-
const SLOT_ID = /^(repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
41
|
+
const SLOT_ID = /^(app|repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
36
42
|
const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
|
|
37
43
|
const TRACKED = ['tracked', 'ignored', 'external'];
|
|
38
44
|
const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
45
|
+
/** The sides of an app; each lives in the folder of its name and is the profile of its slots. */
|
|
39
46
|
const PROFILES = ['be', 'fe'];
|
|
47
|
+
export const SIDES = Object.freeze([...PROFILES]);
|
|
48
|
+
/** The profile of the app root's own slots. */
|
|
49
|
+
export const APP_SCOPE = 'app';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The rewriter of a side's finding messages: `(message) => message` with its paths made app-relative like the finding's own path.
|
|
53
|
+
* Every check and rule of a side judges the side folder as its root, so the paths it names (`apps/web/src/...`, `src/modules/...`,
|
|
54
|
+
* `tsconfig.json`) are side-relative. A path here is a token that starts at a word boundary with a top-level entry of the side folder
|
|
55
|
+
* (`sideRoot`, read once) and goes on with `/`, or is that entry when its name holds a dot (a file). An import specifier (`@/x`,
|
|
56
|
+
* `../x`) or a path already app-relative is left alone.
|
|
57
|
+
*/
|
|
58
|
+
export function appRelativeMessages(side, sideRoot) {
|
|
59
|
+
let entries = [];
|
|
60
|
+
try { entries = fs.readdirSync(sideRoot).filter((name) => name !== 'node_modules' && !name.startsWith('.git')); } catch { /* no side folder: nothing to rewrite */ }
|
|
61
|
+
if (!entries.length) return (message) => message;
|
|
62
|
+
const escaped = entries.sort((a, b) => b.length - a.length).map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
63
|
+
const token = new RegExp(`(^|[\\s'"\`(\\[{,;=<>])((?:${escaped.join('|')})(?=/|[\\s'"\`)\\]},;:!?<>]|\\.(?:\\s|$)|$))`, 'g');
|
|
64
|
+
return (message) => (typeof message === 'string' && message
|
|
65
|
+
? message.replace(token, (whole, before, entry, offset) => (message.startsWith('/', offset + whole.length) || entry.includes('.') ? `${before}${side}/${entry}` : whole))
|
|
66
|
+
: message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SCOPES = [APP_SCOPE, ...PROFILES];
|
|
70
|
+
const APP_KIND = 'app';
|
|
40
71
|
const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
|
|
41
72
|
|
|
42
73
|
// ------------------------------------------------------------------------------------------------ patterns
|
|
@@ -120,7 +151,7 @@ const fail = (code, message, details) => { throw new HfsSlotsError(code, message
|
|
|
120
151
|
function manifestShapeProblems(m) {
|
|
121
152
|
const bad = [];
|
|
122
153
|
if (!isPlainObject(m)) return ['the manifest is not a map'];
|
|
123
|
-
const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
|
|
154
|
+
const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'sides', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
|
|
124
155
|
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
125
156
|
if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
|
|
126
157
|
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
@@ -128,6 +159,11 @@ function manifestShapeProblems(m) {
|
|
|
128
159
|
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
129
160
|
if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED)) bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
|
|
130
161
|
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
162
|
+
if (!isPlainObject(m.sides) || Object.keys(m.sides).sort().join() !== PROFILES.join()) bad.push('sides must be a map with exactly be and fe');
|
|
163
|
+
else for (const side of PROFILES) {
|
|
164
|
+
const def = m.sides[side];
|
|
165
|
+
if (!isPlainObject(def) || Object.keys(def).join() !== 'reads' || !Array.isArray(def.reads) || !def.reads.every((r) => typeof r === 'string' && /^(be|fe)\/([^/]+\/)+$/.test(r)) || new Set(def.reads).size !== def.reads.length) bad.push(`sides.${side} must be {reads: [unique <side>/<dir>/ paths]}`);
|
|
166
|
+
}
|
|
131
167
|
for (const key of ['appKinds', 'tiers']) {
|
|
132
168
|
if (!isPlainObject(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
|
|
133
169
|
for (const extra of Object.keys(m[key])) if (!PROFILES.includes(extra)) bad.push(`${key}.${extra} is not a profile`);
|
|
@@ -174,7 +210,8 @@ function manifestShapeProblems(m) {
|
|
|
174
210
|
if (!isPlainObject(slot)) { bad.push(`${at} is not a map`); return; }
|
|
175
211
|
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
176
212
|
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
177
|
-
if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) =>
|
|
213
|
+
if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) => SCOPES.includes(p)) || new Set(slot.profiles).size !== slot.profiles.length || (slot.profiles.includes(APP_SCOPE) && slot.profiles.length !== 1)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
|
|
214
|
+
else if ((slot.profiles[0] === APP_SCOPE) !== String(slot.id).startsWith(`${APP_SCOPE}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
|
|
178
215
|
if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
|
|
179
216
|
if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
|
|
180
217
|
if (!TRACKED.includes(slot.tracked)) bad.push(`${at}: tracked must be one of ${TRACKED.join(', ')}`);
|
|
@@ -211,9 +248,15 @@ function manifestSemanticProblems(m) {
|
|
|
211
248
|
ids.add(slot.id);
|
|
212
249
|
const pathVars = new Set(varsOf(slot.path));
|
|
213
250
|
for (const profile of slot.profiles) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
251
|
+
if (profile === APP_SCOPE) {
|
|
252
|
+
// The app root holds no source: its slots take no part in import checks and describe no app.
|
|
253
|
+
for (const key of ['appKind', 'owner', 'layers', 'kinds', 'composedBy', 'requiredWhen', 'requiredInstances']) if (slot[key] !== undefined) bad.push(`slot ${slot.id}: an app-root slot has no ${key}`);
|
|
254
|
+
if (slot.tier !== 'none') bad.push(`slot ${slot.id}: an app-root slot has tier none`);
|
|
255
|
+
} else {
|
|
256
|
+
const tiers = m.tiers[profile];
|
|
257
|
+
if (slot.tier !== 'none' && slot.tier !== 'inherit' && !(slot.tier in tiers)) bad.push(`slot ${slot.id}: tier ${slot.tier} is not a ${profile} tier`);
|
|
258
|
+
if (slot.appKind !== undefined && !m.appKinds[profile].includes(slot.appKind)) bad.push(`slot ${slot.id}: app kind ${slot.appKind} is not a ${profile} kind`);
|
|
259
|
+
}
|
|
217
260
|
if (slot.appKind === undefined) {
|
|
218
261
|
for (const variant of braceVariants(slot.path)) {
|
|
219
262
|
const key = `${profile}:${variant}`;
|
|
@@ -234,6 +277,12 @@ function manifestSemanticProblems(m) {
|
|
|
234
277
|
for (const kind of slot.composedBy ?? []) if (!slot.profiles.every((p) => m.appKinds[p].includes(kind))) bad.push(`slot ${slot.id}: composedBy names ${kind}, which is not an app kind of every profile of the slot`);
|
|
235
278
|
if (slot.layers !== undefined && slot.tier !== 'none' && !slot.profiles.every((p) => m.tiers[p][slot.tier]?.lowerLayerOnly)) bad.push(`slot ${slot.id}: layers need a lowerLayerOnly tier`);
|
|
236
279
|
}
|
|
280
|
+
for (const side of PROFILES) for (const read of m.sides[side].reads) {
|
|
281
|
+
const [owner, ...rest] = read.split('/');
|
|
282
|
+
const below = rest.join('/');
|
|
283
|
+
if (owner === side) bad.push(`sides.${side}.reads names ${read}, which is its own side`);
|
|
284
|
+
else if (!m.slots.some((s) => s.profiles.includes(owner) && s.presence !== 'forbidden' && braceVariants(s.path).some((v) => v.startsWith(below)))) bad.push(`sides.${side}.reads names ${read}, which no ${owner} slot owns`);
|
|
285
|
+
}
|
|
237
286
|
for (const profile of PROFILES) {
|
|
238
287
|
for (const [tier, def] of Object.entries(m.tiers[profile])) {
|
|
239
288
|
for (const target of def.mayImport) if (!(target in m.tiers[profile])) bad.push(`tiers.${profile}.${tier}.mayImport names ${target}, which is not a ${profile} tier`);
|
|
@@ -268,79 +317,131 @@ export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_
|
|
|
268
317
|
function declarationShapeProblems(d) {
|
|
269
318
|
const bad = [];
|
|
270
319
|
if (!isPlainObject(d)) return ['hfs.json is not an object'];
|
|
271
|
-
for (const key of Object.keys(d)) if (!['hfs', '
|
|
320
|
+
for (const key of Object.keys(d)) if (!['hfs', 'kind', 'project', 'sides'].includes(key)) bad.push(`unknown key ${key}`);
|
|
272
321
|
if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
|
|
273
|
-
if (
|
|
322
|
+
if (d.kind !== APP_KIND) bad.push(`kind must be ${APP_KIND}: a product is one app repository with a be and an fe side`);
|
|
274
323
|
if (!NAME.test(String(d.project))) bad.push('project must be a project name');
|
|
275
|
-
if (!
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
324
|
+
if (!isPlainObject(d.sides) || Object.keys(d.sides).sort().join() !== PROFILES.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
|
|
325
|
+
const names = new Map();
|
|
326
|
+
for (const side of PROFILES) {
|
|
327
|
+
const s = d.sides[side];
|
|
328
|
+
const at = `sides.${side}`;
|
|
329
|
+
if (!isPlainObject(s)) { bad.push(`${at} must be an object`); continue; }
|
|
330
|
+
for (const key of Object.keys(s)) if (!['apps', 'optionalSlots', 'connections', 'reads'].includes(key)) bad.push(`${at} has unknown key ${key}`);
|
|
331
|
+
if (!Array.isArray(s.apps) || !s.apps.length) bad.push(`${at}.apps must list every ${side}/apps/<name> with its kind`);
|
|
332
|
+
else s.apps.forEach((app, i) => {
|
|
333
|
+
if (!isPlainObject(app) || !NAME.test(String(app.name)) || !NAME.test(String(app.kind)) || Object.keys(app).some((k) => k !== 'name' && k !== 'kind')) bad.push(`${at}.apps[${i}] must be {name, kind}`);
|
|
334
|
+
// The root scripts name every app (start:<app>), so an app name is unique across both sides.
|
|
335
|
+
else if (names.has(app.name)) bad.push(`app ${app.name} is declared twice (${names.get(app.name)} and ${side})`);
|
|
336
|
+
else names.set(app.name, side);
|
|
337
|
+
});
|
|
338
|
+
if (s.optionalSlots !== undefined && (!Array.isArray(s.optionalSlots) || !s.optionalSlots.every((v) => SLOT_ID.test(String(v))) || new Set(s.optionalSlots).size !== s.optionalSlots.length)) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
|
|
339
|
+
if (s.reads !== undefined && (!Array.isArray(s.reads) || !s.reads.every((r) => typeof r === 'string' && r.length > 0) || new Set(s.reads).size !== s.reads.length)) bad.push(`${at}.reads must be a unique list of paths`);
|
|
340
|
+
if (s.connections !== undefined) {
|
|
341
|
+
if (side !== 'be') { bad.push('connections belong to the be side'); continue; }
|
|
342
|
+
// One physical database = one entry (R84): {name, envPrefix}; names and env prefixes unique, no prefix inside another's keys.
|
|
343
|
+
const list = Array.isArray(s.connections) ? s.connections : null;
|
|
344
|
+
if (!list || !list.every((c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && Object.keys(c).length === 2)) bad.push('connections must be a list of {name: kebab-case database name, envPrefix: UPPER_SNAKE prefix of its env keys}');
|
|
345
|
+
else {
|
|
346
|
+
if (new Set(list.map((c) => c.name)).size !== list.length) bad.push('connections names must be unique');
|
|
347
|
+
for (const a of list) for (const b of list) if (a !== b && `${b.envPrefix}_`.startsWith(`${a.envPrefix}_`)) bad.push(`connections ${a.name} and ${b.name} share env keys (${a.envPrefix}_ covers ${b.envPrefix}_)`);
|
|
348
|
+
}
|
|
288
349
|
}
|
|
289
350
|
}
|
|
290
|
-
if (d.profile === 'fe' && d.connections !== undefined) bad.push('connections belong to a backend repository');
|
|
291
351
|
return bad;
|
|
292
352
|
}
|
|
293
353
|
|
|
294
354
|
const declarationInvalid = (problems, file) => fail('HFS_DECLARATION_INVALID', `hfs.json is refused: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? `; and ${problems.length - 5} more` : ''}`, { file, problems });
|
|
295
355
|
|
|
356
|
+
/** One side of a declaration checked against the manifest: app kinds of the profile, opt-in slots, required app kinds, reads. */
|
|
357
|
+
function sideProblems(manifest, side, s) {
|
|
358
|
+
const bad = [];
|
|
359
|
+
for (const app of s.apps) if (!manifest.appKinds[side].includes(app.kind)) bad.push(`${side} app ${app.name} has kind ${app.kind}, which is not a ${side} kind (${manifest.appKinds[side].join(', ')})`);
|
|
360
|
+
for (const id of s.optionalSlots ?? []) {
|
|
361
|
+
const slot = manifest.slots.find((candidate) => candidate.id === id);
|
|
362
|
+
if (!slot || !slot.profiles.includes(side)) bad.push(`sides.${side}.optionalSlots names ${id}, which is not a ${side} slot`);
|
|
363
|
+
else if (slot.presence !== 'opt-in') bad.push(`sides.${side}.optionalSlots names ${id}, which is ${slot.presence}, not opt-in`);
|
|
364
|
+
else if (slot.appKind !== undefined) bad.push(`sides.${side}.optionalSlots names ${id}; an app of kind ${slot.appKind} enables it`);
|
|
365
|
+
}
|
|
366
|
+
for (const read of s.reads ?? []) if (!manifest.sides[side].reads.includes(read)) bad.push(`sides.${side}.reads names ${read}; ${side} may read only ${manifest.sides[side].reads.join(', ') || 'nothing of the other side'}`);
|
|
367
|
+
const connections = s.connections ?? [];
|
|
368
|
+
for (const slot of manifest.slots) {
|
|
369
|
+
if (slot.appKind === undefined || !slot.profiles.includes(side) || slot.presence !== 'required') continue;
|
|
370
|
+
if (slot.requiredWhen === 'connections' && !connections.length) continue;
|
|
371
|
+
if (!s.apps.some((a) => a.kind === slot.appKind)) bad.push(`no ${side} app of kind ${slot.appKind} is declared (${slot.id} is required${slot.requiredWhen ? ' once a connection is declared' : ''})`);
|
|
372
|
+
}
|
|
373
|
+
return bad;
|
|
374
|
+
}
|
|
375
|
+
|
|
296
376
|
/**
|
|
297
|
-
* A declaration checked against the manifest: the pinned major
|
|
298
|
-
*
|
|
299
|
-
*
|
|
377
|
+
* A declaration checked against the manifest: kind app, the pinned major the manifest's (HFS_MANIFEST_MAJOR_MISMATCH otherwise,
|
|
378
|
+
* and there is no compatibility window), and per side: app kinds of that profile, optionalSlots naming only opt-in slots of the
|
|
379
|
+
* side that no app kind implies, every required app kind declared, reads within the manifest's. Returns the app (profile app,
|
|
380
|
+
* its two sides under `sides`), or with `side` that side's view: the declaration a check of the side folder runs under.
|
|
300
381
|
*/
|
|
301
|
-
export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLARATION_FILE } = {}) {
|
|
382
|
+
export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLARATION_FILE, side = null } = {}) {
|
|
302
383
|
const problems = declarationShapeProblems(declaration);
|
|
303
384
|
if (problems.length) declarationInvalid(problems, file);
|
|
304
385
|
if (declaration.hfs !== manifest.major)
|
|
305
386
|
fail('HFS_MANIFEST_MAJOR_MISMATCH', `hfs.json pins manifest major ${declaration.hfs} but the manifest is ${manifest.version}`, { pinned: declaration.hfs, manifest: manifest.version, manifestMajor: manifest.major, file });
|
|
306
|
-
const
|
|
307
|
-
const bad = [];
|
|
308
|
-
const names = new Set();
|
|
309
|
-
for (const app of declaration.apps) {
|
|
310
|
-
if (names.has(app.name)) bad.push(`app ${app.name} is declared twice`);
|
|
311
|
-
names.add(app.name);
|
|
312
|
-
if (!manifest.appKinds[profile].includes(app.kind)) bad.push(`app ${app.name} has kind ${app.kind}, which is not a ${profile} kind (${manifest.appKinds[profile].join(', ')})`);
|
|
313
|
-
}
|
|
314
|
-
for (const id of declaration.optionalSlots ?? []) {
|
|
315
|
-
const slot = manifest.slots.find((s) => s.id === id);
|
|
316
|
-
if (!slot || !slot.profiles.includes(profile)) bad.push(`optionalSlots names ${id}, which is not a ${profile} slot`);
|
|
317
|
-
else if (slot.presence !== 'opt-in') bad.push(`optionalSlots names ${id}, which is ${slot.presence}, not opt-in`);
|
|
318
|
-
else if (slot.appKind !== undefined) bad.push(`optionalSlots names ${id}; an app of kind ${slot.appKind} enables it`);
|
|
319
|
-
}
|
|
320
|
-
const connections = declaration.connections ?? [];
|
|
321
|
-
for (const slot of manifest.slots) {
|
|
322
|
-
if (slot.appKind === undefined || !slot.profiles.includes(profile) || slot.presence !== 'required') continue;
|
|
323
|
-
if (slot.requiredWhen === 'connections' && !connections.length) continue;
|
|
324
|
-
if (!declaration.apps.some((a) => a.kind === slot.appKind)) bad.push(`no app of kind ${slot.appKind} is declared (${slot.id} is required${slot.requiredWhen ? ' once a connection is declared' : ''})`);
|
|
325
|
-
}
|
|
387
|
+
const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name]));
|
|
326
388
|
if (bad.length) declarationInvalid(bad, file);
|
|
389
|
+
if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
|
|
390
|
+
const sides = Object.fromEntries(PROFILES.map((name) => {
|
|
391
|
+
const s = declaration.sides[name];
|
|
392
|
+
return [name, Object.freeze({
|
|
393
|
+
hfs: declaration.hfs,
|
|
394
|
+
kind: APP_KIND,
|
|
395
|
+
project: declaration.project,
|
|
396
|
+
side: name,
|
|
397
|
+
profile: name,
|
|
398
|
+
apps: Object.freeze(s.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
|
|
399
|
+
optionalSlots: Object.freeze([...(s.optionalSlots ?? [])]),
|
|
400
|
+
connections: Object.freeze((s.connections ?? []).map((c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix }))),
|
|
401
|
+
reads: Object.freeze([...(s.reads ?? [])]),
|
|
402
|
+
manifestVersion: manifest.version,
|
|
403
|
+
})];
|
|
404
|
+
}));
|
|
405
|
+
if (side !== null) return sides[side];
|
|
327
406
|
return Object.freeze({
|
|
328
407
|
hfs: declaration.hfs,
|
|
329
|
-
|
|
408
|
+
kind: APP_KIND,
|
|
330
409
|
project: declaration.project,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
410
|
+
side: null,
|
|
411
|
+
profile: APP_SCOPE,
|
|
412
|
+
// The root declares no app, opt-in slot or connection of its own: each side does.
|
|
413
|
+
apps: Object.freeze([]),
|
|
414
|
+
optionalSlots: Object.freeze([]),
|
|
415
|
+
connections: Object.freeze([]),
|
|
416
|
+
reads: Object.freeze([]),
|
|
417
|
+
sides: Object.freeze(sides),
|
|
334
418
|
manifestVersion: manifest.version,
|
|
335
419
|
});
|
|
336
420
|
}
|
|
337
421
|
|
|
338
|
-
/**
|
|
422
|
+
/**
|
|
423
|
+
* Where the declaration of `dir` lives: `dir` itself when it holds hfs.json (the app root), else its parent when `dir` is a side
|
|
424
|
+
* folder (be/ or fe/) of an app (the side view). `{ file, appRoot, side }`; side is null at the app root.
|
|
425
|
+
*/
|
|
426
|
+
export function locateDeclaration(dir) {
|
|
427
|
+
const own = path.join(dir, HFS_DECLARATION_FILE);
|
|
428
|
+
if (fs.existsSync(own)) return { file: own, appRoot: dir, side: null };
|
|
429
|
+
const parent = path.dirname(dir);
|
|
430
|
+
const side = path.basename(dir);
|
|
431
|
+
const up = path.join(parent, HFS_DECLARATION_FILE);
|
|
432
|
+
if (PROFILES.includes(side) && fs.existsSync(up)) return { file: up, appRoot: parent, side };
|
|
433
|
+
return { file: own, appRoot: dir, side: null };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* hfs.json of the app at `repoRoot` (the app itself), or of the app whose side folder `repoRoot` is (that side's view), parsed and
|
|
438
|
+
* resolved; a missing or unreadable file is a refusal, never "unavailable".
|
|
439
|
+
*/
|
|
339
440
|
export function readRepoDeclaration(manifest, repoRoot) {
|
|
340
|
-
const file =
|
|
441
|
+
const { file, side } = locateDeclaration(repoRoot);
|
|
341
442
|
let declaration;
|
|
342
443
|
try { declaration = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { declarationInvalid([`hfs.json cannot be read (${String(error?.code ?? error?.message ?? error).split('\n')[0]})`], file); }
|
|
343
|
-
return resolveRepoDeclaration(manifest, declaration, { file });
|
|
444
|
+
return resolveRepoDeclaration(manifest, declaration, { file, side });
|
|
344
445
|
}
|
|
345
446
|
|
|
346
447
|
// ------------------------------------------------------------------------------------------- resolver
|
|
@@ -348,10 +449,10 @@ export function readRepoDeclaration(manifest, repoRoot) {
|
|
|
348
449
|
const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
|
|
349
450
|
|
|
350
451
|
/**
|
|
351
|
-
* The four questions for one
|
|
352
|
-
*
|
|
452
|
+
* The four questions for one scope: the app root (profile app, root paths) or one side (profile be or fe, paths relative to the
|
|
453
|
+
* side folder). createSlotResolver composes them; nothing else calls this.
|
|
353
454
|
*/
|
|
354
|
-
|
|
455
|
+
function createScopeResolver(manifest, repo) {
|
|
355
456
|
const profile = repo.profile;
|
|
356
457
|
const slots = manifest.slots.filter((s) => s.profiles.includes(profile));
|
|
357
458
|
const byId = new Map(slots.map((s) => [s.id, s]));
|
|
@@ -579,7 +680,86 @@ export function createSlotResolver(manifest, repo) {
|
|
|
579
680
|
trackingOf,
|
|
580
681
|
isTracked,
|
|
581
682
|
ruleParams: () => ruleParams(manifest, profile),
|
|
582
|
-
allowedImports: (tier) => manifest.tiers[profile][tier]?.mayImport ?? null,
|
|
683
|
+
allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The four questions for one app, or for one side of it. `repo` comes from resolveRepoDeclaration / readRepoDeclaration.
|
|
689
|
+
* Paths are relative to the root the declaration was resolved for (backslashes and a leading ./ are folded; a trailing / or a
|
|
690
|
+
* directory path is fine): a side view (repo.side set) answers for the side folder as its root, exactly as a check of that side
|
|
691
|
+
* runs; the app (profile app) answers a root path with the app-root slots and a path below be/ or fe/ with that side's view,
|
|
692
|
+
* the side prefixed back onto every path and root it returns (and `side` added). Only the declared `reads` cross sides.
|
|
693
|
+
*/
|
|
694
|
+
export function createSlotResolver(manifest, repo) {
|
|
695
|
+
if (repo.profile !== APP_SCOPE) return createScopeResolver(manifest, repo);
|
|
696
|
+
const root = createScopeResolver(manifest, repo);
|
|
697
|
+
const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, repo.sides[side])]));
|
|
698
|
+
const clean = (p) => posixPath(p).replace(/\/+$/, '');
|
|
699
|
+
/** { side, rest } when `p` lies below a side folder, else null. */
|
|
700
|
+
const split = (input) => {
|
|
701
|
+
const p = clean(input);
|
|
702
|
+
const slash = p.indexOf('/');
|
|
703
|
+
const head = slash < 0 ? p : p.slice(0, slash);
|
|
704
|
+
return PROFILES.includes(head) && slash > 0 ? { side: head, rest: p.slice(slash + 1) } : null;
|
|
705
|
+
};
|
|
706
|
+
const under = (side, rel) => (rel ? `${side}/${rel}` : side);
|
|
707
|
+
const prefixed = (side, c) => ({
|
|
708
|
+
...c,
|
|
709
|
+
path: under(side, c.path),
|
|
710
|
+
side,
|
|
711
|
+
...(c.root !== undefined ? { root: under(side, c.root) } : {}),
|
|
712
|
+
...(c.nearest ? { nearest: { ...c.nearest, matchedPrefix: under(side, c.nearest.matchedPrefix), matchedDepth: c.nearest.matchedDepth + 1 } } : {}),
|
|
713
|
+
});
|
|
714
|
+
const classifyPath = (input) => { const at = split(input); return at ? prefixed(at.side, sides[at.side].classifyPath(at.rest)) : root.classifyPath(input); };
|
|
715
|
+
const ownerOf = (input) => {
|
|
716
|
+
const at = split(input);
|
|
717
|
+
if (!at) return root.ownerOf(input);
|
|
718
|
+
const owner = sides[at.side].ownerOf(at.rest);
|
|
719
|
+
return owner ? { ...owner, root: under(at.side, owner.root), side: at.side } : null;
|
|
720
|
+
};
|
|
721
|
+
const allSlots = [...new Map([...root.slots(), ...PROFILES.flatMap((side) => sides[side].slots())].map((s) => [s.id, s])).values()];
|
|
722
|
+
const byId = new Map(allSlots.map((s) => [s.id, s]));
|
|
723
|
+
/** Whether `toPath`, of the other side, lies below a path `fromSide` declares it reads. */
|
|
724
|
+
const reads = (fromSide, toPath) => repo.sides[fromSide].reads.some((read) => `${clean(toPath)}/`.startsWith(read));
|
|
725
|
+
return Object.freeze({
|
|
726
|
+
repo,
|
|
727
|
+
sides: Object.freeze(sides),
|
|
728
|
+
/** The side of a path (be, fe) or null for a root path. */
|
|
729
|
+
sideOf: (input) => split(input)?.side ?? null,
|
|
730
|
+
slot: (id) => byId.get(id) ?? null,
|
|
731
|
+
slots: () => allSlots,
|
|
732
|
+
slotEnabled: (slot) => (slot.profiles.includes(APP_SCOPE) ? root.slotEnabled(slot) : PROFILES.some((side) => slot.profiles.includes(side) && sides[side].slotEnabled(slot))),
|
|
733
|
+
classifyPath,
|
|
734
|
+
slotOf: (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; },
|
|
735
|
+
ownerOf,
|
|
736
|
+
tierOf: (input) => { const at = split(input); return at ? sides[at.side].tierOf(at.rest) : root.tierOf(input); },
|
|
737
|
+
importAllowed(fromPath, toPath) {
|
|
738
|
+
const from = split(fromPath);
|
|
739
|
+
const to = split(toPath);
|
|
740
|
+
if (from && to && from.side !== to.side) {
|
|
741
|
+
return reads(from.side, toPath) ? { allowed: true, reason: 'sideRead', fromSide: from.side, toSide: to.side } : { allowed: false, reason: 'crossSide', fromSide: from.side, toSide: to.side, reads: repo.sides[from.side].reads };
|
|
742
|
+
}
|
|
743
|
+
if (from && to) return sides[from.side].importAllowed(from.rest, to.rest);
|
|
744
|
+
return root.importAllowed(fromPath, toPath);
|
|
745
|
+
},
|
|
746
|
+
requiredFiles: (input) => { const at = split(input); return at ? sides[at.side].requiredFiles(at.rest).map((p) => under(at.side, p)) : root.requiredFiles(input); },
|
|
747
|
+
requiredPaths() {
|
|
748
|
+
const own = root.requiredPaths();
|
|
749
|
+
const paths = [...own.paths];
|
|
750
|
+
const minimums = [...own.minimums];
|
|
751
|
+
for (const side of PROFILES) {
|
|
752
|
+
const required = sides[side].requiredPaths();
|
|
753
|
+
paths.push(...required.paths.map((entry) => ({ ...entry, path: under(side, entry.path), side })));
|
|
754
|
+
minimums.push(...required.minimums.map((entry) => ({ ...entry, side })));
|
|
755
|
+
}
|
|
756
|
+
return { paths, minimums };
|
|
757
|
+
},
|
|
758
|
+
trackingOf: (p) => classifyPath(p).tracking ?? null,
|
|
759
|
+
isTracked: (p) => classifyPath(p).tracking === 'tracked',
|
|
760
|
+
// The root has no tier and no rule parameters of its own; each side has them (sides.<side>.ruleParams()).
|
|
761
|
+
ruleParams: () => null,
|
|
762
|
+
allowedImports: () => null,
|
|
583
763
|
});
|
|
584
764
|
}
|
|
585
765
|
|
|
@@ -590,9 +770,12 @@ export function ruleParams(manifest, profile) {
|
|
|
590
770
|
return deepFreeze(structuredClone(manifest.ruleParams[profile]));
|
|
591
771
|
}
|
|
592
772
|
|
|
593
|
-
/**
|
|
594
|
-
|
|
595
|
-
|
|
773
|
+
/**
|
|
774
|
+
* The manifest of this runtime plus the resolver for the app or side folder at `repoRoot` (a side folder gets that side's view), or
|
|
775
|
+
* for an already-parsed declaration (the app, or with `side` that side's view).
|
|
776
|
+
*/
|
|
777
|
+
export function openHfs({ root = skillRoot, repoRoot, declaration, side = null, manifest = loadSlotManifest({ root }) } = {}) {
|
|
778
|
+
const repo = declaration !== undefined ? resolveRepoDeclaration(manifest, declaration, { side }) : readRepoDeclaration(manifest, repoRoot);
|
|
596
779
|
return { manifest, repo, ...createSlotResolver(manifest, repo), rules: () => loadRuleCatalog({ root, manifest }) };
|
|
597
780
|
}
|
|
598
781
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// hfs-view.mjs - the frozen view of one
|
|
2
|
-
// (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
|
|
1
|
+
// hfs-view.mjs - the frozen view of one side of an app (be/ or fe/, the side folder as its root) that the lint factories hand
|
|
2
|
+
// their rules (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
|
|
3
3
|
// runtime/ bundle (packages/hfs/scripts/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
|
|
4
4
|
// architecture machine and `hfs check` use, and never tests a path with a regular expression of its own.
|
|
5
5
|
import path from 'node:path';
|
|
@@ -31,6 +31,7 @@ export function hfsView(opened, repoRoot) {
|
|
|
31
31
|
return Object.freeze({
|
|
32
32
|
repoRoot,
|
|
33
33
|
profile: opened.repo.profile,
|
|
34
|
+
side: opened.repo.side ?? null,
|
|
34
35
|
apps: opened.repo.apps,
|
|
35
36
|
connections: opened.repo.connections,
|
|
36
37
|
ruleParams: opened.ruleParams(),
|
|
@@ -52,17 +53,18 @@ export function hfsView(opened, repoRoot) {
|
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
|
-
* The view of the
|
|
56
|
+
* The view of the app folder on disk at `repoRoot`, against the manifest under `runtimeRoot`: a side folder (be/, fe/) finds the
|
|
57
|
+
* app-root hfs.json one level up and gets that side's view, so every linted file below it is judged by its own side's slots.
|
|
56
58
|
*
|
|
57
|
-
* @param {{ runtimeRoot: string, repoRoot: string }} input - Where the manifest copy lives and the
|
|
59
|
+
* @param {{ runtimeRoot: string, repoRoot: string }} input - Where the manifest copy lives and the side folder (or app root).
|
|
58
60
|
* @returns {object} The frozen view.
|
|
59
61
|
*/
|
|
60
62
|
export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, repoRoot }), repoRoot);
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
|
-
* The view of an in-memory declaration (rule tests): no file is read except the manifest.
|
|
65
|
+
* The view of one side of an in-memory app declaration (rule tests): no file is read except the manifest.
|
|
64
66
|
*
|
|
65
|
-
* @param {{ runtimeRoot: string, declaration: object, repoRoot: string }} input - Manifest root, hfs.json object, linted
|
|
67
|
+
* @param {{ runtimeRoot: string, declaration: object, repoRoot: string, side: string }} input - Manifest root, hfs.json object, the linted side folder, its side.
|
|
66
68
|
* @returns {object} The frozen view.
|
|
67
69
|
*/
|
|
68
|
-
export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot }) => hfsView(openHfs({ root: runtimeRoot, declaration }), repoRoot);
|
|
70
|
+
export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => hfsView(openHfs({ root: runtimeRoot, declaration, side }), repoRoot);
|
|
@@ -39,6 +39,9 @@ export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meanin
|
|
|
39
39
|
* - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
|
|
40
40
|
* - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
|
|
41
41
|
* phrase lists of the archetype recognisers).
|
|
42
|
+
* An op manifest (modules/ops/ops/<id>.yaml, the starci/op@1 documents check-op-manifest types) is declared by
|
|
43
|
+
* OP_MANIFEST_VIETNAMESE_FIELDS: its text objects are {en, vi} (modules/schemas/op.schema.yaml $defs text), and `vi` is the
|
|
44
|
+
* owner's reading of the same rule.
|
|
42
45
|
*/
|
|
43
46
|
export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
|
|
44
47
|
'modules/kernel/failure-codes.yaml': Object.freeze({ fields: FAILURE_CODE_VIETNAMESE_FIELDS }),
|
|
@@ -51,6 +54,13 @@ export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
|
|
|
51
54
|
}),
|
|
52
55
|
});
|
|
53
56
|
|
|
57
|
+
/** The `vi` field of an op manifest's text objects (modules/schemas/op.schema.yaml $defs text). */
|
|
58
|
+
export const OP_MANIFEST_VIETNAMESE_FIELDS = Object.freeze({ fields: Object.freeze(['vi']) });
|
|
59
|
+
const OP_MANIFEST_DIR = 'modules/ops/ops/';
|
|
60
|
+
/** The declared Vietnamese fields of `rel`: its own entry, or the op-manifest text field for a manifest of modules/ops/ops. */
|
|
61
|
+
export const declaredVietnameseFieldsOf = (rel) => DECLARED_VIETNAMESE_FIELDS[rel]
|
|
62
|
+
?? (rel.startsWith(OP_MANIFEST_DIR) && !rel.slice(OP_MANIFEST_DIR.length).includes('/') && /\.ya?ml$/.test(rel) ? OP_MANIFEST_VIETNAMESE_FIELDS : null);
|
|
63
|
+
|
|
54
64
|
/**
|
|
55
65
|
* The slots whose files may carry another language, and why: a catalog is product copy in two languages, and an i18n
|
|
56
66
|
* fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
|
|
@@ -86,7 +96,7 @@ export function yamlKeyOfEachLine(text) {
|
|
|
86
96
|
|
|
87
97
|
/** The Vietnamese hits of a document, minus the declared field-level exceptions of `rel` (a repository-relative POSIX path). */
|
|
88
98
|
export function documentLanguageHits(rel, text) {
|
|
89
|
-
const declared =
|
|
99
|
+
const declared = declaredVietnameseFieldsOf(rel);
|
|
90
100
|
const hits = secondLanguageHits(text);
|
|
91
101
|
if (!declared) return hits;
|
|
92
102
|
const keys = yamlKeyOfEachLine(text);
|