Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Contract-coverage gate (docs/adr/ADR-029-v2-r5-final-hardening.md, brief Phase 1):
|
||||
// fails when a route actually registered by an ASP.NET Minimal API endpoint file under
|
||||
// src/backend/ItWorx.KnowledgeHub.Api/Endpoints does not appear in
|
||||
// docs/api/openapi.yaml. This is what makes "the API must be reliably usable by a
|
||||
// future separate MCP server" (CLAUDE.md) an enforced property, not an aspiration —
|
||||
// a route can no longer silently ship undocumented.
|
||||
//
|
||||
// Deliberately a source-scan, not a live-server introspection: it runs with no
|
||||
// database, no built application and no network, so it is cheap enough to run on every
|
||||
// `node scripts/verify.mjs` (fast and full), not only the release gate.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const endpointsDir = "src/backend/ItWorx.KnowledgeHub.Api/Endpoints";
|
||||
const openApiPath = "docs/api/openapi.yaml";
|
||||
|
||||
// Routes intentionally excluded from OpenAPI coverage: not `/api/v1/*` REST endpoints
|
||||
// at all (health probes, the raw attachment byte stream already documented once under
|
||||
// its real path, etc.) — see docs/09-api-contract.md "Base path: /api/v1".
|
||||
const EXCLUDED_ROUTE_PREFIXES = ["/health"];
|
||||
|
||||
function listEndpointFiles(dir) {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".cs"))
|
||||
.map((file) => path.join(dir, file));
|
||||
}
|
||||
|
||||
function extractRoutesFromFile(filePath) {
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const routes = [];
|
||||
|
||||
// MapGroup("/api/v1/xyz") establishes a prefix for every Map* call chained off the
|
||||
// returned variable within the same file — every endpoint file in this codebase uses
|
||||
// exactly this one-group-per-file pattern (verified by inspection), so a single
|
||||
// "most recent MapGroup literal in this file" tracking variable is sufficient and
|
||||
// avoids needing a real C# parser.
|
||||
const groupMatch = source.match(/MapGroup\(\s*"([^"]+)"\s*\)/);
|
||||
const groupPrefix = groupMatch ? groupMatch[1] : "";
|
||||
|
||||
const mapCallPattern = /\.Map(Get|Post|Put|Delete|Patch)\(\s*"([^"]*)"/g;
|
||||
let match;
|
||||
while ((match = mapCallPattern.exec(source)) !== null) {
|
||||
const method = match[1].toUpperCase();
|
||||
const routeLiteral = match[2];
|
||||
const fullPath = groupPrefix + routeLiteral;
|
||||
routes.push({ method, path: normalizeRoute(fullPath), file: filePath });
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
// Normalizes an ASP.NET route template's typed route constraints
|
||||
// (`{attachmentId:guid}`) to OpenAPI's untyped `{attachmentId}` style, and strips the
|
||||
// leading `/api/v1` prefix to match how docs/api/openapi.yaml keys its paths (relative
|
||||
// to the documented `servers[0].url`).
|
||||
function normalizeRoute(route) {
|
||||
let normalized = route.replace(/\{([a-zA-Z0-9_]+)(:[a-zA-Z]+)?\}/g, "{$1}");
|
||||
if (normalized.startsWith("/api/v1")) {
|
||||
normalized = normalized.slice("/api/v1".length);
|
||||
}
|
||||
if (normalized === "") {
|
||||
normalized = "/";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isExcluded(routePath) {
|
||||
return EXCLUDED_ROUTE_PREFIXES.some((prefix) => routePath.startsWith(prefix));
|
||||
}
|
||||
|
||||
const registeredRoutes = listEndpointFiles(endpointsDir).flatMap(extractRoutesFromFile).filter((route) => !isExcluded(route.path));
|
||||
|
||||
const document = yaml.load(fs.readFileSync(openApiPath, "utf8"));
|
||||
const documentedPaths = document?.paths ?? {};
|
||||
|
||||
const missing = [];
|
||||
for (const route of registeredRoutes) {
|
||||
const pathEntry = documentedPaths[route.path];
|
||||
const documentedMethods = pathEntry ? Object.keys(pathEntry).map((key) => key.toUpperCase()) : [];
|
||||
if (!documentedMethods.includes(route.method)) {
|
||||
missing.push(route);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`OpenAPI coverage gate failed: ${missing.length} registered route(s) not documented in ${openApiPath}:`);
|
||||
for (const route of missing.sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
console.error(`- ${route.method} /api/v1${route.path === "/" ? "" : route.path} (${route.file})`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`OpenAPI coverage gate passed: all ${registeredRoutes.length} registered /api/v1 routes are documented.`);
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { capture, commandExists, run } from "./lib/exec.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const requiredRootFiles = [
|
||||
"CLAUDE.md",
|
||||
"product/milestones.json",
|
||||
"ItWorx.KnowledgeHub.slnx",
|
||||
"src/frontend/package.json"
|
||||
];
|
||||
|
||||
const missingRoot = requiredRootFiles.filter((entry) => !fs.existsSync(path.join(root, entry)));
|
||||
if (missingRoot.length > 0) {
|
||||
console.error("Run this command from the repository root. Missing:", missingRoot.join(", "));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
|
||||
async function check(name, command, args, predicate, required = true) {
|
||||
if (!commandExists(command)) {
|
||||
checks.push({ name, value: "missing", ok: false, required });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await capture(command, args);
|
||||
checks.push({ name, value: value.split(/\r?\n/)[0], ok: predicate(value), required });
|
||||
} catch (error) {
|
||||
checks.push({ name, value: `error: ${error.message}`, ok: false, required });
|
||||
}
|
||||
}
|
||||
|
||||
await check("Git", "git", ["--version"], (value) => /git version/i.test(value));
|
||||
await check("Node.js", "node", ["--version"], (value) => {
|
||||
const major = Number(value.match(/v?(\d+)/)?.[1] ?? 0);
|
||||
return major >= 24;
|
||||
});
|
||||
await check("Corepack", "corepack", ["--version"], (value) => value.length > 0);
|
||||
await check("pnpm", "corepack", ["pnpm", "--version"], (value) => {
|
||||
const major = Number(value.match(/(\d+)/)?.[1] ?? 0);
|
||||
return major >= 11;
|
||||
});
|
||||
await check(".NET SDK", "dotnet", ["--version"], (value) => value.trim().startsWith("10."));
|
||||
function findWorkingPython() {
|
||||
for (const candidate of ["python3", "python"]) {
|
||||
if (!commandExists(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const probe = spawnSync(candidate, ["--version"], { stdio: "ignore", shell: false });
|
||||
if (probe.status === 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const python = findWorkingPython();
|
||||
if (python) {
|
||||
await check("Python", python, ["--version"], (value) => {
|
||||
const match = value.match(/Python\s+(\d+)\.(\d+)/i);
|
||||
return Boolean(match && Number(match[1]) >= 3 && Number(match[2]) >= 11);
|
||||
});
|
||||
} else {
|
||||
checks.push({ name: "Python", value: "missing or non-working", ok: false, required: true });
|
||||
}
|
||||
await check("Docker", "docker", ["--version"], (value) => /Docker version/i.test(value));
|
||||
if (commandExists("docker")) {
|
||||
try {
|
||||
const compose = await capture("docker", ["compose", "version"]);
|
||||
checks.push({ name: "Docker Compose", value: compose, ok: /Compose version\s+v?\d+\./i.test(compose), required: true });
|
||||
} catch (error) {
|
||||
checks.push({ name: "Docker Compose", value: `error: ${error.message}`, ok: false, required: true });
|
||||
}
|
||||
}
|
||||
|
||||
const width = Math.max(...checks.map((entry) => entry.name.length), 10);
|
||||
for (const entry of checks) {
|
||||
const marker = entry.ok ? "OK " : entry.required ? "ERR" : "WARN";
|
||||
console.log(`${marker} ${entry.name.padEnd(width)} ${entry.value}`);
|
||||
}
|
||||
|
||||
const failures = checks.filter((entry) => entry.required && !entry.ok);
|
||||
if (failures.length > 0) {
|
||||
console.error("\nRequired toolchain checks failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(root, "pnpm-lock.yaml"))) {
|
||||
console.warn("\nWARN pnpm-lock.yaml is not present yet. M0 must generate and commit it.");
|
||||
}
|
||||
|
||||
console.log("\nToolchain baseline is available.");
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Canonical, machine-readable release identity (docs/adr/ADR-029-v2-r5-final-hardening.md,
|
||||
// brief Phase 9). Computes everything genuinely knowable at *build* time — source
|
||||
// commit, OpenAPI contract hash, frontend build hash, semantic version, build
|
||||
// timestamp — and writes release-manifest.json, which the Dockerfile copies into the
|
||||
// image and the running application reads at startup to enrich GET /api/v1/system/info.
|
||||
//
|
||||
// Deliberately leaves deployment-time-only fields (image tag, image ID, immutable
|
||||
// digest, deployment timestamp, database migration range as actually applied,
|
||||
// inventory snapshot ID, backup ID) as null here — those are not knowable until the
|
||||
// image has actually been built and deployed (the registry/daemon assigns a digest
|
||||
// only after push; a migration range depends on which database the running instance
|
||||
// is actually talking to). Per the brief: "Tag de nieuwe release pas nadat de
|
||||
// daadwerkelijke productie-image ... is gebouwd en succesvol gedeployed" — this script
|
||||
// never invents those values, and a real deployment record (docs/deployments/*.md)
|
||||
// is the source of truth for them, cross-referenced from here only after the fact.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const outputPath = process.argv[2] ?? path.join(repoRoot, "release-manifest.json");
|
||||
|
||||
function sha256File(relativePath) {
|
||||
const fullPath = path.join(repoRoot, relativePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return null;
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
hash.update(fs.readFileSync(fullPath));
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
// The built frontend bundle's content-hashed filenames already make every asset's own
|
||||
// hash visible in its filename (Vite's default), but there is no single "the bundle"
|
||||
// file — hash the whole dist/ directory's file list + each file's own bytes instead,
|
||||
// so any output change (including a filename/hash change) changes this value too.
|
||||
function sha256Directory(relativeDir) {
|
||||
const fullDir = path.join(repoRoot, relativeDir);
|
||||
if (!fs.existsSync(fullDir)) {
|
||||
return null;
|
||||
}
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(entryPath);
|
||||
} else {
|
||||
files.push(entryPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(fullDir);
|
||||
files.sort();
|
||||
|
||||
const hash = createHash("sha256");
|
||||
for (const file of files) {
|
||||
hash.update(path.relative(fullDir, file).replace(/\\/g, "/"));
|
||||
hash.update(fs.readFileSync(file));
|
||||
}
|
||||
return `sha256:${hash.digest("hex")}`;
|
||||
}
|
||||
|
||||
function gitCommit() {
|
||||
if (process.env.BUILD_COMMIT && process.env.BUILD_COMMIT !== "development") {
|
||||
return process.env.BUILD_COMMIT;
|
||||
}
|
||||
const result = spawnSync("git", ["rev-parse", "--short=12", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : "unknown";
|
||||
}
|
||||
|
||||
function readOpenApiVersion() {
|
||||
try {
|
||||
const contents = fs.readFileSync(path.join(repoRoot, "docs/api/openapi.yaml"), "utf8");
|
||||
const match = contents.match(/^\s*version:\s*(.+)$/m);
|
||||
return match ? match[1].trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readSemanticVersion() {
|
||||
const versionFile = path.join(repoRoot, "VERSION");
|
||||
if (fs.existsSync(versionFile)) {
|
||||
return fs.readFileSync(versionFile, "utf8").trim();
|
||||
}
|
||||
// Falls back to the most recent annotated/lightweight tag reachable from HEAD, the
|
||||
// same source `git describe` uses — never invents a version number.
|
||||
const described = spawnSync("git", ["describe", "--tags", "--always"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8"
|
||||
});
|
||||
return described.status === 0 ? described.stdout.trim() : null;
|
||||
}
|
||||
|
||||
// An empty string (as opposed to an unset variable — Docker build args with no
|
||||
// override are still passed as "") means "not actually known" just as much as
|
||||
// unset does; never record it as a real, empty-but-present value.
|
||||
const nonEmpty = (value) => (value && value.trim().length > 0 ? value.trim() : null);
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
semanticVersion: readSemanticVersion(),
|
||||
releaseTag: nonEmpty(process.env.RELEASE_TAG),
|
||||
sourceCommit: gitCommit(),
|
||||
imageRepository: process.env.IMAGE_REPOSITORY ?? "itworx/knowledge-hub",
|
||||
imageTag: nonEmpty(process.env.IMAGE_TAG),
|
||||
imageId: null,
|
||||
imageDigest: null,
|
||||
buildTimestamp: new Date().toISOString(),
|
||||
deploymentTimestamp: null,
|
||||
environment: process.env.BUILD_ENVIRONMENT ?? "production",
|
||||
openApiVersion: readOpenApiVersion(),
|
||||
openApiHash: sha256File("docs/api/openapi.yaml"),
|
||||
frontendBuildHash: sha256Directory("src/frontend/dist"),
|
||||
databaseMigrationRange: null,
|
||||
inventorySnapshotId: null,
|
||||
backupId: null
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote release manifest to ${outputPath}:`);
|
||||
console.log(JSON.stringify(manifest, null, 2));
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
export function commandExists(command) {
|
||||
const locator = process.platform === "win32" ? "where" : "which";
|
||||
const result = spawnSync(locator, [command], {
|
||||
stdio: "ignore",
|
||||
shell: false
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
// On Windows, some tools (corepack, pnpm, npm, ...) are installed as `.cmd`/`.bat`
|
||||
// shims. Those are not real PE executables, so libuv/CreateProcess cannot launch
|
||||
// them directly: `child_process.spawn` fails with ENOENT even though the same
|
||||
// command works in an interactive shell. Real executables (dotnet.exe, docker.exe,
|
||||
// node.exe, python.exe) do not have this problem and must NOT be routed through a
|
||||
// shell, because shell-quoting a path that contains spaces (e.g. "C:\Program
|
||||
// Files\...") breaks it. Detect which case applies per command instead of
|
||||
// guessing globally.
|
||||
function requiresWindowsShell(command) {
|
||||
if (process.platform !== "win32") {
|
||||
return false;
|
||||
}
|
||||
if (/[\\/]/.test(command)) {
|
||||
// Already a path; trust its extension.
|
||||
return /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
const located = spawnSync("where", [command], {
|
||||
encoding: "utf8",
|
||||
shell: false
|
||||
});
|
||||
if (located.status !== 0) {
|
||||
return false;
|
||||
}
|
||||
// `where` can list several matches on Windows (e.g. an extension-less shebang
|
||||
// launcher script alongside a `.cmd` shim for the same tool). Node can only
|
||||
// ever run the `.cmd`/`.bat` variant through a shell, so treat the command as
|
||||
// shell-requiring if *any* match needs one, not just the first line.
|
||||
const matches = located.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return matches.some((match) => /\.(cmd|bat)$/i.test(match));
|
||||
}
|
||||
|
||||
function quoteForWindowsShell(value) {
|
||||
if (value === "") {
|
||||
return '""';
|
||||
}
|
||||
if (!/[\s"^&|<>()%]/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `"${value.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export async function run(command, args = [], options = {}) {
|
||||
const printable = [command, ...args].join(" ");
|
||||
if (!options.quiet) {
|
||||
process.stdout.write(`\n> ${printable}\n`);
|
||||
}
|
||||
|
||||
const useShell = requiresWindowsShell(command);
|
||||
const spawnCommand = useShell
|
||||
? [command, ...args].map(quoteForWindowsShell).join(" ")
|
||||
: command;
|
||||
const spawnArgs = useShell ? [] : args;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(spawnCommand, spawnArgs, {
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
||||
shell: useShell
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
if (options.capture) {
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
}
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve({ code, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = signal ? ` (signal ${signal})` : "";
|
||||
const error = new Error(`Command failed (${code}${suffix}): ${printable}`);
|
||||
error.code = code;
|
||||
error.stdout = stdout;
|
||||
error.stderr = stderr;
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function capture(command, args = [], options = {}) {
|
||||
const result = await run(command, args, { ...options, capture: true, quiet: true });
|
||||
return result.stdout.trim();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Lightweight, dependency-free OpenAPI consistency check: valid YAML, every internal
|
||||
// $ref resolves, and every path/schema referenced by a $ref actually exists. This is
|
||||
// not a full OpenAPI 3.1 schema validator, but it catches the class of error that
|
||||
// actually breaks generated clients and docs sites: dangling references and typos.
|
||||
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const path = "docs/api/openapi.yaml";
|
||||
const document = yaml.load(fs.readFileSync(path, "utf8"));
|
||||
|
||||
const errors = [];
|
||||
|
||||
function resolveRef(ref) {
|
||||
if (!ref.startsWith("#/")) {
|
||||
errors.push(`External or malformed $ref not supported: ${ref}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||
|
||||
let cursor = document;
|
||||
for (const segment of segments) {
|
||||
if (cursor === undefined || cursor === null || !(segment in cursor)) {
|
||||
errors.push(`Dangling $ref: ${ref}`);
|
||||
return;
|
||||
}
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function walk(node) {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
walk(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (node && typeof node === "object") {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === "$ref" && typeof value === "string") {
|
||||
resolveRef(value);
|
||||
} else {
|
||||
walk(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!document || typeof document !== "object") {
|
||||
errors.push("Document did not parse to an object.");
|
||||
} else {
|
||||
if (document.openapi === undefined) {
|
||||
errors.push("Missing top-level 'openapi' version field.");
|
||||
}
|
||||
if (!document.paths || Object.keys(document.paths).length === 0) {
|
||||
errors.push("No paths defined.");
|
||||
}
|
||||
walk(document);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error("OpenAPI validation failed:");
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pathCount = Object.keys(document.paths ?? {}).length;
|
||||
const schemaCount = Object.keys(document.components?.schemas ?? {}).length;
|
||||
console.log(`OpenAPI document is well-formed: ${pathCount} paths, ${schemaCount} schemas, all $ref pointers resolve.`);
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const required = [
|
||||
"README.md", "SECURITY.md", "CONTRIBUTING.md", "LICENSE", "PUBLIC_SOURCE_EXPORT.md",
|
||||
"PUBLIC_SOURCE_MANIFEST.json", "MANIFEST.json", "ItWorx.KnowledgeHub.slnx", "docker-compose.yml",
|
||||
"deploy/docker/Dockerfile", "docs/api/openapi.yaml", "src/frontend/package.json"
|
||||
];
|
||||
const missing = required.filter((entry) => !fs.existsSync(path.join(process.cwd(), entry)));
|
||||
if (missing.length > 0) {
|
||||
console.error(`Missing public source files: ${missing.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const privatePath of [".claude", ".agents", ".codex", "artifacts", "docs/deployments", "docs/v2", "n8n"]) {
|
||||
if (fs.existsSync(path.join(process.cwd(), privatePath))) {
|
||||
console.error(`Private-only path present in public export: ${privatePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync("PUBLIC_SOURCE_MANIFEST.json", "utf8"));
|
||||
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||
console.error("PUBLIC_SOURCE_MANIFEST.json contains no files.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Public source structure passed (${manifest.files.length} allowlisted files).`);
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { commandExists, run } from "./lib/exec.mjs";
|
||||
|
||||
const flags = new Set(process.argv.slice(2));
|
||||
const structureOnly = flags.has("--structure-only");
|
||||
const fast = flags.has("--fast");
|
||||
const ci = flags.has("--ci");
|
||||
const release = flags.has("--release");
|
||||
const publicSourceExport = fs.existsSync("PUBLIC_SOURCE_EXPORT.md");
|
||||
|
||||
function findWorkingPython() {
|
||||
// On some Windows setups `python3` resolves via PATH to the Microsoft Store
|
||||
// app-execution-alias stub (present even when no Store install exists),
|
||||
// which `where`/commandExists reports as found but which fails at runtime.
|
||||
// Probe candidates by actually invoking them rather than trusting PATH
|
||||
// resolution alone.
|
||||
const candidates = ["python3", "python"];
|
||||
for (const candidate of candidates) {
|
||||
if (!commandExists(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const probe = spawnSync(candidate, ["--version"], { stdio: "ignore", shell: false });
|
||||
if (probe.status === 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error("No working Python 3 interpreter found (tried python3, python).");
|
||||
}
|
||||
|
||||
const python = findWorkingPython();
|
||||
|
||||
async function section(name, action) {
|
||||
console.log(`\n=== ${name} ===`);
|
||||
await action();
|
||||
}
|
||||
|
||||
try {
|
||||
await section("Bundle structure", async () => {
|
||||
if (publicSourceExport) {
|
||||
await run(process.execPath, ["scripts/validate-public-source.mjs"]);
|
||||
} else {
|
||||
await run(process.execPath, ["scripts/validate-bundle.mjs"]);
|
||||
await run(process.execPath, ["scripts/test-safety-hook.mjs"]);
|
||||
}
|
||||
});
|
||||
|
||||
await section("LeafWiki inspector tests", async () => {
|
||||
await run(python, [
|
||||
"-m",
|
||||
"unittest",
|
||||
"discover",
|
||||
"-s",
|
||||
"tools/leafwiki-inspector/tests",
|
||||
"-p",
|
||||
"test_*.py"
|
||||
]);
|
||||
});
|
||||
|
||||
await section("Knowledge-ingestion policy tests", async () => {
|
||||
if (!publicSourceExport) {
|
||||
await run(process.execPath, ["--test", "tools/knowledge-ingestion/tests/*.test.mjs"]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (structureOnly) {
|
||||
console.log("\nStructure-only verification passed.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await section(".NET restore, format, build and tests", async () => {
|
||||
await run("dotnet", ["restore", "ItWorx.KnowledgeHub.slnx"]);
|
||||
await run("dotnet", [
|
||||
"format",
|
||||
"ItWorx.KnowledgeHub.slnx",
|
||||
"--verify-no-changes",
|
||||
"--no-restore"
|
||||
]);
|
||||
await run("dotnet", [
|
||||
"build",
|
||||
"ItWorx.KnowledgeHub.slnx",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-restore"
|
||||
]);
|
||||
await run("dotnet", [
|
||||
"test",
|
||||
"ItWorx.KnowledgeHub.slnx",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-build",
|
||||
// Excludes the M8 full-scale search/tree suite (100,000 pages / 1,000,000
|
||||
// revisions, docs/10-search-and-indexing.md "Scale verification") — a
|
||||
// deliberate, occasional release-gate exercise (run explicitly in the
|
||||
// "release" section below), not a per-commit check; it alone would add well
|
||||
// over a minute to every routine verification run.
|
||||
"--filter",
|
||||
"Category!=ReleaseScale",
|
||||
// No explicit LogFileName: `dotnet test` runs every project in the solution
|
||||
// through the same invocation, so a fixed name would have each project's
|
||||
// results overwrite the previous one. Omitting it lets each project write its
|
||||
// own default `<assembly>_<timestamp>.trx` file instead.
|
||||
"--logger",
|
||||
"trx",
|
||||
"--results-directory",
|
||||
"artifacts/test-results"
|
||||
]);
|
||||
});
|
||||
|
||||
await section("Frontend install and verification", async () => {
|
||||
const installArgs = fs.existsSync("pnpm-lock.yaml")
|
||||
? ["pnpm", "install", "--frozen-lockfile"]
|
||||
: ["pnpm", "install", "--no-frozen-lockfile"];
|
||||
|
||||
if (!fs.existsSync("pnpm-lock.yaml")) {
|
||||
console.warn("Pre-M0: generating pnpm-lock.yaml. Commit it after reviewing dependency versions.");
|
||||
}
|
||||
|
||||
await run("corepack", installArgs);
|
||||
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "lint"]);
|
||||
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "typecheck"]);
|
||||
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "test"]);
|
||||
await run("corepack", ["pnpm", "--filter", "@itworx/knowledge-hub-web", "build"]);
|
||||
|
||||
// Depends on the root workspace's node_modules (js-yaml), so it must run after
|
||||
// `pnpm install` above, not in the earlier structure-only "Bundle structure"
|
||||
// section that runs before any dependency install exists on a clean checkout.
|
||||
await run(process.execPath, ["scripts/validate-openapi.mjs"]);
|
||||
|
||||
// Contract-coverage gate (docs/adr/ADR-029-v2-r5-final-hardening.md): fails when a
|
||||
// registered /api/v1 endpoint is not documented in docs/api/openapi.yaml. Same
|
||||
// js-yaml dependency, same placement.
|
||||
await run(process.execPath, ["scripts/check-openapi-coverage.mjs"]);
|
||||
});
|
||||
|
||||
if (!fast) {
|
||||
await section("Docker configuration", async () => {
|
||||
await run("docker", ["compose", "config"], {
|
||||
env: {
|
||||
POSTGRES_PASSWORD: "verification-only-not-for-production"
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (ci || release) {
|
||||
await section("Production image build", async () => {
|
||||
await run("docker", ["compose", "build", "app"], {
|
||||
env: {
|
||||
POSTGRES_PASSWORD: "verification-only-not-for-production",
|
||||
BUILD_COMMIT: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? "verification"
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (release) {
|
||||
await section("Full-scale search/tree suite (release-gate only)", async () => {
|
||||
await run("dotnet", [
|
||||
"test",
|
||||
"tests/backend/ItWorx.KnowledgeHub.IntegrationTests/ItWorx.KnowledgeHub.IntegrationTests.csproj",
|
||||
"--configuration",
|
||||
"Release",
|
||||
"--no-build",
|
||||
"--filter",
|
||||
"Category=ReleaseScale"
|
||||
]);
|
||||
});
|
||||
|
||||
await section(".NET dependency vulnerability scan", async () => {
|
||||
const result = await run(
|
||||
"dotnet",
|
||||
["list", "ItWorx.KnowledgeHub.slnx", "package", "--vulnerable", "--include-transitive"],
|
||||
{ capture: true }
|
||||
);
|
||||
// `dotnet list package --vulnerable` always exits 0 and just prints findings —
|
||||
// the gate itself must fail the release on any reported advisory (M8-A02: "No
|
||||
// known Critical or High security finding remains unresolved" — since the
|
||||
// command does not separate by severity in its text output, any finding at all
|
||||
// blocks release pending explicit triage, matching the release gate's
|
||||
// zero-tolerance-until-reviewed posture).
|
||||
if (/has the following vulnerable packages/i.test(result.stdout)) {
|
||||
throw new Error(
|
||||
"dotnet reported vulnerable NuGet packages — review the output above before releasing.\n" + result.stdout
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await section("Frontend dependency vulnerability scan", async () => {
|
||||
await run("corepack", ["pnpm", "audit", "--prod"]);
|
||||
});
|
||||
|
||||
await section("Secret scan", async () => {
|
||||
if (!commandExists("gitleaks")) {
|
||||
throw new Error("gitleaks is required for the release secret scan but was not found on PATH.");
|
||||
}
|
||||
|
||||
await run("gitleaks", ["detect", "--source", ".", "--redact"]);
|
||||
});
|
||||
|
||||
if (commandExists("trivy")) {
|
||||
await section("Container vulnerability scan", async () => {
|
||||
await run("trivy", [
|
||||
"image",
|
||||
"--severity",
|
||||
"CRITICAL,HIGH",
|
||||
"--exit-code",
|
||||
"1",
|
||||
"--scanners",
|
||||
"vuln",
|
||||
"itworx/knowledge-hub:local"
|
||||
]);
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"\ntrivy not found on PATH — container vulnerability scan skipped. Install " +
|
||||
"trivy before a real release; this is not a substitute for the scan."
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
"\nRelease verification still requires the milestone-defined E2E (desktop + " +
|
||||
"mobile, axe), backup/restore rehearsal and runtime-inspection gates to be run " +
|
||||
"manually against the built image, per the documented procedure in EVIDENCE.md " +
|
||||
"— these involve bringing up docker compose and are recorded there rather than " +
|
||||
"orchestrated here."
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nVerification completed successfully for the selected baseline gates.");
|
||||
} catch (error) {
|
||||
console.error(`\nVerification failed: ${error.message}`);
|
||||
process.exit(typeof error.code === "number" ? error.code : 1);
|
||||
}
|
||||
Reference in new issue
Block a user