@habitat-ai/service 0.2.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/dist/client.d.ts +16 -0
- package/dist/client.js +16 -0
- package/dist/service/base.d.ts +28 -0
- package/dist/service/base.js +1 -0
- package/dist/service/contract.d.ts +10 -0
- package/dist/service/contract.js +5 -0
- package/dist/service/impl.d.ts +508 -0
- package/dist/service/impl.js +7 -0
- package/dist/service/modules/catalog/contract/catalog.d.ts +252 -0
- package/dist/service/modules/catalog/contract/catalog.js +13 -0
- package/dist/service/modules/catalog/contract/index.d.ts +3 -0
- package/dist/service/modules/catalog/contract/index.js +3 -0
- package/dist/service/modules/catalog/model/dto/catalog.d.ts +276 -0
- package/dist/service/modules/catalog/model/dto/catalog.js +380 -0
- package/dist/service/modules/catalog/model/dto/check.d.ts +104 -0
- package/dist/service/modules/catalog/model/dto/check.js +305 -0
- package/dist/service/modules/catalog/model/dto/structure.d.ts +23 -0
- package/dist/service/modules/catalog/model/dto/structure.js +93 -0
- package/dist/service/modules/catalog/model/policy/catalog.d.ts +109 -0
- package/dist/service/modules/catalog/model/policy/catalog.js +607 -0
- package/dist/service/modules/catalog/model/policy/check.d.ts +57 -0
- package/dist/service/modules/catalog/model/policy/check.js +207 -0
- package/dist/service/modules/catalog/model/policy/structure.d.ts +64 -0
- package/dist/service/modules/catalog/model/policy/structure.js +249 -0
- package/dist/service/modules/catalog/module.d.ts +521 -0
- package/dist/service/modules/catalog/module.js +12 -0
- package/dist/service/modules/catalog/router/catalog.router.d.ts +266 -0
- package/dist/service/modules/catalog/router/catalog.router.js +659 -0
- package/dist/service/modules/catalog/router.d.ts +265 -0
- package/dist/service/modules/catalog/router.js +3 -0
- package/dist/service/router.d.ts +267 -0
- package/dist/service/router.js +4 -0
- package/package.json +49 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const knownRunnerSelectors = new Set(["grit", "habitat", "nx"]);
|
|
2
|
+
/** Selects the closed executable Grit-check and native structure application set. */
|
|
3
|
+
export function selectCheckApplications(catalog, input) {
|
|
4
|
+
const selectors = input.selectors;
|
|
5
|
+
const requestedRules = [
|
|
6
|
+
...new Set([
|
|
7
|
+
...(selectors?.rule === undefined ? [] : [selectors.rule]),
|
|
8
|
+
...(selectors?.rules ?? []),
|
|
9
|
+
]),
|
|
10
|
+
];
|
|
11
|
+
const hasExplicitSelectors = selectors?.owner !== undefined ||
|
|
12
|
+
selectors?.instance !== undefined ||
|
|
13
|
+
selectors?.rule !== undefined ||
|
|
14
|
+
selectors?.rules !== undefined ||
|
|
15
|
+
selectors?.runner !== undefined;
|
|
16
|
+
const issues = [];
|
|
17
|
+
if (selectors?.owner !== undefined) {
|
|
18
|
+
const issue = selectionIssue(catalog, "owner", selectors.owner);
|
|
19
|
+
if (issue !== undefined)
|
|
20
|
+
issues.push(issue);
|
|
21
|
+
}
|
|
22
|
+
if (selectors?.instance !== undefined) {
|
|
23
|
+
const issue = selectionIssue(catalog, "instance", selectors.instance);
|
|
24
|
+
if (issue !== undefined)
|
|
25
|
+
issues.push(issue);
|
|
26
|
+
}
|
|
27
|
+
for (const ruleId of requestedRules) {
|
|
28
|
+
const issue = selectionIssue(catalog, "rule", ruleId);
|
|
29
|
+
if (issue !== undefined)
|
|
30
|
+
issues.push(issue);
|
|
31
|
+
}
|
|
32
|
+
if (selectors?.runner !== undefined) {
|
|
33
|
+
const issue = selectionIssue(catalog, "runner", selectors.runner);
|
|
34
|
+
if (issue !== undefined)
|
|
35
|
+
issues.push(issue);
|
|
36
|
+
}
|
|
37
|
+
if (issues.length > 0)
|
|
38
|
+
return refused(issues);
|
|
39
|
+
const selected = catalog.applications.filter((application) => (selectors?.owner === undefined || application.ownerProject === selectors.owner) &&
|
|
40
|
+
(selectors?.instance === undefined || application.instanceId === selectors.instance) &&
|
|
41
|
+
(requestedRules.length === 0 || requestedRules.includes(application.ruleId)) &&
|
|
42
|
+
(selectors?.runner === undefined || application.runner.name === selectors.runner));
|
|
43
|
+
if (hasExplicitSelectors && selected.length === 0 && !isKnownEmptyRunnerSelection(selectors)) {
|
|
44
|
+
return refused([
|
|
45
|
+
issue("selector-empty", "selectors", "The requested selector intersection contains no resolved applications."),
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
const unsupported = selected.filter(isUnsupportedGritApplication);
|
|
49
|
+
if (unsupported.length > 0) {
|
|
50
|
+
return refused(unsupported.map((application) => issue("runner-unsupported", `${application.instanceId}:${application.ruleId}`, `Rule "${application.ruleId}" requires Grit ${application.runner.acquisition.kind}, which catalog.check does not execute.`)));
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
applications: selected.filter(isExecutableCheckApplication).sort(compareApplications),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Extracts the first closed Grit fence from one admitted pattern asset. */
|
|
58
|
+
export function extractGritProgram(contents) {
|
|
59
|
+
const lines = contents.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n");
|
|
60
|
+
const opening = lines.findIndex((line) => line === "```grit");
|
|
61
|
+
if (opening < 0) {
|
|
62
|
+
return { ok: false, detail: "Pattern asset has no opening ```grit fence." };
|
|
63
|
+
}
|
|
64
|
+
const closingOffset = lines.slice(opening + 1).findIndex((line) => line === "```");
|
|
65
|
+
if (closingOffset < 0) {
|
|
66
|
+
return { ok: false, detail: "Pattern asset has no closing Grit fence." };
|
|
67
|
+
}
|
|
68
|
+
const program = lines.slice(opening + 1, opening + 1 + closingOffset).join("\n");
|
|
69
|
+
return program.trim().length === 0
|
|
70
|
+
? { ok: false, detail: "Pattern asset contains an empty Grit program." }
|
|
71
|
+
: { ok: true, program };
|
|
72
|
+
}
|
|
73
|
+
/** Produces one semantic report from trusted, repository-relative findings. */
|
|
74
|
+
export function evaluatedApplication(application, findings) {
|
|
75
|
+
const severity = application.lane === "enforced" ? "error" : "advisory";
|
|
76
|
+
return {
|
|
77
|
+
...applicationIdentity(application),
|
|
78
|
+
status: findings.length === 0
|
|
79
|
+
? "pass"
|
|
80
|
+
: application.lane === "enforced"
|
|
81
|
+
? "fail"
|
|
82
|
+
: "advisory-findings",
|
|
83
|
+
disposition: { kind: "evaluated" },
|
|
84
|
+
findings: findings.map((finding) => ({ ...finding, severity, baselined: false })),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Produces one deterministic operational-error report for an application. */
|
|
88
|
+
export function failedApplication(application, reason, detail) {
|
|
89
|
+
return {
|
|
90
|
+
...applicationIdentity(application),
|
|
91
|
+
status: "error",
|
|
92
|
+
disposition: {
|
|
93
|
+
kind: "failed",
|
|
94
|
+
reason,
|
|
95
|
+
detail: boundedDetail(detail),
|
|
96
|
+
},
|
|
97
|
+
findings: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Produces one native Habitat report from pure structure diagnostics. */
|
|
101
|
+
export function evaluatedStructureApplication(application, diagnostics) {
|
|
102
|
+
const severity = application.lane === "enforced" ? "error" : "advisory";
|
|
103
|
+
return {
|
|
104
|
+
...structureApplicationIdentity(application),
|
|
105
|
+
status: diagnostics.length === 0
|
|
106
|
+
? "pass"
|
|
107
|
+
: application.lane === "enforced"
|
|
108
|
+
? "fail"
|
|
109
|
+
: "advisory-findings",
|
|
110
|
+
disposition: { kind: "evaluated" },
|
|
111
|
+
findings: diagnostics.map((diagnostic) => ({
|
|
112
|
+
...diagnostic,
|
|
113
|
+
severity,
|
|
114
|
+
baselined: false,
|
|
115
|
+
})),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Produces one deterministic native Habitat operational-error report. */
|
|
119
|
+
export function failedStructureApplication(application, reason, detail) {
|
|
120
|
+
return {
|
|
121
|
+
...structureApplicationIdentity(application),
|
|
122
|
+
status: "error",
|
|
123
|
+
disposition: { kind: "failed", reason, detail: boundedDetail(detail) },
|
|
124
|
+
findings: [],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Completes a check from already ordered application reports. */
|
|
128
|
+
export function completedCheck(applications) {
|
|
129
|
+
return {
|
|
130
|
+
_tag: "Completed",
|
|
131
|
+
ok: applications.every((application) => application.status === "pass" || application.status === "advisory-findings"),
|
|
132
|
+
applications: [...applications],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function applicationIdentity(application) {
|
|
136
|
+
return {
|
|
137
|
+
ownerProject: application.ownerProject,
|
|
138
|
+
instanceId: application.instanceId,
|
|
139
|
+
ruleId: application.ruleId,
|
|
140
|
+
runner: "grit",
|
|
141
|
+
lane: application.lane,
|
|
142
|
+
locked: false,
|
|
143
|
+
message: application.message,
|
|
144
|
+
remediate: application.remediate,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function structureApplicationIdentity(application) {
|
|
148
|
+
return {
|
|
149
|
+
ownerProject: application.ownerProject,
|
|
150
|
+
instanceId: application.instanceId,
|
|
151
|
+
ruleId: application.ruleId,
|
|
152
|
+
runner: "habitat",
|
|
153
|
+
lane: application.lane,
|
|
154
|
+
locked: false,
|
|
155
|
+
message: application.message,
|
|
156
|
+
remediate: application.remediate,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function isGritCheckApplication(application) {
|
|
160
|
+
return application.runner.name === "grit" && application.runner.acquisition.kind === "check";
|
|
161
|
+
}
|
|
162
|
+
function isUnsupportedGritApplication(application) {
|
|
163
|
+
return application.runner.name === "grit" && application.runner.acquisition.kind !== "check";
|
|
164
|
+
}
|
|
165
|
+
function isExecutableCheckApplication(application) {
|
|
166
|
+
return application.runner.name === "habitat" || isGritCheckApplication(application);
|
|
167
|
+
}
|
|
168
|
+
function compareApplications(left, right) {
|
|
169
|
+
return (compareText(left.ruleId, right.ruleId) ||
|
|
170
|
+
compareText(left.instanceId, right.instanceId) ||
|
|
171
|
+
compareText(left.ownerProject, right.ownerProject));
|
|
172
|
+
}
|
|
173
|
+
function compareText(left, right) {
|
|
174
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
175
|
+
}
|
|
176
|
+
function refused(issues) {
|
|
177
|
+
return { ok: false, issues };
|
|
178
|
+
}
|
|
179
|
+
function issue(code, selector, message) {
|
|
180
|
+
return { code, selector, message };
|
|
181
|
+
}
|
|
182
|
+
function selectionIssue(catalog, kind, value) {
|
|
183
|
+
const matches = {
|
|
184
|
+
owner: catalog.applications.some((application) => application.ownerProject === value),
|
|
185
|
+
instance: catalog.applications.some((application) => application.instanceId === value),
|
|
186
|
+
rule: catalog.applications.some((application) => application.ruleId === value),
|
|
187
|
+
runner: catalog.applications.some((application) => application.runner.name === value),
|
|
188
|
+
};
|
|
189
|
+
if (matches[kind] || (kind === "runner" && knownRunnerSelectors.has(value)))
|
|
190
|
+
return undefined;
|
|
191
|
+
const actualKind = ["owner", "instance", "rule", "runner"].find((candidate) => candidate !== kind && matches[candidate]);
|
|
192
|
+
return actualKind === undefined
|
|
193
|
+
? issue("selector-unknown", `${kind}:${value}`, `No resolved application uses ${kind} "${value}".`)
|
|
194
|
+
: issue("selector-wrong-namespace", `${kind}:${value}`, `"${value}" is a known ${actualKind}, not a ${kind}.`);
|
|
195
|
+
}
|
|
196
|
+
function isKnownEmptyRunnerSelection(selectors) {
|
|
197
|
+
return (selectors?.owner === undefined &&
|
|
198
|
+
selectors?.instance === undefined &&
|
|
199
|
+
selectors?.rule === undefined &&
|
|
200
|
+
selectors?.rules === undefined &&
|
|
201
|
+
selectors?.runner !== undefined &&
|
|
202
|
+
knownRunnerSelectors.has(selectors.runner));
|
|
203
|
+
}
|
|
204
|
+
function boundedDetail(detail) {
|
|
205
|
+
const normalized = detail.trim() || "Application evaluation failed.";
|
|
206
|
+
return normalized.length <= 4_096 ? normalized : `${normalized.slice(0, 4_093)}...`;
|
|
207
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { SourceInventoryResult } from "@habitat-ai/resource-source-inventory";
|
|
2
|
+
import type { HabitatCatalog } from "../dto/catalog.js";
|
|
3
|
+
import type { StructureCheckFinding } from "../dto/check.js";
|
|
4
|
+
import { type StructureScope } from "../dto/structure.js";
|
|
5
|
+
type RuleApplication = HabitatCatalog["applications"][number];
|
|
6
|
+
/** Resolved application executable by the native Habitat structure policy. */
|
|
7
|
+
export type HabitatStructureApplication = RuleApplication & {
|
|
8
|
+
readonly runner: Extract<RuleApplication["runner"], {
|
|
9
|
+
name: "habitat";
|
|
10
|
+
}>;
|
|
11
|
+
};
|
|
12
|
+
/** Narrows one resolved application to the native structure runner. */
|
|
13
|
+
export declare function isHabitatStructureApplication(application: RuleApplication): application is HabitatStructureApplication;
|
|
14
|
+
/** Live or inventory-derived kind understood by native structure evaluation. */
|
|
15
|
+
export type StructureRootKind = StructureScope["kind"] | "other";
|
|
16
|
+
type BoundStructureScope = Omit<StructureScope, "allowEmpty"> & {
|
|
17
|
+
readonly allowEmpty: boolean;
|
|
18
|
+
readonly bindingPath: string;
|
|
19
|
+
};
|
|
20
|
+
/** Schema-admitted structure application with inapplicable unbound scopes removed. */
|
|
21
|
+
export type AdmittedStructureApplication = {
|
|
22
|
+
readonly application: HabitatStructureApplication;
|
|
23
|
+
readonly scopes: readonly BoundStructureScope[];
|
|
24
|
+
};
|
|
25
|
+
/** Inventory-derived source universe shared by every application in one invocation. */
|
|
26
|
+
export type StructureUniverse = {
|
|
27
|
+
readonly paths: readonly string[];
|
|
28
|
+
readonly trackedNonFilePaths: ReadonlySet<string>;
|
|
29
|
+
readonly directChildren: ReadonlyMap<string, readonly string[]>;
|
|
30
|
+
};
|
|
31
|
+
type PlannedRoot = {
|
|
32
|
+
readonly path: string;
|
|
33
|
+
};
|
|
34
|
+
type PlannedScope = {
|
|
35
|
+
readonly scope: BoundStructureScope;
|
|
36
|
+
readonly reportPath: string;
|
|
37
|
+
readonly roots: readonly PlannedRoot[];
|
|
38
|
+
};
|
|
39
|
+
/** Pure inventory interpretation awaiting live matched-root and direct-child observations. */
|
|
40
|
+
export type StructureEvaluationPlan = {
|
|
41
|
+
readonly application: HabitatStructureApplication;
|
|
42
|
+
readonly scopes: readonly PlannedScope[];
|
|
43
|
+
readonly rootObservationPaths: readonly string[];
|
|
44
|
+
readonly universe: StructureUniverse;
|
|
45
|
+
};
|
|
46
|
+
/** Stable path-only native Habitat diagnostic derived from the public report contract. */
|
|
47
|
+
export type StructureDiagnostic = Pick<StructureCheckFinding, "code" | "path" | "message">;
|
|
48
|
+
/** Admits TypeBox-valid structure authority and resolves its root-role applicability. */
|
|
49
|
+
export declare function admitStructureDocument(value: unknown, application: HabitatStructureApplication): {
|
|
50
|
+
readonly ok: true;
|
|
51
|
+
readonly admitted: AdmittedStructureApplication;
|
|
52
|
+
} | {
|
|
53
|
+
readonly ok: false;
|
|
54
|
+
readonly detail: string;
|
|
55
|
+
};
|
|
56
|
+
/** Derives ancestors and direct children while pruning tracked non-file descendants. */
|
|
57
|
+
export declare function makeStructureUniverse(inventory: SourceInventoryResult): StructureUniverse;
|
|
58
|
+
/** Plans inventory-visible root matching without treating retained Git entries as live facts. */
|
|
59
|
+
export declare function planStructureEvaluation(admitted: AdmittedStructureApplication, universe: StructureUniverse): StructureEvaluationPlan;
|
|
60
|
+
/** Selects inventory-admitted direct children only for roots observed as live directories. */
|
|
61
|
+
export declare function structureChildObservationPaths(plan: StructureEvaluationPlan, observedKinds: ReadonlyMap<string, StructureRootKind | "missing">): readonly string[];
|
|
62
|
+
/** Applies native structure semantics to a prepared source universe and observed root kinds. */
|
|
63
|
+
export declare function evaluateStructurePlan(plan: StructureEvaluationPlan, observedKinds: ReadonlyMap<string, StructureRootKind | "missing">): readonly StructureDiagnostic[];
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import picomatch from "picomatch";
|
|
2
|
+
import { Validator } from "typebox/schema";
|
|
3
|
+
import { STRUCTURE_PICOMATCH_OPTIONS, StructureDocumentSchema, } from "../dto/structure.js";
|
|
4
|
+
/** Narrows one resolved application to the native structure runner. */
|
|
5
|
+
export function isHabitatStructureApplication(application) {
|
|
6
|
+
return application.runner.name === "habitat";
|
|
7
|
+
}
|
|
8
|
+
const structureValidator = new Validator({}, StructureDocumentSchema);
|
|
9
|
+
/** Admits TypeBox-valid structure authority and resolves its root-role applicability. */
|
|
10
|
+
export function admitStructureDocument(value, application) {
|
|
11
|
+
if (!structureValidator.Check(value)) {
|
|
12
|
+
const [, errors] = structureValidator.Errors(value);
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
detail: errors
|
|
16
|
+
.slice(0, 20)
|
|
17
|
+
.map((error) => error.message)
|
|
18
|
+
.join("; ") || "Structure document does not satisfy schema version 2.",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const bindings = new Map(application.runner.rootBindings.map((binding) => [
|
|
22
|
+
binding.rootRole,
|
|
23
|
+
binding,
|
|
24
|
+
]));
|
|
25
|
+
const unknownRole = value.scopes.find((scope) => !bindings.has(scope.rootRole));
|
|
26
|
+
if (unknownRole !== undefined) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
detail: `Structure scope "${unknownRole.name}" names unknown root role "${unknownRole.rootRole}".`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const scopes = [];
|
|
33
|
+
for (const scope of value.scopes) {
|
|
34
|
+
const binding = bindings.get(scope.rootRole);
|
|
35
|
+
if (binding?.path === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
if (appendPath(binding.path, scope.relativePath).length > 4_096) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
detail: `Structure scope "${scope.name}" resolves beyond the maximum repository path length.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
scopes.push({ ...scope, allowEmpty: scope.allowEmpty ?? false, bindingPath: binding.path });
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, admitted: { application, scopes } };
|
|
46
|
+
}
|
|
47
|
+
/** Derives ancestors and direct children while pruning tracked non-file descendants. */
|
|
48
|
+
export function makeStructureUniverse(inventory) {
|
|
49
|
+
const trackedNonFilePaths = new Set(inventory.trackedNonFilePaths);
|
|
50
|
+
const visiblePaths = inventory.paths.filter((candidate) => !ancestors(candidate).some((ancestor) => trackedNonFilePaths.has(ancestor)));
|
|
51
|
+
const allPaths = new Set([""]);
|
|
52
|
+
for (const visiblePath of visiblePaths) {
|
|
53
|
+
allPaths.add(visiblePath);
|
|
54
|
+
for (const ancestor of ancestors(visiblePath))
|
|
55
|
+
allPaths.add(ancestor);
|
|
56
|
+
}
|
|
57
|
+
const directChildren = new Map();
|
|
58
|
+
for (const candidate of allPaths) {
|
|
59
|
+
if (candidate === "")
|
|
60
|
+
continue;
|
|
61
|
+
const parent = parentPath(candidate);
|
|
62
|
+
const children = directChildren.get(parent) ?? [];
|
|
63
|
+
children.push(baseName(candidate));
|
|
64
|
+
directChildren.set(parent, children);
|
|
65
|
+
}
|
|
66
|
+
for (const children of directChildren.values())
|
|
67
|
+
children.sort(compareText);
|
|
68
|
+
return {
|
|
69
|
+
paths: [...allPaths].sort(compareText),
|
|
70
|
+
trackedNonFilePaths,
|
|
71
|
+
directChildren,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Plans inventory-visible root matching without treating retained Git entries as live facts. */
|
|
75
|
+
export function planStructureEvaluation(admitted, universe) {
|
|
76
|
+
const rootObservationPaths = new Set();
|
|
77
|
+
const scopes = admitted.scopes.map((scope) => {
|
|
78
|
+
const reportPath = appendPath(scope.bindingPath, scope.relativePath);
|
|
79
|
+
const matches = scope.relativePath === "."
|
|
80
|
+
? (candidate) => candidate === ""
|
|
81
|
+
: picomatch(scope.relativePath, STRUCTURE_PICOMATCH_OPTIONS);
|
|
82
|
+
const roots = universe.paths
|
|
83
|
+
.filter((candidate) => {
|
|
84
|
+
const relativePath = relativeToBoundRoot(candidate, scope.bindingPath);
|
|
85
|
+
return relativePath !== undefined && matches(relativePath);
|
|
86
|
+
})
|
|
87
|
+
.map((candidate) => {
|
|
88
|
+
rootObservationPaths.add(candidate);
|
|
89
|
+
return { path: candidate };
|
|
90
|
+
});
|
|
91
|
+
return { scope, reportPath, roots };
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
application: admitted.application,
|
|
95
|
+
scopes,
|
|
96
|
+
rootObservationPaths: [...rootObservationPaths].sort(compareText),
|
|
97
|
+
universe,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Selects inventory-admitted direct children only for roots observed as live directories. */
|
|
101
|
+
export function structureChildObservationPaths(plan, observedKinds) {
|
|
102
|
+
const paths = new Set();
|
|
103
|
+
for (const planned of plan.scopes) {
|
|
104
|
+
if (planned.scope.kind !== "directory")
|
|
105
|
+
continue;
|
|
106
|
+
for (const root of planned.roots) {
|
|
107
|
+
if (plannedStructureKind(plan, root.path, observedKinds) !== "directory")
|
|
108
|
+
continue;
|
|
109
|
+
for (const child of plan.universe.directChildren.get(root.path) ?? []) {
|
|
110
|
+
paths.add(appendPath(root.path, child));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...paths].sort(compareText);
|
|
115
|
+
}
|
|
116
|
+
/** Applies native structure semantics to a prepared source universe and observed root kinds. */
|
|
117
|
+
export function evaluateStructurePlan(plan, observedKinds) {
|
|
118
|
+
const diagnostics = [];
|
|
119
|
+
for (const planned of plan.scopes) {
|
|
120
|
+
const roots = planned.roots.flatMap((root) => {
|
|
121
|
+
const observedKind = plannedStructureKind(plan, root.path, observedKinds);
|
|
122
|
+
return observedKind === "missing" ? [] : [{ path: root.path, kind: observedKind }];
|
|
123
|
+
});
|
|
124
|
+
if (!planned.scope.allowEmpty && roots.length === 0) {
|
|
125
|
+
diagnostics.push({
|
|
126
|
+
code: "root-missing",
|
|
127
|
+
path: displayPath(planned.reportPath),
|
|
128
|
+
message: `Structure scope "${planned.scope.name}" matched no ${planned.scope.kind} roots for ${displayPath(planned.reportPath)}.`,
|
|
129
|
+
});
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
for (const root of roots) {
|
|
133
|
+
if (root.kind === planned.scope.kind)
|
|
134
|
+
continue;
|
|
135
|
+
diagnostics.push({
|
|
136
|
+
code: "wrong-root-kind",
|
|
137
|
+
path: displayPath(root.path),
|
|
138
|
+
message: `Structure scope "${planned.scope.name}" expected ${planned.scope.kind} root, but ${displayPath(root.path)} is ${root.kind}.`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (planned.scope.kind !== "directory")
|
|
142
|
+
continue;
|
|
143
|
+
for (const root of roots) {
|
|
144
|
+
if (root.kind !== "directory")
|
|
145
|
+
continue;
|
|
146
|
+
const visibleChildren = (plan.universe.directChildren.get(root.path) ?? []).filter((child) => observedStructureKind(appendPath(root.path, child), observedKinds) !== "missing");
|
|
147
|
+
diagnostics.push(...evaluateDirectoryChildren(planned.scope, root.path, visibleChildren));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return diagnostics;
|
|
151
|
+
}
|
|
152
|
+
function observedStructureKind(path, observedKinds) {
|
|
153
|
+
const kind = observedKinds.get(path);
|
|
154
|
+
if (kind === undefined) {
|
|
155
|
+
throw new Error(`Missing observation for planned structure path "${path}".`);
|
|
156
|
+
}
|
|
157
|
+
return kind;
|
|
158
|
+
}
|
|
159
|
+
function plannedStructureKind(plan, path, observedKinds) {
|
|
160
|
+
const kind = observedStructureKind(path, observedKinds);
|
|
161
|
+
return kind !== "missing" && plan.universe.trackedNonFilePaths.has(path) ? "other" : kind;
|
|
162
|
+
}
|
|
163
|
+
function evaluateDirectoryChildren(scope, rootPath, children) {
|
|
164
|
+
const diagnostics = [];
|
|
165
|
+
const required = scope.required ?? [];
|
|
166
|
+
const allowed = scope.allowed ?? [];
|
|
167
|
+
const forbidden = scope.forbidden ?? [];
|
|
168
|
+
const requiredMatchers = makeMatchers(required);
|
|
169
|
+
const allowedMatchers = makeMatchers([...required, ...allowed]);
|
|
170
|
+
const forbiddenMatchers = makeMatchers(forbidden);
|
|
171
|
+
for (const pattern of required) {
|
|
172
|
+
if (children.some((child) => requiredMatchers.get(pattern)?.(child) === true))
|
|
173
|
+
continue;
|
|
174
|
+
diagnostics.push({
|
|
175
|
+
code: "missing-required-child",
|
|
176
|
+
path: displayPath(rootPath),
|
|
177
|
+
message: `Structure scope "${scope.name}" requires direct child ${pattern} under ${displayPath(rootPath)}.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
for (const child of children) {
|
|
181
|
+
const forbiddenPattern = firstMatchingPattern(child, forbiddenMatchers);
|
|
182
|
+
if (forbiddenPattern !== undefined) {
|
|
183
|
+
diagnostics.push({
|
|
184
|
+
code: "forbidden-child",
|
|
185
|
+
path: displayPath(appendPath(rootPath, child)),
|
|
186
|
+
message: `Structure scope "${scope.name}" forbids direct child ${child} under ${displayPath(rootPath)} via ${forbiddenPattern}.`,
|
|
187
|
+
});
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (scope.mode === "closed" && firstMatchingPattern(child, allowedMatchers) === undefined) {
|
|
191
|
+
diagnostics.push({
|
|
192
|
+
code: "unexpected-child",
|
|
193
|
+
path: displayPath(appendPath(rootPath, child)),
|
|
194
|
+
message: `Structure scope "${scope.name}" is closed and does not allow direct child ${child} under ${displayPath(rootPath)}.`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return diagnostics;
|
|
199
|
+
}
|
|
200
|
+
function makeMatchers(patterns) {
|
|
201
|
+
return new Map(patterns.map((pattern) => [
|
|
202
|
+
pattern,
|
|
203
|
+
picomatch(pattern, STRUCTURE_PICOMATCH_OPTIONS),
|
|
204
|
+
]));
|
|
205
|
+
}
|
|
206
|
+
function firstMatchingPattern(name, matchers) {
|
|
207
|
+
for (const [pattern, matches] of matchers) {
|
|
208
|
+
if (matches(name))
|
|
209
|
+
return pattern;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
function appendPath(parent, child) {
|
|
214
|
+
const normalizedParent = parent === "." ? "" : parent;
|
|
215
|
+
if (child === ".")
|
|
216
|
+
return normalizedParent;
|
|
217
|
+
return normalizedParent === "" ? child : `${normalizedParent}/${child}`;
|
|
218
|
+
}
|
|
219
|
+
function relativeToBoundRoot(candidate, bindingPath) {
|
|
220
|
+
const normalizedBinding = bindingPath === "." ? "" : bindingPath;
|
|
221
|
+
if (normalizedBinding === "")
|
|
222
|
+
return candidate;
|
|
223
|
+
if (candidate === normalizedBinding)
|
|
224
|
+
return "";
|
|
225
|
+
const prefix = `${normalizedBinding}/`;
|
|
226
|
+
return candidate.startsWith(prefix) ? candidate.slice(prefix.length) : undefined;
|
|
227
|
+
}
|
|
228
|
+
function ancestors(candidate) {
|
|
229
|
+
const parts = candidate.split("/");
|
|
230
|
+
const out = [];
|
|
231
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
232
|
+
out.push(parts.slice(0, index).join("/"));
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
function parentPath(candidate) {
|
|
237
|
+
const separator = candidate.lastIndexOf("/");
|
|
238
|
+
return separator < 0 ? "" : candidate.slice(0, separator);
|
|
239
|
+
}
|
|
240
|
+
function baseName(candidate) {
|
|
241
|
+
const separator = candidate.lastIndexOf("/");
|
|
242
|
+
return separator < 0 ? candidate : candidate.slice(separator + 1);
|
|
243
|
+
}
|
|
244
|
+
function displayPath(candidate) {
|
|
245
|
+
return candidate === "" ? "." : candidate;
|
|
246
|
+
}
|
|
247
|
+
function compareText(left, right) {
|
|
248
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
249
|
+
}
|