@intentset/architecture 0.8.1 → 0.9.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.
Files changed (56) hide show
  1. package/dist/areas.d.ts +1 -1
  2. package/dist/areas.d.ts.map +1 -1
  3. package/dist/areas.js +3 -0
  4. package/dist/areas.js.map +1 -1
  5. package/dist/baseline.d.ts +96 -11
  6. package/dist/baseline.d.ts.map +1 -1
  7. package/dist/baseline.js +219 -22
  8. package/dist/baseline.js.map +1 -1
  9. package/dist/boundaries.d.ts +11 -2
  10. package/dist/boundaries.d.ts.map +1 -1
  11. package/dist/boundaries.js +175 -17
  12. package/dist/boundaries.js.map +1 -1
  13. package/dist/check.d.ts +9 -2
  14. package/dist/check.d.ts.map +1 -1
  15. package/dist/check.js +21 -6
  16. package/dist/check.js.map +1 -1
  17. package/dist/config.d.ts +10 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +32 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/extract.d.ts +36 -1
  22. package/dist/extract.d.ts.map +1 -1
  23. package/dist/extract.js +142 -22
  24. package/dist/extract.js.map +1 -1
  25. package/dist/finding.d.ts +12 -0
  26. package/dist/finding.d.ts.map +1 -1
  27. package/dist/finding.js +6 -1
  28. package/dist/finding.js.map +1 -1
  29. package/dist/imports.d.ts +16 -0
  30. package/dist/imports.d.ts.map +1 -1
  31. package/dist/imports.js +17 -2
  32. package/dist/imports.js.map +1 -1
  33. package/dist/index.d.ts +8 -7
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +6 -5
  36. package/dist/index.js.map +1 -1
  37. package/dist/layers.d.ts +6 -0
  38. package/dist/layers.d.ts.map +1 -1
  39. package/dist/layers.js +19 -0
  40. package/dist/layers.js.map +1 -1
  41. package/dist/names.d.ts +48 -0
  42. package/dist/names.d.ts.map +1 -0
  43. package/dist/names.js +194 -0
  44. package/dist/names.js.map +1 -0
  45. package/package.json +2 -2
  46. package/src/areas.ts +3 -1
  47. package/src/baseline.ts +266 -24
  48. package/src/boundaries.ts +191 -22
  49. package/src/check.ts +34 -7
  50. package/src/config.ts +44 -1
  51. package/src/extract.ts +168 -22
  52. package/src/finding.ts +19 -1
  53. package/src/imports.ts +25 -3
  54. package/src/index.ts +16 -4
  55. package/src/layers.ts +18 -0
  56. package/src/names.ts +215 -0
package/src/baseline.ts CHANGED
@@ -5,14 +5,33 @@
5
5
  * warning marked "(baselined)" and is counted apart; in strict mode the
6
6
  * baseline is ignored, so the two modes are always distinguishable.
7
7
  *
8
- * An entry fingerprints the code, path, artifact, field and message, and the
9
- * messages carry no line numbers, so moving an import does not unbaseline it
10
- * while a new file, a new target or a larger cycle is a new fingerprint and
11
- * stays an error. Matching is one entry per diagnostic: a baseline of one
12
- * violation does not absorb a second copy of it.
8
+ * An entry fingerprints what a finding is about, not how its message reads
9
+ * (0.9, closing #20 and #88): the code, path, artifact and field, then, for a
10
+ * finding about one import, how it imports, its target and one name it takes,
11
+ * so a finding about an import is held one name at a time; for another finding
12
+ * with edges, its file and slice edges; for one with neither edges nor a
13
+ * field, its message, which is all that tells it from another. Nothing hashed
14
+ * carries a line number, so moving an import does not unbaseline it, while a
15
+ * new file, a new target, a new name or a larger cycle is a new entry and
16
+ * stays an error. A message that names unrelated state, such as AMP007's list
17
+ * of declared areas, no longer moves a fingerprint. Matching is one entry per
18
+ * key: a baseline of one violation does not absorb a second copy of it. An
19
+ * entry also keeps the name and the message it was written from, so a reader
20
+ * of the file, or of a diff of it, sees what each entry holds.
21
+ *
22
+ * An entry written before 0.9 fingerprints the whole message. It still
23
+ * matches, by that fingerprint, and goes when the baseline is written again,
24
+ * which writes the current form; a review reads that as a rewrite, not as
25
+ * growth.
26
+ *
27
+ * A baseline only shrinks. An entry that matches nothing in migration mode is
28
+ * VSA014, an error: the violation it held is gone, and the entry would let it
29
+ * come back unnoticed. `compareBaselines` says what a change added to a
30
+ * baseline and what it retired, for `intentset review` (VSA §9).
13
31
  */
14
32
  import { canonicalJson, type Diagnostic, sha256Hex } from "@intentset/core";
15
33
  import { annotate } from "./exceptions.ts";
34
+ import type { SubjectEdge, Taken } from "./finding.ts";
16
35
  import { compareStrings } from "./patterns.ts";
17
36
 
18
37
  export type Mode = "migration" | "strict";
@@ -21,10 +40,28 @@ export interface BaselineEntry {
21
40
  code: string;
22
41
  path: string | null;
23
42
  artifact?: string;
43
+ /** For an entry about an import, the one name it holds (`type X` for a type-only name, `*` for every name). */
44
+ name?: string;
45
+ /** The diagnostic's message without notes. Written since 0.9; an older file's entries have none. */
46
+ message?: string;
47
+ /** `v2-` and a hash of what the finding is about since 0.9; a bare hash of the whole message before. */
24
48
  fingerprint: string;
25
49
  }
26
50
 
27
- const NOTES = / \((?:baselined|excepted by [^()]*|exception [^()]* expired [^()]*)\)(?=\.?$)/;
51
+ /** What a diagnostic is about beyond its code and place, from the finding it came from. */
52
+ export interface BaselineSubject {
53
+ edges: readonly SubjectEdge[];
54
+ taken?: Taken;
55
+ }
56
+
57
+ /** The prefix of a fingerprint written since 0.9. */
58
+ export const FINGERPRINT_V2 = "v2-";
59
+
60
+ /** A stale entry: reported, never recorded in a baseline. */
61
+ export const STALE_ENTRY_CODE = "VSA014";
62
+
63
+ const NOTES =
64
+ / \((?:baselined|excepted by [^()]*|exception [^()]* expired [^()]*|not in the baseline: [^()]*)\)(?=\.?$)/;
28
65
 
29
66
  /** The message without the notes exceptions and baselines add. */
30
67
  export function plainMessage(message: string): string {
@@ -36,6 +73,7 @@ export function plainMessage(message: string): string {
36
73
  return out;
37
74
  }
38
75
 
76
+ /** The fingerprint before 0.9: the whole message. Kept so a baseline written then still matches. */
39
77
  export function fingerprint(diagnostic: Diagnostic): string {
40
78
  return sha256Hex(
41
79
  canonicalJson({
@@ -52,16 +90,58 @@ function isBaselined(diagnostic: Diagnostic): boolean {
52
90
  return diagnostic.severity === "warning" && / \(baselined\)\.?$/.test(diagnostic.message);
53
91
  }
54
92
 
55
- /** Record every error, and every diagnostic already baselined, as the baseline to adopt. Sorted. */
56
- export function writeBaseline(diagnostics: readonly Diagnostic[]): BaselineEntry[] {
93
+ /**
94
+ * The keys a diagnostic is held by: one for each name a finding about an import takes, else one. `subject` is what
95
+ * the finding is about; a diagnostic with none, such as a configuration error, is held by its field or its message.
96
+ */
97
+ export function baselineKeys(
98
+ diagnostic: Diagnostic,
99
+ subject?: BaselineSubject,
100
+ ): { name: string | null; fingerprint: string }[] {
101
+ const place = {
102
+ code: diagnostic.code,
103
+ path: diagnostic.path,
104
+ artifact: diagnostic.artifact,
105
+ field: diagnostic.field ?? null,
106
+ };
107
+ const key = (identity: Record<string, unknown>, name: string | null) => ({
108
+ name,
109
+ fingerprint: `${FINGERPRINT_V2}${sha256Hex(canonicalJson({ ...place, ...identity, name }))}`,
110
+ });
111
+ if (subject?.taken !== undefined) {
112
+ const { kind, to, names } = subject.taken;
113
+ const each = names.length === 0 ? [null] : [...new Set(names)].sort(compareStrings);
114
+ return each.map((name) => key({ kind, to }, name));
115
+ }
116
+ if (subject !== undefined && subject.edges.length > 0) {
117
+ const edges = [...new Set(subject.edges.map((edge) => `${edge.from} -> ${edge.to}`))].sort(compareStrings);
118
+ return [key({ edges }, null)];
119
+ }
120
+ if (diagnostic.field !== undefined) return [key({}, null)];
121
+ return [key({ message: plainMessage(diagnostic.message) }, null)];
122
+ }
123
+
124
+ /**
125
+ * Record every error, and every diagnostic already baselined, as the baseline to adopt: one entry per key. Sorted.
126
+ * A stale entry's VSA014 is not recorded: writing the baseline again is how a stale entry is retired.
127
+ */
128
+ export function writeBaseline(
129
+ diagnostics: readonly Diagnostic[],
130
+ subjectOf: (diagnostic: Diagnostic) => BaselineSubject | undefined = () => undefined,
131
+ ): BaselineEntry[] {
57
132
  return diagnostics
58
133
  .filter((diagnostic) => diagnostic.severity === "error" || isBaselined(diagnostic))
59
- .map((diagnostic) => ({
60
- code: diagnostic.code,
61
- path: diagnostic.path,
62
- ...(diagnostic.artifact !== null ? { artifact: diagnostic.artifact } : {}),
63
- fingerprint: fingerprint(diagnostic),
64
- }))
134
+ .filter((diagnostic) => diagnostic.code !== STALE_ENTRY_CODE)
135
+ .flatMap((diagnostic) =>
136
+ baselineKeys(diagnostic, subjectOf(diagnostic)).map((key) => ({
137
+ code: diagnostic.code,
138
+ path: diagnostic.path,
139
+ ...(diagnostic.artifact !== null ? { artifact: diagnostic.artifact } : {}),
140
+ ...(key.name !== null ? { name: key.name } : {}),
141
+ message: plainMessage(diagnostic.message),
142
+ fingerprint: key.fingerprint,
143
+ })),
144
+ )
65
145
  .sort(
66
146
  (a, b) =>
67
147
  compareStrings(a.code, b.code) ||
@@ -82,28 +162,34 @@ export function parseBaseline(text: string): BaselineEntry[] {
82
162
  typeof entry.code !== "string" ||
83
163
  typeof entry.fingerprint !== "string" ||
84
164
  !(typeof entry.path === "string" || entry.path === null) ||
85
- !(entry.artifact === undefined || typeof entry.artifact === "string")
165
+ !(entry.artifact === undefined || typeof entry.artifact === "string") ||
166
+ !(entry.name === undefined || typeof entry.name === "string") ||
167
+ !(entry.message === undefined || typeof entry.message === "string")
86
168
  ) {
87
- throw new Error(`baseline entry ${i} is not { code, path, artifact?, fingerprint }`);
169
+ throw new Error(`baseline entry ${i} is not { code, path, artifact?, name?, message?, fingerprint }`);
88
170
  }
89
171
  return {
90
172
  code: entry.code,
91
173
  path: entry.path as string | null,
92
174
  ...(typeof entry.artifact === "string" ? { artifact: entry.artifact } : {}),
175
+ ...(typeof entry.name === "string" ? { name: entry.name } : {}),
176
+ ...(typeof entry.message === "string" ? { message: entry.message } : {}),
93
177
  fingerprint: entry.fingerprint,
94
178
  };
95
179
  });
96
180
  }
97
181
 
98
182
  /**
99
- * Apply a baseline. In migration mode each entry turns one matching error
100
- * into a "(baselined)" warning; entries nothing matched are returned as
101
- * stale. Strict mode returns the diagnostics unchanged.
183
+ * Apply a baseline. In migration mode an error is baselined, a "(baselined)" warning, when an entry holds each of its
184
+ * keys, or, for a baseline written before 0.9, when one entry holds its whole message. An error with some of its
185
+ * names held stays an error, noting the names the baseline does not hold, and the held ones are not stale. Entries
186
+ * nothing matched are returned as stale. Strict mode returns the diagnostics unchanged.
102
187
  */
103
188
  export function applyBaseline(
104
189
  diagnostics: readonly Diagnostic[],
105
190
  baseline: readonly BaselineEntry[],
106
191
  mode: Mode = "migration",
192
+ subjectOf: (diagnostic: Diagnostic) => BaselineSubject | undefined = () => undefined,
107
193
  ): { diagnostics: Diagnostic[]; baselined: number; stale: BaselineEntry[] } {
108
194
  if (mode === "strict") return { diagnostics: [...diagnostics], baselined: 0, stale: [] };
109
195
  const remaining = new Map<string, BaselineEntry[]>();
@@ -112,15 +198,171 @@ export function applyBaseline(
112
198
  list.push(entry);
113
199
  remaining.set(entry.fingerprint, list);
114
200
  }
201
+ const held = (fingerprint: string) => (remaining.get(fingerprint)?.length ?? 0) > 0;
202
+ const take = (fingerprint: string) => remaining.get(fingerprint)?.pop();
115
203
  let baselined = 0;
116
204
  const out = diagnostics.map((diagnostic) => {
117
205
  if (diagnostic.severity !== "error") return diagnostic;
118
- const list = remaining.get(fingerprint(diagnostic));
119
- if (list === undefined || list.length === 0) return diagnostic;
120
- list.pop();
121
- baselined++;
122
- return { ...diagnostic, severity: "warning" as const, message: annotate(diagnostic.message, "baselined") };
206
+ const keys = baselineKeys(diagnostic, subjectOf(diagnostic));
207
+ const have = keys.filter((key) => held(key.fingerprint));
208
+ const legacy = fingerprint(diagnostic);
209
+ if (have.length === keys.length || held(legacy)) {
210
+ if (have.length === keys.length) for (const key of keys) take(key.fingerprint);
211
+ else take(legacy);
212
+ baselined++;
213
+ return { ...diagnostic, severity: "warning" as const, message: annotate(diagnostic.message, "baselined") };
214
+ }
215
+ for (const key of have) take(key.fingerprint);
216
+ if (have.length === 0) return diagnostic;
217
+ const missing = keys.filter((key) => !have.includes(key)).map((key) => key.name ?? "it");
218
+ return { ...diagnostic, message: annotate(diagnostic.message, `not in the baseline: ${missing.join(", ")}`) };
123
219
  });
124
220
  const stale = [...remaining.values()].flat().sort((a, b) => compareStrings(a.fingerprint, b.fingerprint));
125
221
  return { diagnostics: out, baselined, stale };
126
222
  }
223
+
224
+ /** What an entry says it holds: its code, path and message, or its fingerprint when it was written without one. */
225
+ export function describeEntry(entry: BaselineEntry): string {
226
+ const where = `${entry.code} ${entry.path ?? "(no path)"}${entry.name === undefined ? "" : ` [${entry.name}]`}`;
227
+ return entry.message === undefined
228
+ ? `${where} (fingerprint ${entry.fingerprint.slice(0, 16)})`
229
+ : `${where}: ${entry.message}`;
230
+ }
231
+
232
+ /**
233
+ * VSA014 for each entry a migration run matched to nothing. `baseline` is the whole baseline, so each diagnostic
234
+ * points at its entry's index in the file; `path` is the file's repository-relative path when it is known.
235
+ */
236
+ export function staleEntryDiagnostics(
237
+ stale: readonly BaselineEntry[],
238
+ baseline: readonly BaselineEntry[],
239
+ path: string | null,
240
+ ): Diagnostic[] {
241
+ return stale.map((entry) => {
242
+ const index = baseline.indexOf(entry);
243
+ return {
244
+ code: STALE_ENTRY_CODE,
245
+ severity: "error" as const,
246
+ origin: "architecture",
247
+ artifact: entry.artifact ?? null,
248
+ path,
249
+ ...(index >= 0 ? { field: `/${index}` } : {}),
250
+ message: `A baseline entry matches no diagnostic, so the violation it held is gone and the entry would let it come back: ${describeEntry(entry).replace(/\.$/, "")}.`,
251
+ remediation:
252
+ "Remove the entry, or write the baseline again with --write-baseline, so the baseline only shrinks (VSA §9, VSA014).",
253
+ };
254
+ });
255
+ }
256
+
257
+ export interface BaselineComparison {
258
+ /** Entries the base did not hold: the baseline grew by these. */
259
+ added: BaselineEntry[];
260
+ /** Entries the base held that are gone: retired. */
261
+ removed: BaselineEntry[];
262
+ /** An entry whose file was renamed, with what it says renamed too: neither added nor retired. */
263
+ moved: { from: BaselineEntry; to: BaselineEntry }[];
264
+ /** A VSA005 cycle replaced by one among the same slices or fewer, with no more edges: a shrink, not growth. */
265
+ replaced: { from: BaselineEntry; to: BaselineEntry }[];
266
+ /**
267
+ * An entry written before 0.9 and the entries written since for the same code, path and artifact (and message,
268
+ * where both carry one): the same violations in the current form, one entry per name.
269
+ */
270
+ rewritten: { from: BaselineEntry; to: BaselineEntry[] }[];
271
+ }
272
+
273
+ const CYCLE =
274
+ /^Slices (.+) form a compile-time dependency cycle, .*, from (\d+) observed import edges? and (\d+) declared dependsOn edges?\.$/;
275
+
276
+ function cycleOf(entry: BaselineEntry): { slices: Set<string>; edges: number } | null {
277
+ const match = entry.code === "VSA005" && entry.message !== undefined ? CYCLE.exec(entry.message) : null;
278
+ if (match === null) return null;
279
+ return { slices: new Set(match[1].split(", ")), edges: Number(match[2]) + Number(match[3]) };
280
+ }
281
+
282
+ /** `text` with every renamed path in it replaced by its new path, longest old path first. */
283
+ function renamed(text: string, renames: ReadonlyMap<string, string>): string {
284
+ let out = text;
285
+ for (const [from, to] of [...renames].sort((a, b) => b[0].length - a[0].length || compareStrings(a[0], b[0]))) {
286
+ out = out.split(from).join(to);
287
+ }
288
+ return out;
289
+ }
290
+
291
+ /**
292
+ * What a change did to a baseline: `base` is the base's copy (null when the base has none, so every entry is new),
293
+ * `head` the change's, `renames` the files the change renamed, old path to new. Entries are matched one for one by
294
+ * fingerprint. Of the rest, an entry is moved when renaming its path and message gives another's, or, for an entry
295
+ * written without a message, when its path was renamed to the other's with the same code and artifact; and a cycle
296
+ * is replaced when the new one's slices are among the old one's and it has no more edges. Each list is sorted.
297
+ */
298
+ export function compareBaselines(
299
+ base: readonly BaselineEntry[] | null,
300
+ head: readonly BaselineEntry[],
301
+ renames: ReadonlyMap<string, string> = new Map(),
302
+ ): BaselineComparison {
303
+ const waiting = new Map<string, BaselineEntry[]>();
304
+ for (const entry of base ?? []) {
305
+ const list = waiting.get(entry.fingerprint) ?? [];
306
+ list.push(entry);
307
+ waiting.set(entry.fingerprint, list);
308
+ }
309
+ let added: BaselineEntry[] = [];
310
+ for (const entry of head) {
311
+ const list = waiting.get(entry.fingerprint);
312
+ if (list !== undefined && list.length > 0) list.pop();
313
+ else added.push(entry);
314
+ }
315
+ let removed = [...waiting.values()].flat();
316
+ const pair = (test: (from: BaselineEntry, to: BaselineEntry) => boolean) => {
317
+ const pairs: { from: BaselineEntry; to: BaselineEntry }[] = [];
318
+ for (const to of [...added]) {
319
+ const from = removed.find((candidate) => test(candidate, to));
320
+ if (from === undefined) continue;
321
+ pairs.push({ from, to });
322
+ removed = removed.filter((entry) => entry !== from);
323
+ added = added.filter((entry) => entry !== to);
324
+ }
325
+ return pairs;
326
+ };
327
+ const moved = pair((from, to) => {
328
+ if (from.code !== to.code || from.artifact !== to.artifact || from.path === null || to.path === null) return false;
329
+ if (from.name !== to.name) return false;
330
+ if (renamed(from.path, renames) !== to.path) return false;
331
+ if (from.message === undefined || to.message === undefined) return renames.get(from.path) === to.path;
332
+ return renamed(from.message, renames) === to.message;
333
+ });
334
+ const replaced = pair((from, to) => {
335
+ const old = cycleOf(from);
336
+ const now = cycleOf(to);
337
+ if (old === null || now === null || ![...now.slices].every((slice) => old.slices.has(slice))) return false;
338
+ return now.slices.size < old.slices.size || now.edges <= old.edges;
339
+ });
340
+ const rewritten: { from: BaselineEntry; to: BaselineEntry[] }[] = [];
341
+ for (const from of [...removed]) {
342
+ if (from.fingerprint.startsWith(FINGERPRINT_V2)) continue;
343
+ const to = added.filter(
344
+ (entry) =>
345
+ entry.fingerprint.startsWith(FINGERPRINT_V2) &&
346
+ entry.code === from.code &&
347
+ entry.path === from.path &&
348
+ entry.artifact === from.artifact &&
349
+ (from.message === undefined || entry.message === from.message),
350
+ );
351
+ if (to.length === 0) continue;
352
+ rewritten.push({ from, to });
353
+ removed = removed.filter((entry) => entry !== from);
354
+ added = added.filter((entry) => !to.includes(entry));
355
+ }
356
+ const order = (a: BaselineEntry, b: BaselineEntry) =>
357
+ compareStrings(a.code, b.code) ||
358
+ compareStrings(a.path ?? "", b.path ?? "") ||
359
+ compareStrings(a.fingerprint, b.fingerprint);
360
+ const byTo = (a: { to: BaselineEntry }, b: { to: BaselineEntry }) => order(a.to, b.to);
361
+ return {
362
+ added: added.sort(order),
363
+ removed: removed.sort(order),
364
+ moved: moved.sort(byTo),
365
+ replaced: replaced.sort(byTo),
366
+ rewritten: rewritten.sort((a, b) => order(a.from, b.from)),
367
+ };
368
+ }