@purix/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/cli/commands/auth.d.ts +3 -0
- package/dist/cli/commands/auth.d.ts.map +1 -0
- package/dist/cli/commands/auth.js +130 -0
- package/dist/cli/commands/auth.js.map +1 -0
- package/dist/cli/commands/backup.d.ts +3 -0
- package/dist/cli/commands/backup.d.ts.map +1 -0
- package/dist/cli/commands/backup.js +54 -0
- package/dist/cli/commands/backup.js.map +1 -0
- package/dist/cli/commands/config.d.ts +3 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +35 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/connect.d.ts +16 -0
- package/dist/cli/commands/connect.d.ts.map +1 -0
- package/dist/cli/commands/connect.js +45 -0
- package/dist/cli/commands/connect.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +3 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +58 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/index.d.ts +3 -0
- package/dist/cli/commands/index.d.ts.map +1 -0
- package/dist/cli/commands/index.js +35 -0
- package/dist/cli/commands/index.js.map +1 -0
- package/dist/cli/commands/lang.d.ts +3 -0
- package/dist/cli/commands/lang.d.ts.map +1 -0
- package/dist/cli/commands/lang.js +187 -0
- package/dist/cli/commands/lang.js.map +1 -0
- package/dist/cli/commands/lifecycle.d.ts +3 -0
- package/dist/cli/commands/lifecycle.d.ts.map +1 -0
- package/dist/cli/commands/lifecycle.js +782 -0
- package/dist/cli/commands/lifecycle.js.map +1 -0
- package/dist/cli/commands/mcp.d.ts +3 -0
- package/dist/cli/commands/mcp.d.ts.map +1 -0
- package/dist/cli/commands/mcp.js +75 -0
- package/dist/cli/commands/mcp.js.map +1 -0
- package/dist/cli/commands/mcp_serve.d.ts +3 -0
- package/dist/cli/commands/mcp_serve.d.ts.map +1 -0
- package/dist/cli/commands/mcp_serve.js +16 -0
- package/dist/cli/commands/mcp_serve.js.map +1 -0
- package/dist/cli/commands/memory.d.ts +3 -0
- package/dist/cli/commands/memory.d.ts.map +1 -0
- package/dist/cli/commands/memory.js +12 -0
- package/dist/cli/commands/memory.js.map +1 -0
- package/dist/cli/commands/migrations.d.ts +3 -0
- package/dist/cli/commands/migrations.d.ts.map +1 -0
- package/dist/cli/commands/migrations.js +98 -0
- package/dist/cli/commands/migrations.js.map +1 -0
- package/dist/cli/commands/observability.d.ts +3 -0
- package/dist/cli/commands/observability.d.ts.map +1 -0
- package/dist/cli/commands/observability.js +141 -0
- package/dist/cli/commands/observability.js.map +1 -0
- package/dist/cli/commands/provider.d.ts +3 -0
- package/dist/cli/commands/provider.d.ts.map +1 -0
- package/dist/cli/commands/provider.js +82 -0
- package/dist/cli/commands/provider.js.map +1 -0
- package/dist/cli/commands/security.d.ts +3 -0
- package/dist/cli/commands/security.d.ts.map +1 -0
- package/dist/cli/commands/security.js +27 -0
- package/dist/cli/commands/security.js.map +1 -0
- package/dist/cli/commands/tier.d.ts +3 -0
- package/dist/cli/commands/tier.d.ts.map +1 -0
- package/dist/cli/commands/tier.js +27 -0
- package/dist/cli/commands/tier.js.map +1 -0
- package/dist/cli/commands/tools.d.ts +3 -0
- package/dist/cli/commands/tools.d.ts.map +1 -0
- package/dist/cli/commands/tools.js +18 -0
- package/dist/cli/commands/tools.js.map +1 -0
- package/dist/cli/output.d.ts +5 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +22 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/savings_output.d.ts +9 -0
- package/dist/cli/savings_output.d.ts.map +1 -0
- package/dist/cli/savings_output.js +62 -0
- package/dist/cli/savings_output.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +155 -0
- package/dist/cli.js.map +1 -0
- package/dist/telemetry/log.d.ts +4 -0
- package/dist/telemetry/log.d.ts.map +1 -0
- package/dist/telemetry/log.js +92 -0
- package/dist/telemetry/log.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
import { confirmGated } from "@purix/core/cli-io/gated-confirm";
|
|
2
|
+
import { readManifest, writeManifestWithLimitCheck, linkComponents, createPendingOperation, completeModification, commitManifestWithRetry, deletePendingOperation, deleteManifestEntry, removeDependent, removeDependencyReference, } from "@purix/core/manifest/store";
|
|
3
|
+
import { existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { buildManifestEntry, writeScaffold } from "@purix/core/entrypoints/scaffold";
|
|
6
|
+
import { snapshotSavings, printRunSummary } from "../savings_output.js";
|
|
7
|
+
import { classifyGreenfield, refineIntent, classifyModification, classifyDiff, scanDiffForInjectionAttempts, scanFilesForInjectionAttempts } from "@purix/core/llm/classify";
|
|
8
|
+
import { scanForInjectionAttempts } from "@purix/core/llm/injection";
|
|
9
|
+
import { checkDrift } from "@purix/core/state/drift";
|
|
10
|
+
import { applyModificationFiles, rollbackModification } from "@purix/core/entrypoints/modify";
|
|
11
|
+
import { computeSyncHash } from "@purix/core/state/hash";
|
|
12
|
+
import { compilePatch } from "@purix/core/verify/compile";
|
|
13
|
+
import { verifyInSandbox } from "@purix/core/sandbox/sandbox";
|
|
14
|
+
import { getDependents, reVerifyDependents } from "@purix/core/verify/impact";
|
|
15
|
+
import { runSelfHealingLoop } from "@purix/core/recovery/heal";
|
|
16
|
+
import { runEscalation } from "@purix/core/recovery/escalate";
|
|
17
|
+
import { auditSecurityPatterns } from "@purix/core/security/security_audit";
|
|
18
|
+
import { suggestTools, formatSuggestions } from "@purix/core/tools/matchmaker";
|
|
19
|
+
import { buildMigrationPlan, stageMigration } from "@purix/core/state/migration";
|
|
20
|
+
import { assertAuthorizedToApprove } from "@purix/core/security/auth";
|
|
21
|
+
import { recordEvent } from "@purix/core/manifest/events";
|
|
22
|
+
import { readRelevantMemory, readGlobalMemory, formatMemoryLines } from "@purix/core/manifest/memory";
|
|
23
|
+
import { computeRequestKey, findPriorCommit, recordRequestCommit } from "@purix/core/state/idempotency";
|
|
24
|
+
import { ingestDiffFromFile } from "@purix/core/entrypoints/ingest";
|
|
25
|
+
import { resolveLanguage, getLanguageProvider } from "@purix/core/language/registry";
|
|
26
|
+
import { evaluateTrustGate, hasTestCoverage, checkDeterministicOverrideFloor, loadDofPatterns } from "@purix/core/gates/trustgate";
|
|
27
|
+
import { setBudgetOverride } from "@purix/core/llm/budget";
|
|
28
|
+
import { setSecurityOverride } from "@purix/core/gates/security_gate";
|
|
29
|
+
import { recordOverrideAudit } from "@purix/core/security/override_audit";
|
|
30
|
+
export function registerLifecycleCommands(program) {
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// create (§3.3 Greenfield)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
program
|
|
35
|
+
.command("create <n>")
|
|
36
|
+
.description("Scaffold a new component (Greenfield, Instruction Path)")
|
|
37
|
+
.action(async (name) => {
|
|
38
|
+
const savingsBefore = snapshotSavings();
|
|
39
|
+
if (readManifest(name)) {
|
|
40
|
+
console.error(`Component "${name}" already exists in the manifest. Use "purix modify" instead.`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.log(`Planning "${name}"...`);
|
|
45
|
+
const plan = await classifyGreenfield(name);
|
|
46
|
+
console.log(`\nProposed component: ${plan.component_id} (${plan.component_type})`);
|
|
47
|
+
for (const f of plan.files)
|
|
48
|
+
console.log(` ${f.path} ā ${f.purpose}`);
|
|
49
|
+
if (plan.depends_on.length > 0)
|
|
50
|
+
console.log(` depends_on: ${plan.depends_on.join(", ")}`);
|
|
51
|
+
// §11.1: Tool Matchmaker reads Repository Memory too. A brand new
|
|
52
|
+
// component has no history of its own yet, so this checks the
|
|
53
|
+
// repo-wide conventions log instead ("we decided against X" notes).
|
|
54
|
+
const globalMemory = readGlobalMemory();
|
|
55
|
+
if (globalMemory.length > 0) {
|
|
56
|
+
console.log(`\n Repository Memory (§11.1) ā keep these in mind before picking a package:`);
|
|
57
|
+
for (const line of formatMemoryLines(globalMemory))
|
|
58
|
+
console.log(` ${line}`);
|
|
59
|
+
}
|
|
60
|
+
// §12.1 Tool Matchmaker: add_component is exactly the trigger case
|
|
61
|
+
// this describes ā check for a vetted, maintained npm package
|
|
62
|
+
// before committing to hand-rolled scaffolding. Advisory only: this
|
|
63
|
+
// never blocks and never touches package.json itself.
|
|
64
|
+
const matchQuery = `${name} ${plan.files.map((f) => f.purpose).join(" ")}`.trim();
|
|
65
|
+
const toolSuggestions = await suggestTools(matchQuery);
|
|
66
|
+
if (toolSuggestions.length > 0) {
|
|
67
|
+
console.log(`\n Vetted package suggestion(s) (§12.1, advisory only ā not applied):`);
|
|
68
|
+
console.log(formatSuggestions(toolSuggestions));
|
|
69
|
+
}
|
|
70
|
+
// BUG FIX: assertAuthorizedToApprove is async (it may prompt to
|
|
71
|
+
// bootstrap the sole-approver file) ā this was previously called
|
|
72
|
+
// without await, meaning a declined bootstrap couldn't reliably
|
|
73
|
+
// block the write it's supposed to gate. Same fix applied below in
|
|
74
|
+
// "modify" and in escalate.ts.
|
|
75
|
+
try {
|
|
76
|
+
await assertAuthorizedToApprove();
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error(`\nš ${err instanceof Error ? err.message : err}`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const proceed = await confirmGated(`\nWrite these files and register the component?`, "create_write", name);
|
|
84
|
+
if (!proceed) {
|
|
85
|
+
console.log("Cancelled ā nothing written.");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
await writeScaffold(plan, process.cwd());
|
|
89
|
+
const entry = buildManifestEntry(plan);
|
|
90
|
+
entry.last_synced_hash = computeSyncHash(plan.files.map((f) => ({ path: f.path, content: f.starter_content })));
|
|
91
|
+
writeManifestWithLimitCheck(entry);
|
|
92
|
+
if (plan.depends_on.length > 0) {
|
|
93
|
+
const link = linkComponents(entry.component_id, plan.depends_on);
|
|
94
|
+
if (link.skippedNotFound.length > 0) {
|
|
95
|
+
console.log(` ā depends_on referenced unknown component(s), not linked: ${link.skippedNotFound.join(", ")}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
console.log(`\nā
"${entry.component_id}" created (v${entry.current_version}).`);
|
|
99
|
+
printRunSummary(savingsBefore);
|
|
100
|
+
});
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// modify (§3.1 Instruction Path ā the full Brownfield node loop)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
program
|
|
105
|
+
.command("modify <componentId> <instruction>")
|
|
106
|
+
.description("Modify an existing component via the Instruction Path ( Brownfield )")
|
|
107
|
+
.option("--override <reason>", "Override gate stops with a reason")
|
|
108
|
+
.action(async (componentId, instruction, options) => {
|
|
109
|
+
if (options.override !== undefined) {
|
|
110
|
+
setBudgetOverride(options.override);
|
|
111
|
+
setSecurityOverride(options.override);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const savingsBefore = snapshotSavings();
|
|
115
|
+
// Node 2: State Resolver
|
|
116
|
+
const entry = readManifest(componentId);
|
|
117
|
+
if (!entry) {
|
|
118
|
+
console.error(`No manifest entry for "${componentId}". Run "purix create" first, or check the id.`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Node 2a: Drift Detector ā shared convergence point for both paths (§3.2)
|
|
123
|
+
const drift = await checkDrift(entry, process.cwd());
|
|
124
|
+
if (drift.drifted) {
|
|
125
|
+
console.error(`\nā "${componentId}" has drifted from its last known state (§7.2).\n` +
|
|
126
|
+
`Someone edited the files outside Purix. Run "purix accept-drift ${componentId}" first, ` +
|
|
127
|
+
`then retry this modify.`);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const originalFiles = drift.liveFiles;
|
|
132
|
+
if (originalFiles.length === 0) {
|
|
133
|
+
console.error(`"${componentId}" has no readable files on disk ā nothing to modify.`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Idempotency key, checked before Node 1 spends anything. Same
|
|
138
|
+
// component + same raw instruction + same file state that already
|
|
139
|
+
// committed once is a no-op repeat, not a second edit.
|
|
140
|
+
const requestKey = computeRequestKey(componentId, instruction, drift.liveHash);
|
|
141
|
+
const priorCommit = findPriorCommit(requestKey);
|
|
142
|
+
if (priorCommit) {
|
|
143
|
+
console.log(`\n This exact request already committed as v${priorCommit.resultingVersion} ā nothing to do (idempotency key match).`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
recordEvent("request", { component_id: componentId, detail: { instruction } });
|
|
147
|
+
// §E-DOF: Deterministic Override Floor. Checked here, ahead of both
|
|
148
|
+
// Node 1's and Node 3b's LLM calls, purely deterministic ā no
|
|
149
|
+
// confidence, no LLM, path matching only. Honest note on scope: this
|
|
150
|
+
// does NOT skip calling the classifier (compilePatch below still
|
|
151
|
+
// needs a real verdict to produce edits from ā there's no way to
|
|
152
|
+
// patch a file without deciding what the patch is). What it DOES
|
|
153
|
+
// guarantee is that a hit here forces human_confirm at the TrustGate
|
|
154
|
+
// decision further down, unconditionally, regardless of whatever
|
|
155
|
+
// confidence the classifier reports. Logged now so you know before
|
|
156
|
+
// spending any LLM cost that this request will need your sign-off
|
|
157
|
+
// no matter what comes back.
|
|
158
|
+
const dofPatterns = loadDofPatterns(process.cwd());
|
|
159
|
+
const dof = checkDeterministicOverrideFloor(entry.files, dofPatterns);
|
|
160
|
+
if (dof.hit) {
|
|
161
|
+
console.log(`\n š Deterministic Override Floor hit: "${dof.matchedPath}" matches protected pattern "${dof.matchedPattern}" (§E-DOF).\n` +
|
|
162
|
+
` This will require your explicit confirmation regardless of classifier confidence.`);
|
|
163
|
+
}
|
|
164
|
+
// §9.3 pre-LLM pass on the raw developer instruction ā same
|
|
165
|
+
// discipline the diff-ingest path already applies to diff content,
|
|
166
|
+
// now applied before the instruction ever reaches Node 1
|
|
167
|
+
// (refineIntent) or Node 3b (classifyModification), neither of
|
|
168
|
+
// which previously had any local scan on this string at all.
|
|
169
|
+
const instructionInjectionHits = scanForInjectionAttempts(instruction);
|
|
170
|
+
if (instructionInjectionHits.length > 0) {
|
|
171
|
+
console.log(`\nā Instruction-like text found inside the developer instruction itself (§9.3):`);
|
|
172
|
+
for (const h of instructionInjectionHits)
|
|
173
|
+
console.log(` "${h}"`);
|
|
174
|
+
const proceedWithInstruction = await confirmGated(`This could be an attempt to redirect the classifier via the instruction text. Send it to the classifier anyway?`, "injection_risk_proceed", componentId);
|
|
175
|
+
if (!proceedWithInstruction) {
|
|
176
|
+
console.error(`\nš Aborted after suspicious content was flagged in the instruction.`);
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// §9.3 pass over the component's EXISTING file content. The
|
|
182
|
+
// instruction scan above only covers what the developer typed ā
|
|
183
|
+
// originalFiles gets pasted into the refineIntent and
|
|
184
|
+
// classifyModification prompts too (filesBlock), and until now
|
|
185
|
+
// nothing scanned that content before it reached the model.
|
|
186
|
+
const fileInjectionFindings = scanFilesForInjectionAttempts(originalFiles);
|
|
187
|
+
if (fileInjectionFindings.length > 0) {
|
|
188
|
+
console.log(`\nā Instruction-like text found inside "${componentId}"'s existing file content (§9.3):`);
|
|
189
|
+
for (const f of fileInjectionFindings) {
|
|
190
|
+
for (const h of f.hits)
|
|
191
|
+
console.log(` ${f.path}: "${h}"`);
|
|
192
|
+
}
|
|
193
|
+
const proceedWithFiles = await confirmGated(`This could be an attempt to redirect the classifier via content already in the component's files. Send it to the classifier anyway?`, "injection_risk_proceed", componentId);
|
|
194
|
+
if (!proceedWithFiles) {
|
|
195
|
+
console.error(`\nš Aborted after suspicious content was flagged in existing file content.`);
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Node 1: Intake / Intent Refinement
|
|
201
|
+
console.log(`Refining intent...`);
|
|
202
|
+
const memory = readRelevantMemory(componentId);
|
|
203
|
+
const refined = await refineIntent(componentId, instruction, originalFiles, formatMemoryLines(memory));
|
|
204
|
+
console.log(`\nInterpreted as: "${refined.explicit_instruction}"`);
|
|
205
|
+
if (refined.assumptions.length > 0) {
|
|
206
|
+
console.log(`Assumptions made:`);
|
|
207
|
+
for (const a of refined.assumptions)
|
|
208
|
+
console.log(` - ${a}`);
|
|
209
|
+
}
|
|
210
|
+
const intentOk = await confirmGated(`\nProceed with this interpretation?`, "intent_refinement", componentId);
|
|
211
|
+
if (!intentOk) {
|
|
212
|
+
console.log("Cancelled ā nothing touched.");
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// Node 3b: Change Classifier (intent-classify mode)
|
|
216
|
+
console.log(`\nClassifying...`);
|
|
217
|
+
const verdict = await classifyModification(componentId, refined.explicit_instruction, originalFiles);
|
|
218
|
+
console.log(` operation: ${verdict.operation}`);
|
|
219
|
+
console.log(` contract_changing: ${verdict.contract_changing}`);
|
|
220
|
+
console.log(` confidence: ${verdict.confidence.toFixed(2)}`);
|
|
221
|
+
console.log(` reasoning: ${verdict.reasoning}`);
|
|
222
|
+
if (verdict.suspicious_injected_instruction) {
|
|
223
|
+
console.log(` ā Classifier self-reported suspicious instruction-like content.`);
|
|
224
|
+
}
|
|
225
|
+
recordEvent("classification", {
|
|
226
|
+
component_id: componentId,
|
|
227
|
+
operation: verdict.operation,
|
|
228
|
+
detail: {
|
|
229
|
+
confidence: verdict.confidence,
|
|
230
|
+
contract_changing: verdict.contract_changing,
|
|
231
|
+
suspicious_injected_instruction: verdict.suspicious_injected_instruction,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
// Node 3a: Impact Analysis / Advisory Audit (never blocks)
|
|
235
|
+
const secFindings = auditSecurityPatterns(originalFiles);
|
|
236
|
+
if (secFindings.length > 0) {
|
|
237
|
+
console.log(`\n Advisory, not applied ā ${secFindings.length} pattern finding(s):`);
|
|
238
|
+
for (const f of secFindings)
|
|
239
|
+
console.log(` ${f.path}:${f.line} [${f.category}] ${f.message}`);
|
|
240
|
+
}
|
|
241
|
+
let dependents = getDependents(entry);
|
|
242
|
+
if (verdict.contract_changing && dependents.length > 0) {
|
|
243
|
+
console.log(`\n This is contract-changing ā ${dependents.length} dependent(s) will be re-verified after commit.`);
|
|
244
|
+
}
|
|
245
|
+
// Node 4: Patch Compiler
|
|
246
|
+
const compiled = compilePatch(verdict, originalFiles);
|
|
247
|
+
let finalFiles;
|
|
248
|
+
let fromEscalation = false;
|
|
249
|
+
if (!compiled.ok) {
|
|
250
|
+
// No deterministic transform for this operation at all ā genuine
|
|
251
|
+
// capability gap, straight to §6.3 rather than pretending a local
|
|
252
|
+
// retry could ever succeed.
|
|
253
|
+
const compileReason = compiled.reason;
|
|
254
|
+
console.log(`\n No deterministic transform available: ${compileReason}`);
|
|
255
|
+
const esc = await runEscalation(componentId, verdict.operation, refined.explicit_instruction, originalFiles, compileReason, process.cwd());
|
|
256
|
+
if (!esc.ok) {
|
|
257
|
+
console.error(`\nš Escalation failed: ${esc.reason}`);
|
|
258
|
+
process.exitCode = 1;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
finalFiles = esc.files.map((f) => ({ path: f.path, new_content: f.new_content }));
|
|
262
|
+
fromEscalation = true;
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const candidateFiles = originalFiles.map((f) => {
|
|
266
|
+
const changed = compiled.files.find((c) => c.path === f.path);
|
|
267
|
+
return { path: f.path, new_content: changed ? changed.new_content : f.content };
|
|
268
|
+
});
|
|
269
|
+
const verification = verifyInSandbox(componentId, candidateFiles, process.cwd());
|
|
270
|
+
if (verification.status === "pass") {
|
|
271
|
+
finalFiles = candidateFiles;
|
|
272
|
+
recordEvent("verification_pass", { component_id: componentId, operation: verdict.operation, detail: { stage: "direct_patch" } });
|
|
273
|
+
recordEvent("idiom_findings", { component_id: componentId, operation: verdict.operation, detail: { count: verification.idiomFindings.length } });
|
|
274
|
+
if (verification.idiomFindings.length > 0) {
|
|
275
|
+
console.log(`\n Idiom findings (soft, non-blocking):`);
|
|
276
|
+
for (const f of verification.idiomFindings)
|
|
277
|
+
console.log(` ${f.path}:${f.line} [${f.rule}] ${f.message}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
// Node 6: first failure -> capped self-healing (§6.1).
|
|
282
|
+
console.log(`\n ā Verification failed: ${verification.reason}`);
|
|
283
|
+
recordEvent("verification_failure", {
|
|
284
|
+
component_id: componentId,
|
|
285
|
+
operation: verdict.operation,
|
|
286
|
+
detail: { stage: "direct_patch", reason: verification.reason },
|
|
287
|
+
});
|
|
288
|
+
const healed = await runSelfHealingLoop(componentId, verdict.operation, originalFiles, compiled.files, verification.reason, process.cwd());
|
|
289
|
+
if (healed.ok) {
|
|
290
|
+
finalFiles = healed.files.map((f) => ({ path: f.path, new_content: f.new_content }));
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
// Second consecutive failure (self-healing exhausted) -> §6.3.
|
|
294
|
+
const lastReason = healed.attempts[healed.attempts.length - 1]?.reason ?? "self-healing exhausted";
|
|
295
|
+
const esc = await runEscalation(componentId, verdict.operation, refined.explicit_instruction, originalFiles, lastReason, process.cwd());
|
|
296
|
+
if (!esc.ok) {
|
|
297
|
+
console.error(`\nš Escalation failed: ${esc.reason}`);
|
|
298
|
+
process.exitCode = 1;
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
finalFiles = esc.files.map((f) => ({ path: f.path, new_content: f.new_content }));
|
|
302
|
+
fromEscalation = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// Node 3c: Test-Integrity Check (§6.4) ā deterministic, runs before TrustGate.
|
|
307
|
+
const changedFiles = finalFiles.filter((f) => {
|
|
308
|
+
const orig = originalFiles.find((o) => o.path === f.path);
|
|
309
|
+
return orig ? orig.content !== f.new_content : true;
|
|
310
|
+
});
|
|
311
|
+
const lang = resolveLanguage(componentId, process.cwd());
|
|
312
|
+
const provider = getLanguageProvider(lang);
|
|
313
|
+
const testFilesBefore = originalFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test."));
|
|
314
|
+
const testFilesAfter = finalFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test.")).map((f) => ({ path: f.path, content: f.new_content }));
|
|
315
|
+
const testIntegrity = provider?.testIntegrityChecker
|
|
316
|
+
? provider.testIntegrityChecker.check(testFilesBefore, testFilesAfter)
|
|
317
|
+
: { flagged: true, findings: [{ path: "unknown", reason: `no test integrity checker registered for language ${lang} ā failing closed` }] };
|
|
318
|
+
if (testIntegrity.flagged) {
|
|
319
|
+
recordEvent("test_integrity_flag", { component_id: componentId, operation: verdict.operation, detail: { findings: testIntegrity.findings } });
|
|
320
|
+
console.log(`\n ā Test-integrity check (§6.4): ${testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ")}`);
|
|
321
|
+
}
|
|
322
|
+
const hasCoverage = hasTestCoverage(changedFiles, process.cwd());
|
|
323
|
+
if (!hasCoverage) {
|
|
324
|
+
recordEvent("coverage_gate_flag", { component_id: componentId, operation: verdict.operation, detail: { changed_files: changedFiles.map((f) => f.path) } });
|
|
325
|
+
}
|
|
326
|
+
recordEvent("gate_evaluation", { component_id: componentId, operation: verdict.operation, detail: { path: "instruction" } });
|
|
327
|
+
// TrustGate (§6.2): three independent gates feeding one decision.
|
|
328
|
+
let decision = evaluateTrustGate({
|
|
329
|
+
confidence: verdict.confidence,
|
|
330
|
+
contractChanging: verdict.contract_changing,
|
|
331
|
+
hasCoverage,
|
|
332
|
+
testIntegrity: {
|
|
333
|
+
flagged: testIntegrity.flagged,
|
|
334
|
+
reason: testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ") || undefined,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
// An escalation-authored fix never auto-commits, regardless of the
|
|
338
|
+
// ORIGINAL classifier confidence ā that confidence described the
|
|
339
|
+
// classifier's own edits, not the content escalation actually
|
|
340
|
+
// produced after those edits failed. §7.5's checkpoint policy
|
|
341
|
+
// treats this the same as any first-time externally-authored change.
|
|
342
|
+
if (fromEscalation && decision.action === "auto_commit") {
|
|
343
|
+
decision = {
|
|
344
|
+
action: "human_confirm",
|
|
345
|
+
reason: `escalation-authored fix always requires confirmation, regardless of the original classifier confidence`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
// §E-DOF enforcement point: a deterministic floor overrides an
|
|
349
|
+
// auto_commit outcome the same way the escalation override above
|
|
350
|
+
// does. It does NOT downgrade an "abort" ā if the classifier itself
|
|
351
|
+
// rejected this, a protected path doesn't make that safer to proceed
|
|
352
|
+
// past.
|
|
353
|
+
if (dof.hit && decision.action === "auto_commit") {
|
|
354
|
+
decision = {
|
|
355
|
+
action: "human_confirm",
|
|
356
|
+
reason: `touches a Deterministic Override Floor path ("${dof.matchedPath}" matches "${dof.matchedPattern}") ā always requires confirmation regardless of confidence (§E-DOF)`,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
console.log(`\n TrustGate: ${decision.action} ā ${decision.reason}`);
|
|
360
|
+
if (decision.action === "abort") {
|
|
361
|
+
if (options.override !== undefined) {
|
|
362
|
+
recordOverrideAudit("TrustGate", options.override, decision.reason);
|
|
363
|
+
console.log(` [override] TrustGate overridden: ${options.override.trim()}`);
|
|
364
|
+
decision = { action: "human_confirm", reason: `Overridden via --override: ${options.override}` };
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
console.error(`\nš ${decision.reason}`);
|
|
368
|
+
console.error(` Rephrase the instruction and re-run "purix modify" ā no real files were touched.`);
|
|
369
|
+
process.exitCode = 1;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (decision.action === "human_confirm") {
|
|
374
|
+
try {
|
|
375
|
+
await assertAuthorizedToApprove();
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
console.error(`\nš ${err instanceof Error ? err.message : err}`);
|
|
379
|
+
process.exitCode = 1;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const checkpointKind = fromEscalation
|
|
383
|
+
? "escalation_fix"
|
|
384
|
+
: dof.hit
|
|
385
|
+
? "deterministic_override_floor"
|
|
386
|
+
: verdict.contract_changing
|
|
387
|
+
? "contract_changing"
|
|
388
|
+
: "trust_gate";
|
|
389
|
+
const proceed = await confirmGated(`\nApply this change to real files now? (${decision.reason})`, checkpointKind, componentId);
|
|
390
|
+
if (!proceed) {
|
|
391
|
+
console.log("Cancelled ā no real files touched.");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Node 5: Executor + atomic manifest commit (§7.3/§7.4)
|
|
396
|
+
const beforeSnapshot = originalFiles.map((f) => ({ path: f.path, content: f.content }));
|
|
397
|
+
const newVersion = entry.current_version + 1;
|
|
398
|
+
const pendingId = createPendingOperation({
|
|
399
|
+
component_id: componentId,
|
|
400
|
+
before_snapshot: beforeSnapshot,
|
|
401
|
+
after_snapshot: finalFiles.map((f) => ({ path: f.path, content: f.new_content })),
|
|
402
|
+
new_version: newVersion,
|
|
403
|
+
operation: verdict.operation,
|
|
404
|
+
contract_changed: verdict.contract_changing,
|
|
405
|
+
provenance: { source_type: "instruction", source_agent: null },
|
|
406
|
+
});
|
|
407
|
+
let backups;
|
|
408
|
+
try {
|
|
409
|
+
backups = await applyModificationFiles(finalFiles, process.cwd());
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
console.error(`\nš File write failed, rolled back: ${err instanceof Error ? err.message : err}`);
|
|
413
|
+
process.exitCode = 1;
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
entry.current_version = newVersion;
|
|
417
|
+
entry.verification_status = "pass";
|
|
418
|
+
entry.last_synced_hash = computeSyncHash(finalFiles.map((f) => ({ path: f.path, content: f.new_content })));
|
|
419
|
+
entry.version_history.push({
|
|
420
|
+
version: newVersion,
|
|
421
|
+
operation: verdict.operation,
|
|
422
|
+
patch_ref: `v${newVersion}-${verdict.operation}`,
|
|
423
|
+
contract_changed: verdict.contract_changing,
|
|
424
|
+
timestamp: new Date().toISOString(),
|
|
425
|
+
provenance: { source_type: "instruction", source_agent: null },
|
|
426
|
+
});
|
|
427
|
+
const committed = await commitManifestWithRetry(entry, newVersion - 1);
|
|
428
|
+
if (!committed) {
|
|
429
|
+
// Real conflict: another process committed a newer version between
|
|
430
|
+
// our State Resolver read and now. Roll back the files we just wrote
|
|
431
|
+
// and hand back to the human rather than clobbering the other write.
|
|
432
|
+
await rollbackModification(backups);
|
|
433
|
+
deletePendingOperation(pendingId);
|
|
434
|
+
console.error(`\nš Manifest write conflict ā "${componentId}" changed underneath this run. Rolled back file writes. Re-run "purix modify" against current state.`);
|
|
435
|
+
process.exitCode = 1;
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
completeModification(entry, pendingId, newVersion);
|
|
439
|
+
recordRequestCommit(requestKey, componentId, newVersion);
|
|
440
|
+
console.log(`\nā
"${componentId}" now at v${newVersion}.`);
|
|
441
|
+
// §6.5: cascade re-verification, only on contract-changing.
|
|
442
|
+
if (verdict.contract_changing) {
|
|
443
|
+
const plan = buildMigrationPlan(componentId, verdict.operation, beforeSnapshot, finalFiles.map((f) => ({ path: f.path, content: f.new_content })));
|
|
444
|
+
const migrationId = stageMigration(componentId, verdict.operation, newVersion - 1, newVersion, beforeSnapshot, finalFiles.map((f) => ({ path: f.path, content: f.new_content })));
|
|
445
|
+
console.log(` Migration record staged: ${migrationId} (${plan.summary})`);
|
|
446
|
+
dependents = getDependents(entry);
|
|
447
|
+
if (dependents.length > 0) {
|
|
448
|
+
console.log(` Re-verifying ${dependents.length} dependent(s)...`);
|
|
449
|
+
const cascade = reVerifyDependents(dependents, process.cwd());
|
|
450
|
+
for (const c of cascade) {
|
|
451
|
+
console.log(c.status === "pass" ? ` ā
${c.component_id}` : ` ā ${c.component_id}: ${c.reason}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
printRunSummary(savingsBefore);
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
setBudgetOverride(undefined);
|
|
459
|
+
setSecurityOverride(undefined);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// ingest (§3.1 Diff Ingestion Path ā Node 0)
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
program
|
|
466
|
+
.command("ingest <componentId> <diffFile>")
|
|
467
|
+
.description("Ingest an external diff (PR patch, pre-commit hook output, agent-authored diff) against a tracked component")
|
|
468
|
+
.option("-a, --agent <name>", "the upstream agent that produced this diff (cursor, claude-code, devin, human, ...)")
|
|
469
|
+
.action(async (componentId, diffFile, opts) => {
|
|
470
|
+
const sourceAgent = opts.agent ?? null;
|
|
471
|
+
// Node 2: State Resolver ā identical requirement as the Instruction Path.
|
|
472
|
+
const entry = readManifest(componentId);
|
|
473
|
+
if (!entry) {
|
|
474
|
+
console.error(`No manifest entry for "${componentId}". This diff needs a component to land against ā run "purix create" first, or link it under the right one.`);
|
|
475
|
+
process.exitCode = 1;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
// Node 2a: Drift Detector ā shared convergence point (§3.2).
|
|
479
|
+
const drift = await checkDrift(entry, process.cwd());
|
|
480
|
+
if (drift.drifted) {
|
|
481
|
+
console.error(`\nā "${componentId}" has drifted from its last known state. Run "purix accept-drift ${componentId}" first, then retry this ingest.`);
|
|
482
|
+
process.exitCode = 1;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const originalFiles = drift.liveFiles;
|
|
486
|
+
// Node 0: Diff Ingestion
|
|
487
|
+
const ingested = await ingestDiffFromFile(diffFile, sourceAgent, process.cwd());
|
|
488
|
+
if (!ingested.ok) {
|
|
489
|
+
const ingestReason = ingested.reason;
|
|
490
|
+
console.error(`\nš Diff didn't apply: ${ingestReason}`);
|
|
491
|
+
process.exitCode = 1;
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const deletions = ingested.files.filter((f) => f.status === "deleted");
|
|
495
|
+
if (deletions.length > 0) {
|
|
496
|
+
console.error(`\nš This diff deletes file(s) (${deletions.map((f) => f.path).join(", ")}) ā file deletion isn't wired into ingest yet. Handle deletions manually for now.`);
|
|
497
|
+
process.exitCode = 1;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const trackedPaths = new Set(entry.files);
|
|
501
|
+
const untrackedModified = ingested.files.filter((f) => f.status === "modified" && !trackedPaths.has(f.path));
|
|
502
|
+
if (untrackedModified.length > 0) {
|
|
503
|
+
console.error(`\nš This diff modifies file(s) not tracked by "${componentId}": ${untrackedModified.map((f) => f.path).join(", ")}. ` +
|
|
504
|
+
`Refusing to guess which component owns them ā link the right component first.`);
|
|
505
|
+
process.exitCode = 1;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const newPaths = ingested.files.filter((f) => f.status === "added").map((f) => f.path);
|
|
509
|
+
// §9.3 pre-LLM pass, same discipline runEscalation applies to
|
|
510
|
+
// neighbor context ā cheap, local, runs before the diff content
|
|
511
|
+
// ever reaches the classifier.
|
|
512
|
+
const injectionFindings = scanDiffForInjectionAttempts(ingested.files.map((f) => ({ path: f.path, old_content: null, new_content: f.new_content, status: f.status })));
|
|
513
|
+
if (injectionFindings.length > 0) {
|
|
514
|
+
console.log(`\nā Instruction-like text found in the ingested diff content (§9.3):`);
|
|
515
|
+
for (const finding of injectionFindings) {
|
|
516
|
+
for (const h of finding.hits)
|
|
517
|
+
console.log(` ${finding.path}: "${h}"`);
|
|
518
|
+
}
|
|
519
|
+
const proceed = await confirmGated(`This could be an attempt to redirect the classifier via untrusted diff content. Send it to the classifier anyway?`, "injection_risk_proceed", componentId);
|
|
520
|
+
if (!proceed) {
|
|
521
|
+
console.error(`\nš Aborted after suspicious content was flagged in the ingested diff.`);
|
|
522
|
+
process.exitCode = 1;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
console.log(`\nIngested diff from ${sourceAgent ?? "(unknown source)"}: ${ingested.files.length} file(s) touched.`);
|
|
527
|
+
for (const f of ingested.files)
|
|
528
|
+
console.log(` ${f.status}: ${f.path}`);
|
|
529
|
+
// Node 3b: Change Classifier, diff-classify mode (§3.2). Same
|
|
530
|
+
// structured verdict shape the Instruction Path's intent-classify
|
|
531
|
+
// produces, fed by the diff itself instead of a developer
|
|
532
|
+
// instruction ā this is what finally lets TrustGate below run the
|
|
533
|
+
// real evaluateTrustGate instead of the always-confirm placeholder.
|
|
534
|
+
console.log(`\nClassifying diff...`);
|
|
535
|
+
const diffVerdict = await classifyDiff(componentId, sourceAgent, ingested.files.map((f) => ({
|
|
536
|
+
path: f.path,
|
|
537
|
+
old_content: f.status === "added" ? null : originalFiles.find((o) => o.path === f.path)?.content ?? null,
|
|
538
|
+
new_content: f.new_content,
|
|
539
|
+
status: f.status,
|
|
540
|
+
})));
|
|
541
|
+
console.log(` operation: ${diffVerdict.operation}`);
|
|
542
|
+
console.log(` contract_changing: ${diffVerdict.contract_changing}`);
|
|
543
|
+
console.log(` confidence: ${diffVerdict.confidence.toFixed(2)}`);
|
|
544
|
+
console.log(` reasoning: ${diffVerdict.reasoning}`);
|
|
545
|
+
if (diffVerdict.suspicious_injected_instruction) {
|
|
546
|
+
console.log(` ā Classifier self-reported suspicious instruction-like content inside the diff.`);
|
|
547
|
+
}
|
|
548
|
+
recordEvent("classification", {
|
|
549
|
+
component_id: componentId,
|
|
550
|
+
operation: diffVerdict.operation,
|
|
551
|
+
detail: { confidence: diffVerdict.confidence, contract_changing: diffVerdict.contract_changing, path: "diff" },
|
|
552
|
+
});
|
|
553
|
+
const finalFiles = [
|
|
554
|
+
...originalFiles.map((f) => {
|
|
555
|
+
const changed = ingested.files.find((c) => c.path === f.path);
|
|
556
|
+
return { path: f.path, new_content: changed ? changed.new_content : f.content };
|
|
557
|
+
}),
|
|
558
|
+
...ingested.files.filter((f) => f.status === "added").map((f) => ({ path: f.path, new_content: f.new_content })),
|
|
559
|
+
];
|
|
560
|
+
// §3.2 Node 6: identical Verifier every path converges on.
|
|
561
|
+
let verification = verifyInSandbox(componentId, finalFiles, process.cwd());
|
|
562
|
+
let workingFiles = finalFiles;
|
|
563
|
+
let fromEscalation = false;
|
|
564
|
+
if (verification.status !== "pass") {
|
|
565
|
+
console.log(`\n ā Verification failed: ${verification.reason}`);
|
|
566
|
+
recordEvent("verification_failure", { component_id: componentId, operation: "diff_ingest", detail: { stage: "direct_ingest", reason: verification.reason } });
|
|
567
|
+
const healed = await runSelfHealingLoop(componentId, "diff_ingest", originalFiles, workingFiles, verification.reason, process.cwd());
|
|
568
|
+
if (healed.ok) {
|
|
569
|
+
workingFiles = healed.files.map((f) => ({ path: f.path, new_content: f.new_content }));
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
const lastReason = healed.attempts[healed.attempts.length - 1]?.reason ?? "self-healing exhausted";
|
|
573
|
+
const esc = await runEscalation(componentId, "diff_ingest", `apply ingested diff from ${sourceAgent ?? "unknown source"}`, originalFiles, lastReason, process.cwd());
|
|
574
|
+
if (!esc.ok) {
|
|
575
|
+
console.error(`\nš Escalation failed: ${esc.reason}`);
|
|
576
|
+
process.exitCode = 1;
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
workingFiles = esc.files.map((f) => ({ path: f.path, new_content: f.new_content }));
|
|
580
|
+
fromEscalation = true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
recordEvent("verification_pass", { component_id: componentId, operation: "diff_ingest", detail: { stage: "direct_ingest" } });
|
|
585
|
+
if (verification.idiomFindings.length > 0) {
|
|
586
|
+
console.log(`\n Idiom findings (soft, non-blocking):`);
|
|
587
|
+
for (const f of verification.idiomFindings)
|
|
588
|
+
console.log(` ${f.path}:${f.line} [${f.rule}] ${f.message}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
// Node 3c: Test-Integrity Check ā identical gate, same as the Instruction Path.
|
|
592
|
+
const changedFiles = workingFiles.filter((f) => {
|
|
593
|
+
const orig = originalFiles.find((o) => o.path === f.path);
|
|
594
|
+
return orig ? orig.content !== f.new_content : true;
|
|
595
|
+
});
|
|
596
|
+
const lang = resolveLanguage(componentId, process.cwd());
|
|
597
|
+
const provider = getLanguageProvider(lang);
|
|
598
|
+
const testFilesBefore = originalFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test."));
|
|
599
|
+
const testFilesAfter = workingFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test.")).map((f) => ({ path: f.path, content: f.new_content }));
|
|
600
|
+
const testIntegrity = provider?.testIntegrityChecker
|
|
601
|
+
? provider.testIntegrityChecker.check(testFilesBefore, testFilesAfter)
|
|
602
|
+
: { flagged: true, findings: [{ path: "unknown", reason: `no test integrity checker registered for language ${lang} ā failing closed` }] };
|
|
603
|
+
if (testIntegrity.flagged) {
|
|
604
|
+
recordEvent("test_integrity_flag", { component_id: componentId, operation: "diff_ingest", detail: { findings: testIntegrity.findings } });
|
|
605
|
+
console.log(`\n ā Test-integrity check (§6.4): ${testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ")}`);
|
|
606
|
+
}
|
|
607
|
+
const hasCoverage = hasTestCoverage(changedFiles, process.cwd());
|
|
608
|
+
if (!hasCoverage) {
|
|
609
|
+
recordEvent("coverage_gate_flag", { component_id: componentId, operation: "diff_ingest", detail: { changed_files: changedFiles.map((f) => f.path) } });
|
|
610
|
+
}
|
|
611
|
+
recordEvent("gate_evaluation", { component_id: componentId, operation: "diff_ingest", detail: { path: "diff" } });
|
|
612
|
+
// TrustGate (§6.2) ā now fed a real classifier confidence via
|
|
613
|
+
// diff-classify (Node 3b's second mode) instead of the old
|
|
614
|
+
// always-confirm placeholder (evaluateTrustGateForDiff). Every path
|
|
615
|
+
// converges on the identical evaluateTrustGate the Instruction Path
|
|
616
|
+
// uses, per Principle 12 (author-agnostic verification: nothing
|
|
617
|
+
// downstream gets a special case for "this came from a diff").
|
|
618
|
+
let decision = evaluateTrustGate({
|
|
619
|
+
confidence: diffVerdict.confidence,
|
|
620
|
+
contractChanging: diffVerdict.contract_changing,
|
|
621
|
+
hasCoverage,
|
|
622
|
+
testIntegrity: {
|
|
623
|
+
flagged: testIntegrity.flagged,
|
|
624
|
+
reason: testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ") || undefined,
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
// Same rule the Instruction Path applies (§7.5): an escalation-
|
|
628
|
+
// authored fix never auto-commits on the strength of a confidence
|
|
629
|
+
// score that described the ORIGINAL diff, not what escalation
|
|
630
|
+
// actually produced after that diff failed verification.
|
|
631
|
+
if (fromEscalation && decision.action === "auto_commit") {
|
|
632
|
+
decision = {
|
|
633
|
+
action: "human_confirm",
|
|
634
|
+
reason: `escalation-authored fix always requires confirmation, regardless of the original diff-classify confidence`,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
console.log(`\n TrustGate: ${decision.action} ā ${decision.reason}`);
|
|
638
|
+
if (decision.action === "abort") {
|
|
639
|
+
console.error(`\nš ${decision.reason}`);
|
|
640
|
+
console.error(` No real files were touched ā the diff was not applied.`);
|
|
641
|
+
process.exitCode = 1;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (decision.action === "human_confirm") {
|
|
645
|
+
try {
|
|
646
|
+
await assertAuthorizedToApprove();
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
console.error(`\nš ${err instanceof Error ? err.message : err}`);
|
|
650
|
+
process.exitCode = 1;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const checkpointKind = fromEscalation ? "escalation_fix" : "diff_ingest";
|
|
654
|
+
const proceed = await confirmGated(`\nApply this ingested diff to real files now? (${decision.reason})`, checkpointKind, componentId);
|
|
655
|
+
if (!proceed) {
|
|
656
|
+
console.log("Cancelled ā no real files touched.");
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// Node 5: Executor + atomic manifest commit
|
|
661
|
+
const beforeSnapshot = originalFiles.map((f) => ({ path: f.path, content: f.content }));
|
|
662
|
+
const newVersion = entry.current_version + 1;
|
|
663
|
+
// Previously always hardcoded to true ā the conservative worst-case
|
|
664
|
+
// default drift.ts's acceptDrift still uses for the same reason
|
|
665
|
+
// (genuinely unknown, so don't guess "safe"). diff-classify (Node
|
|
666
|
+
// 3b's second mode) now supplies a real verdict for ingested diffs,
|
|
667
|
+
// so this uses that instead of guessing.
|
|
668
|
+
const contractChanged = diffVerdict.contract_changing;
|
|
669
|
+
const pendingId = createPendingOperation({
|
|
670
|
+
component_id: componentId,
|
|
671
|
+
before_snapshot: beforeSnapshot,
|
|
672
|
+
after_snapshot: workingFiles.map((f) => ({ path: f.path, content: f.new_content })),
|
|
673
|
+
new_version: newVersion,
|
|
674
|
+
operation: "diff_ingest",
|
|
675
|
+
contract_changed: contractChanged,
|
|
676
|
+
provenance: { source_type: "external_diff", source_agent: sourceAgent },
|
|
677
|
+
});
|
|
678
|
+
let backups;
|
|
679
|
+
try {
|
|
680
|
+
backups = await applyModificationFiles(workingFiles, process.cwd());
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
console.error(`\nš File write failed, rolled back: ${err instanceof Error ? err.message : err}`);
|
|
684
|
+
process.exitCode = 1;
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (newPaths.length > 0) {
|
|
688
|
+
entry.files = Array.from(new Set([...entry.files, ...newPaths]));
|
|
689
|
+
}
|
|
690
|
+
entry.current_version = newVersion;
|
|
691
|
+
entry.verification_status = "pass";
|
|
692
|
+
entry.last_synced_hash = computeSyncHash(workingFiles.map((f) => ({ path: f.path, content: f.new_content })));
|
|
693
|
+
entry.version_history.push({
|
|
694
|
+
version: newVersion,
|
|
695
|
+
operation: "diff_ingest",
|
|
696
|
+
patch_ref: `v${newVersion}-diff-ingest`,
|
|
697
|
+
contract_changed: contractChanged,
|
|
698
|
+
timestamp: new Date().toISOString(),
|
|
699
|
+
provenance: { source_type: "external_diff", source_agent: sourceAgent },
|
|
700
|
+
});
|
|
701
|
+
const committed = await commitManifestWithRetry(entry, newVersion - 1);
|
|
702
|
+
if (!committed) {
|
|
703
|
+
await rollbackModification(backups);
|
|
704
|
+
deletePendingOperation(pendingId);
|
|
705
|
+
console.error(`\nš Manifest write conflict ā "${componentId}" changed underneath this run. Rolled back file writes. Re-run "purix ingest" against current state.`);
|
|
706
|
+
process.exitCode = 1;
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
completeModification(entry, pendingId, newVersion);
|
|
710
|
+
console.log(`\nā
"${componentId}" now at v${newVersion} (ingested from ${sourceAgent ?? "unknown source"}).`);
|
|
711
|
+
const dependents = getDependents(entry);
|
|
712
|
+
if (contractChanged && dependents.length > 0) {
|
|
713
|
+
console.log(` Re-verifying ${dependents.length} dependent(s) ā diff-classify flagged this as contract-changing.`);
|
|
714
|
+
const cascade = reVerifyDependents(dependents, process.cwd());
|
|
715
|
+
for (const c of cascade) {
|
|
716
|
+
console.log(c.status === "pass" ? ` ā
${c.component_id}` : ` ā ${c.component_id}: ${c.reason}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
// delete ā previously deleteManifestEntry existed in manifest/store.ts
|
|
722
|
+
// but was never wired to a command. create/modify had a lifecycle end
|
|
723
|
+
// but no exit. Refuses on live dependents unless --force; --force also
|
|
724
|
+
// strips the deleted id out of each dependent's own depends_on list
|
|
725
|
+
// (removeDependencyReference), so nothing is left pointing at a
|
|
726
|
+
// component that no longer resolves via readManifest. Manifest-only by
|
|
727
|
+
// default ā files on disk are untouched unless --files is passed.
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
program
|
|
730
|
+
.command("delete <componentId>")
|
|
731
|
+
.description("Permanently remove a component from the manifest (DESTRUCTIVE)")
|
|
732
|
+
.option("--force", "Delete even if other components still depend on it")
|
|
733
|
+
.option("--files", "Also delete the component's files from disk")
|
|
734
|
+
.action(async (componentId, opts) => {
|
|
735
|
+
const entry = readManifest(componentId);
|
|
736
|
+
if (!entry) {
|
|
737
|
+
console.error(`No manifest entry for "${componentId}". Run "purix library" to see what's tracked.`);
|
|
738
|
+
process.exitCode = 1;
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (entry.depended_on_by.length > 0 && !opts.force) {
|
|
742
|
+
console.error(`\nš "${componentId}" still has dependent(s): ${entry.depended_on_by.join(", ")}.\n` +
|
|
743
|
+
` Re-run with --force to delete anyway (dependents will have this reference removed from their depends_on list), ` +
|
|
744
|
+
`or remove those dependencies first.`);
|
|
745
|
+
process.exitCode = 1;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
const proceed = await confirmGated(`This will permanently delete "${componentId}" from the manifest${opts.files ? " and remove its files from disk" : ""}. This cannot be undone. Continue?`, "component_delete", componentId);
|
|
749
|
+
if (!proceed) {
|
|
750
|
+
console.log("Cancelled ā nothing deleted.");
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
// Clean up both directions of the dependency graph before the row
|
|
754
|
+
// itself is gone and readManifest(componentId) stops working.
|
|
755
|
+
for (const depId of entry.depends_on) {
|
|
756
|
+
removeDependent(depId, componentId);
|
|
757
|
+
}
|
|
758
|
+
for (const dependentId of entry.depended_on_by) {
|
|
759
|
+
removeDependencyReference(dependentId, componentId);
|
|
760
|
+
}
|
|
761
|
+
if (opts.files) {
|
|
762
|
+
for (const relPath of entry.files) {
|
|
763
|
+
const fullPath = join(process.cwd(), relPath);
|
|
764
|
+
if (existsSync(fullPath)) {
|
|
765
|
+
try {
|
|
766
|
+
rmSync(fullPath);
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
console.warn(` warning: couldn't remove ${relPath}: ${err instanceof Error ? err.message : err}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
deleteManifestEntry(componentId);
|
|
775
|
+
recordEvent("component_deleted", {
|
|
776
|
+
component_id: componentId,
|
|
777
|
+
detail: { forced: !!opts.force, files_removed: !!opts.files, had_dependents: entry.depended_on_by.length > 0 },
|
|
778
|
+
});
|
|
779
|
+
console.log(`\nšļø Deleted "${componentId}"${opts.files ? " (files removed)" : " (manifest entry only ā files left on disk)"}.`);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
//# sourceMappingURL=lifecycle.js.map
|