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:
@@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
||||
|
||||
## [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)
|
||||
### Added
|
||||
- `src/autoext/artifact-writer.ts` : écriture d'un asset auto-créé (artefact +
|
||||
|
||||
@@ -37,6 +37,16 @@ export type AlertEvent =
|
||||
blocked: number;
|
||||
provisional: number;
|
||||
items: DigestItem[];
|
||||
}
|
||||
| {
|
||||
kind: "asset_created";
|
||||
assetId: string;
|
||||
assetType: string;
|
||||
version: string;
|
||||
riskTier: string;
|
||||
status: string;
|
||||
commit: string;
|
||||
doc: string;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user