@starci/eslint-canon-be 3.0.7 → 3.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 (81) hide show
  1. package/README.md +1 -1
  2. package/lib/hfs.mjs +3 -3
  3. package/package.json +3 -3
  4. package/project-graph.mjs +4 -4
  5. package/runtime/engine/runtime-root.mjs +5 -0
  6. package/runtime/engine/yaml.mjs +3 -3
  7. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  8. package/runtime/knowledge/hfs/slots.yaml +5 -5
  9. package/runtime/knowledge/patterns/fe/folder.yaml +2 -2
  10. package/runtime/knowledge/sonar-gate.yaml +2 -2
  11. package/runtime/scripts/api/git/lib.mjs +1 -1
  12. package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
  13. package/runtime/scripts/api/sops/decrypt.mjs +1 -1
  14. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  15. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +1 -1
  16. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +2 -2
  17. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +1 -1
  18. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +1 -1
  19. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +3 -3
  20. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +1 -1
  21. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  22. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +3 -3
  23. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  24. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +18 -105
  25. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +1 -1
  26. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +1 -1
  27. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +2 -2
  28. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +1 -1
  29. package/runtime/scripts/hfs/manifest-shape.mjs +170 -0
  30. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +6 -6
  31. package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
  32. package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
  33. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +2 -2
  34. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +52 -58
  35. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +3 -3
  36. package/runtime/scripts/lib/language.mjs +2 -2
  37. package/runtime/scripts/lib/line-count.mjs +9 -0
  38. package/runtime/scripts/lib/path-key.mjs +2 -0
  39. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +1 -12
  40. package/size-budget.mjs +2 -1
  41. package/runtime/scripts/lib/recorded-lines.mjs +0 -27
  42. /package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +0 -0
  43. /package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +0 -0
  44. /package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +0 -0
  45. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  46. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  47. /package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +0 -0
  48. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  49. /package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +0 -0
  50. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  51. /package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +0 -0
  52. /package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +0 -0
  53. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  54. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  55. /package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +0 -0
  56. /package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +0 -0
  57. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  58. /package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +0 -0
  59. /package/runtime/scripts/{checks → hfs}/architecture/index.mjs +0 -0
  60. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  61. /package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +0 -0
  62. /package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +0 -0
  63. /package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +0 -0
  64. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  65. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  66. /package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +0 -0
  67. /package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +0 -0
  68. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  69. /package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +0 -0
  70. /package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +0 -0
  71. /package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +0 -0
  72. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  73. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  74. /package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +0 -0
  75. /package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +0 -0
  76. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
  77. /package/runtime/scripts/{lib → hfs}/repo-identity.mjs +0 -0
  78. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +0 -0
  79. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +0 -0
  80. /package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +0 -0
  81. /package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -0
@@ -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)) {
@@ -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);
@@ -1,6 +1,6 @@
1
1
  // language.mjs - the one home of "is this text English?" for source, comments, tests and docs (HFS_LANGUAGE_NOT_ENGLISH,
2
2
  // BE_SOURCE_FORM, FE_SOURCE_FORM). Shared by the architecture machine (docs) and by both lint canons (each ships a byte
3
- // copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
3
+ // copy in its runtime/ bundle, kept by scripts/hfs/sync-runtime.mjs).
4
4
  //
5
5
  // Detection is structural on characters, never a word list: the letters Vietnamese adds to the Latin alphabet (a-breve, a-circumflex,
6
6
  // d-stroke, e-circumflex, o-circumflex, o-horn, u-horn) and every vowel carrying a tone mark. Text is folded to NFC first, so a
@@ -26,7 +26,7 @@ export function secondLanguageHits(text) {
26
26
 
27
27
  /**
28
28
  * The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
29
- * (scripts/checks/failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
29
+ * (scripts/checks/check-failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
30
30
  */
31
31
  export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meaning_vi', 'nextStep_vi', 'causes_vi']);
32
32
 
@@ -0,0 +1,9 @@
1
+ // line-count.mjs - text line counts and the size-growth limit (pure).
2
+
3
+ /** The line count of a text; a trailing newline does not open a line. */
4
+ export const lineCount = (text) => {
5
+ const lines = String(text).split(/\r?\n/);
6
+ return lines.at(-1) === '' ? lines.length - 1 : lines.length;
7
+ };
8
+ /** The line count above which a file may not grow: the soft budget when `hardGrowth` holds, else no limit. */
9
+ export const hardGrowthLines = ({ soft, hardGrowth }) => (hardGrowth ? soft : Number.POSITIVE_INFINITY);
@@ -15,3 +15,5 @@ export const foldCase = (p) => (WIN ? p.toLowerCase() : p);
15
15
  export const samePath = (a, b) => foldCase(a) === foldCase(b);
16
16
  /** A path's comparable identity: absolute, forward slashes, no trailing slash, case-folded on Windows. */
17
17
  export const pathKey = (p) => foldCase(slash(path.resolve(p)).replace(/\/+$/, ''));
18
+ /** A work-relative path in one spelling: forward slashes, no leading ./, no trailing slash. */
19
+ export const normWork = (p) => String(p ?? '').trim().replaceAll('\\', '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
@@ -1,23 +1,12 @@
1
+ // walk.mjs - filesystem walking helpers: isInside (containment) and walkFiles (depth-first file listing).
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import {fileURLToPath} from 'node:url';
4
4
 
5
5
  /** Whether `target` is `root` itself or inside it (filesystem paths, either separator). */
6
6
  export function isInside(root, target) {
7
7
  const relative = path.relative(root, target);
8
8
  return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
9
9
  }
10
-
11
- /** True when this module is the process entry point (`node file.mjs`, not an import). */
12
- /** The real path of a file, or its resolved path when it cannot be read (a missing file is never the entry module). */
13
- const realPathOf = (file) => { try { return fs.realpathSync.native(path.resolve(file)); } catch { return path.resolve(file); } };
14
- /**
15
- * True when the module at `metaUrl` is the process entry point. Both sides are compared as REAL paths: Node gives a module
16
- * its symlink-resolved URL, while argv[1] keeps the path it was started with, so an entry reached through a node_modules
17
- * junction or symlink (a lane worktree, an npx shim) would otherwise look like an import and silently do nothing.
18
- */
19
- export const isMain = (metaUrl, argv = process.argv) => Boolean(argv[1]) && realPathOf(argv[1]) === realPathOf(fileURLToPath(metaUrl));
20
-
21
10
  /**
22
11
  * Every entry under `dir` that is not a directory, depth-first; `filter` sees the entry name and full
23
12
  * path. `sorted` sorts each directory's entries by name (default: readdir order). `exclude`
package/size-budget.mjs CHANGED
@@ -16,7 +16,8 @@
16
16
  */
17
17
  import { basename } from "node:path"
18
18
  import { hfsOf } from "./lib/hfs.mjs"
19
- import { hardGrowthLines, lineCount, recordedLines } from "./runtime/scripts/lib/recorded-lines.mjs"
19
+ import { recordedLines } from "./runtime/scripts/api/git/recorded-lines.mjs"
20
+ import { hardGrowthLines, lineCount } from "./runtime/scripts/lib/line-count.mjs"
20
21
 
21
22
  /** A migration is a file of the persistence slot named `<timestamp>-<name>.ts`. */
22
23
  const isMigration = (hfs, filename) => hfs.slotOf(filename) === "be.persistence" && /^\d{13,14}-[^/]+\.[cm]?ts$/.test(basename(filename))
@@ -1,27 +0,0 @@
1
- // recorded-lines.mjs - the one home of the file-size ratchet's arithmetic (R20 HFS_SIZE_GROWTH), shared by the back-end
2
- // and front-end lint canons (each ships a byte copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
3
- import path from 'node:path';
4
- import { runGit } from '../api/git/lib.mjs';
5
-
6
- /** The line count of a text; a trailing newline does not open a line. */
7
- export const lineCount = (text) => {
8
- const lines = String(text).split(/\r?\n/);
9
- return lines.at(-1) === '' ? lines.length - 1 : lines.length;
10
- };
11
-
12
- /** The line count above which a file may not grow: the soft budget when `hardGrowth` holds, else no limit. */
13
- export const hardGrowthLines = ({ soft, hardGrowth }) => (hardGrowth ? soft : Number.POSITIVE_INFINITY);
14
-
15
- /**
16
- * The line count of a file at a git revision, or null when the file did not exist there or git is absent.
17
- *
18
- * @param {string} filename - Absolute path of the file.
19
- * @param {string} ref - The revision, `HEAD` by default (the parent of the commit being made).
20
- * @returns {number | null} The recorded line count.
21
- */
22
- export const recordedLines = (filename, ref = 'HEAD') => {
23
- const r = runGit(['show', `${ref}:./${path.basename(filename)}`], { dir: path.dirname(filename), maxBuffer: 64 * 1024 * 1024 });
24
- // no repository, no such revision, or a new file: all mean there is no recorded size to compare with
25
- if (r.error || r.status !== 0) return null;
26
- return lineCount(r.stdout);
27
- };
File without changes