Skip to content

Commit fa2ce9d

Browse files
committed
fix(rules): name the defect in a malformed file set
A `files` array containing an entry with no `path` or `content` normalized to `[]`, so `assessDelivery` reported `delivered no files` for a payload that delivered several. Whoever is debugging a real shape defect gets sent to look in the wrong place, on the path where the client writes server-supplied files to disk. Now three outcomes rather than two, and the error names the field: which index, and whether `path` or `content` was missing. Deliberately NOT the reviewer's first suggestion of returning `undefined` for a malformed set. That folds "malformed" into "absent", which sends the payload down the legacy `content` branch — where a rule carrying `files` has no `content` to serialize and the rule file is written from `undefined`. Trading a misleading message for a garbage file on disk is the wrong direction. Found by the review on #219.
1 parent 62dbd65 commit fa2ce9d

3 files changed

Lines changed: 88 additions & 17 deletions

File tree

‎packages/cli/src/rules/deliver.ts‎

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,57 @@ export interface DeliveredFile {
2121
content: string;
2222
}
2323

24-
/** Read a delivered rule's file set, or `undefined` for a legacy single-rule payload. */
25-
export function deliveredFiles(
26-
rule: GeneratedRule
27-
): DeliveredFile[] | undefined {
24+
/**
25+
* What a delivered rule says about a file set: nothing, something malformed,
26+
* or a usable one.
27+
*
28+
* THREE OUTCOMES, NOT TWO. Folding "malformed" into "absent" would send the
29+
* payload down the legacy `content` path, where a rule carrying `files` has no
30+
* `content` to serialize and the rule file is written from `undefined`.
31+
* Folding it into "an empty set" is what this used to do, and it reported
32+
* `delivered no files` for a payload that delivered several — sending whoever
33+
* is debugging a real shape defect to look in the wrong place.
34+
*/
35+
export type DeliveredFileSet =
36+
| { kind: "absent" }
37+
| { kind: "malformed"; reason: string }
38+
| { kind: "present"; files: DeliveredFile[] };
39+
40+
/** Read a delivered rule's file set. */
41+
export function deliveredFiles(rule: GeneratedRule): DeliveredFileSet {
2842
const candidate = (rule as { files?: unknown }).files;
29-
if (!Array.isArray(candidate)) return undefined;
30-
return candidate.every(
31-
(file): file is DeliveredFile =>
32-
typeof file === "object" &&
33-
file !== null &&
34-
typeof (file as DeliveredFile).path === "string" &&
35-
typeof (file as DeliveredFile).content === "string"
36-
)
37-
? candidate
38-
: [];
43+
if (candidate === undefined) return { kind: "absent" };
44+
if (!Array.isArray(candidate)) {
45+
return {
46+
kind: "malformed",
47+
reason: "carries a `files` that is not an array",
48+
};
49+
}
50+
const files: DeliveredFile[] = [];
51+
for (const [index, entry] of candidate.entries()) {
52+
if (typeof entry !== "object" || entry === null) {
53+
return {
54+
kind: "malformed",
55+
reason: `carries a \`files[${index}]\` that is not an object`,
56+
};
57+
}
58+
const file = entry as Partial<DeliveredFile>;
59+
// Named individually so the error points at the field, not the entry.
60+
if (typeof file.path !== "string") {
61+
return {
62+
kind: "malformed",
63+
reason: `carries a \`files[${index}]\` with no string \`path\``,
64+
};
65+
}
66+
if (typeof file.content !== "string") {
67+
return {
68+
kind: "malformed",
69+
reason: `carries \`${file.path}\` with no string \`content\``,
70+
};
71+
}
72+
files.push({ path: file.path, content: file.content });
73+
}
74+
return { kind: "present", files };
3975
}
4076

4177
/**

‎packages/cli/src/rules/files.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ export async function writeRuleFile(
3838
// A file set and a single `content` are mutually exclusive, per the contract.
3939
// Carrying both means the service is unsure what it sent, and picking one
4040
// would write a rule nobody described.
41-
const files = deliveredFiles(rule);
42-
if (files !== undefined) {
41+
const delivered = deliveredFiles(rule);
42+
if (delivered.kind === "malformed") {
43+
throw new Error(`Rule "${rule.id}" ${delivered.reason}.`);
44+
}
45+
if (delivered.kind === "present") {
4346
if (rule.content !== undefined) {
4447
throw new Error(
4548
`Rule "${rule.id}" carries both \`files\` and \`content\`; they are mutually exclusive.`
@@ -48,7 +51,7 @@ export async function writeRuleFile(
4851
// Assessed as a unit before anything is written. A half-written rule
4952
// directory verifies as a broken rule two steps from the cause, and the
5053
// delivery that produced it has already reported success.
51-
const assessment = assessDelivery(cwd, engine, rule.id, files);
54+
const assessment = assessDelivery(cwd, engine, rule.id, delivered.files);
5255
if (!assessment.ok) {
5356
throw new Error(`Rule "${rule.id}" ${assessment.reason}.`);
5457
}

‎packages/cli/test/deliver.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,38 @@ describe("delivering a rule as a file set", () => {
206206
).rejects.toThrow(/differing only in case/);
207207
});
208208

209+
// A malformed set used to normalize to `[]` and report "delivered no files"
210+
// for a payload that delivered several, sending anyone debugging a real
211+
// shape defect to look in the wrong place.
212+
it.each([
213+
["files is not an array", "nope", /files` that is not an array/],
214+
[
215+
"an entry is not an object",
216+
["check.ts"],
217+
/files\[0\]` that is not an object/,
218+
],
219+
[
220+
"an entry has no path",
221+
[{ content: "x\n" }],
222+
/files\[0\]` with no string `path`/,
223+
],
224+
[
225+
"an entry has no content",
226+
[{ path: "check.ts" }],
227+
/`check\.ts` with no string `content`/,
228+
],
229+
])("names the defect when %s", async (_label, files, expected) => {
230+
const rule = {
231+
id: "logs-abc12345",
232+
engine: "runtime",
233+
files,
234+
} as unknown as GeneratedRule;
235+
await expect(writeRuleFile(cwd, rule)).rejects.toThrow(expected);
236+
expect(existsSync(ruleDirectory(cwd, "runtime", "logs-abc12345"))).toBe(
237+
false
238+
);
239+
});
240+
209241
it("refuses a payload carrying both files and content", async () => {
210242
const rule = {
211243
id: "logs-abc12345",

0 commit comments

Comments
 (0)