docs: add n8n integration context

This commit is contained in:
2026-04-20 19:05:05 +08:00
parent ab3f8fecc3
commit 804fd756e7
16 changed files with 2145 additions and 2 deletions
+329
View File
@@ -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();