@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +4 -4
  3. package/config.mjs +2 -2
  4. package/index.d.ts +4 -4
  5. package/index.mjs +1 -1
  6. package/package.json +23 -15
  7. package/runtime/engine/by-code-unit.mjs +12 -0
  8. package/runtime/engine/config.mjs +461 -0
  9. package/runtime/engine/invalid-config.mjs +15 -0
  10. package/runtime/engine/model-config.mjs +90 -0
  11. package/runtime/engine/orca-config.mjs +29 -0
  12. package/runtime/engine/release-config.mjs +28 -0
  13. package/runtime/engine/removed-vocabulary.mjs +26 -0
  14. package/runtime/engine/resources-config.mjs +52 -0
  15. package/runtime/engine/secrets.mjs +134 -0
  16. package/runtime/engine/sonar-config.mjs +21 -0
  17. package/runtime/knowledge/hfs/canon-pins.yaml +19 -20
  18. package/runtime/modules/kernel/removed-vocabulary.yaml +458 -0
  19. package/runtime/modules/models/registry.yaml +221 -0
  20. package/runtime/modules/models/runtimes.yaml +556 -0
  21. package/runtime/modules/models/tiers.yaml +66 -0
  22. package/runtime/scripts/hfs/declaration-shape.mjs +157 -0
  23. package/runtime/scripts/hfs/declaration-slots.mjs +86 -0
  24. package/runtime/scripts/hfs/edition-slots.mjs +77 -0
  25. package/runtime/scripts/hfs/manifest-shape.mjs +435 -0
  26. package/runtime/scripts/hfs/param-names.mjs +18 -0
  27. package/runtime/scripts/hfs/rule-catalog.mjs +362 -0
  28. package/runtime/scripts/hfs/rule-params-shape.mjs +80 -0
  29. package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
  30. package/runtime/scripts/hfs/slot-classify.mjs +65 -0
  31. package/runtime/scripts/hfs/slot-errors.mjs +11 -0
  32. package/runtime/scripts/hfs/slot-imports.mjs +91 -0
  33. package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
  34. package/runtime/scripts/hfs/slot-match.mjs +153 -0
  35. package/runtime/scripts/hfs/slot-path.mjs +8 -0
  36. package/runtime/scripts/hfs/slot-required.mjs +89 -0
  37. package/runtime/scripts/hfs/slot-semantic-problems.mjs +149 -0
  38. package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
  39. package/runtime/scripts/lib/i18n.mjs +85 -0
  40. package/runtime/scripts/lib/json-clone.mjs +61 -0
  41. package/runtime/scripts/lib/list.mjs +31 -0
  42. package/runtime/scripts/lib/regex.mjs +4 -0
  43. package/runtime/scripts/lib/yaml-cached.mjs +14 -0
@@ -0,0 +1,362 @@
1
+ // rule-catalog.mjs - the parsed and validated HFS rule catalog.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { skillRoot } from '../../engine/runtime-root.mjs';
5
+ import { parseYamlCached } from '../lib/yaml-cached.mjs';
6
+ import { jsonClone } from '../lib/json-clone.mjs';
7
+ import { byCodeUnit } from '../lib/list.mjs';
8
+ import { isPlainObject } from '../../engine/plain-object.mjs';
9
+ import { SEMVER } from './manifest-shape.mjs';
10
+ import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
11
+ import { fail } from './slot-errors.mjs';
12
+
13
+ const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
14
+ const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar', 'runtime']);
15
+ const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'eslint-grammar-core', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
16
+ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
17
+ const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
18
+ const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
19
+ const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
20
+ /**
21
+ * The enforcer kinds that run only inside the StarCi runtime repository (`starci runtime check`, `starci work`): no published package carries
22
+ * them, so no package spec can hold them (owner ruling no-runtime-specs: the runtime has no specs of its own; the package clean-test and the
23
+ * scaffold e2e are its tests), and no example app is a product they judge. A rule is runtime-judged when every enforcer it has is of these kinds.
24
+ */
25
+ export const RUNTIME_ONLY_ENFORCERS = Object.freeze(['work-validate', 'runtime']);
26
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
27
+ const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
28
+ const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
29
+ const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
30
+ const CASE_ID = /case-[1-9]\d*/;
31
+ const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
32
+ const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
33
+ const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
34
+ const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
35
+ const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
36
+ const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
37
+ const MUTATION_KEYS = new Set(['op', 'path', 'content']);
38
+
39
+ function textProblem(value, label, bad) {
40
+ if (typeof value !== 'string' || !value.trim()) bad.push(`${label} must be non-empty text`);
41
+ }
42
+
43
+ function dateProblem(value, label, bad) {
44
+ const date = typeof value === 'string' && CASE_DATE.test(value) ? new Date(`${value}T00:00:00Z`) : null;
45
+ if (!date || !Number.isFinite(date.getTime()) || date.toISOString() !== `${value}T00:00:00.000Z`) bad.push(`${label} must be a valid YYYY-MM-DD date`);
46
+ }
47
+
48
+ function relativePathProblem(value, label, bad, anchor = false) {
49
+ const parts = typeof value === 'string' ? value.split('#') : [];
50
+ const file = parts[0];
51
+ const valid = typeof value === 'string' && value.trim() === value && value.length > 0
52
+ && !value.includes('\0') && !/[\r\n\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
53
+ && !file.split('/').some((part) => ['', '.', '..'].includes(part))
54
+ && (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
55
+ if (!valid) bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
56
+ }
57
+
58
+ function unknownKeys(item, allowed, label, bad) {
59
+ for (const key of Object.keys(item)) if (!allowed.has(key)) bad.push(`${label} has unknown key ${key}`);
60
+ }
61
+
62
+ function nonEmptyList(value, label, bad) {
63
+ if (!Array.isArray(value) || !value.length) { bad.push(`${label} must be a non-empty list`); return false; }
64
+ const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort(byCodeUnit).map((key) => [key, item[key]])) : item));
65
+ if (new Set(normalized).size !== value.length) bad.push(`${label} repeats a value`);
66
+ return true;
67
+ }
68
+
69
+ function optionalText(item, key, label, bad) {
70
+ if (item[key] !== undefined) textProblem(item[key], `${label}.${key}`, bad);
71
+ }
72
+
73
+ function evidenceEditionProblems(item, label, bad) {
74
+ if (item.edition !== undefined && !['full', 'lite'].includes(item.edition)) bad.push(`${label}.edition must be full or lite`);
75
+ bad.push(...ruleEditionProblems(item, label));
76
+ if (item.edition !== undefined && item.editions !== undefined) bad.push(`${label} names edition or editions, not both`);
77
+ }
78
+
79
+ function codeListProblems(codes, label, bad) {
80
+ if (!nonEmptyList(codes, label, bad)) return;
81
+ for (const code of codes) if (typeof code !== 'string' || !FINDING_CODE.test(code)) bad.push(`${label} must contain UPPER_SNAKE finding codes`);
82
+ }
83
+
84
+ function evidenceMapProblems(item, key, label, bad) {
85
+ unknownKeys(item, key === 'docs' ? EVIDENCE_KEYS : PROOF_KEYS, label, bad);
86
+ relativePathProblem(item.path, `${label}.path`, bad, key === 'docs');
87
+ evidenceEditionProblems(item, label, bad);
88
+ if (item.codes !== undefined) codeListProblems(item.codes, `${label}.codes`, bad);
89
+ if (item.fixtures !== undefined && nonEmptyList(item.fixtures, `${label}.fixtures`, bad)) item.fixtures.forEach((value) => textProblem(value, `${label}.fixtures`, bad));
90
+ }
91
+
92
+ function evidenceProblems(rule, key, label, bad) {
93
+ if (rule[key] === undefined || !nonEmptyList(rule[key], `${label}.${key}`, bad)) return;
94
+ rule[key].forEach((item, index) => {
95
+ const at = `${label}.${key}[${index}]`;
96
+ if (typeof item === 'string') { relativePathProblem(item, at, bad, key === 'docs'); return; }
97
+ if (!isPlainObject(item)) { bad.push(`${at} must be a path or a map`); return; }
98
+ evidenceMapProblems(item, key, at, bad);
99
+ });
100
+ }
101
+
102
+ function mutationProblems(item, label, bad) {
103
+ if (!isPlainObject(item)) { bad.push(`${label} must be a map`); return; }
104
+ unknownKeys(item, MUTATION_KEYS, label, bad);
105
+ if (!['add', 'edit', 'delete'].includes(item.op)) bad.push(`${label}.op must be add, edit or delete`);
106
+ relativePathProblem(item.path, `${label}.path`, bad);
107
+ if (['add', 'edit'].includes(item.op) && typeof item.content !== 'string') bad.push(`${label}.content must be text for add or edit`);
108
+ if (item.op === 'delete' && item.content !== undefined) bad.push(`${label}.content is absent for delete`);
109
+ }
110
+
111
+ function witnessEntryProblems(item, label, bad) {
112
+ if (!isPlainObject(item)) { bad.push(`${label} must be a map`); return; }
113
+ if (Object.hasOwn(item, 'path')) { evidenceMapProblems(item, 'proves', label, bad); return; }
114
+ unknownKeys(item, WITNESS_KEYS, label, bad);
115
+ if (!['full', 'lite'].includes(item.edition)) bad.push(`${label}.edition must be full or lite`);
116
+ relativePathProblem(item.base, `${label}.base`, bad);
117
+ codeListProblems(item.expect, `${label}.expect`, bad);
118
+ if (nonEmptyList(item.mutate, `${label}.mutate`, bad)) item.mutate.forEach((mutation, index) => mutationProblems(mutation, `${label}.mutate[${index}]`, bad));
119
+ }
120
+
121
+ function witnessProblems(rule, label, bad) {
122
+ const value = rule.witness;
123
+ if (value === undefined) return;
124
+ const at = `${label}.witness`;
125
+ if (!nonEmptyList(value, at, bad)) return;
126
+ value.forEach((item, index) => witnessEntryProblems(item, `${at}[${index}]`, bad));
127
+ }
128
+
129
+ function caseReferenceProblems(value, label, bad) {
130
+ const values = Array.isArray(value) ? value : [value];
131
+ if (!nonEmptyList(values, label, bad)) return;
132
+ 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>`);
133
+ }
134
+
135
+ function caseTransitionProblems(item, label, bad) {
136
+ if (item.status !== undefined && !['live', 'superseded', 'amended'].includes(item.status)) bad.push(`${label}.status must be live, superseded or amended`);
137
+ for (const key of ['supersedes', 'amendedBy']) if (item[key] !== undefined) caseReferenceProblems(item[key], `${label}.${key}`, bad);
138
+ if (item.staleWhen !== undefined && nonEmptyList(item.staleWhen, `${label}.staleWhen`, bad)) {
139
+ for (const pattern of item.staleWhen) {
140
+ if (typeof pattern !== 'string' || !pattern.trim()) { bad.push(`${label}.staleWhen must contain non-empty regex strings`); continue; }
141
+ try { new RegExp(pattern, 'i'); } catch { bad.push(`${label}.staleWhen has an invalid regex ${JSON.stringify(pattern)}`); }
142
+ }
143
+ }
144
+ }
145
+
146
+ function vocabularyProblems(catalog, key, names, bad) {
147
+ if (!isPlainObject(catalog[key])) { bad.push(`${key} must be a map`); return; }
148
+ if (JSON.stringify(Object.keys(catalog[key])) !== JSON.stringify(names)) bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
149
+ for (const [name, text] of Object.entries(catalog[key])) {
150
+ if (typeof text !== 'string' || !text.trim()) bad.push(`${key}.${name} needs a description`);
151
+ }
152
+ }
153
+
154
+ function checkCatalogHeader(catalog, bad) {
155
+ for (const key of Object.keys(catalog)) {
156
+ if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules'].includes(key)) bad.push(`unknown top-level key ${key}`);
157
+ }
158
+ const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(catalog.schema));
159
+ if (!schemaOk) bad.push('schema must be starci/hfs-rules@<major>');
160
+ if (!SEMVER.test(String(catalog.version))) bad.push('version must be MAJOR.MINOR.PATCH');
161
+ else if (schemaOk && catalog.schema.split('@')[1] !== catalog.version.split('.')[0]) bad.push('the major of version must equal the number after @ in schema');
162
+ vocabularyProblems(catalog, 'gates', RULE_GATES, bad);
163
+ vocabularyProblems(catalog, 'enforcerKinds', ENFORCER_FAMILIES, bad);
164
+ }
165
+
166
+ function enumListProblems(rule, label, key, allowed, bad) {
167
+ if (!Array.isArray(rule[key]) || !rule[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
168
+ for (const value of rule[key]) {
169
+ if (!allowed.includes(value)) bad.push(`${label}.${key} has ${JSON.stringify(value)}, not one of ${allowed.join(', ')}`);
170
+ }
171
+ if (new Set(rule[key]).size !== rule[key].length) bad.push(`${label}.${key} repeats a value`);
172
+ return rule[key];
173
+ }
174
+
175
+ function checkRuleIdentity(rule, index, catalog, at, label, bad) {
176
+ if (!/^R\d{2,3}$/.test(String(rule.id))) bad.push(`${at}.id must be R<two or three digits>`);
177
+ else if (index > 0 && typeof catalog.rules[index - 1]?.id === 'string' && Number(rule.id.slice(1)) <= Number(catalog.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${catalog.rules[index - 1].id} comes before it`);
178
+ if (!FINDING_CODE.test(String(rule.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
179
+ }
180
+
181
+ function checkRuleText(rule, label, bad) {
182
+ if (typeof rule.law !== 'string' || !rule.law.trim()) bad.push(`${label}.law is missing`);
183
+ if (typeof rule.law === 'string' && rule.law.includes('\n')) bad.push(`${label}.law must be one line`);
184
+ if (rule.scope !== undefined && rule.scope !== 'runtime') bad.push(`${label}.scope is absent or runtime`);
185
+ }
186
+
187
+ /** The dated owner cases of a rule: each {id case-N, date, source owner, ruling, quote?}, ids unique (an owner ruling recorded where the law lives). */
188
+ function checkRuleCases(rule, label, bad) {
189
+ if (rule.cases === undefined) return;
190
+ if (!Array.isArray(rule.cases) || !rule.cases.length) { bad.push(`${label}.cases must be a non-empty list`); return; }
191
+ const ids = new Set();
192
+ rule.cases.forEach((item, index) => {
193
+ const at = `${label}.cases[${index}]`;
194
+ if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
195
+ for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
196
+ if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
197
+ ids.add(item.id);
198
+ if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
199
+ if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
200
+ for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])]) if (typeof item[key] !== 'string' || !item[key].trim()) bad.push(`${at}.${key} must be text`);
201
+ caseTransitionProblems(item, at, bad);
202
+ });
203
+ }
204
+
205
+ function checkRuleGates(rule, label, gates, bad) {
206
+ if (!gates.length) return;
207
+ const releaseGate = gates.includes('land') || gates.includes('ci');
208
+ const runtimeGate = rule.scope === 'runtime' && gates.includes('runtime');
209
+ if (!releaseGate && !runtimeGate) bad.push(`${label} must run at land or ci or, for runtime scope, the runtime gate`);
210
+ }
211
+
212
+ function checkRuleFailureCodes(rule, label, codeOwner, bad) {
213
+ if (!Array.isArray(rule.failureCodes) || !rule.failureCodes.length || !rule.failureCodes.every((code) => FINDING_CODE.test(String(code)))) {
214
+ bad.push(`${label}.failureCodes must be a non-empty list of UPPER_SNAKE codes`);
215
+ return;
216
+ }
217
+ if (rule.failureCodes[0] !== rule.code) bad.push(`${label}.failureCodes must start with the rule's own code ${rule.code}`);
218
+ if (new Set(rule.failureCodes).size !== rule.failureCodes.length) bad.push(`${label}.failureCodes repeats a code`);
219
+ for (const code of rule.failureCodes) {
220
+ if (codeOwner.has(code) && codeOwner.get(code) !== label) bad.push(`${label} names ${code}, which ${codeOwner.get(code)} already owns`);
221
+ codeOwner.set(code, label);
222
+ }
223
+ }
224
+
225
+ function checkCodeEditions(rule, label, bad) {
226
+ const value = rule.codeEditions;
227
+ if (value === undefined) return;
228
+ if (!isPlainObject(value) || !Object.keys(value).length) { bad.push(`${label}.codeEditions must be a non-empty map`); return; }
229
+ const editions = Array.isArray(rule.editions) ? rule.editions : ['full', 'lite'];
230
+ for (const [code, selected] of Object.entries(value)) {
231
+ const at = `${label}.codeEditions.${code}`;
232
+ if (!Array.isArray(rule.failureCodes) || !rule.failureCodes.includes(code)) bad.push(`${at} must name a code owned by this rule`);
233
+ enumListProblems({ editions: selected }, at, 'editions', ['full', 'lite'], bad);
234
+ if (Array.isArray(selected) && selected.some((edition) => !editions.includes(edition))) bad.push(`${at} must be a subset of the rule's editions`);
235
+ }
236
+ }
237
+
238
+ function checkEnforcerIdentity(enforcer, label, seen, bad) {
239
+ if (!ENFORCER_FAMILIES.includes(enforcer.kind)) bad.push(`${label}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
240
+ if (!ENFORCER_ID.test(String(enforcer.id))) bad.push(`${label}.id must be kebab-case`);
241
+ if (seen.has(`${enforcer.kind}:${enforcer.id}`)) bad.push(`${label} repeats ${enforcer.kind}:${enforcer.id}`);
242
+ seen.add(`${enforcer.kind}:${enforcer.id}`);
243
+ }
244
+
245
+ function checkEnforcerLocation(enforcer, label, bad) {
246
+ if (enforcer.at !== undefined) {
247
+ if (typeof enforcer.at !== 'string' || !enforcer.at.trim() || enforcer.at.startsWith('/') || enforcer.at.includes('..')) bad.push(`${label}.at must be a repository-relative path`);
248
+ if (enforcer.status === 'planned') bad.push(`${label} is planned, so it has no file yet (at)`);
249
+ if (!FILE_ENFORCERS.has(enforcer.kind)) bad.push(`${label}.at belongs to a machine, hfs, work-validate, sonar or runtime enforcer only`);
250
+ } else if (FILE_ENFORCERS.has(enforcer.kind) && enforcer.status !== 'planned') bad.push(`${label} exists, so it names the file (at) that emits its code`);
251
+ }
252
+
253
+ function checkEnforcer(enforcer, index, ruleLabel, seen, bad) {
254
+ const label = `${ruleLabel}.enforcers[${index}]`;
255
+ if (!isPlainObject(enforcer)) { bad.push(`${label} is not a map`); return; }
256
+ for (const key of Object.keys(enforcer)) {
257
+ if (!ENFORCER_KEYS.has(key)) bad.push(`${label} has unknown key ${key}`);
258
+ }
259
+ bad.push(...ruleEditionProblems(enforcer, label));
260
+ optionalText(enforcer, 'editionWhy', label, bad);
261
+ checkEnforcerIdentity(enforcer, label, seen, bad);
262
+ if (enforcer.status !== undefined && enforcer.status !== 'planned') bad.push(`${label}.status is either absent or planned`);
263
+ checkEnforcerLocation(enforcer, label, bad);
264
+ }
265
+
266
+ function checkRuleEnforcers(rule, label, bad) {
267
+ if (!Array.isArray(rule.enforcers) || !rule.enforcers.length) { bad.push(`${label}.enforcers must name at least one enforcer`); return; }
268
+ const seen = new Set();
269
+ rule.enforcers.forEach((enforcer, index) => checkEnforcer(enforcer, index, label, seen, bad));
270
+ }
271
+
272
+ function checkRule(rule, index, catalog, codeOwner, bad) {
273
+ const at = `rules[${index}]`;
274
+ if (!isPlainObject(rule)) { bad.push(`${at} is not a map`); return; }
275
+ const label = typeof rule.id === 'string' ? rule.id : at;
276
+ for (const key of Object.keys(rule)) {
277
+ if (!RULE_KEYS.has(key)) bad.push(`${label} has unknown key ${key}`);
278
+ }
279
+ checkRuleIdentity(rule, index, catalog, at, label, bad);
280
+ checkRuleText(rule, label, bad);
281
+ checkRuleCases(rule, label, bad);
282
+ if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
283
+ optionalText(rule, 'editionWhy', label, bad);
284
+ optionalText(rule, 'templateNa', label, bad);
285
+ for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
286
+ witnessProblems(rule, label, bad);
287
+ enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
288
+ bad.push(...ruleEditionProblems(rule, label));
289
+ const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
290
+ checkRuleGates(rule, label, gates, bad);
291
+ checkRuleFailureCodes(rule, label, codeOwner, bad);
292
+ checkCodeEditions(rule, label, bad);
293
+ checkRuleEnforcers(rule, label, bad);
294
+ if (Array.isArray(rule.gates)) {
295
+ const hasSonar = rule.enforcers.some((enforcer) => isPlainObject(enforcer) && enforcer.kind === 'sonar');
296
+ if (rule.gates.includes('sonar') !== hasSonar) bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
297
+ }
298
+ }
299
+
300
+ /** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
301
+ function ruleCatalogProblems(catalog) {
302
+ const bad = [];
303
+ if (!isPlainObject(catalog)) return ['the rule catalog is not a map'];
304
+ checkCatalogHeader(catalog, bad);
305
+ if (!Array.isArray(catalog.rules) || !catalog.rules.length) { bad.push('rules must be a non-empty list'); return bad; }
306
+ const codeOwner = new Map();
307
+ catalog.rules.forEach((rule, index) => checkRule(rule, index, catalog, codeOwner, bad));
308
+ return bad;
309
+ }
310
+
311
+ const deepFreeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); } return Object.freeze(value); };
312
+
313
+ /**
314
+ * The parsed and validated HFS rule catalog. `text` (or `file`, or `root`) selects the source; the default is the runtime's
315
+ * own knowledge/hfs/rules.yaml. A catalog that breaks its shape or a semantic rule is refused whole (HFS_RULES_INVALID), and
316
+ * so is one whose major differs from the slot manifest passed as `manifest` (HFS_MANIFEST_MAJOR_MISMATCH).
317
+ * Answers: rule(id), byCode(code), forGate(gate), forEnforcer(kind, id), planned() and unbuilt().
318
+ */
319
+ export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_RULES_FILE), text, manifest } = {}) {
320
+ let doc;
321
+ try { doc = parseYamlCached(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
322
+ const problems = ruleCatalogProblems(doc);
323
+ if (problems.length) { fail('HFS_RULES_INVALID', `the rule catalog breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? '; and ' + (problems.length - 5) + ' more' : ''}`, { file, problems }); }
324
+ const [major, minor, patch] = doc.version.split('.').map(Number);
325
+ if (manifest && manifest.major !== major) fail('HFS_MANIFEST_MAJOR_MISMATCH', `the rule catalog is major ${major} but the slot manifest is major ${manifest.major}`, { catalog: major, manifest: manifest.major });
326
+ const list = deepFreeze(doc.rules.map((rule) => ({ ...rule, enforcers: rule.enforcers.map((enforcer) => ({ ...enforcer, planned: enforcer.status === 'planned' })) })));
327
+ const byId = new Map(list.map((rule) => [rule.id, rule]));
328
+ const byCode = new Map(list.flatMap((rule) => rule.failureCodes.map((code) => [code, rule])));
329
+ return Object.freeze({
330
+ version: doc.version, major, minor, patch,
331
+ gates: deepFreeze(jsonClone(doc.gates)),
332
+ enforcerKinds: deepFreeze(jsonClone(doc.enforcerKinds)),
333
+ rules: list,
334
+ /** The rule with this id (R01..), or null. */
335
+ rule: (id) => byId.get(id) ?? null,
336
+ /** The rule that owns this failure code (its own or a sub-check code), or null. */
337
+ byCode: (code) => byCode.get(code) ?? null,
338
+ /** Whether a finding code is judged under `edition`: codeEditions narrows its owner's editions; uncatalogued codes remain judged. */
339
+ judgedIn: (code, edition = 'full') => {
340
+ const owner = byCode.get(code);
341
+ return judgedInEdition(owner, edition) && (!owner?.codeEditions?.[code] || owner.codeEditions[code].includes(edition));
342
+ },
343
+ enforcerJudgedIn: (kind, id, edition = 'full') => enforcerJudgedInEdition(list, kind, id, edition),
344
+ /** The rules that run at a gate. */
345
+ forGate: (gate) => list.filter((rule) => rule.gates.includes(gate)),
346
+ /** The catalogued why code of a lint finding's rule id (`starci-be/<id>`, `starci-fe/<id>`), or undefined. */
347
+ lintCode: (ruleId) => {
348
+ const [plugin, id] = String(ruleId ?? '').split('/');
349
+ const kind = (plugin === 'starci-be' && 'eslint-be') || (plugin === 'starci-fe' && 'eslint-fe') || (plugin === 'starci-grammar' && 'eslint-grammar-core') || null;
350
+ return kind ? list.find((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id))?.code : undefined;
351
+ },
352
+ /** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
353
+ forEnforcer: (kind, id) => list.filter((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id)),
354
+ /** Every enforcer still owed, as {rule, kind, id}. */
355
+ planned: () => list.flatMap((rule) => rule.enforcers.filter((enforcer) => enforcer.planned).map((enforcer) => ({ rule: rule.id, kind: enforcer.kind, id: enforcer.id }))),
356
+ /** The rules with no existing enforcer at all. */
357
+ unbuilt: () => list.filter((rule) => rule.enforcers.every((enforcer) => enforcer.planned)),
358
+ });
359
+ }
360
+
361
+ /** The rules of this runtime's catalog, frozen, in id order. */
362
+ export const rules = (options) => loadRuleCatalog(options).rules;
@@ -0,0 +1,80 @@
1
+ // rule-params-shape.mjs - the shape of `ruleParams` of an app slot manifest (knowledge/hfs/slots.yaml): the parameters both sides
2
+ // share (`common`), the back end's own (`be`) and the front end's overrides (`fe`). Pure: a parsed manifest in, a list of
3
+ // problems out, in the words of modules/schemas/hfs-slots.schema.yaml. scripts/hfs/slots.mjs (loadSlotManifest) is the reader.
4
+ import { isPlainObject } from '../../engine/plain-object.mjs';
5
+ import { paramNamesOk } from './param-names.mjs';
6
+ import { kindParamProblems, scenarioProblem } from './declaration-slots.mjs';
7
+ import { NAME, namedMapValid, PACKAGE_NAME, roleListProblems, SCHEMA_AUTHORITIES, unitRolesProblems } from './manifest-shape.mjs';
8
+ import { byCodeUnit, nonemptyUniqueList } from '../lib/list.mjs';
9
+ import { ruleEditionProblems } from './edition-slots.mjs';
10
+
11
+ const PROFILES = ['be', 'fe'];
12
+ const BE_OWN = ['infraOwners', 'eventBus', 'specDoubles', 'paramNames', 'suffixes', 'bannedSuffixes', 'contractShape', 'unitRoles', 'logicRoles', 'valueRoles', 'thinRoles', 'patternScenarios', 'kindPatterns', 'addKinds', 'backing'];
13
+ const backingEntryOk = (entry) => isPlainObject(entry) && Object.keys(entry).every((key) => ['clients', 'images', 'patterns', 'editions'].includes(key))
14
+ && nonemptyUniqueList(entry.clients, (name) => PACKAGE_NAME.test(String(name)))
15
+ && Array.isArray(entry.patterns) && entry.patterns.every((name) => NAME.test(String(name))) && new Set(entry.patterns).size === entry.patterns.length
16
+ && nonemptyUniqueList(entry.images, (name) => PACKAGE_NAME.test(String(name)))
17
+ && ruleEditionProblems(entry, 'backing service').length === 0;
18
+ const FORM_NAMES = new Set(['call', 'new', 'curried', 'object', 'primitive', 'array']);
19
+ const blockOk = (v) => isPlainObject(v) && Number.isInteger(v.lines) && v.lines >= 2 && Number.isInteger(v.tokens) && v.tokens >= 1 && Object.keys(v).length === 2;
20
+ const fileLinesOk = (v) => isPlainObject(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
21
+ const SHARED_PARAMS = {
22
+ fileLines: fileLinesOk,
23
+ duplicateBlock: blockOk,
24
+ functionLines: v => Number.isInteger(v) && v >= 1,
25
+ featureFiles: v => Number.isInteger(v) && v >= 1,
26
+ };
27
+ const SHARED_KEYS = Object.keys(SHARED_PARAMS);
28
+ const sharedParam = (key, v) => Object.hasOwn(SHARED_PARAMS, key) && SHARED_PARAMS[key](v);
29
+ 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));
30
+ const liteKeyOk = (lite, key, keys) => key !== 'lite' && keys.includes(key)
31
+ && (!['functionLines', 'featureFiles'].includes(key) || sharedParam(key, lite[key]));
32
+ const liteOk = (lite, keys) => lite === undefined || (isPlainObject(lite) && Object.keys(lite).length > 0 && Object.keys(lite).every((k) => liteKeyOk(lite, k, keys)));
33
+ 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);
34
+ const roleList = (v) => nonemptyUniqueList(v, (role) => NAME.test(String(role)));
35
+ const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => FORM_NAMES.has(x)) && new Set(v).size === v.length;
36
+ const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
37
+ const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
38
+ const dbExtensionsOk = (value) => Array.isArray(value) && value.every((name) => typeof name === 'string' && name.length > 0) && new Set(value).size === value.length;
39
+ const SQL_NAME = /^[a-z_][a-z0-9_]*$/;
40
+ const queueOutboxOk = (value) => isPlainObject(value) && Object.keys(value).sort(byCodeUnit).join() === 'columns,table'
41
+ && typeof value.table === 'string' && value.table.split('.').length === 2 && value.table.split('.').every((part) => SQL_NAME.test(part))
42
+ && nonemptyUniqueList(value.columns, (column) => SQL_NAME.test(String(column))) && value.columns.includes('queue');
43
+ const DB_PARAMS = { extensions: dbExtensionsOk, queueOutbox: queueOutboxOk };
44
+ const dbParamsOk = (db) => db === undefined || (isPlainObject(db) && Object.entries(db).every(([key, value]) => Object.hasOwn(DB_PARAMS, key) && DB_PARAMS[key](value)));
45
+
46
+ function beSpecificProblems(be, manifest) {
47
+ const bad = [];
48
+ bad.push(...scenarioProblem(be.patternScenarios), ...kindParamProblems(be, manifest.triggerKinds));
49
+ if (!isPlainObject(be.contractShape) || Object.keys(be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
50
+ const owners = be.infraOwners;
51
+ const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
52
+ if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
53
+ const eventBus = be.eventBus;
54
+ if (!isPlainObject(eventBus) || Object.keys(eventBus).sort(byCodeUnit).join() !== 'classes,imports' || !memberMap(eventBus.imports, true) || !memberMap(eventBus.classes, false)) bad.push('ruleParams.be.eventBus must be {imports: {<module>: [unique PascalCase members]}, classes: {<module>: [non-empty unique PascalCase classes]}}');
55
+ if (!paramNamesOk(be.paramNames)) bad.push('ruleParams.be.paramNames must be a non-empty list of unique {type | typeSuffix, names, nameSuffix?} entries');
56
+ if (!namedMapValid(be.backing, backingEntryOk)) bad.push('ruleParams.be.backing must map a backing service name to {clients: [unique package names], patterns: [unique pattern names], images: [unique image repositories], editions?: [unique known editions]}');
57
+ if (!roleList(be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
58
+ if (!roleList(be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
59
+ else if (roleList(be.suffixes) && be.suffixes.some((x) => be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
60
+ const sd = be.specDoubles;
61
+ if (!isPlainObject(sd) || Object.keys(sd).sort(byCodeUnit).join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && (e.from === undefined || regexOk(e.from)) && Object.keys(e).length === (e.from === undefined ? 3 : 4)) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
62
+ return bad;
63
+ }
64
+
65
+ /** The shape problems of `m.ruleParams`. */
66
+ export function ruleParamsProblems(m) {
67
+ const bad = [];
68
+ const rp = m.ruleParams;
69
+ 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'];
70
+ else {
71
+ // common holds the parameters both sides share; a side may restate one of them (a valid value) as an override, nothing else.
72
+ // Edition lite: a side's optional `lite` map overrides keys of the same side (never `lite` itself) when the app declares edition lite.
73
+ 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');
74
+ 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, addKinds and backing (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));
75
+ bad.push(...beSpecificProblems(rp.be, m));
76
+ 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');
77
+ if (!dbParamsOk(rp.db)) bad.push('ruleParams.db may contain only extensions (a list of unique non-empty extension names) and queueOutbox ({table: <schema>.<table>, columns: [unique column names including queue]})');
78
+ }
79
+ return bad;
80
+ }
@@ -0,0 +1,102 @@
1
+ // slot-app-view.mjs - the resolver of a whole app: a root path is answered by the scope of the app root, a path below a side folder
2
+ // (be/, fe/) by that side's scope with the side prefixed back onto every path and root it returns. Only the declared `reads` cross sides.
3
+ // slots.mjs builds the scopes (`root` and `sides`, each a resolver of one scope) and hands them to appResolver.
4
+ import { APP_SCOPE, PROFILES } from './slot-manifest-shape.mjs';
5
+ import { cleanSlotPath } from './slot-path.mjs';
6
+
7
+ /** { side, rest } when `p` lies below a side folder, else null. */
8
+ function splitSide(input) {
9
+ const p = cleanSlotPath(input);
10
+ const slash = p.indexOf('/');
11
+ const head = slash < 0 ? p : p.slice(0, slash);
12
+ return PROFILES.includes(head) && slash > 0 ? { side: head, rest: p.slice(slash + 1) } : null;
13
+ }
14
+
15
+ const under = (side, rel) => (rel ? `${side}/${rel}` : side);
16
+
17
+ function prefixed(side, c) {
18
+ return {
19
+ ...c,
20
+ path: under(side, c.path),
21
+ side,
22
+ ...(c.root !== undefined ? { root: under(side, c.root) } : {}),
23
+ ...(c.nearest ? { nearest: { ...c.nearest, matchedPrefix: under(side, c.nearest.matchedPrefix), matchedDepth: c.nearest.matchedDepth + 1 } } : {}),
24
+ };
25
+ }
26
+
27
+ function classifyApp({ root, sides }, input) {
28
+ const at = splitSide(input);
29
+ return at ? prefixed(at.side, sides[at.side].classifyPath(at.rest)) : root.classifyPath(input);
30
+ }
31
+
32
+ function ownerApp({ root, sides }, input) {
33
+ const at = splitSide(input);
34
+ if (!at) return root.ownerOf(input);
35
+ const owner = sides[at.side].ownerOf(at.rest);
36
+ return owner ? { ...owner, root: under(at.side, owner.root), side: at.side } : null;
37
+ }
38
+
39
+ /** Whether `toPath`, of the other side, lies below a path `fromSide` declares it reads. */
40
+ const readsPath = (repo, fromSide, toPath) => repo.sides[fromSide].reads.some((read) => `${cleanSlotPath(toPath)}/`.startsWith(read));
41
+
42
+ /** The verdict of an import from a side file to a path of the other side (or of the app root, `toSide` null): allowed only through a declared read. */
43
+ function sideCrossing(repo, fromSide, toSide, toPath) {
44
+ return readsPath(repo, fromSide, toPath)
45
+ ? { allowed: true, reason: 'sideRead', fromSide, toSide }
46
+ : { allowed: false, reason: 'crossSide', fromSide, toSide, reads: repo.sides[fromSide].reads };
47
+ }
48
+
49
+ function importAllowedApp({ repo, root, sides }, fromPath, toPath) {
50
+ const from = splitSide(fromPath);
51
+ const to = splitSide(toPath);
52
+ // A side file may read an app-root path (supabase/types/) only through a declared read, exactly as it reads the other side.
53
+ if (from && from.side !== to?.side) return sideCrossing(repo, from.side, to?.side ?? null, toPath);
54
+ if (from && to) return sides[from.side].importAllowed(from.rest, to.rest);
55
+ return root.importAllowed(fromPath, toPath);
56
+ }
57
+
58
+ function requiredPathsApp({ root, sides }) {
59
+ const own = root.requiredPaths();
60
+ const paths = [...own.paths];
61
+ const minimums = [...own.minimums];
62
+ for (const side of PROFILES) {
63
+ const required = sides[side].requiredPaths();
64
+ paths.push(...required.paths.map((entry) => ({ ...entry, path: under(side, entry.path), side })));
65
+ minimums.push(...required.minimums.map((entry) => ({ ...entry, side })));
66
+ }
67
+ return { paths, minimums };
68
+ }
69
+
70
+ function slotEnabledApp({ root, sides }, slot) {
71
+ if (slot.profiles.includes(APP_SCOPE)) return root.slotEnabled(slot);
72
+ return PROFILES.some((side) => slot.profiles.includes(side) && sides[side].slotEnabled(slot));
73
+ }
74
+
75
+ /** The resolver of the app `repo`: `root` answers its root paths and `sides[side]` the paths below that side's folder. */
76
+ export function appResolver(repo, root, sides) {
77
+ const view = { repo, root, sides };
78
+ const allSlots = [...new Map([...root.slots(), ...PROFILES.flatMap((side) => sides[side].slots())].map((s) => [s.id, s])).values()];
79
+ const byId = new Map(allSlots.map((s) => [s.id, s]));
80
+ const classifyPath = (input) => classifyApp(view, input);
81
+ return Object.freeze({
82
+ repo,
83
+ sides: Object.freeze(sides),
84
+ /** The side of a path (be, fe) or null for a root path. */
85
+ sideOf: (input) => splitSide(input)?.side ?? null,
86
+ slot: (id) => byId.get(id) ?? null,
87
+ slots: () => allSlots,
88
+ slotEnabled: (slot) => slotEnabledApp(view, slot),
89
+ classifyPath,
90
+ slotOf: (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; },
91
+ ownerOf: (input) => ownerApp(view, input),
92
+ tierOf: (input) => { const at = splitSide(input); return at ? sides[at.side].tierOf(at.rest) : root.tierOf(input); },
93
+ importAllowed: (fromPath, toPath) => importAllowedApp(view, fromPath, toPath),
94
+ requiredFiles: (input) => { const at = splitSide(input); return at ? sides[at.side].requiredFiles(at.rest).map((p) => under(at.side, p)) : root.requiredFiles(input); },
95
+ requiredPaths: () => requiredPathsApp(view),
96
+ trackingOf: (p) => classifyPath(p).tracking ?? null,
97
+ isTracked: (p) => classifyPath(p).tracking === 'tracked',
98
+ // The root has no tier and no rule parameters of its own; each side has them (sides.<side>.ruleParams()).
99
+ ruleParams: () => null,
100
+ allowedImports: () => null,
101
+ });
102
+ }
@@ -0,0 +1,65 @@
1
+ // slot-classify.mjs - what slot of one scope (the app root or one side) owns a path: its status, owner unit, tier, folder kind and role.
2
+ // A scope is {manifest, repo, profile, edition, slots, byId, variants, appKind} built once by slots.mjs; every function here reads it.
3
+ import { globExpression } from '../lib/glob.mjs';
4
+ import { declaredSlotEnabled } from './declaration-slots.mjs';
5
+ import { bestMatch, fillVars, matchVariants, nearestSlot } from './slot-match.mjs';
6
+ import { cleanSlotPath } from './slot-path.mjs';
7
+
8
+ /** Whether the repository of the scope holds the slot. */
9
+ export function slotEnabled(scope, slot) {
10
+ // A provider slot is gated by its provider in every edition: litePresence may lift it to required, but it still
11
+ // exists for this repository only while a connection declares the provider.
12
+ if (slot.provider !== undefined) return declaredSlotEnabled(slot, scope.repo);
13
+ if (slot.presence !== 'opt-in') return true;
14
+ return declaredSlotEnabled(slot, scope.repo);
15
+ }
16
+
17
+ /** The folder kind of a file in its slot, from a named layer or kind folder. */
18
+ function kindOf({ variant, slot, root }, p) {
19
+ const names = [...(slot.layers ?? []), ...(slot.kinds ?? [])];
20
+ if (!names.length) return null;
21
+ const literal = variant.segments.find((segment) => names.includes(segment));
22
+ if (literal) return literal;
23
+ const below = root ? p.slice(root.length + 1) : p;
24
+ return below.split('/').slice(0, -1).find((segment) => names.includes(segment)) ?? null;
25
+ }
26
+
27
+ /** The role of a file in its slot: the entry of `roles` whose file name (variables filled from the path, `*` a wildcard inside the name) matches the file's name. */
28
+ function roleOf(slot, bindings, p) {
29
+ const name = p.split('/').pop();
30
+ for (const [role, file] of Object.entries(slot.roles ?? {})) if (globExpression(fillVars(file, bindings)).test(name)) return role;
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * status: owned | forbidden (external slot) | not-enabled (opt-in slot the repository did not declare) | ambiguous
36
+ * (two slots of equal specificity; a manifest gap) | no-slot (code HFS_SLOT_UNDECLARED, with the nearest slot).
37
+ */
38
+ export function classifyIn(scope, input) {
39
+ const p = cleanSlotPath(input);
40
+ const { hit, ambiguous } = bestMatch(matchVariants(scope, p));
41
+ if (ambiguous.length) return { path: p, status: 'ambiguous', candidates: ambiguous };
42
+ if (!hit) return { path: p, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: nearestSlot(scope.variants, p) };
43
+ const { slot, root, bindings } = hit;
44
+ const status = (slot.presence === 'forbidden' && 'forbidden') || (slotEnabled(scope, slot) ? 'owned' : 'not-enabled');
45
+ const kind = kindOf(hit, p);
46
+ const role = roleOf(slot, bindings, p);
47
+ return { path: p, status, slot: slot.id, root, bindings, ...(kind ? { kind } : {}), ...(role ? { role } : {}), presence: slot.presence, tracking: slot.tracked, ...(status === 'forbidden' ? { goesTo: slot.goesTo } : {}) };
48
+ }
49
+
50
+ /** The owner unit of `p`: the most specific owner slot instance containing it (a slot's own root when it owns nothing). */
51
+ export function ownerIn(scope, input) {
52
+ const p = cleanSlotPath(input);
53
+ const { hit } = bestMatch(matchVariants(scope, p, (s) => s.owner === true));
54
+ return hit ? { slot: hit.slot.id, root: hit.root, bindings: hit.bindings } : null;
55
+ }
56
+
57
+ /** The tier of `p` in the direction matrix: its slot's tier, the owner's for an inheriting slot, none for an untiered one; null when no slot owns it. */
58
+ export function tierIn(scope, input) {
59
+ const c = classifyIn(scope, input);
60
+ if (!c.slot) return null;
61
+ const tier = scope.byId.get(c.slot).tier;
62
+ if (tier !== 'inherit') return tier;
63
+ const owner = ownerIn(scope, input);
64
+ return owner ? scope.byId.get(owner.slot).tier : 'none';
65
+ }
@@ -0,0 +1,11 @@
1
+ // slot-errors.mjs - the shared refusal type for HFS manifest and declaration loaders.
2
+ export class HfsSlotsError extends Error {
3
+ constructor(code, message, details = {}) {
4
+ super(`${code}: ${message}`);
5
+ this.name = 'HfsSlotsError';
6
+ this.code = code;
7
+ this.details = details;
8
+ }
9
+ }
10
+
11
+ export const fail = (code, message, details) => { throw new HfsSlotsError(code, message, details); };