@starci/eslint-grammar-core 0.15.34 → 0.16.2

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 (63) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/dist/index.js +4 -0
  4. package/dist/lib/why.js +15 -0
  5. package/dist/mascot.d.ts +28 -0
  6. package/dist/mascot.js +166 -0
  7. package/dist/surface-depth.d.ts +7 -0
  8. package/dist/surface-depth.js +92 -0
  9. package/docs/mascot.md +60 -0
  10. package/docs/surface-depth.md +40 -0
  11. package/package.json +2 -2
  12. package/runtime/bundle.json +74 -0
  13. package/runtime/engine/by-code-unit.mjs +6 -6
  14. package/runtime/engine/model-config.mjs +98 -76
  15. package/runtime/engine/orca-config.mjs +22 -17
  16. package/runtime/engine/release-config.mjs +22 -18
  17. package/runtime/engine/removed-vocabulary.mjs +21 -20
  18. package/runtime/engine/resources-config.mjs +42 -35
  19. package/runtime/engine/runtime-root.mjs +11 -13
  20. package/runtime/engine/secrets.mjs +170 -102
  21. package/runtime/engine/sonar-config.mjs +16 -13
  22. package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
  23. package/runtime/knowledge/hfs/rules.yaml +95 -6
  24. package/runtime/knowledge/hfs/slots.yaml +2 -2
  25. package/runtime/knowledge/patterns/fe/ui.yaml +13 -1
  26. package/runtime/scripts/hfs/allows.mjs +37 -38
  27. package/runtime/scripts/hfs/architecture/config.mjs +302 -268
  28. package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
  29. package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
  30. package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
  31. package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
  32. package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
  33. package/runtime/scripts/hfs/edition-slots.mjs +43 -40
  34. package/runtime/scripts/hfs/param-names.mjs +6 -11
  35. package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
  36. package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
  37. package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
  38. package/runtime/scripts/hfs/slot-classify.mjs +42 -38
  39. package/runtime/scripts/hfs/slot-errors.mjs +8 -7
  40. package/runtime/scripts/hfs/slot-imports.mjs +70 -63
  41. package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
  42. package/runtime/scripts/hfs/slot-match.mjs +122 -111
  43. package/runtime/scripts/hfs/slot-path.mjs +0 -2
  44. package/runtime/scripts/hfs/slot-required.mjs +65 -59
  45. package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
  46. package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
  47. package/runtime/scripts/hfs/slots.mjs +175 -152
  48. package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
  49. package/runtime/scripts/hfs/view.mjs +38 -40
  50. package/runtime/scripts/lib/glob.mjs +23 -14
  51. package/runtime/scripts/lib/grammar-lint.mjs +33 -37
  52. package/runtime/scripts/lib/i18n.mjs +32 -35
  53. package/runtime/scripts/lib/json-clone.mjs +45 -36
  54. package/runtime/scripts/lib/json.mjs +26 -14
  55. package/runtime/scripts/lib/list.mjs +9 -14
  56. package/runtime/scripts/lib/path-key.mjs +53 -38
  57. package/runtime/scripts/lib/read-text.mjs +30 -17
  58. package/runtime/scripts/lib/regex.mjs +1 -2
  59. package/runtime/scripts/lib/source-phrases.mjs +13 -13
  60. package/runtime/scripts/lib/ts-analysis.mjs +28 -26
  61. package/runtime/scripts/lib/ts-ast.mjs +36 -42
  62. package/runtime/scripts/lib/walk.mjs +33 -27
  63. package/runtime/scripts/lib/yaml-cached.mjs +4 -4
@@ -1,4 +1,3 @@
1
- // rule-catalog.mjs - the parsed and validated HFS rule catalog.
2
1
  import fs from 'node:fs';
3
2
  import path from 'node:path';
4
3
  import { skillRoot } from '../../engine/runtime-root.mjs';
@@ -9,7 +8,6 @@ import { isPlainObject } from '../../engine/plain-object.mjs';
9
8
  import { SEMVER } from './manifest-shape.mjs';
10
9
  import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
11
10
  import { fail } from './slot-errors.mjs';
12
-
13
11
  const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
14
12
  const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar', 'runtime']);
15
13
  const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'eslint-grammar-core', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
@@ -28,288 +26,379 @@ const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersede
28
26
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
29
27
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
30
28
  const CASE_ID = /case-[1-9]\d*/;
31
- const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
29
+ const RULING_REFERENCE = new RegExp(String.raw `(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
32
30
  const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
33
31
  const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
34
32
  const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
35
33
  const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
36
34
  const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
37
35
  const MUTATION_KEYS = new Set(['op', 'path', 'content']);
38
-
39
36
  function textProblem(value, label, bad) {
40
- if (typeof value !== 'string' || !value.trim()) bad.push(`${label} must be non-empty text`);
37
+ if (typeof value !== 'string' || !value.trim())
38
+ bad.push(`${label} must be non-empty text`);
41
39
  }
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
40
  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' : ''}`);
41
+ const parts = typeof value === 'string' ? value.split('#') : [];
42
+ const file = parts[0];
43
+ const valid = typeof value === 'string' && value.trim() === value && value.length > 0
44
+ && !value.includes('\0') && !/[\r\n\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
45
+ && !file.split('/').some((part) => ['', '.', '..'].includes(part))
46
+ && (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
47
+ if (!valid)
48
+ bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
56
49
  }
57
-
58
50
  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}`);
51
+ for (const key of Object.keys(item))
52
+ if (!allowed.has(key))
53
+ bad.push(`${label} has unknown key ${key}`);
60
54
  }
61
-
62
55
  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;
56
+ if (!Array.isArray(value) || !value.length) {
57
+ bad.push(`${label} must be a non-empty list`);
58
+ return false;
59
+ }
60
+ 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));
61
+ if (new Set(normalized).size !== value.length)
62
+ bad.push(`${label} repeats a value`);
63
+ return true;
67
64
  }
68
-
69
65
  function optionalText(item, key, label, bad) {
70
- if (item[key] !== undefined) textProblem(item[key], `${label}.${key}`, bad);
66
+ if (item[key] !== undefined)
67
+ textProblem(item[key], `${label}.${key}`, bad);
71
68
  }
72
-
73
69
  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`);
70
+ if (item.edition !== undefined && !['full', 'lite'].includes(item.edition))
71
+ bad.push(`${label}.edition must be full or lite`);
72
+ bad.push(...ruleEditionProblems(item, label));
73
+ if (item.edition !== undefined && item.editions !== undefined)
74
+ bad.push(`${label} names edition or editions, not both`);
77
75
  }
78
-
79
76
  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`);
77
+ if (!nonEmptyList(codes, label, bad))
78
+ return;
79
+ for (const code of codes)
80
+ if (typeof code !== 'string' || !FINDING_CODE.test(code))
81
+ bad.push(`${label} must contain UPPER_SNAKE finding codes`);
82
82
  }
83
-
84
83
  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));
84
+ unknownKeys(item, key === 'docs' ? EVIDENCE_KEYS : PROOF_KEYS, label, bad);
85
+ relativePathProblem(item.path, `${label}.path`, bad, key === 'docs');
86
+ evidenceEditionProblems(item, label, bad);
87
+ if (item.codes !== undefined)
88
+ codeListProblems(item.codes, `${label}.codes`, bad);
89
+ if (item.fixtures !== undefined && nonEmptyList(item.fixtures, `${label}.fixtures`, bad))
90
+ item.fixtures.forEach((value) => textProblem(value, `${label}.fixtures`, bad));
90
91
  }
91
-
92
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
- });
93
+ if (rule[key] === undefined || !nonEmptyList(rule[key], `${label}.${key}`, bad))
94
+ return;
95
+ rule[key].forEach((item, index) => {
96
+ const at = `${label}.${key}[${index}]`;
97
+ if (typeof item === 'string') {
98
+ relativePathProblem(item, at, bad, key === 'docs');
99
+ return;
100
+ }
101
+ if (!isPlainObject(item)) {
102
+ bad.push(`${at} must be a path or a map`);
103
+ return;
104
+ }
105
+ evidenceMapProblems(item, key, at, bad);
106
+ });
100
107
  }
101
-
102
108
  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
+ if (!isPlainObject(item)) {
110
+ bad.push(`${label} must be a map`);
111
+ return;
112
+ }
113
+ unknownKeys(item, MUTATION_KEYS, label, bad);
114
+ if (!['add', 'edit', 'delete'].includes(item.op))
115
+ bad.push(`${label}.op must be add, edit or delete`);
116
+ relativePathProblem(item.path, `${label}.path`, bad);
117
+ if (['add', 'edit'].includes(item.op) && typeof item.content !== 'string')
118
+ bad.push(`${label}.content must be text for add or edit`);
119
+ if (item.op === 'delete' && item.content !== undefined)
120
+ bad.push(`${label}.content is absent for delete`);
109
121
  }
110
-
111
122
  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));
123
+ if (!isPlainObject(item)) {
124
+ bad.push(`${label} must be a map`);
125
+ return;
126
+ }
127
+ if (Object.hasOwn(item, 'path')) {
128
+ evidenceMapProblems(item, 'proves', label, bad);
129
+ return;
130
+ }
131
+ unknownKeys(item, WITNESS_KEYS, label, bad);
132
+ if (!['full', 'lite'].includes(item.edition))
133
+ bad.push(`${label}.edition must be full or lite`);
134
+ relativePathProblem(item.base, `${label}.base`, bad);
135
+ codeListProblems(item.expect, `${label}.expect`, bad);
136
+ if (nonEmptyList(item.mutate, `${label}.mutate`, bad))
137
+ item.mutate.forEach((mutation, index) => mutationProblems(mutation, `${label}.mutate[${index}]`, bad));
119
138
  }
120
-
121
139
  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));
140
+ const value = rule.witness;
141
+ if (value === undefined)
142
+ return;
143
+ const at = `${label}.witness`;
144
+ if (!nonEmptyList(value, at, bad))
145
+ return;
146
+ value.forEach((item, index) => witnessEntryProblems(item, `${at}[${index}]`, bad));
127
147
  }
128
-
129
148
  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>`);
149
+ const values = Array.isArray(value) ? value : [value];
150
+ if (!nonEmptyList(values, label, bad))
151
+ return;
152
+ for (const ref of values)
153
+ if (typeof ref !== 'string' || !CASE_REFERENCE.test(ref))
154
+ bad.push(`${label} must name case-N, R<id>[/case-N], ruling:R<id>[/case-N] or owner:<id>`);
133
155
  }
134
-
135
156
  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)}`); }
157
+ if (item.status !== undefined && !['live', 'superseded', 'amended'].includes(item.status))
158
+ bad.push(`${label}.status must be live, superseded or amended`);
159
+ for (const key of ['supersedes', 'amendedBy'])
160
+ if (item[key] !== undefined)
161
+ caseReferenceProblems(item[key], `${label}.${key}`, bad);
162
+ if (item.staleWhen !== undefined && nonEmptyList(item.staleWhen, `${label}.staleWhen`, bad)) {
163
+ for (const pattern of item.staleWhen) {
164
+ if (typeof pattern !== 'string' || !pattern.trim()) {
165
+ bad.push(`${label}.staleWhen must contain non-empty regex strings`);
166
+ continue;
167
+ }
168
+ try {
169
+ new RegExp(pattern, 'i');
170
+ }
171
+ catch {
172
+ bad.push(`${label}.staleWhen has an invalid regex ${JSON.stringify(pattern)}`);
173
+ }
174
+ }
142
175
  }
143
- }
144
176
  }
145
-
146
177
  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
- }
178
+ if (!isPlainObject(catalog[key])) {
179
+ bad.push(`${key} must be a map`);
180
+ return;
181
+ }
182
+ if (JSON.stringify(Object.keys(catalog[key])) !== JSON.stringify(names))
183
+ bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
184
+ for (const [name, text] of Object.entries(catalog[key])) {
185
+ if (typeof text !== 'string' || !text.trim())
186
+ bad.push(`${key}.${name} needs a description`);
187
+ }
152
188
  }
153
-
154
189
  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);
190
+ for (const key of Object.keys(catalog)) {
191
+ if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules'].includes(key))
192
+ bad.push(`unknown top-level key ${key}`);
193
+ }
194
+ const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(catalog.schema));
195
+ if (!schemaOk)
196
+ bad.push('schema must be starci/hfs-rules@<major>');
197
+ if (!SEMVER.test(String(catalog.version)))
198
+ bad.push('version must be MAJOR.MINOR.PATCH');
199
+ else if (schemaOk && catalog.schema.split('@')[1] !== catalog.version.split('.')[0])
200
+ bad.push('the major of version must equal the number after @ in schema');
201
+ vocabularyProblems(catalog, 'gates', RULE_GATES, bad);
202
+ vocabularyProblems(catalog, 'enforcerKinds', ENFORCER_FAMILIES, bad);
164
203
  }
165
-
166
204
  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];
205
+ if (!Array.isArray(rule[key]) || !rule[key].length) {
206
+ bad.push(`${label}.${key} must be a non-empty list`);
207
+ return [];
208
+ }
209
+ for (const value of rule[key]) {
210
+ if (!allowed.includes(value))
211
+ bad.push(`${label}.${key} has ${JSON.stringify(value)}, not one of ${allowed.join(', ')}`);
212
+ }
213
+ if (new Set(rule[key]).size !== rule[key].length)
214
+ bad.push(`${label}.${key} repeats a value`);
215
+ return rule[key];
173
216
  }
174
-
175
217
  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`);
218
+ if (!/^R\d{2,3}$/.test(String(rule.id)))
219
+ bad.push(`${at}.id must be R<two or three digits>`);
220
+ else if (index > 0 && typeof catalog.rules[index - 1]?.id === 'string' && Number(rule.id.slice(1)) <= Number(catalog.rules[index - 1].id.slice(1)))
221
+ bad.push(`${label} is out of order: ids must increase, and ${catalog.rules[index - 1].id} comes before it`);
222
+ if (!FINDING_CODE.test(String(rule.code)))
223
+ bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
179
224
  }
180
-
181
225
  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`);
226
+ if (typeof rule.law !== 'string' || !rule.law.trim())
227
+ bad.push(`${label}.law is missing`);
228
+ if (typeof rule.law === 'string' && rule.law.includes('\n'))
229
+ bad.push(`${label}.law must be one line`);
230
+ if (rule.scope !== undefined && rule.scope !== 'runtime')
231
+ bad.push(`${label}.scope is absent or runtime`);
185
232
  }
186
-
187
233
  /** 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
234
  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
- });
235
+ if (rule.cases === undefined)
236
+ return;
237
+ if (!Array.isArray(rule.cases) || !rule.cases.length) {
238
+ bad.push(`${label}.cases must be a non-empty list`);
239
+ return;
240
+ }
241
+ const ids = new Set();
242
+ rule.cases.forEach((item, index) => {
243
+ const at = `${label}.cases[${index}]`;
244
+ if (!isPlainObject(item)) {
245
+ bad.push(`${at} is not a map`);
246
+ return;
247
+ }
248
+ for (const key of Object.keys(item))
249
+ if (!CASE_KEYS.has(key))
250
+ bad.push(`${at} has unknown key ${key}`);
251
+ if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id))
252
+ bad.push(`${at}.id must be a unique case-N`);
253
+ ids.add(item.id);
254
+ if (typeof item.date !== 'string' || !CASE_DATE.test(item.date))
255
+ bad.push(`${at}.date must be YYYY-MM-DD`);
256
+ if (item.source !== 'owner')
257
+ bad.push(`${at}.source must be owner`);
258
+ for (const key of ['ruling', ...(item.quote === undefined ? [] : ['quote'])])
259
+ if (typeof item[key] !== 'string' || !item[key].trim())
260
+ bad.push(`${at}.${key} must be text`);
261
+ caseTransitionProblems(item, at, bad);
262
+ });
203
263
  }
204
-
205
264
  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`);
265
+ if (!gates.length)
266
+ return;
267
+ const releaseGate = gates.includes('land') || gates.includes('ci');
268
+ const runtimeGate = rule.scope === 'runtime' && gates.includes('runtime');
269
+ if (!releaseGate && !runtimeGate)
270
+ bad.push(`${label} must run at land or ci or, for runtime scope, the runtime gate`);
210
271
  }
211
-
212
272
  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
- }
273
+ if (!Array.isArray(rule.failureCodes) || !rule.failureCodes.length || !rule.failureCodes.every((code) => FINDING_CODE.test(String(code)))) {
274
+ bad.push(`${label}.failureCodes must be a non-empty list of UPPER_SNAKE codes`);
275
+ return;
276
+ }
277
+ if (rule.failureCodes[0] !== rule.code)
278
+ bad.push(`${label}.failureCodes must start with the rule's own code ${rule.code}`);
279
+ if (new Set(rule.failureCodes).size !== rule.failureCodes.length)
280
+ bad.push(`${label}.failureCodes repeats a code`);
281
+ for (const code of rule.failureCodes) {
282
+ if (codeOwner.has(code) && codeOwner.get(code) !== label)
283
+ bad.push(`${label} names ${code}, which ${codeOwner.get(code)} already owns`);
284
+ codeOwner.set(code, label);
285
+ }
223
286
  }
224
-
225
287
  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
- }
288
+ const value = rule.codeEditions;
289
+ if (value === undefined)
290
+ return;
291
+ if (!isPlainObject(value) || !Object.keys(value).length) {
292
+ bad.push(`${label}.codeEditions must be a non-empty map`);
293
+ return;
294
+ }
295
+ const editions = Array.isArray(rule.editions) ? rule.editions : ['full', 'lite'];
296
+ for (const [code, selected] of Object.entries(value)) {
297
+ const at = `${label}.codeEditions.${code}`;
298
+ if (!Array.isArray(rule.failureCodes) || !rule.failureCodes.includes(code))
299
+ bad.push(`${at} must name a code owned by this rule`);
300
+ enumListProblems({ editions: selected }, at, 'editions', ['full', 'lite'], bad);
301
+ if (Array.isArray(selected) && selected.some((edition) => !editions.includes(edition)))
302
+ bad.push(`${at} must be a subset of the rule's editions`);
303
+ }
236
304
  }
237
-
238
305
  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}`);
306
+ if (!ENFORCER_FAMILIES.includes(enforcer.kind))
307
+ bad.push(`${label}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
308
+ if (!ENFORCER_ID.test(String(enforcer.id)))
309
+ bad.push(`${label}.id must be kebab-case`);
310
+ if (seen.has(`${enforcer.kind}:${enforcer.id}`))
311
+ bad.push(`${label} repeats ${enforcer.kind}:${enforcer.id}`);
312
+ seen.add(`${enforcer.kind}:${enforcer.id}`);
243
313
  }
244
-
245
314
  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`);
315
+ if (enforcer.at !== undefined) {
316
+ if (typeof enforcer.at !== 'string' || !enforcer.at.trim() || enforcer.at.startsWith('/') || enforcer.at.includes('..'))
317
+ bad.push(`${label}.at must be a repository-relative path`);
318
+ if (enforcer.status === 'planned')
319
+ bad.push(`${label} is planned, so it has no file yet (at)`);
320
+ if (!FILE_ENFORCERS.has(enforcer.kind))
321
+ bad.push(`${label}.at belongs to a machine, hfs, work-validate, sonar or runtime enforcer only`);
322
+ }
323
+ else if (FILE_ENFORCERS.has(enforcer.kind) && enforcer.status !== 'planned')
324
+ bad.push(`${label} exists, so it names the file (at) that emits its code`);
251
325
  }
252
-
253
326
  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);
327
+ const label = `${ruleLabel}.enforcers[${index}]`;
328
+ if (!isPlainObject(enforcer)) {
329
+ bad.push(`${label} is not a map`);
330
+ return;
331
+ }
332
+ for (const key of Object.keys(enforcer)) {
333
+ if (!ENFORCER_KEYS.has(key))
334
+ bad.push(`${label} has unknown key ${key}`);
335
+ }
336
+ bad.push(...ruleEditionProblems(enforcer, label));
337
+ optionalText(enforcer, 'editionWhy', label, bad);
338
+ checkEnforcerIdentity(enforcer, label, seen, bad);
339
+ if (enforcer.status !== undefined && enforcer.status !== 'planned')
340
+ bad.push(`${label}.status is either absent or planned`);
341
+ checkEnforcerLocation(enforcer, label, bad);
264
342
  }
265
-
266
343
  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));
344
+ if (!Array.isArray(rule.enforcers) || !rule.enforcers.length) {
345
+ bad.push(`${label}.enforcers must name at least one enforcer`);
346
+ return;
347
+ }
348
+ const seen = new Set();
349
+ rule.enforcers.forEach((enforcer, index) => checkEnforcer(enforcer, index, label, seen, bad));
270
350
  }
271
-
272
351
  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
- }
352
+ const at = `rules[${index}]`;
353
+ if (!isPlainObject(rule)) {
354
+ bad.push(`${at} is not a map`);
355
+ return;
356
+ }
357
+ const label = typeof rule.id === 'string' ? rule.id : at;
358
+ for (const key of Object.keys(rule)) {
359
+ if (!RULE_KEYS.has(key))
360
+ bad.push(`${label} has unknown key ${key}`);
361
+ }
362
+ checkRuleIdentity(rule, index, catalog, at, label, bad);
363
+ checkRuleText(rule, label, bad);
364
+ checkRuleCases(rule, label, bad);
365
+ if (rule.amendedBy !== undefined)
366
+ caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
367
+ optionalText(rule, 'editionWhy', label, bad);
368
+ optionalText(rule, 'templateNa', label, bad);
369
+ for (const key of ['docs', 'exemplifiedBy', 'proves'])
370
+ evidenceProblems(rule, key, label, bad);
371
+ witnessProblems(rule, label, bad);
372
+ enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
373
+ bad.push(...ruleEditionProblems(rule, label));
374
+ const gates = enumListProblems(rule, label, 'gates', RULE_GATES, bad);
375
+ checkRuleGates(rule, label, gates, bad);
376
+ checkRuleFailureCodes(rule, label, codeOwner, bad);
377
+ checkCodeEditions(rule, label, bad);
378
+ checkRuleEnforcers(rule, label, bad);
379
+ if (Array.isArray(rule.gates)) {
380
+ const hasSonar = rule.enforcers.some((enforcer) => isPlainObject(enforcer) && enforcer.kind === 'sonar');
381
+ if (rule.gates.includes('sonar') !== hasSonar)
382
+ bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
383
+ }
298
384
  }
299
-
300
385
  /** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
301
386
  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;
387
+ const bad = [];
388
+ if (!isPlainObject(catalog))
389
+ return ['the rule catalog is not a map'];
390
+ checkCatalogHeader(catalog, bad);
391
+ if (!Array.isArray(catalog.rules) || !catalog.rules.length) {
392
+ bad.push('rules must be a non-empty list');
393
+ return bad;
394
+ }
395
+ const codeOwner = new Map();
396
+ catalog.rules.forEach((rule, index) => checkRule(rule, index, catalog, codeOwner, bad));
397
+ return bad;
309
398
  }
310
-
311
- const deepFreeze = (value) => { if (value && typeof value === 'object') { Object.values(value).forEach(deepFreeze); } return Object.freeze(value); };
312
-
399
+ const deepFreeze = (value) => { if (value && typeof value === 'object') {
400
+ Object.values(value).forEach(deepFreeze);
401
+ } return Object.freeze(value); };
313
402
  /**
314
403
  * The parsed and validated HFS rule catalog. `text` (or `file`, or `root`) selects the source; the default is the runtime's
315
404
  * own knowledge/hfs/rules.yaml. A catalog that breaks its shape or a semantic rule is refused whole (HFS_RULES_INVALID), and
@@ -317,46 +406,53 @@ const deepFreeze = (value) => { if (value && typeof value === 'object') { Object
317
406
  * Answers: rule(id), byCode(code), forGate(gate), forEnforcer(kind, id), planned() and unbuilt().
318
407
  */
319
408
  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
- });
409
+ let doc;
410
+ try {
411
+ doc = parseYamlCached(text ?? fs.readFileSync(file, 'utf8'));
412
+ }
413
+ catch (error) {
414
+ fail('HFS_RULES_INVALID', `the rule catalog cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file });
415
+ }
416
+ const problems = ruleCatalogProblems(doc);
417
+ if (problems.length) {
418
+ 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 });
419
+ }
420
+ const [major, minor, patch] = doc.version.split('.').map(Number);
421
+ if (manifest && manifest.major !== major)
422
+ fail('HFS_MANIFEST_MAJOR_MISMATCH', `the rule catalog is major ${major} but the slot manifest is major ${manifest.major}`, { catalog: major, manifest: manifest.major });
423
+ const list = deepFreeze(doc.rules.map((rule) => ({ ...rule, enforcers: rule.enforcers.map((enforcer) => ({ ...enforcer, planned: enforcer.status === 'planned' })) })));
424
+ const byId = new Map(list.map((rule) => [rule.id, rule]));
425
+ const byCode = new Map(list.flatMap((rule) => rule.failureCodes.map((code) => [code, rule])));
426
+ return Object.freeze({
427
+ version: doc.version, major, minor, patch,
428
+ gates: deepFreeze(jsonClone(doc.gates)),
429
+ enforcerKinds: deepFreeze(jsonClone(doc.enforcerKinds)),
430
+ rules: list,
431
+ /** The rule with this id (R01..), or null. */
432
+ rule: (id) => byId.get(id) ?? null,
433
+ /** The rule that owns this failure code (its own or a sub-check code), or null. */
434
+ byCode: (code) => byCode.get(code) ?? null,
435
+ /** Whether a finding code is judged under `edition`: codeEditions narrows its owner's editions; uncatalogued codes remain judged. */
436
+ judgedIn: (code, edition = 'full') => {
437
+ const owner = byCode.get(code);
438
+ return judgedInEdition(owner, edition) && (!owner?.codeEditions?.[code] || owner.codeEditions[code].includes(edition));
439
+ },
440
+ enforcerJudgedIn: (kind, id, edition = 'full') => enforcerJudgedInEdition(list, kind, id, edition),
441
+ /** The rules that run at a gate. */
442
+ forGate: (gate) => list.filter((rule) => rule.gates.includes(gate)),
443
+ /** The catalogued why code of a lint finding's rule id (`starci-be/<id>`, `starci-fe/<id>`), or undefined. */
444
+ lintCode: (ruleId) => {
445
+ const [plugin, id] = String(ruleId ?? '').split('/');
446
+ const kind = (plugin === 'starci-be' && 'eslint-be') || (plugin === 'starci-fe' && 'eslint-fe') || (plugin === 'starci-grammar' && 'eslint-grammar-core') || null;
447
+ return kind ? list.find((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id))?.code : undefined;
448
+ },
449
+ /** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
450
+ forEnforcer: (kind, id) => list.filter((rule) => rule.enforcers.some((enforcer) => enforcer.kind === kind && enforcer.id === id)),
451
+ /** Every enforcer still owed, as {rule, kind, id}. */
452
+ planned: () => list.flatMap((rule) => rule.enforcers.filter((enforcer) => enforcer.planned).map((enforcer) => ({ rule: rule.id, kind: enforcer.kind, id: enforcer.id }))),
453
+ /** The rules with no existing enforcer at all. */
454
+ unbuilt: () => list.filter((rule) => rule.enforcers.every((enforcer) => enforcer.planned)),
455
+ });
359
456
  }
360
-
361
457
  /** The rules of this runtime's catalog, frozen, in id order. */
362
458
  export const rules = (options) => loadRuleCatalog(options).rules;