@starci/eslint-canon-fe 8.3.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/callback-shape.mjs +344 -0
  3. package/client-boundary.mjs +1 -1
  4. package/docs/hooks-folder.md +1 -1
  5. package/env-owner.mjs +120 -9
  6. package/function-budget.mjs +49 -0
  7. package/hooks-folder.mjs +149 -18
  8. package/icon.mjs +67 -0
  9. package/index.mjs +47 -0
  10. package/lib/own-hook-origin.mjs +53 -0
  11. package/lib/program-for.mjs +22 -0
  12. package/lib/pure-owner-helper.mjs +148 -0
  13. package/lib/redux-hook-origin.mjs +44 -0
  14. package/next-conventions.mjs +38 -35
  15. package/package.json +1 -1
  16. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  17. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  18. package/runtime/knowledge/hfs/slots.yaml +344 -62
  19. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  20. package/runtime/knowledge/sonar-gate.yaml +1 -1
  21. package/runtime/modules/models/runtimes.yaml +1 -1
  22. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  23. package/runtime/scripts/api/git/lib.mjs +3 -0
  24. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  25. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  26. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  27. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  28. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  29. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  30. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  31. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  32. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  33. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  34. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  35. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  36. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  37. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  38. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  39. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  40. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  41. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  42. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  43. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  44. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  45. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  46. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  47. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  48. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  49. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  50. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  51. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  52. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  53. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  54. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  55. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  56. package/runtime/scripts/hfs/slots.mjs +1 -0
  57. package/runtime/scripts/lib/env.mjs +20 -0
  58. package/runtime/scripts/lib/event-contract.mjs +107 -0
  59. package/runtime/scripts/lib/json-clone.mjs +61 -0
  60. package/runtime/scripts/lib/list.mjs +4 -0
  61. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  62. package/runtime/scripts/lib/stack-services.mjs +1 -1
  63. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  64. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  65. package/served-locale.mjs +80 -9
  66. package/size-growth.mjs +10 -6
  67. package/swr-family.mjs +197 -0
  68. package/translation.mjs +135 -7
  69. package/transport.mjs +126 -59
@@ -60,26 +60,62 @@ function connectionProblems(side, value) {
60
60
  /** Whether a declared list is present but not a list of distinct entries that all satisfy `valid`. */
61
61
  const isBadUniqueList = (list, valid) => list !== undefined && (!Array.isArray(list) || !list.every((entry) => valid(entry)) || new Set(list).size !== list.length);
62
62
 
63
+ const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
64
+
65
+ function validExtraPackage(entry) {
66
+ return isPlainObject(entry) && Object.keys(entry).every(key => key === 'name' || key === 'reason')
67
+ && typeof entry.name === 'string' && entry.name.length <= 214 && PACKAGE_NAME.test(entry.name)
68
+ && typeof entry.reason === 'string' && entry.reason.trim().length > 0;
69
+ }
70
+
71
+ function extraPackageProblems(side, entries) {
72
+ if (entries === undefined) return [];
73
+ if (side !== 'fe') return ['extraPackages belong to the fe side'];
74
+ if (!Array.isArray(entries) || !entries.every(validExtraPackage)) return ['sides.fe.extraPackages must be a list of {name, reason} with a package name and a nonempty reason'];
75
+ return [];
76
+ }
77
+
63
78
  function declarationSideProblems(side, value, names) {
64
79
  const bad = [];
65
80
  const at = `sides.${side}`;
66
81
  if (!isPlainObject(value)) return [`${at} must be an object`];
67
- for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads'].includes(key)) bad.push(`${at} has unknown key ${key}`);
82
+ for (const key of Object.keys(value)) if (!['apps', 'optionalSlots', 'patterns', 'kinds', 'connections', 'reads', 'extraPackages'].includes(key)) bad.push(`${at} has unknown key ${key}`);
68
83
  bad.push(...appShapeProblems(value.apps, at, side, names));
69
84
  if (isBadUniqueList(value.optionalSlots, (slot) => SLOT_ID.test(String(slot)))) bad.push(`${at}.optionalSlots must be a unique list of slot ids`);
70
85
  bad.push(...patternShapeProblems(value, at, NAME), ...kindShapeProblems(value, at, NAME));
71
86
  if (isBadUniqueList(value.reads, (read) => typeof read === 'string' && read.length > 0)) bad.push(`${at}.reads must be a unique list of paths`);
72
87
  if (value.connections !== undefined) bad.push(...connectionProblems(side, value));
88
+ bad.push(...extraPackageProblems(side, value.extraPackages));
89
+ return bad;
90
+ }
91
+
92
+ /** Product vocabulary is a declaration, never a switch for a rule. */
93
+ function lexiconBlockProblems(lexicon) {
94
+ if (lexicon === undefined) return [];
95
+ if (!isPlainObject(lexicon)) return ['lexicon must be an object'];
96
+ const bad = [];
97
+ for (const key of Object.keys(lexicon)) if (key !== 'removed') bad.push(`lexicon has unknown key ${key}`);
98
+ if (isBadUniqueList(lexicon.removed, (word) => typeof word === 'string' && word.trim().length > 0)) bad.push('lexicon.removed must be a unique list of nonempty words');
99
+ return bad;
100
+ }
101
+
102
+ /** Front-end error reporting remains optional in both editions. */
103
+ function observeBlockProblems(observe) {
104
+ if (observe === undefined) return [];
105
+ if (!isPlainObject(observe)) return ['observe must be an object'];
106
+ const bad = [];
107
+ for (const key of Object.keys(observe)) if (key !== 'reporter') bad.push(`observe has unknown key ${key}`);
108
+ if (observe.reporter !== undefined && (typeof observe.reporter !== 'string' || !observe.reporter.trim())) bad.push('observe.reporter must name the optional error reporter');
73
109
  return bad;
74
110
  }
75
111
 
76
- const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'supabase', 'sides', 'browser']);
112
+ const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'supabase', 'sides', 'browser', 'lexicon', 'observe']);
77
113
  const RUNTIME_DECLARATION_KEYS = new Set(['hfs', 'kind', 'project']);
78
114
 
79
115
  function productDeclarationProblems(declaration, profiles) {
80
116
  const bad = [];
81
117
  for (const key of Object.keys(declaration)) if (!DECLARATION_KEYS.has(key)) bad.push(`unknown key ${key}`);
82
- bad.push(...supabaseBlockProblems(declaration.supabase));
118
+ bad.push(...supabaseBlockProblems(declaration.supabase), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
83
119
  if (declaration.browser !== undefined && declaration.browser !== true) bad.push('browser is `true` when the app has a browser journey (slot app.browser), and is left out otherwise');
84
120
  if (declaration.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)`);
85
121
  if (!isPlainObject(declaration.sides) || Object.keys(declaration.sides).sort(byCodeUnit).join() !== profiles.join()) { bad.push('sides must declare exactly be and fe'); return bad; }
@@ -1,7 +1,7 @@
1
1
  // edition.mjs - the one edition filter shared by the HFS slot resolver, declaration validation, rule catalog and
2
2
  // edition-specific rule parameters. Full is the default; lite changes data views, never the checks that consume them.
3
- // Lite is the full architecture deployed without self-hosted infrastructure (owner ruling 2026-10-12): its overlays swap
4
- // adapters and remove the infra and e2e world, and every slot keeps its own `tests`.
3
+ // Lite uses provider adapters and declares Docker Swarm production infrastructure (owner:lite-production-swarm, ruling:R212/case-4).
4
+ // Its overlays exclude the e2e world, and every slot keeps its own `tests`.
5
5
  import { isPlainObject } from '../../engine/plain-object.mjs';
6
6
  import { EDITIONS } from './manifest-shape.mjs';
7
7
 
@@ -4,7 +4,7 @@
4
4
  // (sides, app kinds) and refuses a manifest whole on any problem (HFS_MANIFEST_INVALID). Pure: every function takes a
5
5
  // parsed manifest or slot and returns a list of problems in the words of modules/schemas/hfs-slots.schema.yaml.
6
6
  import { isPlainObject } from '../../engine/plain-object.mjs';
7
- import { stringList, byCodeUnit } from '../lib/list.mjs';
7
+ import { stringList, byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
8
8
 
9
9
  export const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
10
10
  export const NAME = /^[a-z][a-z0-9-]*$/;
@@ -86,7 +86,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
86
86
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
87
87
  const strList = (v) => stringList(v);
88
88
 
89
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome'];
89
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
90
90
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
91
91
  const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
92
92
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
@@ -184,6 +184,9 @@ function slotListProblems(slot, at) {
184
184
 
185
185
  function slotAppOptionProblems(slot, at) {
186
186
  const bad = [];
187
+ bad.push(...slotEditionWhyProblems(slot, at), ...slotCoverageEvidenceProblems(slot, at));
188
+ // Legacy coverage status derives only from an explicit `since`; a slot that omits it is refused, so a new slot can never be taken for an old one.
189
+ if (slot.since === undefined) bad.push(`${at}: since is required (the manifest version that added the slot)`);
187
190
  if (slot.editions !== undefined && !(Array.isArray(slot.editions) && slot.editions.length && slot.editions.every((e) => EDITIONS.includes(e)) && new Set(slot.editions).size === slot.editions.length)) bad.push(`${at}: editions must be a non-empty subset of ${EDITIONS.join(', ')}`);
188
191
  if (slot.litePresence !== undefined && !(PRESENCE.includes(slot.litePresence) || (isPlainObject(slot.litePresence) && Object.keys(slot.litePresence).length && Object.entries(slot.litePresence).every(([side, value]) => ['be', 'fe'].includes(side) && PRESENCE.includes(value))))) bad.push(`${at}: litePresence must be a presence value or map sides to presence values`);
189
192
  if (slot.provider !== undefined) {
@@ -193,6 +196,38 @@ function slotAppOptionProblems(slot, at) {
193
196
  return bad;
194
197
  }
195
198
 
199
+ function slotEditionWhyProblems(slot, at) {
200
+ if (slot.editionWhy === undefined) return [];
201
+ return typeof slot.editionWhy === 'string' && slot.editionWhy.trim()
202
+ ? [] : [`${at}: editionWhy must be non-empty text`];
203
+ }
204
+
205
+ const slotEvidencePathValid = (value) => relPath(value) && /^[^\s]+$/.test(value);
206
+
207
+ function slotEvidenceRefValid(ref) {
208
+ if (typeof ref === 'string') return slotEvidencePathValid(ref);
209
+ if (!isPlainObject(ref) || !slotEvidencePathValid(ref.path)) return false;
210
+ if (!Object.keys(ref).every((key) => ['path', 'edition', 'editions'].includes(key))) return false;
211
+ if (ref.edition !== undefined && !EDITIONS.includes(ref.edition)) return false;
212
+ if (ref.editions !== undefined && !nonemptyUniqueList(ref.editions, (edition) => EDITIONS.includes(edition))) return false;
213
+ return ref.edition === undefined || ref.editions === undefined;
214
+ }
215
+
216
+ const slotEvidenceRefKey = (ref) => typeof ref === 'string' ? ref
217
+ : JSON.stringify([ref.path, ref.edition, ref.editions]);
218
+
219
+ function slotCoverageEvidenceProblems(slot, at) {
220
+ const bad = [];
221
+ if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
222
+ bad.push(`${at}: templateNa must be non-empty text`);
223
+ if (slot.exemplifiedBy === undefined) return bad;
224
+ const refs = slot.exemplifiedBy;
225
+ const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
226
+ && new Set(refs.map(slotEvidenceRefKey)).size === refs.length;
227
+ if (!valid) bad.push(`${at}: exemplifiedBy must be unique repository-relative paths or {path, edition? | editions?} references`);
228
+ return bad;
229
+ }
230
+
196
231
  function slotLiteOverlayProblems(slot, at) {
197
232
  if (slot.lite === undefined) return [];
198
233
  const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
@@ -209,7 +244,7 @@ function slotEntryProblems(slot, at) {
209
244
  if (slot.entries !== undefined) {
210
245
  const listed = strList(slot.entries) && slot.entries.length > 0 && new Set(slot.entries).size === slot.entries.length && slot.entries.every(relPath);
211
246
  if (!listed) bad.push(`${at}: entries must be a non-empty list of unique relative file paths`);
212
- else if (typeof slot.path !== 'string' || !slot.path.endsWith('/') || slot.owner === true) bad.push(`${at}: entries belong to a directory slot that is not an owner (they name extra public entries of the owner that holds it)`);
247
+ else if (typeof slot.path !== 'string' || !slot.path.endsWith('/')) bad.push(`${at}: entries belong to a directory slot (they name its explicit public API files)`);
213
248
  else if (Array.isArray(slot.allows) && slot.entries.some((entry) => !slot.allows.includes(entry))) bad.push(`${at}: every entry must be one of the names the slot allows`);
214
249
  }
215
250
  if (slot.outcomeHome !== undefined && !(slot.outcomeHome === true && typeof slot.path === 'string' && slot.path && !slot.path.endsWith('/'))) bad.push(`${at}: outcomeHome is true and belongs to a file slot (the one file that declares the repository's Outcome union)`);
@@ -328,6 +363,21 @@ const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCo
328
363
  && ['from', 'to'].every(side => folderKindsValid(pair[side]));
329
364
  const kindRestrictionValid = kinds => isPlainObject(kinds) && Object.keys(kinds).length > 0
330
365
  && Object.entries(kinds).every(([target, pair]) => NAME.test(target) && kindPairValid(pair));
366
+ const slotIdsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length
367
+ && list.every(id => SLOT_ID.test(id) || RUNTIME_SLOT_ID.test(id));
368
+ const slotRestrictionPairValid = pair => isPlainObject(pair) && Object.keys(pair).join() === 'from' && slotIdsValid(pair.from);
369
+ const slotRestrictionValid = slots => isPlainObject(slots) && Object.keys(slots).length > 0
370
+ && Object.entries(slots).every(([target, pair]) => NAME.test(target) && slotRestrictionPairValid(pair));
371
+
372
+ function tierDefinitionProblems(profile, tier, def) {
373
+ const bad = [];
374
+ const at = `tiers.${profile}.${tier}`;
375
+ if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`${at}.mayImport must be a list of tier names`);
376
+ if (def?.mayImportKinds !== undefined && !kindRestrictionValid(def.mayImportKinds)) bad.push(`${at}.mayImportKinds must map tier names to {from, to} non-empty lists of unique folder kinds`);
377
+ if (def?.mayImportSlots !== undefined && !slotRestrictionValid(def.mayImportSlots)) bad.push(`${at}.mayImportSlots must map tier names to {from} non-empty lists of unique slot ids`);
378
+ for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'mayImportSlots', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`${at}.${key} is not a tier field`);
379
+ return bad;
380
+ }
331
381
 
332
382
  /** Shape problems of the tier map of one profile. */
333
383
  export function tierMapProblems(profile, tiers) {
@@ -335,9 +385,7 @@ export function tierMapProblems(profile, tiers) {
335
385
  if (!isPlainObject(tiers) || !Object.keys(tiers).length) return [`tiers.${profile} must be a non-empty map`];
336
386
  for (const [tier, def] of Object.entries(tiers)) {
337
387
  if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
338
- 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`);
339
- if (def?.mayImportKinds !== undefined && !kindRestrictionValid(def.mayImportKinds)) bad.push(`tiers.${profile}.${tier}.mayImportKinds must map tier names to {from, to} non-empty lists of unique folder kinds`);
340
- for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
388
+ bad.push(...tierDefinitionProblems(profile, tier, def));
341
389
  }
342
390
  return bad;
343
391
  }
@@ -347,10 +395,22 @@ function tierFolderKindProblems(manifest, profile, tier, kinds, at) {
347
395
  return kinds.filter(kind => !known.has(kind)).map(kind => `${at} names ${kind}, which is not a folder kind of the ${tier} tier`);
348
396
  }
349
397
 
350
- /** Kind restrictions narrow an allowed tier edge and name kinds that its source and target slots actually declare. */
398
+ function tierSlotRestrictionProblems(manifest, profile, tier, def) {
399
+ const bad = [];
400
+ const known = new Set(manifest.slots.filter(slot => slot.profiles.includes(profile) && slot.tier === tier).map(slot => slot.id));
401
+ for (const [target, restriction] of Object.entries(def.mayImportSlots ?? {})) {
402
+ const at = `tiers.${profile}.${tier}.mayImportSlots.${target}`;
403
+ if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
404
+ for (const id of restriction.from) if (!known.has(id)) bad.push(`${at}.from names ${id}, which is not a slot of the ${profile} ${tier} tier`);
405
+ }
406
+ return bad;
407
+ }
408
+
409
+ /** Kind and slot restrictions narrow allowed tier edges and name declared source and target roles. */
351
410
  export function tierKindProblems(manifest, profile) {
352
411
  const bad = [];
353
412
  for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
413
+ bad.push(...tierSlotRestrictionProblems(manifest, profile, tier, def));
354
414
  for (const [target, kinds] of Object.entries(def.mayImportKinds ?? {})) {
355
415
  const at = `tiers.${profile}.${tier}.mayImportKinds.${target}`;
356
416
  if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
@@ -3,6 +3,8 @@
3
3
  // A project rule judges nothing itself: the graph (the architecture machine's module graph and slot manifest, built once per
4
4
  // process) holds the findings of its codes, and the rule reports those of the file it is visiting, on the line the machine named.
5
5
  // `settings.starci.injectedTypeScript` is a fixtures-only seam, a function returning the compiler (RuleTester freezes settings, and a frozen compiler cannot run).
6
+ // `settings.starci.injectedRuntimeRoot` is the second fixtures-only seam: a directory holding knowledge/hfs/slots.yaml that stands in for the shipped runtime, so a hermetic
7
+ // fixture can hand the registered rule a manifest with a gap (two slots of equal specificity) that the shipped manifest, guarded by its specs, never has.
6
8
  // Both lint canons ship a byte copy of this file in runtime/ and build their rules with it.
7
9
  import path from 'node:path';
8
10
  import { projectGraph } from './project-graph.mjs';
@@ -22,7 +24,7 @@ export const projectRule = ({ enforcer, runtimeRoot, hfsOf }) => {
22
24
  const hfs = hfsOf(context);
23
25
  const rel = posixPath(path.relative(hfs.repoRoot, context.filename));
24
26
  if (rel.startsWith('..')) return {};
25
- const { byFile } = projectGraph({ repoRoot: hfs.repoRoot, runtimeRoot, injectedTypeScript: context.settings?.starci?.injectedTypeScript?.() });
27
+ const { byFile } = projectGraph({ repoRoot: hfs.repoRoot, runtimeRoot: context.settings?.starci?.injectedRuntimeRoot ?? runtimeRoot, injectedTypeScript: context.settings?.starci?.injectedTypeScript?.() });
26
28
  const mine = (byFile.get(rel) ?? []).filter((finding) => belongsTo(enforcer, finding));
27
29
  return {
28
30
  Program(node) {
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { skillRoot } from '../../engine/runtime-root.mjs';
5
5
  import { parseYamlCached } from '../lib/yaml-cached.mjs';
6
+ import { jsonClone } from '../lib/json-clone.mjs';
6
7
  import { isPlainObject } from '../../engine/plain-object.mjs';
7
8
  import { SEMVER } from './manifest-shape.mjs';
8
9
  import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
@@ -15,10 +16,129 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
15
16
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
16
17
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
17
18
  const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
18
- const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'enforcers', 'cases']);
19
- const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote']);
19
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
20
+ const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
20
21
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
21
- const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions']);
22
+ const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
23
+ const CASE_REFERENCE = /^(?:case-[1-9][0-9]*|(?:ruling:)?R[0-9]{2,3}(?:\/case-[1-9][0-9]*)?|owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/;
24
+ const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
25
+ const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
26
+ const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
27
+ const MUTATION_KEYS = new Set(['op', 'path', 'content']);
28
+
29
+ function textProblem(value, label, bad) {
30
+ if (typeof value !== 'string' || !value.trim()) bad.push(`${label} must be non-empty text`);
31
+ }
32
+
33
+ function dateProblem(value, label, bad) {
34
+ const date = typeof value === 'string' && CASE_DATE.test(value) ? new Date(`${value}T00:00:00Z`) : null;
35
+ if (!date || !Number.isFinite(date.getTime()) || date.toISOString() !== `${value}T00:00:00.000Z`) bad.push(`${label} must be a valid YYYY-MM-DD date`);
36
+ }
37
+
38
+ function relativePathProblem(value, label, bad, anchor = false) {
39
+ const parts = typeof value === 'string' ? value.split('#') : [];
40
+ const file = parts[0];
41
+ const valid = typeof value === 'string' && value.trim() === value && value.length > 0
42
+ && !/[\r\n\u0000\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
43
+ && !file.split('/').some((part) => ['', '.', '..'].includes(part))
44
+ && (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
45
+ if (!valid) bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
46
+ }
47
+
48
+ function unknownKeys(item, allowed, label, bad) {
49
+ for (const key of Object.keys(item)) if (!allowed.has(key)) bad.push(`${label} has unknown key ${key}`);
50
+ }
51
+
52
+ function nonEmptyList(value, label, bad) {
53
+ if (!Array.isArray(value) || !value.length) { bad.push(`${label} must be a non-empty list`); return false; }
54
+ const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item));
55
+ if (new Set(normalized).size !== value.length) bad.push(`${label} repeats a value`);
56
+ return true;
57
+ }
58
+
59
+ function optionalText(item, key, label, bad) {
60
+ if (item[key] !== undefined) textProblem(item[key], `${label}.${key}`, bad);
61
+ }
62
+
63
+ function evidenceEditionProblems(item, label, bad) {
64
+ if (item.edition !== undefined && !['full', 'lite'].includes(item.edition)) bad.push(`${label}.edition must be full or lite`);
65
+ bad.push(...ruleEditionProblems(item, label));
66
+ if (item.edition !== undefined && item.editions !== undefined) bad.push(`${label} names edition or editions, not both`);
67
+ }
68
+
69
+ function codeListProblems(codes, label, bad) {
70
+ if (!nonEmptyList(codes, label, bad)) return;
71
+ for (const code of codes) if (typeof code !== 'string' || !FINDING_CODE.test(code)) bad.push(`${label} must contain UPPER_SNAKE finding codes`);
72
+ }
73
+
74
+ function evidenceMapProblems(item, key, label, bad) {
75
+ unknownKeys(item, key === 'docs' ? EVIDENCE_KEYS : PROOF_KEYS, label, bad);
76
+ relativePathProblem(item.path, `${label}.path`, bad, key === 'docs');
77
+ evidenceEditionProblems(item, label, bad);
78
+ if (item.codes !== undefined) codeListProblems(item.codes, `${label}.codes`, bad);
79
+ if (item.fixtures !== undefined && nonEmptyList(item.fixtures, `${label}.fixtures`, bad)) item.fixtures.forEach((value) => textProblem(value, `${label}.fixtures`, bad));
80
+ }
81
+
82
+ function evidenceProblems(rule, key, label, bad) {
83
+ if (rule[key] === undefined || !nonEmptyList(rule[key], `${label}.${key}`, bad)) return;
84
+ rule[key].forEach((item, index) => {
85
+ const at = `${label}.${key}[${index}]`;
86
+ if (typeof item === 'string') { relativePathProblem(item, at, bad, key === 'docs'); return; }
87
+ if (!isPlainObject(item)) { bad.push(`${at} must be a path or a map`); return; }
88
+ evidenceMapProblems(item, key, at, bad);
89
+ });
90
+ }
91
+
92
+ function mutationProblems(item, label, bad) {
93
+ if (!isPlainObject(item)) { bad.push(`${label} must be a map`); return; }
94
+ unknownKeys(item, MUTATION_KEYS, label, bad);
95
+ if (!['add', 'edit', 'delete'].includes(item.op)) bad.push(`${label}.op must be add, edit or delete`);
96
+ relativePathProblem(item.path, `${label}.path`, bad);
97
+ if (['add', 'edit'].includes(item.op) && typeof item.content !== 'string') bad.push(`${label}.content must be text for add or edit`);
98
+ if (item.op === 'delete' && item.content !== undefined) bad.push(`${label}.content is absent for delete`);
99
+ }
100
+
101
+ function witnessEntryProblems(item, label, bad) {
102
+ if (!isPlainObject(item)) { bad.push(`${label} must be a map`); return; }
103
+ if (Object.hasOwn(item, 'path')) { evidenceMapProblems(item, 'proves', label, bad); return; }
104
+ unknownKeys(item, WITNESS_KEYS, label, bad);
105
+ if (!['full', 'lite'].includes(item.edition)) bad.push(`${label}.edition must be full or lite`);
106
+ relativePathProblem(item.base, `${label}.base`, bad);
107
+ codeListProblems(item.expect, `${label}.expect`, bad);
108
+ if (nonEmptyList(item.mutate, `${label}.mutate`, bad)) item.mutate.forEach((mutation, index) => mutationProblems(mutation, `${label}.mutate[${index}]`, bad));
109
+ }
110
+
111
+ function witnessProblems(rule, label, bad) {
112
+ const value = rule.witness;
113
+ if (value === undefined) return;
114
+ const at = `${label}.witness`;
115
+ if (isPlainObject(value)) {
116
+ unknownKeys(value, new Set(['status', 'reason', 'date']), at, bad);
117
+ if (value.status !== 'unreachable') bad.push(`${at}.status must be unreachable`);
118
+ textProblem(value.reason, `${at}.reason`, bad);
119
+ dateProblem(value.date, `${at}.date`, bad);
120
+ return;
121
+ }
122
+ if (!nonEmptyList(value, at, bad)) return;
123
+ value.forEach((item, index) => witnessEntryProblems(item, `${at}[${index}]`, bad));
124
+ }
125
+
126
+ function caseReferenceProblems(value, label, bad) {
127
+ const values = Array.isArray(value) ? value : [value];
128
+ if (!nonEmptyList(values, label, bad)) return;
129
+ for (const ref of values) if (typeof ref !== 'string' || !CASE_REFERENCE.test(ref)) bad.push(`${label} must name case-N, R<id>[/case-N], ruling:R<id>[/case-N] or owner:<id>`);
130
+ }
131
+
132
+ function caseTransitionProblems(item, label, bad) {
133
+ if (item.status !== undefined && !['live', 'superseded', 'amended'].includes(item.status)) bad.push(`${label}.status must be live, superseded or amended`);
134
+ for (const key of ['supersedes', 'amendedBy']) if (item[key] !== undefined) caseReferenceProblems(item[key], `${label}.${key}`, bad);
135
+ if (item.staleWhen !== undefined && nonEmptyList(item.staleWhen, `${label}.staleWhen`, bad)) {
136
+ for (const pattern of item.staleWhen) {
137
+ if (typeof pattern !== 'string' || !pattern.trim()) { bad.push(`${label}.staleWhen must contain non-empty regex strings`); continue; }
138
+ try { new RegExp(pattern, 'i'); } catch { bad.push(`${label}.staleWhen has an invalid regex ${JSON.stringify(pattern)}`); }
139
+ }
140
+ }
141
+ }
22
142
 
23
143
  function vocabularyProblems(catalog, key, names, bad) {
24
144
  if (!isPlainObject(catalog[key])) { bad.push(`${key} must be a map`); return; }
@@ -75,13 +195,15 @@ function checkRuleCases(rule, label, bad) {
75
195
  if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
76
196
  if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
77
197
  for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])]) if (typeof item[key] !== 'string' || !item[key].trim()) bad.push(`${at}.${key} must be text`);
198
+ caseTransitionProblems(item, at, bad);
78
199
  });
79
200
  }
80
201
 
81
- function checkRuleGates(label, gates, bad) {
202
+ function checkRuleGates(rule, label, gates, bad) {
82
203
  if (!gates.length) return;
83
- if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
84
- if (gates.includes('pre-commit') && !gates.includes('pre-push')) bad.push(`${label} runs at pre-commit, so it also runs at pre-push`);
204
+ const releaseGate = gates.includes('land') || gates.includes('ci');
205
+ const runtimeGate = rule.scope === 'runtime' && gates.includes('runtime');
206
+ if (!releaseGate && !runtimeGate) bad.push(`${label} must run at land or ci or, for runtime scope, the runtime gate`);
85
207
  }
86
208
 
87
209
  function checkRuleFailureCodes(rule, label, codeOwner, bad) {
@@ -97,6 +219,19 @@ function checkRuleFailureCodes(rule, label, codeOwner, bad) {
97
219
  }
98
220
  }
99
221
 
222
+ function checkCodeEditions(rule, label, bad) {
223
+ const value = rule.codeEditions;
224
+ if (value === undefined) return;
225
+ if (!isPlainObject(value) || !Object.keys(value).length) { bad.push(`${label}.codeEditions must be a non-empty map`); return; }
226
+ const editions = Array.isArray(rule.editions) ? rule.editions : ['full', 'lite'];
227
+ for (const [code, selected] of Object.entries(value)) {
228
+ const at = `${label}.codeEditions.${code}`;
229
+ if (!Array.isArray(rule.failureCodes) || !rule.failureCodes.includes(code)) bad.push(`${at} must name a code owned by this rule`);
230
+ enumListProblems({ editions: selected }, at, 'editions', ['full', 'lite'], bad);
231
+ if (Array.isArray(selected) && selected.some((edition) => !editions.includes(edition))) bad.push(`${at} must be a subset of the rule's editions`);
232
+ }
233
+ }
234
+
100
235
  function checkEnforcerIdentity(enforcer, label, seen, bad) {
101
236
  if (!ENFORCER_FAMILIES.includes(enforcer.kind)) bad.push(`${label}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
102
237
  if (!ENFORCER_ID.test(String(enforcer.id))) bad.push(`${label}.id must be kebab-case`);
@@ -119,6 +254,7 @@ function checkEnforcer(enforcer, index, ruleLabel, seen, bad) {
119
254
  if (!ENFORCER_KEYS.has(key)) bad.push(`${label} has unknown key ${key}`);
120
255
  }
121
256
  bad.push(...ruleEditionProblems(enforcer, label));
257
+ optionalText(enforcer, 'editionWhy', label, bad);
122
258
  checkEnforcerIdentity(enforcer, label, seen, bad);
123
259
  if (enforcer.status !== undefined && enforcer.status !== 'planned') bad.push(`${label}.status is either absent or planned`);
124
260
  checkEnforcerLocation(enforcer, label, bad);
@@ -140,11 +276,17 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
140
276
  checkRuleIdentity(rule, index, catalog, at, label, bad);
141
277
  checkRuleText(rule, label, bad);
142
278
  checkRuleCases(rule, label, bad);
279
+ if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
280
+ optionalText(rule, 'editionWhy', label, bad);
281
+ optionalText(rule, 'templateNa', label, bad);
282
+ for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
283
+ witnessProblems(rule, label, bad);
143
284
  enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
144
285
  bad.push(...ruleEditionProblems(rule, label));
145
286
  const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
146
- checkRuleGates(label, gates, bad);
287
+ checkRuleGates(rule, label, gates, bad);
147
288
  checkRuleFailureCodes(rule, label, codeOwner, bad);
289
+ checkCodeEditions(rule, label, bad);
148
290
  checkRuleEnforcers(rule, label, bad);
149
291
  if (Array.isArray(rule.gates)) {
150
292
  const hasSonar = rule.enforcers.some((enforcer) => isPlainObject(enforcer) && enforcer.kind === 'sonar');
@@ -183,15 +325,18 @@ export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_R
183
325
  const byCode = new Map(list.flatMap((rule) => rule.failureCodes.map((code) => [code, rule])));
184
326
  return Object.freeze({
185
327
  version: doc.version, major, minor, patch,
186
- gates: deepFreeze(structuredClone(doc.gates)),
187
- enforcerKinds: deepFreeze(structuredClone(doc.enforcerKinds)),
328
+ gates: deepFreeze(jsonClone(doc.gates)),
329
+ enforcerKinds: deepFreeze(jsonClone(doc.enforcerKinds)),
188
330
  rules: list,
189
331
  /** The rule with this id (R01..), or null. */
190
332
  rule: (id) => byId.get(id) ?? null,
191
333
  /** The rule that owns this failure code (its own or a sub-check code), or null. */
192
334
  byCode: (code) => byCode.get(code) ?? null,
193
- /** Whether a finding code is judged under `edition`: a code of a rule that names `editions` without it is not (a code outside the catalog always is). */
194
- judgedIn: (code, edition = 'full') => judgedInEdition(byCode.get(code), edition),
335
+ /** Whether a finding code is judged under `edition`: codeEditions narrows its owner's editions; uncatalogued codes remain judged. */
336
+ judgedIn: (code, edition = 'full') => {
337
+ const owner = byCode.get(code);
338
+ return judgedInEdition(owner, edition) && (!owner?.codeEditions?.[code] || owner.codeEditions[code].includes(edition));
339
+ },
195
340
  enforcerJudgedIn: (kind, id, edition = 'full') => enforcerJudgedInEdition(list, kind, id, edition),
196
341
  /** The rules that run at a gate. */
197
342
  forGate: (gate) => list.filter((rule) => rule.gates.includes(gate)),
@@ -4,22 +4,33 @@
4
4
  import { isPlainObject } from '../../engine/plain-object.mjs';
5
5
  import { paramNamesOk } from './param-names.mjs';
6
6
  import { kindParamProblems, scenarioProblem } from './declaration-slots.mjs';
7
- import { roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
8
- import { byCodeUnit } from '../lib/list.mjs';
7
+ import { NAME, roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
8
+ import { byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
9
9
 
10
10
  const PROFILES = ['be', 'fe'];
11
11
  const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds'];
12
12
  const FORM_NAMES = new Set(['call', 'new', 'curried', 'object', 'primitive', 'array']);
13
13
  const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
14
14
  const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
15
- const sharedParam = (key, v) => (key === 'fileLines' ? fileLinesOk(v) : blockOk(v));
16
- const sideOk = (side, own, extra = []) => own.every((k) => side[k] !== undefined) && Object.entries(side).every(([k, v]) => own.includes(k) || extra.includes(k) || (['fileLines', 'duplicateBlock'].includes(k) && sharedParam(k, v)));
17
- const liteOk = (lite, keys) => lite === undefined || (isPlainObject(lite) && Object.keys(lite).length > 0 && Object.keys(lite).every((k) => k !== 'lite' && keys.includes(k)));
15
+ const SHARED_PARAMS = {
16
+ fileLines: fileLinesOk,
17
+ duplicateBlock: blockOk,
18
+ functionLines: v => Number.isInteger(v) && v >= 1,
19
+ featureFiles: v => Number.isInteger(v) && v >= 1,
20
+ };
21
+ const SHARED_KEYS = Object.keys(SHARED_PARAMS);
22
+ const sharedParam = (key, v) => Object.hasOwn(SHARED_PARAMS, key) && SHARED_PARAMS[key](v);
23
+ const sideOk = (side, own, extra = []) => own.every((k) => side[k] !== undefined) && Object.entries(side).every(([k, v]) => own.includes(k) || extra.includes(k) || sharedParam(k, v));
24
+ const liteKeyOk = (lite, key, keys) => key !== 'lite' && keys.includes(key)
25
+ && (!['functionLines', 'featureFiles'].includes(key) || sharedParam(key, lite[key]));
26
+ const liteOk = (lite, keys) => lite === undefined || (isPlainObject(lite) && Object.keys(lite).length > 0 && Object.keys(lite).every((k) => liteKeyOk(lite, k, keys)));
18
27
  const memberMap = (v, allowEmpty) => isPlainObject(v) && Object.keys(v).length > 0 && Object.values(v).every((list) => Array.isArray(list) && (allowEmpty || list.length > 0) && list.every((x) => /^[A-Z][A-Za-z0-9]*$/.test(String(x))) && new Set(list).size === list.length);
19
- const roleList = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => /^[a-z][a-z0-9-]*$/.test(String(x))) && new Set(v).size === v.length;
28
+ const roleList = (v) => nonemptyUniqueList(v, (role) => NAME.test(String(role)));
20
29
  const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => FORM_NAMES.has(x)) && new Set(v).size === v.length;
21
30
  const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
22
31
  const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
32
+ const dbExtensionsOk = (value) => Array.isArray(value) && value.every((name) => typeof name === 'string' && name.length > 0) && new Set(value).size === value.length;
33
+ const dbParamsOk = (db) => db === undefined || (isPlainObject(db) && Object.entries(db).every(([key, value]) => key === 'extensions' && dbExtensionsOk(value)));
23
34
 
24
35
  function beSpecificProblems(be, manifest) {
25
36
  const bad = [];
@@ -43,14 +54,15 @@ function beSpecificProblems(be, manifest) {
43
54
  export function ruleParamsProblems(m) {
44
55
  const bad = [];
45
56
  const rp = m.ruleParams;
46
- if (!isPlainObject(rp) || Object.keys(rp).some((k) => ![...PROFILES, 'common'].includes(k)) || !isPlainObject(rp.common) || !isPlainObject(rp.be) || (rp.fe !== undefined && !isPlainObject(rp.fe))) return ['ruleParams must be a map with common, be and optionally fe'];
57
+ if (!isPlainObject(rp) || Object.keys(rp).some((k) => ![...PROFILES, 'common', 'db'].includes(k)) || !isPlainObject(rp.common) || !isPlainObject(rp.be) || (rp.fe !== undefined && !isPlainObject(rp.fe))) return ['ruleParams must be a map with common, be and optionally fe and db'];
47
58
  else {
48
59
  // common holds the parameters both sides share; a side may restate one of them (a valid value) as an override, nothing else.
49
60
  // Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
50
- if (Object.keys(rp.common).length !== 2 || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock)) bad.push('ruleParams.common needs exactly fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
51
- if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, 'fileLines', 'duplicateBlock', 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns and addKinds (fileLines and duplicateBlock live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + ') and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
61
+ if (!sideOk(rp.common, []) || !fileLinesOk(rp.common.fileLines) || !blockOk(rp.common.duplicateBlock)) bad.push('ruleParams.common needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}; optional functionLines and featureFiles must be positive integers');
62
+ if (!sideOk(rp.be, BE_OWN, ['schemaAuthority', 'lite']) || (rp.be.schemaAuthority !== undefined && !SCHEMA_AUTHORITIES.includes(rp.be.schemaAuthority)) || !liteOk(rp.be.lite, [...BE_OWN, ...SHARED_KEYS, 'schemaAuthority'])) bad.push('ruleParams.be needs infraOwners, eventBus, specDoubles, paramNames, suffixes, bannedSuffixes, contractShape {helper}, unitRoles, logicRoles, thinRoles, patternScenarios, kindPatterns and addKinds (shared parameters live in ruleParams.common, a side may override them); optional: schemaAuthority (' + SCHEMA_AUTHORITIES.join(' | ') + ') and lite (overrides of the same keys)'); else bad.push(...unitRolesProblems(rp.be), ...roleListProblems(rp.be));
52
63
  bad.push(...beSpecificProblems(rp.be, m));
53
- if (rp.fe !== undefined && (!sideOk(rp.fe, [], ['lite']) || !liteOk(rp.fe.lite, ['fileLines', 'duplicateBlock']))) bad.push('ruleParams.fe may only restate fileLines and duplicateBlock of ruleParams.common, and carry a lite override of them');
64
+ if (rp.fe !== undefined && (!sideOk(rp.fe, [], ['lite']) || !liteOk(rp.fe.lite, SHARED_KEYS))) bad.push('ruleParams.fe may only restate the shared parameters of ruleParams.common, and carry a lite override of them');
65
+ if (!dbParamsOk(rp.db)) bad.push('ruleParams.db may contain only extensions: a list of unique non-empty extension names');
54
66
  }
55
67
  return bad;
56
68
  }
@@ -66,3 +66,16 @@ export function secretFindings({ repoRoot, files, resolver }) {
66
66
  }
67
67
  return findings;
68
68
  }
69
+
70
+ // The age identity stays on the host: `.claude/secret.env` (SOPS_AGE_KEY) or the sops config dir's `keys.txt`,
71
+ // both host-local files. A tracked `secret.env`, `keys.txt` or `*.agekey` is the identity committed.
72
+ const AGE_KEY_FILE = /^\.claude\/secret\.env$|(?:^|\/)keys\.txt$|\.agekey$/i;
73
+ export function ageKeyCustodyFindings({ files, inScope }) {
74
+ const findings = [];
75
+ for (const file of files.filter((f) => !inScope || inScope(f))) {
76
+ if (!AGE_KEY_FILE.test(file)) continue;
77
+ findings.push(found('HFS_AGE_KEY_CUSTODY', file,
78
+ `${file} is a tracked sops/age identity file; the age key lives only in the host-local .claude/secret.env or the sops keys.txt - never commit the identity`));
79
+ }
80
+ return findings;
81
+ }
@@ -2,9 +2,14 @@
2
2
  // owner units, the layer order and the public entry of an owner. Cycles are a graph property of the architecture check, not of one edge.
3
3
  import { RUNTIME_KIND } from './manifest-shape.mjs';
4
4
  import { classifyIn, ownerIn, tierIn } from './slot-classify.mjs';
5
+ import { braceVariants, globExpression } from '../lib/glob.mjs';
5
6
 
6
7
  const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
7
8
 
9
+ function declaredEntry(slot, relative) {
10
+ return (slot.entries ?? []).some(entry => braceVariants(entry).some(pattern => globExpression(pattern).test(relative)));
11
+ }
12
+
8
13
  function unownedPathProblem(from, to) {
9
14
  for (const side of [from, to]) {
10
15
  if (side.status === 'no-slot') return { allowed: false, reason: 'unowned', code: side.code, path: side.path, nearest: side.nearest };
@@ -39,12 +44,22 @@ function privateEntryProblem({ repo, byId }, to, toOwner) {
39
44
  if (toOwner && repo.kind !== RUNTIME_KIND) {
40
45
  const relative = to.path === toOwner.root ? '' : to.path.slice(toOwner.root.length + 1);
41
46
  const ownerSlot = byId.get(toOwner.slot);
42
- const entry = isEntryFile(relative) || (byId.get(to.slot).entries ?? []).includes(to.path.slice(to.root.length + 1)) || (ownerSlot.tier === 'package' && relative === 'src/index.ts') || (ownerSlot.tier === 'app' && relative === 'app.module.ts');
47
+ const entry = isEntryFile(relative) || declaredEntry(byId.get(to.slot), to.path.slice(to.root.length + 1)) || (ownerSlot.tier === 'package' && relative === 'src/index.ts') || (ownerSlot.tier === 'app' && relative === 'app.module.ts');
43
48
  if (!entry) return { allowed: false, reason: 'notPublicEntry', owner: toOwner.root, path: to.path };
44
49
  }
45
50
  return null;
46
51
  }
47
52
 
53
+ function tierImportProblem(scope, from, to, fromTier, toTier) {
54
+ const { mayImport, mayImportKinds, mayImportSlots } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
55
+ if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
56
+ const kindRestriction = mayImportKinds?.[toTier];
57
+ if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
58
+ const slotRestriction = mayImportSlots?.[toTier];
59
+ if (slotRestriction && !slotRestriction.from.includes(from.slot)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, slotRestriction, fromSlot: from.slot };
60
+ return null;
61
+ }
62
+
48
63
  /**
49
64
  * Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
50
65
  * tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_SLOT_UNDECLARED for the path no slot owns).
@@ -65,10 +80,8 @@ export function importAllowedIn(scope, fromPath, toPath) {
65
80
  const fromOwner = ownerIn(scope, from.path);
66
81
  const toOwner = ownerIn(scope, to.path);
67
82
  if (sameOwnerUnit(from, fromOwner) === sameOwnerUnit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
68
- const { mayImport, mayImportKinds } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
69
- if (!mayImport?.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
70
- const kindRestriction = mayImportKinds?.[toTier];
71
- if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind))) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
83
+ const tierProblem = tierImportProblem(scope, from, to, fromTier, toTier);
84
+ if (tierProblem) return tierProblem;
72
85
  const layerProblem = lowerLayerProblem(scope, from, to, fromTier, toTier, fromOwner, toOwner);
73
86
  if (layerProblem) return layerProblem;
74
87
  // A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
@@ -119,6 +119,7 @@ function sideDeclaration(manifest, declaration, name, edition, providers) {
119
119
  connections: Object.freeze((s.connections ?? []).map(connectionView)),
120
120
  providers,
121
121
  reads: Object.freeze([...(s.reads ?? [])]),
122
+ ...(s.extraPackages === undefined ? {} : { extraPackages: Object.freeze(s.extraPackages.map(entry => Object.freeze({ ...entry }))) }),
122
123
  manifestVersion: manifest.version,
123
124
  });
124
125
  }