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

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 (150) hide show
  1. package/build/dts/core/coverage.d.ts +20 -0
  2. package/build/dts/core/coverage.d.ts.map +1 -1
  3. package/build/dts/core/graph.d.ts +2 -0
  4. package/build/dts/core/graph.d.ts.map +1 -1
  5. package/build/dts/core/imports.d.ts +3 -1
  6. package/build/dts/core/imports.d.ts.map +1 -1
  7. package/build/dts/core/slack.d.ts +22 -0
  8. package/build/dts/core/slack.d.ts.map +1 -0
  9. package/build/dts/core/structure.d.ts +1 -0
  10. package/build/dts/core/structure.d.ts.map +1 -1
  11. package/build/dts/domain/architecture-config.d.ts +19 -3
  12. package/build/dts/domain/architecture-config.d.ts.map +1 -1
  13. package/build/dts/domain/architecture-error.d.ts +8 -0
  14. package/build/dts/domain/architecture-error.d.ts.map +1 -1
  15. package/build/dts/domain/facts.d.ts +1 -0
  16. package/build/dts/domain/facts.d.ts.map +1 -1
  17. package/build/dts/domain/manifest-location.d.ts +9 -0
  18. package/build/dts/domain/manifest-location.d.ts.map +1 -0
  19. package/build/dts/domain/snapshot.d.ts +356 -0
  20. package/build/dts/domain/snapshot.d.ts.map +1 -0
  21. package/build/dts/domain/violation.d.ts +1 -1
  22. package/build/dts/domain/violation.d.ts.map +1 -1
  23. package/build/dts/index.d.ts +22 -10
  24. package/build/dts/index.d.ts.map +1 -1
  25. package/build/dts/infrastructure/file-system-fake.d.ts.map +1 -1
  26. package/build/dts/infrastructure/file-system-live.d.ts.map +1 -1
  27. package/build/dts/infrastructure/manifest-file.d.ts +11 -2
  28. package/build/dts/infrastructure/manifest-file.d.ts.map +1 -1
  29. package/build/dts/infrastructure/manifest-include.d.ts +16 -0
  30. package/build/dts/infrastructure/manifest-include.d.ts.map +1 -0
  31. package/build/dts/infrastructure/syntax-matcher-fake.d.ts +10 -0
  32. package/build/dts/infrastructure/syntax-matcher-fake.d.ts.map +1 -0
  33. package/build/dts/infrastructure/walk.d.ts +12 -1
  34. package/build/dts/infrastructure/walk.d.ts.map +1 -1
  35. package/build/dts/load/extension.d.ts +33 -0
  36. package/build/dts/load/extension.d.ts.map +1 -0
  37. package/build/dts/load/policy.d.ts +10 -0
  38. package/build/dts/load/policy.d.ts.map +1 -1
  39. package/build/dts/manifest/compile.d.ts +11 -1
  40. package/build/dts/manifest/compile.d.ts.map +1 -1
  41. package/build/dts/manifest/expand.d.ts +27 -0
  42. package/build/dts/manifest/expand.d.ts.map +1 -0
  43. package/build/dts/manifest/extension.d.ts +8 -0
  44. package/build/dts/manifest/extension.d.ts.map +1 -0
  45. package/build/dts/manifest/glob.d.ts +1 -0
  46. package/build/dts/manifest/glob.d.ts.map +1 -1
  47. package/build/dts/manifest/infer.d.ts +55 -0
  48. package/build/dts/manifest/infer.d.ts.map +1 -0
  49. package/build/dts/manifest/json-schema.d.ts +14 -0
  50. package/build/dts/manifest/json-schema.d.ts.map +1 -0
  51. package/build/dts/manifest/manifest.d.ts +26 -1
  52. package/build/dts/manifest/manifest.d.ts.map +1 -1
  53. package/build/dts/ports/file-system.d.ts +1 -0
  54. package/build/dts/ports/file-system.d.ts.map +1 -1
  55. package/build/dts/ports/language.d.ts +4 -0
  56. package/build/dts/ports/language.d.ts.map +1 -1
  57. package/build/dts/ports/syntax-matcher.d.ts +21 -0
  58. package/build/dts/ports/syntax-matcher.d.ts.map +1 -0
  59. package/build/dts/testing.d.ts +1 -0
  60. package/build/dts/testing.d.ts.map +1 -1
  61. package/build/esm/core/coverage.js +107 -32
  62. package/build/esm/core/coverage.js.map +1 -1
  63. package/build/esm/core/graph.js +45 -0
  64. package/build/esm/core/graph.js.map +1 -1
  65. package/build/esm/core/imports.js +14 -9
  66. package/build/esm/core/imports.js.map +1 -1
  67. package/build/esm/core/slack.js +76 -0
  68. package/build/esm/core/slack.js.map +1 -0
  69. package/build/esm/core/structure.js +6 -3
  70. package/build/esm/core/structure.js.map +1 -1
  71. package/build/esm/domain/architecture-config.js +28 -4
  72. package/build/esm/domain/architecture-config.js.map +1 -1
  73. package/build/esm/domain/architecture-error.js +27 -0
  74. package/build/esm/domain/architecture-error.js.map +1 -1
  75. package/build/esm/domain/manifest-location.js +21 -0
  76. package/build/esm/domain/manifest-location.js.map +1 -0
  77. package/build/esm/domain/snapshot.js +181 -0
  78. package/build/esm/domain/snapshot.js.map +1 -0
  79. package/build/esm/domain/violation.js.map +1 -1
  80. package/build/esm/index.js +28 -9
  81. package/build/esm/index.js.map +1 -1
  82. package/build/esm/infrastructure/file-system-fake.js +10 -0
  83. package/build/esm/infrastructure/file-system-fake.js.map +1 -1
  84. package/build/esm/infrastructure/file-system-live.js +9 -1
  85. package/build/esm/infrastructure/file-system-live.js.map +1 -1
  86. package/build/esm/infrastructure/manifest-file.js +158 -7
  87. package/build/esm/infrastructure/manifest-file.js.map +1 -1
  88. package/build/esm/infrastructure/manifest-include.js +187 -0
  89. package/build/esm/infrastructure/manifest-include.js.map +1 -0
  90. package/build/esm/infrastructure/syntax-matcher-fake.js +33 -0
  91. package/build/esm/infrastructure/syntax-matcher-fake.js.map +1 -0
  92. package/build/esm/infrastructure/walk.js +106 -4
  93. package/build/esm/infrastructure/walk.js.map +1 -1
  94. package/build/esm/load/extension.js +2 -0
  95. package/build/esm/load/extension.js.map +1 -0
  96. package/build/esm/load/policy.js +49 -2
  97. package/build/esm/load/policy.js.map +1 -1
  98. package/build/esm/manifest/compile.js +50 -27
  99. package/build/esm/manifest/compile.js.map +1 -1
  100. package/build/esm/manifest/expand.js +116 -0
  101. package/build/esm/manifest/expand.js.map +1 -0
  102. package/build/esm/manifest/extension.js +2 -0
  103. package/build/esm/manifest/extension.js.map +1 -0
  104. package/build/esm/manifest/glob.js +3 -0
  105. package/build/esm/manifest/glob.js.map +1 -1
  106. package/build/esm/manifest/infer.js +455 -0
  107. package/build/esm/manifest/infer.js.map +1 -0
  108. package/build/esm/manifest/json-schema.js +169 -0
  109. package/build/esm/manifest/json-schema.js.map +1 -0
  110. package/build/esm/manifest/manifest.js +127 -5
  111. package/build/esm/manifest/manifest.js.map +1 -1
  112. package/build/esm/ports/syntax-matcher.js +12 -0
  113. package/build/esm/ports/syntax-matcher.js.map +1 -0
  114. package/build/esm/testing.js +1 -0
  115. package/build/esm/testing.js.map +1 -1
  116. package/package.json +9 -3
  117. package/schema/architecture-node.schema.json +1518 -0
  118. package/schema/architecture.schema.json +4498 -0
  119. package/schema/conformance.schema.json +1019 -0
  120. package/src/core/coverage.ts +164 -34
  121. package/src/core/graph.ts +48 -0
  122. package/src/core/imports.ts +29 -13
  123. package/src/core/slack.ts +135 -0
  124. package/src/core/structure.ts +15 -8
  125. package/src/domain/architecture-config.ts +31 -4
  126. package/src/domain/architecture-error.ts +30 -0
  127. package/src/domain/facts.ts +9 -4
  128. package/src/domain/manifest-location.ts +41 -0
  129. package/src/domain/snapshot.ts +402 -0
  130. package/src/domain/violation.ts +4 -1
  131. package/src/index.ts +149 -7
  132. package/src/infrastructure/file-system-fake.ts +12 -0
  133. package/src/infrastructure/file-system-live.ts +8 -1
  134. package/src/infrastructure/manifest-file.ts +204 -8
  135. package/src/infrastructure/manifest-include.ts +318 -0
  136. package/src/infrastructure/syntax-matcher-fake.ts +51 -0
  137. package/src/infrastructure/walk.ts +127 -3
  138. package/src/load/extension.ts +66 -0
  139. package/src/load/policy.ts +77 -2
  140. package/src/manifest/compile.ts +81 -30
  141. package/src/manifest/expand.ts +183 -0
  142. package/src/manifest/extension.ts +41 -0
  143. package/src/manifest/glob.ts +5 -0
  144. package/src/manifest/infer.ts +643 -0
  145. package/src/manifest/json-schema.ts +210 -0
  146. package/src/manifest/manifest.ts +206 -10
  147. package/src/ports/file-system.ts +3 -0
  148. package/src/ports/language.ts +17 -0
  149. package/src/ports/syntax-matcher.ts +42 -0
  150. package/src/testing.ts +1 -0
@@ -1,15 +1,20 @@
1
1
  import type { BindingKind, DeclarationKind, MemberSubject } from "./architecture-config.js";
2
2
 
3
- // What a policy can know about one source file, read once. Both adapters
4
- // produce this vocabulary — the plugin from oxlint's syntax tree, the CLI from
5
- // TypeScript's — which is what keeps them answerable to the same core rather
6
- // than to each other. A rule is evaluated against these and nothing else.
3
+ // What a policy can know about one source file, read once. A language pack
4
+ // produces this vocabulary out of its syntax tree, and both hosts read it —
5
+ // which is what keeps them answerable to the same core rather than to each
6
+ // other. A rule is evaluated against these and nothing else.
7
7
 
8
8
  // One name pulled across one import edge: `import { makeCommandBus } from "…"`
9
9
  // is a single binding, and so is the `Effect` in `import { Effect } from "effect"`.
10
10
  export type Binding = {
11
11
  readonly symbol: string;
12
12
  readonly kind: BindingKind;
13
+ // The name the binding is known by in the importing file — `C` for
14
+ // `import { Component as C }`, `React` for a default import — which is how
15
+ // an identifier in the file's own syntax is traced back to the module it
16
+ // came from. Absent for a form that binds no name.
17
+ readonly local?: string;
13
18
  };
14
19
 
15
20
  // One declared or called name. For `members`, the declaration it is written in
@@ -0,0 +1,41 @@
1
+ // Where in the manifest file a value was written. A decode error that names a
2
+ // path is something the reader has to find; one that names a line is something
3
+ // an editor can jump to. The host that read the file knows its lines; the
4
+ // decoder knows the path — the locator is how the second asks the first.
5
+
6
+ // A path into the manifest as the decoder sees it: object keys and array
7
+ // indices, root first.
8
+ export type ManifestPath = ReadonlyArray<PropertyKey>;
9
+
10
+ export type ManifestPosition = {
11
+ // 1-based, as editors count.
12
+ readonly line: number;
13
+ readonly column: number;
14
+ // The file the position is in, when it is not the manifest itself: a
15
+ // manifest assembled from several files through `include` answers with the
16
+ // included file's path, relative to the manifest's own directory. Absent
17
+ // for a position in the manifest file.
18
+ readonly file?: string | undefined;
19
+ };
20
+
21
+ // Answers with the position of the value at `path`, or the nearest ancestor
22
+ // that exists when the path names something the file does not contain (a
23
+ // missing key), or `null` when the source has no positions to give.
24
+ export type ManifestLocator = (path: ManifestPath) => ManifestPosition | null;
25
+
26
+ const isIdentifier = (key: string): boolean => /^[A-Za-z_$][\w$]*$/.test(key);
27
+
28
+ // `tree["~/core/"].members[0].subject` — dotted where a key reads as a name,
29
+ // bracketed where it does not, so a node key that is a path pattern stays
30
+ // legible.
31
+ export const renderManifestPath = (path: ManifestPath): string =>
32
+ path.length === 0
33
+ ? "(root)"
34
+ : path
35
+ .map((segment, index) => {
36
+ if (typeof segment === "number") return `[${String(segment)}]`;
37
+ const key = String(segment);
38
+ if (isIdentifier(key)) return index === 0 ? key : `.${key}`;
39
+ return `[${JSON.stringify(key)}]`;
40
+ })
41
+ .join("");
@@ -0,0 +1,402 @@
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 = 2;
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 objective's ledger, so `check` does not fail on it.",
86
+ ),
87
+ objective: Schema.optionalKey(
88
+ describe(Schema.String, "For a campaign hit: the objective that fired."),
89
+ ),
90
+ sector: Schema.optionalKey(
91
+ describe(Schema.String, "For a campaign hit: the sector the hit falls in."),
92
+ ),
93
+ entry: Schema.optionalKey(
94
+ describe(Schema.String, "For a campaign hit: its ledger entry, relative to the sector's root."),
95
+ ),
96
+ });
97
+
98
+ const AllowanceKind = describe(
99
+ Schema.Literals(["allow", "external"]),
100
+ "`allow` is a path glob under `imports.allow`; `external` a package name under `imports.external`.",
101
+ );
102
+
103
+ const Entry = describe(Schema.String, "The entry as written, after alias expansion.");
104
+
105
+ export const SnapshotSlack = Schema.Struct({
106
+ node: describe(
107
+ Schema.String,
108
+ "The manifest node that wrote the entry — or, when `fragment` is set, the `defs` fragment it was written in.",
109
+ ),
110
+ kind: AllowanceKind,
111
+ entry: Entry,
112
+ fragment: Schema.optionalKey(
113
+ describe(
114
+ Schema.String,
115
+ "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.",
116
+ ),
117
+ ),
118
+ of: Schema.optionalKey(
119
+ describe(
120
+ Schema.Finite,
121
+ "With `fragment`: how many non-vacant nodes were granted the entry through it. None of them uses it.",
122
+ ),
123
+ ),
124
+ });
125
+
126
+ export const SnapshotConcentration = Schema.Struct({
127
+ fragment: describe(Schema.String, "The `defs` fragment the entry was written in."),
128
+ kind: AllowanceKind,
129
+ entry: Entry,
130
+ usedAt: describe(Schema.Finite, "Nodes granted the entry through the fragment that use it."),
131
+ of: describe(Schema.Finite, "Non-vacant nodes granted the entry through the fragment."),
132
+ });
133
+
134
+ export const SnapshotVacancy = Schema.Struct({
135
+ node: describe(Schema.String, "The manifest node."),
136
+ allowances: describe(
137
+ Schema.Finite,
138
+ "Distinct entries the node wrote, `allow` and `external` together.",
139
+ ),
140
+ });
141
+
142
+ // One objective's burn-down: what its ledger says, summed over its sectors.
143
+ export const SnapshotObjective = Schema.Struct({
144
+ id: describe(
145
+ Schema.String,
146
+ "The objective's id; its ledger is `<ledger>/<campaign>/<objective>.json`.",
147
+ ),
148
+ phase: describe(
149
+ Schema.NullOr(Schema.String),
150
+ "The phase naming the objective, or `null` for one no phase names — in window everywhere.",
151
+ ),
152
+ initial: describe(Schema.Finite, "Holdouts recorded as each sector entered the window, summed."),
153
+ allowed: describe(
154
+ Schema.Finite,
155
+ "Holdouts added since by `objectives concede` or a re-baseline, each with a concession.",
156
+ ),
157
+ count: describe(Schema.Finite, "Holdouts in the ledger now, every sector summed."),
158
+ cleared: describe(
159
+ Schema.Finite,
160
+ "Holdouts removed by `objectives clear` because they stopped firing.",
161
+ ),
162
+ closed: describe(
163
+ Schema.Finite,
164
+ "Holdouts still firing when a sector left the window — not progress.",
165
+ ),
166
+ progress: describe(
167
+ Schema.Finite,
168
+ "`1 - count / (initial + allowed - closed)`: how much of everything ever ledgered has been paid down.",
169
+ ),
170
+ lastCleared: describe(
171
+ Schema.NullOr(Schema.String),
172
+ "When a holdout last left the ledger, ISO 8601 — the stall clock's reading; `null` with no ledger.",
173
+ ),
174
+ concessions: describe(Schema.Finite, "How many times a count was allowed to go up."),
175
+ complete: describe(Schema.Boolean, "No holdouts remain."),
176
+ ledgered: describe(
177
+ Schema.Boolean,
178
+ "Whether a ledger exists. An objective with none has been declared and not yet cleared.",
179
+ ),
180
+ });
181
+
182
+ // One phase of the ladder, and how many sectors stand at it.
183
+ export const SnapshotPhase = Schema.Struct({
184
+ id: describe(Schema.String, "The phase's id."),
185
+ defined: describe(
186
+ Schema.Boolean,
187
+ "Whether the phase names criteria. An open phase has only an intent, and is last.",
188
+ ),
189
+ sectors: describe(Schema.Finite, "How many sectors are derived to stand at this phase."),
190
+ });
191
+
192
+ // One sector: where it stands, and its residue toward its next phase.
193
+ export const SnapshotSector = Schema.Struct({
194
+ name: describe(Schema.String, "The sector's identity, as its perimeter names it."),
195
+ phase: describe(
196
+ Schema.NullOr(Schema.String),
197
+ "The phase the sector is derived to stand at, or `null` when it is past the last one.",
198
+ ),
199
+ reached: describe(
200
+ Schema.NullOr(Schema.String),
201
+ "The furthest phase the sector's record says it has reached; `null` before its first `clear`.",
202
+ ),
203
+ files: describe(Schema.Finite, "How many files the sector claims."),
204
+ residue: describe(
205
+ Schema.Record(Schema.String, Schema.Finite),
206
+ "One dimension per objective in window for the sector: its holdouts there, never summed.",
207
+ ),
208
+ stalled: describe(
209
+ Schema.Boolean,
210
+ "Holdouts remain for the sector and nothing has been cleared, attested or noted within `staleAfter`.",
211
+ ),
212
+ });
213
+
214
+ // One campaign: its objectives' burn-down, the distribution of its sectors
215
+ // over its phases, the legacy remainder, and what changed in its plan.
216
+ export const SnapshotCampaign = Schema.Struct({
217
+ id: describe(Schema.String, "The campaign's id; its ledgers are under `<ledger>/<id>/`."),
218
+ title: Schema.optionalKey(describe(Schema.String, "The campaign's title, when it states one.")),
219
+ owner: Schema.optionalKey(
220
+ describe(Schema.String, "Who is running the campaign, as the manifest names them."),
221
+ ),
222
+ count: describe(Schema.Finite, "Holdouts across every objective and sector."),
223
+ progress: describe(
224
+ Schema.Finite,
225
+ "Cleared over everything ever ledgered, across the campaign's objectives.",
226
+ ),
227
+ objectives: describe(Schema.Array(SnapshotObjective), "Every objective, in manifest order."),
228
+ phases: describe(
229
+ Schema.Array(SnapshotPhase),
230
+ "The ladder, in order, with how many sectors stand at each phase.",
231
+ ),
232
+ sectors: describe(Schema.Array(SnapshotSector), "Every sector the perimeter births, by name."),
233
+ legacy: describe(
234
+ Schema.Struct({
235
+ files: describe(Schema.Finite, "Files in the scope no sector claims."),
236
+ holdouts: describe(
237
+ Schema.Finite,
238
+ "Holdouts in the legacy, under the first phase's objectives — where a holdout moved out of a sector lands.",
239
+ ),
240
+ }),
241
+ "The unclaimed remainder of the scope, which stands at the first phase and is not a sink.",
242
+ ),
243
+ plan: describe(
244
+ Schema.Struct({
245
+ refined: describe(
246
+ Schema.Array(Schema.String),
247
+ "Phases refined since the last `clear`: an open phase that gained criteria, or a new one. Free.",
248
+ ),
249
+ changed: describe(
250
+ Schema.Array(Schema.String),
251
+ "Defined phases whose definition changed since the last `clear` — what a reviewer reads every time.",
252
+ ),
253
+ unreceipted: describe(
254
+ Schema.Array(Schema.String),
255
+ "Changed phases carrying no new concession; `check` fails on them.",
256
+ ),
257
+ }),
258
+ "What changed in the plan, refinements and changes apart.",
259
+ ),
260
+ stalled: describe(
261
+ Schema.Boolean,
262
+ "Holdouts remain and nothing has been cleared, attested or noted within the campaign's `staleAfter`.",
263
+ ),
264
+ complete: describe(Schema.Boolean, "No holdouts remain in any objective."),
265
+ onComplete: describe(
266
+ Schema.Literals(["keep", "remove"]),
267
+ "What the manifest asks once complete: keep the campaign as a guard, or remove it.",
268
+ ),
269
+ ledgered: describe(Schema.Boolean, "Whether every objective has a ledger."),
270
+ });
271
+
272
+ export const Snapshot = Schema.Struct({
273
+ version: describe(Schema.Literal(SNAPSHOT_VERSION), "The shape of this document."),
274
+ manifest: describe(
275
+ Schema.Struct({
276
+ path: describe(
277
+ Path,
278
+ "The file the policy was read from — the root file, when split with `include`.",
279
+ ),
280
+ sha256: describe(
281
+ Schema.String,
282
+ "A hash of that file's bytes, so two snapshots can say whether the policy changed between them.",
283
+ ),
284
+ }),
285
+ "The policy this snapshot was taken against — the repository's own, or the one `--against` named.",
286
+ ),
287
+ roots: describe(Schema.Array(Path), "The directories walked."),
288
+ files: describe(Schema.Finite, "Files walked."),
289
+ ok: describe(
290
+ Schema.Boolean,
291
+ "What `check` would exit with: true when no reportable violation, unresolved import, stale baseline entry or coverage shortfall exists.",
292
+ ),
293
+ coverage: describe(
294
+ Schema.Struct(
295
+ Object.fromEntries(COVERAGE_FAMILIES.map((family) => [family, FamilyCoverage])) as Record<
296
+ (typeof COVERAGE_FAMILIES)[number],
297
+ typeof FamilyCoverage
298
+ >,
299
+ ),
300
+ "Per family, how many walked files it reaches. Structure counts enumerated folders only.",
301
+ ),
302
+ conformance: describe(
303
+ Schema.Struct(
304
+ Object.fromEntries(CONFORMANCE_MEASURES.map((measure) => [measure, MeasureCount])) as Record<
305
+ ConformanceMeasure,
306
+ typeof MeasureCount
307
+ >,
308
+ ),
309
+ "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.",
310
+ ),
311
+ residue: describe(
312
+ Schema.Struct({
313
+ files: describe(Schema.Array(Path), "Files no family reaches, sorted."),
314
+ folders: describe(
315
+ Schema.Array(Path),
316
+ "Folders every walked file of which is residue, each the topmost such folder.",
317
+ ),
318
+ }),
319
+ "What the policy has nothing to say about. A file in an open folder under no allowlist is claimed, not policed, and counts.",
320
+ ),
321
+ vacant: describe(
322
+ Schema.Array(SnapshotVacancy),
323
+ "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.",
324
+ ),
325
+ violations: describe(
326
+ Schema.Array(SnapshotViolation),
327
+ "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.",
328
+ ),
329
+ unresolved: describe(
330
+ Schema.Array(Schema.Struct({ file: Path, specifier: Schema.String, detail: Schema.String })),
331
+ "Imports the resolver could not turn into a file. Every rule about one enforces nothing.",
332
+ ),
333
+ stale: describe(Schema.Array(Schema.String), "Baseline entries the code no longer produces."),
334
+ baseline: describe(
335
+ Schema.Struct({
336
+ size: describe(Schema.Finite, "Entries in the baseline file."),
337
+ }),
338
+ "The debt the policy is carrying. The ratchet: it may only shrink.",
339
+ ),
340
+ cycles: describe(
341
+ Schema.Finite,
342
+ "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.",
343
+ ),
344
+ slack: describe(
345
+ Schema.Array(SnapshotSlack),
346
+ "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.",
347
+ ),
348
+ concentration: describe(
349
+ Schema.Array(SnapshotConcentration),
350
+ "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.",
351
+ ),
352
+ adoption: describe(
353
+ Schema.Struct({
354
+ unrestricted: describe(Schema.Array(Schema.String), "Nodes that say `unrestricted: true`."),
355
+ partial: describe(Schema.Array(Schema.String), "Nodes that say `partial: true`."),
356
+ }),
357
+ 'The tiers that said "not tightened yet", by name; `limits` caps how many may.',
358
+ ),
359
+ campaigns: describe(
360
+ Schema.Array(SnapshotCampaign),
361
+ "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.",
362
+ ),
363
+ });
364
+
365
+ export type Snapshot = typeof Snapshot.Type;
366
+ export type SnapshotViolation = typeof SnapshotViolation.Type;
367
+ export type SnapshotSlack = typeof SnapshotSlack.Type;
368
+ export type SnapshotConcentration = typeof SnapshotConcentration.Type;
369
+ export type SnapshotVacancy = typeof SnapshotVacancy.Type;
370
+ export type SnapshotCampaign = typeof SnapshotCampaign.Type;
371
+ export type SnapshotObjective = typeof SnapshotObjective.Type;
372
+ export type SnapshotPhase = typeof SnapshotPhase.Type;
373
+ export type SnapshotSector = typeof SnapshotSector.Type;
374
+
375
+ // Decodes a document some other run wrote — the base of a pull request, a
376
+ // stored one — refusing a key the shape does not declare, so a consumer never
377
+ // reads a field that a later version renamed.
378
+ export const decodeSnapshot = Schema.decodeUnknownResult(Snapshot, {
379
+ errors: "all",
380
+ onExcessProperty: "error",
381
+ });
382
+
383
+ type JsonValue = string | number | boolean | null | JsonObject | ReadonlyArray<JsonValue>;
384
+ type JsonObject = { readonly [key: string]: JsonValue };
385
+
386
+ // The document's shape as a JSON Schema, generated from the same codec, so
387
+ // the two cannot disagree. Published beside the manifest's.
388
+ export const snapshotJsonSchema = (): JsonObject => {
389
+ const generated = Schema.toJsonSchemaDocument(Snapshot) as unknown as {
390
+ readonly schema: JsonObject;
391
+ readonly definitions: JsonObject;
392
+ };
393
+ return {
394
+ $schema: "https://json-schema.org/draft/2020-12/schema",
395
+ $id: SNAPSHOT_SCHEMA_ID,
396
+ title: "Conformance snapshot",
397
+ description:
398
+ "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/.",
399
+ ...generated.schema,
400
+ ...(Object.keys(generated.definitions).length === 0 ? {} : { $defs: generated.definitions }),
401
+ };
402
+ };
@@ -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;