Skip to content

Commit 11fa6ec

Browse files
committed
fix(engine): apply the JSON→YAML fallback in config-lint's canonical parser too
config-lint had two independent top-level-object parsers for the same manifest text that disagreed on JSON/YAML fallback. `parseTopLevelObject` (used by `unknownTopLevelWarnings`) retried with `parseYaml` when `JSON.parse` threw on a `{`/`[`-prefixed text — YAML flow mappings can start that way while still being valid manifest syntax. `parseCanonicalTopLevelObject` (used by `recognizedFieldsFor`) did not: it caught the `JSON.parse` failure and returned null → [], so `buildConfigLintReport` silently reported zero recognized fields for a valid YAML-flow-mapping manifest that `unknownTopLevelWarnings` happily parsed and warned about — the two functions produced inconsistent results for the same input in the same report call. De-duplicate the two into one shared `parseManifestTopLevelObject` used by both callers, so recognizedFieldsFor and unknownTopLevelWarnings can never drift on whether a given manifest text parses. Add a regression test asserting a valid YAML flow mapping starting with `{` now yields non-empty recognized fields consistent with the unknown-field warning for the same text. Closes #7244
1 parent 2372739 commit 11fa6ec

2 files changed

Lines changed: 29 additions & 21 deletions

File tree

packages/loopover-engine/src/config-lint.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export function lintManifestText(text: string | null | undefined): SelfHostConfi
6363
}
6464

6565
function recognizedFieldsFor(text: string | null | undefined): string[] {
66-
const parsed = parseCanonicalTopLevelObject(text);
66+
const parsed = parseManifestTopLevelObject(text);
6767
if (parsed === null) return [];
6868
return TOP_LEVEL_FIELDS.filter(
6969
(field) => field !== "source" && Object.prototype.hasOwnProperty.call(parsed, field),
@@ -77,10 +77,7 @@ const RETIRED_FIELD_MIGRATION_WARNINGS: Record<string, string> = {
7777
};
7878

7979
export function unknownTopLevelWarnings(text: string | null | undefined): string[] {
80-
const raw = text ?? "";
81-
const trimmed = raw.trim();
82-
if (!trimmed || isOversize(raw)) return [];
83-
const parsed = parseTopLevelObject(trimmed);
80+
const parsed = parseManifestTopLevelObject(text);
8481
if (parsed === null) return [];
8582
const keys = Object.keys(parsed).filter((key) => !TOP_LEVEL_FIELD_SET.has(key));
8683
// `hasOwnProperty.call`, NOT `key in`: a manifest field named like an Object.prototype member
@@ -96,30 +93,24 @@ export function unknownTopLevelWarnings(text: string | null | undefined): string
9693
];
9794
}
9895

99-
function parseCanonicalTopLevelObject(text: string | null | undefined): Record<string, unknown> | null {
96+
// Single top-level-object parser shared by both `recognizedFieldsFor` and `unknownTopLevelWarnings` so the two
97+
// can never disagree on whether a given manifest text parses. When the text looks like JSON (`{`/`[`) but
98+
// `JSON.parse` throws, it retries with `parseYaml`: YAML flow mappings can start with "{" or "[" (e.g. unquoted
99+
// keys) while still being valid manifest syntax, so a strict-JSON failure alone must not be treated as unparseable.
100+
function parseManifestTopLevelObject(text: string | null | undefined): Record<string, unknown> | null {
100101
const raw = text ?? "";
101102
const trimmed = raw.trim();
102103
if (!trimmed || isOversize(raw)) return null;
103104
const looksLikeJson = trimmed.startsWith("{") || trimmed.startsWith("[");
104-
try {
105-
return topLevelObjectOrNull(looksLikeJson ? JSON.parse(trimmed) : parseYaml(trimmed));
106-
} catch {
107-
return null;
108-
}
109-
}
110-
111-
function parseTopLevelObject(text: string): Record<string, unknown> | null {
112-
const looksLikeJson = text.startsWith("{") || text.startsWith("[");
113105
if (looksLikeJson) {
114106
try {
115-
const parsed = JSON.parse(text);
116-
return topLevelObjectOrNull(parsed);
107+
return topLevelObjectOrNull(JSON.parse(trimmed));
117108
} catch {
118-
// YAML flow mappings can start with "{" or "[" while still being valid manifest syntax.
109+
// Fall through to YAML: a `{`/`[` prefix can be a valid YAML flow mapping that is invalid strict JSON.
119110
}
120111
}
121112
try {
122-
return topLevelObjectOrNull(parseYaml(text));
113+
return topLevelObjectOrNull(parseYaml(trimmed));
123114
} catch {
124115
return null;
125116
}

test/unit/selfhost-config-lint.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { lintManifestText } from "../../src/selfhost/config-lint";
2+
import { lintManifestText, unknownTopLevelWarnings } from "../../src/selfhost/config-lint";
33
import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest";
44

55
describe("lintManifestText (#2079)", () => {
@@ -271,14 +271,31 @@ unknownSecretKey: super-secret-value
271271
const result = lintManifestText("{wantedPaths: [src/], unknownSecretKey: secret}");
272272

273273
expect(result.ok).toBe(false);
274-
expect(result.recognizedFields).toEqual([]);
274+
// recognizedFieldsFor now applies the same JSON->YAML fallback as unknownTopLevelWarnings, so a flow-mapping
275+
// manifest that is valid YAML but invalid strict JSON reports its real recognized fields instead of [].
276+
expect(result.recognizedFields).toEqual(["wantedPaths"]);
275277
expect(result.warnings).toEqual([
276278
"Manifest content was not valid JSON; ignoring it and falling back to deterministic signals.",
277279
"Manifest contains unknown top-level field: unknownSecretKey.",
278280
]);
279281
expect(JSON.stringify(result)).not.toContain("secret");
280282
});
281283

284+
it("REGRESSION (#7244): recognizes fields in a YAML flow-mapping manifest starting with '{' consistently with unknown-field detection", () => {
285+
// `{settings: ..., wantedPaths: ..., mysteryKey: ...}` is a valid YAML flow mapping but invalid strict JSON
286+
// (unquoted keys). unknownTopLevelWarnings already fell back to YAML and saw the real fields; recognizedFieldsFor
287+
// did NOT (it caught the JSON.parse failure and returned null -> []), so buildConfigLintReport silently reported
288+
// zero recognized fields for a fully-parseable, valid manifest. Both must now agree the manifest parses.
289+
const text = "{settings: {commentMode: all_prs}, wantedPaths: [src/], mysteryKey: hidden}";
290+
const result = lintManifestText(text);
291+
292+
// recognizedFieldsFor (surfaced via result.recognizedFields) sees the real fields instead of silently empty.
293+
expect(result.recognizedFields).toEqual(["wantedPaths", "settings"]);
294+
// ...consistent with unknownTopLevelWarnings, which flags the unknown key on the SAME parsed object.
295+
expect(unknownTopLevelWarnings(text)).toEqual(["Manifest contains unknown top-level field: mysteryKey."]);
296+
expect(JSON.stringify(result)).not.toContain("hidden");
297+
});
298+
282299
it("keeps known JSON manifests quiet and non-object JSON invalid", () => {
283300
expect(lintManifestText(JSON.stringify({ gate: { enabled: true, checkMode: "required" } }))).toMatchObject({
284301
ok: true,

0 commit comments

Comments
 (0)