@starci/hfs 4.0.7 → 4.0.8
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 +13 -0
- package/README.md +10 -9
- package/bin/hfs.mjs +4 -4
- package/lint/run.mjs +1 -1
- package/package.json +2 -2
- package/runtime/engine/runtime-root.mjs +5 -0
- package/runtime/engine/yaml.mjs +3 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/slots.yaml +5 -5
- package/runtime/knowledge/patterns/fe/folder.yaml +2 -2
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +12 -2
- package/runtime/scripts/api/fs/lib.mjs +6 -0
- package/runtime/scripts/api/fs/rmdir-link.mjs +1 -1
- package/runtime/scripts/{lib → api/fs}/safe-remove.mjs +31 -82
- package/runtime/scripts/api/git/lib.mjs +1 -1
- package/runtime/scripts/api/sops/decrypt.mjs +1 -1
- package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
- package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/config.mjs +2 -2
- package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +3 -3
- package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
- package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +18 -105
- package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +2 -2
- package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture.mjs +2 -2
- package/runtime/scripts/{lib/hfs-check.mjs → hfs/check.mjs} +33 -25
- package/runtime/scripts/hfs/manifest-shape.mjs +170 -0
- package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +6 -6
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/contract.mjs +3 -2
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +66 -0
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/integration-specs.mjs +1 -1
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +2 -2
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/stacks.mjs +2 -2
- package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +52 -58
- package/runtime/scripts/{lib/hfs-tree.mjs → hfs/tree.mjs} +1 -1
- package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +3 -3
- package/runtime/scripts/lib/graphql-contract.mjs +429 -0
- package/runtime/scripts/lib/is-main.mjs +13 -0
- package/runtime/scripts/lib/language.mjs +2 -2
- package/runtime/scripts/lib/path-key.mjs +2 -0
- package/runtime/scripts/lib/stack-declaration.mjs +2 -2
- package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +1 -12
- package/scaffold/app.mjs +1 -1
- package/scaffold/service.mjs +2 -2
- package/sync/hygiene.mjs +8 -8
- package/sync/index.mjs +48 -11
- package/templates/app/quality-config/codecov.yml +1 -1
- package/templates/app/quality-config/sonar-project.properties +1 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +2 -2
- package/templates/fe/skeleton/apps/__app__/next.config.ts +10 -2
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +2 -2
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +0 -2
- package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +1 -7
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +0 -2
- package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +0 -1
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +0 -4
- package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +0 -4
- package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +0 -4
- package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +1 -1
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +8 -3
- package/templates/fe/skeleton/apps/__app__/src/proxy.ts +1 -1
- package/runtime/engine/admission.mjs +0 -284
- package/runtime/engine/digest.mjs +0 -10
- package/runtime/engine/ledger-db.mjs +0 -1245
- package/runtime/engine/machine-db.mjs +0 -1565
- package/runtime/engine/migrations/machine/0001-init.sql +0 -887
- package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +0 -13
- package/runtime/engine/migrations/machine/0003-worktrees-workflow-orca.sql +0 -21
- package/runtime/engine/migrations/runtime/0001-init.sql +0 -1072
- package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +0 -13
- package/runtime/engine/migrations/runtime/0004-attempt-why.sql +0 -43
- package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +0 -93
- package/runtime/scripts/lib/artifact-hold.mjs +0 -89
- package/runtime/scripts/lib/artifact-store.mjs +0 -103
- package/runtime/scripts/lib/redact.mjs +0 -148
- /package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/index.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
- /package/runtime/scripts/{lib → hfs}/repo-identity.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/deps.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules/frontend.mjs → hfs/rules/frontend-tree.mjs} +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/lint-suppression.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/peer-integrations.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/pipeline.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/proof-commands.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/repo-local-checks.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +0 -0
- /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/test-topology.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// manifest-shape.mjs - the shape rules of an HFS slot manifest that both manifest kinds share, and the whole shape of a
|
|
2
|
+
// manifest of kind runtime (knowledge/hfs/runtime-slots.yaml): the slot fields, the tier map, ruleParams.runtime and the
|
|
3
|
+
// `pending` allowlist. scripts/hfs/slots.mjs (loadSlotManifest) is the only reader; it adds the app kind's own rules
|
|
4
|
+
// (sides, app kinds) and refuses a manifest whole on any problem (HFS_MANIFEST_INVALID). Pure: every function takes a
|
|
5
|
+
// parsed manifest or slot and returns a list of problems in the words of modules/schemas/hfs-slots.schema.yaml.
|
|
6
|
+
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
7
|
+
|
|
8
|
+
export const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
9
|
+
export const NAME = /^[a-z][a-z0-9-]*$/;
|
|
10
|
+
export const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
11
|
+
export const SLOT_ID = /^(app|repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
12
|
+
export const RUNTIME_SLOT_ID = /^runtime\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
13
|
+
export const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
|
|
14
|
+
export const TRACKED = ['tracked', 'ignored', 'external'];
|
|
15
|
+
/** A runtime manifest adds `generated`: tracked, written only by the slot's generatedBy, judged by drift (RT_GENERATED_DRIFT). */
|
|
16
|
+
export const RUNTIME_TRACKED = [...TRACKED, 'generated'];
|
|
17
|
+
export const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
18
|
+
export const APP_KIND = 'app';
|
|
19
|
+
/** The kind of a manifest and of a declaration that describes the StarCi runtime repository; also its one profile. */
|
|
20
|
+
export const RUNTIME_KIND = 'runtime';
|
|
21
|
+
export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
22
|
+
/** The kind of a parsed manifest: `kind`, or app when absent (knowledge/hfs/slots.yaml carries none). */
|
|
23
|
+
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
24
|
+
export const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
|
|
25
|
+
|
|
26
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor'];
|
|
27
|
+
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
28
|
+
const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor', 'generatedBy'];
|
|
29
|
+
|
|
30
|
+
/** Shape problems of one slot of a manifest of `kind` (app or runtime). */
|
|
31
|
+
export function slotProblems(slot, index, kind, { appScope = 'app', scopes = [] } = {}) {
|
|
32
|
+
const bad = [];
|
|
33
|
+
const runtime = kind === RUNTIME_KIND;
|
|
34
|
+
const slotKeys = new Set(runtime ? RUNTIME_SLOT_KEYS : APP_SLOT_KEYS);
|
|
35
|
+
const tracked = runtime ? RUNTIME_TRACKED : TRACKED;
|
|
36
|
+
const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
37
|
+
if (!isPlainObject(slot)) return [`${at} is not a map`];
|
|
38
|
+
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
39
|
+
if (runtime) {
|
|
40
|
+
if (!RUNTIME_SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like runtime.<name>`);
|
|
41
|
+
if (JSON.stringify(slot.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push(`${at}: profiles must be [runtime]`);
|
|
42
|
+
if (slot.tracked === 'generated' && (typeof slot.generatedBy !== 'string' || !slot.generatedBy || slot.generatedBy.startsWith('/') || slot.generatedBy.includes('..'))) bad.push(`${at}: a generated slot names its generator (generatedBy, a repository-relative path)`);
|
|
43
|
+
if (slot.generatedBy !== undefined && slot.tracked !== 'generated') bad.push(`${at}: generatedBy belongs to a generated slot`);
|
|
44
|
+
} else {
|
|
45
|
+
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
46
|
+
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(appScope) && slot.profiles.length !== 1)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
|
|
47
|
+
else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
|
|
48
|
+
}
|
|
49
|
+
if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
|
|
50
|
+
if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
|
|
51
|
+
if (!tracked.includes(slot.tracked)) bad.push(`${at}: tracked must be one of ${tracked.join(', ')}`);
|
|
52
|
+
if (!NAME.test(String(slot.tier))) bad.push(`${at}: tier must be a tier name, none or inherit`);
|
|
53
|
+
if (!TESTS.includes(slot.tests)) bad.push(`${at}: tests must be one of ${TESTS.join(', ')}`);
|
|
54
|
+
if (slot.owner !== undefined && typeof slot.owner !== 'boolean') bad.push(`${at}: owner must be a boolean`);
|
|
55
|
+
if (slot.appKind !== undefined && !NAME.test(String(slot.appKind))) bad.push(`${at}: appKind must be a name`);
|
|
56
|
+
if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
57
|
+
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
58
|
+
if (slot.requiredInstances !== undefined && !(isPlainObject(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
|
|
59
|
+
for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
60
|
+
if (slot.kinds !== undefined && strList(slot.kinds) && (!slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k)))) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
|
|
61
|
+
if (slot.roles !== undefined && !(isPlainObject(slot.roles) && Object.keys(slot.roles).length && Object.entries(slot.roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/')))) bad.push(`${at}: roles must map a role name to a file name`);
|
|
62
|
+
if (slot.composedBy !== undefined && !(strList(slot.composedBy) && slot.composedBy.length && new Set(slot.composedBy).size === slot.composedBy.length)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
|
|
63
|
+
if (slot.budget !== undefined && !(isPlainObject(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
|
|
64
|
+
if (slot.managedBy !== undefined && !NAME.test(String(slot.managedBy))) bad.push(`${at}: managedBy must be a template id`);
|
|
65
|
+
if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
|
|
66
|
+
if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
|
|
67
|
+
if (slot.retiredIn !== undefined && !(Number.isInteger(slot.retiredIn) && slot.retiredIn >= 2)) bad.push(`${at}: retiredIn must be a major`);
|
|
68
|
+
if (slot.retiredIn !== undefined && typeof slot.successor !== 'string') bad.push(`${at}: a retired slot names its successor`);
|
|
69
|
+
if (slot.tracked === 'external' && (typeof slot.goesTo !== 'string' || slot.presence !== 'forbidden')) bad.push(`${at}: an external slot is forbidden and says where it goes (goesTo)`);
|
|
70
|
+
if (slot.presence === 'forbidden' && slot.tracked !== 'external') bad.push(`${at}: a forbidden slot is external`);
|
|
71
|
+
return bad;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The owners of an external call in a runtime manifest: an api system folder (`api/<system>`, `api/*` any system) or the DB tier. */
|
|
75
|
+
const INFRA_OWNER = /^(?:api\/(?:\*|[a-z][a-z0-9-]*)|engine\/db)$/;
|
|
76
|
+
const RUNTIME_PARAM_KEYS = ['fileLines', 'sourceRoots', 'infraOwners', 'baseWriteMembers', 'baseEnvSeams', 'apiContracts', 'sourceName', 'oneOffNames', 'sharedBasenames', 'generated', 'pinned', 'selfChecks'];
|
|
77
|
+
const PENDING_KEYS = ['path', 'rule', 'lane', 'since', 'reason'];
|
|
78
|
+
/** The chunk of the runtime migration that deletes a pending entry (C1..C8, a split chunk C2a/C2b). */
|
|
79
|
+
const PENDING_LANE = /^C[1-8][ab]?$/;
|
|
80
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
81
|
+
const relPath = (v) => typeof v === 'string' && v.length > 0 && !v.startsWith('/') && !v.includes('..') && !v.includes('\\');
|
|
82
|
+
|
|
83
|
+
/** Shape problems of a parsed manifest of kind runtime (knowledge/hfs/runtime-slots.yaml), in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
84
|
+
export function runtimeShapeProblems(m) {
|
|
85
|
+
const bad = [];
|
|
86
|
+
const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'profiles', 'tiers', 'ruleParams', 'crossOwner', 'crossSystem', 'slots', 'pending', 'consumers']);
|
|
87
|
+
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
88
|
+
if (!/^starci\/runtime-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/runtime-slots@<major>');
|
|
89
|
+
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
90
|
+
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');
|
|
91
|
+
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
92
|
+
if (JSON.stringify(m.trackedValues) !== JSON.stringify(RUNTIME_TRACKED)) bad.push(`trackedValues must be ${RUNTIME_TRACKED.join(', ')}`);
|
|
93
|
+
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
94
|
+
if (JSON.stringify(m.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push('profiles must be [runtime]');
|
|
95
|
+
for (const key of ['crossOwner', 'crossSystem']) if (m[key] !== undefined && typeof m[key] !== 'string') bad.push(`${key} must be text`);
|
|
96
|
+
if (!isPlainObject(m.tiers) || Object.keys(m.tiers).join() !== RUNTIME_KIND) bad.push('tiers must be a map with exactly runtime');
|
|
97
|
+
else bad.push(...tierMapProblems(RUNTIME_KIND, m.tiers.runtime));
|
|
98
|
+
const rp = m.ruleParams;
|
|
99
|
+
if (!isPlainObject(rp) || Object.keys(rp).join() !== RUNTIME_KIND || !isPlainObject(rp.runtime)) bad.push('ruleParams must be a map with exactly runtime');
|
|
100
|
+
else bad.push(...runtimeParamProblems(rp.runtime));
|
|
101
|
+
if (!Array.isArray(m.slots) || !m.slots.length) bad.push('slots must be a non-empty list');
|
|
102
|
+
else m.slots.forEach((slot, index) => bad.push(...slotProblems(slot, index, RUNTIME_KIND)));
|
|
103
|
+
if (m.pending !== undefined) {
|
|
104
|
+
if (!Array.isArray(m.pending)) bad.push('pending must be a list');
|
|
105
|
+
else m.pending.forEach((entry, index) => {
|
|
106
|
+
const at = `pending[${index}]`;
|
|
107
|
+
if (!isPlainObject(entry)) { bad.push(`${at} is not a map`); return; }
|
|
108
|
+
for (const key of Object.keys(entry)) if (!PENDING_KEYS.includes(key)) bad.push(`${at}: unknown field ${key}`);
|
|
109
|
+
if (!relPath(entry.path)) bad.push(`${at}: path must be a repository-relative glob`);
|
|
110
|
+
if (!/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/.test(String(entry.rule))) bad.push(`${at}: rule must be a finding code`);
|
|
111
|
+
if (!PENDING_LANE.test(String(entry.lane))) bad.push(`${at}: lane must be the chunk that removes it (C1..C8, C2a, C2b)`);
|
|
112
|
+
if (!DATE.test(String(entry.since))) bad.push(`${at}: since must be a date YYYY-MM-DD`);
|
|
113
|
+
if (typeof entry.reason !== 'string' || !entry.reason.trim()) bad.push(`${at}: reason is required`);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (m.consumers !== undefined && !strList(m.consumers)) bad.push('consumers must be a list of paths');
|
|
117
|
+
return bad;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Shape problems of ruleParams.runtime. */
|
|
121
|
+
function runtimeParamProblems(rp) {
|
|
122
|
+
const bad = [];
|
|
123
|
+
for (const key of Object.keys(rp)) if (!RUNTIME_PARAM_KEYS.includes(key)) bad.push(`ruleParams.runtime.${key} is not a runtime parameter`);
|
|
124
|
+
for (const key of RUNTIME_PARAM_KEYS) if (!(key in rp)) bad.push(`ruleParams.runtime.${key} is missing`);
|
|
125
|
+
const fl = rp.fileLines;
|
|
126
|
+
if (!(isPlainObject(fl) && Number.isInteger(fl.soft) && fl.soft >= 1 && typeof fl.hardGrowth === 'boolean' && Object.keys(fl).length === 2)) bad.push('ruleParams.runtime.fileLines must be {soft, hardGrowth}');
|
|
127
|
+
const owners = rp.infraOwners;
|
|
128
|
+
const ownerMap = (v) => isPlainObject(v) && Object.entries(v).every(([key, list]) => key && Array.isArray(list) && list.every((o) => INFRA_OWNER.test(String(o))) && new Set(list).size === list.length);
|
|
129
|
+
if (!isPlainObject(owners) || Object.keys(owners).sort().join() !== 'globals,modules,programs' || !['globals', 'modules', 'programs'].every((k) => ownerMap(owners[k]))) bad.push('ruleParams.runtime.infraOwners must be {modules, globals, programs}, each mapping a name to unique owners api/<system>, api/* or engine/db ([] means nowhere)');
|
|
130
|
+
for (const key of ['sourceRoots', 'baseWriteMembers', 'oneOffNames', 'sharedBasenames']) if (!(strList(rp[key]) && rp[key].length && new Set(rp[key]).size === rp[key].length)) bad.push(`ruleParams.runtime.${key} must be a non-empty list of unique strings`);
|
|
131
|
+
if (!(Array.isArray(rp.baseEnvSeams) && rp.baseEnvSeams.every(relPath))) bad.push('ruleParams.runtime.baseEnvSeams must be a list of repository-relative paths');
|
|
132
|
+
if (!(isPlainObject(rp.apiContracts) && Object.entries(rp.apiContracts).every(([system, file]) => NAME.test(system) && relPath(file)))) bad.push('ruleParams.runtime.apiContracts must map an api system to its calls contract path');
|
|
133
|
+
let sourceNameOk = typeof rp.sourceName === 'string';
|
|
134
|
+
try { if (sourceNameOk) new RegExp(rp.sourceName); } catch { sourceNameOk = false; }
|
|
135
|
+
if (!sourceNameOk) bad.push('ruleParams.runtime.sourceName must be a regular expression');
|
|
136
|
+
if (!(Array.isArray(rp.generated) && rp.generated.length && rp.generated.every((g) => isPlainObject(g) && relPath(g.root) && relPath(g.generatedBy) && Object.keys(g).length === 2))) bad.push('ruleParams.runtime.generated must be a non-empty list of {root, generatedBy}');
|
|
137
|
+
if (!(Array.isArray(rp.pinned) && rp.pinned.length && rp.pinned.every((p) => isPlainObject(p) && relPath(p.path) && typeof p.why === 'string' && p.why && Object.keys(p).length === 2))) bad.push('ruleParams.runtime.pinned must be a non-empty list of {path, why}');
|
|
138
|
+
if (!(Array.isArray(rp.selfChecks) && rp.selfChecks.length && rp.selfChecks.every((c) => isPlainObject(c) && NAME.test(String(c.id)) && relPath(c.run) && (c.args === undefined || strList(c.args)) && Object.keys(c).every((k) => ['id', 'run', 'args'].includes(k))))) bad.push('ruleParams.runtime.selfChecks must be a non-empty list of {id, run, args?}');
|
|
139
|
+
return bad;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Shape problems of the tier map of one profile. */
|
|
143
|
+
export function tierMapProblems(profile, tiers) {
|
|
144
|
+
const bad = [];
|
|
145
|
+
if (!isPlainObject(tiers) || !Object.keys(tiers).length) return [`tiers.${profile} must be a non-empty map`];
|
|
146
|
+
for (const [tier, def] of Object.entries(tiers)) {
|
|
147
|
+
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
148
|
+
if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
|
|
149
|
+
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
|
|
150
|
+
}
|
|
151
|
+
return bad;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The semantic rules of a runtime manifest beyond the slot rules both kinds share: tier names, the schema major, unique pending entries. */
|
|
155
|
+
export function runtimeSemanticProblems(m) {
|
|
156
|
+
const bad = [];
|
|
157
|
+
const tiers = m.tiers.runtime;
|
|
158
|
+
for (const [tier, def] of Object.entries(tiers)) {
|
|
159
|
+
for (const target of def.mayImport) if (!(target in tiers)) bad.push(`tiers.runtime.${tier}.mayImport names ${target}, which is not a runtime tier`);
|
|
160
|
+
}
|
|
161
|
+
const seen = new Set();
|
|
162
|
+
for (const entry of m.pending ?? []) {
|
|
163
|
+
const key = `${entry.path}\0${entry.rule}`;
|
|
164
|
+
if (seen.has(key)) bad.push(`pending names ${entry.path} for ${entry.rule} twice`);
|
|
165
|
+
seen.add(key);
|
|
166
|
+
}
|
|
167
|
+
const major = Number(m.version.split('.')[0]);
|
|
168
|
+
if (String(m.schema) !== `starci/runtime-slots@${major}`) bad.push(`schema ${m.schema} does not carry the major of version ${m.version}`);
|
|
169
|
+
return bad;
|
|
170
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// hfs-path-findings.mjs - the per-path judgements of the slot manifest: what a tracked path is. One function, two surfaces: `hfs check`
|
|
2
2
|
// (checkRepo) runs it over the tracked tree and keeps the findings that have no TypeScript file to sit on; the lint canon's project graph
|
|
3
|
-
// (scripts/
|
|
3
|
+
// (scripts/hfs/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
|
|
4
4
|
// HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
|
|
5
5
|
// BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { allowsFile } from './
|
|
8
|
-
import { isFeTestPath } from './
|
|
9
|
-
import { slotOwnsSecrets } from './
|
|
10
|
-
import { specPlacementFindings } from './
|
|
11
|
-
import { APP_SCOPE } from './
|
|
7
|
+
import { allowsFile } from './allows.mjs';
|
|
8
|
+
import { isFeTestPath } from './rules/fe-no-tests.mjs';
|
|
9
|
+
import { slotOwnsSecrets } from './rules/secrets.mjs';
|
|
10
|
+
import { specPlacementFindings } from './rules/spec-placement.mjs';
|
|
11
|
+
import { APP_SCOPE } from './slots.mjs';
|
|
12
12
|
|
|
13
13
|
const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
14
14
|
const SOURCE_ROOT = /^(?:src|apps)\//;
|
|
@@ -11,7 +11,7 @@ import fs from 'node:fs';
|
|
|
11
11
|
import os from 'node:os';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { found } from './read.mjs';
|
|
14
|
-
import { safeRemoveTree } from '
|
|
14
|
+
import { safeRemoveTree } from '../../api/fs/safe-remove.mjs';
|
|
15
15
|
|
|
16
16
|
export const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
|
|
17
17
|
export const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
|
|
@@ -93,7 +93,8 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
} finally {
|
|
96
|
-
|
|
96
|
+
// A temp directory this rule made itself, in no ledger's repository: no indexed job artifact can live in it.
|
|
97
|
+
safeRemoveTree(scratch, { hold: () => null });
|
|
97
98
|
}
|
|
98
99
|
return { findings, apps };
|
|
99
100
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// fe-contract-documents.mjs - FE_GRAPHQL_CONTRACT (R113): every GraphQL document a front end sends is one its back end
|
|
2
|
+
// serves. An app keeps the contract of each GraphQL service it runs as a snapshot, `be/contracts/<service>/schema.graphql`
|
|
3
|
+
// (`hfs emit-contracts`, HFS_CONTRACT_SNAPSHOT_DRIFT keeps it current), and its front end keeps every operation it sends
|
|
4
|
+
// in a `.graphql` file under `fe/`. Each operation is judged against the contract of the service that serves its first
|
|
5
|
+
// root field (the one snapshot whose root type declares it): a root field no contract declares, a field, argument or input
|
|
6
|
+
// field the contract does not declare, a required argument left out, a variable of another type, a leaf with a selection or
|
|
7
|
+
// an object without one is a finding on the document. The finding names the service and every problem.
|
|
8
|
+
import { operationProblems, parseDocument, parseSchema, rootFieldsOf } from '../../lib/graphql-contract.mjs';
|
|
9
|
+
import { found, readText } from './read.mjs';
|
|
10
|
+
|
|
11
|
+
export const FE_GRAPHQL_CONTRACT = 'FE_GRAPHQL_CONTRACT';
|
|
12
|
+
|
|
13
|
+
const CONTRACT = /^be\/contracts\/([^/]+)\/schema\.graphql$/;
|
|
14
|
+
const isDocument = (file) => file.startsWith('fe/') && file.endsWith('.graphql') && !file.includes('/__generated__/') && !file.includes('/node_modules/');
|
|
15
|
+
|
|
16
|
+
/** The parsed contracts of the app: [{ service, path, schema }], or a finding for a snapshot that does not parse. */
|
|
17
|
+
function contractsOf(repoRoot, files, findings) {
|
|
18
|
+
const contracts = [];
|
|
19
|
+
for (const file of files) {
|
|
20
|
+
const match = CONTRACT.exec(file);
|
|
21
|
+
if (!match) continue;
|
|
22
|
+
const text = readText(repoRoot, file);
|
|
23
|
+
if (text === null) continue;
|
|
24
|
+
try {
|
|
25
|
+
contracts.push({ service: match[1], path: file, schema: parseSchema(text) });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file} is not a GraphQL schema the front-end documents can be judged against: ${error.message}. Run \`npm run contract:emit\`.`));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return contracts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The findings of R113 over the tracked paths `files` of the app at `repoRoot`. */
|
|
34
|
+
export function feContractFindings({ repoRoot, files }) {
|
|
35
|
+
const documents = files.filter(isDocument).sort();
|
|
36
|
+
if (documents.length === 0) return [];
|
|
37
|
+
const findings = [];
|
|
38
|
+
const contracts = contractsOf(repoRoot, files, findings);
|
|
39
|
+
for (const file of documents) {
|
|
40
|
+
const text = readText(repoRoot, file);
|
|
41
|
+
if (text === null) continue;
|
|
42
|
+
let document;
|
|
43
|
+
try {
|
|
44
|
+
document = parseDocument(text);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file} is not a GraphQL document: ${error.message}.`));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const operation of document.operations) {
|
|
50
|
+
const label = operation.name ?? `anonymous ${operation.operation}`;
|
|
51
|
+
const [first] = rootFieldsOf(operation);
|
|
52
|
+
const serving = contracts.filter(({ schema }) => schema.types.get(schema.roots[operation.operation])?.fields?.has(first));
|
|
53
|
+
if (serving.length === 0) {
|
|
54
|
+
const where = contracts.length === 0 ? 'the app keeps no be/contracts/<service>/schema.graphql' : `no contract of ${contracts.map((c) => c.path).join(', ')} declares ${operation.operation} ${first}`;
|
|
55
|
+
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} asks ${operation.operation} ${first}, but ${where}; the front end sends only what a back end serves.`, { operation: label, service: null }));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const [contract] = serving;
|
|
59
|
+
const problems = operationProblems(contract.schema, document, operation);
|
|
60
|
+
if (problems.length > 0) {
|
|
61
|
+
findings.push(found(FE_GRAPHQL_CONTRACT, file, `${file}: ${label} does not match the ${contract.service} contract (${contract.path}): ${problems.join('; ')}.`, { operation: label, service: contract.service, problems }));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { findPackage, requirePackage } from '
|
|
17
|
+
import { findPackage, requirePackage } from '../../lib/package-at.mjs';
|
|
18
18
|
import { found, readText } from './read.mjs';
|
|
19
19
|
|
|
20
20
|
export const INTEGRATION_SPEC_MISSING = 'BE_INTEGRATION_SPEC_MISSING';
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
|
|
6
6
|
// - a line of any other tracked text file that matches a secret pattern.
|
|
7
7
|
// A finding names the file, the line and the pattern, never the value.
|
|
8
|
-
import { FORBIDDEN_FILES, secretHits } from '
|
|
9
|
-
import { isSopsEnvelope } from '
|
|
8
|
+
import { FORBIDDEN_FILES, secretHits } from '../../lib/secret-patterns.mjs';
|
|
9
|
+
import { isSopsEnvelope } from '../../lib/test-secrets.mjs';
|
|
10
10
|
import { found, readText } from './read.mjs';
|
|
11
11
|
|
|
12
12
|
export const PLAINTEXT_SECRET = 'HFS_PLAINTEXT_SECRET';
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// - the declaration (read by scripts/lib/stack-declaration.mjs, the one reader of it) states a sonar service, a local
|
|
7
7
|
// Sonar is owned by the host (`stack.owner: host`, root `.claude/ext/sonar`), and no service still points at `.stacks`.
|
|
8
8
|
// The declaration's services contract (custody, CI wiring, project keys) stays check-starcistacks's; this file judges shape only.
|
|
9
|
-
import { braceVariants, globExpression } from '
|
|
10
|
-
import { declaredStack, findStackDeclaration, STACK_ROOT, text } from '
|
|
9
|
+
import { braceVariants, globExpression } from '../../lib/glob.mjs';
|
|
10
|
+
import { declaredStack, findStackDeclaration, STACK_ROOT, text } from '../../lib/stack-declaration.mjs';
|
|
11
11
|
import { found } from './read.mjs';
|
|
12
12
|
|
|
13
13
|
export const STACKS_SHAPE = 'HFS_STACKS_SHAPE';
|
|
@@ -7,22 +7,32 @@
|
|
|
7
7
|
// Every check and lint rule of HFS reads knowledge/hfs/slots.yaml through this module; none keeps its own path
|
|
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
|
-
// (tests/hfs-slots.spec.mjs proves the two agree).
|
|
10
|
+
// (tests/hfs/hfs-slots.spec.mjs proves the two agree).
|
|
11
11
|
//
|
|
12
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
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
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
15
|
// every check and lint rule runs unchanged with a side folder as its repository root. Nothing crosses sides except the
|
|
16
16
|
// paths the declaration lists in `sides.<side>.reads` (a subset of the manifest's `sides.<side>.reads`).
|
|
17
|
+
//
|
|
18
|
+
// One loader, two manifest kinds. `kind: app` (the default; knowledge/hfs/slots.yaml) is the product standard above.
|
|
19
|
+
// `kind: runtime` (knowledge/hfs/runtime-slots.yaml, schema starci/runtime-slots@<major>) is the standard of the StarCi
|
|
20
|
+
// runtime repository itself: one profile `runtime`, no sides and no app kinds, slot ids runtime.<name>, the tracked value
|
|
21
|
+
// `generated` (a copy written only by the slot's `generatedBy`), and a top-level `pending` list, the one shrink-only
|
|
22
|
+
// allowlist of the runtime check (scripts/hfs/runtime-check.mjs). A runtime repository declares itself with
|
|
23
|
+
// hfs.json {"hfs": <major>, "kind": "runtime", "project": <name>}.
|
|
17
24
|
import fs from 'node:fs';
|
|
18
25
|
import path from 'node:path';
|
|
19
26
|
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
20
27
|
import { parseYaml } from '../../engine/yaml.mjs';
|
|
21
|
-
import { braceVariants, globExpression } from '
|
|
22
|
-
import { posixPath } from '
|
|
28
|
+
import { braceVariants, globExpression } from '../lib/glob.mjs';
|
|
29
|
+
import { posixPath } from '../lib/path-key.mjs';
|
|
23
30
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
31
|
+
import { APP_KIND, ENV_PREFIX, MANIFEST_KINDS, NAME, PRESENCE, RUNTIME_KIND, SEMVER, SLOT_ID, TESTS, TRACKED, manifestKind, runtimeSemanticProblems, runtimeShapeProblems, slotProblems, tierMapProblems } from './manifest-shape.mjs';
|
|
24
32
|
|
|
25
33
|
export const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
|
|
34
|
+
/** The manifest of kind runtime: the standard tree of the StarCi runtime repository (judged by scripts/hfs/runtime-check.mjs). */
|
|
35
|
+
export const RUNTIME_MANIFEST_FILE = 'knowledge/hfs/runtime-slots.yaml';
|
|
26
36
|
export const HFS_DECLARATION_FILE = 'hfs.json';
|
|
27
37
|
|
|
28
38
|
/** A refusal with a catalogued code (modules/kernel/failure-codes.yaml) and the facts a check reports. */
|
|
@@ -35,13 +45,6 @@ export class HfsSlotsError extends Error {
|
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
|
|
38
|
-
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
39
|
-
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
40
|
-
const ENV_PREFIX = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
41
|
-
const SLOT_ID = /^(app|repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
42
|
-
const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
|
|
43
|
-
const TRACKED = ['tracked', 'ignored', 'external'];
|
|
44
|
-
const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
45
48
|
/** The sides of an app; each lives in the folder of its name and is the profile of its slots. */
|
|
46
49
|
const PROFILES = ['be', 'fe'];
|
|
47
50
|
export const SIDES = Object.freeze([...PROFILES]);
|
|
@@ -67,8 +70,6 @@ export function appRelativeMessages(side, sideRoot) {
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
const SCOPES = [APP_SCOPE, ...PROFILES];
|
|
70
|
-
const APP_KIND = 'app';
|
|
71
|
-
const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
|
|
72
73
|
|
|
73
74
|
// ------------------------------------------------------------------------------------------------ patterns
|
|
74
75
|
|
|
@@ -151,7 +152,9 @@ const fail = (code, message, details) => { throw new HfsSlotsError(code, message
|
|
|
151
152
|
function manifestShapeProblems(m) {
|
|
152
153
|
const bad = [];
|
|
153
154
|
if (!isPlainObject(m)) return ['the manifest is not a map'];
|
|
154
|
-
|
|
155
|
+
if (!MANIFEST_KINDS.includes(manifestKind(m))) return [`kind must be one of ${MANIFEST_KINDS.join(', ')}`];
|
|
156
|
+
if (manifestKind(m) === RUNTIME_KIND) return runtimeShapeProblems(m);
|
|
157
|
+
const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'sides', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
|
|
155
158
|
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
156
159
|
if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
|
|
157
160
|
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
@@ -174,12 +177,7 @@ function manifestShapeProblems(m) {
|
|
|
174
177
|
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`);
|
|
175
178
|
const tiers = m.tiers?.[profile];
|
|
176
179
|
if (tiers === undefined) continue;
|
|
177
|
-
|
|
178
|
-
for (const [tier, def] of Object.entries(tiers)) {
|
|
179
|
-
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
180
|
-
if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
|
|
181
|
-
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
|
|
182
|
-
}
|
|
180
|
+
bad.push(...tierMapProblems(profile, tiers));
|
|
183
181
|
}
|
|
184
182
|
const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
|
|
185
183
|
const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
|
|
@@ -204,37 +202,7 @@ function manifestShapeProblems(m) {
|
|
|
204
202
|
if (!fileLinesOk(rp.fe.fileLines) || !blockOk(rp.fe.duplicateBlock) || Object.keys(rp.fe).length !== 2) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
|
|
205
203
|
}
|
|
206
204
|
if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
|
|
207
|
-
|
|
208
|
-
m.slots.forEach((slot, index) => {
|
|
209
|
-
const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
210
|
-
if (!isPlainObject(slot)) { bad.push(`${at} is not a map`); return; }
|
|
211
|
-
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
212
|
-
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
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`);
|
|
215
|
-
if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
|
|
216
|
-
if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
|
|
217
|
-
if (!TRACKED.includes(slot.tracked)) bad.push(`${at}: tracked must be one of ${TRACKED.join(', ')}`);
|
|
218
|
-
if (!NAME.test(String(slot.tier))) bad.push(`${at}: tier must be a tier name, none or inherit`);
|
|
219
|
-
if (!TESTS.includes(slot.tests)) bad.push(`${at}: tests must be one of ${TESTS.join(', ')}`);
|
|
220
|
-
if (slot.owner !== undefined && typeof slot.owner !== 'boolean') bad.push(`${at}: owner must be a boolean`);
|
|
221
|
-
if (slot.appKind !== undefined && !NAME.test(String(slot.appKind))) bad.push(`${at}: appKind must be a name`);
|
|
222
|
-
if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
223
|
-
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
224
|
-
if (slot.requiredInstances !== undefined && !(isPlainObject(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
|
|
225
|
-
for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
226
|
-
if (slot.kinds !== undefined && strList(slot.kinds) && (!slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k)))) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
|
|
227
|
-
if (slot.roles !== undefined && !(isPlainObject(slot.roles) && Object.keys(slot.roles).length && Object.entries(slot.roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/')))) bad.push(`${at}: roles must map a role name to a file name`);
|
|
228
|
-
if (slot.composedBy !== undefined && !(strList(slot.composedBy) && slot.composedBy.length && new Set(slot.composedBy).size === slot.composedBy.length)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
|
|
229
|
-
if (slot.budget !== undefined && !(isPlainObject(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
|
|
230
|
-
if (slot.managedBy !== undefined && !NAME.test(String(slot.managedBy))) bad.push(`${at}: managedBy must be a template id`);
|
|
231
|
-
if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
|
|
232
|
-
if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
|
|
233
|
-
if (slot.retiredIn !== undefined && !(Number.isInteger(slot.retiredIn) && slot.retiredIn >= 2)) bad.push(`${at}: retiredIn must be a major`);
|
|
234
|
-
if (slot.retiredIn !== undefined && typeof slot.successor !== 'string') bad.push(`${at}: a retired slot names its successor`);
|
|
235
|
-
if (slot.tracked === 'external' && (typeof slot.goesTo !== 'string' || slot.presence !== 'forbidden')) bad.push(`${at}: an external slot is forbidden and says where it goes (goesTo)`);
|
|
236
|
-
if (slot.presence === 'forbidden' && slot.tracked !== 'external') bad.push(`${at}: a forbidden slot is external`);
|
|
237
|
-
});
|
|
205
|
+
m.slots.forEach((slot, index) => bad.push(...slotProblems(slot, index, APP_KIND, { appScope: APP_SCOPE, scopes: SCOPES })));
|
|
238
206
|
return bad;
|
|
239
207
|
}
|
|
240
208
|
|
|
@@ -277,6 +245,7 @@ function manifestSemanticProblems(m) {
|
|
|
277
245
|
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`);
|
|
278
246
|
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`);
|
|
279
247
|
}
|
|
248
|
+
if (manifestKind(m) === RUNTIME_KIND) return [...bad, ...runtimeSemanticProblems(m)];
|
|
280
249
|
for (const side of PROFILES) for (const read of m.sides[side].reads) {
|
|
281
250
|
const [owner, ...rest] = read.split('/');
|
|
282
251
|
const below = rest.join('/');
|
|
@@ -317,9 +286,16 @@ export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_
|
|
|
317
286
|
function declarationShapeProblems(d) {
|
|
318
287
|
const bad = [];
|
|
319
288
|
if (!isPlainObject(d)) return ['hfs.json is not an object'];
|
|
289
|
+
if (d.kind === RUNTIME_KIND) {
|
|
290
|
+
// The StarCi runtime repository: no sides and no apps; its tree is the runtime manifest's.
|
|
291
|
+
for (const key of Object.keys(d)) if (!['hfs', 'kind', 'project'].includes(key)) bad.push(`unknown key ${key} (a runtime declaration is {hfs, kind, project})`);
|
|
292
|
+
if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
|
|
293
|
+
if (!NAME.test(String(d.project))) bad.push('project must be a project name');
|
|
294
|
+
return bad;
|
|
295
|
+
}
|
|
320
296
|
for (const key of Object.keys(d)) if (!['hfs', 'kind', 'project', 'sides'].includes(key)) bad.push(`unknown key ${key}`);
|
|
321
297
|
if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
|
|
322
|
-
if (d.kind !== APP_KIND) bad.push(`kind must be ${APP_KIND}
|
|
298
|
+
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) or ${RUNTIME_KIND} (the StarCi runtime repository)`);
|
|
323
299
|
if (!NAME.test(String(d.project))) bad.push('project must be a project name');
|
|
324
300
|
if (!isPlainObject(d.sides) || Object.keys(d.sides).sort().join() !== PROFILES.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
|
|
325
301
|
const names = new Map();
|
|
@@ -382,8 +358,24 @@ function sideProblems(manifest, side, s) {
|
|
|
382
358
|
export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLARATION_FILE, side = null } = {}) {
|
|
383
359
|
const problems = declarationShapeProblems(declaration);
|
|
384
360
|
if (problems.length) declarationInvalid(problems, file);
|
|
361
|
+
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);
|
|
385
362
|
if (declaration.hfs !== manifest.major)
|
|
386
363
|
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 });
|
|
364
|
+
if (declaration.kind === RUNTIME_KIND) {
|
|
365
|
+
if (side !== null) fail('HFS_DECLARATION_INVALID', 'a runtime repository has no sides', { file, side });
|
|
366
|
+
return Object.freeze({
|
|
367
|
+
hfs: declaration.hfs,
|
|
368
|
+
kind: RUNTIME_KIND,
|
|
369
|
+
project: declaration.project,
|
|
370
|
+
side: null,
|
|
371
|
+
profile: RUNTIME_KIND,
|
|
372
|
+
apps: Object.freeze([]),
|
|
373
|
+
optionalSlots: Object.freeze([]),
|
|
374
|
+
connections: Object.freeze([]),
|
|
375
|
+
reads: Object.freeze([]),
|
|
376
|
+
manifestVersion: manifest.version,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
387
379
|
const bad = PROFILES.flatMap((name) => sideProblems(manifest, name, declaration.sides[name]));
|
|
388
380
|
if (bad.length) declarationInvalid(bad, file);
|
|
389
381
|
if (side !== null && !PROFILES.includes(side)) fail('HFS_DECLARATION_INVALID', `${side} is not a side of an app (be, fe)`, { file, side });
|
|
@@ -599,7 +591,8 @@ function createScopeResolver(manifest, repo) {
|
|
|
599
591
|
const b = layerIndex(byId.get((toOwner ?? to).slot), toOwner?.root ?? to.root);
|
|
600
592
|
if (a >= 0 && b >= 0 && b <= a) return { allowed: false, reason: 'layerOrder', fromLayer: fromSlot.layers[a], toLayer: fromSlot.layers[b] };
|
|
601
593
|
}
|
|
602
|
-
|
|
594
|
+
// A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
|
|
595
|
+
if (toOwner && repo.kind !== RUNTIME_KIND) {
|
|
603
596
|
const relative = to.path === toOwner.root ? '' : to.path.slice(toOwner.root.length + 1);
|
|
604
597
|
const ownerTier = byId.get(toOwner.slot).tier;
|
|
605
598
|
const entry = isEntryFile(relative) || (ownerTier === 'package' && relative === 'src/index.ts') || (ownerTier === 'app' && relative === 'app.module.ts');
|
|
@@ -763,9 +756,10 @@ export function createSlotResolver(manifest, repo) {
|
|
|
763
756
|
});
|
|
764
757
|
}
|
|
765
758
|
|
|
766
|
-
/** The rule parameters of one profile (be: fileLines, duplicateBlock, infraOwners, suffixes, bannedSuffixes; fe: fileLines, duplicateBlock), as a frozen deep copy. */
|
|
759
|
+
/** The rule parameters of one profile (be: fileLines, duplicateBlock, infraOwners, suffixes, bannedSuffixes; fe: fileLines, duplicateBlock; runtime: ruleParams.runtime of a runtime manifest), as a frozen deep copy. */
|
|
767
760
|
export function ruleParams(manifest, profile) {
|
|
768
|
-
|
|
761
|
+
const profiles = manifestKind(manifest) === RUNTIME_KIND ? [RUNTIME_KIND] : PROFILES;
|
|
762
|
+
if (!profiles.includes(profile)) fail('HFS_MANIFEST_INVALID', `ruleParams has no profile ${profile}`, { profile });
|
|
769
763
|
const deepFreeze = (v) => { if (v && typeof v === 'object') Object.values(v).forEach(deepFreeze); return Object.freeze(v); };
|
|
770
764
|
return deepFreeze(structuredClone(manifest.ruleParams[profile]));
|
|
771
765
|
}
|
|
@@ -782,12 +776,12 @@ export function openHfs({ root = skillRoot, repoRoot, declaration, side = null,
|
|
|
782
776
|
// ------------------------------------------------------------------------------------------ rule catalog
|
|
783
777
|
|
|
784
778
|
export const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
|
|
785
|
-
export const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar']);
|
|
786
|
-
export const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar']);
|
|
779
|
+
export const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar', 'runtime']);
|
|
780
|
+
export const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
|
|
787
781
|
export const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
788
782
|
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
789
783
|
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
790
|
-
const FILE_ENFORCERS = ['machine', 'hfs', 'work-validate', 'sonar'];
|
|
784
|
+
const FILE_ENFORCERS = ['machine', 'hfs', 'work-validate', 'sonar', 'runtime'];
|
|
791
785
|
|
|
792
786
|
/** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
|
|
793
787
|
function ruleCatalogProblems(d) {
|
|
@@ -853,7 +847,7 @@ function ruleCatalogProblems(d) {
|
|
|
853
847
|
if (e.at !== undefined) {
|
|
854
848
|
if (typeof e.at !== 'string' || !e.at.trim() || e.at.startsWith('/') || e.at.includes('..')) bad.push(`${eat}.at must be a repository-relative path`);
|
|
855
849
|
if (e.status === 'planned') bad.push(`${eat} is planned, so it has no file yet (at)`);
|
|
856
|
-
if (!FILE_ENFORCERS.includes(e.kind)) bad.push(`${eat}.at belongs to a machine, hfs, work-validate or
|
|
850
|
+
if (!FILE_ENFORCERS.includes(e.kind)) bad.push(`${eat}.at belongs to a machine, hfs, work-validate, sonar or runtime enforcer only`);
|
|
857
851
|
} else if (FILE_ENFORCERS.includes(e.kind) && e.status !== 'planned') bad.push(`${eat} exists, so it names the file (at) that emits its code`);
|
|
858
852
|
});
|
|
859
853
|
if (Array.isArray(r.gates)) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { gitOutput } from '../api/git/lib.mjs';
|
|
8
|
-
import { posixPath } from '
|
|
8
|
+
import { posixPath } from '../lib/path-key.mjs';
|
|
9
9
|
|
|
10
10
|
/** Directory names no tree check enters: git's own store and installed packages. */
|
|
11
11
|
const NEVER_WALKED = new Set(['.git', 'node_modules']);
|
|
@@ -1,10 +1,10 @@
|
|
|
1
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
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
|
-
// runtime/ bundle (
|
|
3
|
+
// runtime/ bundle (scripts/hfs/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';
|
|
6
|
-
import { allowsFile } from './
|
|
7
|
-
import { openHfs } from './
|
|
6
|
+
import { allowsFile } from './allows.mjs';
|
|
7
|
+
import { openHfs } from './slots.mjs';
|
|
8
8
|
|
|
9
9
|
const posix = (p) => String(p).replace(/\\/g, '/');
|
|
10
10
|
const isAbsoluteAny = (text) => path.isAbsolute(text) || /^[A-Za-z]:\//.test(text);
|