@forwardimpact/libcoaligned 0.1.4 → 0.1.5
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/bin/coaligned.js +28 -15
- package/package.json +2 -1
- package/src/instructions.js +117 -43
- package/src/jtbd.js +142 -63
package/bin/coaligned.js
CHANGED
|
@@ -4,6 +4,7 @@ import "@forwardimpact/libpreflight/node22";
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { createCli } from "@forwardimpact/libcli";
|
|
7
|
+
import { emitFindingsJson, emitFindingsText } from "@forwardimpact/libutil";
|
|
7
8
|
import { checkInstructions, checkJtbd } from "../src/index.js";
|
|
8
9
|
|
|
9
10
|
const { version: VERSION } = JSON.parse(
|
|
@@ -40,37 +41,48 @@ const definition = {
|
|
|
40
41
|
globalOptions: {
|
|
41
42
|
help: { type: "boolean", short: "h", description: "Show this help" },
|
|
42
43
|
version: { type: "boolean", description: "Show version" },
|
|
43
|
-
json: { type: "boolean", description: "Output
|
|
44
|
+
json: { type: "boolean", description: "Output findings as JSON" },
|
|
44
45
|
},
|
|
45
46
|
examples: ["coaligned", "coaligned instructions", "coaligned jtbd --fix"],
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const cli = createCli(definition);
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
function writeFindings(findings, passMessage, jsonOutput, cwd) {
|
|
52
|
+
if (jsonOutput) {
|
|
53
|
+
process.stdout.write(emitFindingsJson(findings));
|
|
54
|
+
} else if (findings.length > 0) {
|
|
55
|
+
process.stderr.write(emitFindingsText(findings, { cwd, passMessage }));
|
|
56
|
+
} else {
|
|
57
|
+
process.stdout.write(emitFindingsText(findings, { cwd, passMessage }));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runInstructions(root, jsonOutput) {
|
|
62
|
+
const findings = await checkInstructions({ root });
|
|
63
|
+
writeFindings(findings, "coaligned instructions passed", jsonOutput, root);
|
|
64
|
+
return findings.length > 0 ? 1 : 0;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
async function runJtbd(root, fix) {
|
|
57
|
-
const {
|
|
58
|
-
|
|
67
|
+
async function runJtbd(root, fix, jsonOutput) {
|
|
68
|
+
const { findings, stale, fixed } = await checkJtbd({ root, fix });
|
|
69
|
+
writeFindings(findings, "coaligned jtbd passed", jsonOutput, root);
|
|
59
70
|
for (const f of fixed) process.stdout.write(`Regenerated ${f}.\n`);
|
|
60
|
-
|
|
71
|
+
if (stale.length > 0 && !jsonOutput) {
|
|
61
72
|
process.stderr.write(
|
|
62
|
-
|
|
73
|
+
`\n${stale.length} file${stale.length === 1 ? "" : "s"} out of date — run \`coaligned jtbd --fix\` to regenerate:\n`,
|
|
63
74
|
);
|
|
75
|
+
for (const s of stale) process.stderr.write(` - ${s}\n`);
|
|
64
76
|
}
|
|
65
|
-
return
|
|
77
|
+
return findings.length > 0 || stale.length > 0 ? 1 : 0;
|
|
66
78
|
}
|
|
67
79
|
|
|
68
80
|
async function instructionsHandler(ctx) {
|
|
69
|
-
return runInstructions(ctx.data.root);
|
|
81
|
+
return runInstructions(ctx.data.root, !!ctx.options.json);
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
async function jtbdHandler(ctx) {
|
|
73
|
-
return runJtbd(ctx.data.root, !!ctx.options.fix);
|
|
85
|
+
return runJtbd(ctx.data.root, !!ctx.options.fix, !!ctx.options.json);
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
async function main() {
|
|
@@ -78,12 +90,13 @@ async function main() {
|
|
|
78
90
|
if (!parsed) return 0;
|
|
79
91
|
|
|
80
92
|
const root = process.cwd();
|
|
93
|
+
const jsonOutput = !!parsed.values.json;
|
|
81
94
|
|
|
82
95
|
// No subcommand → run every check; --fix stays jtbd-only and must be opted
|
|
83
96
|
// into explicitly via `coaligned jtbd --fix`.
|
|
84
97
|
if (parsed.positionals.length === 0) {
|
|
85
|
-
const a = await runInstructions(root);
|
|
86
|
-
const b = await runJtbd(root, false);
|
|
98
|
+
const a = await runInstructions(root, jsonOutput);
|
|
99
|
+
const b = await runJtbd(root, false, jsonOutput);
|
|
87
100
|
return a || b;
|
|
88
101
|
}
|
|
89
102
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libcoaligned",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Co-Aligned architecture checks — enforce instruction-layer length caps and JTBD invariants across the repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"coaligned",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@forwardimpact/libcli": "^0.1.9",
|
|
49
49
|
"@forwardimpact/libpreflight": "^0.1.0",
|
|
50
|
+
"@forwardimpact/libutil": "*",
|
|
50
51
|
"prettier": "^3.0.0"
|
|
51
52
|
},
|
|
52
53
|
"engines": {
|
package/src/instructions.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
import { runRules } from "@forwardimpact/libutil";
|
|
3
4
|
|
|
4
5
|
const SKIP_DIRS = new Set([
|
|
5
6
|
".cache",
|
|
@@ -133,7 +134,7 @@ async function buildLayers(root) {
|
|
|
133
134
|
id: "L2",
|
|
134
135
|
name: "JTBD.md",
|
|
135
136
|
maxLines: 320,
|
|
136
|
-
//
|
|
137
|
+
// Larger than the L2 default to absorb a fifth persona block.
|
|
137
138
|
maxWords: 1664,
|
|
138
139
|
files: ["JTBD.md"],
|
|
139
140
|
},
|
|
@@ -162,70 +163,143 @@ async function buildLayers(root) {
|
|
|
162
163
|
};
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (text
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
);
|
|
166
|
+
function offsetToLine(text, offset) {
|
|
167
|
+
let line = 1;
|
|
168
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
169
|
+
if (text.charCodeAt(i) === 10) line++;
|
|
170
|
+
}
|
|
171
|
+
return line;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// -- Subject builders ----------------------------------------------------
|
|
175
|
+
|
|
176
|
+
async function buildFileSubjects(root, layers) {
|
|
177
|
+
const subjects = [];
|
|
178
|
+
for (const layer of layers) {
|
|
179
|
+
for (const relPath of layer.files) {
|
|
180
|
+
const text = await readText(root, relPath);
|
|
181
|
+
if (text == null) continue;
|
|
182
|
+
subjects.push({
|
|
183
|
+
path: resolve(root, relPath),
|
|
184
|
+
layer: { id: layer.id, name: layer.name },
|
|
185
|
+
lines: lineCount(text),
|
|
186
|
+
words: wordCount(text),
|
|
187
|
+
maxLines: layer.maxLines,
|
|
188
|
+
maxWords: layer.maxWords,
|
|
189
|
+
});
|
|
180
190
|
}
|
|
181
191
|
}
|
|
192
|
+
return subjects;
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
async function
|
|
185
|
-
|
|
186
|
-
|
|
195
|
+
async function buildChecklistSubjects(root, sources) {
|
|
196
|
+
const subjects = [];
|
|
197
|
+
for (const relPath of sources) {
|
|
198
|
+
const text = await readText(root, relPath);
|
|
187
199
|
if (text == null) continue;
|
|
200
|
+
const absPath = resolve(root, relPath);
|
|
201
|
+
CHECKLIST_RE.lastIndex = 0;
|
|
188
202
|
let m;
|
|
189
|
-
let
|
|
203
|
+
let blockIndex = 0;
|
|
190
204
|
while ((m = CHECKLIST_RE.exec(text))) {
|
|
191
|
-
|
|
192
|
-
const type = m[1];
|
|
205
|
+
blockIndex += 1;
|
|
193
206
|
const items = m[2].split(ITEM_SPLIT_RE).slice(1);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const w = wordCount(raw.trim());
|
|
201
|
-
if (w > L6_MAX_WORDS_PER_ITEM) {
|
|
202
|
-
errors.push(
|
|
203
|
-
`${path} checklist #${index} (${type}) item ${i + 1} has ${w} words (max ${L6_MAX_WORDS_PER_ITEM}, L6 checklist item)`,
|
|
204
|
-
);
|
|
205
|
-
}
|
|
207
|
+
subjects.push({
|
|
208
|
+
path: absPath,
|
|
209
|
+
lineNo: offsetToLine(text, m.index),
|
|
210
|
+
type: m[1],
|
|
211
|
+
blockIndex,
|
|
212
|
+
items: items.map((raw) => ({ words: wordCount(raw.trim()) })),
|
|
206
213
|
});
|
|
207
214
|
}
|
|
208
215
|
}
|
|
216
|
+
return subjects;
|
|
209
217
|
}
|
|
210
218
|
|
|
219
|
+
const HINT_LAYER_BUDGET =
|
|
220
|
+
"trim prose to fit the layer cap — see COALIGNED.md for the layered-instruction model";
|
|
221
|
+
|
|
222
|
+
// -- Rule catalogue ------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
export const INSTRUCTION_RULES = [
|
|
225
|
+
{
|
|
226
|
+
id: "instructions.line-budget",
|
|
227
|
+
scope: "instruction-file",
|
|
228
|
+
severity: "fail",
|
|
229
|
+
check: (s) =>
|
|
230
|
+
s.lines > s.maxLines ? { value: s.lines, max: s.maxLines } : null,
|
|
231
|
+
message: (s, r) => `${r.value} lines (max ${r.max}, ${s.layer.name})`,
|
|
232
|
+
hint: HINT_LAYER_BUDGET,
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
id: "instructions.word-budget",
|
|
236
|
+
scope: "instruction-file",
|
|
237
|
+
severity: "fail",
|
|
238
|
+
check: (s) =>
|
|
239
|
+
s.words > s.maxWords ? { value: s.words, max: s.maxWords } : null,
|
|
240
|
+
message: (s, r) => `${r.value} words (max ${r.max}, ${s.layer.name})`,
|
|
241
|
+
hint: HINT_LAYER_BUDGET,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "L6.too-many-items",
|
|
245
|
+
scope: "checklist-block",
|
|
246
|
+
severity: "fail",
|
|
247
|
+
check: (s) =>
|
|
248
|
+
s.items.length > L6_MAX_ITEMS
|
|
249
|
+
? { count: s.items.length, max: L6_MAX_ITEMS }
|
|
250
|
+
: null,
|
|
251
|
+
message: (s, r) =>
|
|
252
|
+
`checklist #${s.blockIndex} (${s.type}) has ${r.count} items (max ${r.max})`,
|
|
253
|
+
hint: "split the checklist into multiple sections, or remove items not load-bearing for the goal",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
id: "L6.item-too-many-words",
|
|
257
|
+
scope: "checklist-block",
|
|
258
|
+
severity: "fail",
|
|
259
|
+
check: (s) => {
|
|
260
|
+
const offenders = [];
|
|
261
|
+
s.items.forEach((item, i) => {
|
|
262
|
+
if (item.words > L6_MAX_WORDS_PER_ITEM) {
|
|
263
|
+
offenders.push({
|
|
264
|
+
itemIndex: i + 1,
|
|
265
|
+
words: item.words,
|
|
266
|
+
max: L6_MAX_WORDS_PER_ITEM,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
return offenders.length === 0 ? null : offenders;
|
|
271
|
+
},
|
|
272
|
+
message: (s, r) =>
|
|
273
|
+
`checklist #${s.blockIndex} (${s.type}) item ${r.itemIndex} has ${r.words} words (max ${r.max})`,
|
|
274
|
+
hint: "rewrite the item more concisely — checklist items are pointers, not explanations",
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
// -- Public entry --------------------------------------------------------
|
|
279
|
+
|
|
211
280
|
/**
|
|
212
281
|
* Walk the repo rooted at `root`, applying the L1–L6 caps from COALIGNED.md.
|
|
213
282
|
* Each layer is gated by a line cap AND a word cap; either breach fails.
|
|
214
283
|
*
|
|
215
284
|
* @param {{ root: string }} options
|
|
216
|
-
* @returns {Promise<
|
|
285
|
+
* @returns {Promise<Finding[]>} Structured findings; empty when conformant.
|
|
286
|
+
* Each Finding is `{ id, level, path, lineNo?, message, hint? }` for use
|
|
287
|
+
* with `emitFindingsText` / `emitFindingsJson` from libutil.
|
|
217
288
|
*/
|
|
218
289
|
export async function checkInstructions({ root }) {
|
|
219
|
-
const errors = [];
|
|
220
290
|
const { layers, skillDirs } = await buildLayers(root);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const checklistSources = [
|
|
291
|
+
const fileSubjects = await buildFileSubjects(root, layers);
|
|
292
|
+
const checklistSubjects = await buildChecklistSubjects(root, [
|
|
225
293
|
"CONTRIBUTING.md",
|
|
226
294
|
...skillDirs.map((d) => `${d}/SKILL.md`),
|
|
227
|
-
];
|
|
228
|
-
await checkChecklists(root, checklistSources, errors);
|
|
295
|
+
]);
|
|
229
296
|
|
|
230
|
-
|
|
297
|
+
const ctx = {
|
|
298
|
+
subjects: {
|
|
299
|
+
"instruction-file": fileSubjects,
|
|
300
|
+
"checklist-block": checklistSubjects,
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
|
|
304
|
+
return runRules(INSTRUCTION_RULES, ctx, { resolveScope });
|
|
231
305
|
}
|
package/src/jtbd.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import * as prettier from "prettier";
|
|
4
|
+
import { runRules } from "@forwardimpact/libutil";
|
|
4
5
|
|
|
5
6
|
const VALID_USERS = [
|
|
6
7
|
"Engineering Leaders",
|
|
@@ -55,65 +56,140 @@ function loadPackages(dir, filter) {
|
|
|
55
56
|
return out.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
if (!(entry.user && VALID_USERS.includes(entry.user))) {
|
|
61
|
-
errors.push(
|
|
62
|
-
`${prefix}: invalid user "${entry.user}". Must be one of: ${VALID_USERS.join(", ")}`,
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
for (const field of ["goal", "trigger", "competesWith"]) {
|
|
66
|
-
if (!entry[field] || typeof entry[field] !== "string") {
|
|
67
|
-
errors.push(`${prefix}: ${field} is required and must be a string`);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
for (const field of ["bigHire", "littleHire"]) {
|
|
71
|
-
if (!entry[field] || typeof entry[field] !== "string") {
|
|
72
|
-
errors.push(`${prefix}: ${field} is required and must be a string`);
|
|
73
|
-
} else if (!entry[field].endsWith(".")) {
|
|
74
|
-
errors.push(`${prefix}: ${field} must end with ".": "${entry[field]}"`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return errors;
|
|
59
|
+
function slot(s) {
|
|
60
|
+
return `.jobs[${s.index}]`;
|
|
78
61
|
}
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
63
|
+
export const JTBD_RULES = [
|
|
64
|
+
{
|
|
65
|
+
id: "jtbd.jobs-must-be-array",
|
|
66
|
+
scope: "package-jobs",
|
|
67
|
+
severity: "fail",
|
|
68
|
+
check: (s) => (Array.isArray(s.jobs) ? null : {}),
|
|
69
|
+
message: () => ".jobs must be an array",
|
|
70
|
+
hint: "wrap the value in [] — even a single job is an array of one",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "jtbd.invalid-user",
|
|
74
|
+
scope: "jtbd-entry",
|
|
75
|
+
severity: "fail",
|
|
76
|
+
check: (s) =>
|
|
77
|
+
s.entry.user && VALID_USERS.includes(s.entry.user)
|
|
78
|
+
? null
|
|
79
|
+
: { user: s.entry.user },
|
|
80
|
+
message: (s, r) => `${slot(s)}: invalid user "${r.user}"`,
|
|
81
|
+
hint: `must be one of: ${VALID_USERS.join(", ")}`,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "jtbd.missing-field",
|
|
85
|
+
scope: "jtbd-entry",
|
|
86
|
+
severity: "fail",
|
|
87
|
+
check: (s) => {
|
|
88
|
+
const offenders = [];
|
|
89
|
+
for (const field of [
|
|
90
|
+
"goal",
|
|
91
|
+
"trigger",
|
|
92
|
+
"competesWith",
|
|
93
|
+
"bigHire",
|
|
94
|
+
"littleHire",
|
|
95
|
+
]) {
|
|
96
|
+
if (!s.entry[field] || typeof s.entry[field] !== "string") {
|
|
97
|
+
offenders.push({ field });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return offenders.length === 0 ? null : offenders;
|
|
101
|
+
},
|
|
102
|
+
message: (s, r) =>
|
|
103
|
+
`${slot(s)}: ${r.field} is required and must be a string`,
|
|
104
|
+
hint: "every job entry needs goal, trigger, competesWith, bigHire, and littleHire (hires end with a period)",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "jtbd.hire-missing-period",
|
|
108
|
+
scope: "jtbd-entry",
|
|
109
|
+
severity: "fail",
|
|
110
|
+
check: (s) => {
|
|
111
|
+
const offenders = [];
|
|
112
|
+
for (const field of ["bigHire", "littleHire"]) {
|
|
113
|
+
const v = s.entry[field];
|
|
114
|
+
if (typeof v === "string" && v.length > 0 && !v.endsWith(".")) {
|
|
115
|
+
offenders.push({ field, value: v });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return offenders.length === 0 ? null : offenders;
|
|
119
|
+
},
|
|
120
|
+
message: (s, r) =>
|
|
121
|
+
`${slot(s)}: ${r.field} must end with "." — "${r.value}"`,
|
|
122
|
+
hint: "append a period to the hire sentence",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
// Cross-entry uniqueness — mutates ctx.allHires across iterations.
|
|
126
|
+
id: "jtbd.duplicate-hire",
|
|
127
|
+
scope: "jtbd-entry",
|
|
128
|
+
severity: "fail",
|
|
129
|
+
when: (s) => !s.skipUniqueHires,
|
|
130
|
+
check: (s, ctx) => {
|
|
131
|
+
const offenders = [];
|
|
132
|
+
for (const field of ["bigHire", "littleHire"]) {
|
|
133
|
+
const v = s.entry[field];
|
|
134
|
+
if (typeof v !== "string" || !v) continue;
|
|
135
|
+
const key = `${field}:${v.toLowerCase()}`;
|
|
136
|
+
const prior = ctx.allHires.get(key);
|
|
137
|
+
if (prior && prior.goal !== s.entry.goal) {
|
|
138
|
+
offenders.push({ field, value: v, otherLoc: prior.loc });
|
|
139
|
+
}
|
|
140
|
+
ctx.allHires.set(key, { loc: s.loc, goal: s.entry.goal });
|
|
141
|
+
}
|
|
142
|
+
return offenders.length === 0 ? null : offenders;
|
|
143
|
+
},
|
|
144
|
+
message: (s, r) =>
|
|
145
|
+
`${slot(s)}: duplicate ${r.field} "${r.value}" (also in ${r.otherLoc})`,
|
|
146
|
+
hint: "merge the duplicate job into a single entry, or differentiate the hire text",
|
|
147
|
+
},
|
|
148
|
+
];
|
|
94
149
|
|
|
95
|
-
function
|
|
96
|
-
const
|
|
97
|
-
const
|
|
150
|
+
function buildSubjects(packages, catalogDir, catalogName, skipUniqueHires) {
|
|
151
|
+
const packageSubjects = [];
|
|
152
|
+
const entrySubjects = [];
|
|
98
153
|
for (const { dir, pkg } of packages) {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
}
|
|
154
|
+
const pkgPath = join(catalogDir, dir, "package.json");
|
|
155
|
+
if (pkg.jobs == null) continue;
|
|
156
|
+
packageSubjects.push({ path: pkgPath, jobs: pkg.jobs });
|
|
157
|
+
if (!Array.isArray(pkg.jobs)) continue;
|
|
158
|
+
const loc = `${catalogName}/${dir}`;
|
|
159
|
+
pkg.jobs.forEach((entry, i) => {
|
|
160
|
+
entrySubjects.push({
|
|
161
|
+
path: pkgPath,
|
|
162
|
+
index: i,
|
|
163
|
+
entry,
|
|
164
|
+
loc,
|
|
165
|
+
skipUniqueHires,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
115
168
|
}
|
|
116
|
-
return
|
|
169
|
+
return { packageSubjects, entrySubjects };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function validate(
|
|
173
|
+
packages,
|
|
174
|
+
catalogDir,
|
|
175
|
+
catalogName,
|
|
176
|
+
{ skipUniqueHires = false } = {},
|
|
177
|
+
) {
|
|
178
|
+
const { packageSubjects, entrySubjects } = buildSubjects(
|
|
179
|
+
packages,
|
|
180
|
+
catalogDir,
|
|
181
|
+
catalogName,
|
|
182
|
+
skipUniqueHires,
|
|
183
|
+
);
|
|
184
|
+
const ctx = {
|
|
185
|
+
allHires: new Map(),
|
|
186
|
+
subjects: {
|
|
187
|
+
"package-jobs": packageSubjects,
|
|
188
|
+
"jtbd-entry": entrySubjects,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const resolveScope = (scopeKey) => ctx.subjects[scopeKey] ?? [];
|
|
192
|
+
return runRules(JTBD_RULES, ctx, { resolveScope });
|
|
117
193
|
}
|
|
118
194
|
|
|
119
195
|
function renderTable(headers, rows) {
|
|
@@ -340,11 +416,11 @@ function commitUpdate(filePath, label, original, updated, fix, result) {
|
|
|
340
416
|
|
|
341
417
|
async function processCatalog(catalog, fix, formatMarkdown, result) {
|
|
342
418
|
const packages = loadPackages(catalog.dir, catalog.filter);
|
|
343
|
-
const
|
|
419
|
+
const findings = validate(packages, catalog.dir, catalog.name, {
|
|
344
420
|
skipUniqueHires: catalog.skipUniqueHires ?? false,
|
|
345
421
|
});
|
|
346
|
-
if (
|
|
347
|
-
result.
|
|
422
|
+
if (findings.length > 0) {
|
|
423
|
+
result.findings.push(...findings);
|
|
348
424
|
return;
|
|
349
425
|
}
|
|
350
426
|
|
|
@@ -394,9 +470,11 @@ async function processJtbdMd(root, fix, formatMarkdown, result) {
|
|
|
394
470
|
if (!existsSync(jtbdPath)) return;
|
|
395
471
|
const productsCatalog = catalogs(root).find((c) => c.name === "products");
|
|
396
472
|
const packages = loadPackages(productsCatalog.dir, productsCatalog.filter);
|
|
397
|
-
const
|
|
398
|
-
|
|
399
|
-
|
|
473
|
+
const findings = validate(packages, productsCatalog.dir, "products", {
|
|
474
|
+
skipUniqueHires: true,
|
|
475
|
+
});
|
|
476
|
+
if (findings.length > 0) {
|
|
477
|
+
if (result.findings.length === 0) result.findings.push(...findings);
|
|
400
478
|
return;
|
|
401
479
|
}
|
|
402
480
|
const original = readFileSync(jtbdPath, "utf8");
|
|
@@ -412,13 +490,14 @@ async function processJtbdMd(root, fix, formatMarkdown, result) {
|
|
|
412
490
|
* jobs, and description blocks in the corresponding README.md and JTBD.md.
|
|
413
491
|
*
|
|
414
492
|
* @param {{ root: string, fix?: boolean }} options
|
|
415
|
-
* @returns {Promise<{
|
|
416
|
-
* `
|
|
493
|
+
* @returns {Promise<{ findings: Finding[], stale: string[], fixed: string[] }>}
|
|
494
|
+
* `findings` are validation failures (structured for `emitFindingsText` /
|
|
495
|
+
* `emitFindingsJson` from libutil); `stale` is files whose generated blocks
|
|
417
496
|
* are out of date (only populated when `fix` is false); `fixed` is files
|
|
418
497
|
* that were rewritten in place.
|
|
419
498
|
*/
|
|
420
499
|
export async function checkJtbd({ root, fix = false }) {
|
|
421
|
-
const result = {
|
|
500
|
+
const result = { findings: [], stale: [], fixed: [] };
|
|
422
501
|
const prettierConfig = await prettier.resolveConfig(join(root, "JTBD.md"));
|
|
423
502
|
const formatMarkdown = makeFormatter(prettierConfig);
|
|
424
503
|
|