@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
package/src/jtbd.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import * as prettier from "prettier";
|
|
3
|
+
import { runRules } from "@forwardimpact/libutil";
|
|
4
|
+
|
|
5
|
+
const VALID_USERS = [
|
|
6
|
+
"Engineering Leaders",
|
|
7
|
+
"Empowered Engineers",
|
|
8
|
+
"Platform Builders",
|
|
9
|
+
"Teams Using Agents",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const USER_ORDER = new Map(VALID_USERS.map((u, i) => [u, i]));
|
|
13
|
+
|
|
14
|
+
function catalogs(root) {
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
name: "products",
|
|
18
|
+
dir: join(root, "products"),
|
|
19
|
+
readme: join(root, "products", "README.md"),
|
|
20
|
+
filter: (name) => !name.startsWith(".") && name !== "node_modules",
|
|
21
|
+
column: "Product",
|
|
22
|
+
skipUniqueHires: true,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "services",
|
|
26
|
+
dir: join(root, "services"),
|
|
27
|
+
readme: join(root, "services", "README.md"),
|
|
28
|
+
filter: (name) => !name.startsWith(".") && name !== "node_modules",
|
|
29
|
+
column: "Service",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "libraries",
|
|
33
|
+
dir: join(root, "libraries"),
|
|
34
|
+
readme: join(root, "libraries", "README.md"),
|
|
35
|
+
filter: (name) => name.startsWith("lib"),
|
|
36
|
+
column: "Library",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadPackages(dir, filter, fsSync) {
|
|
42
|
+
if (!fsSync.existsSync(dir)) return [];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const name of fsSync.readdirSync(dir)) {
|
|
45
|
+
if (!filter(name)) continue;
|
|
46
|
+
const pkgPath = join(dir, name, "package.json");
|
|
47
|
+
let pkg;
|
|
48
|
+
try {
|
|
49
|
+
pkg = JSON.parse(fsSync.readFileSync(pkgPath, "utf8"));
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
out.push({ dir: name, pkg });
|
|
54
|
+
}
|
|
55
|
+
return out.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function slot(s) {
|
|
59
|
+
return `.jobs[${s.index}]`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const JTBD_RULES = [
|
|
63
|
+
{
|
|
64
|
+
id: "jtbd.jobs-must-be-array",
|
|
65
|
+
scope: "package-jobs",
|
|
66
|
+
severity: "fail",
|
|
67
|
+
check: (s) => (Array.isArray(s.jobs) ? null : {}),
|
|
68
|
+
message: () => ".jobs must be an array",
|
|
69
|
+
hint: "wrap the value in [] — even a single job is an array of one",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "jtbd.invalid-user",
|
|
73
|
+
scope: "jtbd-entry",
|
|
74
|
+
severity: "fail",
|
|
75
|
+
check: (s) =>
|
|
76
|
+
s.entry.user && VALID_USERS.includes(s.entry.user)
|
|
77
|
+
? null
|
|
78
|
+
: { user: s.entry.user },
|
|
79
|
+
message: (s, r) => `${slot(s)}: invalid user "${r.user}"`,
|
|
80
|
+
hint: `must be one of: ${VALID_USERS.join(", ")}`,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "jtbd.missing-field",
|
|
84
|
+
scope: "jtbd-entry",
|
|
85
|
+
severity: "fail",
|
|
86
|
+
check: (s) => {
|
|
87
|
+
const offenders = [];
|
|
88
|
+
for (const field of [
|
|
89
|
+
"goal",
|
|
90
|
+
"trigger",
|
|
91
|
+
"competesWith",
|
|
92
|
+
"bigHire",
|
|
93
|
+
"littleHire",
|
|
94
|
+
]) {
|
|
95
|
+
if (!s.entry[field] || typeof s.entry[field] !== "string") {
|
|
96
|
+
offenders.push({ field });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return offenders.length === 0 ? null : offenders;
|
|
100
|
+
},
|
|
101
|
+
message: (s, r) =>
|
|
102
|
+
`${slot(s)}: ${r.field} is required and must be a string`,
|
|
103
|
+
hint: "every job entry needs goal, trigger, competesWith, bigHire, and littleHire (hires end with a period)",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: "jtbd.hire-missing-period",
|
|
107
|
+
scope: "jtbd-entry",
|
|
108
|
+
severity: "fail",
|
|
109
|
+
check: (s) => {
|
|
110
|
+
const offenders = [];
|
|
111
|
+
for (const field of ["bigHire", "littleHire"]) {
|
|
112
|
+
const v = s.entry[field];
|
|
113
|
+
if (typeof v === "string" && v.length > 0 && !v.endsWith(".")) {
|
|
114
|
+
offenders.push({ field, value: v });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return offenders.length === 0 ? null : offenders;
|
|
118
|
+
},
|
|
119
|
+
message: (s, r) =>
|
|
120
|
+
`${slot(s)}: ${r.field} must end with "." — "${r.value}"`,
|
|
121
|
+
hint: "append a period to the hire sentence",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
// Cross-entry uniqueness — mutates ctx.allHires across iterations.
|
|
125
|
+
id: "jtbd.duplicate-hire",
|
|
126
|
+
scope: "jtbd-entry",
|
|
127
|
+
severity: "fail",
|
|
128
|
+
when: (s) => !s.skipUniqueHires,
|
|
129
|
+
check: (s, ctx) => {
|
|
130
|
+
const offenders = [];
|
|
131
|
+
for (const field of ["bigHire", "littleHire"]) {
|
|
132
|
+
const v = s.entry[field];
|
|
133
|
+
if (typeof v !== "string" || !v) continue;
|
|
134
|
+
const key = `${field}:${v.toLowerCase()}`;
|
|
135
|
+
const prior = ctx.allHires.get(key);
|
|
136
|
+
if (prior && prior.goal !== s.entry.goal) {
|
|
137
|
+
offenders.push({ field, value: v, otherLoc: prior.loc });
|
|
138
|
+
}
|
|
139
|
+
ctx.allHires.set(key, { loc: s.loc, goal: s.entry.goal });
|
|
140
|
+
}
|
|
141
|
+
return offenders.length === 0 ? null : offenders;
|
|
142
|
+
},
|
|
143
|
+
message: (s, r) =>
|
|
144
|
+
`${slot(s)}: duplicate ${r.field} "${r.value}" (also in ${r.otherLoc})`,
|
|
145
|
+
hint: "merge the duplicate job into a single entry, or differentiate the hire text",
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
function buildSubjects(packages, catalogDir, catalogName, skipUniqueHires) {
|
|
150
|
+
const packageSubjects = [];
|
|
151
|
+
const entrySubjects = [];
|
|
152
|
+
for (const { dir, pkg } of packages) {
|
|
153
|
+
const pkgPath = join(catalogDir, dir, "package.json");
|
|
154
|
+
if (pkg.jobs == null) continue;
|
|
155
|
+
packageSubjects.push({ path: pkgPath, jobs: pkg.jobs });
|
|
156
|
+
if (!Array.isArray(pkg.jobs)) continue;
|
|
157
|
+
const loc = `${catalogName}/${dir}`;
|
|
158
|
+
pkg.jobs.forEach((entry, i) => {
|
|
159
|
+
entrySubjects.push({
|
|
160
|
+
path: pkgPath,
|
|
161
|
+
index: i,
|
|
162
|
+
entry,
|
|
163
|
+
loc,
|
|
164
|
+
skipUniqueHires,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return { packageSubjects, entrySubjects };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validate(
|
|
172
|
+
packages,
|
|
173
|
+
catalogDir,
|
|
174
|
+
catalogName,
|
|
175
|
+
{ skipUniqueHires = false } = {},
|
|
176
|
+
) {
|
|
177
|
+
const { packageSubjects, entrySubjects } = buildSubjects(
|
|
178
|
+
packages,
|
|
179
|
+
catalogDir,
|
|
180
|
+
catalogName,
|
|
181
|
+
skipUniqueHires,
|
|
182
|
+
);
|
|
183
|
+
const ctx = {
|
|
184
|
+
allHires: new Map(),
|
|
185
|
+
subjects: {
|
|
186
|
+
"package-jobs": packageSubjects,
|
|
187
|
+
"jtbd-entry": entrySubjects,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
|
|
191
|
+
return runRules(JTBD_RULES, ctx, { resolveScope });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function renderTable(headers, rows) {
|
|
195
|
+
const widths = headers.map((h, i) =>
|
|
196
|
+
Math.max(h.length, ...rows.map((r) => r[i].length)),
|
|
197
|
+
);
|
|
198
|
+
const line = (cells) =>
|
|
199
|
+
`| ${cells.map((c, i) => c.padEnd(widths[i])).join(" | ")} |`;
|
|
200
|
+
return [
|
|
201
|
+
line(headers),
|
|
202
|
+
line(widths.map((w) => "-".repeat(w))),
|
|
203
|
+
...rows.map(line),
|
|
204
|
+
].join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function findMarker(content, tag) {
|
|
208
|
+
const pattern = new RegExp(`<!--\\s*${tag}[^>]*-->`);
|
|
209
|
+
const match = content.match(pattern);
|
|
210
|
+
if (!match) return null;
|
|
211
|
+
return { text: match[0], index: match.index };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function replaceBlock(content, beginTag, endTag, body) {
|
|
215
|
+
const begin = findMarker(content, beginTag);
|
|
216
|
+
const end = findMarker(content, endTag);
|
|
217
|
+
if (!(begin && end) || end.index < begin.index) return null;
|
|
218
|
+
return (
|
|
219
|
+
content.slice(0, begin.index) +
|
|
220
|
+
`${begin.text}\n\n${body}\n\n${end.text}` +
|
|
221
|
+
content.slice(end.index + end.text.length)
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function mergeTriggers(triggers) {
|
|
226
|
+
const stripped = triggers.map((t) => t.replace(/\.\s*$/, "").trim());
|
|
227
|
+
const unique = [...new Set(stripped)];
|
|
228
|
+
const parts = unique.map((s, i) =>
|
|
229
|
+
i > 0 ? s.charAt(0).toLowerCase() + s.slice(1) : s,
|
|
230
|
+
);
|
|
231
|
+
return parts.join("; ") + ".";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function mergeCompetesWith(fragments) {
|
|
235
|
+
if (fragments.length === 0) return null;
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
const unique = [];
|
|
238
|
+
for (const f of fragments) {
|
|
239
|
+
const normalized = f.replace(/\.\s*$/, "").trim();
|
|
240
|
+
const key = normalized.toLowerCase();
|
|
241
|
+
if (!seen.has(key)) {
|
|
242
|
+
seen.add(key);
|
|
243
|
+
unique.push(normalized);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return unique.join("; ") + ".";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function mergeHireField(entries, capitalize) {
|
|
250
|
+
if (entries.length === 0) return null;
|
|
251
|
+
const stripped = entries.map((e) => e.text.replace(/\.\s*$/, "").trim());
|
|
252
|
+
const unique = [...new Set(stripped)];
|
|
253
|
+
const parts = unique.map((s, i) =>
|
|
254
|
+
i > 0 ? s.charAt(0).toLowerCase() + s.slice(1) : s,
|
|
255
|
+
);
|
|
256
|
+
const pkgs = [...new Set(entries.map((e) => e.pkg))];
|
|
257
|
+
const names = pkgs.map((p) =>
|
|
258
|
+
capitalize ? p.charAt(0).toUpperCase() + p.slice(1) : p,
|
|
259
|
+
);
|
|
260
|
+
return `Help me ${parts.join("; ")}. → **${names.join(", ")}**`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function addEntryToGroup(group, entry, dir) {
|
|
264
|
+
if (entry.trigger) group.triggers.push(entry.trigger);
|
|
265
|
+
if (entry.bigHire) group.bigHires.push({ text: entry.bigHire, pkg: dir });
|
|
266
|
+
if (entry.littleHire)
|
|
267
|
+
group.littleHires.push({ text: entry.littleHire, pkg: dir });
|
|
268
|
+
if (entry.competesWith) {
|
|
269
|
+
for (const fragment of entry.competesWith.split(";")) {
|
|
270
|
+
const trimmed = fragment.trim();
|
|
271
|
+
if (trimmed) group.competesWith.push(trimmed);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (entry.forces && !group.forces) group.forces = entry.forces;
|
|
275
|
+
if (entry.firedWhen && !group.firedWhen) group.firedWhen = entry.firedWhen;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function collectJobGroups(packages) {
|
|
279
|
+
const groups = new Map();
|
|
280
|
+
for (const { dir, pkg } of packages) {
|
|
281
|
+
if (!pkg.jobs) continue;
|
|
282
|
+
for (const entry of pkg.jobs) {
|
|
283
|
+
const key = `${entry.user}\0${entry.goal}`;
|
|
284
|
+
if (!groups.has(key)) {
|
|
285
|
+
groups.set(key, {
|
|
286
|
+
user: entry.user,
|
|
287
|
+
goal: entry.goal,
|
|
288
|
+
triggers: [],
|
|
289
|
+
bigHires: [],
|
|
290
|
+
littleHires: [],
|
|
291
|
+
competesWith: [],
|
|
292
|
+
forces: null,
|
|
293
|
+
firedWhen: null,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
addEntryToGroup(groups.get(key), entry, dir);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return groups;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function buildJobProse(group, capitalize) {
|
|
303
|
+
const trigger = mergeTriggers(group.triggers);
|
|
304
|
+
const competesWith = mergeCompetesWith(group.competesWith);
|
|
305
|
+
const bigHire = mergeHireField(group.bigHires, capitalize);
|
|
306
|
+
const littleHire = mergeHireField(group.littleHires, capitalize);
|
|
307
|
+
|
|
308
|
+
const lines = [
|
|
309
|
+
`## ${group.user}: ${group.goal}`,
|
|
310
|
+
"",
|
|
311
|
+
`**Trigger:** ${trigger}`,
|
|
312
|
+
];
|
|
313
|
+
if (bigHire) lines.push("", `**Big Hire:** ${bigHire}`);
|
|
314
|
+
if (littleHire) lines.push("", `**Little Hire:** ${littleHire}`);
|
|
315
|
+
lines.push("", `**Competes With:** ${competesWith}`);
|
|
316
|
+
|
|
317
|
+
if (group.forces) {
|
|
318
|
+
lines.push(
|
|
319
|
+
"",
|
|
320
|
+
"**Forces:**",
|
|
321
|
+
`- **Push:** ${group.forces.push}`,
|
|
322
|
+
`- **Pull:** ${group.forces.pull}`,
|
|
323
|
+
`- **Habit:** ${group.forces.habit}`,
|
|
324
|
+
`- **Anxiety:** ${group.forces.anxiety}`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
if (group.firedWhen) lines.push("", `**Fired When:** ${group.firedWhen}`);
|
|
328
|
+
return lines.join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function makeFormatter(prettierConfig) {
|
|
332
|
+
return async function formatMarkdown(text) {
|
|
333
|
+
const formatted = await prettier.format(text, {
|
|
334
|
+
...prettierConfig,
|
|
335
|
+
parser: "markdown",
|
|
336
|
+
});
|
|
337
|
+
// Prettier inserts a blank line between bold labels and bullet lists;
|
|
338
|
+
// remove it to match the hand-written JTBD.md style.
|
|
339
|
+
return formatted.replace(/\*\*\n\n(- )/g, "**\n$1").trimEnd();
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function buildCatalog(content, packages, column, formatMarkdown) {
|
|
344
|
+
const rows = packages
|
|
345
|
+
.filter(({ pkg }) => pkg.description)
|
|
346
|
+
.map(({ dir, pkg }) => [`**${dir}**`, pkg.description]);
|
|
347
|
+
if (rows.length === 0) return content;
|
|
348
|
+
const body = await formatMarkdown(renderTable([column, "Description"], rows));
|
|
349
|
+
return replaceBlock(content, "BEGIN:catalog", "END:catalog", body) ?? content;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function buildJobs(
|
|
353
|
+
content,
|
|
354
|
+
packages,
|
|
355
|
+
formatMarkdown,
|
|
356
|
+
{ capitalize = false } = {},
|
|
357
|
+
) {
|
|
358
|
+
const groups = collectJobGroups(packages);
|
|
359
|
+
if (groups.size === 0) return content;
|
|
360
|
+
|
|
361
|
+
const sorted = [...groups.values()].sort((a, b) => {
|
|
362
|
+
const ua = USER_ORDER.get(a.user) ?? 99;
|
|
363
|
+
const ub = USER_ORDER.get(b.user) ?? 99;
|
|
364
|
+
if (ua !== ub) return ua - ub;
|
|
365
|
+
return a.goal.localeCompare(b.goal);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const blocks = await Promise.all(
|
|
369
|
+
sorted.map(async (group) => {
|
|
370
|
+
const formatted = await formatMarkdown(buildJobProse(group, capitalize));
|
|
371
|
+
return `<job user="${group.user}" goal="${group.goal}">\n\n${formatted}\n\n</job>`;
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const body = blocks.join("\n\n");
|
|
376
|
+
return replaceBlock(content, "BEGIN:jobs", "END:jobs", body) ?? content;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function buildDescription(content, description, formatMarkdown) {
|
|
380
|
+
const formatted = await formatMarkdown(description);
|
|
381
|
+
const result = replaceBlock(
|
|
382
|
+
content,
|
|
383
|
+
"BEGIN:description",
|
|
384
|
+
"END:description",
|
|
385
|
+
formatted,
|
|
386
|
+
);
|
|
387
|
+
if (result !== null) return result;
|
|
388
|
+
|
|
389
|
+
const headingMatch = content.match(/^# .+\n/);
|
|
390
|
+
if (!headingMatch) return null;
|
|
391
|
+
const headingEnd = headingMatch[0].length;
|
|
392
|
+
const nextSection = content.indexOf("\n\n## ", headingEnd);
|
|
393
|
+
const descEnd = nextSection !== -1 ? nextSection : content.length;
|
|
394
|
+
|
|
395
|
+
const begin =
|
|
396
|
+
"<!-- BEGIN:description — Do not edit. Generated from package.json. -->";
|
|
397
|
+
const end = "<!-- END:description -->";
|
|
398
|
+
|
|
399
|
+
return (
|
|
400
|
+
content.slice(0, headingEnd) +
|
|
401
|
+
`\n${begin}\n\n${formatted}\n\n${end}` +
|
|
402
|
+
content.slice(descEnd)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function commitUpdate(filePath, label, original, updated, fix, result, fsSync) {
|
|
407
|
+
if (updated === null || updated === original) return;
|
|
408
|
+
if (!fix) {
|
|
409
|
+
result.stale.push(label);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
fsSync.writeFileSync(filePath, updated);
|
|
413
|
+
result.fixed.push(label);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function processCatalog(catalog, fix, formatMarkdown, result, fsSync) {
|
|
417
|
+
const packages = loadPackages(catalog.dir, catalog.filter, fsSync);
|
|
418
|
+
const findings = validate(packages, catalog.dir, catalog.name, {
|
|
419
|
+
skipUniqueHires: catalog.skipUniqueHires ?? false,
|
|
420
|
+
});
|
|
421
|
+
if (findings.length > 0) {
|
|
422
|
+
result.findings.push(...findings);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (fsSync.existsSync(catalog.readme)) {
|
|
427
|
+
const original = fsSync.readFileSync(catalog.readme, "utf8");
|
|
428
|
+
let content = original;
|
|
429
|
+
content = await buildCatalog(
|
|
430
|
+
content,
|
|
431
|
+
packages,
|
|
432
|
+
catalog.column,
|
|
433
|
+
formatMarkdown,
|
|
434
|
+
);
|
|
435
|
+
content = await buildJobs(content, packages, formatMarkdown);
|
|
436
|
+
commitUpdate(
|
|
437
|
+
catalog.readme,
|
|
438
|
+
`${catalog.name}/README.md`,
|
|
439
|
+
original,
|
|
440
|
+
content,
|
|
441
|
+
fix,
|
|
442
|
+
result,
|
|
443
|
+
fsSync,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
for (const { dir, pkg } of packages) {
|
|
448
|
+
if (!pkg.description) continue;
|
|
449
|
+
const pkgReadme = join(catalog.dir, dir, "README.md");
|
|
450
|
+
if (!fsSync.existsSync(pkgReadme)) continue;
|
|
451
|
+
const original = fsSync.readFileSync(pkgReadme, "utf8");
|
|
452
|
+
const updated = await buildDescription(
|
|
453
|
+
original,
|
|
454
|
+
pkg.description,
|
|
455
|
+
formatMarkdown,
|
|
456
|
+
);
|
|
457
|
+
commitUpdate(
|
|
458
|
+
pkgReadme,
|
|
459
|
+
`${catalog.name}/${dir}/README.md`,
|
|
460
|
+
original,
|
|
461
|
+
updated,
|
|
462
|
+
fix,
|
|
463
|
+
result,
|
|
464
|
+
fsSync,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function processJtbdMd(root, fix, formatMarkdown, result, fsSync) {
|
|
470
|
+
const jtbdPath = join(root, "JTBD.md");
|
|
471
|
+
if (!fsSync.existsSync(jtbdPath)) return;
|
|
472
|
+
const productsCatalog = catalogs(root).find((c) => c.name === "products");
|
|
473
|
+
const packages = loadPackages(
|
|
474
|
+
productsCatalog.dir,
|
|
475
|
+
productsCatalog.filter,
|
|
476
|
+
fsSync,
|
|
477
|
+
);
|
|
478
|
+
const findings = validate(packages, productsCatalog.dir, "products", {
|
|
479
|
+
skipUniqueHires: true,
|
|
480
|
+
});
|
|
481
|
+
if (findings.length > 0) {
|
|
482
|
+
if (result.findings.length === 0) result.findings.push(...findings);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const original = fsSync.readFileSync(jtbdPath, "utf8");
|
|
486
|
+
const updated = await buildJobs(original, packages, formatMarkdown, {
|
|
487
|
+
capitalize: true,
|
|
488
|
+
});
|
|
489
|
+
commitUpdate(jtbdPath, "JTBD.md", original, updated, fix, result, fsSync);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Validate every `package.json .jobs` entry under products/, services/, and
|
|
494
|
+
* libraries/, and (when `fix` is true) regenerate the marker-delimited catalog,
|
|
495
|
+
* jobs, and description blocks in the corresponding README.md and JTBD.md.
|
|
496
|
+
*
|
|
497
|
+
* @param {{ root: string, fix?: boolean, runtime?: import('@forwardimpact/libutil/runtime').Runtime }} options
|
|
498
|
+
* @returns {Promise<{ findings: Finding[], stale: string[], fixed: string[] }>}
|
|
499
|
+
* `findings` are validation failures (structured for `emitFindingsText` /
|
|
500
|
+
* `emitFindingsJson` from libutil); `stale` is files whose generated blocks
|
|
501
|
+
* are out of date (only populated when `fix` is false); `fixed` is files
|
|
502
|
+
* that were rewritten in place.
|
|
503
|
+
*/
|
|
504
|
+
export async function checkJtbd({ root, fix = false, runtime }) {
|
|
505
|
+
if (!runtime) throw new Error("runtime is required");
|
|
506
|
+
const { fsSync } = runtime;
|
|
507
|
+
const result = { findings: [], stale: [], fixed: [] };
|
|
508
|
+
const prettierConfig = await prettier.resolveConfig(join(root, "JTBD.md"));
|
|
509
|
+
const formatMarkdown = makeFormatter(prettierConfig);
|
|
510
|
+
|
|
511
|
+
for (const catalog of catalogs(root)) {
|
|
512
|
+
await processCatalog(catalog, fix, formatMarkdown, result, fsSync);
|
|
513
|
+
}
|
|
514
|
+
await processJtbdMd(root, fix, formatMarkdown, result, fsSync);
|
|
515
|
+
|
|
516
|
+
return result;
|
|
517
|
+
}
|