docs: add n8n integration context
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user