@goodbones/core 0.1.0-beta.1 → 0.1.0-beta.10

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 (164) hide show
  1. package/build/dts/core/campaigns.d.ts +141 -0
  2. package/build/dts/core/campaigns.d.ts.map +1 -0
  3. package/build/dts/core/coverage.d.ts +20 -0
  4. package/build/dts/core/coverage.d.ts.map +1 -1
  5. package/build/dts/core/graph.d.ts +2 -0
  6. package/build/dts/core/graph.d.ts.map +1 -1
  7. package/build/dts/core/imports.d.ts +3 -1
  8. package/build/dts/core/imports.d.ts.map +1 -1
  9. package/build/dts/core/ledger.d.ts +61 -0
  10. package/build/dts/core/ledger.d.ts.map +1 -0
  11. package/build/dts/core/slack.d.ts +22 -0
  12. package/build/dts/core/slack.d.ts.map +1 -0
  13. package/build/dts/core/structure.d.ts +1 -0
  14. package/build/dts/core/structure.d.ts.map +1 -1
  15. package/build/dts/domain/architecture-config.d.ts +324 -1
  16. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  17. package/build/dts/domain/architecture-error.d.ts +8 -0
  18. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  19. package/build/dts/domain/facts.d.ts +1 -0
  20. package/build/dts/domain/facts.d.ts.map +1 -1
  21. package/build/dts/domain/manifest-location.d.ts +9 -0
  22. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  23. package/build/dts/domain/report.d.ts +16 -0
  24. package/build/dts/domain/report.d.ts.map +1 -0
  25. package/build/dts/domain/snapshot.d.ts +224 -0
  26. package/build/dts/domain/snapshot.d.ts.map +1 -0
  27. package/build/dts/domain/violation.d.ts +1 -1
  28. package/build/dts/domain/violation.d.ts.map +1 -1
  29. package/build/dts/index.d.ts +24 -9
  30. package/build/dts/index.d.ts.map +1 -1
  31. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  32. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  33. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  34. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  35. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  36. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  37. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  38. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  39. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  40. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  41. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  42. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  43. package/build/dts/infrastructure/walk.d.ts +6 -0
  44. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  45. package/build/dts/load/policy.d.ts +16 -0
  46. package/build/dts/load/policy.d.ts.map +1 -1
  47. package/build/dts/manifest/compile.d.ts +7 -2
  48. package/build/dts/manifest/compile.d.ts.map +1 -1
  49. package/build/dts/manifest/expand.d.ts +27 -0
  50. package/build/dts/manifest/expand.d.ts.map +1 -0
  51. package/build/dts/manifest/infer.d.ts +55 -0
  52. package/build/dts/manifest/infer.d.ts.map +1 -0
  53. package/build/dts/manifest/json-schema.d.ts +10 -0
  54. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  55. package/build/dts/manifest/manifest.d.ts +381 -1
  56. package/build/dts/manifest/manifest.d.ts.map +1 -1
  57. package/build/dts/ports/campaign-predicate.d.ts +24 -0
  58. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  59. package/build/dts/ports/language.d.ts +4 -0
  60. package/build/dts/ports/language.d.ts.map +1 -1
  61. package/build/dts/ports/report-source.d.ts +14 -0
  62. package/build/dts/ports/report-source.d.ts.map +1 -0
  63. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  64. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  65. package/build/dts/testing.d.ts +2 -0
  66. package/build/dts/testing.d.ts.map +1 -1
  67. package/build/esm/core/campaigns.js +778 -0
  68. package/build/esm/core/campaigns.js.map +1 -0
  69. package/build/esm/core/coverage.js +107 -32
  70. package/build/esm/core/coverage.js.map +1 -1
  71. package/build/esm/core/graph.js +45 -0
  72. package/build/esm/core/graph.js.map +1 -1
  73. package/build/esm/core/imports.js +14 -9
  74. package/build/esm/core/imports.js.map +1 -1
  75. package/build/esm/core/ledger.js +171 -0
  76. package/build/esm/core/ledger.js.map +1 -0
  77. package/build/esm/core/slack.js +76 -0
  78. package/build/esm/core/slack.js.map +1 -0
  79. package/build/esm/core/structure.js +5 -2
  80. package/build/esm/core/structure.js.map +1 -1
  81. package/build/esm/domain/architecture-config.js +158 -1
  82. package/build/esm/domain/architecture-config.js.map +1 -1
  83. package/build/esm/domain/architecture-error.js +27 -0
  84. package/build/esm/domain/architecture-error.js.map +1 -1
  85. package/build/esm/domain/manifest-location.js +21 -0
  86. package/build/esm/domain/manifest-location.js.map +1 -0
  87. package/build/esm/domain/report.js +168 -0
  88. package/build/esm/domain/report.js.map +1 -0
  89. package/build/esm/domain/snapshot.js +141 -0
  90. package/build/esm/domain/snapshot.js.map +1 -0
  91. package/build/esm/domain/violation.js.map +1 -1
  92. package/build/esm/index.js +21 -8
  93. package/build/esm/index.js.map +1 -1
  94. package/build/esm/infrastructure/campaign-functions.js +67 -0
  95. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  96. package/build/esm/infrastructure/manifest-file.js +158 -7
  97. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  98. package/build/esm/infrastructure/manifest-include.js +187 -0
  99. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  100. package/build/esm/infrastructure/report-source-fake.js +6 -0
  101. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  102. package/build/esm/infrastructure/report-source-live.js +165 -0
  103. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  104. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  105. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  106. package/build/esm/infrastructure/walk.js +57 -1
  107. package/build/esm/infrastructure/walk.js.map +1 -1
  108. package/build/esm/load/policy.js +123 -3
  109. package/build/esm/load/policy.js.map +1 -1
  110. package/build/esm/manifest/compile.js +228 -26
  111. package/build/esm/manifest/compile.js.map +1 -1
  112. package/build/esm/manifest/expand.js +116 -0
  113. package/build/esm/manifest/expand.js.map +1 -0
  114. package/build/esm/manifest/infer.js +455 -0
  115. package/build/esm/manifest/infer.js.map +1 -0
  116. package/build/esm/manifest/json-schema.js +135 -0
  117. package/build/esm/manifest/json-schema.js.map +1 -0
  118. package/build/esm/manifest/manifest.js +243 -5
  119. package/build/esm/manifest/manifest.js.map +1 -1
  120. package/build/esm/ports/campaign-predicate.js +2 -0
  121. package/build/esm/ports/campaign-predicate.js.map +1 -0
  122. package/build/esm/ports/report-source.js +7 -0
  123. package/build/esm/ports/report-source.js.map +1 -0
  124. package/build/esm/ports/syntax-matcher.js +12 -0
  125. package/build/esm/ports/syntax-matcher.js.map +1 -0
  126. package/build/esm/testing.js +2 -0
  127. package/build/esm/testing.js.map +1 -1
  128. package/package.json +9 -3
  129. package/schema/architecture-node.schema.json +2405 -0
  130. package/schema/architecture.schema.json +2825 -0
  131. package/schema/conformance.schema.json +775 -0
  132. package/src/core/campaigns.ts +1056 -0
  133. package/src/core/coverage.ts +164 -34
  134. package/src/core/graph.ts +48 -0
  135. package/src/core/imports.ts +29 -13
  136. package/src/core/ledger.ts +242 -0
  137. package/src/core/slack.ts +135 -0
  138. package/src/core/structure.ts +10 -5
  139. package/src/domain/architecture-config.ts +205 -1
  140. package/src/domain/architecture-error.ts +30 -0
  141. package/src/domain/facts.ts +9 -4
  142. package/src/domain/manifest-location.ts +41 -0
  143. package/src/domain/report.ts +203 -0
  144. package/src/domain/snapshot.ts +302 -0
  145. package/src/domain/violation.ts +4 -1
  146. package/src/index.ts +172 -3
  147. package/src/infrastructure/campaign-functions.ts +98 -0
  148. package/src/infrastructure/manifest-file.ts +204 -8
  149. package/src/infrastructure/manifest-include.ts +318 -0
  150. package/src/infrastructure/report-source-fake.ts +10 -0
  151. package/src/infrastructure/report-source-live.ts +192 -0
  152. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  153. package/src/infrastructure/walk.ts +70 -1
  154. package/src/load/policy.ts +193 -3
  155. package/src/manifest/compile.ts +290 -28
  156. package/src/manifest/expand.ts +183 -0
  157. package/src/manifest/infer.ts +643 -0
  158. package/src/manifest/json-schema.ts +168 -0
  159. package/src/manifest/manifest.ts +339 -11
  160. package/src/ports/campaign-predicate.ts +30 -0
  161. package/src/ports/language.ts +17 -0
  162. package/src/ports/report-source.ts +35 -0
  163. package/src/ports/syntax-matcher.ts +42 -0
  164. package/src/testing.ts +2 -0
@@ -0,0 +1,203 @@
1
+ // A campaign's `report` term names a pattern no parser of ours sees: a type
2
+ // error, a lint finding from another tool, anything a program prints one
3
+ // line per occurrence. This is the vocabulary such a report is read into —
4
+ // a diagnostic with a file, a position, a code and a message — and the
5
+ // readers for the formats a report comes in. Pure: text in, diagnostics out.
6
+ // Running the command or reading the file is the report source's business.
7
+
8
+ export type Diagnostic = {
9
+ // Repo-relative, forward slashes.
10
+ readonly file: string;
11
+ // Zero-based, as a syntax match's range is.
12
+ readonly line: number;
13
+ readonly column: number;
14
+ // `TS2551`, `eslint(no-unused-vars)`, `no-debugger` — whatever the tool
15
+ // calls the kind of finding; `""` when the format carries none.
16
+ readonly code: string;
17
+ // The first line of the message.
18
+ readonly message: string;
19
+ };
20
+
21
+ export type ReportFormat = "tsc" | "eslint" | "oxlint" | "regex";
22
+
23
+ export type ParseReportOptions = {
24
+ // Absolute paths in the report are made relative to this.
25
+ readonly repoRoot: string;
26
+ // `regex` only: a pattern with named groups `file` and `line`, and
27
+ // optionally `column`, `code` and `message`; one diagnostic per line that
28
+ // matches, positions one-based as tools print them.
29
+ readonly pattern?: string | undefined;
30
+ };
31
+
32
+ const relativeTo = (repoRoot: string, file: string): string => {
33
+ const slashed = file.replaceAll("\\", "/");
34
+ const root = repoRoot.replaceAll("\\", "/").replace(/\/$/, "");
35
+ const relative =
36
+ slashed === root
37
+ ? ""
38
+ : slashed.startsWith(`${root}/`)
39
+ ? slashed.slice(root.length + 1)
40
+ : slashed;
41
+ return relative.replace(/^\.\//, "");
42
+ };
43
+
44
+ const oneBased = (value: string | undefined, fallback = 1): number => {
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed - 1 : fallback - 1;
47
+ };
48
+
49
+ const firstLine = (message: string): string => message.split(/\r?\n/, 1)[0]?.trim() ?? "";
50
+
51
+ // `src/a.ts(12,5): error TS2551: Property 'x' does not exist…`, one per
52
+ // line, with the continuation lines tsc indents beneath a message ignored —
53
+ // the first line is the message.
54
+ const TSC_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
55
+
56
+ const parseTsc = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
57
+ const found: Array<Diagnostic> = [];
58
+ for (const line of text.split(/\r?\n/)) {
59
+ const match = TSC_LINE.exec(line);
60
+ if (match === null) continue;
61
+ const [, file = "", row, column, code = "", message = ""] = match;
62
+ found.push({
63
+ file: relativeTo(options.repoRoot, file),
64
+ line: oneBased(row),
65
+ column: oneBased(column),
66
+ code,
67
+ message: firstLine(message),
68
+ });
69
+ }
70
+ return found;
71
+ };
72
+
73
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
74
+ typeof value === "object" && value !== null && !Array.isArray(value);
75
+
76
+ const asString = (value: unknown): string => (typeof value === "string" ? value : "");
77
+ const asNumber = (value: unknown): number | undefined =>
78
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
79
+
80
+ const parseJson = (text: string, format: string): unknown => {
81
+ try {
82
+ return JSON.parse(text);
83
+ } catch (cause) {
84
+ throw new Error(`a ${format} report is JSON, and this one does not parse: ${String(cause)}`);
85
+ }
86
+ };
87
+
88
+ // `eslint --format json`: one object per file with its messages. A message
89
+ // with no `ruleId` (a parse error) carries the code `""`.
90
+ const parseEslint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
91
+ const parsed = parseJson(text, "eslint");
92
+ if (!Array.isArray(parsed)) throw new Error("an eslint report is a JSON array of files");
93
+ const found: Array<Diagnostic> = [];
94
+ for (const entry of parsed) {
95
+ if (!isRecord(entry) || !Array.isArray(entry.messages)) continue;
96
+ const file = relativeTo(options.repoRoot, asString(entry.filePath));
97
+ for (const message of entry.messages) {
98
+ if (!isRecord(message)) continue;
99
+ found.push({
100
+ file,
101
+ line: (asNumber(message.line) ?? 1) - 1,
102
+ column: (asNumber(message.column) ?? 1) - 1,
103
+ code: asString(message.ruleId),
104
+ message: firstLine(asString(message.message)),
105
+ });
106
+ }
107
+ }
108
+ return found;
109
+ };
110
+
111
+ // `oxlint --format json`: `{ diagnostics: [{ filename, code, message,
112
+ // labels: [{ span: { line, column } }] }] }`, positions one-based.
113
+ const parseOxlint = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
114
+ const parsed = parseJson(text, "oxlint");
115
+ if (!isRecord(parsed) || !Array.isArray(parsed.diagnostics)) {
116
+ throw new Error("an oxlint report is a JSON object with a `diagnostics` array");
117
+ }
118
+ const found: Array<Diagnostic> = [];
119
+ for (const entry of parsed.diagnostics) {
120
+ if (!isRecord(entry)) continue;
121
+ const labels: ReadonlyArray<unknown> = Array.isArray(entry.labels) ? entry.labels : [];
122
+ const label: unknown = labels[0];
123
+ const span = isRecord(label) && isRecord(label.span) ? label.span : {};
124
+ found.push({
125
+ file: relativeTo(options.repoRoot, asString(entry.filename)),
126
+ line: (asNumber(span.line) ?? 1) - 1,
127
+ column: (asNumber(span.column) ?? 1) - 1,
128
+ code: asString(entry.code),
129
+ message: firstLine(asString(entry.message)),
130
+ });
131
+ }
132
+ return found;
133
+ };
134
+
135
+ const parseRegex = (text: string, options: ParseReportOptions): ReadonlyArray<Diagnostic> => {
136
+ if (options.pattern === undefined) {
137
+ throw new Error("a `regex` report needs a `pattern` with named groups `file` and `line`");
138
+ }
139
+ const pattern = new RegExp(options.pattern);
140
+ const found: Array<Diagnostic> = [];
141
+ for (const line of text.split(/\r?\n/)) {
142
+ const match = pattern.exec(line);
143
+ if (match === null) continue;
144
+ const groups = match.groups ?? {};
145
+ if (groups.file === undefined) continue;
146
+ found.push({
147
+ file: relativeTo(options.repoRoot, groups.file),
148
+ line: oneBased(groups.line),
149
+ column: oneBased(groups.column),
150
+ code: groups.code ?? "",
151
+ message: firstLine(groups.message ?? ""),
152
+ });
153
+ }
154
+ return found;
155
+ };
156
+
157
+ export const parseReport = (
158
+ format: ReportFormat,
159
+ text: string,
160
+ options: ParseReportOptions,
161
+ ): ReadonlyArray<Diagnostic> => {
162
+ switch (format) {
163
+ case "tsc":
164
+ return parseTsc(text, options);
165
+ case "eslint":
166
+ return parseEslint(text, options);
167
+ case "oxlint":
168
+ return parseOxlint(text, options);
169
+ case "regex":
170
+ return parseRegex(text, options);
171
+ }
172
+ };
173
+
174
+ // One report from several outputs. A tool that takes one project at a time
175
+ // is run once per project, and a project's program includes the files of
176
+ // the projects it references, so one diagnostic is printed under several
177
+ // runs; it is one diagnostic. Kept once, at its first appearance, when
178
+ // identical in file, position, code and message.
179
+ export const uniqueDiagnostics = (diagnostics: Iterable<Diagnostic>): ReadonlyArray<Diagnostic> => {
180
+ const seen = new Set<string>();
181
+ const kept: Array<Diagnostic> = [];
182
+ for (const one of diagnostics) {
183
+ const key = JSON.stringify([one.file, one.line, one.column, one.code, one.message]);
184
+ if (seen.has(key)) continue;
185
+ seen.add(key);
186
+ kept.push(one);
187
+ }
188
+ return kept;
189
+ };
190
+
191
+ // The diagnostics indexed by file, which is how a per-file evaluator asks
192
+ // for them.
193
+ export const indexByFile = (
194
+ diagnostics: Iterable<Diagnostic>,
195
+ ): ReadonlyMap<string, ReadonlyArray<Diagnostic>> => {
196
+ const byFile = new Map<string, Array<Diagnostic>>();
197
+ for (const one of diagnostics) {
198
+ const found = byFile.get(one.file);
199
+ if (found === undefined) byFile.set(one.file, [one]);
200
+ else found.push(one);
201
+ }
202
+ return byFile;
203
+ };
@@ -0,0 +1,302 @@
1
+ import * as Schema from "effect/Schema";
2
+
3
+ import type { ViolationKind } from "./violation.js";
4
+
5
+ // The conformance snapshot: one JSON document per run saying how far the
6
+ // tree is from the manifest. `architecture conformance --json` emits it, a
7
+ // pull-request check compares two of them, an agent reads one before it
8
+ // edits, and a service that keeps history stores them — so it is a wire
9
+ // format, defined here with a schema before anything consumes it, and
10
+ // versioned so a document that grows it can say which one it grew.
11
+ //
12
+ // Three things it says that `check` does not: the residue (what no family
13
+ // reaches), the slack (what the allowlists permit and nothing uses), and the
14
+ // violations ordered by how much of the graph each fix drags along. Every
15
+ // entry that names a violation names it by its line-independent fingerprint,
16
+ // which is what lets one be tracked across commits.
17
+
18
+ export const SNAPSHOT_SCHEMA_ID =
19
+ "https://dataquail.github.io/goodbones/schema/conformance.schema.json";
20
+
21
+ export const SNAPSHOT_VERSION = 1;
22
+
23
+ const describe = <S extends Schema.Top>(schema: S, description: string) =>
24
+ schema.annotate({ description });
25
+
26
+ const COVERAGE_FAMILIES = ["imports", "structure", "members", "surface", "graph"] as const;
27
+
28
+ // The kinds `Violation` carries, spelled here as a schema; the `satisfies`
29
+ // below keeps the two lists one.
30
+ const VIOLATION_KINDS = [
31
+ "import",
32
+ "export",
33
+ "structure",
34
+ "member",
35
+ "surface",
36
+ "graph",
37
+ "campaign",
38
+ ] as const satisfies ReadonlyArray<ViolationKind>;
39
+
40
+ const Path = describe(Schema.String, "Repo-relative, with forward slashes.");
41
+
42
+ const FamilyCoverage = Schema.Struct({
43
+ covered: describe(Schema.Finite, "Files this family reaches."),
44
+ total: describe(Schema.Finite, "Files walked."),
45
+ floor: Schema.optionalKey(
46
+ describe(
47
+ Schema.Finite,
48
+ "The fraction the manifest's `limits.coverage` states for this family, when it states one.",
49
+ ),
50
+ ),
51
+ });
52
+
53
+ export const CONFORMANCE_MEASURES = ["residue", "vacant", "slack", "concentration"] as const;
54
+
55
+ export type ConformanceMeasure = (typeof CONFORMANCE_MEASURES)[number];
56
+
57
+ // One conformance measure as `check` holds it: the count, and the ceiling
58
+ // the manifest's `limits.conformance` states for it, when it states one.
59
+ const MeasureCount = Schema.Struct({
60
+ count: describe(Schema.Finite, "What the measure counts today."),
61
+ ceiling: Schema.optionalKey(
62
+ describe(
63
+ Schema.Finite,
64
+ "The count the manifest's `limits.conformance` states for this measure, when it states one.",
65
+ ),
66
+ ),
67
+ });
68
+
69
+ export const SnapshotViolation = Schema.Struct({
70
+ fingerprint: describe(
71
+ Schema.String,
72
+ "kind|rule|file|subject — line-independent, so it survives edits to the file it names; the baseline's key.",
73
+ ),
74
+ kind: Schema.Literals(VIOLATION_KINDS),
75
+ ruleName: describe(Schema.String, "The manifest node path the rule was lowered from."),
76
+ file: Path,
77
+ subject: describe(
78
+ Schema.NullOr(Schema.String),
79
+ "The other end of the violated relationship — the resolved target, the restricted symbol, the missing sibling — or null when the file alone is the violation.",
80
+ ),
81
+ message: Schema.String,
82
+ baselined: describe(Schema.Boolean, "Carried by the baseline, so `check` does not fail on it."),
83
+ ledgered: describe(
84
+ Schema.Boolean,
85
+ "For a campaign hit: carried by the campaign's ledger, so `check` does not fail on it.",
86
+ ),
87
+ });
88
+
89
+ const AllowanceKind = describe(
90
+ Schema.Literals(["allow", "external"]),
91
+ "`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`.",
92
+ );
93
+
94
+ const Entry = describe(Schema.String, "The entry as written, after alias expansion.");
95
+
96
+ export const SnapshotSlack = Schema.Struct({
97
+ node: describe(
98
+ Schema.String,
99
+ "The manifest node that wrote the entry — or, when `fragment` is set, the `defs` fragment it was written in.",
100
+ ),
101
+ kind: AllowanceKind,
102
+ entry: Entry,
103
+ fragment: Schema.optionalKey(
104
+ describe(
105
+ Schema.String,
106
+ "Present when the entry arrived through `use`. The nodes that referenced the fragment wrote one word, so the entry is reported once, against the fragment, rather than once per node.",
107
+ ),
108
+ ),
109
+ of: Schema.optionalKey(
110
+ describe(
111
+ Schema.Finite,
112
+ "With `fragment`: how many non-vacant nodes were granted the entry through it. None of them uses it.",
113
+ ),
114
+ ),
115
+ });
116
+
117
+ export const SnapshotConcentration = Schema.Struct({
118
+ fragment: describe(Schema.String, "The `defs` fragment the entry was written in."),
119
+ kind: AllowanceKind,
120
+ entry: Entry,
121
+ usedAt: describe(Schema.Finite, "Nodes granted the entry through the fragment that use it."),
122
+ of: describe(Schema.Finite, "Non-vacant nodes granted the entry through the fragment."),
123
+ });
124
+
125
+ export const SnapshotVacancy = Schema.Struct({
126
+ node: describe(Schema.String, "The manifest node."),
127
+ allowances: describe(
128
+ Schema.Finite,
129
+ "Distinct entries the node wrote, `allow` and `external` together.",
130
+ ),
131
+ });
132
+
133
+ // One campaign's burn-down: what its ledger says, and what the clock says
134
+ // about it. Every count is over ledger entries, which are hits by fingerprint.
135
+ export const SnapshotCampaign = Schema.Struct({
136
+ id: describe(Schema.String, "The campaign's id; its ledger is `<ledger>/<id>.json`."),
137
+ title: Schema.optionalKey(describe(Schema.String, "The campaign's title, when it states one.")),
138
+ owner: Schema.optionalKey(
139
+ describe(Schema.String, "Who is running the campaign, as the manifest names them."),
140
+ ),
141
+ initial: describe(Schema.Finite, "Entries the day the ledger was written."),
142
+ allowed: describe(
143
+ Schema.Finite,
144
+ "Entries added since by `campaigns allow`, each with a regression record.",
145
+ ),
146
+ count: describe(Schema.Finite, "Entries in the ledger now."),
147
+ fixed: describe(
148
+ Schema.Finite,
149
+ "Entries removed by `campaigns prune` since the ledger was written.",
150
+ ),
151
+ progress: describe(
152
+ Schema.Finite,
153
+ "`1 - count / (initial + allowed)`: how much of everything ever ledgered has been paid down.",
154
+ ),
155
+ lastProgress: describe(
156
+ Schema.String,
157
+ "When an entry last left the ledger, ISO 8601 — the stall clock's reading.",
158
+ ),
159
+ regressions: describe(Schema.Finite, "How many times the count was allowed to go up."),
160
+ stalled: describe(
161
+ Schema.Boolean,
162
+ "Entries remain and `lastProgress` is older than the campaign's `staleAfter`.",
163
+ ),
164
+ complete: describe(Schema.Boolean, "No entries remain."),
165
+ onComplete: describe(
166
+ Schema.Literals(["keep", "remove"]),
167
+ "What the manifest asks once complete: keep the campaign as a guard, or remove it.",
168
+ ),
169
+ ledgered: describe(
170
+ Schema.Boolean,
171
+ "Whether a ledger exists. A campaign with none has been declared and not yet initialised.",
172
+ ),
173
+ });
174
+
175
+ export const Snapshot = Schema.Struct({
176
+ version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
177
+ manifest: describe(
178
+ Schema.Struct({
179
+ path: describe(
180
+ Path,
181
+ "The file the policy was read from — the root file, when split with `include`.",
182
+ ),
183
+ sha256: describe(
184
+ Schema.String,
185
+ "A hash of that file's bytes, so two snapshots can say whether the policy changed between them.",
186
+ ),
187
+ }),
188
+ "The policy this snapshot was taken against — the repository's own, or the one `--against` named.",
189
+ ),
190
+ roots: describe(Schema.Array(Path), "The directories walked."),
191
+ files: describe(Schema.Finite, "Files walked."),
192
+ ok: describe(
193
+ Schema.Boolean,
194
+ "What `check` would exit with: true when no reportable violation, unresolved import, stale baseline entry or coverage shortfall exists.",
195
+ ),
196
+ coverage: describe(
197
+ Schema.Struct(
198
+ Object.fromEntries(COVERAGE_FAMILIES.map((family) => [family, FamilyCoverage])) as Record<
199
+ (typeof COVERAGE_FAMILIES)[number],
200
+ typeof FamilyCoverage
201
+ >,
202
+ ),
203
+ "Per family, how many walked files it reaches. Structure counts enumerated folders only.",
204
+ ),
205
+ conformance: describe(
206
+ Schema.Struct(
207
+ Object.fromEntries(CONFORMANCE_MEASURES.map((measure) => [measure, MeasureCount])) as Record<
208
+ ConformanceMeasure,
209
+ typeof MeasureCount
210
+ >,
211
+ ),
212
+ "Per measure, the count `check` holds to the manifest's `limits.conformance`: residue files, vacant nodes, slack allowances, and fragment entries used at fewer than half the nodes granted them. The lists below name what each counts.",
213
+ ),
214
+ residue: describe(
215
+ Schema.Struct({
216
+ files: describe(Schema.Array(Path), "Files no family reaches, sorted."),
217
+ folders: describe(
218
+ Schema.Array(Path),
219
+ "Folders every walked file of which is residue, each the topmost such folder.",
220
+ ),
221
+ }),
222
+ "What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts.",
223
+ ),
224
+ vacant: describe(
225
+ Schema.Array(SnapshotVacancy),
226
+ "Nodes that state an import allowlist and select no walked file, in manifest order. Every allowance on one is unused by construction, so none is counted as slack; the node is a tier declared ahead of its first file, or a pattern that no longer matches. Residue is files no node reaches; this is nodes no file reaches.",
227
+ ),
228
+ violations: describe(
229
+ Schema.Array(SnapshotViolation),
230
+ "Every finding, baselined ones included, ordered so the ones cheapest to fix come first: by the height of the violated target in the import graph, leaves first.",
231
+ ),
232
+ unresolved: describe(
233
+ Schema.Array(Schema.Struct({ file: Path, specifier: Schema.String, detail: Schema.String })),
234
+ "Imports the resolver could not turn into a file. Every rule about one enforces nothing.",
235
+ ),
236
+ stale: describe(Schema.Array(Schema.String), "Baseline entries the code no longer produces."),
237
+ baseline: describe(
238
+ Schema.Struct({
239
+ size: describe(Schema.Finite, "Entries in the baseline file."),
240
+ }),
241
+ "The debt the policy is carrying. The ratchet: it may only shrink.",
242
+ ),
243
+ cycles: describe(
244
+ Schema.Finite,
245
+ "Strongly connected components of more than one file, or a file importing itself, anywhere in the walked graph — in a cycles rule's scope or not.",
246
+ ),
247
+ slack: describe(
248
+ Schema.Array(SnapshotSlack),
249
+ "Allowances no observed import uses, in manifest order, vacant nodes excluded. A manifest inferred from the tree has none on the day it is written; every entry here is permission nothing needs. An entry that arrived through `use` is reported once, against the fragment, and only when no node granted it uses it.",
250
+ ),
251
+ concentration: describe(
252
+ Schema.Array(SnapshotConcentration),
253
+ "Fragment entries used at some of the nodes granted them and not the rest. Not slack — the fragment's line is needed somewhere — but a per-file permission written as a many-node allowance, which is what an allowlist widened to make one build green looks like.",
254
+ ),
255
+ adoption: describe(
256
+ Schema.Struct({
257
+ unrestricted: describe(Schema.Array(Schema.String), "Nodes that say `unrestricted: true`."),
258
+ partial: describe(Schema.Array(Schema.String), "Nodes that say `partial: true`."),
259
+ }),
260
+ 'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
261
+ ),
262
+ campaigns: describe(
263
+ Schema.Array(SnapshotCampaign),
264
+ "Every campaign the manifest declares, in manifest order, with its burn-down. Campaign hits are not counted in `coverage` or `residue`: a campaign is scoped by construction.",
265
+ ),
266
+ });
267
+
268
+ export type Snapshot = typeof Snapshot.Type;
269
+ export type SnapshotViolation = typeof SnapshotViolation.Type;
270
+ export type SnapshotSlack = typeof SnapshotSlack.Type;
271
+ export type SnapshotConcentration = typeof SnapshotConcentration.Type;
272
+ export type SnapshotVacancy = typeof SnapshotVacancy.Type;
273
+ export type SnapshotCampaign = typeof SnapshotCampaign.Type;
274
+
275
+ // Decodes a document some other run wrote — the base of a pull request, a
276
+ // stored one — refusing a key the shape does not declare, so a consumer never
277
+ // reads a field that a later version renamed.
278
+ export const decodeSnapshot = Schema.decodeUnknownResult(Snapshot, {
279
+ errors: "all",
280
+ onExcessProperty: "error",
281
+ });
282
+
283
+ type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
284
+ type JsonObject = { readonly [key: string]: JsonValue };
285
+
286
+ // The document's shape as a JSON Schema, generated from the same codec, so
287
+ // the two cannot disagree. Published beside the manifest's.
288
+ export const snapshotJsonSchema = (): JsonObject => {
289
+ const generated = Schema.toJsonSchemaDocument(Snapshot) as unknown as {
290
+ readonly schema: JsonObject;
291
+ readonly definitions: JsonObject;
292
+ };
293
+ return {
294
+ $schema: "https://json-schema.org/draft/2020-12/schema",
295
+ $id: SNAPSHOT_SCHEMA_ID,
296
+ title: "Conformance snapshot",
297
+ description:
298
+ "How far a repository's tree is from its architecture manifest, as `architecture conformance --json` reports it. See https://dataquail.github.io/goodbones/architecture-rules/enforcement/conformance/.",
299
+ ...generated.schema,
300
+ ...(Object.keys(generated.definitions).length === 0 ? {} : { $defs: generated.definitions }),
301
+ };
302
+ };
@@ -1,4 +1,7 @@
1
- export type ViolationKind = "import" | "export" | "structure" | "member" | "surface" | "graph";
1
+ // `campaign` is the one kind whose entries live in a ledger rather than the
2
+ // baseline: a hit is debt a campaign is paying down, not a rule being broken.
3
+ export type ViolationKind =
4
+ "import" | "export" | "structure" | "member" | "surface" | "graph" | "campaign";
2
5
 
3
6
  export type Violation = {
4
7
  readonly kind: ViolationKind;