@starci/hfs 1.0.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/README.md +38 -0
- package/bin/hfs.mjs +100 -0
- package/package.json +28 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +212 -0
- package/runtime/knowledge/hfs/slots.yaml +842 -0
- package/runtime/modules/kernel/failure-codes.yaml +169 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-check.mjs +305 -0
- package/runtime/scripts/lib/hfs-slots.mjs +675 -0
- package/runtime/scripts/lib/path-key.mjs +15 -0
- package/sync/cli.mjs +15 -0
- package/sync/hygiene.mjs +92 -0
- package/sync/index.mjs +224 -0
- package/sync/skeleton.mjs +54 -0
- package/sync/sonar-key.mjs +45 -0
- package/templates/be/e2e.yml +21 -0
- package/templates/be/gitignore +2 -0
- package/templates/be/pre-commit +8 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.composition.spec.ts +31 -0
- package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +7 -0
- package/templates/be/skeleton/apps/__app__/src/app.module.ts +23 -0
- package/templates/be/skeleton/apps/__app__/src/main.ts +20 -0
- package/templates/be/skeleton/src/features/system-health/index.ts +1 -0
- package/templates/be/skeleton/src/features/system-health/system-health.module.ts +6 -0
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.spec.ts +13 -0
- package/templates/be/skeleton/src/features/system-health/transport/http/live.controller.ts +18 -0
- package/templates/be/skeleton/src/features/system-health/transport/http/system-health-http.module.ts +10 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.spec.ts +36 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.ts +40 -0
- package/templates/be/skeleton/src/modules/platform/config/errors/config.error.ts +21 -0
- package/templates/be/skeleton/src/modules/platform/config/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/config/server.config.spec.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/config/server.config.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/config/server.options.ts +8 -0
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.spec.ts +15 -0
- package/templates/be/skeleton/src/modules/platform/errors/domain-error.ts +11 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.spec.ts +38 -0
- package/templates/be/skeleton/src/modules/platform/errors/error.filter.ts +24 -0
- package/templates/be/skeleton/src/modules/platform/errors/index.ts +2 -0
- package/templates/be/skeleton/src/modules/platform/logging/index.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.spec.ts +33 -0
- package/templates/be/skeleton/src/modules/platform/logging/json-logger.ts +35 -0
- package/templates/be/skeleton/src/modules/platform/logging/log-id.ts +9 -0
- package/templates/be/skeleton/src/modules/platform/logging/logger.port.ts +19 -0
- package/templates/be/skeleton/src/modules/platform/logging/logging.module.ts +11 -0
- package/templates/be/sonar-project.properties +10 -0
- package/templates/be/starciwork.gitignore +39 -0
- package/templates/common/ci.yml +50 -0
- package/templates/common/codecov.yml +13 -0
- package/templates/common/gitignore.base +33 -0
- package/templates/common/pre-push +5 -0
- package/templates/fe/e2e.yml +22 -0
- package/templates/fe/gitignore +3 -0
- package/templates/fe/pre-commit +7 -0
- package/templates/fe/skeleton/apps/__app__/next.config.ts +11 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/error.tsx +22 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/layout.tsx +31 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/not-found.tsx +15 -0
- package/templates/fe/skeleton/apps/__app__/src/app/[locale]/page.tsx +27 -0
- package/templates/fe/skeleton/apps/__app__/src/app/global-error.tsx +24 -0
- package/templates/fe/skeleton/apps/__app__/src/app/globals.css +1 -0
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.spec.ts +10 -0
- package/templates/fe/skeleton/apps/__app__/src/app/health/live/route.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/config.ts +8 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages/vi.json +19 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/messages.spec.ts +27 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/navigation.ts +5 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/request.ts +13 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.spec.ts +10 -0
- package/templates/fe/skeleton/apps/__app__/src/modules/i18n/routing.ts +9 -0
- package/templates/fe/skeleton/apps/__app__/src/proxy.ts +10 -0
- package/templates/fe/sonar-project.properties +11 -0
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
// hfs-slots.mjs - the HFS slot manifest and repository declaration, loaded once and asked four questions:
|
|
2
|
+
// which slot owns path P slotOf(P) / classifyPath(P) (an unknown path reports its nearest slot)
|
|
3
|
+
// is import A -> B allowed importAllowed(A, B) (tier matrix, cross-owner entry, cross-app, layers)
|
|
4
|
+
// which files are required requiredFiles(P) / requiredPaths()
|
|
5
|
+
// is this path tracked isTracked(P) / trackingOf(P)
|
|
6
|
+
// The rule catalog (knowledge/hfs/rules.yaml, modules/schemas/hfs-rules.schema.yaml) loads through loadRuleCatalog / rules().
|
|
7
|
+
// Every check and lint rule of HFS reads knowledge/hfs/slots.yaml through this module; none keeps its own path
|
|
8
|
+
// list. The manifest shape is modules/schemas/hfs-slots.schema.yaml and hfs.json is modules/schemas/hfs-repo.schema.yaml;
|
|
9
|
+
// the installed runtime carries no npm dependency, so this file re-states those shapes instead of loading ajv
|
|
10
|
+
// (tests/hfs-slots.spec.mjs proves the two agree).
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
14
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
15
|
+
import { braceVariants } from './glob.mjs';
|
|
16
|
+
import { posixPath } from './path-key.mjs';
|
|
17
|
+
|
|
18
|
+
export const HFS_MANIFEST_FILE = 'knowledge/hfs/slots.yaml';
|
|
19
|
+
export const HFS_DECLARATION_FILE = 'hfs.json';
|
|
20
|
+
|
|
21
|
+
/** A refusal with a catalogued code (modules/kernel/failure-codes.yaml) and the facts a check reports. */
|
|
22
|
+
export class HfsSlotsError extends Error {
|
|
23
|
+
constructor(code, message, details = {}) {
|
|
24
|
+
super(`${code}: ${message}`);
|
|
25
|
+
this.name = 'HfsSlotsError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.details = details;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
32
|
+
const NAME = /^[a-z][a-z0-9-]*$/;
|
|
33
|
+
const SLOT_ID = /^(repo|be|fe)\.[a-z0-9-]+(\.[a-z0-9-]+)*$/;
|
|
34
|
+
const PRESENCE = ['required', 'optional', 'opt-in', 'forbidden'];
|
|
35
|
+
const TRACKED = ['tracked', 'ignored', 'external'];
|
|
36
|
+
const TESTS = ['unit-beside', 'e2e', 'none'];
|
|
37
|
+
const PROFILES = ['be', 'fe'];
|
|
38
|
+
const isMap = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
39
|
+
const strList = (v) => Array.isArray(v) && v.every((s) => typeof s === 'string' && s.length > 0);
|
|
40
|
+
|
|
41
|
+
// ------------------------------------------------------------------------------------------------ patterns
|
|
42
|
+
|
|
43
|
+
const VAR = /<([A-Za-z][A-Za-z0-9-]*)>/g;
|
|
44
|
+
const varsOf = (text) => [...String(text).matchAll(VAR)].map((m) => m[1]);
|
|
45
|
+
const hasWildcard = (segment) => /[*?]/.test(segment);
|
|
46
|
+
|
|
47
|
+
/** Weight of one pattern segment: literal 4, literal mixed with a variable 3, a bare variable 2, a wildcard 1, `**` 0. */
|
|
48
|
+
function segmentWeight(segment) {
|
|
49
|
+
if (segment === '**') return 0;
|
|
50
|
+
if (hasWildcard(segment)) return 1;
|
|
51
|
+
if (/^<[^>]+>$/.test(segment)) return 2;
|
|
52
|
+
if (segment.includes('<')) return 3;
|
|
53
|
+
return 4;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The RegExp source of one pattern segment (no separators); variables capture, and are listed in `names`. */
|
|
57
|
+
function segmentSource(segment, names) {
|
|
58
|
+
let source = '';
|
|
59
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
60
|
+
const c = segment[i];
|
|
61
|
+
if (c === '<') {
|
|
62
|
+
const end = segment.indexOf('>', i);
|
|
63
|
+
names.push(segment.slice(i + 1, end));
|
|
64
|
+
source += '([^/]+?)';
|
|
65
|
+
i = end;
|
|
66
|
+
} else if (c === '*') source += '[^/]*';
|
|
67
|
+
else if (c === '?') source += '[^/]';
|
|
68
|
+
else source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
69
|
+
}
|
|
70
|
+
return source;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One brace-free pattern compiled: `dir` patterns (trailing /) own everything below their root. */
|
|
74
|
+
function compileVariant(slot, pattern) {
|
|
75
|
+
const dir = pattern.endsWith('/');
|
|
76
|
+
const body = dir ? pattern.slice(0, -1) : pattern;
|
|
77
|
+
const segments = body.split('/');
|
|
78
|
+
const names = [];
|
|
79
|
+
let source = '';
|
|
80
|
+
segments.forEach((segment, index) => {
|
|
81
|
+
const last = index === segments.length - 1;
|
|
82
|
+
if (segment === '**') source += last ? '.*' : '(?:.*/)?';
|
|
83
|
+
else source += segmentSource(segment, names) + (last ? '' : '/');
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
slot,
|
|
87
|
+
pattern,
|
|
88
|
+
dir,
|
|
89
|
+
segments,
|
|
90
|
+
names,
|
|
91
|
+
score: segments.reduce((sum, s) => sum + segmentWeight(s), 0),
|
|
92
|
+
wildcards: segments.filter(hasWildcard).length,
|
|
93
|
+
regex: new RegExp(`^(${source})${dir ? '(?:/.*)?' : ''}$`),
|
|
94
|
+
segmentRegexes: segments.map((s) => (s === '**' ? null : new RegExp(`^${segmentSource(s, [])}$`))),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const variantsOf = (slot) => braceVariants(slot.path).map((pattern) => compileVariant(slot, pattern));
|
|
99
|
+
|
|
100
|
+
const levenshtein = (a, b) => {
|
|
101
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
102
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
103
|
+
let prev = row[0];
|
|
104
|
+
row[0] = i;
|
|
105
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
106
|
+
const held = row[j];
|
|
107
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
108
|
+
prev = held;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return row[b.length];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ------------------------------------------------------------------------------------------- manifest
|
|
115
|
+
|
|
116
|
+
const fail = (code, message, details) => { throw new HfsSlotsError(code, message, details); };
|
|
117
|
+
|
|
118
|
+
/** Shape problems of a parsed manifest, in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
119
|
+
function manifestShapeProblems(m) {
|
|
120
|
+
const bad = [];
|
|
121
|
+
if (!isMap(m)) return ['the manifest is not a map'];
|
|
122
|
+
const allowed = new Set(['schema', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'appKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers']);
|
|
123
|
+
for (const key of Object.keys(m)) if (!allowed.has(key)) bad.push(`unknown top-level key ${key}`);
|
|
124
|
+
if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema))) bad.push('schema must be starci/hfs-slots@<major>');
|
|
125
|
+
if (!SEMVER.test(String(m.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
126
|
+
if (!isMap(m.versioning) || !['patch', 'minor', 'major', 'retire', 'pins'].every((k) => typeof m.versioning[k] === 'string')) bad.push('versioning needs patch, minor, major, retire and pins text');
|
|
127
|
+
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE)) bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
128
|
+
if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED)) bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
|
|
129
|
+
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS)) bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
130
|
+
for (const key of ['appKinds', 'tiers']) {
|
|
131
|
+
if (!isMap(m[key])) { bad.push(`${key} must be a map with be and fe`); continue; }
|
|
132
|
+
for (const extra of Object.keys(m[key])) if (!PROFILES.includes(extra)) bad.push(`${key}.${extra} is not a profile`);
|
|
133
|
+
for (const profile of PROFILES) if (!(profile in m[key])) bad.push(`${key}.${profile} is missing`);
|
|
134
|
+
}
|
|
135
|
+
for (const profile of PROFILES) {
|
|
136
|
+
const kinds = m.appKinds?.[profile];
|
|
137
|
+
if (kinds !== undefined && (!Array.isArray(kinds) || !kinds.length || !kinds.every((k) => NAME.test(String(k))) || new Set(kinds).size !== kinds.length)) bad.push(`appKinds.${profile} must be a non-empty list of unique names`);
|
|
138
|
+
const tiers = m.tiers?.[profile];
|
|
139
|
+
if (tiers === undefined) continue;
|
|
140
|
+
if (!isMap(tiers) || !Object.keys(tiers).length) { bad.push(`tiers.${profile} must be a non-empty map`); continue; }
|
|
141
|
+
for (const [tier, def] of Object.entries(tiers)) {
|
|
142
|
+
if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
|
|
143
|
+
if (!isMap(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
|
|
144
|
+
for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const blockLinesOk = (v) => Number.isInteger(v) && v >= 2;
|
|
148
|
+
const fileLinesOk = (v) => isMap(v) && Number.isInteger(v.soft) && v.soft >= 1 && typeof v.hardGrowth === 'boolean' && Object.keys(v).length === 2;
|
|
149
|
+
const rp = m.ruleParams;
|
|
150
|
+
if (!isMap(rp) || Object.keys(rp).some((k) => !PROFILES.includes(k)) || !PROFILES.every((p) => isMap(rp[p]))) bad.push('ruleParams must be a map with be and fe');
|
|
151
|
+
else {
|
|
152
|
+
if (!(strList(rp.be.globalModules) && new Set(rp.be.globalModules).size === rp.be.globalModules.length) || !fileLinesOk(rp.be.fileLines) || !blockLinesOk(rp.be.duplicateBlockLines) || Object.keys(rp.be).length !== 3) bad.push('ruleParams.be needs globalModules (unique paths), fileLines {soft, hardGrowth} and duplicateBlockLines (integer >= 2)');
|
|
153
|
+
if (!fileLinesOk(rp.fe.fileLines) || typeof rp.fe.clientModule !== 'string' || !rp.fe.clientModule || !blockLinesOk(rp.fe.duplicateBlockLines) || Object.keys(rp.fe).length !== 3) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth}, clientModule and duplicateBlockLines (integer >= 2)');
|
|
154
|
+
}
|
|
155
|
+
if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
|
|
156
|
+
const slotKeys = new Set(['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'allows', 'forbids', 'layers', 'budget', 'managedBy', 'rules', 'goesTo', 'why', 'since', 'retiredIn', 'successor']);
|
|
157
|
+
m.slots.forEach((slot, index) => {
|
|
158
|
+
const at = isMap(slot) && typeof slot.id === 'string' ? `slot ${slot.id}` : `slots[${index}]`;
|
|
159
|
+
if (!isMap(slot)) { bad.push(`${at} is not a map`); return; }
|
|
160
|
+
for (const key of Object.keys(slot)) if (!slotKeys.has(key)) bad.push(`${at}: unknown field ${key}`);
|
|
161
|
+
if (!SLOT_ID.test(String(slot.id))) bad.push(`${at}: id must look like be.transport.http`);
|
|
162
|
+
if (!Array.isArray(slot.profiles) || !slot.profiles.length || !slot.profiles.every((p) => PROFILES.includes(p)) || new Set(slot.profiles).size !== slot.profiles.length) bad.push(`${at}: profiles must be a unique non-empty subset of be, fe`);
|
|
163
|
+
if (typeof slot.path !== 'string' || !slot.path) bad.push(`${at}: path is required`);
|
|
164
|
+
if (!PRESENCE.includes(slot.presence)) bad.push(`${at}: presence must be one of ${PRESENCE.join(', ')}`);
|
|
165
|
+
if (!TRACKED.includes(slot.tracked)) bad.push(`${at}: tracked must be one of ${TRACKED.join(', ')}`);
|
|
166
|
+
if (!NAME.test(String(slot.tier))) bad.push(`${at}: tier must be a tier name, none or inherit`);
|
|
167
|
+
if (!TESTS.includes(slot.tests)) bad.push(`${at}: tests must be one of ${TESTS.join(', ')}`);
|
|
168
|
+
if (slot.owner !== undefined && typeof slot.owner !== 'boolean') bad.push(`${at}: owner must be a boolean`);
|
|
169
|
+
if (slot.appKind !== undefined && !NAME.test(String(slot.appKind))) bad.push(`${at}: appKind must be a name`);
|
|
170
|
+
if (slot.minInstances !== undefined && !(Number.isInteger(slot.minInstances) && slot.minInstances >= 1)) bad.push(`${at}: minInstances must be a positive integer`);
|
|
171
|
+
if (slot.requiredWhen !== undefined && slot.requiredWhen !== 'connections') bad.push(`${at}: requiredWhen may only be connections`);
|
|
172
|
+
if (slot.requiredInstances !== undefined && !(isMap(slot.requiredInstances) && Object.values(slot.requiredInstances).every((v) => strList(v) && v.length))) bad.push(`${at}: requiredInstances must map a variable to a non-empty list of names`);
|
|
173
|
+
for (const key of ['requires', 'allows', 'forbids', 'layers']) if (slot[key] !== undefined && !strList(slot[key])) bad.push(`${at}: ${key} must be a list of strings`);
|
|
174
|
+
if (slot.budget !== undefined && !(isMap(slot.budget) && Object.keys(slot.budget).length && Object.values(slot.budget).every((v) => Number.isInteger(v) && v >= 1))) bad.push(`${at}: budget must map names to positive integers`);
|
|
175
|
+
if (slot.managedBy !== undefined && !NAME.test(String(slot.managedBy))) bad.push(`${at}: managedBy must be a template id`);
|
|
176
|
+
if (slot.rules !== undefined && !(Array.isArray(slot.rules) && slot.rules.every((r) => /^[A-Z][A-Z0-9_]*\*?$/.test(String(r))) && new Set(slot.rules).size === slot.rules.length)) bad.push(`${at}: rules must be unique rule ids`);
|
|
177
|
+
if (slot.since !== undefined && !SEMVER.test(String(slot.since))) bad.push(`${at}: since must be a version`);
|
|
178
|
+
if (slot.retiredIn !== undefined && !(Number.isInteger(slot.retiredIn) && slot.retiredIn >= 2)) bad.push(`${at}: retiredIn must be a major`);
|
|
179
|
+
if (slot.retiredIn !== undefined && typeof slot.successor !== 'string') bad.push(`${at}: a retired slot names its successor`);
|
|
180
|
+
if (slot.tracked === 'external' && (typeof slot.goesTo !== 'string' || slot.presence !== 'forbidden')) bad.push(`${at}: an external slot is forbidden and says where it goes (goesTo)`);
|
|
181
|
+
if (slot.presence === 'forbidden' && slot.tracked !== 'external') bad.push(`${at}: a forbidden slot is external`);
|
|
182
|
+
});
|
|
183
|
+
return bad;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The rules a JSON Schema cannot state: unique ids, tiers named and reachable, app kinds, variables, no duplicate pattern. */
|
|
187
|
+
function manifestSemanticProblems(m) {
|
|
188
|
+
const bad = [];
|
|
189
|
+
const ids = new Set();
|
|
190
|
+
const claimed = new Map();
|
|
191
|
+
for (const slot of m.slots) {
|
|
192
|
+
if (ids.has(slot.id)) bad.push(`slot id ${slot.id} appears twice`);
|
|
193
|
+
ids.add(slot.id);
|
|
194
|
+
const pathVars = new Set(varsOf(slot.path));
|
|
195
|
+
for (const profile of slot.profiles) {
|
|
196
|
+
const tiers = m.tiers[profile];
|
|
197
|
+
if (slot.tier !== 'none' && slot.tier !== 'inherit' && !(slot.tier in tiers)) bad.push(`slot ${slot.id}: tier ${slot.tier} is not a ${profile} tier`);
|
|
198
|
+
if (slot.appKind !== undefined && !m.appKinds[profile].includes(slot.appKind)) bad.push(`slot ${slot.id}: app kind ${slot.appKind} is not a ${profile} kind`);
|
|
199
|
+
if (slot.appKind === undefined) {
|
|
200
|
+
for (const variant of braceVariants(slot.path)) {
|
|
201
|
+
const key = `${profile}:${variant}`;
|
|
202
|
+
if (claimed.has(key)) bad.push(`slots ${claimed.get(key)} and ${slot.id} claim the same pattern ${variant} on ${profile}`);
|
|
203
|
+
else claimed.set(key, slot.id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (slot.appKind !== undefined && !pathVars.has('app')) bad.push(`slot ${slot.id}: an app-kind slot binds <app> in its path`);
|
|
208
|
+
if (slot.requiredWhen !== undefined && slot.presence !== 'required') bad.push(`slot ${slot.id}: requiredWhen belongs to a required slot`);
|
|
209
|
+
for (const name of Object.keys(slot.requiredInstances ?? {})) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requiredInstances names <${name}>, which the path does not bind`);
|
|
210
|
+
for (const entry of slot.requires ?? []) for (const name of varsOf(entry)) if (!pathVars.has(name)) bad.push(`slot ${slot.id}: requires ${entry} uses <${name}>, which the path does not bind`);
|
|
211
|
+
for (const variant of braceVariants(slot.path)) {
|
|
212
|
+
try { compileVariant(slot, variant); } catch (error) { bad.push(`slot ${slot.id}: pattern ${variant} does not compile (${error.message})`); }
|
|
213
|
+
}
|
|
214
|
+
if (slot.successor !== undefined && !m.slots.some((s) => s.id === slot.successor)) bad.push(`slot ${slot.id}: successor ${slot.successor} is not a slot`);
|
|
215
|
+
if (slot.layers !== undefined && slot.tier !== 'none' && !slot.profiles.every((p) => m.tiers[p][slot.tier]?.lowerLayerOnly)) bad.push(`slot ${slot.id}: layers need a lowerLayerOnly tier`);
|
|
216
|
+
}
|
|
217
|
+
for (const profile of PROFILES) {
|
|
218
|
+
for (const [tier, def] of Object.entries(m.tiers[profile])) {
|
|
219
|
+
for (const target of def.mayImport) if (!(target in m.tiers[profile])) bad.push(`tiers.${profile}.${tier}.mayImport names ${target}, which is not a ${profile} tier`);
|
|
220
|
+
}
|
|
221
|
+
for (const kind of m.appKinds[profile]) {
|
|
222
|
+
const owners = m.slots.filter((s) => s.profiles.includes(profile) && s.appKind === kind);
|
|
223
|
+
if (owners.length !== 1) bad.push(`${profile} app kind ${kind} must have exactly one slot (found ${owners.length})`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const major = Number(m.version.split('.')[0]);
|
|
227
|
+
if (String(m.schema) !== `starci/hfs-slots@${major}`) bad.push(`schema ${m.schema} does not carry the major of version ${m.version}`);
|
|
228
|
+
return bad;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The parsed and validated manifest. `text` (or `file`, or `root`) selects the source; the default is the runtime's own
|
|
233
|
+
* knowledge/hfs/slots.yaml. A manifest that fails its shape or a semantic rule is refused whole (HFS_MANIFEST_INVALID).
|
|
234
|
+
*/
|
|
235
|
+
export function loadSlotManifest({ root = skillRoot, file = path.join(root, HFS_MANIFEST_FILE), text } = {}) {
|
|
236
|
+
let doc;
|
|
237
|
+
try { doc = parseYaml(text ?? fs.readFileSync(file, 'utf8')); } catch (error) { fail('HFS_MANIFEST_INVALID', `the slot manifest cannot be read (${String(error?.message ?? error).split('\n')[0]})`, { file }); }
|
|
238
|
+
const problems = manifestShapeProblems(doc);
|
|
239
|
+
if (!problems.length) problems.push(...manifestSemanticProblems(doc));
|
|
240
|
+
if (problems.length) fail('HFS_MANIFEST_INVALID', `the slot manifest breaks its schema: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? `; and ${problems.length - 5} more` : ''}`, { file, problems });
|
|
241
|
+
const [major, minor, patch] = doc.version.split('.').map(Number);
|
|
242
|
+
return Object.freeze({ ...doc, major, minor, patch });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ------------------------------------------------------------------------------------- declaration
|
|
246
|
+
|
|
247
|
+
/** Shape problems of a parsed hfs.json, in the words of modules/schemas/hfs-repo.schema.yaml. */
|
|
248
|
+
function declarationShapeProblems(d) {
|
|
249
|
+
const bad = [];
|
|
250
|
+
if (!isMap(d)) return ['hfs.json is not an object'];
|
|
251
|
+
for (const key of Object.keys(d)) if (!['hfs', 'profile', 'project', 'apps', 'optionalSlots', 'connections', 'stacks'].includes(key)) bad.push(`unknown key ${key}`);
|
|
252
|
+
if (!(Number.isInteger(d.hfs) && d.hfs >= 1)) bad.push('hfs must be the pinned manifest major (an integer, 1 or more)');
|
|
253
|
+
if (!PROFILES.includes(d.profile)) bad.push('profile must be be or fe');
|
|
254
|
+
if (!NAME.test(String(d.project))) bad.push('project must be a project name');
|
|
255
|
+
if (!Array.isArray(d.apps) || !d.apps.length) bad.push('apps must list every apps/<name> with its kind');
|
|
256
|
+
else d.apps.forEach((app, i) => {
|
|
257
|
+
if (!isMap(app) || !NAME.test(String(app.name)) || !NAME.test(String(app.kind)) || Object.keys(app).some((k) => k !== 'name' && k !== 'kind')) bad.push(`apps[${i}] must be {name, kind}`);
|
|
258
|
+
});
|
|
259
|
+
if (d.stacks !== undefined && (d.profile !== 'fe' || typeof d.stacks !== 'string' || !d.stacks || /^([a-zA-Z]:)?[\/]/.test(d.stacks))) bad.push('stacks is front end only and must be a relative path to the sibling back-end repository');
|
|
260
|
+
for (const key of ['optionalSlots', 'connections']) {
|
|
261
|
+
if (d[key] === undefined) continue;
|
|
262
|
+
const pattern = key === 'optionalSlots' ? SLOT_ID : NAME;
|
|
263
|
+
if (!Array.isArray(d[key]) || !d[key].every((v) => pattern.test(String(v))) || new Set(d[key]).size !== d[key].length) bad.push(`${key} must be a unique list of ${key === 'optionalSlots' ? 'slot ids' : 'connection names'}`);
|
|
264
|
+
}
|
|
265
|
+
if (d.profile === 'fe' && d.connections !== undefined) bad.push('connections belong to a backend repository');
|
|
266
|
+
return bad;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const declarationInvalid = (problems, file) => fail('HFS_DECLARATION_INVALID', `hfs.json is refused: ${problems.slice(0, 5).join('; ')}${problems.length > 5 ? `; and ${problems.length - 5} more` : ''}`, { file, problems });
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A declaration checked against the manifest: the pinned major must be the manifest's (HFS_MANIFEST_MAJOR_MISMATCH
|
|
273
|
+
* otherwise, and there is no compatibility window), app kinds must exist for the profile, optionalSlots may name only
|
|
274
|
+
* opt-in slots that no app kind implies, and every required app kind must be declared.
|
|
275
|
+
*/
|
|
276
|
+
export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLARATION_FILE } = {}) {
|
|
277
|
+
const problems = declarationShapeProblems(declaration);
|
|
278
|
+
if (problems.length) declarationInvalid(problems, file);
|
|
279
|
+
if (declaration.hfs !== manifest.major)
|
|
280
|
+
fail('HFS_MANIFEST_MAJOR_MISMATCH', `hfs.json pins manifest major ${declaration.hfs} but the manifest is ${manifest.version}`, { pinned: declaration.hfs, manifest: manifest.version, manifestMajor: manifest.major, file });
|
|
281
|
+
const { profile } = declaration;
|
|
282
|
+
const bad = [];
|
|
283
|
+
const names = new Set();
|
|
284
|
+
for (const app of declaration.apps) {
|
|
285
|
+
if (names.has(app.name)) bad.push(`app ${app.name} is declared twice`);
|
|
286
|
+
names.add(app.name);
|
|
287
|
+
if (!manifest.appKinds[profile].includes(app.kind)) bad.push(`app ${app.name} has kind ${app.kind}, which is not a ${profile} kind (${manifest.appKinds[profile].join(', ')})`);
|
|
288
|
+
}
|
|
289
|
+
for (const id of declaration.optionalSlots ?? []) {
|
|
290
|
+
const slot = manifest.slots.find((s) => s.id === id);
|
|
291
|
+
if (!slot || !slot.profiles.includes(profile)) bad.push(`optionalSlots names ${id}, which is not a ${profile} slot`);
|
|
292
|
+
else if (slot.presence !== 'opt-in') bad.push(`optionalSlots names ${id}, which is ${slot.presence}, not opt-in`);
|
|
293
|
+
else if (slot.appKind !== undefined) bad.push(`optionalSlots names ${id}; an app of kind ${slot.appKind} enables it`);
|
|
294
|
+
}
|
|
295
|
+
const connections = declaration.connections ?? [];
|
|
296
|
+
for (const slot of manifest.slots) {
|
|
297
|
+
if (slot.appKind === undefined || !slot.profiles.includes(profile) || slot.presence !== 'required') continue;
|
|
298
|
+
if (slot.requiredWhen === 'connections' && !connections.length) continue;
|
|
299
|
+
if (!declaration.apps.some((a) => a.kind === slot.appKind)) bad.push(`no app of kind ${slot.appKind} is declared (${slot.id} is required${slot.requiredWhen ? ' once a connection is declared' : ''})`);
|
|
300
|
+
}
|
|
301
|
+
if (bad.length) declarationInvalid(bad, file);
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
hfs: declaration.hfs,
|
|
304
|
+
profile,
|
|
305
|
+
project: declaration.project,
|
|
306
|
+
apps: Object.freeze(declaration.apps.map((a) => Object.freeze({ name: a.name, kind: a.kind }))),
|
|
307
|
+
optionalSlots: Object.freeze([...(declaration.optionalSlots ?? [])]),
|
|
308
|
+
connections: Object.freeze([...connections]),
|
|
309
|
+
manifestVersion: manifest.version,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** hfs.json of the repository at `repoRoot`, parsed and resolved; a missing or unreadable file is a refusal, never "unavailable". */
|
|
314
|
+
export function readRepoDeclaration(manifest, repoRoot) {
|
|
315
|
+
const file = path.join(repoRoot, HFS_DECLARATION_FILE);
|
|
316
|
+
let declaration;
|
|
317
|
+
try { declaration = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { declarationInvalid([`hfs.json cannot be read (${String(error?.code ?? error?.message ?? error).split('\n')[0]})`], file); }
|
|
318
|
+
return resolveRepoDeclaration(manifest, declaration, { file });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ------------------------------------------------------------------------------------------- resolver
|
|
322
|
+
|
|
323
|
+
const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The four questions for one repository. `repo` comes from resolveRepoDeclaration / readRepoDeclaration.
|
|
327
|
+
* Paths are repository-relative (backslashes and a leading ./ are folded); a trailing / or a directory path is fine.
|
|
328
|
+
*/
|
|
329
|
+
export function createSlotResolver(manifest, repo) {
|
|
330
|
+
const profile = repo.profile;
|
|
331
|
+
const slots = manifest.slots.filter((s) => s.profiles.includes(profile));
|
|
332
|
+
const byId = new Map(slots.map((s) => [s.id, s]));
|
|
333
|
+
const variants = slots.flatMap(variantsOf);
|
|
334
|
+
const appKind = new Map(repo.apps.map((a) => [a.name, a.kind]));
|
|
335
|
+
|
|
336
|
+
const slotEnabled = (slot) => {
|
|
337
|
+
if (slot.presence !== 'opt-in') return true;
|
|
338
|
+
return slot.appKind !== undefined ? repo.apps.some((a) => a.kind === slot.appKind) : repo.optionalSlots.includes(slot.id);
|
|
339
|
+
};
|
|
340
|
+
const clean = (p) => posixPath(p).replace(/\/+$/, '');
|
|
341
|
+
|
|
342
|
+
/** Every variant matching `p`, with its root and bindings, minus app-kind slots of another kind. */
|
|
343
|
+
function matches(p, only) {
|
|
344
|
+
const found = [];
|
|
345
|
+
for (const variant of variants) {
|
|
346
|
+
if (only && !only(variant.slot)) continue;
|
|
347
|
+
const m = variant.regex.exec(p);
|
|
348
|
+
if (!m) continue;
|
|
349
|
+
const bindings = {};
|
|
350
|
+
variant.names.forEach((name, i) => { bindings[name] = m[i + 2]; });
|
|
351
|
+
if (variant.slot.appKind !== undefined && appKind.get(bindings.app) !== variant.slot.appKind) continue;
|
|
352
|
+
const root = variant.dir ? m[1] : path.posix.dirname(m[1]);
|
|
353
|
+
found.push({ variant, slot: variant.slot, root: root === '.' ? '' : root, bindings });
|
|
354
|
+
}
|
|
355
|
+
return found.sort((a, b) => b.variant.score - a.variant.score || a.variant.wildcards - b.variant.wildcards);
|
|
356
|
+
}
|
|
357
|
+
const best = (found) => {
|
|
358
|
+
if (!found.length) return { hit: null, ambiguous: [] };
|
|
359
|
+
const top = found.filter((f) => f.variant.score === found[0].variant.score && f.variant.wildcards === found[0].variant.wildcards);
|
|
360
|
+
const distinct = [...new Set(top.map((f) => f.slot.id))];
|
|
361
|
+
return distinct.length > 1 ? { hit: null, ambiguous: distinct } : { hit: top[0], ambiguous: [] };
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** The leading segments of `p` a variant accepts, and how far it got; how the nearest slot of an unknown path is found. */
|
|
365
|
+
function nearest(p) {
|
|
366
|
+
const parts = p.split('/');
|
|
367
|
+
let winner = null;
|
|
368
|
+
for (const variant of variants) {
|
|
369
|
+
let depth = 0;
|
|
370
|
+
while (depth < parts.length && depth < variant.segments.length) {
|
|
371
|
+
const rx = variant.segmentRegexes[depth];
|
|
372
|
+
if (rx === null || !rx.test(parts[depth])) break;
|
|
373
|
+
depth += 1;
|
|
374
|
+
}
|
|
375
|
+
const expected = variant.segments[depth] ?? '';
|
|
376
|
+
const distance = levenshtein(parts[depth] ?? '', expected.replace(VAR, ''));
|
|
377
|
+
const shape = Math.abs(variant.segments.length - parts.length);
|
|
378
|
+
const cand = { variant, depth, shape, distance };
|
|
379
|
+
const better = !winner || depth > winner.depth || (depth === winner.depth && (shape < winner.shape || (shape === winner.shape && (distance < winner.distance || (distance === winner.distance && variant.score > winner.variant.score)))));
|
|
380
|
+
if (better) winner = cand;
|
|
381
|
+
}
|
|
382
|
+
if (!winner) return null;
|
|
383
|
+
return {
|
|
384
|
+
slot: winner.variant.slot.id,
|
|
385
|
+
pattern: winner.variant.pattern,
|
|
386
|
+
matchedDepth: winner.depth,
|
|
387
|
+
matchedPrefix: parts.slice(0, winner.depth).join('/'),
|
|
388
|
+
expectedNext: winner.variant.segments[winner.depth] ?? null,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* status: owned | forbidden (external slot) | not-enabled (opt-in slot the repository did not declare) | ambiguous
|
|
394
|
+
* (two slots of equal specificity; a manifest gap) | no-slot (code HFS_PATH_NO_SLOT, with the nearest slot).
|
|
395
|
+
*/
|
|
396
|
+
function classifyPath(input) {
|
|
397
|
+
const p = clean(input);
|
|
398
|
+
const { hit, ambiguous } = best(matches(p));
|
|
399
|
+
if (ambiguous.length) return { path: p, status: 'ambiguous', candidates: ambiguous };
|
|
400
|
+
if (!hit) return { path: p, status: 'no-slot', code: 'HFS_PATH_NO_SLOT', nearest: nearest(p) };
|
|
401
|
+
const { slot, root, bindings } = hit;
|
|
402
|
+
const status = slot.presence === 'forbidden' ? 'forbidden' : (slotEnabled(slot) ? 'owned' : 'not-enabled');
|
|
403
|
+
return { path: p, status, slot: slot.id, root, bindings, presence: slot.presence, tracking: slot.tracked, ...(status === 'forbidden' ? { goesTo: slot.goesTo } : {}) };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const slotOf = (p) => { const c = classifyPath(p); return c.slot ? byId.get(c.slot) : null; };
|
|
407
|
+
|
|
408
|
+
/** The owner unit of `p`: the most specific owner slot instance containing it (a slot's own root when it owns nothing). */
|
|
409
|
+
function ownerOf(input) {
|
|
410
|
+
const p = clean(input);
|
|
411
|
+
const { hit } = best(matches(p, (s) => s.owner === true));
|
|
412
|
+
return hit ? { slot: hit.slot.id, root: hit.root, bindings: hit.bindings } : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 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. */
|
|
416
|
+
function tierOf(input) {
|
|
417
|
+
const c = classifyPath(input);
|
|
418
|
+
if (!c.slot) return null;
|
|
419
|
+
const tier = byId.get(c.slot).tier;
|
|
420
|
+
if (tier !== 'inherit') return tier;
|
|
421
|
+
const owner = ownerOf(input);
|
|
422
|
+
return owner ? byId.get(owner.slot).tier : 'none';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const layerIndex = (slot, root) => (slot.layers ? slot.layers.findIndex((layer) => root.split('/').includes(layer)) : -1);
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
|
|
429
|
+
* tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_PATH_NO_SLOT for the path no slot owns).
|
|
430
|
+
* Cycles are a graph property and belong to the architecture check, not to one edge.
|
|
431
|
+
*/
|
|
432
|
+
function importAllowed(fromPath, toPath) {
|
|
433
|
+
const from = classifyPath(fromPath);
|
|
434
|
+
const to = classifyPath(toPath);
|
|
435
|
+
for (const side of [from, to]) if (side.status === 'no-slot') return { allowed: false, reason: 'unowned', code: side.code, path: side.path, nearest: side.nearest };
|
|
436
|
+
for (const side of [from, to]) if (side.status !== 'owned') return { allowed: false, reason: `slot${side.status[0].toUpperCase()}${side.status.slice(1).replace(/-(.)/g, (_, c) => c.toUpperCase())}`, path: side.path, slot: side.slot };
|
|
437
|
+
const fromTier = tierOf(from.path);
|
|
438
|
+
const toTier = tierOf(to.path);
|
|
439
|
+
if (fromTier === 'none' || toTier === 'none') return { allowed: true, reason: 'untiered' };
|
|
440
|
+
if (from.bindings.app !== undefined && to.bindings.app !== undefined && from.bindings.app !== to.bindings.app)
|
|
441
|
+
return { allowed: false, reason: 'crossApp', from: from.bindings.app, to: to.bindings.app };
|
|
442
|
+
const fromOwner = ownerOf(from.path);
|
|
443
|
+
const toOwner = ownerOf(to.path);
|
|
444
|
+
const unit = (side, owner) => (owner ? `${owner.slot}:${owner.root}` : `${side.slot}:${side.root}`);
|
|
445
|
+
if (unit(from, fromOwner) === unit(to, toOwner)) return { allowed: true, reason: 'sameOwner' };
|
|
446
|
+
if (!manifest.tiers[profile][fromTier]?.mayImport.includes(toTier)) return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: manifest.tiers[profile][fromTier]?.mayImport ?? [] };
|
|
447
|
+
if (manifest.tiers[profile][fromTier].lowerLayerOnly && fromTier === toTier) {
|
|
448
|
+
const fromSlot = byId.get((fromOwner ?? from).slot);
|
|
449
|
+
const a = layerIndex(fromSlot, fromOwner?.root ?? from.root);
|
|
450
|
+
const b = layerIndex(byId.get((toOwner ?? to).slot), toOwner?.root ?? to.root);
|
|
451
|
+
if (a >= 0 && b >= 0 && b <= a) return { allowed: false, reason: 'layerOrder', fromLayer: fromSlot.layers[a], toLayer: fromSlot.layers[b] };
|
|
452
|
+
}
|
|
453
|
+
if (toOwner) {
|
|
454
|
+
const relative = to.path === toOwner.root ? '' : to.path.slice(toOwner.root.length + 1);
|
|
455
|
+
const ownerTier = byId.get(toOwner.slot).tier;
|
|
456
|
+
const entry = isEntryFile(relative) || (ownerTier === 'package' && relative === 'src/index.ts') || (ownerTier === 'app' && relative === 'app.module.ts');
|
|
457
|
+
if (!entry) return { allowed: false, reason: 'notPublicEntry', owner: toOwner.root, path: to.path };
|
|
458
|
+
}
|
|
459
|
+
return { allowed: true, reason: 'allowed', fromTier, toTier };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const fillVars = (text, bindings) => String(text).replace(VAR, (whole, name) => bindings[name] ?? whole);
|
|
463
|
+
|
|
464
|
+
/** The files and directories the instance holding `p` must contain (directories end with /); {path} entries are repo-relative. */
|
|
465
|
+
function requiredFiles(input) {
|
|
466
|
+
const c = classifyPath(input);
|
|
467
|
+
if (!c.slot) return [];
|
|
468
|
+
const slot = byId.get(c.slot);
|
|
469
|
+
return (slot.requires ?? []).map((entry) => {
|
|
470
|
+
const rooted = entry.startsWith('/');
|
|
471
|
+
const filled = fillVars(rooted ? entry.slice(1) : entry, c.bindings);
|
|
472
|
+
return rooted || !c.root ? filled : `${c.root}/${filled}`;
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* What the repository must contain, without walking it: {paths: [{slot, path, via}], minimums: [{slot, min}]}.
|
|
478
|
+
* via is slot (a fixed path), instance (a requiredInstances or app-kind root) or requires (a file an instance needs).
|
|
479
|
+
*/
|
|
480
|
+
function requiredPaths() {
|
|
481
|
+
const paths = [];
|
|
482
|
+
const minimums = [];
|
|
483
|
+
const expandApps = (slot) => repo.apps.filter((a) => slot.appKind === undefined || a.kind === slot.appKind);
|
|
484
|
+
for (const slot of slots) {
|
|
485
|
+
if (slot.presence !== 'required') continue;
|
|
486
|
+
if (slot.requiredWhen === 'connections' && !repo.connections.length) continue;
|
|
487
|
+
if (slot.minInstances) minimums.push({ slot: slot.id, min: slot.minInstances, ...(slot.appKind ? { appKind: slot.appKind } : {}) });
|
|
488
|
+
for (const variant of braceVariants(slot.path)) {
|
|
489
|
+
const names = [...new Set(varsOf(variant))];
|
|
490
|
+
const fixed = { ...(slot.requiredInstances ?? {}) };
|
|
491
|
+
const open = names.filter((n) => n !== 'app' && !(n in fixed));
|
|
492
|
+
if (open.length) continue; // an instance-level slot: its instances are found by walking the tree
|
|
493
|
+
const combos = [{}];
|
|
494
|
+
const grow = (name, values) => { const next = []; for (const c of combos) for (const v of values) next.push({ ...c, [name]: v }); combos.splice(0, combos.length, ...next); };
|
|
495
|
+
if (names.includes('app')) grow('app', expandApps(slot).map((a) => a.name));
|
|
496
|
+
for (const [name, values] of Object.entries(fixed)) grow(name, values);
|
|
497
|
+
const isInstance = names.length > 0;
|
|
498
|
+
for (const bindings of combos) {
|
|
499
|
+
const target = fillVars(variant, bindings);
|
|
500
|
+
paths.push({ slot: slot.id, path: target, via: isInstance ? 'instance' : 'slot' });
|
|
501
|
+
const root = target.endsWith('/') ? target.slice(0, -1) : path.posix.dirname(target);
|
|
502
|
+
for (const entry of slot.requires ?? []) {
|
|
503
|
+
const rooted = entry.startsWith('/');
|
|
504
|
+
const filled = fillVars(rooted ? entry.slice(1) : entry, bindings);
|
|
505
|
+
paths.push({ slot: slot.id, path: rooted || !root || root === '.' ? filled : `${root}/${filled}`, via: 'requires' });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const seen = new Set();
|
|
511
|
+
return { paths: paths.filter((p) => { const key = `${p.slot}|${p.path}`; if (seen.has(key)) return false; seen.add(key); return true; }), minimums };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** tracked | ignored | external for the slot owning `p`, or null when no slot owns it. */
|
|
515
|
+
const trackingOf = (p) => classifyPath(p).tracking ?? null;
|
|
516
|
+
/** True only for a path a slot owns and requires to be committed. */
|
|
517
|
+
const isTracked = (p) => trackingOf(p) === 'tracked';
|
|
518
|
+
|
|
519
|
+
return Object.freeze({
|
|
520
|
+
repo,
|
|
521
|
+
slot: (id) => byId.get(id) ?? null,
|
|
522
|
+
slots: () => slots,
|
|
523
|
+
slotEnabled,
|
|
524
|
+
classifyPath,
|
|
525
|
+
slotOf,
|
|
526
|
+
ownerOf,
|
|
527
|
+
tierOf,
|
|
528
|
+
importAllowed,
|
|
529
|
+
requiredFiles,
|
|
530
|
+
requiredPaths,
|
|
531
|
+
trackingOf,
|
|
532
|
+
isTracked,
|
|
533
|
+
ruleParams: () => ruleParams(manifest, profile),
|
|
534
|
+
allowedImports: (tier) => manifest.tiers[profile][tier]?.mayImport ?? null,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** The rule parameters of one profile (be: globalModules, fileLines, duplicateBlockLines; fe: fileLines, clientModule, duplicateBlockLines), as a frozen deep copy. */
|
|
539
|
+
export function ruleParams(manifest, profile) {
|
|
540
|
+
if (!PROFILES.includes(profile)) fail('HFS_MANIFEST_INVALID', `ruleParams has no profile ${profile}`, { profile });
|
|
541
|
+
const deepFreeze = (v) => { if (v && typeof v === 'object') Object.values(v).forEach(deepFreeze); return Object.freeze(v); };
|
|
542
|
+
return deepFreeze(structuredClone(manifest.ruleParams[profile]));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** The manifest of this runtime plus the resolver for the repository at `repoRoot` (or for an already-parsed declaration). */
|
|
546
|
+
export function openHfs({ root = skillRoot, repoRoot, declaration, manifest = loadSlotManifest({ root }) } = {}) {
|
|
547
|
+
const repo = declaration !== undefined ? resolveRepoDeclaration(manifest, declaration) : readRepoDeclaration(manifest, repoRoot);
|
|
548
|
+
return { manifest, repo, ...createSlotResolver(manifest, repo), rules: () => loadRuleCatalog({ root, manifest }) };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ------------------------------------------------------------------------------------------ rule catalog
|
|
552
|
+
|
|
553
|
+
export const HFS_RULES_FILE = 'knowledge/hfs/rules.yaml';
|
|
554
|
+
export const RULE_GATES = Object.freeze(['pre-commit', 'pre-push', 'settle', 'land', 'ci', 'sonar']);
|
|
555
|
+
export const ENFORCER_FAMILIES = Object.freeze(['eslint-be', 'eslint-fe', 'stylelint', 'machine', 'hfs', 'work-validate', 'sonar']);
|
|
556
|
+
export const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
557
|
+
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
558
|
+
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
559
|
+
const FILE_ENFORCERS = ['machine', 'hfs', 'work-validate', 'sonar'];
|
|
560
|
+
|
|
561
|
+
/** Shape and semantic problems of a parsed knowledge/hfs/rules.yaml, in the words of modules/schemas/hfs-rules.schema.yaml. */
|
|
562
|
+
function ruleCatalogProblems(d) {
|
|
563
|
+
const bad = [];
|
|
564
|
+
if (!isMap(d)) return ['the rule catalog is not a map'];
|
|
565
|
+
for (const key of Object.keys(d)) if (!['schema', 'version', 'gates', 'enforcerKinds', 'rules'].includes(key)) bad.push(`unknown top-level key ${key}`);
|
|
566
|
+
const schemaOk = /^starci\/hfs-rules@\d+$/.test(String(d.schema));
|
|
567
|
+
if (!schemaOk) bad.push('schema must be starci/hfs-rules@<major>');
|
|
568
|
+
if (!SEMVER.test(String(d.version))) bad.push('version must be MAJOR.MINOR.PATCH');
|
|
569
|
+
else if (schemaOk && d.schema.split('@')[1] !== d.version.split('.')[0]) bad.push('the major of version must equal the number after @ in schema');
|
|
570
|
+
const vocabulary = (key, names) => {
|
|
571
|
+
if (!isMap(d[key])) { bad.push(`${key} must be a map`); return; }
|
|
572
|
+
if (JSON.stringify(Object.keys(d[key])) !== JSON.stringify(names)) bad.push(`${key} must list exactly ${names.join(', ')} in that order`);
|
|
573
|
+
for (const [name, text] of Object.entries(d[key])) if (typeof text !== 'string' || !text.trim()) bad.push(`${key}.${name} needs a description`);
|
|
574
|
+
};
|
|
575
|
+
vocabulary('gates', RULE_GATES);
|
|
576
|
+
vocabulary('enforcerKinds', ENFORCER_FAMILIES);
|
|
577
|
+
if (!Array.isArray(d.rules) || !d.rules.length) { bad.push('rules must be a non-empty list'); return bad; }
|
|
578
|
+
const codeOwner = new Map();
|
|
579
|
+
d.rules.forEach((r, index) => {
|
|
580
|
+
const at = `rules[${index}]`;
|
|
581
|
+
if (!isMap(r)) { bad.push(`${at} is not a map`); return; }
|
|
582
|
+
const label = typeof r.id === 'string' ? r.id : at;
|
|
583
|
+
for (const key of Object.keys(r)) if (!['id', 'code', 'title_vi', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
|
|
584
|
+
const expectedId = `R${String(index + 1).padStart(2, '0')}`;
|
|
585
|
+
if (!/^R\d{2}$/.test(String(r.id))) bad.push(`${at}.id must be R<two digits>`);
|
|
586
|
+
else if (r.id !== expectedId) bad.push(`${label} is out of order: ${at} must be ${expectedId}`);
|
|
587
|
+
if (!FINDING_CODE.test(String(r.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
|
|
588
|
+
for (const key of ['title_vi', 'law']) if (typeof r[key] !== 'string' || !r[key].trim()) bad.push(`${label}.${key} is missing`);
|
|
589
|
+
if (typeof r.law === 'string' && r.law.includes('\n')) bad.push(`${label}.law must be one line`);
|
|
590
|
+
const enumList = (key, allowed) => {
|
|
591
|
+
if (!Array.isArray(r[key]) || !r[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
|
|
592
|
+
for (const v of r[key]) if (!allowed.includes(v)) bad.push(`${label}.${key} has ${JSON.stringify(v)}, not one of ${allowed.join(', ')}`);
|
|
593
|
+
if (new Set(r[key]).size !== r[key].length) bad.push(`${label}.${key} repeats a value`);
|
|
594
|
+
return r[key];
|
|
595
|
+
};
|
|
596
|
+
enumList('kinds', RULE_KINDS);
|
|
597
|
+
const gates = enumList('gates', RULE_GATES);
|
|
598
|
+
if (gates.length) {
|
|
599
|
+
if (!gates.includes('land')) bad.push(`${label} must run at the land gate (every rule does)`);
|
|
600
|
+
if (gates.includes('pre-commit') && !gates.includes('pre-push')) bad.push(`${label} runs at pre-commit, so it also runs at pre-push`);
|
|
601
|
+
}
|
|
602
|
+
if (!Array.isArray(r.failureCodes) || !r.failureCodes.length || !r.failureCodes.every((c) => FINDING_CODE.test(String(c)))) bad.push(`${label}.failureCodes must be a non-empty list of UPPER_SNAKE codes`);
|
|
603
|
+
else {
|
|
604
|
+
if (r.failureCodes[0] !== r.code) bad.push(`${label}.failureCodes must start with the rule's own code ${r.code}`);
|
|
605
|
+
if (new Set(r.failureCodes).size !== r.failureCodes.length) bad.push(`${label}.failureCodes repeats a code`);
|
|
606
|
+
for (const c of r.failureCodes) {
|
|
607
|
+
if (codeOwner.has(c) && codeOwner.get(c) !== label) bad.push(`${label} names ${c}, which ${codeOwner.get(c)} already owns`);
|
|
608
|
+
codeOwner.set(c, label);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!Array.isArray(r.enforcers) || !r.enforcers.length) { bad.push(`${label}.enforcers must name at least one enforcer`); return; }
|
|
612
|
+
const seen = new Set();
|
|
613
|
+
r.enforcers.forEach((e, n) => {
|
|
614
|
+
const eat = `${label}.enforcers[${n}]`;
|
|
615
|
+
if (!isMap(e)) { bad.push(`${eat} is not a map`); return; }
|
|
616
|
+
for (const key of Object.keys(e)) if (!['kind', 'id', 'status', 'at'].includes(key)) bad.push(`${eat} has unknown key ${key}`);
|
|
617
|
+
if (!ENFORCER_FAMILIES.includes(e.kind)) bad.push(`${eat}.kind must be one of ${ENFORCER_FAMILIES.join(', ')}`);
|
|
618
|
+
if (!ENFORCER_ID.test(String(e.id))) bad.push(`${eat}.id must be kebab-case`);
|
|
619
|
+
if (seen.has(`${e.kind}:${e.id}`)) bad.push(`${eat} repeats ${e.kind}:${e.id}`);
|
|
620
|
+
seen.add(`${e.kind}:${e.id}`);
|
|
621
|
+
if (e.status !== undefined && e.status !== 'planned') bad.push(`${eat}.status is either absent or planned`);
|
|
622
|
+
if (e.at !== undefined) {
|
|
623
|
+
if (typeof e.at !== 'string' || !e.at.trim() || e.at.startsWith('/') || e.at.includes('..')) bad.push(`${eat}.at must be a repository-relative path`);
|
|
624
|
+
if (e.status === 'planned') bad.push(`${eat} is planned, so it has no file yet (at)`);
|
|
625
|
+
if (!FILE_ENFORCERS.includes(e.kind)) bad.push(`${eat}.at belongs to a machine, hfs, work-validate or sonar enforcer only`);
|
|
626
|
+
} else if (FILE_ENFORCERS.includes(e.kind) && e.status !== 'planned') bad.push(`${eat} exists, so it names the file (at) that emits its code`);
|
|
627
|
+
});
|
|
628
|
+
if (Array.isArray(r.gates)) {
|
|
629
|
+
const hasSonar = r.enforcers.some((e) => isMap(e) && e.kind === 'sonar');
|
|
630
|
+
if (r.gates.includes('sonar') !== hasSonar) bad.push(`${label}: the sonar gate and a sonar enforcer go together`);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
return bad;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const deepFreeze = (v) => { if (v && typeof v === 'object') Object.values(v).forEach(deepFreeze); return Object.freeze(v); };
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The parsed and validated HFS rule catalog. `text` (or `file`, or `root`) selects the source; the default is the runtime's
|
|
640
|
+
* own knowledge/hfs/rules.yaml. A catalog that breaks its shape or a semantic rule is refused whole (HFS_RULES_INVALID), and
|
|
641
|
+
* so is one whose major differs from the slot manifest passed as `manifest` (HFS_MANIFEST_MAJOR_MISMATCH).
|
|
642
|
+
* Answers: rule(id), byCode(code), forGate(gate), forEnforcer(kind, id), planned() and unbuilt().
|
|
643
|
+
*/
|
|
644
|
+
export function loadRuleCatalog({ root = skillRoot, file = path.join(root, HFS_RULES_FILE), text, manifest } = {}) {
|
|
645
|
+
let doc;
|
|
646
|
+
try { doc = parseYaml(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 }); }
|
|
647
|
+
const problems = ruleCatalogProblems(doc);
|
|
648
|
+
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 });
|
|
649
|
+
const [major, minor, patch] = doc.version.split('.').map(Number);
|
|
650
|
+
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 });
|
|
651
|
+
const list = deepFreeze(doc.rules.map((r) => ({ ...r, enforcers: r.enforcers.map((e) => ({ ...e, planned: e.status === 'planned' })) })));
|
|
652
|
+
const byId = new Map(list.map((r) => [r.id, r]));
|
|
653
|
+
const byCode = new Map(list.flatMap((r) => r.failureCodes.map((c) => [c, r])));
|
|
654
|
+
return Object.freeze({
|
|
655
|
+
version: doc.version, major, minor, patch,
|
|
656
|
+
gates: deepFreeze(structuredClone(doc.gates)),
|
|
657
|
+
enforcerKinds: deepFreeze(structuredClone(doc.enforcerKinds)),
|
|
658
|
+
rules: list,
|
|
659
|
+
/** The rule with this id (R01..), or null. */
|
|
660
|
+
rule: (id) => byId.get(id) ?? null,
|
|
661
|
+
/** The rule that owns this failure code (its own or a sub-check code), or null. */
|
|
662
|
+
byCode: (code) => byCode.get(code) ?? null,
|
|
663
|
+
/** The rules that run at a gate. */
|
|
664
|
+
forGate: (gate) => list.filter((r) => r.gates.includes(gate)),
|
|
665
|
+
/** The rules one enforcer judges, e.g. forEnforcer('eslint-be', 'error-home'). */
|
|
666
|
+
forEnforcer: (kind, id) => list.filter((r) => r.enforcers.some((e) => e.kind === kind && e.id === id)),
|
|
667
|
+
/** Every enforcer still owed, as {rule, kind, id}. */
|
|
668
|
+
planned: () => list.flatMap((r) => r.enforcers.filter((e) => e.planned).map((e) => ({ rule: r.id, kind: e.kind, id: e.id }))),
|
|
669
|
+
/** The rules with no existing enforcer at all. */
|
|
670
|
+
unbuilt: () => list.filter((r) => r.enforcers.every((e) => e.planned)),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The 77 rules of this runtime's catalog, frozen, in id order. */
|
|
675
|
+
export const rules = (options) => loadRuleCatalog(options).rules;
|