From d1255b926b0552abc7e73473cdce7c1705f82094 Mon Sep 17 00:00:00 2001 From: Kantin-Petit Date: Tue, 23 Jun 2026 06:32:49 +0200 Subject: [PATCH] feat: ArtifactWriter auto-extension (artefact + doc) (v0.25.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Écrit un asset auto-créé (workflow/outil) + sa doc générée depuis le gabarit, chemins sanitizés (anti-traversée), semver validé. 5 tests (dépôt temp). Le dépôt fait foi avant tout passage en need-review. Palier de risque : reversible (écriture fichier locale, sans commit ni exécution). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 ++ orchestrator/src/autoext/artifact-writer.ts | 104 ++++++++++++++++++++ orchestrator/test/artifact-writer.test.ts | 60 +++++++++++ 3 files changed, 171 insertions(+) create mode 100644 orchestrator/src/autoext/artifact-writer.ts create mode 100644 orchestrator/test/artifact-writer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 61add83..d306502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.25.0] — 2026-06-23 — début Phase 5 (auto-extension) +### Added +- `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact + + doc générée depuis le gabarit), chemins sanitizés (anti-traversée), version + semver validée. `slugify`, `artifactRelPath`, `docRelPath`, `renderDoc`, + `writeArtifact`. 5 tests (dépôt temp). + ## [0.24.0] — 2026-06-23 — fin Phase 4 (UI v1 : chat + review) ### Added - UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente, diff --git a/orchestrator/src/autoext/artifact-writer.ts b/orchestrator/src/autoext/artifact-writer.ts new file mode 100644 index 0000000..ab33afd --- /dev/null +++ b/orchestrator/src/autoext/artifact-writer.ts @@ -0,0 +1,104 @@ +import { writeFile, mkdir } from "node:fs/promises"; +import { join, dirname, isAbsolute, normalize } from "node:path"; +import type { RiskTier } from "../audit/log.js"; + +/** + * Écriture des artefacts auto-créés par CHLOVA (Phase 5). + * + * Le dépôt fait foi : un asset auto-créé est écrit en fichier versionné + une doc + * générée depuis le gabarit AVANT d'être enregistré en need-review (voir + * docs/versioning.md, docs/need-review.md). Chemins sanitizés (anti-traversée). + */ + +export type AutoAssetType = "workflow-n8n" | "tool"; + +export interface AssetDraft { + type: AutoAssetType; + name: string; + version: string; // semver + riskTier: RiskTier; + summary: string; + /** Contenu : JSON du workflow n8n, ou définition d'outil. */ + content: string; +} + +const SEMVER = /^\d+\.\d+\.\d+$/; + +export function slugify(name: string): string { + const slug = name + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (!slug) throw new Error("nom d'asset invalide (slug vide)"); + return slug; +} + +export function artifactRelPath(draft: AssetDraft): string { + const slug = slugify(draft.name); + if (draft.type === "workflow-n8n") return `workflows-n8n/${slug}.v${draft.version}.json`; + return `tools/${slug}.v${draft.version}.json`; +} + +export function docRelPath(draft: AssetDraft): string { + return `docs/assets/${draft.type}-${slugify(draft.name)}.md`; +} + +/** Génère la doc d'asset depuis le gabarit (docs/asset-template.md). */ +export function renderDoc(draft: AssetDraft, artifactPath: string, createdAt: string): string { + return `## ${draft.name} + +- **Type** : \`${draft.type}\` +- **Version** : \`v${draft.version}\` +- **Palier de risque** : \`${draft.riskTier}\` +- **Statut (need-review)** : \`${draft.riskTier === "reversible" ? "provisoire" : "bloqué"}\` +- **Créé le** : ${createdAt} (auto-extension CHLOVA) +- **Fichier** : \`${artifactPath}\` + +### Rôle +${draft.summary} + +### Sécurité / rollback +Asset auto-créé par CHLOVA, en attente de review. Palier \`${draft.riskTier}\` : +${draft.riskTier === "privileged" + ? "BLOQUÉ jusqu'à validation humaine (aucun sursis)." + : "PROVISOIRE, sursis de 7 jours."} +Rollback : \`git revert\` du commit, ou refus via la review (/refuse). +`; +} + +function assertSafe(repoRoot: string, rel: string): void { + if (isAbsolute(rel) || normalize(rel).startsWith("..")) { + throw new Error(`chemin non autorisé: ${rel}`); + } +} + +/** + * Écrit l'artefact + sa doc dans le dépôt. Retourne les chemins relatifs (servent + * de doc_link et de cible de commit). Ne commite PAS (cf. GitCommitter). + */ +export async function writeArtifact( + repoRoot: string, + draft: AssetDraft, +): Promise<{ artifactPath: string; docPath: string }> { + if (!SEMVER.test(draft.version)) throw new Error(`version semver invalide: ${draft.version}`); + const artifactPath = artifactRelPath(draft); + const docPath = docRelPath(draft); + assertSafe(repoRoot, artifactPath); + assertSafe(repoRoot, docPath); + + const createdAt = new Date().toISOString().slice(0, 10); + const doc = renderDoc(draft, artifactPath, createdAt); + + for (const [rel, body] of [ + [artifactPath, draft.content], + [docPath, doc], + ] as const) { + const abs = join(repoRoot, rel); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, body, "utf8"); + } + + return { artifactPath, docPath }; +} diff --git a/orchestrator/test/artifact-writer.test.ts b/orchestrator/test/artifact-writer.test.ts new file mode 100644 index 0000000..312357a --- /dev/null +++ b/orchestrator/test/artifact-writer.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + slugify, + artifactRelPath, + docRelPath, + writeArtifact, + type AssetDraft, +} from "../src/autoext/artifact-writer.js"; + +const draft = (over: Partial = {}): AssetDraft => ({ + type: "workflow-n8n", + name: "Backup Nextcloud", + version: "1.0.0", + riskTier: "privileged", + summary: "Sauvegarde quotidienne.", + content: '{"name":"x"}', + ...over, +}); + +let root: string; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "chlova-")); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("slugify & chemins", () => { + it("slugifie un nom accentué", () => { + expect(slugify("Sauvegarde Été #2")).toBe("sauvegarde-ete-2"); + }); + + it("refuse un nom vide", () => { + expect(() => slugify(" ")).toThrow(); + }); + + it("chemins par type", () => { + expect(artifactRelPath(draft())).toBe("workflows-n8n/backup-nextcloud.v1.0.0.json"); + expect(artifactRelPath(draft({ type: "tool" }))).toBe("tools/backup-nextcloud.v1.0.0.json"); + expect(docRelPath(draft())).toBe("docs/assets/workflow-n8n-backup-nextcloud.md"); + }); +}); + +describe("writeArtifact", () => { + it("écrit artefact + doc", async () => { + const { artifactPath, docPath } = await writeArtifact(root, draft()); + expect(await readFile(join(root, artifactPath), "utf8")).toBe('{"name":"x"}'); + const doc = await readFile(join(root, docPath), "utf8"); + expect(doc).toContain("Backup Nextcloud"); + expect(doc).toContain("v1.0.0"); + expect(doc).toContain("bloqué"); // privilégié → bloqué + }); + + it("refuse une version non semver", async () => { + await expect(writeArtifact(root, draft({ version: "1.0" }))).rejects.toThrow(/semver/); + }); +});