@intentset/architecture 0.1.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/baseline.d.ts +39 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +104 -0
- package/dist/baseline.js.map +1 -0
- package/dist/boundaries.d.ts +63 -0
- package/dist/boundaries.d.ts.map +1 -0
- package/dist/boundaries.js +436 -0
- package/dist/boundaries.js.map +1 -0
- package/dist/check.d.ts +73 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +130 -0
- package/dist/check.js.map +1 -0
- package/dist/claims.d.ts +24 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +201 -0
- package/dist/claims.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +159 -0
- package/dist/config.js.map +1 -0
- package/dist/dependencies.d.ts +30 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/dependencies.js +179 -0
- package/dist/dependencies.js.map +1 -0
- package/dist/exceptions.d.ts +43 -0
- package/dist/exceptions.d.ts.map +1 -0
- package/dist/exceptions.js +233 -0
- package/dist/exceptions.js.map +1 -0
- package/dist/extract.d.ts +24 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +316 -0
- package/dist/extract.js.map +1 -0
- package/dist/finding.d.ts +39 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +29 -0
- package/dist/finding.js.map +1 -0
- package/dist/imports.d.ts +30 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +48 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/layers.d.ts +38 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +41 -0
- package/dist/layers.js.map +1 -0
- package/dist/ownership.d.ts +25 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +97 -0
- package/dist/ownership.js.map +1 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +54 -0
- package/dist/paths.js.map +1 -0
- package/dist/patterns.d.ts +25 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +101 -0
- package/dist/patterns.js.map +1 -0
- package/dist/regions.d.ts +85 -0
- package/dist/regions.d.ts.map +1 -0
- package/dist/regions.js +144 -0
- package/dist/regions.js.map +1 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +268 -0
- package/dist/resolve.js.map +1 -0
- package/dist/tsconfig.d.ts +42 -0
- package/dist/tsconfig.d.ts.map +1 -0
- package/dist/tsconfig.js +186 -0
- package/dist/tsconfig.js.map +1 -0
- package/package.json +36 -0
- package/src/baseline.ts +126 -0
- package/src/boundaries.ts +499 -0
- package/src/check.ts +197 -0
- package/src/claims.ts +229 -0
- package/src/config.ts +212 -0
- package/src/dependencies.ts +211 -0
- package/src/exceptions.ts +269 -0
- package/src/extract.ts +344 -0
- package/src/finding.ts +64 -0
- package/src/imports.ts +77 -0
- package/src/index.ts +58 -0
- package/src/layers.ts +67 -0
- package/src/ownership.ts +135 -0
- package/src/paths.ts +53 -0
- package/src/patterns.ts +95 -0
- package/src/regions.ts +243 -0
- package/src/resolve.ts +283 -0
- package/src/tsconfig.ts +198 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared against observed dependencies (VSA004) and cycles (VSA005).
|
|
3
|
+
*
|
|
4
|
+
* VSA004: an observed import from one slice into another that the importer
|
|
5
|
+
* does not list in `dependsOn`, one finding per pair of slices at the first
|
|
6
|
+
* import; a consumed contract that no slice exposes, or that more than one
|
|
7
|
+
* exposes; a consumed contract whose one exposing slice is not depended on.
|
|
8
|
+
*
|
|
9
|
+
* VSA005: strongly connected components (Tarjan) over the union of observed
|
|
10
|
+
* slice edges and declared `dependsOn` edges, type-only imports included
|
|
11
|
+
* (profile §3: they still count for cycles). One finding per component of
|
|
12
|
+
* two or more slices, naming them along one concrete cycle.
|
|
13
|
+
*/
|
|
14
|
+
import type { Graph } from "@intentset/core";
|
|
15
|
+
import type { SliceEdge } from "./boundaries.ts";
|
|
16
|
+
import { type Finding, finding, plural } from "./finding.ts";
|
|
17
|
+
import { compareStrings } from "./patterns.ts";
|
|
18
|
+
import type { Model } from "./regions.ts";
|
|
19
|
+
|
|
20
|
+
export function checkDependencies(
|
|
21
|
+
model: Model,
|
|
22
|
+
graph: Graph,
|
|
23
|
+
sliceEdges: ReadonlyMap<string, SliceEdge>,
|
|
24
|
+
): { findings: Finding[]; cycles: number } {
|
|
25
|
+
const findings: Finding[] = [];
|
|
26
|
+
|
|
27
|
+
for (const key of [...sliceEdges.keys()].sort(compareStrings)) {
|
|
28
|
+
const { from, to, imports } = sliceEdges.get(key)!;
|
|
29
|
+
const importer = model.sliceById.get(from)!;
|
|
30
|
+
if (importer.dependsOn.includes(to)) continue;
|
|
31
|
+
const first = imports[0];
|
|
32
|
+
findings.push(
|
|
33
|
+
finding({
|
|
34
|
+
code: "VSA004",
|
|
35
|
+
artifact: from,
|
|
36
|
+
path: first.from,
|
|
37
|
+
location: { line: first.line },
|
|
38
|
+
message: `${from} imports ${to} (${plural(imports.length, "import")}, the first here) but does not declare ${to} in dependsOn.`,
|
|
39
|
+
remediation: `Add ${to} to links.dependsOn in ${importer.path}, or remove the import (VSA004).`,
|
|
40
|
+
paths: [...new Set(imports.map((edge) => edge.from)), importer.path],
|
|
41
|
+
edges: [{ from, to }, ...imports.map((edge) => ({ from: edge.from, to: edge.to as string }))],
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const exposers = new Map<string, string[]>();
|
|
47
|
+
for (const slice of model.slices) {
|
|
48
|
+
for (const contract of slice.exposes) {
|
|
49
|
+
const list = exposers.get(contract) ?? [];
|
|
50
|
+
list.push(slice.id);
|
|
51
|
+
exposers.set(contract, list);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const slice of model.slices) {
|
|
55
|
+
const consumes = slice.artifact.meta.links.consumes ?? [];
|
|
56
|
+
consumes.forEach((contract, i) => {
|
|
57
|
+
const field = `/intentset/links/consumes/${i}`;
|
|
58
|
+
const owners = (exposers.get(contract) ?? []).sort(compareStrings);
|
|
59
|
+
const base = { artifact: slice.id, path: slice.path, field, edges: [{ from: slice.id, to: contract }] };
|
|
60
|
+
if (owners.length === 0) {
|
|
61
|
+
if (graph.artifacts.get(contract)?.meta.type !== "contract") return; // An unresolved reference is core's CORE003.
|
|
62
|
+
findings.push(
|
|
63
|
+
finding({
|
|
64
|
+
...base,
|
|
65
|
+
code: "VSA004",
|
|
66
|
+
message: `${slice.id} consumes ${contract}, which no slice exposes.`,
|
|
67
|
+
remediation: `Add ${contract} to the exposes of the slice that maintains it, or stop consuming it (VSA004).`,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
} else if (owners.length > 1) {
|
|
71
|
+
findings.push(
|
|
72
|
+
finding({
|
|
73
|
+
...base,
|
|
74
|
+
code: "VSA004",
|
|
75
|
+
message: `${slice.id} consumes ${contract}, which ${owners.length} slices expose: ${owners.join(", ")}.`,
|
|
76
|
+
remediation: `Keep ${contract} in the exposes of the one slice that maintains it (VSA004).`,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
} else if (owners[0] !== slice.id && !slice.dependsOn.includes(owners[0])) {
|
|
80
|
+
findings.push(
|
|
81
|
+
finding({
|
|
82
|
+
...base,
|
|
83
|
+
code: "VSA004",
|
|
84
|
+
message: `${slice.id} consumes ${contract}, exposed by ${owners[0]}, but does not declare ${owners[0]} in dependsOn.`,
|
|
85
|
+
remediation: `Add ${owners[0]} to links.dependsOn of ${slice.id}; a consumer lists both consumes and dependsOn (VSA §4).`,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Cycles over observed and declared slice edges.
|
|
93
|
+
const nodes = model.slices.map((slice) => slice.id);
|
|
94
|
+
const adjacency = new Map<string, Set<string>>(nodes.map((id) => [id, new Set<string>()]));
|
|
95
|
+
const declared = new Set<string>();
|
|
96
|
+
for (const slice of model.slices) {
|
|
97
|
+
for (const target of slice.dependsOn) {
|
|
98
|
+
if (!adjacency.has(target) || target === slice.id) continue;
|
|
99
|
+
adjacency.get(slice.id)!.add(target);
|
|
100
|
+
declared.add(`${slice.id}->${target}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const { from, to } of sliceEdges.values()) adjacency.get(from)?.add(to);
|
|
104
|
+
const successors = new Map([...adjacency].map(([id, set]) => [id, [...set].sort(compareStrings)]));
|
|
105
|
+
const components = stronglyConnected(nodes, successors).filter((component) => component.length > 1);
|
|
106
|
+
for (const component of components) {
|
|
107
|
+
const members = new Set(component);
|
|
108
|
+
const cycle = shortestCycle(component[0], successors, members);
|
|
109
|
+
const internal: { from: string; to: string }[] = [];
|
|
110
|
+
let observed = 0;
|
|
111
|
+
let written = 0;
|
|
112
|
+
for (const from of component) {
|
|
113
|
+
for (const to of successors.get(from) ?? []) {
|
|
114
|
+
if (!members.has(to)) continue;
|
|
115
|
+
internal.push({ from, to });
|
|
116
|
+
if (sliceEdges.has(`${from}->${to}`)) observed++;
|
|
117
|
+
if (declared.has(`${from}->${to}`)) written++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const first = model.sliceById.get(component[0])!;
|
|
121
|
+
findings.push(
|
|
122
|
+
finding({
|
|
123
|
+
code: "VSA005",
|
|
124
|
+
artifact: first.id,
|
|
125
|
+
path: first.path,
|
|
126
|
+
message: `Slices ${component.join(", ")} form a compile-time dependency cycle, ${cycle.join(" -> ")}, from ${plural(observed, "observed import edge")} and ${plural(written, "declared dependsOn edge")}.`,
|
|
127
|
+
remediation:
|
|
128
|
+
"Break the cycle: move the shared rule to one owner's contract, invert one dependency, or decouple it through an event contract (VSA005).",
|
|
129
|
+
paths: component.map((id) => model.sliceById.get(id)!.path),
|
|
130
|
+
edges: internal,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { findings, cycles: components.length };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Tarjan's strongly connected components. Nodes and successors are visited in
|
|
140
|
+
* sorted order and each component comes back sorted, components ordered by
|
|
141
|
+
* their first member, so the output is the same for the same graph.
|
|
142
|
+
*/
|
|
143
|
+
export function stronglyConnected(
|
|
144
|
+
nodes: readonly string[],
|
|
145
|
+
successors: ReadonlyMap<string, readonly string[]>,
|
|
146
|
+
): string[][] {
|
|
147
|
+
let counter = 0;
|
|
148
|
+
const index = new Map<string, number>();
|
|
149
|
+
const low = new Map<string, number>();
|
|
150
|
+
const stack: string[] = [];
|
|
151
|
+
const onStack = new Set<string>();
|
|
152
|
+
const components: string[][] = [];
|
|
153
|
+
|
|
154
|
+
const visit = (node: string): void => {
|
|
155
|
+
index.set(node, counter);
|
|
156
|
+
low.set(node, counter);
|
|
157
|
+
counter++;
|
|
158
|
+
stack.push(node);
|
|
159
|
+
onStack.add(node);
|
|
160
|
+
for (const next of successors.get(node) ?? []) {
|
|
161
|
+
if (!index.has(next)) {
|
|
162
|
+
visit(next);
|
|
163
|
+
low.set(node, Math.min(low.get(node)!, low.get(next)!));
|
|
164
|
+
} else if (onStack.has(next)) {
|
|
165
|
+
low.set(node, Math.min(low.get(node)!, index.get(next)!));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (low.get(node) === index.get(node)) {
|
|
169
|
+
const component: string[] = [];
|
|
170
|
+
let member: string;
|
|
171
|
+
do {
|
|
172
|
+
member = stack.pop()!;
|
|
173
|
+
onStack.delete(member);
|
|
174
|
+
component.push(member);
|
|
175
|
+
} while (member !== node);
|
|
176
|
+
components.push(component.sort(compareStrings));
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const node of [...nodes].sort(compareStrings)) if (!index.has(node)) visit(node);
|
|
181
|
+
return components.sort((a, b) => compareStrings(a[0], b[0]));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The shortest cycle through `start` inside the component, as a closed path: A -> B -> A. */
|
|
185
|
+
export function shortestCycle(
|
|
186
|
+
start: string,
|
|
187
|
+
successors: ReadonlyMap<string, readonly string[]>,
|
|
188
|
+
members: ReadonlySet<string>,
|
|
189
|
+
): string[] {
|
|
190
|
+
const previous = new Map<string, string>();
|
|
191
|
+
const queue: string[] = [start];
|
|
192
|
+
const seen = new Set<string>([start]);
|
|
193
|
+
while (queue.length > 0) {
|
|
194
|
+
const node = queue.shift()!;
|
|
195
|
+
for (const next of successors.get(node) ?? []) {
|
|
196
|
+
if (!members.has(next)) continue;
|
|
197
|
+
if (next === start) {
|
|
198
|
+
const path = [start];
|
|
199
|
+
for (let at: string | undefined = node; at !== undefined && at !== start; at = previous.get(at))
|
|
200
|
+
path.splice(1, 0, at);
|
|
201
|
+
path.push(start);
|
|
202
|
+
return path;
|
|
203
|
+
}
|
|
204
|
+
if (seen.has(next)) continue;
|
|
205
|
+
seen.add(next);
|
|
206
|
+
previous.set(next, node);
|
|
207
|
+
queue.push(next);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return [start];
|
|
211
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception records (VSA §9). A record names one rule and the exact paths or
|
|
3
|
+
* edges it tolerates, with rationale, accountable owner, approver, creation
|
|
4
|
+
* and expiry dates and a remediation issue. Records live one per file in
|
|
5
|
+
* `architecture/exceptions/*.yaml` (profile §1), or are handed in.
|
|
6
|
+
*
|
|
7
|
+
* A diagnostic a current record covers is kept, never hidden: it becomes a
|
|
8
|
+
* warning whose message says which exception covers it and until when, so a
|
|
9
|
+
* report built on it reads "with exceptions" rather than as a pass (Core
|
|
10
|
+
* §11). An expired record is itself an error, VSA013, and what it covered
|
|
11
|
+
* stays an error. A record that is malformed is VSA013 and covers nothing; a
|
|
12
|
+
* current record that covers nothing is a VSA013 warning, so retired
|
|
13
|
+
* violations retire their exceptions too.
|
|
14
|
+
*/
|
|
15
|
+
import { ID_PATTERN, parseYamlDetailed } from "@intentset/core";
|
|
16
|
+
import { type Finding, finding, type SubjectEdge } from "./finding.ts";
|
|
17
|
+
import { compareStrings, matchPattern, validatePattern } from "./patterns.ts";
|
|
18
|
+
|
|
19
|
+
export const EXCEPTIONS_PATTERNS = ["architecture/exceptions/*.yaml", "architecture/exceptions/*.yml"];
|
|
20
|
+
|
|
21
|
+
export interface ExceptionRecord {
|
|
22
|
+
id: string;
|
|
23
|
+
rule: string;
|
|
24
|
+
paths: string[];
|
|
25
|
+
edges: SubjectEdge[];
|
|
26
|
+
rationale: string;
|
|
27
|
+
owner: string;
|
|
28
|
+
approver: string;
|
|
29
|
+
/** YYYY-MM-DD. */
|
|
30
|
+
created: string;
|
|
31
|
+
/** YYYY-MM-DD, the last day the exception holds. */
|
|
32
|
+
expires: string;
|
|
33
|
+
remediation: string;
|
|
34
|
+
/** The file it was read from, or null when it was handed in. */
|
|
35
|
+
source: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TEXT_FIELDS = ["id", "rule", "rationale", "owner", "approver", "created", "expires", "remediation"] as const;
|
|
39
|
+
const KNOWN_FIELDS = [...TEXT_FIELDS, "paths", "edges"];
|
|
40
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
41
|
+
const CODE = /^[A-Z]+[0-9]{3}$/;
|
|
42
|
+
|
|
43
|
+
function isDate(text: string): boolean {
|
|
44
|
+
const match = DATE.exec(text);
|
|
45
|
+
if (match === null) return false;
|
|
46
|
+
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
|
47
|
+
return date.toISOString().slice(0, 10) === text;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate one record. Returns the record, or the problems that keep it from covering anything. */
|
|
51
|
+
export function readExceptionRecord(
|
|
52
|
+
value: unknown,
|
|
53
|
+
source: string | null,
|
|
54
|
+
lines: ReadonlyMap<string, number> = new Map(),
|
|
55
|
+
): { record: ExceptionRecord | null; findings: Finding[] } {
|
|
56
|
+
const findings: Finding[] = [];
|
|
57
|
+
const fail = (field: string | undefined, message: string) => {
|
|
58
|
+
const line = field === undefined ? undefined : lines.get(field);
|
|
59
|
+
findings.push(
|
|
60
|
+
finding({
|
|
61
|
+
code: "VSA013",
|
|
62
|
+
artifact: null,
|
|
63
|
+
path: source,
|
|
64
|
+
...(line !== undefined ? { location: { line } } : {}),
|
|
65
|
+
...(field !== undefined ? { field } : {}),
|
|
66
|
+
message,
|
|
67
|
+
remediation:
|
|
68
|
+
"An exception records id, rule, paths or edges, rationale, owner, approver, created, expires and remediation (VSA §9).",
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
73
|
+
fail(undefined, "The exception record is not a mapping.");
|
|
74
|
+
return { record: null, findings };
|
|
75
|
+
}
|
|
76
|
+
const map = value as Record<string, unknown>;
|
|
77
|
+
for (const key of Object.keys(map).sort(compareStrings)) {
|
|
78
|
+
if (!KNOWN_FIELDS.includes(key)) fail(`/${key}`, `The exception record has the unknown key \`${key}\`.`);
|
|
79
|
+
}
|
|
80
|
+
for (const key of TEXT_FIELDS) {
|
|
81
|
+
if (typeof map[key] !== "string" || (map[key] as string).trim() === "") {
|
|
82
|
+
fail(`/${key}`, `The exception record has no \`${key}\`.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const text = (key: (typeof TEXT_FIELDS)[number]) => (typeof map[key] === "string" ? (map[key] as string) : "");
|
|
86
|
+
if (text("id") !== "" && !ID_PATTERN.test(text("id")))
|
|
87
|
+
fail("/id", `The exception id ${text("id")} does not match the ID pattern.`);
|
|
88
|
+
if (text("rule") !== "" && !CODE.test(text("rule")))
|
|
89
|
+
fail("/rule", `The exception rule ${text("rule")} is not a diagnostic code.`);
|
|
90
|
+
for (const key of ["created", "expires"] as const) {
|
|
91
|
+
if (text(key) !== "" && !isDate(text(key)))
|
|
92
|
+
fail(`/${key}`, `The exception's ${key} date ${text(key)} is not a quoted YYYY-MM-DD date.`);
|
|
93
|
+
}
|
|
94
|
+
if (isDate(text("created")) && isDate(text("expires")) && text("expires") < text("created")) {
|
|
95
|
+
fail("/expires", `The exception expires on ${text("expires")}, before it was created on ${text("created")}.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const paths: string[] = [];
|
|
99
|
+
if (map.paths !== undefined && map.paths !== null) {
|
|
100
|
+
if (!Array.isArray(map.paths)) fail("/paths", "The exception's paths is not a list.");
|
|
101
|
+
else
|
|
102
|
+
map.paths.forEach((item, i) => {
|
|
103
|
+
const why = typeof item === "string" ? validatePattern(item) : "is not a string";
|
|
104
|
+
if (why !== null) fail(`/paths/${i}`, `Exception path ${i} ${why}.`);
|
|
105
|
+
else paths.push(item as string);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const edges: SubjectEdge[] = [];
|
|
109
|
+
if (map.edges !== undefined && map.edges !== null) {
|
|
110
|
+
if (!Array.isArray(map.edges)) fail("/edges", "The exception's edges is not a list.");
|
|
111
|
+
else
|
|
112
|
+
map.edges.forEach((item, i) => {
|
|
113
|
+
const edge = item as Record<string, unknown> | null;
|
|
114
|
+
if (typeof edge !== "object" || edge === null || typeof edge.from !== "string" || typeof edge.to !== "string") {
|
|
115
|
+
fail(`/edges/${i}`, `Exception edge ${i} is not a mapping with from and to.`);
|
|
116
|
+
} else edges.push({ from: edge.from, to: edge.to });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (paths.length === 0 && edges.length === 0) {
|
|
120
|
+
fail("/paths", "The exception names no exact paths or edges, and an exception is never a blanket waiver.");
|
|
121
|
+
}
|
|
122
|
+
if (findings.length > 0) return { record: null, findings };
|
|
123
|
+
return {
|
|
124
|
+
record: {
|
|
125
|
+
id: text("id"),
|
|
126
|
+
rule: text("rule"),
|
|
127
|
+
paths,
|
|
128
|
+
edges,
|
|
129
|
+
rationale: text("rationale"),
|
|
130
|
+
owner: text("owner"),
|
|
131
|
+
approver: text("approver"),
|
|
132
|
+
created: text("created"),
|
|
133
|
+
expires: text("expires"),
|
|
134
|
+
remediation: text("remediation"),
|
|
135
|
+
source,
|
|
136
|
+
},
|
|
137
|
+
findings,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every exception file in the tree, read with core's strict YAML reader. */
|
|
142
|
+
export function readExceptions(files: ReadonlyMap<string, string>): {
|
|
143
|
+
records: ExceptionRecord[];
|
|
144
|
+
findings: Finding[];
|
|
145
|
+
} {
|
|
146
|
+
const records: ExceptionRecord[] = [];
|
|
147
|
+
const findings: Finding[] = [];
|
|
148
|
+
for (const path of [...files.keys()].sort(compareStrings)) {
|
|
149
|
+
if (!EXCEPTIONS_PATTERNS.some((pattern) => matchPattern(pattern, path))) continue;
|
|
150
|
+
const parsed = parseYamlDetailed(files.get(path) as string);
|
|
151
|
+
if (parsed.error !== null) {
|
|
152
|
+
findings.push(
|
|
153
|
+
finding({
|
|
154
|
+
code: "VSA013",
|
|
155
|
+
artifact: null,
|
|
156
|
+
path,
|
|
157
|
+
location: { line: parsed.error.line },
|
|
158
|
+
message: `The exception file does not parse: ${parsed.error.message}`,
|
|
159
|
+
remediation: "Write the exception as one block mapping (VSA §9).",
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const read = readExceptionRecord(parsed.value, path, parsed.lines);
|
|
165
|
+
findings.push(...read.findings);
|
|
166
|
+
if (read.record !== null) records.push(read.record);
|
|
167
|
+
}
|
|
168
|
+
return { records, findings };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Does the record name this finding's rule and one of its exact paths or edges? */
|
|
172
|
+
export function covers(record: ExceptionRecord, target: Finding): boolean {
|
|
173
|
+
if (record.rule !== target.diagnostic.code) return false;
|
|
174
|
+
if (record.paths.some((pattern) => target.paths.some((path) => matchPattern(pattern, path)))) return true;
|
|
175
|
+
return record.edges.some((edge) => target.edges.some((other) => other.from === edge.from && other.to === edge.to));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Put a note in parentheses before a message's final period. */
|
|
179
|
+
export function annotate(message: string, note: string): string {
|
|
180
|
+
return message.endsWith(".") ? `${message.slice(0, -1)} (${note}).` : `${message} (${note})`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Apply exceptions on a given date (YYYY-MM-DD). Returns the findings with
|
|
185
|
+
* covered ones rewritten, the VSA013 findings about the records themselves,
|
|
186
|
+
* and how many diagnostics a current exception covers.
|
|
187
|
+
*/
|
|
188
|
+
export function applyExceptions(
|
|
189
|
+
findings: readonly Finding[],
|
|
190
|
+
records: readonly ExceptionRecord[],
|
|
191
|
+
today: string,
|
|
192
|
+
): { findings: Finding[]; excepted: number; recordFindings: Finding[] } {
|
|
193
|
+
const recordFindings: Finding[] = [];
|
|
194
|
+
const seen = new Map<string, ExceptionRecord>();
|
|
195
|
+
const usable: ExceptionRecord[] = [];
|
|
196
|
+
for (const record of [...records].sort(
|
|
197
|
+
(a, b) => compareStrings(a.id, b.id) || compareStrings(a.source ?? "", b.source ?? ""),
|
|
198
|
+
)) {
|
|
199
|
+
const earlier = seen.get(record.id);
|
|
200
|
+
if (earlier !== undefined) {
|
|
201
|
+
recordFindings.push(
|
|
202
|
+
finding({
|
|
203
|
+
code: "VSA013",
|
|
204
|
+
artifact: null,
|
|
205
|
+
path: record.source,
|
|
206
|
+
field: "/id",
|
|
207
|
+
message: `Exception ${record.id} is recorded twice, here and in ${earlier.source ?? "the records handed in"}.`,
|
|
208
|
+
remediation: "Keep one record per exception ID; IDs are never reused (VSA §9).",
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
seen.set(record.id, record);
|
|
214
|
+
usable.push(record);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let excepted = 0;
|
|
218
|
+
const used = new Set<string>();
|
|
219
|
+
const out = findings.map((item) => {
|
|
220
|
+
const record = usable.find((candidate) => covers(candidate, item));
|
|
221
|
+
if (record === undefined) return item;
|
|
222
|
+
used.add(record.id);
|
|
223
|
+
if (record.expires < today) {
|
|
224
|
+
return {
|
|
225
|
+
...item,
|
|
226
|
+
diagnostic: {
|
|
227
|
+
...item.diagnostic,
|
|
228
|
+
message: annotate(item.diagnostic.message, `exception ${record.id} expired ${record.expires}`),
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
excepted++;
|
|
233
|
+
return {
|
|
234
|
+
...item,
|
|
235
|
+
diagnostic: {
|
|
236
|
+
...item.diagnostic,
|
|
237
|
+
severity: "warning" as const,
|
|
238
|
+
message: annotate(item.diagnostic.message, `excepted by ${record.id} until ${record.expires}`),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
for (const record of usable) {
|
|
244
|
+
if (record.expires < today) {
|
|
245
|
+
recordFindings.push(
|
|
246
|
+
finding({
|
|
247
|
+
code: "VSA013",
|
|
248
|
+
artifact: null,
|
|
249
|
+
path: record.source,
|
|
250
|
+
field: "/expires",
|
|
251
|
+
message: `Exception ${record.id} for ${record.rule} expired on ${record.expires}, so what it covered is an error again.`,
|
|
252
|
+
remediation: `Fix the violation tracked by ${record.remediation}, or have ${record.approver} renew the exception with a new expiry (VSA §9).`,
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
} else if (!used.has(record.id)) {
|
|
256
|
+
recordFindings.push(
|
|
257
|
+
finding({
|
|
258
|
+
code: "VSA013",
|
|
259
|
+
severity: "warning",
|
|
260
|
+
artifact: null,
|
|
261
|
+
path: record.source,
|
|
262
|
+
message: `Exception ${record.id} for ${record.rule} covers no current diagnostic.`,
|
|
263
|
+
remediation: "Remove the record; the violation it tolerated is gone (VSA §9).",
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { findings: out, excepted, recordFindings };
|
|
269
|
+
}
|