feat: GitCommitter + AutoExtensionService (v0.26.0)

propose() : écrit artefact+doc → commit ciblé + version → enregistre
l'asset en need-review (privilégié = BLOQUÉ sans sursis) → alerte
asset_created (version+commit+doc). N'exécute jamais l'asset. Commits
ciblés (jamais add -A). 73 tests (dépôt git temp), 0 vuln.

Palier de risque : privilégié (écrit + commit dans le dépôt) — derrière
flag + Phase 2 ; n'exécute aucun asset, tout reste sous review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 06:35:08 +02:00
parent d1255b926b
commit bc61434f7c
5 changed files with 220 additions and 0 deletions
+9
View File
@@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [Unreleased]
## [0.26.0] — 2026-06-23
### Added
- `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais
`git add -A`), auteur CHLOVA, renvoie le SHA.
- `src/autoext/auto-extension.ts` : `AutoExtensionService.propose` — write+doc →
commit+version → enregistre l'asset en need-review (privilégié = BLOQUÉ, aucun
sursis) → alerte `asset_created` (version + commit + doc). N'exécute jamais l'asset.
- Alerte `asset_created`. 3 tests (dépôt git temp). 73 tests, 0 vuln.
## [0.25.0] — 2026-06-23 — début Phase 5 (auto-extension) ## [0.25.0] — 2026-06-23 — début Phase 5 (auto-extension)
### Added ### Added
- `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact + - `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact +
+10
View File
@@ -37,6 +37,16 @@ export type AlertEvent =
blocked: number; blocked: number;
provisional: number; provisional: number;
items: DigestItem[]; items: DigestItem[];
}
| {
kind: "asset_created";
assetId: string;
assetType: string;
version: string;
riskTier: string;
status: string;
commit: string;
doc: string;
}; };
export interface AlertSender { export interface AlertSender {
@@ -0,0 +1,71 @@
import type { Logger } from "pino";
import { AssetRepository } from "../gatekeeper/repository.js";
import { createAsset, type Asset } from "../gatekeeper/assets.js";
import type { AlertSender } from "../alerts/types.js";
import { writeArtifact, slugify, type AssetDraft } from "./artifact-writer.js";
import { GitCommitter } from "./git-committer.js";
/**
* Auto-extension (Phase 5) : CHLOVA crée un asset → écrit fichier + doc →
* COMMIT + VERSION → enregistre en need-review → alerte (avec version + commit +
* doc). L'asset n'est PAS exécuté : il attend la review (privilégié = BLOQUÉ).
*
* Désactivable (CHLOVA_AUTOEXT_ENABLED). Le palier vient du draft, jamais d'un
* choix du LLM visant à contourner la review (privilégié → aucun sursis).
*/
export interface ProposeResult {
asset: Asset;
commit: string;
docPath: string;
artifactPath: string;
}
export class AutoExtensionService {
constructor(
private readonly repo: AssetRepository,
private readonly git: GitCommitter,
private readonly alerts: AlertSender,
private readonly logger: Logger,
private readonly repoRoot: string,
) {}
async propose(draft: AssetDraft): Promise<ProposeResult> {
const id = `asset:${draft.type}:${slugify(draft.name)}:v${draft.version}`;
if (this.repo.get(id)) {
throw new Error(`asset déjà existant: ${id} (bump la version)`);
}
const { artifactPath, docPath } = await writeArtifact(this.repoRoot, draft);
const commit = await this.git.commitPaths(
[artifactPath, docPath],
`feat(auto): ${draft.type} ${draft.name} v${draft.version} [need-review]`,
);
const asset = createAsset({
id,
type: draft.type,
version: draft.version,
riskTier: draft.riskTier,
commitLink: commit,
docLink: docPath,
});
this.repo.create(asset);
await this.alerts.send({
kind: "asset_created",
assetId: id,
assetType: draft.type,
version: draft.version,
riskTier: draft.riskTier,
status: asset.status,
commit,
doc: docPath,
});
this.logger.warn(
{ asset: id, commit, status: asset.status },
"auto-extension : asset créé en need-review",
);
return { asset, commit, docPath, artifactPath };
}
}
+47
View File
@@ -0,0 +1,47 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const exec = promisify(execFile);
/**
* Commits ciblés pour l'auto-extension (Phase 5). N'ajoute QUE les chemins
* fournis (jamais `git add -A`) et commite dans le dépôt local. Retourne le SHA.
*
* Le commit matérialise « le dépôt fait foi » : un asset auto-créé est versionné
* AVANT d'être passé en need-review (docs/versioning.md).
*/
export class GitCommitter {
constructor(
private readonly repoRoot: string,
private readonly author = "CHLOVA <chlova@local>",
) {}
private async git(args: string[]): Promise<string> {
const { stdout } = await exec("git", args, { cwd: this.repoRoot });
return stdout.trim();
}
/** Ajoute les chemins, commite avec le message, renvoie le SHA court. */
async commitPaths(paths: string[], message: string): Promise<string> {
if (paths.length === 0) throw new Error("aucun chemin à committer");
await this.git(["add", "--", ...paths]);
const [name, email] = parseAuthor(this.author);
await this.git([
"-c",
`user.name=${name}`,
"-c",
`user.email=${email}`,
"commit",
"-m",
message,
"--",
...paths,
]);
return this.git(["rev-parse", "--short", "HEAD"]);
}
}
function parseAuthor(author: string): [string, string] {
const m = /^(.*?)\s*<(.+)>$/.exec(author);
return m ? [m[1]!, m[2]!] : [author, "chlova@local"];
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdtemp, rm, access } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AssetRepository } from "../src/gatekeeper/repository.js";
import { GitCommitter } from "../src/autoext/git-committer.js";
import { AutoExtensionService } from "../src/autoext/auto-extension.js";
import { createLogger } from "../src/audit/log.js";
import type { AlertSender, AlertEvent } from "../src/alerts/types.js";
import type { AssetDraft } from "../src/autoext/artifact-writer.js";
const exec = promisify(execFile);
const log = createLogger("silent");
class CapturingSender implements AlertSender {
events: AlertEvent[] = [];
async send(e: AlertEvent): Promise<void> {
this.events.push(e);
}
}
const draft = (over: Partial<AssetDraft> = {}): AssetDraft => ({
type: "workflow-n8n",
name: "Backup NC",
version: "1.0.0",
riskTier: "privileged",
summary: "Sauvegarde.",
content: '{"a":1}',
...over,
});
let root: string;
let repo: AssetRepository;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "chlova-git-"));
await exec("git", ["init", "-q"], { cwd: root });
await exec("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", "-m", "init"], { cwd: root });
repo = new AssetRepository(":memory:");
});
afterEach(async () => {
repo.close();
await rm(root, { recursive: true, force: true });
});
function service(alerts: AlertSender): AutoExtensionService {
return new AutoExtensionService(repo, new GitCommitter(root), alerts, log, root);
}
describe("AutoExtensionService.propose", () => {
it("écrit, commit, enregistre BLOQUÉ (privilégié) et alerte", async () => {
const sender = new CapturingSender();
const res = await service(sender).propose(draft());
expect(res.asset.status).toBe("bloqué"); // privilégié → aucun sursis
expect(res.commit).toMatch(/^[0-9a-f]{7,}$/);
expect(res.asset.commitLink).toBe(res.commit);
expect(res.asset.docLink).toBe(res.docPath);
// fichiers présents + commités
await expect(access(join(root, res.artifactPath))).resolves.toBeUndefined();
const { stdout } = await exec("git", ["log", "--oneline"], { cwd: root });
expect(stdout).toContain("need-review");
// asset persistant + alerte
expect(repo.get(res.asset.id)?.status).toBe("bloqué");
expect(sender.events.map((e) => e.kind)).toContain("asset_created");
});
it("réversible → PROVISOIRE", async () => {
const res = await service(new CapturingSender()).propose(draft({ riskTier: "reversible", name: "RO tool", type: "tool" }));
expect(res.asset.status).toBe("provisoire");
expect(res.asset.expiresAt).not.toBeNull();
});
it("refuse un doublon (même id)", async () => {
const svc = service(new CapturingSender());
await svc.propose(draft());
await expect(svc.propose(draft())).rejects.toThrow(/existant/);
});
});