@starci/hfs 4.0.9 → 4.1.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 +1 -1
- package/LICENSE +21 -0
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +82 -8
- package/package.json +3 -2
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +247 -273
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +18 -18
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +102 -69
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -397
- package/runtime/modules/models/runtimes.yaml +92 -262
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +36 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +52 -0
- package/runtime/scripts/api/process/run-program.mjs +8 -0
- package/runtime/scripts/api/sops/decrypt.mjs +9 -16
- package/runtime/scripts/api/sops/lib.mjs +309 -11
- package/runtime/scripts/api/sops/seal.mjs +8 -4
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +3 -2
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +90 -45
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/fs-kind.mjs +14 -4
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/mutation-fence.mjs +15 -0
- package/runtime/scripts/lib/path-key.mjs +42 -9
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +103 -2
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
|
@@ -18,40 +18,34 @@
|
|
|
18
18
|
// runtime repository itself: one profile `runtime`, no sides and no app kinds, slot ids runtime.<name>, the tracked value
|
|
19
19
|
// `generated` (a copy written only by the slot's `generatedBy`). A runtime repository declares itself with
|
|
20
20
|
// hfs.json {"hfs": <major>, "kind": "runtime", "project": <name>}.
|
|
21
|
-
import { ruleParamsProblems } from './rule-params-shape.mjs';
|
|
22
21
|
import fs from 'node:fs';
|
|
23
22
|
import path from 'node:path';
|
|
24
23
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
25
24
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
26
|
-
import { braceVariants
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
25
|
+
import { braceVariants } from '../lib/glob.mjs';
|
|
26
|
+
import { APP_KIND, RUNTIME_KIND, manifestKind } from './manifest-shape.mjs';
|
|
27
|
+
import { APP_SCOPE, manifestShapeProblems, PROFILES } from './slot-manifest-shape.mjs';
|
|
28
|
+
import { manifestSemanticProblems } from './slot-semantic-problems.mjs';
|
|
29
|
+
import { classifyIn, ownerIn, slotEnabled, tierIn } from './slot-classify.mjs';
|
|
30
|
+
import { compileVariant, varsOf, variantsOf } from './slot-match.mjs';
|
|
31
|
+
import { importAllowedIn } from './slot-imports.mjs';
|
|
32
|
+
import { requiredFilesIn, requiredPathsIn } from './slot-required.mjs';
|
|
33
|
+
import { appResolver } from './slot-app-view.mjs';
|
|
34
|
+
import { sideProblems } from './slot-side-problems.mjs';
|
|
32
35
|
import { declarationShapeProblems } from './declaration-shape.mjs';
|
|
33
|
-
import { declarationEdition, editionRuleParams, effectiveSlot,
|
|
36
|
+
import { declarationEdition, editionRuleParams, effectiveSlot, slotInEdition } from './edition-slots.mjs';
|
|
37
|
+
import { fail } from './slot-errors.mjs';
|
|
38
|
+
import { loadRuleCatalog } from './rule-catalog.mjs';
|
|
34
39
|
export { litePresenceOf, slotInEdition } from './edition-slots.mjs';
|
|
40
|
+
export { HfsSlotsError } from './slot-errors.mjs';
|
|
41
|
+
export { loadRuleCatalog };
|
|
42
|
+
export { rules } from './rule-catalog.mjs';
|
|
43
|
+
export { APP_SCOPE };
|
|
35
44
|
export const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
|
|
36
45
|
/** The manifest of kind runtime: the standard tree of the StarCi runtime repository (judged by scripts/hfs/runtime-check.mjs). */
|
|
37
46
|
export const RUNTIME_MANIFEST_FILE = 'knowledge/hfs/runtime-slots.yaml';
|
|
38
47
|
export const HFS_DECLARATION_FILE = 'hfs.json';
|
|
39
|
-
|
|
40
|
-
/** A refusal with a catalogued code (modules/kernel/failure-codes.yaml) and the facts a check reports. */
|
|
41
|
-
export class HfsSlotsError extends Error {
|
|
42
|
-
constructor(code, message, details = {}) {
|
|
43
|
-
super(`${code}: ${message}`);
|
|
44
|
-
this.name = 'HfsSlotsError';
|
|
45
|
-
this.code = code;
|
|
46
|
-
this.details = details;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The sides of an app; each lives in the folder of its name and is the profile of its slots. */
|
|
51
|
-
const PROFILES = ['be', 'fe'];
|
|
52
48
|
export const SIDES = Object.freeze([...PROFILES]);
|
|
53
|
-
/** The profile of the app root's own slots. */
|
|
54
|
-
export const APP_SCOPE = 'app';
|
|
55
49
|
|
|
56
50
|
/**
|
|
57
51
|
* The rewriter of a side's finding messages: `(message) => message` with its paths made app-relative like the finding's own path.
|
|
@@ -64,204 +58,15 @@ export function appRelativeMessages(side, sideRoot) {
|
|
|
64
58
|
let entries = [];
|
|
65
59
|
try { entries = fs.readdirSync(sideRoot).filter((name) => name !== 'node_modules' && !name.startsWith('.git')); } catch { /* no side folder: nothing to rewrite */ }
|
|
66
60
|
if (!entries.length) return (message) => message;
|
|
67
|
-
const escaped = entries.sort((a, b) => b.length - a.length).map((name) => name.replace(/[.*+?^${}()|[\]\\]/g,
|
|
61
|
+
const escaped = entries.sort((a, b) => b.length - a.length).map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`));
|
|
68
62
|
const token = new RegExp(`(^|[\\s'"\`(\\[{,;=<>])((?:${escaped.join('|')})(?=/|[\\s'"\`)\\]},;:!?<>]|\\.(?:\\s|$)|$))`, 'g');
|
|
69
63
|
return (message) => (typeof message === 'string' && message
|
|
70
64
|
? message.replace(token, (whole, before, entry, offset) => (message.startsWith('/', offset + whole.length) || entry.includes('.') ? `${before}${side}/${entry}` : whole))
|
|
71
65
|
: message);
|
|
72
66
|
}
|
|
73
67
|
|
|
74
|
-
const SCOPES = [APP_SCOPE, ...PROFILES];
|
|
75
|
-
|
|
76
|
-
// ------------------------------------------------------------------------------------------------ patterns
|
|
77
|
-
|
|
78
|
-
const VAR = /<([A-Za-z][A-Za-z0-9-]*)>/g;
|
|
79
|
-
const varsOf = (text) => captureNames(text, VAR);
|
|
80
|
-
const hasWildcard = (segment) => /[*?]/.test(segment);
|
|
81
|
-
|
|
82
|
-
/** Weight of one pattern segment: literal 4, literal mixed with a variable 3, a bare variable 2, a wildcard 1, `**` 0. */
|
|
83
|
-
function segmentWeight(segment) {
|
|
84
|
-
if (segment === '**') return 0;
|
|
85
|
-
if (hasWildcard(segment)) return 1;
|
|
86
|
-
if (/^<[^>]+>$/.test(segment)) return 2;
|
|
87
|
-
if (segment.includes('<')) return 3;
|
|
88
|
-
return 4;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The RegExp source of one pattern segment (no separators); variables capture, and are listed in `names`. */
|
|
92
|
-
function segmentSource(segment, names) {
|
|
93
|
-
let source = '';
|
|
94
|
-
for (let i = 0; i < segment.length; i += 1) {
|
|
95
|
-
const c = segment[i];
|
|
96
|
-
if (c === '<') {
|
|
97
|
-
const end = segment.indexOf('>', i);
|
|
98
|
-
names.push(segment.slice(i + 1, end));
|
|
99
|
-
source += '([^/]+?)';
|
|
100
|
-
i = end;
|
|
101
|
-
} else if (c === '*') source += '[^/]*';
|
|
102
|
-
else if (c === '?') source += '[^/]';
|
|
103
|
-
else source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
104
|
-
}
|
|
105
|
-
return source;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** One brace-free pattern compiled: `dir` patterns (trailing /) own everything below their root. */
|
|
109
|
-
function compileVariant(slot, pattern) {
|
|
110
|
-
const dir = pattern.endsWith('/');
|
|
111
|
-
const body = dir ? pattern.slice(0, -1) : pattern;
|
|
112
|
-
const segments = body.split('/');
|
|
113
|
-
const names = [];
|
|
114
|
-
let source = '';
|
|
115
|
-
segments.forEach((segment, index) => {
|
|
116
|
-
const last = index === segments.length - 1;
|
|
117
|
-
if (segment === '**') source += last ? '.*' : '(?:.*/)?';
|
|
118
|
-
else source += segmentSource(segment, names) + (last ? '' : '/');
|
|
119
|
-
});
|
|
120
|
-
return {
|
|
121
|
-
slot,
|
|
122
|
-
pattern,
|
|
123
|
-
dir,
|
|
124
|
-
segments,
|
|
125
|
-
names,
|
|
126
|
-
score: segments.reduce((sum, s) => sum + segmentWeight(s), 0),
|
|
127
|
-
wildcards: segments.filter(hasWildcard).length,
|
|
128
|
-
regex: new RegExp(`^(${source})${dir ? '(?:/.*)?' : ''}$`),
|
|
129
|
-
segmentRegexes: segments.map((s) => (s === '**' ? null : new RegExp(`^${segmentSource(s, [])}$`))),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const variantsOf = (slot) => braceVariants(slot.path).map((pattern) => compileVariant(slot, pattern));
|
|
134
|
-
|
|
135
|
-
const levenshtein = (a, b) => {
|
|
136
|
-
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
137
|
-
for (let i = 1; i <= a.length; i += 1) {
|
|
138
|
-
let prev = row[0];
|
|
139
|
-
row[0] = i;
|
|
140
|
-
for (let j = 1; j <= b.length; j += 1) {
|
|
141
|
-
const held = row[j];
|
|
142
|
-
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
143
|
-
prev = held;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return row[b.length];
|
|
147
|
-
};
|
|
148
|
-
|
|
149
68
|
// ------------------------------------------------------------------------------------------- manifest
|
|
150
69
|
|
|
151
|
-
const fail = (code, message, details) => { throw new HfsSlotsError(code, message, details); };
|
|
152
|
-
|
|
153
|
-
/** Shape problems of a parsed manifest, in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
154
|
-
function manifestShapeProblems(m) {
|
|
155
|
-
const bad = [];
|
|
156
|
-
if (!isPlainObject(m)) return ['the manifest is not a map'];
|
|
157
|
-
if (!MANIFEST_KINDS.includes(manifestKind(m))) return [`kind must be one of ${MANIFEST_KINDS.join(', ')}`];
|
|
158
|
-
if (manifestKind(m) === RUNTIME_KIND) return runtimeShapeProblems(m);
|
|
159
|
-
const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'editions', 'sides', 'appKinds', 'triggerKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers', 'naming']);
|
|
160
|
-
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
161
|
-
if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
|
|
162
|
-
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
163
|
-
if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'retire', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major, retire and pins text');
|
|
164
|
-
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
165
|
-
if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED)) bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
|
|
166
|
-
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
167
|
-
if (JSON.stringify(m.editions) !== JSON.stringify(EDITIONS)) bad.push(`editions must be ${EDITIONS.join(', ')}`);
|
|
168
|
-
if (!isPlainObject(m.sides) || Object.keys(m.sides).sort().join() !== PROFILES.join()) bad.push('sides must be a map with exactly be and fe');
|
|
169
|
-
else for (const side of PROFILES) {
|
|
170
|
-
const def = m.sides[side];
|
|
171
|
-
if (!isPlainObject(def) || Object.keys(def).join() !== 'reads' || !Array.isArray(def.reads) || !def.reads.every((r) => typeof r === 'string' && /^[a-z][a-z0-9-]*\/([^/]+\/)+$/.test(r)) || new Set(def.reads).size !== def.reads.length) bad.push(`sides.${side} must be {reads: [unique <owner>/<dir>/ paths]}`);
|
|
172
|
-
}
|
|
173
|
-
if (m.triggerKinds !== undefined && (!Array.isArray(m.triggerKinds) || !m.triggerKinds.length || !m.triggerKinds.every((k) => NAME.test(String(k))) || new Set(m.triggerKinds).size !== m.triggerKinds.length)) bad.push('triggerKinds must be a non-empty list of unique names');
|
|
174
|
-
for (const key of ['appKinds', 'tiers']) {
|
|
175
|
-
if (!isPlainObject(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
|
|
176
|
-
for (const extra of Object.keys(m[key])) if (!PROFILES.includes(extra)) bad.push(`${key}.${extra} is not a profile`);
|
|
177
|
-
for (const profile of PROFILES) if (!(profile in m[key])) bad.push(`${key}.${profile} is missing`);
|
|
178
|
-
}
|
|
179
|
-
for (const profile of PROFILES) {
|
|
180
|
-
const kinds = m.appKinds?.[profile];
|
|
181
|
-
if (kinds !== undefined && (!Array.isArray(kinds) || !kinds.length || !kinds.every((k) => NAME.test(String(k))) || new Set(kinds).size !== kinds.length)) bad.push(`appKinds.${profile} must be a non-empty list of unique names`);
|
|
182
|
-
const tiers = m.tiers?.[profile];
|
|
183
|
-
if (tiers === undefined) continue;
|
|
184
|
-
bad.push(...tierMapProblems(profile, tiers));
|
|
185
|
-
}
|
|
186
|
-
bad.push(...ruleParamsProblems(m));
|
|
187
|
-
if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
|
|
188
|
-
m.slots.forEach((slot, index) => bad.push(...slotProblems(slot, index, APP_KIND, { appScope: APP_SCOPE, scopes: SCOPES })));
|
|
189
|
-
return bad;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** The rules a JSON Schema cannot state: unique ids, tiers named and reachable, app kinds, variables, no duplicate pattern. */
|
|
193
|
-
function manifestSemanticProblems(m) {
|
|
194
|
-
const bad = [];
|
|
195
|
-
const ids = new Set();
|
|
196
|
-
const claimed = new Map();
|
|
197
|
-
for (const slot of m.slots) {
|
|
198
|
-
if (ids.has(slot.id)) bad.push(`slot id ${slot.id} appears twice`);
|
|
199
|
-
ids.add(slot.id);
|
|
200
|
-
const pathVars = new Set(varsOf(slot.path));
|
|
201
|
-
for (const profile of slot.profiles) {
|
|
202
|
-
if (profile === APP_SCOPE) {
|
|
203
|
-
// The app root holds no source: its slots take no part in import checks and describe no app.
|
|
204
|
-
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}`);
|
|
205
|
-
if (slot.tier !== 'none') bad.push(`slot ${slot.id}: an app-root slot has tier none`);
|
|
206
|
-
} else {
|
|
207
|
-
const tiers = m.tiers[profile];
|
|
208
|
-
if (slot.tier !== 'none' && slot.tier !== 'inherit' && !(slot.tier in tiers)) bad.push(`slot ${slot.id}: tier ${slot.tier} is not a ${profile} tier`);
|
|
209
|
-
if (slot.appKind !== undefined && !m.appKinds[profile].includes(slot.appKind)) bad.push(`slot ${slot.id}: app kind ${slot.appKind} is not a ${profile} kind`);
|
|
210
|
-
bad.push(...triggerProblems(slot, m.triggerKinds));
|
|
211
|
-
}
|
|
212
|
-
if (slot.appKind === undefined) {
|
|
213
|
-
for (const variant of braceVariants(slot.path)) {
|
|
214
|
-
const key = `${profile}:${variant}`;
|
|
215
|
-
if (claimed.has(key)) bad.push(`slots ${claimed.get(key)} and ${slot.id} claim the same pattern ${variant} on ${profile}`);
|
|
216
|
-
else claimed.set(key, slot.id);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (slot.appKind !== undefined && !pathVars.has('app')) bad.push(`slot ${slot.id}: an app-kind slot binds <app> in its path`);
|
|
221
|
-
if (slot.requiredWhen !== undefined && slot.presence !== 'required') bad.push(`slot ${slot.id}: requiredWhen belongs to a required slot`);
|
|
222
|
-
if (!slotInEdition(m, slot, 'lite') && (slot.litePresence !== undefined || slot.lite !== undefined)) bad.push(`slot ${slot.id}: litePresence/lite on a slot lite never sees (editions)`);
|
|
223
|
-
if (isPlainObject(slot.litePresence) && slot.profiles.includes(APP_SCOPE)) bad.push(`slot ${slot.id}: an app-root slot takes a bare litePresence, not a per-side map`);
|
|
224
|
-
for (const profile of slot.profiles) if (litePresenceOf(slot, profile) === 'forbidden' && typeof slot.goesTo !== 'string') bad.push(`slot ${slot.id}: forbidden in lite for ${profile}, so it says where the content goes (goesTo)`);
|
|
225
|
-
if (slot.lite?.path !== undefined) {
|
|
226
|
-
for (const name of varsOf(slot.lite.path)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: lite.path binds <${name}>, which the path does not`);
|
|
227
|
-
for (const variant of braceVariants(slot.lite.path)) {
|
|
228
|
-
try { compileVariant(slot, variant); } catch (error) { bad.push(`slot ${slot.id}: lite.path ${variant} does not compile (${error.message})`); }
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
for (const name of Object.keys(slot.requiredInstances ?? {})) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requiredInstances names <${name}>, which the path does not bind`);
|
|
232
|
-
for (const file of Object.values(slot.roles ?? {})) for (const name of varsOf(file)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: roles names <${name}> in ${file}, which the path does not bind`);
|
|
233
|
-
for (const entry of slot.requires ?? []) for (const name of varsOf(entry)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requires ${entry} uses <${name}>, which the path does not bind`);
|
|
234
|
-
for (const variant of braceVariants(slot.path)) {
|
|
235
|
-
try { compileVariant(slot, variant); } catch (error) { bad.push(`slot ${slot.id}: pattern ${variant} does not compile (${error.message})`); }
|
|
236
|
-
}
|
|
237
|
-
if (slot.successor !== undefined && !m.slots.some((s) => s.id === slot.successor)) bad.push(`slot ${slot.id}: successor ${slot.successor} is not a slot`);
|
|
238
|
-
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`);
|
|
239
|
-
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`);
|
|
240
|
-
}
|
|
241
|
-
if (manifestKind(m) === RUNTIME_KIND) return [...bad, ...runtimeSemanticProblems(m)];
|
|
242
|
-
for (const side of PROFILES) for (const read of m.sides[side].reads) {
|
|
243
|
-
const [owner, ...rest] = read.split('/');
|
|
244
|
-
const below = rest.join('/');
|
|
245
|
-
if (owner === side) bad.push(`sides.${side}.reads names ${read}, which is its own side`);
|
|
246
|
-
// A side read is a path of the other side (be/contracts/); an app-root read (supabase/types/) names a tree an app slot owns.
|
|
247
|
-
else if (PROFILES.includes(owner)) {
|
|
248
|
-
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`);
|
|
249
|
-
} else if (!m.slots.some((s) => s.profiles.includes(APP_SCOPE) && s.presence !== 'forbidden' && braceVariants(s.path).some((v) => v.startsWith(read)))) bad.push(`sides.${side}.reads names ${read}, which no app-root slot owns`);
|
|
250
|
-
}
|
|
251
|
-
for (const profile of PROFILES) {
|
|
252
|
-
for (const [tier, def] of Object.entries(m.tiers[profile])) {
|
|
253
|
-
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`);
|
|
254
|
-
}
|
|
255
|
-
for (const kind of m.appKinds[profile]) {
|
|
256
|
-
const owners = m.slots.filter((s) => s.profiles.includes(profile) && s.appKind === kind);
|
|
257
|
-
if (owners.length !== 1) bad.push(`${profile} app kind ${kind} must have exactly one slot (found ${owners.length})`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
const major = Number(m.version.split('.')[0]);
|
|
261
|
-
if (String(m.schema) !== `starci/hfs-slots@${major}`) bad.push(`schema ${m.schema} does not carry the major of version ${m.version}`);
|
|
262
|
-
return bad;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
70
|
/**
|
|
266
71
|
* The parsed and validated manifest. `text` (or `file`, or `root`) selects the source; the default is the runtime's own
|
|
267
72
|
* knowledge/hfs/slots.yaml. A manifest that fails its shape or a semantic rule is refused whole (HFS_MANIFEST_INVALID).
|
|
@@ -270,29 +75,52 @@ export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_
|
|
|
270
75
|
let doc;
|
|
271
76
|
try { doc = parseYaml(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_MANIFEST_INVALID', `the slot manifest cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
|
|
272
77
|
const problems = manifestShapeProblems(doc);
|
|
273
|
-
if (!problems.length) problems.push(...manifestSemanticProblems(doc));
|
|
274
|
-
if (problems.length) fail('HFS_MANIFEST_INVALID', `the slot manifest breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ?
|
|
78
|
+
if (!problems.length) problems.push(...manifestSemanticProblems(doc, { varsOf, braceVariants, compileVariant }));
|
|
79
|
+
if (problems.length) fail('HFS_MANIFEST_INVALID', `the slot manifest breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems });
|
|
275
80
|
const [major, minor, patch] = doc.version.split('.').map(Number);
|
|
276
81
|
return Object.freeze({ ...doc, major, minor, patch });
|
|
277
82
|
}
|
|
278
83
|
|
|
279
84
|
// ------------------------------------------------------------------------------------- declaration
|
|
280
85
|
|
|
281
|
-
const declarationInvalid = (problems, file) => fail('HFS_DECLARATION_INVALID', `hfs.json is refused: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ?
|
|
86
|
+
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 });
|
|
282
87
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
88
|
+
function runtimeDeclaration(manifest, declaration, file, side) {
|
|
89
|
+
if (side !== null) fail('HFS_DECLARATION_INVALID', 'a runtime repository has no sides', { file, side });
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
hfs: declaration.hfs,
|
|
92
|
+
kind: RUNTIME_KIND,
|
|
93
|
+
project: declaration.project,
|
|
94
|
+
side: null,
|
|
95
|
+
profile: RUNTIME_KIND,
|
|
96
|
+
apps: Object.freeze([]),
|
|
97
|
+
optionalSlots: Object.freeze([]),
|
|
98
|
+
connections: Object.freeze([]),
|
|
99
|
+
reads: Object.freeze([]),
|
|
100
|
+
manifestVersion: manifest.version,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const connectionView = (c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix, owner: c.owner, isolation: c.isolation, ...(c.provider !== undefined ? { provider: c.provider } : {}) });
|
|
105
|
+
|
|
106
|
+
/** The declaration of one side of an app, frozen: the view a check of that side folder runs under. */
|
|
107
|
+
function sideDeclaration(manifest, declaration, name, edition, providers) {
|
|
108
|
+
const s = declaration.sides[name];
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
hfs: declaration.hfs,
|
|
111
|
+
kind: APP_KIND,
|
|
112
|
+
project: declaration.project,
|
|
113
|
+
edition,
|
|
114
|
+
side: name,
|
|
115
|
+
profile: name,
|
|
116
|
+
apps: Object.freeze(s.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
|
|
117
|
+
optionalSlots: Object.freeze([...(s.optionalSlots ?? [])]),
|
|
118
|
+
patterns: Object.freeze([...(s.patterns ?? [])]), kinds: Object.freeze([...(s.kinds ?? [])]),
|
|
119
|
+
connections: Object.freeze((s.connections ?? []).map(connectionView)),
|
|
120
|
+
providers,
|
|
121
|
+
reads: Object.freeze([...(s.reads ?? [])]),
|
|
122
|
+
manifestVersion: manifest.version,
|
|
123
|
+
});
|
|
296
124
|
}
|
|
297
125
|
|
|
298
126
|
/**
|
|
@@ -307,21 +135,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
|
|
|
307
135
|
if (declaration.kind !== manifestKind(manifest)) declarationInvalid([`hfs.json is of kind ${declaration.kind}, but the manifest it is judged by is of kind ${manifestKind(manifest)}`], file);
|
|
308
136
|
if (declaration.hfs !== manifest.major)
|
|
309
137
|
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 });
|
|
310
|
-
if (declaration.kind === RUNTIME_KIND)
|
|
311
|
-
if (side !== null) fail('HFS_DECLARATION_INVALID', 'a runtime repository has no sides', { file, side });
|
|
312
|
-
return Object.freeze({
|
|
313
|
-
hfs: declaration.hfs,
|
|
314
|
-
kind: RUNTIME_KIND,
|
|
315
|
-
project: declaration.project,
|
|
316
|
-
side: null,
|
|
317
|
-
profile: RUNTIME_KIND,
|
|
318
|
-
apps: Object.freeze([]),
|
|
319
|
-
optionalSlots: Object.freeze([]),
|
|
320
|
-
connections: Object.freeze([]),
|
|
321
|
-
reads: Object.freeze([]),
|
|
322
|
-
manifestVersion: manifest.version,
|
|
323
|
-
});
|
|
324
|
-
}
|
|
138
|
+
if (declaration.kind === RUNTIME_KIND) return runtimeDeclaration(manifest, declaration, file, side);
|
|
325
139
|
const { edition, known, valid } = declarationEdition(manifest, declaration);
|
|
326
140
|
if (!valid) fail('HFS_EDITION_INVALID', `hfs.json edition is ${JSON.stringify(declaration.edition)}; the editions this manifest knows are ${known.join(', ')} (absent means full)`, { file, edition: declaration.edition });
|
|
327
141
|
const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name]));
|
|
@@ -329,24 +143,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
|
|
|
329
143
|
if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
|
|
330
144
|
// A provider declared on any connection enables the provider slots of every profile (a fe side declares none): each view carries the union.
|
|
331
145
|
const providers = Object.freeze([...new Set(PROFILES.flatMap((name) => (declaration.sides[name].connections ?? []).map((c) => c.provider).filter((p) => p !== undefined)))]);
|
|
332
|
-
const sides = Object.fromEntries(PROFILES.map((name) =>
|
|
333
|
-
const s = declaration.sides[name];
|
|
334
|
-
return [name, Object.freeze({
|
|
335
|
-
hfs: declaration.hfs,
|
|
336
|
-
kind: APP_KIND,
|
|
337
|
-
project: declaration.project,
|
|
338
|
-
edition,
|
|
339
|
-
side: name,
|
|
340
|
-
profile: name,
|
|
341
|
-
apps: Object.freeze(s.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
|
|
342
|
-
optionalSlots: Object.freeze([...(s.optionalSlots ?? [])]),
|
|
343
|
-
patterns: Object.freeze([...(s.patterns ?? [])]), kinds: Object.freeze([...(s.kinds ?? [])]),
|
|
344
|
-
connections: Object.freeze((s.connections ?? []).map((c) => Object.freeze({ name: c.name, envPrefix: c.envPrefix, owner: c.owner, isolation: c.isolation, ...(c.provider !== undefined ? { provider: c.provider } : {}) }))),
|
|
345
|
-
providers,
|
|
346
|
-
reads: Object.freeze([...(s.reads ?? [])]),
|
|
347
|
-
manifestVersion: manifest.version,
|
|
348
|
-
})];
|
|
349
|
-
}));
|
|
146
|
+
const sides = Object.fromEntries(PROFILES.map((name) => [name, sideDeclaration(manifest, declaration, name, edition, providers)]));
|
|
350
147
|
if (side !== null) return sides[side];
|
|
351
148
|
return Object.freeze({
|
|
352
149
|
hfs: declaration.hfs,
|
|
@@ -393,8 +190,6 @@ export function readRepoDeclaration(manifest, repoRoot) {
|
|
|
393
190
|
|
|
394
191
|
// ------------------------------------------------------------------------------------------- resolver
|
|
395
192
|
|
|
396
|
-
const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
|
|
397
|
-
|
|
398
193
|
/**
|
|
399
194
|
* The four questions for one scope: the app root (profile app, root paths) or one side (profile be or fe, paths relative to the
|
|
400
195
|
* side folder). createSlotResolver composes them; nothing else calls this.
|
|
@@ -403,234 +198,25 @@ function createScopeResolver(manifest, repo) {
|
|
|
403
198
|
const profile = repo.profile;
|
|
404
199
|
const edition = repo.edition ?? 'full';
|
|
405
200
|
const slots = manifest.slots.filter((s) => s.profiles.includes(profile) && slotInEdition(manifest, s, edition)).map((s) => effectiveSlot(s, profile, edition));
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
const appKind = new Map(repo.apps.map((a) => [a.name, a.kind]));
|
|
409
|
-
|
|
410
|
-
const slotEnabled = (slot) => {
|
|
411
|
-
// A provider slot is gated by its provider in every edition: litePresence may lift it to required, but it still
|
|
412
|
-
// exists for this repository only while a connection declares the provider.
|
|
413
|
-
if (slot.provider !== undefined) return declaredSlotEnabled(slot, repo);
|
|
414
|
-
if (slot.presence !== 'opt-in') return true;
|
|
415
|
-
return declaredSlotEnabled(slot, repo);
|
|
416
|
-
};
|
|
417
|
-
const clean = (p) => posixPath(p).replace(/\/+$/, '');
|
|
418
|
-
|
|
419
|
-
/** Every variant matching `p`, with its root and bindings, minus app-kind slots of another kind. */
|
|
420
|
-
function matches(p, only) {
|
|
421
|
-
const found = [];
|
|
422
|
-
for (const variant of variants) {
|
|
423
|
-
if (only && !only(variant.slot)) continue;
|
|
424
|
-
const m = variant.regex.exec(p);
|
|
425
|
-
if (!m) continue;
|
|
426
|
-
const bindings = {};
|
|
427
|
-
variant.names.forEach((name, i) => { bindings[name] = m[i + 2]; });
|
|
428
|
-
if (variant.slot.appKind !== undefined && appKind.get(bindings.app) !== variant.slot.appKind) continue;
|
|
429
|
-
const root = variant.dir ? m[1] : path.posix.dirname(m[1]);
|
|
430
|
-
found.push({ variant, slot: variant.slot, root: root === '.' ? '' : root, bindings });
|
|
431
|
-
}
|
|
432
|
-
return found.sort((a, b) => b.variant.score - a.variant.score || a.variant.wildcards - b.variant.wildcards);
|
|
433
|
-
}
|
|
434
|
-
const best = (found) => {
|
|
435
|
-
if (!found.length) return { hit: null, ambiguous: [] };
|
|
436
|
-
const top = found.filter((f) => f.variant.score === found[0].variant.score && f.variant.wildcards === found[0].variant.wildcards);
|
|
437
|
-
const distinct = [...new Set(top.map((f) => f.slot.id))];
|
|
438
|
-
return distinct.length > 1 ? { hit: null, ambiguous: distinct } : { hit: top[0], ambiguous: [] };
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
/** The leading segments of `p` a variant accepts, and how far it got; how the nearest slot of an unknown path is found. */
|
|
442
|
-
function nearest(p) {
|
|
443
|
-
const parts = p.split('/');
|
|
444
|
-
let winner = null;
|
|
445
|
-
for (const variant of variants) {
|
|
446
|
-
let depth = 0;
|
|
447
|
-
while (depth < parts.length && depth < variant.segments.length) {
|
|
448
|
-
const rx = variant.segmentRegexes[depth];
|
|
449
|
-
if (rx === null || !rx.test(parts[depth])) break;
|
|
450
|
-
depth += 1;
|
|
451
|
-
}
|
|
452
|
-
const expected = variant.segments[depth] ?? '';
|
|
453
|
-
const distance = levenshtein(parts[depth] ?? '', expected.replace(VAR, ''));
|
|
454
|
-
const shape = Math.abs(variant.segments.length - parts.length);
|
|
455
|
-
const cand = { variant, depth, shape, distance };
|
|
456
|
-
const better = !winner || depth > winner.depth || (depth === winner.depth && (shape < winner.shape || (shape === winner.shape && (distance < winner.distance || (distance === winner.distance && variant.score > winner.variant.score)))));
|
|
457
|
-
if (better) winner = cand;
|
|
458
|
-
}
|
|
459
|
-
if (!winner) return null;
|
|
460
|
-
return {
|
|
461
|
-
slot: winner.variant.slot.id,
|
|
462
|
-
pattern: winner.variant.pattern,
|
|
463
|
-
matchedDepth: winner.depth,
|
|
464
|
-
matchedPrefix: parts.slice(0, winner.depth).join('/'),
|
|
465
|
-
expectedNext: winner.variant.segments[winner.depth] ?? null,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* The folder kind of a file: the layer or kind folder its slot names (`layers`, `kinds`) that the file sits in. A slot whose
|
|
471
|
-
* path spells the choice (`components/{blocks,leaves}/<name>/`) answers from the matched pattern; a slot that owns a whole
|
|
472
|
-
* directory (`packages/<family>-ui/`) answers from the first folder below its root that the list names.
|
|
473
|
-
*/
|
|
474
|
-
function kindOf({ variant, slot, root }, p) {
|
|
475
|
-
const names = [...(slot.layers ?? []), ...(slot.kinds ?? [])];
|
|
476
|
-
if (!names.length) return null;
|
|
477
|
-
const literal = variant.segments.find((segment) => names.includes(segment));
|
|
478
|
-
if (literal) return literal;
|
|
479
|
-
const below = root ? p.slice(root.length + 1) : p;
|
|
480
|
-
return below.split('/').slice(0, -1).find((segment) => names.includes(segment)) ?? null;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/** The role of a file in its slot: the entry of `roles` whose file name (variables filled from the path, `*` a wildcard inside the name) matches the file's name. */
|
|
484
|
-
function roleOf(slot, bindings, p) {
|
|
485
|
-
const name = p.split('/').pop();
|
|
486
|
-
for (const [role, file] of Object.entries(slot.roles ?? {})) if (globExpression(fillVars(file, bindings)).test(name)) return role;
|
|
487
|
-
return null;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* status: owned | forbidden (external slot) | not-enabled (opt-in slot the repository did not declare) | ambiguous
|
|
492
|
-
* (two slots of equal specificity; a manifest gap) | no-slot (code HFS_SLOT_UNDECLARED, with the nearest slot).
|
|
493
|
-
*/
|
|
494
|
-
function classifyPath(input) {
|
|
495
|
-
const p = clean(input);
|
|
496
|
-
const { hit, ambiguous } = best(matches(p));
|
|
497
|
-
if (ambiguous.length) return { path: p, status: 'ambiguous', candidates: ambiguous };
|
|
498
|
-
if (!hit) return { path: p, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: nearest(p) };
|
|
499
|
-
const { slot, root, bindings } = hit;
|
|
500
|
-
const status = slot.presence === 'forbidden' ? 'forbidden' : (slotEnabled(slot) ? 'owned' : 'not-enabled');
|
|
501
|
-
const kind = kindOf(hit, p);
|
|
502
|
-
const role = roleOf(slot, bindings, p);
|
|
503
|
-
return { path: p, status, slot: slot.id, root, bindings, ...(kind ? { kind } : {}), ...(role ? { role } : {}), presence: slot.presence, tracking: slot.tracked, ...(status === 'forbidden' ? { goesTo: slot.goesTo } : {}) };
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const slotOf = (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; };
|
|
507
|
-
|
|
508
|
-
/** The owner unit of `p`: the most specific owner slot instance containing it (a slot's own root when it owns nothing). */
|
|
509
|
-
function ownerOf(input) {
|
|
510
|
-
const p = clean(input);
|
|
511
|
-
const { hit } = best(matches(p, (s) => s.owner === true));
|
|
512
|
-
return hit ? { slot: hit.slot.id, root: hit.root, bindings: hit.bindings } : null;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/** The tier of `p` in the direction matrix: its slot's tier, the owner's for an inheriting slot, none for an untiered one; null when no slot owns it. */
|
|
516
|
-
function tierOf(input) {
|
|
517
|
-
const c = classifyPath(input);
|
|
518
|
-
if (!c.slot) return null;
|
|
519
|
-
const tier = byId.get(c.slot).tier;
|
|
520
|
-
if (tier !== 'inherit') return tier;
|
|
521
|
-
const owner = ownerOf(input);
|
|
522
|
-
return owner ? byId.get(owner.slot).tier : 'none';
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
const layerIndex = (slot, root) => (slot.layers ? slot.layers.findIndex((layer) => root.split('/').includes(layer)) : -1);
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
|
|
529
|
-
* tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_SLOT_UNDECLARED for the path no slot owns).
|
|
530
|
-
* Cycles are a graph property and belong to the architecture check, not to one edge.
|
|
531
|
-
*/
|
|
532
|
-
function importAllowed(fromPath, toPath) {
|
|
533
|
-
const from = classifyPath(fromPath);
|
|
534
|
-
const to = classifyPath(toPath);
|
|
535
|
-
for (const side of [from, to]) if (side.status === 'no-slot') return { allowed: false, reason: 'unowned', code: side.code, path: side.path, nearest: side.nearest };
|
|
536
|
-
for (const side of [from, to]) if (side.status !== 'owned') return { allowed: false, reason: `slot${side.status[0].toUpperCase()}${side.status.slice(1).replace(/-(.)/g, (_, c) => c.toUpperCase())}`, path: side.path, slot: side.slot };
|
|
537
|
-
const fromTier = tierOf(from.path);
|
|
538
|
-
const toTier = tierOf(to.path);
|
|
539
|
-
if (fromTier === 'none' || toTier === 'none') return { allowed: true, reason: 'untiered' };
|
|
540
|
-
if (from.bindings.app !== undefined && to.bindings.app !== undefined && from.bindings.app !== to.bindings.app)
|
|
541
|
-
return { allowed: false, reason: 'crossApp', from: from.bindings.app, to: to.bindings.app };
|
|
542
|
-
const fromOwner = ownerOf(from.path);
|
|
543
|
-
const toOwner = ownerOf(to.path);
|
|
544
|
-
const unit = (side, owner) => (owner ? `${owner.slot}:${owner.root}` : `${side.slot}:${side.root}`);
|
|
545
|
-
if (unit(from, fromOwner) === unit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
|
|
546
|
-
if (!manifest.tiers[profile][fromTier]?.mayImport.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: manifest.tiers[profile][fromTier]?.mayImport ?? [] };
|
|
547
|
-
if (manifest.tiers[profile][fromTier].lowerLayerOnly && fromTier === toTier) {
|
|
548
|
-
const fromSlot = byId.get((fromOwner ?? from).slot);
|
|
549
|
-
const a = layerIndex(fromSlot, fromOwner?.root ?? from.root);
|
|
550
|
-
const b = layerIndex(byId.get((toOwner ?? to).slot), toOwner?.root ?? to.root);
|
|
551
|
-
if (a >= 0 && b >= 0 && b <= a) return { allowed: false, reason: 'layerOrder', fromLayer: fromSlot.layers[a], toLayer: fromSlot.layers[b] };
|
|
552
|
-
}
|
|
553
|
-
// A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
|
|
554
|
-
if (toOwner && repo.kind !== RUNTIME_KIND) {
|
|
555
|
-
const relative = to.path === toOwner.root ? '' : to.path.slice(toOwner.root.length + 1);
|
|
556
|
-
const ownerSlot = byId.get(toOwner.slot);
|
|
557
|
-
const entry = isEntryFile(relative) || (byId.get(to.slot).entries ?? []).includes(to.path.slice(to.root.length + 1)) || (ownerSlot.tier === 'package' && relative === 'src/index.ts') || (ownerSlot.tier === 'app' && relative === 'app.module.ts');
|
|
558
|
-
if (!entry) return { allowed: false, reason: 'notPublicEntry', owner: toOwner.root, path: to.path };
|
|
559
|
-
}
|
|
560
|
-
return { allowed: true, reason: 'allowed', fromTier, toTier };
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const fillVars = (text, bindings) => String(text).replace(VAR, (whole, name) => bindings[name] ?? whole);
|
|
564
|
-
|
|
565
|
-
/** The files and directories the instance holding `p` must contain (directories end with /); {path} entries are repo-relative. */
|
|
566
|
-
function requiredFiles(input) {
|
|
567
|
-
const c = classifyPath(input);
|
|
568
|
-
if (!c.slot) return [];
|
|
569
|
-
const slot = byId.get(c.slot);
|
|
570
|
-
return (slot.requires ?? []).map((entry) => {
|
|
571
|
-
const rooted = entry.startsWith('/');
|
|
572
|
-
const filled = fillVars(rooted ? entry.slice(1) : entry, c.bindings);
|
|
573
|
-
return rooted || !c.root ? filled : `${c.root}/${filled}`;
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/**
|
|
578
|
-
* What the repository must contain, without walking it: {paths: [{slot, path, via}], minimums: [{slot, min}]}.
|
|
579
|
-
* via is slot (a fixed path), instance (a requiredInstances or app-kind root) or requires (a file an instance needs).
|
|
580
|
-
*/
|
|
581
|
-
function requiredPaths() {
|
|
582
|
-
const paths = [];
|
|
583
|
-
const minimums = [];
|
|
584
|
-
const expandApps = (slot) => repo.apps.filter((a) => slot.appKind === undefined || a.kind === slot.appKind);
|
|
585
|
-
for (const slot of slots) {
|
|
586
|
-
if (slot.presence !== 'required' || !slotEnabled(slot)) continue;
|
|
587
|
-
if (slot.requiredWhen === 'connections' && !repo.connections.length) continue;
|
|
588
|
-
if (slot.minInstances) minimums.push({ slot: slot.id, min: slot.minInstances, ...(slot.appKind ? { appKind: slot.appKind } : {}) });
|
|
589
|
-
for (const variant of braceVariants(slot.path)) {
|
|
590
|
-
const names = [...new Set(varsOf(variant))];
|
|
591
|
-
const fixed = { ...(slot.requiredInstances ?? {}) };
|
|
592
|
-
const open = names.filter((n) => n !== 'app' && !(n in fixed));
|
|
593
|
-
if (open.length) continue; // an instance-level slot: its instances are found by walking the tree
|
|
594
|
-
const combos = [{}];
|
|
595
|
-
const grow = (name, values) => { const next = []; for (const c of combos) for (const v of values) next.push({ ...c, [name]: v }); combos.splice(0, combos.length, ...next); };
|
|
596
|
-
if (names.includes('app')) grow('app', expandApps(slot).map((a) => a.name));
|
|
597
|
-
for (const [name, values] of Object.entries(fixed)) grow(name, values);
|
|
598
|
-
const isInstance = names.length > 0;
|
|
599
|
-
for (const bindings of combos) {
|
|
600
|
-
const target = fillVars(variant, bindings);
|
|
601
|
-
paths.push({ slot: slot.id, path: target, via: isInstance ? 'instance' : 'slot' });
|
|
602
|
-
const root = target.endsWith('/') ? target.slice(0, -1) : path.posix.dirname(target);
|
|
603
|
-
for (const entry of slot.requires ?? []) {
|
|
604
|
-
const rooted = entry.startsWith('/');
|
|
605
|
-
const filled = fillVars(rooted ? entry.slice(1) : entry, bindings);
|
|
606
|
-
paths.push({ slot: slot.id, path: rooted || !root || root === '.' ? filled : `${root}/${filled}`, via: 'requires' });
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
const seen = new Set();
|
|
612
|
-
return { paths: paths.filter((p) => { const key = `${p.slot}|${p.path}`; if (seen.has(key)) return false; seen.add(key); return true; }), minimums };
|
|
613
|
-
}
|
|
614
|
-
|
|
201
|
+
const scope = { manifest, repo, profile, slots, byId: new Map(slots.map((s) => [s.id, s])), variants: slots.flatMap(variantsOf), appKind: new Map(repo.apps.map((a) => [a.name, a.kind])) };
|
|
202
|
+
const classifyPath = (input) => classifyIn(scope, input);
|
|
615
203
|
/** tracked | ignored | external for the slot owning `p`, or null when no slot owns it. */
|
|
616
204
|
const trackingOf = (p) => classifyPath(p).tracking ?? null;
|
|
617
|
-
/** True only for a path a slot owns and requires to be committed. */
|
|
618
|
-
const isTracked = (p) => trackingOf(p) === 'tracked';
|
|
619
|
-
|
|
620
205
|
return Object.freeze({
|
|
621
206
|
repo,
|
|
622
|
-
slot: (id) => byId.get(id) ?? null,
|
|
207
|
+
slot: (id) => scope.byId.get(id) ?? null,
|
|
623
208
|
slots: () => slots,
|
|
624
|
-
slotEnabled,
|
|
209
|
+
slotEnabled: (slot) => slotEnabled(scope, slot),
|
|
625
210
|
classifyPath,
|
|
626
|
-
slotOf,
|
|
627
|
-
ownerOf,
|
|
628
|
-
tierOf,
|
|
629
|
-
importAllowed,
|
|
630
|
-
requiredFiles,
|
|
631
|
-
requiredPaths,
|
|
211
|
+
slotOf: (p) => { const c = classifyPath(p); return c.slot ? scope.byId.get(c.slot) : null; },
|
|
212
|
+
ownerOf: (input) => ownerIn(scope, input),
|
|
213
|
+
tierOf: (input) => tierIn(scope, input),
|
|
214
|
+
importAllowed: (fromPath, toPath) => importAllowedIn(scope, fromPath, toPath),
|
|
215
|
+
requiredFiles: (input) => requiredFilesIn(scope, input),
|
|
216
|
+
requiredPaths: () => requiredPathsIn(scope),
|
|
632
217
|
trackingOf,
|
|
633
|
-
|
|
218
|
+
/** True only for a path a slot owns and requires to be committed. */
|
|
219
|
+
isTracked: (p) => trackingOf(p) === 'tracked',
|
|
634
220
|
ruleParams: () => ruleParams(manifest, profile, edition),
|
|
635
221
|
allowedImports: (tier) => manifest.tiers[profile]?.[tier]?.mayImport ?? null,
|
|
636
222
|
});
|
|
@@ -649,76 +235,7 @@ export function createSlotResolver(manifest, repo) {
|
|
|
649
235
|
// side's connection with that provider: the root scope reads the union of the sides' connections.
|
|
650
236
|
const root = createScopeResolver(manifest, { ...repo, connections: SIDES.flatMap((side) => repo.sides[side].connections) });
|
|
651
237
|
const sides = Object.fromEntries(PROFILES.map((side) => [side, createScopeResolver(manifest, repo.sides[side])]));
|
|
652
|
-
|
|
653
|
-
/** { side, rest } when `p` lies below a side folder, else null. */
|
|
654
|
-
const split = (input) => {
|
|
655
|
-
const p = clean(input);
|
|
656
|
-
const slash = p.indexOf('/');
|
|
657
|
-
const head = slash < 0 ? p : p.slice(0, slash);
|
|
658
|
-
return PROFILES.includes(head) && slash > 0 ? { side: head, rest: p.slice(slash + 1) } : null;
|
|
659
|
-
};
|
|
660
|
-
const under = (side, rel) => (rel ? `${side}/${rel}` : side);
|
|
661
|
-
const prefixed = (side, c) => ({
|
|
662
|
-
...c,
|
|
663
|
-
path: under(side, c.path),
|
|
664
|
-
side,
|
|
665
|
-
...(c.root !== undefined ? { root: under(side, c.root) } : {}),
|
|
666
|
-
...(c.nearest ? { nearest: { ...c.nearest, matchedPrefix: under(side, c.nearest.matchedPrefix), matchedDepth: c.nearest.matchedDepth + 1 } } : {}),
|
|
667
|
-
});
|
|
668
|
-
const classifyPath = (input) => { const at = split(input); return at ? prefixed(at.side, sides[at.side].classifyPath(at.rest)) : root.classifyPath(input); };
|
|
669
|
-
const ownerOf = (input) => {
|
|
670
|
-
const at = split(input);
|
|
671
|
-
if (!at) return root.ownerOf(input);
|
|
672
|
-
const owner = sides[at.side].ownerOf(at.rest);
|
|
673
|
-
return owner ? { ...owner, root: under(at.side, owner.root), side: at.side } : null;
|
|
674
|
-
};
|
|
675
|
-
const allSlots = [...new Map([...root.slots(), ...PROFILES.flatMap((side) => sides[side].slots())].map((s) => [s.id, s])).values()];
|
|
676
|
-
const byId = new Map(allSlots.map((s) => [s.id, s]));
|
|
677
|
-
/** Whether `toPath`, of the other side, lies below a path `fromSide` declares it reads. */
|
|
678
|
-
const reads = (fromSide, toPath) => repo.sides[fromSide].reads.some((read) => `${clean(toPath)}/`.startsWith(read));
|
|
679
|
-
return Object.freeze({
|
|
680
|
-
repo,
|
|
681
|
-
sides: Object.freeze(sides),
|
|
682
|
-
/** The side of a path (be, fe) or null for a root path. */
|
|
683
|
-
sideOf: (input) => split(input)?.side ?? null,
|
|
684
|
-
slot: (id) => byId.get(id) ?? null,
|
|
685
|
-
slots: () => allSlots,
|
|
686
|
-
slotEnabled: (slot) => (slot.profiles.includes(APP_SCOPE) ? root.slotEnabled(slot) : PROFILES.some((side) => slot.profiles.includes(side) && sides[side].slotEnabled(slot))),
|
|
687
|
-
classifyPath,
|
|
688
|
-
slotOf: (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; },
|
|
689
|
-
ownerOf,
|
|
690
|
-
tierOf: (input) => { const at = split(input); return at ? sides[at.side].tierOf(at.rest) : root.tierOf(input); },
|
|
691
|
-
importAllowed(fromPath, toPath) {
|
|
692
|
-
const from = split(fromPath);
|
|
693
|
-
const to = split(toPath);
|
|
694
|
-
if (from && to && from.side !== to.side) {
|
|
695
|
-
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 };
|
|
696
|
-
}
|
|
697
|
-
// A side file may read an app-root path (supabase/types/) only through a declared read, exactly as it reads the other side.
|
|
698
|
-
if (from && !to) {
|
|
699
|
-
return reads(from.side, toPath) ? { allowed: true, reason: 'sideRead', fromSide: from.side, toSide: null } : { allowed: false, reason: 'crossSide', fromSide: from.side, toSide: null, reads: repo.sides[from.side].reads };
|
|
700
|
-
}
|
|
701
|
-
if (from && to) return sides[from.side].importAllowed(from.rest, to.rest);
|
|
702
|
-
return root.importAllowed(fromPath, toPath);
|
|
703
|
-
},
|
|
704
|
-
requiredFiles: (input) => { const at = split(input); return at ? sides[at.side].requiredFiles(at.rest).map((p) => under(at.side, p)) : root.requiredFiles(input); },
|
|
705
|
-
requiredPaths() {
|
|
706
|
-
const own = root.requiredPaths();
|
|
707
|
-
const paths = [...own.paths];
|
|
708
|
-
const minimums = [...own.minimums];
|
|
709
|
-
for (const side of PROFILES) {
|
|
710
|
-
const required = sides[side].requiredPaths();
|
|
711
|
-
paths.push(...required.paths.map((entry) => ({ ...entry, path: under(side, entry.path), side })));
|
|
712
|
-
minimums.push(...required.minimums.map((entry) => ({ ...entry, side })));
|
|
713
|
-
}
|
|
714
|
-
return { paths, minimums };
|
|
715
|
-
},
|
|
716
|
-
trackingOf: (p) => classifyPath(p).tracking ?? null,
|
|
717
|
-
isTracked: (p) => classifyPath(p).tracking === 'tracked',
|
|
718
|
-
// The root has no tier and no rule parameters of its own; each side has them (sides.<side>.ruleParams()).
|
|
719
|
-
ruleParams: () => null,
|
|
720
|
-
allowedImports: () => null,
|
|
721
|
-
});
|
|
238
|
+
return appResolver(repo, root, sides);
|
|
722
239
|
}
|
|
723
240
|
|
|
724
241
|
/** The rule parameters of one profile (be: infraOwners, suffixes, bannedSuffixes and the rest over the shared ruleParams.common fileLines and duplicateBlock; fe: common alone or with its overrides; runtime: ruleParams.runtime of a runtime manifest), as a frozen deep copy. Under `edition` lite the `lite` overrides of ruleParams.<profile> merge over the base (the `lite` key itself is never returned). */
|
|
@@ -736,141 +253,3 @@ export function openHfs({ root = skillRoot, repoRoot, declaration, side = null,
|
|
|
736
253
|
const repo = declaration !== undefined ? resolveRepoDeclaration(manifest, declaration, { side }) : readRepoDeclaration(manifest, repoRoot);
|
|
737
254
|
return { manifest, repo, ...createSlotResolver(manifest, repo), rules: () => loadRuleCatalog({ root, manifest }) };
|
|
738
255
|
}
|
|
739
|
-
|
|
740
|
-
// ------------------------------------------------------------------------------------------ rule catalog
|
|
741
|
-
|
|
742
|
-
const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
|
|
743
|
-
const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar', 'runtime']);
|
|
744
|
-
const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
|
|
745
|
-
const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
746
|
-
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
747
|
-
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
748
|
-
const FILE_ENFORCERS = ['machine', 'hfs', 'work-validate', 'sonar', 'runtime'];
|
|
749
|
-
|
|
750
|
-
/** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
|
|
751
|
-
function ruleCatalogProblems(d) {
|
|
752
|
-
const bad = [];
|
|
753
|
-
if (!isPlainObject(d)) return ['the rule catalog is not a map'];
|
|
754
|
-
for (const key of Object.keys(d)) if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules', 'retired'].includes(key)) bad.push(`unknown top-level key ${key}`);
|
|
755
|
-
const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(d.schema));
|
|
756
|
-
if (!schemaOk) bad.push('schema must be starci/hfs-rules@<major>');
|
|
757
|
-
if (!SEMVER.test(String(d.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
758
|
-
else if (schemaOk && d.schema.split('@')[1] !== d.version.split('.')[0]) bad.push('the major of version must equal the number after @ in schema');
|
|
759
|
-
const vocabulary = (key, names) => {
|
|
760
|
-
if (!isPlainObject(d[key])) { bad.push(`${key} must be a map`); return; }
|
|
761
|
-
if (JSON.stringify(Object.keys(d[key])) !== JSON.stringify(names)) bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
|
|
762
|
-
for (const [name, text] of Object.entries(d[key])) if (typeof text !== 'string' || !text.trim()) bad.push(`${key}.${name} needs a description`);
|
|
763
|
-
};
|
|
764
|
-
vocabulary('gates', RULE_GATES);
|
|
765
|
-
vocabulary('enforcerKinds', ENFORCER_FAMILIES);
|
|
766
|
-
if (!Array.isArray(d.rules) || !d.rules.length) { bad.push('rules must be a non-empty list'); return bad; }
|
|
767
|
-
const codeOwner = new Map();
|
|
768
|
-
d.rules.forEach((r, index) => {
|
|
769
|
-
const at = `rules[${index}]`;
|
|
770
|
-
if (!isPlainObject(r)) { bad.push(`${at} is not a map`); return; }
|
|
771
|
-
const label = typeof r.id === 'string' ? r.id : at;
|
|
772
|
-
for (const key of Object.keys(r)) if (!['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
|
|
773
|
-
// A retired rule leaves its id unused for good (never reused), so ids only have to increase.
|
|
774
|
-
if (!/^R\d{2,3}$/.test(String(r.id))) bad.push(`${at}.id must be R<two or three digits>`);
|
|
775
|
-
else if (index > 0 && typeof d.rules[index - 1]?.id === 'string' && Number(r.id.slice(1)) <= Number(d.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${d.rules[index - 1].id} comes before it`);
|
|
776
|
-
if (!FINDING_CODE.test(String(r.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
|
|
777
|
-
if (typeof r.law !== 'string' || !r.law.trim()) bad.push(`${label}.law is missing`);
|
|
778
|
-
if (typeof r.law === 'string' && r.law.includes('\n')) bad.push(`${label}.law must be one line`);
|
|
779
|
-
if (r.scope !== undefined && r.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
|
|
780
|
-
const enumList = (key, allowed) => {
|
|
781
|
-
if (!Array.isArray(r[key]) || !r[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
|
|
782
|
-
for (const v of r[key]) if (!allowed.includes(v)) bad.push(`${label}.${key} has ${JSON.stringify(v)}, not one of ${allowed.join(', ')}`);
|
|
783
|
-
if (new Set(r[key]).size !== r[key].length) bad.push(`${label}.${key} repeats a value`);
|
|
784
|
-
return r[key];
|
|
785
|
-
};
|
|
786
|
-
enumList('kinds', RULE_KINDS);
|
|
787
|
-
bad.push(...ruleEditionProblems(r, label));
|
|
788
|
-
const gates = enumList('gates', RULE_GATES);
|
|
789
|
-
if (gates.length) {
|
|
790
|
-
if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
|
|
791
|
-
if (gates.includes('pre-commit') && !gates.includes('pre-push')) bad.push(`${label} runs at pre-commit, so it also runs at pre-push`);
|
|
792
|
-
}
|
|
793
|
-
if (!Array.isArray(r.failureCodes) || !r.failureCodes.length || !r.failureCodes.every((c) => FINDING_CODE.test(String(c)))) bad.push(`${label}.failureCodes must be a non-empty list of UPPER_SNAKE codes`);
|
|
794
|
-
else {
|
|
795
|
-
if (r.failureCodes[0] !== r.code) bad.push(`${label}.failureCodes must start with the rule's own code ${r.code}`);
|
|
796
|
-
if (new Set(r.failureCodes).size !== r.failureCodes.length) bad.push(`${label}.failureCodes repeats a code`);
|
|
797
|
-
for (const c of r.failureCodes) {
|
|
798
|
-
if (codeOwner.has(c) && codeOwner.get(c) !== label) bad.push(`${label} names ${c}, which ${codeOwner.get(c)} already owns`);
|
|
799
|
-
codeOwner.set(c, label);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
if (!Array.isArray(r.enforcers) || !r.enforcers.length) { bad.push(`${label}.enforcers must name at least one enforcer`); return; }
|
|
803
|
-
const seen = new Set();
|
|
804
|
-
r.enforcers.forEach((e, n) => {
|
|
805
|
-
const eat = `${label}.enforcers[${n}]`;
|
|
806
|
-
if (!isPlainObject(e)) { bad.push(`${eat} is not a map`); return; }
|
|
807
|
-
for (const key of Object.keys(e)) if (!['kind', 'id', 'status', 'at', 'editions'].includes(key)) bad.push(`${eat} has unknown key ${key}`);
|
|
808
|
-
bad.push(...ruleEditionProblems(e, eat));
|
|
809
|
-
if (!ENFORCER_FAMILIES.includes(e.kind)) bad.push(`${eat}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
|
|
810
|
-
if (!ENFORCER_ID.test(String(e.id))) bad.push(`${eat}.id must be kebab-case`);
|
|
811
|
-
if (seen.has(`${e.kind}:${e.id}`)) bad.push(`${eat} repeats ${e.kind}:${e.id}`);
|
|
812
|
-
seen.add(`${e.kind}:${e.id}`);
|
|
813
|
-
if (e.status !== undefined && e.status !== 'planned') bad.push(`${eat}.status is either absent or planned`);
|
|
814
|
-
if (e.at !== undefined) {
|
|
815
|
-
if (typeof e.at !== 'string' || !e.at.trim() || e.at.startsWith('/') || e.at.includes('..')) bad.push(`${eat}.at must be a repository-relative path`);
|
|
816
|
-
if (e.status === 'planned') bad.push(`${eat} is planned, so it has no file yet (at)`);
|
|
817
|
-
if (!FILE_ENFORCERS.includes(e.kind)) bad.push(`${eat}.at belongs to a machine, hfs, work-validate, sonar or runtime enforcer only`);
|
|
818
|
-
} else if (FILE_ENFORCERS.includes(e.kind) && e.status !== 'planned') bad.push(`${eat} exists, so it names the file (at) that emits its code`);
|
|
819
|
-
});
|
|
820
|
-
if (Array.isArray(r.gates)) {
|
|
821
|
-
const hasSonar = r.enforcers.some((e) => isPlainObject(e) && e.kind === 'sonar');
|
|
822
|
-
if (r.gates.includes('sonar') !== hasSonar) bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
|
|
823
|
-
}
|
|
824
|
-
});
|
|
825
|
-
return bad;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const deepFreeze = (v) => { if (v && typeof v === 'object') Object.values(v).forEach(deepFreeze); return Object.freeze(v); };
|
|
829
|
-
|
|
830
|
-
/**
|
|
831
|
-
* The parsed and validated HFS rule catalog. `text` (or `file`, or `root`) selects the source; the default is the runtime's
|
|
832
|
-
* own knowledge/hfs/rules.yaml. A catalog that breaks its shape or a semantic rule is refused whole (HFS_RULES_INVALID), and
|
|
833
|
-
* so is one whose major differs from the slot manifest passed as `manifest` (HFS_MANIFEST_MAJOR_MISMATCH).
|
|
834
|
-
* Answers: rule(id), byCode(code), forGate(gate), forEnforcer(kind, id), planned() and unbuilt().
|
|
835
|
-
*/
|
|
836
|
-
export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_RULES_FILE), text, manifest } = {}) {
|
|
837
|
-
let doc;
|
|
838
|
-
try { doc = parseYaml(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
|
|
839
|
-
const problems = ruleCatalogProblems(doc);
|
|
840
|
-
if (problems.length) fail('HFS_RULES_INVALID', `the rule catalog breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? `; and ${problems.length - 5} more` : ''}`, { file, problems });
|
|
841
|
-
const [major, minor, patch] = doc.version.split('.').map(Number);
|
|
842
|
-
if (manifest && manifest.major !== major) fail('HFS_MANIFEST_MAJOR_MISMATCH', `the rule catalog is major ${major} but the slot manifest is major ${manifest.major}`, { catalog: major, manifest: manifest.major });
|
|
843
|
-
const list = deepFreeze(doc.rules.map((r) => ({ ...r, enforcers: r.enforcers.map((e) => ({ ...e, planned: e.status === 'planned' })) })));
|
|
844
|
-
const byId = new Map(list.map((r) => [r.id, r]));
|
|
845
|
-
const byCode = new Map(list.flatMap((r) => r.failureCodes.map((c) => [c, r])));
|
|
846
|
-
return Object.freeze({
|
|
847
|
-
version: doc.version, major, minor, patch,
|
|
848
|
-
gates: deepFreeze(structuredClone(doc.gates)),
|
|
849
|
-
enforcerKinds: deepFreeze(structuredClone(doc.enforcerKinds)),
|
|
850
|
-
rules: list, retired: deepFreeze(structuredClone(doc.retired ?? [])),
|
|
851
|
-
/** The rule with this id (R01..), or null. */
|
|
852
|
-
rule: (id) => byId.get(id) ?? null,
|
|
853
|
-
/** The rule that owns this failure code (its own or a sub-check code), or null. */
|
|
854
|
-
byCode: (code) => byCode.get(code) ?? null,
|
|
855
|
-
/** Whether a finding code is judged under `edition`: a code of a rule that names `editions` without it is not (a code outside the catalog always is). */
|
|
856
|
-
judgedIn: (code, edition = 'full') => judgedInEdition(byCode.get(code), edition),
|
|
857
|
-
enforcerJudgedIn: (kind, id, edition = 'full') => enforcerJudgedInEdition(list, kind, id, edition),
|
|
858
|
-
/** The rules that run at a gate. */
|
|
859
|
-
forGate: (gate) => list.filter((r) => r.gates.includes(gate)),
|
|
860
|
-
/** The catalogued why code of a lint finding's rule id (`starci-be/<id>`, `starci-fe/<id>`), or undefined. */
|
|
861
|
-
lintCode: (ruleId) => {
|
|
862
|
-
const [plugin, id] = String(ruleId ?? '').split('/');
|
|
863
|
-
const kind = plugin === 'starci-be' ? 'eslint-be' : plugin === 'starci-fe' ? 'eslint-fe' : null;
|
|
864
|
-
return kind ? list.find((r) => r.enforcers.some((e) => e.kind === kind && e.id === id))?.code : undefined;
|
|
865
|
-
},
|
|
866
|
-
/** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
|
|
867
|
-
forEnforcer: (kind, id) => list.filter((r) => r.enforcers.some((e) => e.kind === kind && e.id === id)),
|
|
868
|
-
/** Every enforcer still owed, as {rule, kind, id}. */
|
|
869
|
-
planned: () => list.flatMap((r) => r.enforcers.filter((e) => e.planned).map((e) => ({ rule: r.id, kind: e.kind, id: e.id }))),
|
|
870
|
-
/** The rules with no existing enforcer at all. */
|
|
871
|
-
unbuilt: () => list.filter((r) => r.enforcers.every((e) => e.planned)),
|
|
872
|
-
});
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
/** The rules of this runtime's catalog, frozen, in id order. */
|
|
876
|
-
export const rules = (options) => loadRuleCatalog(options).rules;
|