@starci/eslint-grammar-offset-pop 0.12.13 → 0.13.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 +10 -0
- package/README.md +4 -4
- package/config.mjs +2 -2
- package/index.d.ts +4 -4
- package/index.mjs +1 -1
- package/package.json +23 -15
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +461 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +29 -0
- package/runtime/engine/release-config.mjs +28 -0
- package/runtime/engine/removed-vocabulary.mjs +26 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/secrets.mjs +134 -0
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +19 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +458 -0
- package/runtime/modules/models/registry.yaml +221 -0
- package/runtime/modules/models/runtimes.yaml +556 -0
- package/runtime/modules/models/tiers.yaml +66 -0
- package/runtime/scripts/hfs/declaration-shape.mjs +157 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +86 -0
- package/runtime/scripts/hfs/edition-slots.mjs +77 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +435 -0
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +362 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +80 -0
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +91 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +89 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +149 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/lib/i18n.mjs +85 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +31 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/yaml-cached.mjs +14 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// manifest-shape.mjs - the shape rules of an HFS slot manifest that both manifest kinds share, and the whole shape of a
|
|
2
|
+
// manifest of kind runtime (knowledge/hfs/runtime-slots.yaml): the slot fields, the tier map and ruleParams.runtime.
|
|
3
|
+
// scripts/hfs/slots.mjs (loadSlotManifest) is the only reader; it adds the app kind's own rules
|
|
4
|
+
// (sides, app kinds) and refuses a manifest whole on any problem (HFS_MANIFEST_INVALID). Pure: every function takes a
|
|
5
|
+
// parsed manifest or slot and returns a list of problems in the words of modules/schemas/hfs-slots.schema.yaml.
|
|
6
|
+
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
7
|
+
import { stringList, byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
|
|
8
|
+
|
|
9
|
+
export const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
10
|
+
export const NAME = /^[a-z][a-z0-9-]*$/;
|
|
11
|
+
/** An npm package name, scoped or not. */
|
|
12
|
+
export const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
13
|
+
const ENV_PREFIX =/^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/;
|
|
14
|
+
/** How a bounded context (a connection) is isolated: its own logical database, or its own schema of a shared one. */
|
|
15
|
+
const CONTEXT_ISOLATIONS = Object.freeze(["database", "schema"]);
|
|
16
|
+
/** The app kinds that may own a context: the services, never the migrate or cli apps that only run migrations. */
|
|
17
|
+
const CONTEXT_OWNER_KINDS = Object.freeze(["api", "worker"]);
|
|
18
|
+
/** The database engines a connection may run on: a self-described Postgres, or the Postgres of a Supabase project. */
|
|
19
|
+
const CONNECTION_PROVIDERS = Object.freeze(["postgres", "supabase"]);
|
|
20
|
+
/** The editions an app repository may declare in hfs.json (`full` when absent): the filter slots.mjs applies. */
|
|
21
|
+
export const EDITIONS = Object.freeze(["full", "lite"]);
|
|
22
|
+
/** What decides where schema changes live (ruleParams.<side>.schemaAuthority): TypeORM migrations under persistence/, or the Supabase migration tree. */
|
|
23
|
+
export const SCHEMA_AUTHORITIES = Object.freeze(["typeorm", "supabase"]);
|
|
24
|
+
/**
|
|
25
|
+
* The shape problems of the `connections` of one side: a list of {name, envPrefix, owner, isolation}, the owner an api or worker app
|
|
26
|
+
* of the same side (a connection is a bounded context and one service owns it), isolation `database` or `schema`.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} list - The declared connections (null when it is not an array).
|
|
29
|
+
* @param {unknown} apps - The declared apps of the same side.
|
|
30
|
+
* @returns {string[]} The problems, none when the shape is right.
|
|
31
|
+
*/
|
|
32
|
+
export function connectionShapeProblems(list, apps) {
|
|
33
|
+
const keys = (c) => isPlainObject(c) && NAME.test(String(c.name)) && ENV_PREFIX.test(String(c.envPrefix)) && NAME.test(String(c.owner)) && CONTEXT_ISOLATIONS.includes(c.isolation)
|
|
34
|
+
&& (c.provider === undefined || CONNECTION_PROVIDERS.includes(c.provider))
|
|
35
|
+
&& Object.keys(c).every((k) => ['name', 'envPrefix', 'owner', 'isolation', 'provider'].includes(k)) && ['name', 'envPrefix', 'owner', 'isolation'].every((k) => k in c);
|
|
36
|
+
if (!list?.every(keys)) return [`connections must be a list of {name: kebab-case context name, envPrefix: UPPER_SNAKE prefix of its env keys, owner: the service app that owns the context, isolation: ${CONTEXT_ISOLATIONS.join(' | ')}, provider?: ${CONNECTION_PROVIDERS.join(' | ')}}`];
|
|
37
|
+
const owns = (c) => Array.isArray(apps) && apps.some((app) => isPlainObject(app) && app.name === c.owner && CONTEXT_OWNER_KINDS.includes(app.kind));
|
|
38
|
+
return list.filter((c) => !owns(c)).map((c) => `connections ${c.name} is owned by ${c.owner}, which is not a ${CONTEXT_OWNER_KINDS.join(' or ')} app declared in the same side`);
|
|
39
|
+
}
|
|
40
|
+
export const SLOT_ID = /^(app|repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
41
|
+
const RUNTIME_SLOT_ID = /^runtime\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
42
|
+
export const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
|
|
43
|
+
export const TRACKED = ['tracked', 'ignored', 'external'];
|
|
44
|
+
/** A runtime manifest adds `generated`: tracked, written only by the slot's generatedBy, judged by drift (RT_GENERATED_DRIFT). */
|
|
45
|
+
const RUNTIME_TRACKED = [...TRACKED, 'generated'];
|
|
46
|
+
export const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
47
|
+
/** The coverage mode of a tracked slot of the be profile or of a runtime manifest (scripts/hfs/coverage-scope.mjs and scripts/hfs/runtime-coverage-scope.mjs derive the coverage consumers from it). */
|
|
48
|
+
export const COVERAGE = ['required', 'none'];
|
|
49
|
+
export const APP_KIND = 'app';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The unit-tested roles of a back end whose spec is required (ruleParams.be.unitRoles of the slot manifest): [{ role, spec, slot?, exempt? }].
|
|
53
|
+
* THE one list: every check that asks "is this file unit-tested, and where is its spec" asks it here. What the unit run MEASURES is
|
|
54
|
+
* logicRoles over the coverage-required slots (coverage-scope.mjs), not this list.
|
|
55
|
+
*/
|
|
56
|
+
export const unitRolesOf = (manifest) => manifest.ruleParams.be.unitRoles;
|
|
57
|
+
|
|
58
|
+
/** The roles that carry logic (ruleParams.be.logicRoles): measured at 100 per file inside a coverage-required slot. */
|
|
59
|
+
export const logicRolesOf = (manifest) => manifest.ruleParams.be.logicRoles;
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
/** Shape problems of ruleParams.be.unitRoles: unique roles of the suffix vocabulary, each spec `<role>.spec`. */
|
|
63
|
+
export function unitRolesProblems(be) {
|
|
64
|
+
const roles = be.unitRoles;
|
|
65
|
+
const roleOk = (r) => r !== null && typeof r === 'object' && !Array.isArray(r) && Object.keys(r).every((k) => ['role', 'spec', 'slot', 'exempt'].includes(k)) && NAME.test(String(r.role)) && String(r.spec) === `${r.role}.spec` && (r.slot === undefined || /^be.[a-z0-9.-]+$/.test(String(r.slot))) && (r.exempt === undefined || (Array.isArray(r.exempt) && r.exempt.every((c) => /^[A-Z][A-Z0-9_]+$/.test(String(c)))));
|
|
66
|
+
if (!Array.isArray(roles) || !roles.length || !roles.every(roleOk) || new Set(roles.map((r) => r.role)).size !== roles.length) return ['ruleParams.be.unitRoles must be a non-empty list of unique {role, spec: <role>.spec, slot?, exempt?: [rule codes]}'];
|
|
67
|
+
if (Array.isArray(be.suffixes) && roles.some((r) => !be.suffixes.includes(r.role))) return ['ruleParams.be.unitRoles names a role that is not in ruleParams.be.suffixes'];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Shape problems of ruleParams.be.logicRoles, valueRoles and thinRoles: each a non-empty list of unique roles of the suffix vocabulary; every unit role is a logic role. */
|
|
72
|
+
export function roleListProblems(be) {
|
|
73
|
+
const bad = [];
|
|
74
|
+
for (const key of ['logicRoles', 'valueRoles', 'thinRoles']) {
|
|
75
|
+
const list = be[key];
|
|
76
|
+
if (!strList(list) || !list.length || new Set(list).size !== list.length || !list.every((role) => NAME.test(role))) bad.push(`ruleParams.be.${key} must be a non-empty list of unique kebab-case roles`);
|
|
77
|
+
else if (Array.isArray(be.suffixes) && list.some((role) => !be.suffixes.includes(role))) bad.push(`ruleParams.be.${key} names a role that is not in ruleParams.be.suffixes`);
|
|
78
|
+
}
|
|
79
|
+
if (strList(be.valueRoles) && strList(be.logicRoles) && be.valueRoles.some((role) => !be.logicRoles.includes(role))) bad.push('every ruleParams.be.valueRoles role is a logic role (ruleParams.be.logicRoles)');
|
|
80
|
+
if (strList(be.logicRoles) && Array.isArray(be.unitRoles) && be.unitRoles.some((r) => r?.slot === undefined && !be.logicRoles.includes(r?.role))) bad.push('every ruleParams.be.unitRoles role that is not tied to a slot is a logic role (ruleParams.be.logicRoles)');
|
|
81
|
+
return bad;
|
|
82
|
+
}
|
|
83
|
+
/** The kind of a manifest and of a declaration that describes the StarCi runtime repository; also its one profile. */
|
|
84
|
+
export const RUNTIME_KIND = 'runtime';
|
|
85
|
+
export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
86
|
+
/** The kind of a parsed manifest: `kind`, or app when absent (knowledge/hfs/slots.yaml carries none). */
|
|
87
|
+
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
88
|
+
const strList = (v) => stringList(v);
|
|
89
|
+
|
|
90
|
+
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'];
|
|
91
|
+
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
92
|
+
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
93
|
+
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
94
|
+
const RUNTIME_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'minInstances', 'requires', 'allows', 'forbids', 'budget', 'rules', 'goesTo', 'why', 'since', 'generatedBy', 'parent', 'coverage'];
|
|
95
|
+
|
|
96
|
+
const isPositiveInt = (v) => Number.isInteger(v) && v >= 1;
|
|
97
|
+
const instancesShapeOk = (v) => isPlainObject(v) && Object.values(v).every((list) => strList(list) && list.length);
|
|
98
|
+
|
|
99
|
+
const generatedByInvalid = (generatedBy) => typeof generatedBy !== 'string' || !generatedBy || generatedBy.startsWith('/') || generatedBy.includes('..');
|
|
100
|
+
|
|
101
|
+
const profilesInvalid = (profiles, appScope, scopes) => !Array.isArray(profiles) || !profiles.length || !profiles.every((p) => scopes.includes(p)) || new Set(profiles).size !== profiles.length || (profiles.includes(appScope) && profiles.length !== 1);
|
|
102
|
+
|
|
103
|
+
function runtimeIdentityProblems(slot, at) {
|
|
104
|
+
const bad = [];
|
|
105
|
+
if (!RUNTIME_SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like runtime.<name>`);
|
|
106
|
+
if (JSON.stringify(slot.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push(`${at}: profiles must be [runtime]`);
|
|
107
|
+
if (slot.tracked === 'generated' && generatedByInvalid(slot.generatedBy)) bad.push(`${at}: a generated slot names its generator (generatedBy, a repository-relative path)`);
|
|
108
|
+
if (slot.generatedBy !== undefined && slot.tracked !== 'generated') bad.push(`${at}: generatedBy belongs to a generated slot`);
|
|
109
|
+
return bad;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function appIdentityProblems(slot, at, appScope, scopes) {
|
|
113
|
+
const bad = [];
|
|
114
|
+
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
115
|
+
if (profilesInvalid(slot.profiles, appScope, scopes)) bad.push(`${at}: profiles must be [app] or a unique non-empty subset of be, fe`);
|
|
116
|
+
else if ((slot.profiles[0] === appScope) !== String(slot.id).startsWith(`${appScope}.`)) bad.push(`${at}: an app-root slot has profiles [app] and an id app.<name>, and only it`);
|
|
117
|
+
return bad;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Shape problems of one slot of a manifest of `kind` (app or runtime). */
|
|
121
|
+
function slotIdentityProblems(slot, runtime, slotKeys, at, appScope, scopes) {
|
|
122
|
+
const bad = [];
|
|
123
|
+
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
124
|
+
bad.push(...(runtime ? runtimeIdentityProblems(slot, at) : appIdentityProblems(slot, at, appScope, scopes)));
|
|
125
|
+
return bad;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function slotCoverageProblems(slot, at, runtime) {
|
|
129
|
+
const bad = [];
|
|
130
|
+
const measured = slot.tracked === 'tracked' && (runtime || (Array.isArray(slot.profiles) && slot.profiles.includes('be')));
|
|
131
|
+
if (measured && !COVERAGE.includes(slot.coverage)) bad.push(`${at}: coverage must be one of ${COVERAGE.join(', ')} (every tracked slot of ${runtime ? 'a runtime manifest' : 'the be profile'} declares it)`);
|
|
132
|
+
if (!measured && slot.coverage !== undefined) bad.push(`${at}: coverage belongs to a tracked slot of ${runtime ? 'a runtime manifest' : 'the be profile'} only`);
|
|
133
|
+
return bad;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function slotLocationProblems(slot, at, runtime, tracked) {
|
|
137
|
+
const bad = [];
|
|
138
|
+
if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
|
|
139
|
+
if (slot.parent !== undefined && !/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/.test(String(slot.parent))) bad.push(`${at}: parent must be a slot id`);
|
|
140
|
+
if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
|
|
141
|
+
if (!tracked.includes(slot.tracked)) bad.push(`${at}: tracked must be one of ${tracked.join(', ')}`);
|
|
142
|
+
if (!NAME.test(String(slot.tier))) bad.push(`${at}: tier must be a tier name, none or inherit`);
|
|
143
|
+
if (!TESTS.includes(slot.tests)) bad.push(`${at}: tests must be one of ${TESTS.join(', ')}`);
|
|
144
|
+
if (slot.owner !== undefined && typeof slot.owner !== 'boolean') bad.push(`${at}: owner must be a boolean`);
|
|
145
|
+
bad.push(...slotCoverageProblems(slot, at, runtime));
|
|
146
|
+
return bad;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const NAMED_SLOT_FIELDS = [['appKind', 'appKind must be a name'], ['trigger', 'trigger must be a trigger kind name'], ['pattern', 'pattern must be a pattern name']];
|
|
150
|
+
|
|
151
|
+
function slotInstanceProblems(slot, at) {
|
|
152
|
+
const bad = [];
|
|
153
|
+
if (slot.pattern !== undefined && !/^[a-z][a-z0-9-]*$/.test(String(slot.pattern))) bad.push(`${at}: pattern must be a kebab-case pattern name`);
|
|
154
|
+
if (slot.requiredInstances !== undefined && !instancesShapeOk(slot.requiredInstances)) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
|
|
155
|
+
return bad;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function slotPatternProblems(slot, at) {
|
|
159
|
+
const bad = [];
|
|
160
|
+
for (const [key, what] of NAMED_SLOT_FIELDS) if (slot[key] !== undefined && !NAME.test(String(slot[key]))) bad.push(`${at}: ${what}`);
|
|
161
|
+
if (slot.minInstances !== undefined && !isPositiveInt(slot.minInstances)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
162
|
+
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
163
|
+
bad.push(...slotInstanceProblems(slot, at));
|
|
164
|
+
return bad;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const kindsInvalid = (slot) => {
|
|
168
|
+
if (slot.kinds === undefined || !strList(slot.kinds)) return false;
|
|
169
|
+
return !slot.kinds.length || new Set(slot.kinds).size !== slot.kinds.length || slot.kinds.some((k) => !NAME.test(k) || (slot.layers ?? []).includes(k));
|
|
170
|
+
};
|
|
171
|
+
const rolesValid = (roles) => isPlainObject(roles) && Object.keys(roles).length && Object.entries(roles).every(([role, file]) => NAME.test(role) && typeof file === 'string' && file && !file.includes('/'));
|
|
172
|
+
const composedByValid = (list) => strList(list) && list.length && new Set(list).size === list.length;
|
|
173
|
+
const budgetValid = (budget) => isPlainObject(budget) && Object.keys(budget).length && Object.values(budget).every(isPositiveInt);
|
|
174
|
+
|
|
175
|
+
function slotListProblems(slot, at) {
|
|
176
|
+
const bad = [];
|
|
177
|
+
for (const key of ['requires', 'allows', 'forbids', 'layers', 'kinds']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
178
|
+
if (kindsInvalid(slot)) bad.push(`${at}: kinds must be a non-empty list of unique folder names that are not layers`);
|
|
179
|
+
if (slot.roles !== undefined && !rolesValid(slot.roles)) bad.push(`${at}: roles must map a role name to a file name`);
|
|
180
|
+
if (slot.composedBy !== undefined && !composedByValid(slot.composedBy)) bad.push(`${at}: composedBy must be a non-empty list of unique app kinds`);
|
|
181
|
+
if (slot.budget !== undefined && !budgetValid(slot.budget)) bad.push(`${at}: budget must map names to positive integers`);
|
|
182
|
+
for (const key of ['managedBy', 'liteManagedBy']) if (slot[key] !== undefined && !NAME.test(String(slot[key]))) bad.push(`${at}: ${key} must be a template id`);
|
|
183
|
+
return bad;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function slotAppOptionProblems(slot, at) {
|
|
187
|
+
const bad = [];
|
|
188
|
+
bad.push(...slotEditionWhyProblems(slot, at), ...slotCoverageEvidenceProblems(slot, at));
|
|
189
|
+
// 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.
|
|
190
|
+
if (slot.since === undefined) bad.push(`${at}: since is required (the manifest version that added the slot)`);
|
|
191
|
+
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(', ')}`);
|
|
192
|
+
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`);
|
|
193
|
+
if (slot.provider !== undefined) {
|
|
194
|
+
if (!CONNECTION_PROVIDERS.includes(slot.provider)) bad.push(`${at}: provider must be one of ${CONNECTION_PROVIDERS.join(' | ')}`);
|
|
195
|
+
else if (slot.presence !== 'opt-in' || slot.appKind !== undefined) bad.push(`${at}: provider belongs to an opt-in slot that is not an app kind (a connection with that provider enables it)`);
|
|
196
|
+
}
|
|
197
|
+
return bad;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function slotEditionWhyProblems(slot, at) {
|
|
201
|
+
if (slot.editionWhy === undefined) return [];
|
|
202
|
+
return typeof slot.editionWhy === 'string' && slot.editionWhy.trim()
|
|
203
|
+
? [] : [`${at}: editionWhy must be non-empty text`];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const slotEvidencePathValid = (value) => relPath(value) && /^[^\s]+$/.test(value);
|
|
207
|
+
|
|
208
|
+
function slotEvidenceRefValid(ref) {
|
|
209
|
+
if (typeof ref === 'string') return slotEvidencePathValid(ref);
|
|
210
|
+
if (!isPlainObject(ref) || !slotEvidencePathValid(ref.path)) return false;
|
|
211
|
+
if (!Object.keys(ref).every((key) => ['path', 'edition', 'editions'].includes(key))) return false;
|
|
212
|
+
if (ref.edition !== undefined && !EDITIONS.includes(ref.edition)) return false;
|
|
213
|
+
if (ref.editions !== undefined && !nonemptyUniqueList(ref.editions, (edition) => EDITIONS.includes(edition))) return false;
|
|
214
|
+
return ref.edition === undefined || ref.editions === undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const slotEvidenceRefKey = (ref) => typeof ref === 'string' ? ref
|
|
218
|
+
: JSON.stringify([ref.path, ref.edition, ref.editions]);
|
|
219
|
+
|
|
220
|
+
function slotCoverageEvidenceProblems(slot, at) {
|
|
221
|
+
const bad = [];
|
|
222
|
+
if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
|
|
223
|
+
bad.push(`${at}: templateNa must be non-empty text`);
|
|
224
|
+
if (slot.exemplifiedBy === undefined) return bad;
|
|
225
|
+
const refs = slot.exemplifiedBy;
|
|
226
|
+
const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
|
|
227
|
+
&& new Set(refs.map(slotEvidenceRefKey)).size === refs.length;
|
|
228
|
+
if (!valid) bad.push(`${at}: exemplifiedBy must be unique repository-relative paths or {path, edition? | editions?} references`);
|
|
229
|
+
return bad;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function slotLiteOverlayProblems(slot, at) {
|
|
233
|
+
if (slot.lite === undefined) return [];
|
|
234
|
+
const ok = isPlainObject(slot.lite) && Object.keys(slot.lite).length && Object.keys(slot.lite).every((key) => LITE_OVERLAY_KEYS.includes(key))
|
|
235
|
+
&& (slot.lite.path === undefined || (typeof slot.lite.path === 'string' && slot.lite.path))
|
|
236
|
+
&& (slot.lite.why === undefined || (typeof slot.lite.why === 'string' && slot.lite.why.trim()))
|
|
237
|
+
&& ['requires', 'allows', 'forbids'].every((key) => slot.lite[key] === undefined || strList(slot.lite[key]))
|
|
238
|
+
&& (slot.lite.minInstances === undefined || (Number.isInteger(slot.lite.minInstances) && slot.lite.minInstances >= 1))
|
|
239
|
+
&& (slot.lite.requiredInstances === undefined || (isPlainObject(slot.lite.requiredInstances) && Object.values(slot.lite.requiredInstances).every((v) => strList(v) && v.length)));
|
|
240
|
+
return ok ? [] : [`${at}: lite may hold only ${LITE_OVERLAY_KEYS.join(', ')}, each shaped as in the slot body`];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function slotEntryProblems(slot, at) {
|
|
244
|
+
const bad = [];
|
|
245
|
+
if (slot.entries !== undefined) {
|
|
246
|
+
const listed = strList(slot.entries) && slot.entries.length > 0 && new Set(slot.entries).size === slot.entries.length && slot.entries.every(relPath);
|
|
247
|
+
if (!listed) bad.push(`${at}: entries must be a non-empty list of unique relative file paths`);
|
|
248
|
+
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)`);
|
|
249
|
+
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`);
|
|
250
|
+
}
|
|
251
|
+
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)`);
|
|
252
|
+
return bad;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function slotPolicyProblems(slot, at) {
|
|
256
|
+
const bad = [];
|
|
257
|
+
if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
|
|
258
|
+
if (slot.perConnection !== undefined && !(slot.id === 'be.persistence' && strList(slot.perConnection) && slot.perConnection.length && new Set(slot.perConnection).size === slot.perConnection.length && slot.perConnection.every((name) => NAME.test(name)))) bad.push(`${at}: perConnection is a unique list of capability names, only on be.persistence (the platform capabilities whose tables exist on every connection that uses them)`);
|
|
259
|
+
if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
|
|
260
|
+
if (slot.tracked === 'external' && (typeof slot.goesTo !== 'string' || slot.presence !== 'forbidden')) bad.push(`${at}: an external slot is forbidden and says where it goes (goesTo)`);
|
|
261
|
+
if (slot.presence === 'forbidden' && slot.tracked !== 'external') bad.push(`${at}: a forbidden slot is external`);
|
|
262
|
+
return bad;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function slotProblems(slot, index, kind, { appScope = 'app', scopes = [] } = {}) {
|
|
266
|
+
const runtime = kind === RUNTIME_KIND;
|
|
267
|
+
const slotKeys = new Set(runtime ? RUNTIME_SLOT_KEYS : APP_SLOT_KEYS);
|
|
268
|
+
const tracked = runtime ? RUNTIME_TRACKED : TRACKED;
|
|
269
|
+
const at = isPlainObject(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
270
|
+
if (!isPlainObject(slot)) return [`${at} is not a map`];
|
|
271
|
+
const bad = slotIdentityProblems(slot, runtime, slotKeys, at, appScope, scopes);
|
|
272
|
+
bad.push(...slotLocationProblems(slot, at, runtime, tracked), ...slotPatternProblems(slot, at), ...slotListProblems(slot, at));
|
|
273
|
+
if (!runtime) bad.push(...slotAppOptionProblems(slot, at), ...slotLiteOverlayProblems(slot, at));
|
|
274
|
+
bad.push(...slotEntryProblems(slot, at), ...slotPolicyProblems(slot, at));
|
|
275
|
+
return bad;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The owners of an external call in a runtime manifest: an api system folder (`api/<system>`, `api/*` any system) or the DB tier. */
|
|
279
|
+
const INFRA_OWNER = /^(?:api\/(?:\*|[a-z][a-z0-9-]*)|engine\/db)$/;
|
|
280
|
+
const RUNTIME_PARAM_KEYS = new Set(['fileLines', 'sourceRoots', 'infraOwners', 'baseWriteMembers', 'baseEnvSeams', 'apiContracts', 'sourceName', 'oneOffNames', 'sharedBasenames', 'generated', 'heldSecrets', 'pinned', 'selfChecks']);
|
|
281
|
+
const relPath = (v) => typeof v === 'string' && v.length > 0 && !v.startsWith('/') && !v.includes('..') && !v.includes('\\');
|
|
282
|
+
|
|
283
|
+
function runtimeHeaderProblems(m) {
|
|
284
|
+
const bad = [];
|
|
285
|
+
const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'profiles', 'tiers', 'ruleParams', 'crossOwner', 'crossSystem', 'slots', 'consumers']);
|
|
286
|
+
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
287
|
+
if (!/^starci\/runtime-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/runtime-slots@<major>');
|
|
288
|
+
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
289
|
+
if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major and pins text');
|
|
290
|
+
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
291
|
+
if (JSON.stringify(m.trackedValues) !== JSON.stringify(RUNTIME_TRACKED)) bad.push(`trackedValues must be ${RUNTIME_TRACKED.join(', ')}`);
|
|
292
|
+
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
293
|
+
if (JSON.stringify(m.profiles) !== JSON.stringify([RUNTIME_KIND])) bad.push('profiles must be [runtime]');
|
|
294
|
+
return bad;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function runtimeSectionsProblems(m) {
|
|
298
|
+
const bad = [];
|
|
299
|
+
for (const key of ['crossOwner', 'crossSystem']) if (m[key] !== undefined && typeof m[key] !== 'string') bad.push(`${key} must be text`);
|
|
300
|
+
if (!isPlainObject(m.tiers) || Object.keys(m.tiers).join() !== RUNTIME_KIND) bad.push('tiers must be a map with exactly runtime');
|
|
301
|
+
else bad.push(...tierMapProblems(RUNTIME_KIND, m.tiers.runtime));
|
|
302
|
+
const rp = m.ruleParams;
|
|
303
|
+
if (!isPlainObject(rp) || Object.keys(rp).join() !== RUNTIME_KIND || !isPlainObject(rp.runtime)) bad.push('ruleParams must be a map with exactly runtime');
|
|
304
|
+
else bad.push(...runtimeParamProblems(rp.runtime));
|
|
305
|
+
if (!Array.isArray(m.slots) || !m.slots.length) bad.push('slots must be a non-empty list');
|
|
306
|
+
else m.slots.forEach((slot, index) => bad.push(...slotProblems(slot, index, RUNTIME_KIND)));
|
|
307
|
+
if (m.consumers !== undefined && !strList(m.consumers)) bad.push('consumers must be a list of paths');
|
|
308
|
+
return bad;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Shape problems of a parsed manifest of kind runtime (knowledge/hfs/runtime-slots.yaml), in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
312
|
+
export function runtimeShapeProblems(m) {
|
|
313
|
+
return [...runtimeHeaderProblems(m), ...runtimeSectionsProblems(m)];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Shape problems of ruleParams.runtime. */
|
|
317
|
+
function runtimeParamKeyProblems(rp) {
|
|
318
|
+
const bad = [];
|
|
319
|
+
for (const key of Object.keys(rp)) if (!RUNTIME_PARAM_KEYS.has(key)) bad.push(`ruleParams.runtime.${key} is not a runtime parameter`);
|
|
320
|
+
for (const key of RUNTIME_PARAM_KEYS) if (!(key in rp)) bad.push(`ruleParams.runtime.${key} is missing`);
|
|
321
|
+
return bad;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function runtimeParamFoundationProblems(rp) {
|
|
325
|
+
const bad = [];
|
|
326
|
+
const fl = rp.fileLines;
|
|
327
|
+
if (!(isPlainObject(fl) && Number.isInteger(fl.soft) && fl.soft >= 1 && typeof fl.hardGrowth === 'boolean' && Object.keys(fl).length === 2)) bad.push('ruleParams.runtime.fileLines must be {soft, hardGrowth}');
|
|
328
|
+
const owners = rp.infraOwners;
|
|
329
|
+
const ownerMap = (v) => isPlainObject(v) && Object.entries(v).every(([key, list]) => key && Array.isArray(list) && list.every((o) => INFRA_OWNER.test(String(o))) && new Set(list).size === list.length);
|
|
330
|
+
if (!isPlainObject(owners) || Object.keys(owners).sort(byCodeUnit).join() !== 'globals,modules,programs' || !['globals', 'modules', 'programs'].every((k) => ownerMap(owners[k]))) bad.push('ruleParams.runtime.infraOwners must be {modules, globals, programs}, each mapping a name to unique owners api/<system>, api/* or engine/db ([] means nowhere)');
|
|
331
|
+
return bad;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function runtimeParamListProblems(rp) {
|
|
335
|
+
const bad = [];
|
|
336
|
+
for (const key of ['sourceRoots', 'baseWriteMembers', 'oneOffNames', 'sharedBasenames']) if (!(strList(rp[key]) && rp[key].length && new Set(rp[key]).size === rp[key].length)) bad.push(`ruleParams.runtime.${key} must be a non-empty list of unique strings`);
|
|
337
|
+
if (!(Array.isArray(rp.baseEnvSeams) && rp.baseEnvSeams.every(relPath))) bad.push('ruleParams.runtime.baseEnvSeams must be a list of repository-relative paths');
|
|
338
|
+
if (!(isPlainObject(rp.apiContracts) && Object.entries(rp.apiContracts).every(([system, file]) => NAME.test(system) && relPath(file)))) bad.push('ruleParams.runtime.apiContracts must map an api system to its calls contract path');
|
|
339
|
+
return bad;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function runtimeParamArtifactProblems(rp) {
|
|
343
|
+
const bad = [];
|
|
344
|
+
let sourceNameOk = typeof rp.sourceName === 'string';
|
|
345
|
+
try { if (sourceNameOk) new RegExp(rp.sourceName); } catch { sourceNameOk = false; }
|
|
346
|
+
if (!sourceNameOk) bad.push('ruleParams.runtime.sourceName must be a regular expression');
|
|
347
|
+
if (!(Array.isArray(rp.generated) && rp.generated.length && rp.generated.every((g) => isPlainObject(g) && relPath(g.root) && relPath(g.generatedBy) && Object.keys(g).length === 2))) bad.push('ruleParams.runtime.generated must be a non-empty list of {root, generatedBy}');
|
|
348
|
+
if (!(Array.isArray(rp.pinned) && rp.pinned.length && rp.pinned.every((p) => isPlainObject(p) && relPath(p.path) && typeof p.why === 'string' && p.why && Object.keys(p).length === 2))) bad.push('ruleParams.runtime.pinned must be a non-empty list of {path, why}');
|
|
349
|
+
if (!(Array.isArray(rp.selfChecks) && rp.selfChecks.length && rp.selfChecks.every((c) => isPlainObject(c) && NAME.test(String(c.id)) && relPath(c.run) && (c.args === undefined || strList(c.args)) && Object.keys(c).every((k) => ['id', 'run', 'args'].includes(k))))) bad.push('ruleParams.runtime.selfChecks must be a non-empty list of {id, run, args?}');
|
|
350
|
+
return bad;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function runtimeParamProblems(rp) {
|
|
354
|
+
return [
|
|
355
|
+
...runtimeParamKeyProblems(rp),
|
|
356
|
+
...runtimeParamFoundationProblems(rp),
|
|
357
|
+
...runtimeParamListProblems(rp),
|
|
358
|
+
...runtimeParamArtifactProblems(rp),
|
|
359
|
+
];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const folderKindsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length && list.every(kind => NAME.test(kind));
|
|
363
|
+
const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCodeUnit).join() === 'from,to'
|
|
364
|
+
&& ['from', 'to'].every(side => folderKindsValid(pair[side]));
|
|
365
|
+
/** A non-empty map whose every key is a name and every value satisfies `valid`. */
|
|
366
|
+
export const namedMapValid = (map, valid) => isPlainObject(map) && Object.keys(map).length > 0
|
|
367
|
+
&& Object.entries(map).every(([name, entry]) => NAME.test(name) && valid(entry));
|
|
368
|
+
const kindRestrictionValid = kinds => namedMapValid(kinds, kindPairValid);
|
|
369
|
+
const slotIdsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length
|
|
370
|
+
&& list.every(id => SLOT_ID.test(id) || RUNTIME_SLOT_ID.test(id));
|
|
371
|
+
const slotRestrictionPairValid = pair => isPlainObject(pair) && Object.keys(pair).join() === 'from' && slotIdsValid(pair.from);
|
|
372
|
+
const slotRestrictionValid = slots => namedMapValid(slots, slotRestrictionPairValid);
|
|
373
|
+
|
|
374
|
+
function tierDefinitionProblems(profile, tier, def) {
|
|
375
|
+
const bad = [];
|
|
376
|
+
const at = `tiers.${profile}.${tier}`;
|
|
377
|
+
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`);
|
|
378
|
+
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`);
|
|
379
|
+
if (def?.mayImportSlots !== undefined && !slotRestrictionValid(def.mayImportSlots)) bad.push(`${at}.mayImportSlots must map tier names to {from} non-empty lists of unique slot ids`);
|
|
380
|
+
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'mayImportSlots', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`${at}.${key} is not a tier field`);
|
|
381
|
+
return bad;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Shape problems of the tier map of one profile. */
|
|
385
|
+
export function tierMapProblems(profile, tiers) {
|
|
386
|
+
const bad = [];
|
|
387
|
+
if (!isPlainObject(tiers) || !Object.keys(tiers).length) return [`tiers.${profile} must be a non-empty map`];
|
|
388
|
+
for (const [tier, def] of Object.entries(tiers)) {
|
|
389
|
+
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
390
|
+
bad.push(...tierDefinitionProblems(profile, tier, def));
|
|
391
|
+
}
|
|
392
|
+
return bad;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function tierFolderKindProblems(manifest, profile, tier, kinds, at) {
|
|
396
|
+
const known = new Set(manifest.slots.filter((slot) => slot.profiles.includes(profile) && slot.tier === tier).flatMap((slot) => [...(slot.kinds ?? []), ...(slot.layers ?? [])]));
|
|
397
|
+
return kinds.filter(kind => !known.has(kind)).map(kind => `${at} names ${kind}, which is not a folder kind of the ${tier} tier`);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function tierSlotRestrictionProblems(manifest, profile, tier, def) {
|
|
401
|
+
const bad = [];
|
|
402
|
+
const known = new Set(manifest.slots.filter(slot => slot.profiles.includes(profile) && slot.tier === tier).map(slot => slot.id));
|
|
403
|
+
for (const [target, restriction] of Object.entries(def.mayImportSlots ?? {})) {
|
|
404
|
+
const at = `tiers.${profile}.${tier}.mayImportSlots.${target}`;
|
|
405
|
+
if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
|
|
406
|
+
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`);
|
|
407
|
+
}
|
|
408
|
+
return bad;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Kind and slot restrictions narrow allowed tier edges and name declared source and target roles. */
|
|
412
|
+
export function tierKindProblems(manifest, profile) {
|
|
413
|
+
const bad = [];
|
|
414
|
+
for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
|
|
415
|
+
bad.push(...tierSlotRestrictionProblems(manifest, profile, tier, def));
|
|
416
|
+
for (const [target, kinds] of Object.entries(def.mayImportKinds ?? {})) {
|
|
417
|
+
const at = `tiers.${profile}.${tier}.mayImportKinds.${target}`;
|
|
418
|
+
if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
|
|
419
|
+
bad.push(...tierFolderKindProblems(manifest, profile, tier, kinds.from, `${at}.from`), ...tierFolderKindProblems(manifest, profile, target, kinds.to, `${at}.to`));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return bad;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The semantic rules of a runtime manifest beyond the slot rules both kinds share: tier names and the schema major. */
|
|
426
|
+
export function runtimeSemanticProblems(m) {
|
|
427
|
+
const bad = tierKindProblems(m, RUNTIME_KIND);
|
|
428
|
+
const tiers = m.tiers.runtime;
|
|
429
|
+
for (const [tier, def] of Object.entries(tiers)) {
|
|
430
|
+
for (const target of def.mayImport) if (!(target in tiers)) bad.push(`tiers.runtime.${tier}.mayImport names ${target}, which is not a runtime tier`);
|
|
431
|
+
}
|
|
432
|
+
const major = Number(m.version.split('.')[0]);
|
|
433
|
+
if (String(m.schema) !== `starci/runtime-slots@${major}`) bad.push(`schema ${m.schema} does not carry the major of version ${m.version}`);
|
|
434
|
+
return bad;
|
|
435
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// param-names.mjs - the one shape of `ruleParams.be.paramNames` of knowledge/hfs/slots.yaml (R85 injected-param-name): a non-empty list of unique
|
|
2
|
+
// entries `{type | typeSuffix, names, nameSuffix?}`. The loader of scripts/hfs/slots.mjs calls it; the schema (modules/schemas/hfs-slots.schema.yaml)
|
|
3
|
+
// states the same shape.
|
|
4
|
+
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
5
|
+
|
|
6
|
+
const UPPER = /^[A-Z][A-Za-z0-9]*$/u;
|
|
7
|
+
const LOWER = /^[a-z][A-Za-z0-9]*$/u;
|
|
8
|
+
|
|
9
|
+
const entryOk = (e) => isPlainObject(e) && ((e.type === undefined) !== (e.typeSuffix === undefined)) && UPPER.test(String(e.type ?? e.typeSuffix))
|
|
10
|
+
&& Array.isArray(e.names) && e.names.length > 0 && new Set(e.names).size === e.names.length && e.names.every((n) => LOWER.test(String(n)))
|
|
11
|
+
&& (e.nameSuffix === undefined || UPPER.test(String(e.nameSuffix))) && Object.keys(e).every((k) => ['type', 'typeSuffix', 'names', 'nameSuffix'].includes(k));
|
|
12
|
+
|
|
13
|
+
/** True when `list` is a well-formed paramNames table. */
|
|
14
|
+
export function paramNamesOk(list) {
|
|
15
|
+
if (!Array.isArray(list) || list.length === 0 || !list.every(entryOk)) return false;
|
|
16
|
+
const keys = list.map((e) => (e.type !== undefined ? `type:${e.type}` : `typeSuffix:${e.typeSuffix}`));
|
|
17
|
+
return new Set(keys).size === keys.length;
|
|
18
|
+
}
|