Files
chlova/orchestrator/src/autoext/artifact-writer.ts
T
Kantin-Petit d1255b926b feat: ArtifactWriter auto-extension (artefact + doc) (v0.25.0)
É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 <noreply@anthropic.com>
2026-06-23 06:32:49 +02:00

105 lines
3.4 KiB
TypeScript

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 };
}