@starci/eslint-grammar-core 0.15.34 → 0.16.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 +5 -0
- package/README.md +1 -1
- package/dist/index.js +2 -0
- package/dist/lib/why.js +10 -0
- package/dist/mascot.d.ts +28 -0
- package/dist/mascot.js +166 -0
- package/docs/mascot.md +60 -0
- package/package.json +2 -2
- package/runtime/bundle.json +74 -0
- package/runtime/engine/by-code-unit.mjs +6 -6
- package/runtime/engine/model-config.mjs +98 -76
- package/runtime/engine/orca-config.mjs +22 -17
- package/runtime/engine/release-config.mjs +22 -18
- package/runtime/engine/removed-vocabulary.mjs +21 -20
- package/runtime/engine/resources-config.mjs +42 -35
- package/runtime/engine/runtime-root.mjs +11 -13
- package/runtime/engine/secrets.mjs +170 -102
- package/runtime/engine/sonar-config.mjs +16 -13
- package/runtime/knowledge/hfs/canon-pins.yaml +10 -10
- package/runtime/knowledge/hfs/rules.yaml +72 -6
- package/runtime/knowledge/hfs/slots.yaml +2 -2
- package/runtime/knowledge/patterns/fe/ui.yaml +12 -1
- package/runtime/scripts/hfs/allows.mjs +37 -38
- package/runtime/scripts/hfs/architecture/config.mjs +302 -268
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +2 -5
- package/runtime/scripts/hfs/architecture/type-context.mjs +255 -250
- package/runtime/scripts/hfs/architecture/typescript.mjs +358 -318
- package/runtime/scripts/hfs/declaration-shape.mjs +140 -103
- package/runtime/scripts/hfs/declaration-slots.mjs +64 -61
- package/runtime/scripts/hfs/edition-slots.mjs +43 -40
- package/runtime/scripts/hfs/param-names.mjs +6 -11
- package/runtime/scripts/hfs/rule-catalog.mjs +353 -257
- package/runtime/scripts/hfs/rule-params-shape.mjs +60 -47
- package/runtime/scripts/hfs/slot-app-view.mjs +69 -74
- package/runtime/scripts/hfs/slot-classify.mjs +42 -38
- package/runtime/scripts/hfs/slot-errors.mjs +8 -7
- package/runtime/scripts/hfs/slot-imports.mjs +70 -63
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +74 -52
- package/runtime/scripts/hfs/slot-match.mjs +122 -111
- package/runtime/scripts/hfs/slot-path.mjs +0 -2
- package/runtime/scripts/hfs/slot-required.mjs +65 -59
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +138 -112
- package/runtime/scripts/hfs/slot-side-problems.mjs +23 -19
- package/runtime/scripts/hfs/slots.mjs +175 -152
- package/runtime/scripts/hfs/typescript-programs.mjs +66 -69
- package/runtime/scripts/hfs/view.mjs +38 -40
- package/runtime/scripts/lib/glob.mjs +23 -14
- package/runtime/scripts/lib/grammar-lint.mjs +33 -37
- package/runtime/scripts/lib/i18n.mjs +32 -35
- package/runtime/scripts/lib/json-clone.mjs +45 -36
- package/runtime/scripts/lib/json.mjs +26 -14
- package/runtime/scripts/lib/list.mjs +9 -14
- package/runtime/scripts/lib/path-key.mjs +53 -38
- package/runtime/scripts/lib/read-text.mjs +30 -17
- package/runtime/scripts/lib/regex.mjs +1 -2
- package/runtime/scripts/lib/source-phrases.mjs +13 -13
- package/runtime/scripts/lib/ts-analysis.mjs +28 -26
- package/runtime/scripts/lib/ts-ast.mjs +36 -42
- package/runtime/scripts/lib/walk.mjs +33 -27
- 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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
66
|
+
if (item[key] !== undefined)
|
|
67
|
+
textProblem(item[key], `${label}.${key}`, bad);
|
|
71
68
|
}
|
|
72
|
-
|
|
73
69
|
function evidenceEditionProblems(item, label, bad) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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;
|