Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@
"lint:cwd-relative-test-reads": "deno run --allow-read scripts/lint/audit-cwd-relative-test-reads.ts",
"lint:anti-slop": "deno run --allow-read scripts/lint/audit-anti-slop.ts",
"lint:dnt-meta-properties": "deno run --config=scripts/test.deno.json --frozen --allow-read scripts/build/dnt-meta-property-safety.ts",
"test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/ci/prepare-rc-build.test.ts scripts/ci/publish-npm-packages.test.ts scripts/ci/setup-deno-workflow.test.ts scripts/build/compile-binary.test.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/generated-artifact-checks.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/build/npm-runtime-helper-contract.test.ts scripts/build/prepare-framework-sources.test.ts scripts/build/report-artifact-sizes.test.ts scripts/build/run-generate.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-chat-composability.test.ts scripts/lint/audit-rfc-status.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-cwd-relative-test-reads.test.ts scripts/lint/audit-cross-runtime-jsr.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/lint-config.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/audit-anti-slop.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/secret-scanning-config.test.ts scripts/security/submit-dependency-snapshot.test.ts scripts/test/template-runtime-e2e.test.ts && deno task test:tool-search-live",
"test:scripts": "deno test --config=scripts/test.deno.json --no-check --allow-read --allow-write --allow-run scripts/build/feature-files-detection.test.ts scripts/ci/prepare-rc-build.test.ts scripts/ci/publish-npm-packages.test.ts scripts/ci/setup-deno-workflow.test.ts scripts/build/compile-binary.test.ts scripts/build/dnt-meta-property-safety.test.ts scripts/build/dnt-polyfill.test.ts scripts/build/generate-sbom.test.ts scripts/build/generated-artifact-checks.test.ts scripts/build/npm-dependency-sources.test.ts scripts/build/npm-extension-package-metadata.test.ts scripts/build/npm-package-metadata.test.ts scripts/build/npm-react-shims.test.ts scripts/build/npm-runtime-helper-contract.test.ts scripts/build/prepare-framework-sources.test.ts scripts/build/report-artifact-sizes.test.ts scripts/build/run-generate.test.ts scripts/docs/docs-coverage.test.ts scripts/docs/generate-api-reference.test.ts scripts/docs/guide-validation.test.ts scripts/lint/audit-chat-composability.test.ts scripts/lint/audit-rfc-status.test.ts scripts/lint/audit-core-deps.test.ts scripts/lint/audit-cwd-relative-test-reads.test.ts scripts/lint/audit-cross-runtime-jsr.test.ts scripts/lint/audit-dependency-boundaries.test.ts scripts/lint/audit-extension-capabilities.test.ts scripts/lint/audit-extension-contracts.test.ts scripts/lint/audit-deps.test.ts scripts/lint/check-module-boundaries.test.ts scripts/lint/lint-config.test.ts scripts/lint/ban-test-only.test.ts scripts/lint/check-sanitizer-baseline.test.ts scripts/lint/check-skipped-tests-baseline.test.ts scripts/lint/audit-anti-slop.test.ts scripts/lint/check-test-typecheck-baseline.test.ts scripts/lint/check-coverage.test.ts scripts/security/audit-npm.test.ts scripts/security/secret-scanning-config.test.ts scripts/security/submit-dependency-snapshot.test.ts scripts/test/template-runtime-e2e.test.ts && deno task test:tool-search-live",
"test:sentry-runtime-packages": "deno test --config=scripts/test.deno.json --no-check --no-lock --allow-read --allow-write --allow-run --allow-env=DENO_DIR,HOME,XDG_CACHE_HOME,LOCALAPPDATA,USERPROFILE scripts/build/sentry-runtime-packages.test.ts",
"test:tool-search-live": "VF_DISABLE_LRU_INTERVAL=1 deno test --no-check -A tests/agent/verify-tool-search-live.test.ts",
"test:cross-runtime": "deno run --allow-all src/platform/compat/cross-runtime.test.ts",
Expand Down
126 changes: 126 additions & 0 deletions scripts/build/feature-files-detection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { assertEquals } from "#veryfront/testing/assert.ts";
import { describe, it } from "#veryfront/testing/bdd.ts";
import { findFeaturesWithIgnoredFiles } from "./generate-templates-manifest.ts";
Comment thread
kojiwakayama marked this conversation as resolved.

/**
* #3786 asked whether `templates/features/mdx/files/` is dead weight or a
* loader bug. It is neither, exactly: the mechanism was never wired. Every
* feature ships a `files/` tree, `FeatureConfig` has no `files` field, and the
* loader never reads the directory, so none of it has ever been scaffolded.
*
* These pin the detector that now says so on every generator run. They do not
* assert the specific features or counts against the real tree, because that
* would fail the moment someone resolves one of them, which is the point.
*/
describe("scripts/build feature files detection", () => {
it("reports a feature whose files/ the manifest does not carry", async () => {
const root = await Deno.makeTempDir({ prefix: "vf-feature-detect-" });
try {
await Deno.mkdir(`${root}/example/files/app`, { recursive: true });
await Deno.writeTextFile(`${root}/example/files/app/page.mdx`, "# hi\n");
await Deno.writeTextFile(`${root}/example/files/app/other.mdx`, "# hi\n");
assertEquals(await findFeaturesWithIgnoredFiles(root), [
{ feature: "example", fileCount: 2 },
]);
} finally {
await Deno.remove(root, { recursive: true });
}
});

it("says nothing about a feature with no files/ directory", async () => {
const root = await Deno.makeTempDir({ prefix: "vf-feature-detect-" });
try {
await Deno.mkdir(`${root}/config-only`, { recursive: true });
await Deno.writeTextFile(`${root}/config-only/feature.json`, "{}\n");
assertEquals(await findFeaturesWithIgnoredFiles(root), []);
} finally {
await Deno.remove(root, { recursive: true });
}
});

it("says nothing about an empty files/ directory", async () => {
const root = await Deno.makeTempDir({ prefix: "vf-feature-detect-" });
try {
await Deno.mkdir(`${root}/empty/files`, { recursive: true });
assertEquals(await findFeaturesWithIgnoredFiles(root), []);
} finally {
await Deno.remove(root, { recursive: true });
}
});

it("returns nothing rather than throwing when the directory is absent", async () => {
assertEquals(
await findFeaturesWithIgnoredFiles("/nonexistent-features-dir"),
[],
);
});

it("detects the condition in the real tree today", async () => {
// Deliberately asserts only that the condition EXISTS, not which features.
// When someone resolves #3786 this flips to an empty array and the test
// fails loudly, which is the reminder to delete it.
const ignored = await findFeaturesWithIgnoredFiles();
assertEquals(
ignored.length > 0,
true,
"expected at least one feature with an ignored files/ directory",
);
});

it("importing the generator does not rewrite the tracked artifacts", async () => {
// Raised in review: the generator ran at module scope, so importing it to
// reach this detector regenerated and REWROTE templates/manifest.json and
// manifest.generated.ts. The gzip representation varies with the installed
// Deno version, so a focused test run could leave a spurious diff in a
// tracked file. `import.meta.main` now guards the entry point.
//
// Runs in a CHILD process on purpose. Importing in-process is a no-op after
// the first import in this file, so an in-process check passes against the
// unguarded version too. It proves nothing.
const artifacts = [
"./templates/manifest.json",
"./templates/manifest.generated.ts",
];
const before = await Promise.all(artifacts.map((path) => Deno.stat(path)));

const moduleUrl =
new URL("./generate-templates-manifest.ts", import.meta.url).href;
// Inside the repo tree, not a temp dir: the generator imports `#std/path`,
// which only resolves against the checked-in import map.
const probeDir = await Deno.makeTempDir({
prefix: ".vf-generator-import-",
dir: ".",
});
const probePath = `${probeDir}/probe.ts`;
await Deno.writeTextFile(
probePath,
`const mod = await import(${JSON.stringify(moduleUrl)});\n` +
`if (typeof mod.findFeaturesWithIgnoredFiles !== "function") Deno.exit(3);\n`,
);
let output;
try {
output = await new Deno.Command(Deno.execPath(), {
args: ["run", "-A", probePath],
cwd: Deno.cwd(),
stdout: "piped",
stderr: "piped",
}).output();
} finally {
await Deno.remove(probeDir, { recursive: true });
}
assertEquals(
output.code,
0,
`probe failed: ${new TextDecoder().decode(output.stderr)}`,
);

const after = await Promise.all(artifacts.map((path) => Deno.stat(path)));
for (const [index, path] of artifacts.entries()) {
assertEquals(
after[index].mtime?.getTime(),
before[index].mtime?.getTime(),
`importing the generator must not rewrite ${path}`,
);
}
});
});
153 changes: 113 additions & 40 deletions scripts/build/generate-templates-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ async function collectSortedFiles(root: string): Promise<Array<{ path: string }>
return files.sort((a, b) => relative(root, a.path).localeCompare(relative(root, b.path)));
}

/**
* Features whose on-disk `files/` directory the manifest does not carry.
*
* Every feature ships one and none of it is ever scaffolded: `FeatureConfig`
* has no `files` field and the loader never reads the directory, so the tree
* is inert (veryfront-code#3786). This reports rather than throws. Making it
* fatal would break the build until the affected features are resolved.
* Whether they should be activated or deleted is not a manifest-generator
* decision. Silence is what let this sit undetected, so the generator says it
* on every run.
*/
export async function findFeaturesWithIgnoredFiles(
featuresDir = "./templates/features",
): Promise<Array<{ feature: string; fileCount: number }>> {
const ignored: Array<{ feature: string; fileCount: number }> = [];
let entries: Deno.DirEntry[];
try {
entries = await collectSortedDirectoryEntries(featuresDir);
} catch {
return ignored;
}
for (const entry of entries) {
if (!entry.isDirectory) continue;
const filesPath = `${featuresDir}/${entry.name}/files`;
try {
const stat = await Deno.stat(filesPath);
if (!stat.isDirectory) continue;
} catch {
continue;
}
const fileCount = (await collectSortedFiles(filesPath)).length;
if (fileCount > 0) ignored.push({ feature: entry.name, fileCount });
}
return ignored;
}

async function generateManifest(): Promise<TemplateManifest> {
const templatesDir = "./templates/files";
const integrationsDir = "./templates/integrations";
Expand Down Expand Up @@ -121,6 +157,24 @@ async function generateManifest(): Promise<TemplateManifest> {
manifest.templates[`integration:${integrationName}`] = { files };
}

// Features declare no files. Every feature directory nevertheless ships a
// `files/` tree, and none of it has ever been scaffolded, because
// `FeatureConfig` has no `files` field and the loader never reads the
// directory (#3786). Warn rather than throw: making this fatal would break
// the build until the affected features are resolved. Resolving them is not
// a manifest-generator decision. Silence is what let this sit undetected,
// so the generator says it on every run.
const featuresWithIgnoredFiles = await findFeaturesWithIgnoredFiles();
if (featuresWithIgnoredFiles.length > 0) {
console.warn(
`[templates-manifest] ignored feature files/ directories, not scaffolded: ${
featuresWithIgnoredFiles
.map(({ feature, fileCount }) => `${feature} (${fileCount})`)
.join(", ")
}. See veryfront-code#3786.`,
);
}

// Process ai-rules templates (used by `veryfront install`)
const aiRulesDir = "./templates/ai-rules";
for (const entry of await collectSortedDirectoryEntries(aiRulesDir)) {
Expand All @@ -132,46 +186,65 @@ async function generateManifest(): Promise<TemplateManifest> {
return manifest;
}

const manifest = await generateManifest();
const outputPath = "./templates/manifest.json";
const output = JSON.stringify(manifest, null, 2) + "\n";
const compressedStream = new Blob([output]).stream().pipeThrough(
new CompressionStream("gzip"),
);
const compressedBytes = new Uint8Array(
await new Response(compressedStream).arrayBuffer(),
);
const compressedOutputPath = "./templates/manifest.generated.ts";
const compressedOutput = `/** Generated by scripts/build/generate-templates-manifest.ts. */
export const COMPRESSED_TEMPLATE_MANIFEST_BASE64: string =
"${
encodeBase64Bytes(compressedBytes)
}";
`;

const templateCount = Object.keys(manifest.templates).length;
const fileCount = Object.values(manifest.templates).reduce(
(sum, t) => sum + Object.keys(t.files).length,
0,
);

if (Deno.args.includes("--check")) {
const existing = await Deno.readTextFile(outputPath).catch(() => null);
const existingCompressed = await Deno.readTextFile(compressedOutputPath).catch(() => null);
const stalePaths = [
...(existing !== output ? [outputPath] : []),
...(existingCompressed !== compressedOutput ? [compressedOutputPath] : []),
];
if (stalePaths.length > 0) {
console.error(`${stalePaths.join(", ")} is stale. Run deno task generate.`);
Deno.exit(1);
/**
* Write (or verify) the generated manifest artifacts.
*
* Guarded by `import.meta.main` so importing this module has no side effects.
* Without it, merely importing to reach a helper regenerated and REWROTE both
* tracked artifacts. The gzip representation varies with the installed
* Deno version, so a test run could leave a spurious diff in the working tree.
* `run-generate.ts` invokes this as a subprocess, so the guard is true there.
*/
async function main(): Promise<void> {
const manifest = await generateManifest();
const outputPath = "./templates/manifest.json";
const output = JSON.stringify(manifest, null, 2) + "\n";
const compressedStream = new Blob([output]).stream().pipeThrough(
new CompressionStream("gzip"),
);
const compressedBytes = new Uint8Array(
await new Response(compressedStream).arrayBuffer(),
);
const compressedOutputPath = "./templates/manifest.generated.ts";
// Joined rather than written as a multi-line template literal: the literal
// preserves its source indentation verbatim, so indenting this block once
// leaked a tab into every line of the GENERATED file and `ci (format)`
// rejected it. Building the lines explicitly makes the output independent of
// how this function happens to be nested.
const compressedOutput = [
"/** Generated by scripts/build/generate-templates-manifest.ts. */",
"export const COMPRESSED_TEMPLATE_MANIFEST_BASE64: string =",
` "${encodeBase64Bytes(compressedBytes)}";`,
"",
].join("\n");

const templateCount = Object.keys(manifest.templates).length;
const fileCount = Object.values(manifest.templates).reduce(
(sum, t) => sum + Object.keys(t.files).length,
0,
);

if (Deno.args.includes("--check")) {
const existing = await Deno.readTextFile(outputPath).catch(() => null);
const existingCompressed = await Deno.readTextFile(compressedOutputPath).catch(() => null);
const stalePaths = [
...(existing !== output ? [outputPath] : []),
...(existingCompressed !== compressedOutput ? [compressedOutputPath] : []),
];
if (stalePaths.length > 0) {
console.error(`${stalePaths.join(", ")} is stale. Run deno task generate.`);
Deno.exit(1);
}

console.log(`${outputPath} and ${compressedOutputPath} are current.`);
console.log(` ${templateCount} templates, ${fileCount} files`);
} else {
await Deno.writeTextFile(outputPath, output);
await Deno.writeTextFile(compressedOutputPath, compressedOutput);
console.log(`✅ Generated ${outputPath} and ${compressedOutputPath}`);
console.log(` ${templateCount} templates, ${fileCount} files`);
}

console.log(`${outputPath} and ${compressedOutputPath} are current.`);
console.log(` ${templateCount} templates, ${fileCount} files`);
} else {
await Deno.writeTextFile(outputPath, output);
await Deno.writeTextFile(compressedOutputPath, compressedOutput);
console.log(`✅ Generated ${outputPath} and ${compressedOutputPath}`);
console.log(` ${templateCount} templates, ${fileCount} files`);
}

if (import.meta.main) await main();