Files
hyperframes/packages/cli/src/utils/projectConfig.test.ts
T
WaterrrForever d287e5244c fix(cli): persist authoring skill in hyperframes.json for durable render attribution (#2762)
* fix(cli): persist authoring skill in hyperframes.json for durable render attribution

authoring_skill was stamped only on the first render through a workflow
passing --skill, so re-renders, `npm run render`, --batch, existing-project
renders, and general-video lost it — leaving 77-96% of real-human render
volume un-attributed and the skills-penetration metric misleadingly low.

Persist the owning skill in hyperframes.json: `init --skill` stamps it at
creation, `render` resolves the flag then falls back to the stored value, and
an explicit --skill seeds it (seed-once, never overwriting the creating
workflow's identity). Activate all render-producing creation workflows to
declare their skill at init.

Forward-only: does not rewrite historical telemetry.

* fix(cli): patch hyperframes.json in place when seeding the authoring skill

seedProjectAuthoringSkill is the only writer that touches an already existing
hyperframes.json — every other writeProjectConfig call site is guarded to write
only when the file is absent, which made the whole-file overwrite safe by
construction. Round-tripping the seed through normalizeConfig broke that: it
rebuilds the object from a field whitelist with no rest-spread, so any key
outside the schema was silently dropped, a media block was materialized in
projects that never had one, and key order was rewritten. hyperframes.json is
normally committed, so a render introduced a diff the user never asked for, and
any field added to the schema later would be deleted by a render on an older
CLI.

Parse the raw JSON, set authoringSkill, write it back, reusing the file's own
indentation. Unknown keys and formatting survive; the only delta is the key
being added. A corrupt config is now left untouched instead of clobbered.

Seed-once semantics are unchanged, still normalized so a hand-edited garbage
slug neither reaches telemetry nor wedges the seed.

Reported independently by both reviewers on #2762.

* fix(cli): create the docker build context with mkdtempSync

The `--docker` build context was created at a guessable path derived from
`Date.now()` in the world-writable OS temp dir. Another local user can
pre-create or symlink that path and have the build read a Dockerfile they
control. mkdtempSync gets a random suffix and 0o700 from the kernel, and it
creates the directory itself, so the separate mkdirSync goes away.

Pre-existing on main (alert #432, 2026-06-04, packages/cli/src/commands/render.ts),
surfaced against this branch only because the seed commit shifted line numbers in
the same file. Fixed here to unblock the CodeQL gate on #2762 rather than left for
a follow-up; the remaining 10 js/insecure-temporary-file alerts elsewhere in the
repo are untouched and still want their own pass.

* fix(cli): drop the check-then-use race when seeding the authoring skill

The seed tested for the config with existsSync and then wrote, which is a
check-then-use race: the file can be created or swapped between the check and
the write (CodeQL js/file-system-race).

Read once and branch on the failure reason instead. Only ENOENT creates a
config from scratch; any other read failure (permissions, I/O) now leaves an
existing file alone rather than overwriting it with a default, so this is also
strictly safer than the version it replaces.

Also replaces the `as Record<string, unknown>` assertion with an isJsonObject
type guard, per the repo's no-assertion convention.

Behaviour unchanged: all 4 seed regression tests still pass, and the
create/preserve/seed-once/corrupt-untouched paths were re-verified end to end.
2026-07-28 19:27:09 +08:00

368 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
normalizeConfig,
projectConfigPath,
readProjectConfig,
resolveAutoProxy,
seedProjectAuthoringSkill,
writeProjectConfig,
PROJECT_CONFIG_FILENAME,
} from "./projectConfig.js";
function tmp(): string {
return mkdtempSync(join(tmpdir(), "hf-cfg-test-"));
}
describe("projectConfig", () => {
describe("write + read round-trip", () => {
it("writes the default config and reads it back", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
const read = readProjectConfig(dir);
expect(read).toEqual(DEFAULT_PROJECT_CONFIG);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("writes a custom config and reads it back verbatim", () => {
const dir = tmp();
try {
const custom = {
$schema: DEFAULT_PROJECT_CONFIG.$schema,
registry: "https://example.com/my-registry",
paths: { blocks: "src/blocks", components: "src/fx", assets: "media" },
media: { autoProxy: true },
};
writeProjectConfig(dir, custom);
const read = readProjectConfig(dir);
expect(read).toEqual(custom);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("normalizeConfig", () => {
it("fills in defaults for missing fields", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
expect(result.registry).toBe("https://alt.example.com");
expect(result.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
expect(result.$schema).toBe(DEFAULT_PROJECT_CONFIG.$schema);
});
it("preserves partial paths objects", () => {
const result = normalizeConfig({ paths: { blocks: "x" } as unknown as never });
expect(result.paths.blocks).toBe("x");
expect(result.paths.components).toBe(DEFAULT_PROJECT_CONFIG.paths.components);
expect(result.paths.assets).toBe(DEFAULT_PROJECT_CONFIG.paths.assets);
});
it("defaults media.autoProxy to true when media is absent", () => {
const result = normalizeConfig({ registry: "https://alt.example.com" });
expect(result.media).toEqual({ autoProxy: true });
});
it("preserves an explicit media.autoProxy: false", () => {
const result = normalizeConfig({ media: { autoProxy: false } });
expect(result.media).toEqual({ autoProxy: false });
});
it("falls back to the default when media.autoProxy is malformed", () => {
const result = normalizeConfig({
media: { autoProxy: "nope" } as unknown as never,
});
expect(result.media).toEqual({ autoProxy: true });
});
it("falls back to the default when media itself is malformed", () => {
const result = normalizeConfig({ media: "nope" as unknown as never });
expect(result.media).toEqual({ autoProxy: true });
});
it("preserves a valid authoringSkill slug", () => {
const result = normalizeConfig({ authoringSkill: "product-launch-video" });
expect(result.authoringSkill).toBe("product-launch-video");
});
it("drops an invalid authoringSkill (never reaches telemetry)", () => {
const result = normalizeConfig({
authoringSkill: "Not A Slug!" as unknown as never,
});
expect(result.authoringSkill).toBeUndefined();
});
});
describe("readProjectConfig", () => {
it("returns undefined when the file is absent", () => {
const dir = tmp();
try {
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns undefined when the file is corrupt", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("normalizes a partial on-disk config", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://only-this.example.com" }),
"utf-8",
);
const read = readProjectConfig(dir);
expect(read?.registry).toBe("https://only-this.example.com");
expect(read?.paths).toEqual(DEFAULT_PROJECT_CONFIG.paths);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("loadProjectConfig", () => {
it("returns defaults when no config file exists", () => {
const dir = tmp();
try {
expect(loadProjectConfig(dir)).toEqual(DEFAULT_PROJECT_CONFIG);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("writeProjectConfig", () => {
it("writes to hyperframes.json at the project root", () => {
const dir = tmp();
try {
writeProjectConfig(dir);
const path = join(dir, PROJECT_CONFIG_FILENAME);
const parsed = JSON.parse(readFileSync(path, "utf-8"));
expect(parsed.registry).toBe(DEFAULT_PROJECT_CONFIG.registry);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("resolveAutoProxy", () => {
it("defaults to true when no config file exists and no flag is passed", () => {
const dir = tmp();
try {
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns false when the config sets media.autoProxy: false", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: false } }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("defaults to true when the config file is partial and omits media", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://only-this.example.com" }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("an explicit false flag wins over a config that enables it", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: true } }),
"utf-8",
);
expect(resolveAutoProxy(dir, false)).toBe(false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("an explicit true flag wins over a config that disables it", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: false } }),
"utf-8",
);
expect(resolveAutoProxy(dir, true)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("falls back to the default when the on-disk media value is malformed", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ media: { autoProxy: "nope" } }),
"utf-8",
);
expect(resolveAutoProxy(dir, undefined)).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("seedProjectAuthoringSkill", () => {
it("stamps the owning skill into a fresh project (creates the config)", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "faceless-explainer");
expect(loadProjectConfig(dir).authoringSkill).toBe("faceless-explainer");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("preserves other config fields when stamping an existing config", () => {
const dir = tmp();
try {
writeProjectConfig(dir, {
...DEFAULT_PROJECT_CONFIG,
registry: "https://custom.example.com",
});
seedProjectAuthoringSkill(dir, "pr-to-video");
const read = readProjectConfig(dir);
expect(read?.authoringSkill).toBe("pr-to-video");
expect(read?.registry).toBe("https://custom.example.com");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("is seed-once: a later --skill never overwrites the project owner", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "product-launch-video");
seedProjectAuthoringSkill(dir, "motion-graphics");
expect(loadProjectConfig(dir).authoringSkill).toBe("product-launch-video");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("ignores an invalid slug and writes nothing", () => {
const dir = tmp();
try {
seedProjectAuthoringSkill(dir, "Not A Slug!");
expect(readProjectConfig(dir)).toBeUndefined();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// The seed is the only writer that touches an existing hyperframes.json,
// which is normally committed — a render must not diff it beyond the one
// key being added. Guards against round-tripping through normalizeConfig.
it("preserves config keys outside the known schema", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify(
{
registry: "https://example.com/my-registry",
myTeamSetting: { reviewer: "wenbo", keep: true },
futureSchemaKey: 42,
},
null,
2,
),
"utf-8",
);
seedProjectAuthoringSkill(dir, "product-launch-video");
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
expect(raw.authoringSkill).toBe("product-launch-video");
expect(raw.myTeamSetting).toEqual({ reviewer: "wenbo", keep: true });
expect(raw.futureSchemaKey).toBe(42);
expect(raw.registry).toBe("https://example.com/my-registry");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("does not materialize a media block the user never wrote", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r" }, null, 2),
"utf-8",
);
seedProjectAuthoringSkill(dir, "motion-graphics");
const raw = JSON.parse(readFileSync(projectConfigPath(dir), "utf-8"));
expect(raw.media).toBeUndefined();
expect(raw.$schema).toBeUndefined();
expect(Object.keys(raw)).toEqual(["registry", "authoringSkill"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reuses the file's own indentation", () => {
const dir = tmp();
try {
writeFileSync(
projectConfigPath(dir),
JSON.stringify({ registry: "https://example.com/r" }, null, 4),
"utf-8",
);
seedProjectAuthoringSkill(dir, "pr-to-video");
expect(readFileSync(projectConfigPath(dir), "utf-8")).toContain('\n "registry"');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("leaves a corrupt config untouched rather than clobbering it", () => {
const dir = tmp();
try {
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
seedProjectAuthoringSkill(dir, "faceless-explainer");
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});