@forwardimpact/libinvariant 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/package.json +60 -0
- package/src/enum-drift-grammar.js +565 -0
- package/src/enum-drift.js +315 -0
- package/src/index.js +9 -0
- package/src/instructions.js +394 -0
- package/src/invariant-kit.js +487 -0
- package/src/invariants.js +164 -0
- package/src/jtbd.js +517 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// The enumeration-drift engine: assert that every registered consumer's fenced
|
|
2
|
+
// enumeration block matches its source-of-truth set. This is the reusable
|
|
3
|
+
// mechanism the invariant kit injects — `kit.enumDrift.build/seed` and the
|
|
4
|
+
// `enumDriftRules` rule set — so a repository's rule module carries only the
|
|
5
|
+
// registry (a topics file) and a one-line delegation. The grammar (probes,
|
|
6
|
+
// extractors, consumer parser) lives in enum-drift-grammar.js and is re-exported
|
|
7
|
+
// here so a single import reaches the whole engine. Filesystem access is passed
|
|
8
|
+
// in by the kit (`fsSync`), keeping this clean under the ambient-deps invariant.
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { parseConsumer, probeSource } from "./enum-drift-grammar.js";
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
bareSlug,
|
|
16
|
+
checkContainment,
|
|
17
|
+
deriveId,
|
|
18
|
+
extractCount,
|
|
19
|
+
extractCounts,
|
|
20
|
+
extractList,
|
|
21
|
+
normalizeToken,
|
|
22
|
+
parseConsumer,
|
|
23
|
+
parseTableRow,
|
|
24
|
+
probeFsGlob,
|
|
25
|
+
probeMdTable,
|
|
26
|
+
probeSource,
|
|
27
|
+
segmentToRegExp,
|
|
28
|
+
VALID_PROPERTIES,
|
|
29
|
+
} from "./enum-drift-grammar.js";
|
|
30
|
+
|
|
31
|
+
// A topics file is expected to name itself this way beside the rule module;
|
|
32
|
+
// used only as the display path on a registry-error finding.
|
|
33
|
+
const REGISTRY_LABEL = "enumeration-drift.topics.yml";
|
|
34
|
+
|
|
35
|
+
function expandProperty(property) {
|
|
36
|
+
return property === "both" ? ["count", "list"] : [property];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Index per-consumer required properties from one topic's consumers list.
|
|
40
|
+
function indexConsumers(topic, propsByConsumer) {
|
|
41
|
+
for (const consumer of topic.consumers ?? []) {
|
|
42
|
+
if (!propsByConsumer.has(consumer.path)) {
|
|
43
|
+
propsByConsumer.set(consumer.path, new Map());
|
|
44
|
+
}
|
|
45
|
+
const map = propsByConsumer.get(consumer.path);
|
|
46
|
+
for (const p of expandProperty(consumer.property)) {
|
|
47
|
+
if (!map.has(topic.id)) map.set(topic.id, new Set());
|
|
48
|
+
map.get(topic.id).add(p);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Walk the registry topics, probing each source and indexing per-consumer
|
|
54
|
+
// required properties; collects probe errors as registry subjects.
|
|
55
|
+
function indexRegistry(topics, root, fsSync, registrySubjects) {
|
|
56
|
+
const expectedByTopic = new Map();
|
|
57
|
+
const propsByConsumer = new Map();
|
|
58
|
+
const knownTopics = new Set();
|
|
59
|
+
for (const topic of topics) {
|
|
60
|
+
if (!topic || typeof topic.id !== "string") {
|
|
61
|
+
registrySubjects.push({
|
|
62
|
+
path: REGISTRY_LABEL,
|
|
63
|
+
error: "topic missing `id`",
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
knownTopics.add(topic.id);
|
|
68
|
+
const probed = probeSource(topic.source, root, fsSync);
|
|
69
|
+
if (probed.error) {
|
|
70
|
+
registrySubjects.push({
|
|
71
|
+
path: REGISTRY_LABEL,
|
|
72
|
+
error: `topic \`${topic.id}\`: ${probed.error}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
expectedByTopic.set(topic.id, probed.error ? null : probed.set);
|
|
76
|
+
indexConsumers(topic, propsByConsumer);
|
|
77
|
+
}
|
|
78
|
+
return { expectedByTopic, propsByConsumer, knownTopics };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Emit assertion subjects for one consumer: the registry property is a required
|
|
82
|
+
// minimum, and beyond that every well-formed fence found is asserted.
|
|
83
|
+
function consumerAssertions(cp, topicMap, records, expectedByTopic) {
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const [topicId, props] of topicMap) {
|
|
86
|
+
const expected = expectedByTopic.get(topicId);
|
|
87
|
+
for (const property of props) {
|
|
88
|
+
const matches = records.filter(
|
|
89
|
+
(r) => r.topic === topicId && r.property === property && !r.malformed,
|
|
90
|
+
);
|
|
91
|
+
if (matches.length === 0) {
|
|
92
|
+
out.push({
|
|
93
|
+
path: cp,
|
|
94
|
+
topic: topicId,
|
|
95
|
+
property,
|
|
96
|
+
expected,
|
|
97
|
+
fenceAbsent: true,
|
|
98
|
+
});
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const match of matches) {
|
|
102
|
+
out.push({
|
|
103
|
+
path: cp,
|
|
104
|
+
topic: topicId,
|
|
105
|
+
property,
|
|
106
|
+
expected,
|
|
107
|
+
observed: match.observed,
|
|
108
|
+
fenceAbsent: false,
|
|
109
|
+
lineNo: match.lineNo,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Map one consumer's parsed records into fence subjects (unknown/malformed
|
|
118
|
+
// detection) under the known-topic set.
|
|
119
|
+
function fenceSubjects(cp, records, knownTopics) {
|
|
120
|
+
return records.map((rec) => ({
|
|
121
|
+
path: cp,
|
|
122
|
+
topic: rec.topic ?? null,
|
|
123
|
+
property: rec.property ?? null,
|
|
124
|
+
lineNo: rec.lineNo,
|
|
125
|
+
malformed: rec.malformed,
|
|
126
|
+
known: rec.topic != null && knownTopics.has(rec.topic),
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build subjects from a parsed registry: assertions (consumer×property),
|
|
132
|
+
* fences, and registry errors. `registry` is the parsed topics object (e.g. the
|
|
133
|
+
* kit's `config(topicsFile)`); a missing or malformed registry yields a single
|
|
134
|
+
* registry-error subject rather than throwing.
|
|
135
|
+
*
|
|
136
|
+
* @param {{ registry: { topics?: object[] }|null, root: string, fsSync: object }} options
|
|
137
|
+
* @returns {{ subjects: { assertion: object[], fence: object[], registry: object[] } }}
|
|
138
|
+
*/
|
|
139
|
+
export function buildSubjects({ registry, root, fsSync }) {
|
|
140
|
+
if (!registry || !Array.isArray(registry.topics)) {
|
|
141
|
+
return {
|
|
142
|
+
subjects: {
|
|
143
|
+
assertion: [],
|
|
144
|
+
fence: [],
|
|
145
|
+
registry: [
|
|
146
|
+
{
|
|
147
|
+
path: REGISTRY_LABEL,
|
|
148
|
+
error:
|
|
149
|
+
"cannot read the registry (expected a top-level `topics` list)",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const registrySubjects = [];
|
|
156
|
+
const { expectedByTopic, propsByConsumer, knownTopics } = indexRegistry(
|
|
157
|
+
registry.topics,
|
|
158
|
+
root,
|
|
159
|
+
fsSync,
|
|
160
|
+
registrySubjects,
|
|
161
|
+
);
|
|
162
|
+
const assertion = [];
|
|
163
|
+
const fence = [];
|
|
164
|
+
for (const [cp, topicMap] of propsByConsumer) {
|
|
165
|
+
let records;
|
|
166
|
+
try {
|
|
167
|
+
records = parseConsumer(fsSync.readFileSync(join(root, cp), "utf8"));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
registrySubjects.push({
|
|
170
|
+
path: cp,
|
|
171
|
+
error: `cannot read consumer: ${err.message}`,
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
fence.push(...fenceSubjects(cp, records, knownTopics));
|
|
176
|
+
assertion.push(
|
|
177
|
+
...consumerAssertions(cp, topicMap, records, expectedByTopic),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return { subjects: { assertion, fence, registry: registrySubjects } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- seed() -----------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function seedIndex(topics, root, fsSync) {
|
|
186
|
+
const byConsumer = new Map();
|
|
187
|
+
const expected = new Map();
|
|
188
|
+
for (const topic of topics) {
|
|
189
|
+
const probed = probeSource(topic.source, root, fsSync);
|
|
190
|
+
expected.set(topic.id, probed.error ? null : probed.set);
|
|
191
|
+
for (const consumer of topic.consumers ?? []) {
|
|
192
|
+
if (!byConsumer.has(consumer.path)) byConsumer.set(consumer.path, []);
|
|
193
|
+
for (const property of expandProperty(consumer.property)) {
|
|
194
|
+
byConsumer.get(consumer.path).push({ topic: topic.id, property });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { byConsumer, expected };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function seedBody(set, property) {
|
|
202
|
+
if (set == null) return ["# (source probe failed)"];
|
|
203
|
+
if (property === "count") return [`${set.size}`];
|
|
204
|
+
return [...set].sort().map((id) => `- ${id}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Render canonical fence bodies per consumer from current probe output, so an
|
|
209
|
+
* author can paste a refreshed enumeration.
|
|
210
|
+
*
|
|
211
|
+
* @param {{ registry: { topics?: object[] }|null, root: string, fsSync: object }} options
|
|
212
|
+
* @returns {string} The seed text.
|
|
213
|
+
*/
|
|
214
|
+
export function seedBodies({ registry, root, fsSync }) {
|
|
215
|
+
if (!registry || !Array.isArray(registry.topics)) {
|
|
216
|
+
return "# registry error: expected a top-level `topics` list\n";
|
|
217
|
+
}
|
|
218
|
+
const { byConsumer, expected } = seedIndex(registry.topics, root, fsSync);
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const [path, claims] of byConsumer) {
|
|
221
|
+
out.push(`# ${path}`);
|
|
222
|
+
for (const { topic, property } of claims) {
|
|
223
|
+
out.push(
|
|
224
|
+
`<!-- enum:${topic}:${property} -->`,
|
|
225
|
+
...seedBody(expected.get(topic), property),
|
|
226
|
+
"<!-- /enum -->",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
out.push("");
|
|
230
|
+
}
|
|
231
|
+
return `${out.join("\n")}\n`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- rules ------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
function symDiff(observed, expected) {
|
|
237
|
+
const obs = observed instanceof Set ? observed : new Set();
|
|
238
|
+
return {
|
|
239
|
+
missing: [...expected].filter((x) => !obs.has(x)).sort(),
|
|
240
|
+
extra: [...obs].filter((x) => !expected.has(x)).sort(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The enumeration-drift rule set, injected into a rule module via the rule kit
|
|
246
|
+
* as `enumDriftRules`. The rules render the subjects `buildSubjects` produces.
|
|
247
|
+
*/
|
|
248
|
+
export const ENUM_DRIFT_RULES = [
|
|
249
|
+
{
|
|
250
|
+
id: "enum.registry-invalid",
|
|
251
|
+
scope: "registry",
|
|
252
|
+
severity: "fail",
|
|
253
|
+
check: (s) => (s.error ? { error: s.error } : null),
|
|
254
|
+
message: (s, r) => `enumeration-drift registry/probe error :: ${r.error}`,
|
|
255
|
+
hint: "fix the enumeration-drift topics file (or the source/consumer it points at) so the probe can resolve",
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "enum.fence-missing",
|
|
259
|
+
scope: "assertion",
|
|
260
|
+
severity: "fail",
|
|
261
|
+
when: (s) => s.fenceAbsent,
|
|
262
|
+
check: (s) => ({ topic: s.topic, property: s.property }),
|
|
263
|
+
message: (s, r) => `${r.topic}:${r.property} :: required fence not found`,
|
|
264
|
+
hint: "wrap the enumeration in <!-- enum:TOPIC:PROPERTY --> … <!-- /enum -->; seed the body with `jidoka invariants --seed enumeration-drift`",
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
id: "enum.unknown-topic",
|
|
268
|
+
scope: "fence",
|
|
269
|
+
severity: "fail",
|
|
270
|
+
when: (s) => !s.malformed && s.topic !== null,
|
|
271
|
+
check: (s) => (s.known ? null : { topic: s.topic }),
|
|
272
|
+
message: (s, r) =>
|
|
273
|
+
`${r.topic} :: unknown topic; remove the fence or add the topic to the registry`,
|
|
274
|
+
hint: "fence TOPIC must be one of the registry topic ids in the enumeration-drift topics file",
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: "enum.malformed-fence",
|
|
278
|
+
scope: "fence",
|
|
279
|
+
severity: "fail",
|
|
280
|
+
when: (s) => Boolean(s.malformed),
|
|
281
|
+
check: (s) => ({ reason: s.malformed }),
|
|
282
|
+
message: (s, r) => `malformed fence (${r.reason})`,
|
|
283
|
+
hint: "fences are <!-- enum:TOPIC:count|list --> … <!-- /enum -->; close every open fence and put a number in a count span",
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
id: "enum.list-drift",
|
|
287
|
+
scope: "assertion",
|
|
288
|
+
severity: "fail",
|
|
289
|
+
when: (s) =>
|
|
290
|
+
s.property === "list" && !s.fenceAbsent && s.expected instanceof Set,
|
|
291
|
+
check: (s) => {
|
|
292
|
+
const { missing, extra } = symDiff(s.observed, s.expected);
|
|
293
|
+
return missing.length === 0 && extra.length === 0
|
|
294
|
+
? null
|
|
295
|
+
: { topic: s.topic, missing, extra };
|
|
296
|
+
},
|
|
297
|
+
message: (s, r) =>
|
|
298
|
+
`${r.topic}:list :: missing=[${r.missing.join(", ")}] extra=[${r.extra.join(", ")}]`,
|
|
299
|
+
hint: "update the fenced list to match the source set; seed with `jidoka invariants --seed enumeration-drift`",
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
id: "enum.count-drift",
|
|
303
|
+
scope: "assertion",
|
|
304
|
+
severity: "fail",
|
|
305
|
+
when: (s) =>
|
|
306
|
+
s.property === "count" && !s.fenceAbsent && s.expected instanceof Set,
|
|
307
|
+
check: (s) =>
|
|
308
|
+
s.observed === s.expected.size
|
|
309
|
+
? null
|
|
310
|
+
: { topic: s.topic, actual: s.observed, expected: s.expected.size },
|
|
311
|
+
message: (s, r) =>
|
|
312
|
+
`${r.topic}:count :: actual=${r.actual} expected=${r.expected}`,
|
|
313
|
+
hint: "update the fenced count to match the source set size; seed with `jidoka invariants --seed enumeration-drift`",
|
|
314
|
+
},
|
|
315
|
+
];
|
package/src/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { checkInstructions } from "./instructions.js";
|
|
2
|
+
export { createBuildKit, RULE_KIT } from "./invariant-kit.js";
|
|
3
|
+
export {
|
|
4
|
+
checkInvariants,
|
|
5
|
+
findInvariantsRoot,
|
|
6
|
+
loadRuleModules,
|
|
7
|
+
runRuleModules,
|
|
8
|
+
} from "./invariants.js";
|
|
9
|
+
export { checkJtbd } from "./jtbd.js";
|