@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.
- package/CHANGELOG.md +6 -0
- package/callback-shape.mjs +344 -0
- package/client-boundary.mjs +1 -1
- package/docs/hooks-folder.md +1 -1
- package/env-owner.mjs +120 -9
- package/function-budget.mjs +49 -0
- package/hooks-folder.mjs +149 -18
- package/icon.mjs +67 -0
- package/index.mjs +47 -0
- package/lib/own-hook-origin.mjs +53 -0
- package/lib/program-for.mjs +22 -0
- package/lib/pure-owner-helper.mjs +148 -0
- package/lib/redux-hook-origin.mjs +44 -0
- package/next-conventions.mjs +38 -35
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/served-locale.mjs +80 -9
- package/size-growth.mjs +10 -6
- package/swr-family.mjs +197 -0
- package/translation.mjs +135 -7
- 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
|
|
4
|
-
//
|
|
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('/')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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(
|
|
187
|
-
enforcerKinds: deepFreeze(
|
|
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`:
|
|
194
|
-
judgedIn: (code, edition = 'full') =>
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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) =>
|
|
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 (
|
|
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,
|
|
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,
|
|
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)
|
|
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
|
|
69
|
-
if (
|
|
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
|
}
|