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>
This commit is contained in:
@@ -6,6 +6,13 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
## [0.24.0] — 2026-06-23 — fin Phase 4 (UI v1 : chat + review)
|
||||||
### Added
|
### Added
|
||||||
- UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente,
|
- UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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> = {}): 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user