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]
|
## [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 +
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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