245 lines
9.7 KiB
JavaScript
245 lines
9.7 KiB
JavaScript
import console from "node:console";
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join, relative, resolve } from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const releaseVersion = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
const require = createRequire(import.meta.url);
|
|
const componentPackages = new Set([
|
|
"auth",
|
|
"captcha",
|
|
"i18n",
|
|
"image",
|
|
"realtime",
|
|
"ui",
|
|
"uploader",
|
|
"validation",
|
|
]);
|
|
const requestedHelperFiles = {
|
|
auth: "src/helpers.ts",
|
|
captcha: "src/helpers.ts",
|
|
db: "src/helpers.ts",
|
|
encryption: "src/http.ts",
|
|
i18n: "src/index.ts",
|
|
image: "src/index.ts",
|
|
jwt: "src/helpers.ts",
|
|
realtime: "src/messages.ts",
|
|
uploader: "src/helpers.ts",
|
|
validation: "src/helpers.ts",
|
|
};
|
|
|
|
function writeIfChanged(path, source) {
|
|
if (existsSync(path) && readFileSync(path, "utf8") === source) return false;
|
|
writeFileSync(path, source, "utf8");
|
|
return true;
|
|
}
|
|
|
|
function walkFiles(directory, pattern) {
|
|
const files = [];
|
|
if (!existsSync(directory)) return files;
|
|
const visit = (current) => {
|
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
const path = join(current, entry.name);
|
|
if (entry.isDirectory()) visit(path);
|
|
else if (entry.isFile() && pattern.test(entry.name)) files.push(path);
|
|
}
|
|
};
|
|
visit(directory);
|
|
return files.sort();
|
|
}
|
|
|
|
function helperIsPublic(entrySource, helperFile, main) {
|
|
if (!helperFile || helperFile === main) return true;
|
|
const relativeHelper = `./${relative(dirname(main), helperFile).replace(/\\/g, "/").replace(/\.ts$/, ".ts")}`;
|
|
const withoutExtension = relativeHelper.replace(/\.ts$/, "");
|
|
return entrySource.includes(relativeHelper) || entrySource.includes(withoutExtension);
|
|
}
|
|
|
|
const compiler = require(join(root, "editors", "vscode", "src", "compiler.cjs"));
|
|
const uiCatalog = JSON.parse(
|
|
readFileSync(join(root, "packages", "ui", "component-catalog.json"), "utf8"),
|
|
);
|
|
const uiComponentNames = new Set(uiCatalog.components.map((entry) => entry.name));
|
|
const packageDirectories = readdirSync(join(root, "packages"), { withFileTypes: true })
|
|
.filter(
|
|
(entry) =>
|
|
entry.isDirectory() && existsSync(join(root, "packages", entry.name, "package.json")),
|
|
)
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
|
|
const packages = [];
|
|
const failures = [];
|
|
let componentCount = 0;
|
|
let helperPackageCount = 0;
|
|
|
|
for (const directory of packageDirectories) {
|
|
const packageRoot = join(root, "packages", directory);
|
|
const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
const main = String(manifest.main ?? manifest.exports?.["."] ?? "src/index.ts").replace(
|
|
/^\.\//,
|
|
"",
|
|
);
|
|
const entry = join(packageRoot, main);
|
|
const entrySource = existsSync(entry) ? readFileSync(entry, "utf8") : "";
|
|
const componentsDir = join(packageRoot, "components");
|
|
const components = existsSync(componentsDir)
|
|
? readdirSync(componentsDir)
|
|
.filter((name) => name.endsWith(".wrn"))
|
|
.sort()
|
|
: [];
|
|
const tests = walkFiles(join(packageRoot, "test"), /\.(?:test|spec)\.[cm]?[jt]s$/).map((path) =>
|
|
relative(join(packageRoot, "test"), path).replace(/\\/g, "/"),
|
|
);
|
|
const sourceFiles = walkFiles(join(packageRoot, "src"), /\.[cm]?ts$/);
|
|
const allSource = sourceFiles.map((path) => readFileSync(path, "utf8")).join("\n");
|
|
const exported = [
|
|
...allSource.matchAll(
|
|
/\bexport\s+(?:declare\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g,
|
|
),
|
|
].map((match) => match[1]);
|
|
const helperFile = requestedHelperFiles[directory];
|
|
const hasHelperKit = helperFile
|
|
? existsSync(join(packageRoot, helperFile))
|
|
: exported.length > 0 ||
|
|
/export\s+\{/.test(allSource) ||
|
|
/export\s+\*/.test(allSource) ||
|
|
directory === "cli";
|
|
const publicHelper = helperIsPublic(entrySource, helperFile, main);
|
|
if (hasHelperKit) helperPackageCount++;
|
|
componentCount += components.length;
|
|
|
|
if (!/^0\.8\.\d+$/.test(manifest.version))
|
|
failures.push(`${manifest.name}: invalid independent version ${manifest.version}`);
|
|
if (!existsSync(entry)) failures.push(`${manifest.name}: missing public entry ${main}`);
|
|
if (!existsSync(join(packageRoot, "README.md")))
|
|
failures.push(`${manifest.name}: missing README.md`);
|
|
if (!tests.length) failures.push(`${manifest.name}: missing package test`);
|
|
if (!hasHelperKit) failures.push(`${manifest.name}: no public helper/API surface found`);
|
|
if (!publicHelper) failures.push(`${manifest.name}: ${helperFile} is not exported by ${main}`);
|
|
|
|
const usedUiComponents = new Set();
|
|
const componentFailures = [];
|
|
for (const component of components) {
|
|
const file = join(componentsDir, component);
|
|
const source = readFileSync(file, "utf8");
|
|
for (const match of source.matchAll(/<([A-Z][A-Za-z0-9]*)\b/g)) {
|
|
if (uiComponentNames.has(match[1])) usedUiComponents.add(match[1]);
|
|
}
|
|
try {
|
|
compiler.compileWireFile(source);
|
|
} catch (error) {
|
|
componentFailures.push(
|
|
`${component}: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (componentPackages.has(directory)) {
|
|
if (!components.length)
|
|
failures.push(`${manifest.name}: component package has no .wrn components`);
|
|
if (directory !== "ui" && manifest.dependencies?.["@wrnexus/ui"] !== "workspace:*") {
|
|
failures.push(`${manifest.name}: component package must depend on @wrnexus/ui`);
|
|
}
|
|
if (directory !== "ui" && usedUiComponents.size === 0) {
|
|
failures.push(`${manifest.name}: package blocks do not compose any @wrnexus/ui component`);
|
|
}
|
|
if (directory !== "ui" && !manifest.exports?.["./plugin"]) {
|
|
failures.push(`${manifest.name}: component package must export ./plugin`);
|
|
}
|
|
}
|
|
|
|
for (const issue of componentFailures) failures.push(`${manifest.name}: ${issue}`);
|
|
|
|
packages.push({
|
|
name: manifest.name,
|
|
directory,
|
|
version: manifest.version,
|
|
kind: componentPackages.has(directory) ? "component-kit" : "helper-kit",
|
|
entry: relative(root, entry).replace(/\\/g, "/"),
|
|
helperFile: helperFile ?? null,
|
|
helperIsPublic: publicHelper,
|
|
publicExportCount: exported.length,
|
|
components,
|
|
uiComponents: [...usedUiComponents].sort(),
|
|
tests,
|
|
readme: existsSync(join(packageRoot, "README.md")),
|
|
usesUi: directory === "ui" || manifest.dependencies?.["@wrnexus/ui"] === "workspace:*",
|
|
});
|
|
}
|
|
|
|
const authSource = readdirSync(join(root, "packages", "auth", "components"))
|
|
.filter((name) => name.endsWith(".wrn"))
|
|
.map((name) => readFileSync(join(root, "packages", "auth", "components", name), "utf8"))
|
|
.join("\n");
|
|
if (/<(?:button|select|textarea)\b/.test(authSource)) {
|
|
failures.push("@wrnexus/auth: visible native controls must use @wrnexus/ui components");
|
|
}
|
|
for (const match of authSource.matchAll(/<input\b[\s\S]*?>/g)) {
|
|
if (!/\btype=(?:"hidden"|'hidden')/i.test(match[0])) {
|
|
failures.push(
|
|
"@wrnexus/auth: visible native input must use @wrnexus/ui Input/PinInput components",
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
version: releaseVersion,
|
|
summary: {
|
|
packageCount: packages.length,
|
|
helperPackageCount,
|
|
componentPackageCount: packages.filter((entry) => entry.kind === "component-kit").length,
|
|
componentCount,
|
|
failures: failures.length,
|
|
},
|
|
packages,
|
|
failures,
|
|
};
|
|
|
|
const markdown = [
|
|
`# WRNexusJS ${releaseVersion} Package Kits`,
|
|
"",
|
|
"Every framework package has a public API/helper surface, package tests, and documentation. Packages that directly render developer-facing UI additionally provide package-owned `.wrn` blocks composed from `@wrnexus/ui`.",
|
|
"",
|
|
`- Packages: ${report.summary.packageCount}`,
|
|
`- Helper/API kits: ${report.summary.helperPackageCount}`,
|
|
`- Component kits: ${report.summary.componentPackageCount}`,
|
|
`- Package-owned components: ${report.summary.componentCount}`,
|
|
"",
|
|
"| Package | Kit | Helper/API entry | Components | UI composition | Tests |",
|
|
"|---|---|---|---:|---|---:|",
|
|
...packages.map(
|
|
(entry) =>
|
|
`| \`${entry.name}\` | ${entry.kind} | ${entry.helperFile ? `\`${entry.helperFile}\`` : `\`${entry.entry}\``} | ${entry.components.length} | ${entry.uiComponents.length ? entry.uiComponents.map((name) => `\`${name}\``).join(", ") : "—"} | ${entry.tests.length} |`,
|
|
),
|
|
"",
|
|
"## Component-kit rule",
|
|
"",
|
|
"Infrastructure-only packages remain helper/API kits and do not pull browser UI into server code. UI-facing packages own complete blocks and register them through their package plugin.",
|
|
"",
|
|
"## Security boundary for encrypted HTTP bodies",
|
|
"",
|
|
"`@wrnexus/encryption` encrypted HTTP envelopes add application-layer confidentiality, replay checks, and method/path/request binding. They do not replace HTTPS and cannot hide data from a browser user who receives the decryption key. Use them for service-to-service, native/mobile, controlled agents, or field-level protection with server-managed keys.",
|
|
"",
|
|
failures.length
|
|
? "## Failures\n\n" + failures.map((failure) => `- ${failure}`).join("\n") + "\n"
|
|
: "## Audit result\n\nAll package-kit requirements pass.\n",
|
|
].join("\n");
|
|
|
|
if (process.argv.includes("--write")) {
|
|
const reportDir = join(root, ".wrnexus", "reports");
|
|
mkdirSync(reportDir, { recursive: true });
|
|
writeIfChanged(
|
|
join(reportDir, "PACKAGE-KITS-0.8.0.json"),
|
|
`${JSON.stringify(report, null, 2)}\n`,
|
|
);
|
|
writeIfChanged(join(reportDir, "PACKAGE-KITS-0.8.0.md"), `${markdown}\n`);
|
|
}
|
|
console.log(JSON.stringify(report.summary, null, 2));
|
|
for (const failure of failures) console.error(`FAIL ${failure}`);
|
|
if (failures.length) process.exitCode = 1;
|