330 lines
9.7 KiB
JavaScript
330 lines
9.7 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
const ROOT = process.cwd();
|
|
const REGISTRY_PATH = path.join(ROOT, "docs", "integrations", "n8n", "registry.json");
|
|
const DOC_OUTPUT_PATH = path.join(
|
|
ROOT,
|
|
"docs",
|
|
"integrations",
|
|
"n8n",
|
|
"CONTEXT.generated.md"
|
|
);
|
|
const PLANNING_OUTPUT_PATH = path.join(ROOT, ".planning", "codebase", "N8N-CONTEXT.md");
|
|
const SOURCE_DIR = path.join(ROOT, "src");
|
|
const ENV_EXAMPLE_PATH = path.join(ROOT, ".env.example");
|
|
const WORKFLOW_EXPORT_DIR = path.join(ROOT, "docs", "integrations", "n8n", "exports");
|
|
|
|
const TOUCHPOINT_PATTERN = /\bN8N_[A-Z0-9_]+\b|n8n|webhook/gi;
|
|
const TOUCHPOINT_LINE_PATTERN = /\bN8N_[A-Z0-9_]+\b|n8n|webhook/i;
|
|
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"]);
|
|
|
|
function ensureDir(targetPath) {
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
}
|
|
|
|
function readJsonIfExists(targetPath, fallback) {
|
|
if (!fs.existsSync(targetPath)) {
|
|
return fallback;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
} catch (error) {
|
|
throw new Error(`Failed to parse JSON at ${targetPath}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function readTextIfExists(targetPath) {
|
|
if (!fs.existsSync(targetPath)) {
|
|
return "";
|
|
}
|
|
|
|
return fs.readFileSync(targetPath, "utf8");
|
|
}
|
|
|
|
function walkFiles(dirPath) {
|
|
if (!fs.existsSync(dirPath)) {
|
|
return [];
|
|
}
|
|
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
const files = [];
|
|
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(dirPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
files.push(...walkFiles(absolutePath));
|
|
continue;
|
|
}
|
|
|
|
if (!CODE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
continue;
|
|
}
|
|
|
|
files.push(absolutePath);
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
function isPrimaryTouchpoint(relativePath) {
|
|
if (relativePath.includes(".test.")) {
|
|
return false;
|
|
}
|
|
|
|
if (relativePath.startsWith("src/messages/")) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function collectEnvVars() {
|
|
const content = readTextIfExists(ENV_EXAMPLE_PATH);
|
|
const envVars = new Set();
|
|
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const match = line.match(/^([A-Z0-9_]+)=/);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
if (match[1].includes("N8N") || match[1].includes("WEBHOOK")) {
|
|
envVars.add(match[1]);
|
|
}
|
|
}
|
|
|
|
return [...envVars].sort();
|
|
}
|
|
|
|
function detectTouchpoints() {
|
|
const files = walkFiles(SOURCE_DIR);
|
|
const touchpoints = [];
|
|
|
|
for (const filePath of files) {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
const matches = [...content.matchAll(TOUCHPOINT_PATTERN)];
|
|
|
|
if (matches.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const relativePath = path.relative(ROOT, filePath).replaceAll("\\", "/");
|
|
const lines = content.split(/\r?\n/);
|
|
const highlights = [];
|
|
const seenLineNumbers = new Set();
|
|
|
|
lines.forEach((line, index) => {
|
|
if (!TOUCHPOINT_LINE_PATTERN.test(line)) {
|
|
return;
|
|
}
|
|
|
|
const lineNumber = index + 1;
|
|
if (seenLineNumbers.has(lineNumber)) {
|
|
return;
|
|
}
|
|
seenLineNumbers.add(lineNumber);
|
|
highlights.push({
|
|
lineNumber,
|
|
text: line.trim(),
|
|
});
|
|
});
|
|
|
|
touchpoints.push({
|
|
path: relativePath,
|
|
matchCount: matches.length,
|
|
highlights: highlights.slice(0, 5),
|
|
});
|
|
}
|
|
|
|
return touchpoints.sort((a, b) => a.path.localeCompare(b.path));
|
|
}
|
|
|
|
function listExports() {
|
|
if (!fs.existsSync(WORKFLOW_EXPORT_DIR)) {
|
|
return [];
|
|
}
|
|
|
|
return fs
|
|
.readdirSync(WORKFLOW_EXPORT_DIR, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
}
|
|
|
|
function toList(value) {
|
|
return Array.isArray(value) ? value.filter(Boolean) : [];
|
|
}
|
|
|
|
function formatBullets(items, emptyText = "- none") {
|
|
if (items.length === 0) {
|
|
return [emptyText];
|
|
}
|
|
|
|
return items.map((item) => `- ${item}`);
|
|
}
|
|
|
|
function quoteInline(value) {
|
|
return String(value).replaceAll("`", "\\`");
|
|
}
|
|
|
|
function renderWorkflow(workflow) {
|
|
const entrypoints = toList(workflow?.n8n?.entrypoints).map((entrypoint) => {
|
|
const method = entrypoint.method ? `${entrypoint.method} ` : "";
|
|
const pathValue = entrypoint.path || "(missing path)";
|
|
return `${entrypoint.kind || "entrypoint"}: ${method}${pathValue}`.trim();
|
|
});
|
|
|
|
const consumers = toList(workflow?.repository?.consumers);
|
|
const envVars = toList(workflow?.repository?.env);
|
|
const schemas = toList(workflow?.repository?.schemas);
|
|
const requestFields = toList(workflow?.contracts?.requestFields).map((item) => `\`${item}\``);
|
|
const responseFields = toList(workflow?.contracts?.responseFields).map((item) => `\`${item}\``);
|
|
const upstreams = toList(workflow?.upstreams);
|
|
const downstreams = toList(workflow?.downstreams);
|
|
const owners = toList(workflow?.owners);
|
|
const notes = workflow?.notes ? [`- ${workflow.notes}`] : ["- none"];
|
|
|
|
const lines = [
|
|
`## ${workflow.name || workflow.id || "Unnamed workflow"}`,
|
|
"",
|
|
`- Status: \`${workflow.status || "unknown"}\``,
|
|
`- ID: \`${quoteInline(workflow.id || "missing-id")}\``,
|
|
`- Purpose: ${workflow.purpose || "missing purpose"}`,
|
|
`- n8n workflow id: \`${quoteInline(workflow?.n8n?.workflowId || "not recorded")}\``,
|
|
`- Export file: \`${quoteInline(workflow?.n8n?.exportFile || "not recorded")}\``,
|
|
"",
|
|
"### Entrypoints",
|
|
...formatBullets(entrypoints),
|
|
"",
|
|
"### Repository Touchpoints",
|
|
...formatBullets(consumers),
|
|
"",
|
|
"### Environment",
|
|
...formatBullets(envVars),
|
|
"",
|
|
"### Contracts",
|
|
`- Request fields: ${requestFields.length > 0 ? requestFields.join(", ") : "none"}`,
|
|
`- Response fields: ${responseFields.length > 0 ? responseFields.join(", ") : "none"}`,
|
|
"",
|
|
"### Related Systems",
|
|
`- Upstreams: ${upstreams.length > 0 ? upstreams.join(", ") : "none"}`,
|
|
`- Downstreams: ${downstreams.length > 0 ? downstreams.join(", ") : "none"}`,
|
|
"",
|
|
"### Ownership",
|
|
...formatBullets(owners),
|
|
"",
|
|
"### Notes",
|
|
...notes,
|
|
"",
|
|
];
|
|
|
|
if (schemas.length > 0) {
|
|
lines.splice(lines.indexOf("### Related Systems"), 0, "### Schemas", ...formatBullets(schemas), "");
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderTouchpoint(touchpoint) {
|
|
const lines = [
|
|
`- \`${touchpoint.path}\` (${touchpoint.matchCount} matches)`,
|
|
];
|
|
|
|
for (const highlight of touchpoint.highlights) {
|
|
lines.push(` - L${highlight.lineNumber}: \`${quoteInline(highlight.text)}\``);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function buildMarkdown(registry, touchpoints, envVars, exportsList) {
|
|
const workflows = toList(registry?.workflows);
|
|
const documentedConsumers = new Set(
|
|
workflows.flatMap((workflow) => toList(workflow?.repository?.consumers))
|
|
);
|
|
const undocumentedTouchpoints = touchpoints.filter(
|
|
(touchpoint) => isPrimaryTouchpoint(touchpoint.path) && !documentedConsumers.has(touchpoint.path)
|
|
);
|
|
|
|
const sections = [
|
|
"# N8N Context",
|
|
"",
|
|
`Generated at: ${new Date().toISOString()}`,
|
|
"",
|
|
"This file is generated from `docs/integrations/n8n/registry.json` plus repository scanning.",
|
|
"It exists so external n8n workflows become committed, reviewable context for AI agents and GSD.",
|
|
"",
|
|
"## Workflow Inventory",
|
|
"",
|
|
];
|
|
|
|
if (workflows.length === 0) {
|
|
sections.push("No workflows documented in `docs/integrations/n8n/registry.json` yet.", "");
|
|
} else {
|
|
for (const workflow of workflows) {
|
|
sections.push(renderWorkflow(workflow));
|
|
}
|
|
}
|
|
|
|
sections.push("## Exported Workflow Files", "");
|
|
sections.push(...formatBullets(exportsList.map((fileName) => `docs/integrations/n8n/exports/${fileName}`)));
|
|
sections.push("");
|
|
|
|
sections.push("## Detected Repository Touchpoints", "");
|
|
if (touchpoints.length === 0) {
|
|
sections.push("No n8n or webhook references detected under `src/`.", "");
|
|
} else {
|
|
for (const touchpoint of touchpoints) {
|
|
sections.push(renderTouchpoint(touchpoint), "");
|
|
}
|
|
}
|
|
|
|
sections.push("## Environment Variables", "");
|
|
sections.push(...formatBullets(envVars), "");
|
|
|
|
sections.push("## Gaps To Fill", "");
|
|
if (undocumentedTouchpoints.length === 0) {
|
|
sections.push("- All detected repo touchpoints are mapped to documented workflows.", "");
|
|
} else {
|
|
sections.push(
|
|
"- These files mention n8n or webhook logic but are not mapped in `docs/integrations/n8n/registry.json`:"
|
|
);
|
|
sections.push(...formatBullets(undocumentedTouchpoints.map((item) => item.path)), "");
|
|
}
|
|
|
|
sections.push("## Maintenance Rules", "");
|
|
sections.push("- When an n8n workflow changes, update `docs/integrations/n8n/registry.json` in the same PR.");
|
|
sections.push("- If possible, export the workflow JSON into `docs/integrations/n8n/exports/` and reference it from the registry.");
|
|
sections.push("- Re-run `pnpm n8n:context` after every workflow, contract, or route change.");
|
|
sections.push("- Treat this file as generated output; edit the registry instead of editing this file directly.", "");
|
|
|
|
return sections.join("\n");
|
|
}
|
|
|
|
function writeOutput(outputPath, content) {
|
|
ensureDir(outputPath);
|
|
fs.writeFileSync(outputPath, content, "utf8");
|
|
}
|
|
|
|
function main() {
|
|
const registry = readJsonIfExists(REGISTRY_PATH, { workflows: [] });
|
|
const touchpoints = detectTouchpoints();
|
|
const envVars = collectEnvVars();
|
|
const exportsList = listExports();
|
|
const markdown = buildMarkdown(registry, touchpoints, envVars, exportsList);
|
|
|
|
writeOutput(DOC_OUTPUT_PATH, markdown);
|
|
|
|
if (fs.existsSync(path.dirname(PLANNING_OUTPUT_PATH))) {
|
|
writeOutput(PLANNING_OUTPUT_PATH, markdown);
|
|
}
|
|
|
|
console.log(`Generated ${path.relative(ROOT, DOC_OUTPUT_PATH)}`);
|
|
if (fs.existsSync(path.dirname(PLANNING_OUTPUT_PATH))) {
|
|
console.log(`Generated ${path.relative(ROOT, PLANNING_OUTPUT_PATH)}`);
|
|
}
|
|
}
|
|
|
|
main();
|