@starci/hfs 4.0.6 → 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.
Files changed (134) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +10 -9
  3. package/bin/hfs.mjs +4 -4
  4. package/lint/run.mjs +1 -1
  5. package/package.json +2 -2
  6. package/runtime/engine/runtime-root.mjs +5 -0
  7. package/runtime/engine/yaml.mjs +3 -3
  8. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  9. package/runtime/knowledge/hfs/slots.yaml +5 -5
  10. package/runtime/knowledge/patterns/fe/folder.yaml +2 -2
  11. package/runtime/knowledge/sonar-gate.yaml +2 -2
  12. package/runtime/modules/kernel/failure-codes.yaml +12 -2
  13. package/runtime/scripts/api/fs/lib.mjs +6 -0
  14. package/runtime/scripts/api/fs/rmdir-link.mjs +1 -1
  15. package/runtime/scripts/{lib → api/fs}/safe-remove.mjs +31 -82
  16. package/runtime/scripts/api/git/lib.mjs +1 -1
  17. package/runtime/scripts/api/sops/decrypt.mjs +16 -0
  18. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  19. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +1 -1
  20. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +2 -2
  21. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +1 -1
  22. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +1 -1
  23. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +3 -3
  24. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +1 -1
  25. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  26. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +3 -3
  27. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  28. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +18 -105
  29. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +1 -1
  30. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +1 -1
  31. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +2 -2
  32. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +1 -1
  33. package/runtime/scripts/{checks → hfs}/architecture.mjs +2 -2
  34. package/runtime/scripts/{lib/hfs-check.mjs → hfs/check.mjs} +33 -25
  35. package/runtime/scripts/hfs/manifest-shape.mjs +170 -0
  36. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +6 -6
  37. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/contract.mjs +3 -2
  38. package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +66 -0
  39. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/integration-specs.mjs +1 -1
  40. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +2 -2
  41. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/stacks.mjs +2 -2
  42. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +52 -58
  43. package/runtime/scripts/{lib/hfs-tree.mjs → hfs/tree.mjs} +1 -1
  44. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +3 -3
  45. package/runtime/scripts/lib/graphql-contract.mjs +429 -0
  46. package/runtime/scripts/lib/is-main.mjs +13 -0
  47. package/runtime/scripts/lib/language.mjs +2 -2
  48. package/runtime/scripts/lib/path-key.mjs +2 -0
  49. package/runtime/scripts/lib/stack-declaration.mjs +2 -2
  50. package/runtime/scripts/lib/test-secrets.mjs +2 -6
  51. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +1 -12
  52. package/scaffold/app.mjs +1 -1
  53. package/scaffold/service.mjs +2 -2
  54. package/sync/hygiene.mjs +8 -8
  55. package/sync/index.mjs +48 -11
  56. package/templates/app/quality-config/codecov.yml +1 -1
  57. package/templates/app/quality-config/sonar-project.properties +1 -1
  58. package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +2 -2
  59. package/templates/fe/skeleton/apps/__app__/next.config.ts +10 -2
  60. package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +2 -2
  61. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/component.tsx +0 -2
  62. package/templates/fe/skeleton/apps/__app__/src/features/pages/ErrorPage/index.tsx +1 -7
  63. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/component.tsx +0 -2
  64. package/templates/fe/skeleton/apps/__app__/src/features/pages/GlobalErrorPage/index.tsx +0 -1
  65. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/component.tsx +0 -4
  66. package/templates/fe/skeleton/apps/__app__/src/features/pages/HomePage/index.tsx +1 -1
  67. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/component.tsx +0 -4
  68. package/templates/fe/skeleton/apps/__app__/src/features/pages/LoadingPage/index.tsx +1 -1
  69. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/component.tsx +0 -4
  70. package/templates/fe/skeleton/apps/__app__/src/features/pages/NotFoundPage/index.tsx +1 -1
  71. package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +8 -3
  72. package/templates/fe/skeleton/apps/__app__/src/proxy.ts +1 -1
  73. package/runtime/engine/admission.mjs +0 -284
  74. package/runtime/engine/digest.mjs +0 -10
  75. package/runtime/engine/ledger-db.mjs +0 -1245
  76. package/runtime/engine/machine-db.mjs +0 -1565
  77. package/runtime/engine/migrations/machine/0001-init.sql +0 -887
  78. package/runtime/engine/migrations/machine/0002-worktrees-no-workflow-kind.sql +0 -13
  79. package/runtime/engine/migrations/machine/0003-worktrees-workflow-orca.sql +0 -21
  80. package/runtime/engine/migrations/runtime/0001-init.sql +0 -1072
  81. package/runtime/engine/migrations/runtime/0003-usage-unavailable.sql +0 -13
  82. package/runtime/engine/migrations/runtime/0004-attempt-why.sql +0 -43
  83. package/runtime/engine/migrations/runtime/0005-ended-workflow-views.sql +0 -93
  84. package/runtime/scripts/lib/artifact-hold.mjs +0 -89
  85. package/runtime/scripts/lib/artifact-store.mjs +0 -103
  86. package/runtime/scripts/lib/redact.mjs +0 -148
  87. /package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +0 -0
  88. /package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +0 -0
  89. /package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +0 -0
  90. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  91. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  92. /package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +0 -0
  93. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  94. /package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +0 -0
  95. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  96. /package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +0 -0
  97. /package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +0 -0
  98. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  99. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  100. /package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +0 -0
  101. /package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +0 -0
  102. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  103. /package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +0 -0
  104. /package/runtime/scripts/{checks → hfs}/architecture/index.mjs +0 -0
  105. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  106. /package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +0 -0
  107. /package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +0 -0
  108. /package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +0 -0
  109. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  110. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  111. /package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +0 -0
  112. /package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +0 -0
  113. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  114. /package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +0 -0
  115. /package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +0 -0
  116. /package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +0 -0
  117. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  118. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  119. /package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +0 -0
  120. /package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +0 -0
  121. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
  122. /package/runtime/scripts/{lib → hfs}/repo-identity.mjs +0 -0
  123. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/deps.mjs +0 -0
  124. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +0 -0
  125. /package/runtime/scripts/{lib/hfs-rules/frontend.mjs → hfs/rules/frontend-tree.mjs} +0 -0
  126. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/lint-suppression.mjs +0 -0
  127. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/peer-integrations.mjs +0 -0
  128. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/pipeline.mjs +0 -0
  129. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/proof-commands.mjs +0 -0
  130. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +0 -0
  131. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/repo-local-checks.mjs +0 -0
  132. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +0 -0
  133. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/test-topology.mjs +0 -0
  134. /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/lib/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
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 './hfs-allows.mjs';
8
- import { isFeTestPath } from './hfs-rules/fe-no-tests.mjs';
9
- import { slotOwnsSecrets } from './hfs-rules/secrets.mjs';
10
- import { specPlacementFindings } from './hfs-rules/spec-placement.mjs';
11
- import { APP_SCOPE } from './hfs-slots.mjs';
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 '../safe-remove.mjs';
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
- safeRemoveTree(scratch);
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 '../package-at.mjs';
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 '../secret-patterns.mjs';
9
- import { isSopsEnvelope } from '../test-secrets.mjs';
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 '../glob.mjs';
10
- import { declaredStack, findStackDeclaration, STACK_ROOT, text } from '../stack-declaration.mjs';
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 './glob.mjs';
22
- import { posixPath } from './path-key.mjs';
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
- const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'sides', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
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
- if (!isPlainObject(tiers) || !Object.keys(tiers).length) { bad.push(`tiers.${profile} must be a non-empty map`); continue; }
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
- const slotKeys = new Set(['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']);
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}: a product is one app repository with a be and an fe side`);
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
- if (toOwner) {
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
- if (!PROFILES.includes(profile)) fail('HFS_MANIFEST_INVALID', `ruleParams has no profile ${profile}`, { profile });
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 sonar enforcer only`);
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 './path-key.mjs';
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 (packages/hfs/scripts/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
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 './hfs-allows.mjs';
7
- import { openHfs } from './hfs-slots.mjs';
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);