@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.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/why.js +15 -0
- package/dist/mascot.d.ts +28 -0
- package/dist/mascot.js +166 -0
- package/dist/surface-depth.d.ts +7 -0
- package/dist/surface-depth.js +92 -0
- package/docs/mascot.md +60 -0
- package/docs/surface-depth.md +40 -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 +95 -6
- package/runtime/knowledge/hfs/slots.yaml +2 -2
- package/runtime/knowledge/patterns/fe/ui.yaml +13 -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
|
@@ -3,89 +3,96 @@
|
|
|
3
3
|
import { RUNTIME_KIND } from './manifest-shape.mjs';
|
|
4
4
|
import { classifyIn, ownerIn, tierIn } from './slot-classify.mjs';
|
|
5
5
|
import { braceVariants, globExpression } from '../lib/glob.mjs';
|
|
6
|
-
|
|
7
6
|
const isEntryFile = (name) => name === 'index.ts' || name === 'index.tsx';
|
|
8
|
-
|
|
9
7
|
function declaredEntry(slot, relative) {
|
|
10
|
-
|
|
8
|
+
return (slot.entries ?? []).some((entry) => braceVariants(entry).some((pattern) => globExpression(pattern).test(relative)));
|
|
11
9
|
}
|
|
12
|
-
|
|
13
10
|
function unownedPathProblem(from, to) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
for (const side of [from, to]) {
|
|
12
|
+
if (side.status === 'no-slot')
|
|
13
|
+
return { allowed: false, reason: 'unowned', code: side.code, path: side.path, nearest: side.nearest };
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
18
16
|
}
|
|
19
|
-
|
|
20
17
|
function unavailableSlotProblem(from, to) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
for (const side of [from, to]) {
|
|
19
|
+
if (side.status !== 'owned')
|
|
20
|
+
return { allowed: false, reason: `slot${side.status[0].toUpperCase()}${side.status.slice(1).replace(/-(.)/g, (_, c) => c.toUpperCase())}`, path: side.path, slot: side.slot };
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
25
23
|
}
|
|
26
|
-
|
|
27
24
|
function sameOwnerUnit(side, owner) {
|
|
28
|
-
|
|
25
|
+
return owner ? `${owner.slot}:${owner.root}` : `${side.slot}:${side.root}`;
|
|
29
26
|
}
|
|
30
|
-
|
|
31
27
|
const layerIndex = (slot, root) => (slot.layers ? slot.layers.findIndex((layer) => root.split('/').includes(layer)) : -1);
|
|
32
|
-
|
|
33
28
|
function lowerLayerProblem({ manifest, profile, byId }, from, to, fromTier, toTier, fromOwner, toOwner) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
29
|
+
if (manifest.tiers[profile][fromTier].lowerLayerOnly && fromTier === toTier) {
|
|
30
|
+
const fromSlot = byId.get((fromOwner ?? from).slot);
|
|
31
|
+
const a = layerIndex(fromSlot, fromOwner?.root ?? from.root);
|
|
32
|
+
const b = layerIndex(byId.get((toOwner ?? to).slot), toOwner?.root ?? to.root);
|
|
33
|
+
if (a >= 0 && b >= 0 && b <= a)
|
|
34
|
+
return { allowed: false, reason: 'layerOrder', fromLayer: fromSlot.layers[a], toLayer: fromSlot.layers[b] };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
41
37
|
}
|
|
42
|
-
|
|
43
38
|
function privateEntryProblem({ repo, byId }, to, toOwner) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
if (toOwner && repo.kind !== RUNTIME_KIND) {
|
|
40
|
+
const relative = to.path === toOwner.root ? '' : to.path.slice(toOwner.root.length + 1);
|
|
41
|
+
const ownerSlot = byId.get(toOwner.slot);
|
|
42
|
+
const entry = isEntryFile(relative) || declaredEntry(byId.get(to.slot), to.path.slice(to.root.length + 1)) || (ownerSlot.tier === 'package' && relative === 'src/index.ts') || (ownerSlot.tier === 'app' && relative === 'app.module.ts');
|
|
43
|
+
if (!entry)
|
|
44
|
+
return { allowed: false, reason: 'notPublicEntry', owner: toOwner.root, path: to.path };
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
51
47
|
}
|
|
52
|
-
|
|
53
48
|
function tierImportProblem(scope, from, to, fromTier, toTier) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
49
|
+
const { mayImport, mayImportKinds, mayImportSlots } = scope.manifest.tiers[scope.profile][fromTier] ?? {};
|
|
50
|
+
if (!mayImport?.includes(toTier))
|
|
51
|
+
return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport: mayImport ?? [] };
|
|
52
|
+
const kindRestriction = mayImportKinds?.[toTier];
|
|
53
|
+
if (kindRestriction && (!kindRestriction.from.includes(from.kind) || !kindRestriction.to.includes(to.kind)))
|
|
54
|
+
return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, kindRestriction, fromKind: from.kind ?? null, toKind: to.kind ?? null };
|
|
55
|
+
const slotRestriction = mayImportSlots?.[toTier];
|
|
56
|
+
if (slotRestriction && !slotRestriction.from.includes(from.slot))
|
|
57
|
+
return { allowed: false, reason: 'tierDirection', fromTier, toTier, mayImport, slotRestriction, fromSlot: from.slot };
|
|
58
|
+
return null;
|
|
61
59
|
}
|
|
62
|
-
|
|
63
60
|
/**
|
|
64
61
|
* Whether `fromPath` may import `toPath`: {allowed, reason, ...}. Reasons: sameOwner, untiered, crossApp,
|
|
65
62
|
* tierDirection, layerOrder, notPublicEntry, allowed, slotForbidden, slotNotEnabled, slotAmbiguous, and unowned (HFS_SLOT_UNDECLARED for the path no slot owns).
|
|
66
63
|
* Cycles are a graph property and belong to the architecture check, not to one edge.
|
|
67
64
|
*/
|
|
68
65
|
export function importAllowedIn(scope, fromPath, toPath) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
66
|
+
const fromClassified = classifyIn(scope, fromPath);
|
|
67
|
+
const toClassified = classifyIn(scope, toPath);
|
|
68
|
+
const unowned = unownedPathProblem(fromClassified, toClassified);
|
|
69
|
+
if (unowned)
|
|
70
|
+
return unowned;
|
|
71
|
+
const unavailable = unavailableSlotProblem(fromClassified, toClassified);
|
|
72
|
+
if (unavailable)
|
|
73
|
+
return unavailable;
|
|
74
|
+
// reason: unavailableSlotProblem answers for every status but owned, so both paths are placed from here on
|
|
75
|
+
const from = fromClassified;
|
|
76
|
+
const to = toClassified;
|
|
77
|
+
const fromTier = tierIn(scope, from.path);
|
|
78
|
+
const toTier = tierIn(scope, to.path);
|
|
79
|
+
if (fromTier === 'none' || toTier === 'none')
|
|
80
|
+
return { allowed: true, reason: 'untiered' };
|
|
81
|
+
if (from.bindings.app !== undefined && to.bindings.app !== undefined && from.bindings.app !== to.bindings.app)
|
|
82
|
+
return { allowed: false, reason: 'crossApp', from: from.bindings.app, to: to.bindings.app };
|
|
83
|
+
const fromOwner = ownerIn(scope, from.path);
|
|
84
|
+
const toOwner = ownerIn(scope, to.path);
|
|
85
|
+
if (sameOwnerUnit(from, fromOwner) === sameOwnerUnit(to, toOwner))
|
|
86
|
+
return { allowed: true, reason: 'sameOwner' };
|
|
87
|
+
const tierProblem = tierImportProblem(scope, from, to, fromTier, toTier);
|
|
88
|
+
if (tierProblem)
|
|
89
|
+
return tierProblem;
|
|
90
|
+
const layerProblem = lowerLayerProblem(scope, from, to, fromTier, toTier, fromOwner, toOwner);
|
|
91
|
+
if (layerProblem)
|
|
92
|
+
return layerProblem;
|
|
93
|
+
// A runtime owner is imported file by file (crossOwner of knowledge/hfs/runtime-slots.yaml): no public entry.
|
|
94
|
+
const privateEntry = privateEntryProblem(scope, to, toOwner);
|
|
95
|
+
if (privateEntry)
|
|
96
|
+
return privateEntry;
|
|
97
|
+
return { allowed: true, reason: 'allowed', fromTier, toTier };
|
|
91
98
|
}
|
|
@@ -1,74 +1,96 @@
|
|
|
1
|
-
// slot-manifest-shape.mjs - the product slot manifest's schema-shaped validation.
|
|
2
1
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
3
2
|
import { byCodeUnit } from '../lib/list.mjs';
|
|
4
3
|
import { ruleParamsProblems } from './rule-params-shape.mjs';
|
|
5
4
|
import { APP_KIND, EDITIONS, MANIFEST_KINDS, NAME, PRESENCE, RUNTIME_KIND, SEMVER, TESTS, TRACKED, manifestKind, runtimeShapeProblems, slotProblems, tierMapProblems } from './manifest-shape.mjs';
|
|
6
|
-
|
|
7
5
|
export const PROFILES = ['be', 'fe'];
|
|
6
|
+
/** Whether `value` names a side of an app. */
|
|
7
|
+
export const isSide = (value) => PROFILES.includes(value);
|
|
8
8
|
export const APP_SCOPE = 'app';
|
|
9
9
|
export const SCOPES = [APP_SCOPE, ...PROFILES];
|
|
10
|
-
|
|
11
10
|
/** Shape problems of a parsed manifest, in the words of modules/schemas/hfs-slots.schema.yaml. */
|
|
12
11
|
function checkCoreManifestFields(m, bad) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
const allowed = new Set(['schema', 'kind', 'version', 'versioning', 'presenceValues', 'trackedValues', 'testValues', 'editions', 'sides', 'appKinds', 'triggerKinds', 'tiers', 'ruleParams', 'crossOwner', 'crossApp', 'slots', 'consumers', 'naming']);
|
|
13
|
+
for (const key of Object.keys(m))
|
|
14
|
+
if (!allowed.has(key))
|
|
15
|
+
bad.push(`unknown top-level key ${key}`);
|
|
16
|
+
if (!/^starci\/hfs-slots@\d+$/.test(String(m.schema)))
|
|
17
|
+
bad.push('schema must be starci/hfs-slots@<major>');
|
|
18
|
+
if (!SEMVER.test(String(m.version)))
|
|
19
|
+
bad.push('version must be MAJOR.MINOR.PATCH');
|
|
20
|
+
if (!isPlainObject(m.versioning) || !['patch', 'minor', 'major', 'pins'].every((k) => typeof m.versioning[k] === 'string'))
|
|
21
|
+
bad.push('versioning needs patch, minor, major and pins text');
|
|
22
|
+
if (JSON.stringify(m.presenceValues) !== JSON.stringify(PRESENCE))
|
|
23
|
+
bad.push(`presenceValues must be ${PRESENCE.join(', ')}`);
|
|
24
|
+
if (JSON.stringify(m.trackedValues) !== JSON.stringify(TRACKED))
|
|
25
|
+
bad.push(`trackedValues must be ${TRACKED.join(', ')}`);
|
|
26
|
+
if (JSON.stringify(m.testValues) !== JSON.stringify(TESTS))
|
|
27
|
+
bad.push(`testValues must be ${TESTS.join(', ')}`);
|
|
28
|
+
if (JSON.stringify(m.editions) !== JSON.stringify(EDITIONS))
|
|
29
|
+
bad.push(`editions must be ${EDITIONS.join(', ')}`);
|
|
22
30
|
}
|
|
23
|
-
|
|
24
31
|
function checkSideShapes(m, bad) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
if (!isPlainObject(m.sides) || Object.keys(m.sides).sort(byCodeUnit).join() !== PROFILES.join())
|
|
33
|
+
bad.push('sides must be a map with exactly be and fe');
|
|
34
|
+
else
|
|
35
|
+
for (const side of PROFILES) {
|
|
36
|
+
const def = m.sides[side];
|
|
37
|
+
if (!isPlainObject(def) || Object.keys(def).join() !== 'reads' || !Array.isArray(def.reads) || !def.reads.every((r) => typeof r === 'string' && /^[a-z][a-z0-9-]*\/([^/]+\/)+$/.test(r)) || new Set(def.reads).size !== def.reads.length)
|
|
38
|
+
bad.push(`sides.${side} must be {reads: [unique <owner>/<dir>/ paths]}`);
|
|
39
|
+
}
|
|
30
40
|
}
|
|
31
|
-
|
|
32
41
|
function checkTriggerKinds(m, bad) {
|
|
33
|
-
|
|
42
|
+
if (m.triggerKinds !== undefined && (!Array.isArray(m.triggerKinds) || !m.triggerKinds.length || !m.triggerKinds.every((k) => NAME.test(String(k))) || new Set(m.triggerKinds).size !== m.triggerKinds.length))
|
|
43
|
+
bad.push('triggerKinds must be a non-empty list of unique names');
|
|
34
44
|
}
|
|
35
|
-
|
|
36
45
|
function checkProfileMaps(m, bad) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
for (const key of ['appKinds', 'tiers']) {
|
|
47
|
+
if (!isPlainObject(m[key])) {
|
|
48
|
+
bad.push(`${key} must be a map with be and fe`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const extra of Object.keys(m[key]))
|
|
52
|
+
if (!isSide(extra))
|
|
53
|
+
bad.push(`${key}.${extra} is not a profile`);
|
|
54
|
+
for (const profile of PROFILES)
|
|
55
|
+
if (!(profile in m[key]))
|
|
56
|
+
bad.push(`${key}.${profile} is missing`);
|
|
57
|
+
}
|
|
42
58
|
}
|
|
43
|
-
|
|
44
59
|
function checkAppKindAndTierShapes(m, bad) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
for (const profile of PROFILES) {
|
|
61
|
+
const kinds = m.appKinds?.[profile];
|
|
62
|
+
if (kinds !== undefined && (!Array.isArray(kinds) || !kinds.length || !kinds.every((k) => NAME.test(String(k))) || new Set(kinds).size !== kinds.length))
|
|
63
|
+
bad.push(`appKinds.${profile} must be a non-empty list of unique names`);
|
|
64
|
+
const tiers = m.tiers?.[profile];
|
|
65
|
+
if (tiers === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
bad.push(...tierMapProblems(profile, tiers));
|
|
68
|
+
}
|
|
52
69
|
}
|
|
53
|
-
|
|
54
70
|
function checkSlotShapes(m, bad) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
if (!Array.isArray(m.slots) || !m.slots.length) {
|
|
72
|
+
bad.push('slots must be a non-empty list');
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const logicRoles = Array.isArray(m.ruleParams?.be?.logicRoles) ? m.ruleParams.be.logicRoles : [];
|
|
76
|
+
m.slots.forEach((slot, index) => bad.push(...slotProblems(slot, index, APP_KIND, { appScope: APP_SCOPE, scopes: SCOPES, logicRoles })));
|
|
77
|
+
return true;
|
|
59
78
|
}
|
|
60
|
-
|
|
61
79
|
export function manifestShapeProblems(m) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
const bad = [];
|
|
81
|
+
if (!isPlainObject(m))
|
|
82
|
+
return ['the manifest is not a map'];
|
|
83
|
+
if (!MANIFEST_KINDS.includes(manifestKind(m)))
|
|
84
|
+
return [`kind must be one of ${MANIFEST_KINDS.join(', ')}`];
|
|
85
|
+
if (manifestKind(m) === RUNTIME_KIND)
|
|
86
|
+
return runtimeShapeProblems(m);
|
|
87
|
+
checkCoreManifestFields(m, bad);
|
|
88
|
+
checkSideShapes(m, bad);
|
|
89
|
+
checkTriggerKinds(m, bad);
|
|
90
|
+
checkProfileMaps(m, bad);
|
|
91
|
+
checkAppKindAndTierShapes(m, bad);
|
|
92
|
+
bad.push(...ruleParamsProblems(m));
|
|
93
|
+
if (!checkSlotShapes(m, bad))
|
|
94
|
+
return bad;
|
|
95
|
+
return bad;
|
|
74
96
|
}
|
|
@@ -3,151 +3,162 @@
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { braceVariants } from '../lib/glob.mjs';
|
|
5
5
|
import { captureNames } from '../lib/i18n.mjs';
|
|
6
|
-
|
|
7
6
|
const VAR = /<([A-Za-z][A-Za-z0-9-]*)>/g;
|
|
8
7
|
export const varsOf = (text) => captureNames(text, VAR);
|
|
9
8
|
const hasWildcard = (segment) => /[*?]/.test(segment);
|
|
10
|
-
|
|
11
9
|
/** `text` with the `<name>` variables filled from `bindings` (an unbound one stays as written). */
|
|
12
10
|
export function fillVars(text, bindings) {
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const bound = (whole, name) => bindings[name] ?? whole;
|
|
12
|
+
return `${text}`.replace(VAR, bound);
|
|
15
13
|
}
|
|
16
|
-
|
|
17
14
|
/** Weight of one pattern segment: literal 4, literal mixed with a variable 3, a bare variable 2, a wildcard 1, `**` 0. */
|
|
18
15
|
function segmentWeight(segment) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
if (segment === '**')
|
|
17
|
+
return 0;
|
|
18
|
+
if (hasWildcard(segment))
|
|
19
|
+
return 1;
|
|
20
|
+
if (/^<[^>]+>$/.test(segment))
|
|
21
|
+
return 2;
|
|
22
|
+
if (segment.includes('<'))
|
|
23
|
+
return 3;
|
|
24
|
+
return 4;
|
|
24
25
|
}
|
|
25
|
-
|
|
26
26
|
/** The RegExp source of one pattern segment (no separators); variables capture, and are listed in `names`. */
|
|
27
27
|
function segmentSource(segment, names) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
let source = '', variableEnd = -1;
|
|
29
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
30
|
+
const c = segment[i];
|
|
31
|
+
if (i <= variableEnd)
|
|
32
|
+
continue;
|
|
33
|
+
if (c === '<') {
|
|
34
|
+
variableEnd = segment.indexOf('>', i);
|
|
35
|
+
names.push(segment.slice(i + 1, variableEnd));
|
|
36
|
+
source += '([^/]+?)';
|
|
37
|
+
}
|
|
38
|
+
else if (c === '*')
|
|
39
|
+
source += '[^/]*';
|
|
40
|
+
else if (c === '?')
|
|
41
|
+
source += '[^/]';
|
|
42
|
+
else
|
|
43
|
+
source += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
44
|
+
}
|
|
45
|
+
return source;
|
|
41
46
|
}
|
|
42
|
-
|
|
43
47
|
/** One brace-free pattern compiled: `dir` patterns (trailing /) own everything below their root. */
|
|
44
48
|
export function compileVariant(slot, pattern) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
49
|
+
const dir = pattern.endsWith('/');
|
|
50
|
+
const body = dir ? pattern.slice(0, -1) : pattern;
|
|
51
|
+
const segments = body.split('/');
|
|
52
|
+
const names = [];
|
|
53
|
+
let source = '';
|
|
54
|
+
segments.forEach((segment, index) => {
|
|
55
|
+
const last = index === segments.length - 1;
|
|
56
|
+
if (segment === '**')
|
|
57
|
+
source += last ? '.*' : '(?:.*/)?';
|
|
58
|
+
else
|
|
59
|
+
source += segmentSource(segment, names) + (last ? '' : '/');
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
slot,
|
|
63
|
+
pattern,
|
|
64
|
+
dir,
|
|
65
|
+
segments,
|
|
66
|
+
names,
|
|
67
|
+
score: segments.reduce((sum, s) => sum + segmentWeight(s), 0),
|
|
68
|
+
wildcards: segments.filter(hasWildcard).length,
|
|
69
|
+
regex: new RegExp(`^(${source})${dir ? '(?:/.*)?' : ''}$`),
|
|
70
|
+
segmentRegexes: segments.map((s) => (s === '**' ? null : new RegExp(`^${segmentSource(s, [])}$`))),
|
|
71
|
+
};
|
|
66
72
|
}
|
|
67
|
-
|
|
68
73
|
export const variantsOf = (slot) => braceVariants(slot.path).map((pattern) => compileVariant(slot, pattern));
|
|
69
|
-
|
|
70
74
|
const levenshtein = (a, b) => {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
76
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
77
|
+
let prev = row[0];
|
|
78
|
+
row[0] = i;
|
|
79
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
80
|
+
const held = row[j];
|
|
81
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
82
|
+
prev = held;
|
|
83
|
+
}
|
|
79
84
|
}
|
|
80
|
-
|
|
81
|
-
return row[b.length];
|
|
85
|
+
return row[b.length];
|
|
82
86
|
};
|
|
83
|
-
|
|
84
87
|
/** The match of `p` against one variant: its slot, root and bindings, or null (not matching, filtered out, or an app-kind slot of another kind). */
|
|
85
88
|
function matchVariant(variant, p, only, appKind) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
if (only && !only(variant.slot))
|
|
90
|
+
return null;
|
|
91
|
+
const m = variant.regex.exec(p);
|
|
92
|
+
if (!m)
|
|
93
|
+
return null;
|
|
94
|
+
const bindings = {};
|
|
95
|
+
variant.names.forEach((name, i) => { bindings[name] = m[i + 2]; });
|
|
96
|
+
if (variant.slot.appKind !== undefined && appKind.get(bindings.app) !== variant.slot.appKind)
|
|
97
|
+
return null;
|
|
98
|
+
const root = variant.dir ? m[1] : path.posix.dirname(m[1]);
|
|
99
|
+
return { variant, slot: variant.slot, root: root === '.' ? '' : root, bindings };
|
|
94
100
|
}
|
|
95
|
-
|
|
96
101
|
/** Every variant matching `p`, with its root and bindings, minus app-kind slots of another kind, the most specific first. */
|
|
97
102
|
export function matchVariants({ variants, appKind }, p, only) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
const found = [];
|
|
104
|
+
for (const variant of variants) {
|
|
105
|
+
const hit = matchVariant(variant, p, only, appKind);
|
|
106
|
+
if (hit)
|
|
107
|
+
found.push(hit);
|
|
108
|
+
}
|
|
109
|
+
return found.sort((a, b) => b.variant.score - a.variant.score || a.variant.wildcards - b.variant.wildcards);
|
|
104
110
|
}
|
|
105
|
-
|
|
106
111
|
/** The single most specific match of a sorted list: {hit, ambiguous}; two slots of equal specificity are ambiguous. */
|
|
107
112
|
export function bestMatch(found) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
if (!found.length)
|
|
114
|
+
return { hit: null, ambiguous: [] };
|
|
115
|
+
const first = found[0];
|
|
116
|
+
const top = found.filter((f) => f.variant.score === first.variant.score && f.variant.wildcards === first.variant.wildcards);
|
|
117
|
+
const distinct = [...new Set(top.map((f) => f.slot.id))];
|
|
118
|
+
return distinct.length > 1 ? { hit: null, ambiguous: distinct } : { hit: top[0], ambiguous: [] };
|
|
112
119
|
}
|
|
113
|
-
|
|
114
120
|
/** How many leading segments of `parts` a variant accepts. */
|
|
115
121
|
function acceptedDepth(variant, parts) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
let depth = 0;
|
|
123
|
+
while (depth < parts.length && depth < variant.segments.length) {
|
|
124
|
+
const rx = variant.segmentRegexes[depth];
|
|
125
|
+
if (rx === null || !rx.test(parts[depth]))
|
|
126
|
+
break;
|
|
127
|
+
depth += 1;
|
|
128
|
+
}
|
|
129
|
+
return depth;
|
|
123
130
|
}
|
|
124
|
-
|
|
125
131
|
/** Whether `cand` is nearer to the path than `winner`: deeper, then closer in shape, then in spelling, then more specific. */
|
|
126
132
|
function nearerCandidate(cand, winner) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
if (!winner)
|
|
134
|
+
return true;
|
|
135
|
+
if (cand.depth !== winner.depth)
|
|
136
|
+
return cand.depth > winner.depth;
|
|
137
|
+
if (cand.shape !== winner.shape)
|
|
138
|
+
return cand.shape < winner.shape;
|
|
139
|
+
if (cand.distance !== winner.distance)
|
|
140
|
+
return cand.distance < winner.distance;
|
|
141
|
+
return cand.variant.score > winner.variant.score;
|
|
132
142
|
}
|
|
133
|
-
|
|
134
143
|
/** The leading segments of `p` a variant accepts, and how far it got; how the nearest slot of an unknown path is found. */
|
|
135
144
|
export function nearestSlot(variants, p) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
const parts = p.split('/');
|
|
146
|
+
let winner = null;
|
|
147
|
+
for (const variant of variants) {
|
|
148
|
+
const depth = acceptedDepth(variant, parts);
|
|
149
|
+
const expected = variant.segments[depth] ?? '';
|
|
150
|
+
const distance = levenshtein(parts[depth] ?? '', expected.replace(VAR, ''));
|
|
151
|
+
const cand = { variant, depth, shape: Math.abs(variant.segments.length - parts.length), distance };
|
|
152
|
+
if (nearerCandidate(cand, winner))
|
|
153
|
+
winner = cand;
|
|
154
|
+
}
|
|
155
|
+
if (!winner)
|
|
156
|
+
return null;
|
|
157
|
+
return {
|
|
158
|
+
slot: winner.variant.slot.id,
|
|
159
|
+
pattern: winner.variant.pattern,
|
|
160
|
+
matchedDepth: winner.depth,
|
|
161
|
+
matchedPrefix: parts.slice(0, winner.depth).join('/'),
|
|
162
|
+
expectedNext: winner.variant.segments[winner.depth] ?? null,
|
|
163
|
+
};
|
|
153
164
|
}
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// slot-path.mjs - normalize a slot-relative path for resolver comparisons.
|
|
2
2
|
import { posixPath } from '../lib/path-key.mjs';
|
|
3
|
-
|
|
4
3
|
const SLASH = '/';
|
|
5
4
|
const END = '$';
|
|
6
5
|
const TRAILING_SLASHES = new RegExp(`${SLASH}+${END}`);
|
|
7
|
-
|
|
8
6
|
export const cleanSlotPath = (value) => posixPath(value).replace(TRAILING_SLASHES, '');
|