@njeybe/nativ 2.4.1 → 2.5.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/CHANGELOG.md +20 -0
- package/README.md +26 -0
- package/dist/commands/db/db-formatter.d.ts +5 -0
- package/dist/commands/db/db-formatter.js +116 -0
- package/dist/commands/db/db-introspect.d.ts +11 -0
- package/dist/commands/db/db-introspect.js +84 -0
- package/dist/commands/db.js +4 -206
- package/dist/commands/profile.d.ts +3 -0
- package/dist/commands/profile.js +39 -0
- package/dist/commands/task/task-add.d.ts +11 -0
- package/dist/commands/task/task-add.js +169 -0
- package/dist/commands/task/task-common.d.ts +57 -0
- package/dist/commands/task/task-common.js +45 -0
- package/dist/commands/task/task-escalate.d.ts +7 -0
- package/dist/commands/task/task-escalate.js +119 -0
- package/dist/commands/task/task-lifecycle.d.ts +5 -0
- package/dist/commands/task/task-lifecycle.js +252 -0
- package/dist/commands/task/task-query.d.ts +10 -0
- package/dist/commands/task/task-query.js +269 -0
- package/dist/commands/task.d.ts +9 -87
- package/dist/commands/task.js +8 -850
- package/dist/commands/triage/triage-store.d.ts +56 -0
- package/dist/commands/triage/triage-store.js +108 -0
- package/dist/commands/triage/triage-ui.d.ts +14 -0
- package/dist/commands/triage/triage-ui.js +104 -0
- package/dist/commands/triage.d.ts +3 -52
- package/dist/commands/triage.js +8 -216
- package/dist/core/enforcement.d.ts +1 -0
- package/dist/core/enforcement.js +19 -0
- package/dist/core/human-gate.d.ts +2 -1
- package/dist/core/human-gate.js +9 -2
- package/dist/core/profiles.d.ts +14 -0
- package/dist/core/profiles.js +82 -0
- package/dist/core/setup/config-merger.d.ts +59 -0
- package/dist/core/setup/config-merger.js +107 -0
- package/dist/core/setup/session-context.d.ts +4 -0
- package/dist/core/setup/session-context.js +65 -0
- package/dist/core/setup/setup-planner.d.ts +14 -0
- package/dist/core/setup/setup-planner.js +180 -0
- package/dist/core/setup/template-planner.d.ts +8 -0
- package/dist/core/setup/template-planner.js +32 -0
- package/dist/core/setup/types.d.ts +65 -0
- package/dist/core/setup/types.js +55 -0
- package/dist/core/setup-assets.d.ts +8 -119
- package/dist/core/setup-assets.js +7 -437
- package/dist/core/tier1-liaison.d.ts +2 -0
- package/dist/core/tier1-liaison.js +64 -3
- package/dist/core/verifier/executor.d.ts +5 -0
- package/dist/core/verifier/executor.js +62 -0
- package/dist/core/verifier/phases.d.ts +5 -0
- package/dist/core/verifier/phases.js +71 -0
- package/dist/core/verifier/task-verifier.d.ts +37 -0
- package/dist/core/verifier/task-verifier.js +233 -0
- package/dist/core/verifier/types.d.ts +51 -0
- package/dist/core/verifier/types.js +1 -0
- package/dist/core/verifier.d.ts +5 -93
- package/dist/core/verifier.js +5 -359
- package/dist/db/introspector.js +59 -59
- package/dist/governor/evaluator.d.ts +4 -64
- package/dist/governor/evaluator.js +21 -686
- package/dist/governor/patch-applier.d.ts +8 -0
- package/dist/governor/patch-applier.js +121 -0
- package/dist/governor/self-healing.d.ts +24 -0
- package/dist/governor/self-healing.js +351 -0
- package/dist/governor/test-integrity.d.ts +41 -0
- package/dist/governor/test-integrity.js +237 -0
- package/dist/index.js +8 -0
- package/dist/mcp/capture.d.ts +4 -0
- package/dist/mcp/capture.js +33 -0
- package/dist/mcp/resources.d.ts +39 -0
- package/dist/mcp/resources.js +44 -0
- package/dist/mcp/server.d.ts +11 -0
- package/dist/mcp/server.js +14 -89
- package/dist/mcp/tools.d.ts +1 -0
- package/dist/mcp/tools.js +1 -0
- package/dist/runner/agent-supervisor.d.ts +8 -302
- package/dist/runner/agent-supervisor.js +41 -1030
- package/dist/runner/cli-config.d.ts +84 -0
- package/dist/runner/cli-config.js +215 -0
- package/dist/runner/native-driver.d.ts +18 -0
- package/dist/runner/native-driver.js +208 -0
- package/dist/runner/native-engine.d.ts +27 -0
- package/dist/runner/native-engine.js +184 -0
- package/dist/runner/native-tools.d.ts +31 -0
- package/dist/runner/native-tools.js +241 -0
- package/dist/runner/process-utils.d.ts +12 -0
- package/dist/runner/process-utils.js +96 -0
- package/dist/runner/run-lifecycle.d.ts +6 -0
- package/dist/runner/run-lifecycle.js +42 -0
- package/dist/runner/types.d.ts +215 -0
- package/dist/runner/types.js +41 -0
- package/dist/scanner/detector.js +42 -663
- package/dist/scanner/detectors/ecosystem-detector.d.ts +16 -0
- package/dist/scanner/detectors/ecosystem-detector.js +354 -0
- package/dist/scanner/detectors/orm-detector.d.ts +5 -0
- package/dist/scanner/detectors/orm-detector.js +59 -0
- package/dist/scanner/detectors/workspace-detector.d.ts +19 -0
- package/dist/scanner/detectors/workspace-detector.js +266 -0
- package/dist/server/cli-runner.d.ts +15 -0
- package/dist/server/cli-runner.js +43 -0
- package/dist/server/event-hub.d.ts +42 -0
- package/dist/server/event-hub.js +169 -0
- package/dist/server/http-utils.d.ts +15 -0
- package/dist/server/http-utils.js +56 -0
- package/dist/server/plan-utils.d.ts +9 -0
- package/dist/server/plan-utils.js +31 -0
- package/dist/server/routes/db-routes.d.ts +57 -0
- package/dist/server/routes/db-routes.js +484 -0
- package/dist/server/routes/task-routes.d.ts +266 -0
- package/dist/server/routes/task-routes.js +285 -0
- package/dist/server/routes/telemetry-routes.d.ts +128 -0
- package/dist/server/routes/telemetry-routes.js +179 -0
- package/dist/server/routes/triage-routes.d.ts +81 -0
- package/dist/server/routes/triage-routes.js +244 -0
- package/dist/server/routes/worktree-routes.d.ts +31 -0
- package/dist/server/routes/worktree-routes.js +144 -0
- package/dist/server/studio-server.d.ts +4 -11
- package/dist/server/studio-server.js +34 -1578
- package/dist/server/studio-ui/shell/pipeline-state.js +2 -0
- package/dist/server/studio-ui/shell/triage.js +10 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +4 -2
- package/plugin/agents/verifier.md +1 -1
- package/plugin/agents/worker.md +7 -4
- package/plugin/skills/nativ/SKILL.md +1 -0
- package/templates/.claudeignore +62 -45
- package/templates/AGENTS.md +1 -0
- package/templates/CLAUDE.md +13 -0
- package/templates/claude-agents/architect.md +4 -2
- package/templates/claude-agents/verifier.md +1 -1
- package/templates/claude-agents/worker.md +7 -4
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
- package/dist/commands/session.d.ts +0 -22
- package/dist/commands/session.js +0 -106
- package/dist/core/session-memory.d.ts +0 -42
- package/dist/core/session-memory.js +0 -121
|
@@ -1,693 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
|
-
import crypto from 'node:crypto';
|
|
5
|
-
import { spawnSync } from 'node:child_process';
|
|
6
3
|
import { evaluateDbPatch } from './rules/db-rules.js';
|
|
7
4
|
import { evaluateApiPatch } from './rules/api-rules.js';
|
|
8
|
-
import { CircuitBreaker
|
|
5
|
+
import { CircuitBreaker } from './circuit-breaker.js';
|
|
6
|
+
import { getContractHash, appendAuditLog, applyDbPatch, applyApiPatch, atomicWriteJson, } from './patch-applier.js';
|
|
7
|
+
import { generateSelfHealingProposal } from './self-healing.js';
|
|
8
|
+
export * from './patch-applier.js';
|
|
9
|
+
export * from './self-healing.js';
|
|
10
|
+
export * from './test-integrity.js';
|
|
9
11
|
const MAX_TASK_PATCH_BUDGET = 3;
|
|
10
|
-
export function getContractHash(filePath) {
|
|
11
|
-
if (!fs.existsSync(filePath))
|
|
12
|
-
return null;
|
|
13
|
-
try {
|
|
14
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
15
|
-
return crypto.createHash('sha256').update(content).digest('hex');
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return null;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
function appendAuditLog(targetDir, entry) {
|
|
22
|
-
const auditPath = path.join(targetDir, '.ai', 'audit_log.jsonl');
|
|
23
|
-
try {
|
|
24
|
-
fs.mkdirSync(path.dirname(auditPath), { recursive: true });
|
|
25
|
-
fs.appendFileSync(auditPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
// Non-fatal
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function applyDbPatch(schema, patch) {
|
|
32
|
-
const { operation, path: targetPath, value } = patch;
|
|
33
|
-
const segments = targetPath.toLowerCase().split('.').filter(Boolean);
|
|
34
|
-
const tableName = segments[0] === 'tables' ? segments[1] : segments[0];
|
|
35
|
-
const targetType = segments.includes('columns')
|
|
36
|
-
? 'column'
|
|
37
|
-
: (segments.includes('indexes') ? 'index' : (segments.includes('foreignkeys') ? 'foreignKey' : 'table'));
|
|
38
|
-
const propertyName = segments.length > 2 ? segments[segments.length - 1] : undefined;
|
|
39
|
-
schema.tables = schema.tables || [];
|
|
40
|
-
if (operation === 'ADD') {
|
|
41
|
-
if (targetType === 'table') {
|
|
42
|
-
schema.tables.push(value || { name: tableName, columns: [], indexes: [], foreignKeys: [] });
|
|
43
|
-
}
|
|
44
|
-
else {
|
|
45
|
-
const table = schema.tables.find((t) => t.name?.toLowerCase() === tableName.toLowerCase());
|
|
46
|
-
if (table) {
|
|
47
|
-
if (targetType === 'column') {
|
|
48
|
-
table.columns = table.columns || [];
|
|
49
|
-
table.columns.push(value || { name: propertyName, type: 'TEXT', nullable: true });
|
|
50
|
-
}
|
|
51
|
-
else if (targetType === 'index') {
|
|
52
|
-
table.indexes = table.indexes || [];
|
|
53
|
-
table.indexes.push(value);
|
|
54
|
-
}
|
|
55
|
-
else if (targetType === 'foreignKey') {
|
|
56
|
-
table.foreignKeys = table.foreignKeys || [];
|
|
57
|
-
table.foreignKeys.push(value);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
else if (operation === 'ALTER') {
|
|
63
|
-
const table = schema.tables.find((t) => t.name?.toLowerCase() === tableName.toLowerCase());
|
|
64
|
-
if (table && targetType === 'column' && propertyName) {
|
|
65
|
-
const colIdx = table.columns?.findIndex((c) => c.name?.toLowerCase() === propertyName.toLowerCase());
|
|
66
|
-
if (colIdx !== undefined && colIdx >= 0) {
|
|
67
|
-
table.columns[colIdx] = { ...table.columns[colIdx], ...value };
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
schema.updatedAt = new Date().toISOString();
|
|
72
|
-
return schema;
|
|
73
|
-
}
|
|
74
|
-
function applyApiPatch(contracts, patch) {
|
|
75
|
-
const { operation, path: targetPath, value } = patch;
|
|
76
|
-
const segments = targetPath.toLowerCase().split('.').filter(Boolean);
|
|
77
|
-
const endpointIdOrPath = segments[0] === 'endpoints' ? segments[1] : segments[0];
|
|
78
|
-
contracts.endpoints = contracts.endpoints || [];
|
|
79
|
-
if (operation === 'ADD') {
|
|
80
|
-
const isEndpoint = segments.length <= 2 && value?.path && value?.method;
|
|
81
|
-
if (isEndpoint) {
|
|
82
|
-
contracts.endpoints.push(value);
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
const ep = contracts.endpoints.find((e) => e.id?.toLowerCase() === endpointIdOrPath.toLowerCase() ||
|
|
86
|
-
e.path?.toLowerCase() === endpointIdOrPath.toLowerCase());
|
|
87
|
-
if (ep) {
|
|
88
|
-
if (segments.includes('queryparams')) {
|
|
89
|
-
ep.request = ep.request || {};
|
|
90
|
-
ep.request.queryParams = ep.request.queryParams || {};
|
|
91
|
-
ep.request.queryParams[segments[segments.length - 1]] = value;
|
|
92
|
-
}
|
|
93
|
-
else if (segments.includes('responses') || segments.includes('response')) {
|
|
94
|
-
ep.responses = ep.responses || {};
|
|
95
|
-
const status = segments.find((s) => /^\d{3}$/.test(s)) || '200';
|
|
96
|
-
ep.responses[status] = ep.responses[status] || { description: 'Success', body: {} };
|
|
97
|
-
if (typeof value === 'object' && value !== null) {
|
|
98
|
-
ep.responses[status].body = { ...ep.responses[status].body, ...value };
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
contracts.updatedAt = new Date().toISOString();
|
|
105
|
-
return contracts;
|
|
106
|
-
}
|
|
107
|
-
function atomicWriteJson(filePath, data) {
|
|
108
|
-
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
109
|
-
try {
|
|
110
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
111
|
-
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
112
|
-
fs.renameSync(tmpPath, filePath);
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
if (fs.existsSync(tmpPath)) {
|
|
117
|
-
fs.rmSync(tmpPath, { force: true });
|
|
118
|
-
}
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
// ─── Self-healing: candidate generation & isolated proofs ──────────────────
|
|
123
|
-
function contractFileFor(target) {
|
|
124
|
-
return target === 'db_schema' ? 'db_schema.json' : 'api_contracts.json';
|
|
125
|
-
}
|
|
126
|
-
function runRules(patch, contract) {
|
|
127
|
-
return patch.target === 'db_schema' ? evaluateDbPatch(patch, contract) : evaluateApiPatch(patch, contract);
|
|
128
|
-
}
|
|
129
|
-
function applyPatch(contract, patch) {
|
|
130
|
-
return patch.target === 'db_schema' ? applyDbPatch(contract, patch) : applyApiPatch(contract, patch);
|
|
131
|
-
}
|
|
132
|
-
/** Hash of the contract's content, ignoring the timestamps every write refreshes. */
|
|
133
|
-
function semanticHash(data) {
|
|
134
|
-
const { updatedAt: _u, lastUpdated: _l, ...rest } = data ?? {};
|
|
135
|
-
return crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex');
|
|
136
|
-
}
|
|
137
|
-
function toCandidate(patch) {
|
|
138
|
-
const { taskId: _taskId, ...candidate } = patch;
|
|
139
|
-
return candidate;
|
|
140
|
-
}
|
|
141
|
-
/** Mirrors the rule modules' path parsing: `[tables.]<table>.columns.<column>`. */
|
|
142
|
-
function locateDbColumn(contract, patchPath) {
|
|
143
|
-
const segments = patchPath.split('.').filter(Boolean);
|
|
144
|
-
const lower = segments.map((s) => s.toLowerCase());
|
|
145
|
-
const tableName = lower[0] === 'tables' ? lower[1] : lower[0];
|
|
146
|
-
const columnName = segments.length > 2 ? segments[segments.length - 1] : undefined;
|
|
147
|
-
const table = (contract?.tables || []).find((t) => t.name?.toLowerCase() === tableName);
|
|
148
|
-
const column = table?.columns?.find((c) => c.name?.toLowerCase() === columnName?.toLowerCase());
|
|
149
|
-
return { table, column, columnName };
|
|
150
|
-
}
|
|
151
|
-
function withLastSegment(patchPath, name) {
|
|
152
|
-
const segments = patchPath.split('.').filter(Boolean);
|
|
153
|
-
segments[segments.length - 1] = name;
|
|
154
|
-
return segments.join('.');
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Backward-compatible alternatives for a rejected patch, ordered by how
|
|
158
|
-
* closely they preserve the agent's intent. Every candidate is still proven
|
|
159
|
-
* in a sandbox before it is offered.
|
|
160
|
-
*/
|
|
161
|
-
function buildCandidates(patch, rule, contract, liveHash) {
|
|
162
|
-
const base = toCandidate(patch);
|
|
163
|
-
const note = (strategy) => `${patch.reason} [self-healing: ${strategy}]`;
|
|
164
|
-
const isColumn = /(^|\.)columns(\.|$)/i.test(patch.path);
|
|
165
|
-
const firstViolation = rule.violations[0] || rule.message;
|
|
166
|
-
const value = patch.value && typeof patch.value === 'object' ? patch.value : undefined;
|
|
167
|
-
const options = [];
|
|
168
|
-
switch (rule.ruleId) {
|
|
169
|
-
case 'CONCURRENT_MODIFICATION_DETECTED':
|
|
170
|
-
options.push({
|
|
171
|
-
strategy: 'rebase_on_current_contract',
|
|
172
|
-
rationale: 'The contract changed after the agent read it; replay the same patch against the current revision.',
|
|
173
|
-
candidate: { ...base, baseHash: liveHash ?? undefined, reason: note('rebase_on_current_contract') },
|
|
174
|
-
});
|
|
175
|
-
break;
|
|
176
|
-
case 'TASK_PATCH_BUDGET_EXCEEDED':
|
|
177
|
-
options.push({
|
|
178
|
-
strategy: 'budget_override',
|
|
179
|
-
rationale: 'The patch itself is safe; only the per-task budget stopped it. Approve to grant one more patch.',
|
|
180
|
-
candidate: { ...base, reason: note('budget_override') },
|
|
181
|
-
});
|
|
182
|
-
break;
|
|
183
|
-
case 'DB_ADD_NOT_NULL_FORBIDDEN':
|
|
184
|
-
case 'DB_CIRCULAR_FK_FORBIDDEN':
|
|
185
|
-
options.push({
|
|
186
|
-
strategy: 'relax_to_nullable',
|
|
187
|
-
rationale: 'Add the field as nullable so existing rows stay valid; tighten it later with a backfill and a default.',
|
|
188
|
-
candidate: { ...base, value: { ...(value ?? {}), nullable: true }, reason: note('relax_to_nullable') },
|
|
189
|
-
});
|
|
190
|
-
break;
|
|
191
|
-
case 'DB_DROP_FORBIDDEN':
|
|
192
|
-
if (isColumn) {
|
|
193
|
-
options.push({
|
|
194
|
-
strategy: 'deprecate_instead_of_drop',
|
|
195
|
-
rationale: 'Keep the column but mark it deprecated and nullable, so readers keep working while writers migrate off it.',
|
|
196
|
-
candidate: {
|
|
197
|
-
...base,
|
|
198
|
-
operation: 'ALTER',
|
|
199
|
-
value: { nullable: true, deprecated: true },
|
|
200
|
-
reason: note('deprecate_instead_of_drop'),
|
|
201
|
-
},
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
break;
|
|
205
|
-
case 'DB_RENAME_FORBIDDEN': {
|
|
206
|
-
const newName = typeof patch.value === 'string' ? patch.value : value?.name ?? value?.to;
|
|
207
|
-
const { column } = locateDbColumn(contract, patch.path);
|
|
208
|
-
if (isColumn && column && typeof newName === 'string' && newName) {
|
|
209
|
-
options.push({
|
|
210
|
-
strategy: 'expand_contract_rename',
|
|
211
|
-
rationale: `Add '${newName}' alongside '${column.name}' (expand), backfill it, and retire the old column in a later reviewed migration (contract).`,
|
|
212
|
-
candidate: {
|
|
213
|
-
...base,
|
|
214
|
-
operation: 'ADD',
|
|
215
|
-
path: withLastSegment(patch.path, newName),
|
|
216
|
-
value: { ...column, name: newName, nullable: true, primaryKey: false },
|
|
217
|
-
reason: note('expand_contract_rename'),
|
|
218
|
-
},
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
break;
|
|
222
|
-
}
|
|
223
|
-
case 'DB_ALTER_DESTRUCTIVE': {
|
|
224
|
-
const { column } = locateDbColumn(contract, patch.path);
|
|
225
|
-
if (isColumn && column && typeof value?.type === 'string') {
|
|
226
|
-
const suffix = value.type.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'v2';
|
|
227
|
-
const shadowName = `${column.name}_${suffix}`;
|
|
228
|
-
options.push({
|
|
229
|
-
strategy: 'shadow_column_for_type_change',
|
|
230
|
-
rationale: `Add '${shadowName}' with the new type next to '${column.name}', backfill it, then switch readers over.`,
|
|
231
|
-
candidate: {
|
|
232
|
-
...base,
|
|
233
|
-
operation: 'ADD',
|
|
234
|
-
path: withLastSegment(patch.path, shadowName),
|
|
235
|
-
value: { ...column, ...value, name: shadowName, nullable: true, primaryKey: false },
|
|
236
|
-
reason: note('shadow_column_for_type_change'),
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
case 'DB_COLLISION_OR_TARGET_MISMATCH': {
|
|
243
|
-
const { columnName } = locateDbColumn(contract, patch.path);
|
|
244
|
-
if (isColumn && patch.operation === 'ADD' && firstViolation.startsWith('PATH_COLLISION')) {
|
|
245
|
-
const { name: _name, ...changes } = value ?? {};
|
|
246
|
-
options.push({
|
|
247
|
-
strategy: 'alter_existing_instead_of_add',
|
|
248
|
-
rationale: 'The column already exists; alter it in place instead of adding a duplicate.',
|
|
249
|
-
candidate: { ...base, operation: 'ALTER', value: changes, reason: note('alter_existing_instead_of_add') },
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
else if (isColumn && patch.operation === 'ALTER' && columnName && firstViolation.startsWith('TARGET_NOT_FOUND')) {
|
|
253
|
-
options.push({
|
|
254
|
-
strategy: 'add_missing_target',
|
|
255
|
-
rationale: `Column '${columnName}' does not exist yet; add it as a nullable column instead of altering it.`,
|
|
256
|
-
candidate: {
|
|
257
|
-
...base,
|
|
258
|
-
operation: 'ADD',
|
|
259
|
-
value: { name: columnName, type: 'TEXT', ...(value ?? {}), nullable: true },
|
|
260
|
-
reason: note('add_missing_target'),
|
|
261
|
-
},
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
break;
|
|
265
|
-
}
|
|
266
|
-
case 'API_ADD_REQUIRED_FORBIDDEN': {
|
|
267
|
-
const relaxed = typeof patch.value === 'string'
|
|
268
|
-
? patch.value.replace(/required/gi, 'optional')
|
|
269
|
-
: { ...(value ?? {}), required: false, ...(value?.nullable === false ? { nullable: true } : {}) };
|
|
270
|
-
options.push({
|
|
271
|
-
strategy: 'relax_to_optional',
|
|
272
|
-
rationale: 'Add the parameter as optional so existing clients keep working; make it required in a versioned endpoint later.',
|
|
273
|
-
candidate: { ...base, value: relaxed, reason: note('relax_to_optional') },
|
|
274
|
-
});
|
|
275
|
-
break;
|
|
276
|
-
}
|
|
277
|
-
case 'API_RENAME_FORBIDDEN': {
|
|
278
|
-
const segments = patch.path.split('.').filter(Boolean);
|
|
279
|
-
const key = (segments[0]?.toLowerCase() === 'endpoints' ? segments[1] : segments[0])?.toLowerCase();
|
|
280
|
-
const endpoint = (contract?.endpoints || []).find((ep) => ep.id?.toLowerCase() === key || ep.path?.toLowerCase() === key);
|
|
281
|
-
const newPath = typeof patch.value === 'string' ? patch.value : value?.path;
|
|
282
|
-
if (endpoint && typeof newPath === 'string' && newPath) {
|
|
283
|
-
const newId = `${endpoint.id || 'endpoint'}-v2`;
|
|
284
|
-
options.push({
|
|
285
|
-
strategy: 'expand_contract_rename',
|
|
286
|
-
rationale: `Serve '${newPath}' as a new endpoint beside '${endpoint.path}' and deprecate the old route once clients have moved.`,
|
|
287
|
-
candidate: {
|
|
288
|
-
...base,
|
|
289
|
-
operation: 'ADD',
|
|
290
|
-
path: `endpoints.${newId}`,
|
|
291
|
-
value: { ...JSON.parse(JSON.stringify(endpoint)), id: newId, path: newPath },
|
|
292
|
-
reason: note('expand_contract_rename'),
|
|
293
|
-
},
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
return options;
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* Replays a candidate against a throwaway copy of the contract in the OS temp
|
|
303
|
-
* directory: governor rules, apply, re-parse, and proof that it changes
|
|
304
|
-
* something while the live `.ai/` file stays byte-identical.
|
|
305
|
-
*/
|
|
306
|
-
export function verifyCandidateInSandbox(targetDir, taskId, candidate) {
|
|
307
|
-
const started = Date.now();
|
|
308
|
-
const fileName = contractFileFor(candidate.target);
|
|
309
|
-
const livePath = path.join(targetDir, '.ai', fileName);
|
|
310
|
-
const liveHashBefore = getContractHash(livePath);
|
|
311
|
-
const checks = [];
|
|
312
|
-
let candidateHash = null;
|
|
313
|
-
let verdict;
|
|
314
|
-
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'nativ-proof-'));
|
|
315
|
-
try {
|
|
316
|
-
const sandboxPath = path.join(sandbox, fileName);
|
|
317
|
-
fs.copyFileSync(livePath, sandboxPath);
|
|
318
|
-
checks.push({ name: 'sandbox_isolated', passed: true, detail: `Replayed against a copy of .ai/${fileName} outside the workspace` });
|
|
319
|
-
const before = JSON.parse(fs.readFileSync(sandboxPath, 'utf8'));
|
|
320
|
-
const patch = { taskId, ...candidate };
|
|
321
|
-
const result = runRules(patch, before);
|
|
322
|
-
verdict = { approved: result.approved, blastRadius: result.blastRadius, ruleId: result.ruleId, message: result.message };
|
|
323
|
-
const safe = result.approved && result.blastRadius === 'LOW_ADDITIVE';
|
|
324
|
-
checks.push({ name: 'governor_rules', passed: safe, detail: `${result.ruleId}: ${result.message}` });
|
|
325
|
-
if (safe) {
|
|
326
|
-
const after = applyPatch(JSON.parse(JSON.stringify(before)), patch);
|
|
327
|
-
const written = atomicWriteJson(sandboxPath, after);
|
|
328
|
-
let reparsed = null;
|
|
329
|
-
try {
|
|
330
|
-
reparsed = JSON.parse(fs.readFileSync(sandboxPath, 'utf8'));
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
// Reported by the check below.
|
|
334
|
-
}
|
|
335
|
-
const collection = candidate.target === 'db_schema' ? reparsed?.tables : reparsed?.endpoints;
|
|
336
|
-
checks.push({
|
|
337
|
-
name: 'contract_parses',
|
|
338
|
-
passed: written && Array.isArray(collection),
|
|
339
|
-
detail: written ? 'Patched contract round-trips as valid JSON' : 'Sandbox write failed',
|
|
340
|
-
});
|
|
341
|
-
const effective = reparsed !== null && semanticHash(reparsed) !== semanticHash(before);
|
|
342
|
-
checks.push({
|
|
343
|
-
name: 'patch_effective',
|
|
344
|
-
passed: effective,
|
|
345
|
-
detail: effective ? 'Candidate changes the contract' : 'Candidate would be a no-op for the governor applier',
|
|
346
|
-
});
|
|
347
|
-
candidateHash = getContractHash(sandboxPath);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
catch (err) {
|
|
351
|
-
checks.push({ name: 'sandbox_replay', passed: false, detail: err?.message ?? String(err) });
|
|
352
|
-
}
|
|
353
|
-
finally {
|
|
354
|
-
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
355
|
-
}
|
|
356
|
-
const untouched = getContractHash(livePath) === liveHashBefore;
|
|
357
|
-
checks.push({
|
|
358
|
-
name: 'live_contract_untouched',
|
|
359
|
-
passed: untouched,
|
|
360
|
-
detail: untouched ? `.ai/${fileName} unchanged during the replay` : `.ai/${fileName} changed during the replay`,
|
|
361
|
-
});
|
|
362
|
-
return {
|
|
363
|
-
isolated: true,
|
|
364
|
-
method: 'sandbox_governor_replay',
|
|
365
|
-
passed: checks.every((c) => c.passed),
|
|
366
|
-
checks,
|
|
367
|
-
baseContractHash: liveHashBefore,
|
|
368
|
-
candidateContractHash: candidateHash,
|
|
369
|
-
verdict,
|
|
370
|
-
verifiedAt: new Date().toISOString(),
|
|
371
|
-
durationMs: Date.now() - started,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Builds the first backward-compatible candidate that survives an isolated
|
|
376
|
-
* replay. Returns null when no candidate can be proven (a human decides).
|
|
377
|
-
*/
|
|
378
|
-
export function generateSelfHealingProposal(targetDir, patch, rule) {
|
|
379
|
-
const livePath = path.join(targetDir, '.ai', contractFileFor(patch.target));
|
|
380
|
-
let contract;
|
|
381
|
-
try {
|
|
382
|
-
contract = JSON.parse(fs.readFileSync(livePath, 'utf8'));
|
|
383
|
-
}
|
|
384
|
-
catch {
|
|
385
|
-
return null; // Missing or corrupt contracts need a human, not a patch.
|
|
386
|
-
}
|
|
387
|
-
const options = buildCandidates(patch, rule, contract, getContractHash(livePath));
|
|
388
|
-
let evaluated = 0;
|
|
389
|
-
for (const option of options) {
|
|
390
|
-
evaluated++;
|
|
391
|
-
const proof = verifyCandidateInSandbox(targetDir, patch.taskId, option.candidate);
|
|
392
|
-
if (!proof.passed)
|
|
393
|
-
continue;
|
|
394
|
-
return {
|
|
395
|
-
kind: 'contract_patch',
|
|
396
|
-
proposalId: `heal-${crypto.randomUUID().slice(0, 8)}`,
|
|
397
|
-
strategy: option.strategy,
|
|
398
|
-
rationale: option.rationale,
|
|
399
|
-
requiresHumanApproval: true,
|
|
400
|
-
candidatesEvaluated: evaluated,
|
|
401
|
-
verificationProof: proof,
|
|
402
|
-
generatedAt: new Date().toISOString(),
|
|
403
|
-
candidate: option.candidate,
|
|
404
|
-
original: toCandidate(patch),
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
return null;
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* Applies an operator-approved proposal. Contract patches are re-checked
|
|
411
|
-
* against the live contract and refused if it moved since the proof was taken.
|
|
412
|
-
* Human-approved patches do not count against the task's patch budget.
|
|
413
|
-
*/
|
|
414
|
-
export function applySelfHealingProposal(targetDir, taskId, proposal) {
|
|
415
|
-
if (proposal.kind === 'restore_tests') {
|
|
416
|
-
const res = spawnSync('git', ['checkout', proposal.baselineRef, '--', ...proposal.files], {
|
|
417
|
-
cwd: targetDir,
|
|
418
|
-
encoding: 'utf8',
|
|
419
|
-
windowsHide: true,
|
|
420
|
-
});
|
|
421
|
-
if (res.status !== 0) {
|
|
422
|
-
return { applied: false, ruleId: 'TEST_RESTORE_FAILED', message: String(res.stderr || res.stdout || 'git checkout failed').trim() };
|
|
423
|
-
}
|
|
424
|
-
CircuitBreaker.recordSuccess(targetDir, taskId);
|
|
425
|
-
return { applied: true, ruleId: 'TEST_SUITE_RESTORED', message: `Restored ${proposal.files.length} test file(s) from ${proposal.baselineRef}` };
|
|
426
|
-
}
|
|
427
|
-
const candidate = proposal.candidate;
|
|
428
|
-
const fileName = contractFileFor(candidate.target);
|
|
429
|
-
const livePath = path.join(targetDir, '.ai', fileName);
|
|
430
|
-
const liveHash = getContractHash(livePath);
|
|
431
|
-
const provenAgainst = proposal.verificationProof.baseContractHash;
|
|
432
|
-
if (provenAgainst && liveHash !== provenAgainst) {
|
|
433
|
-
return {
|
|
434
|
-
applied: false,
|
|
435
|
-
ruleId: 'PROPOSAL_STALE',
|
|
436
|
-
message: `.ai/${fileName} changed after proposal ${proposal.proposalId} was verified; regenerate it against the current contract.`,
|
|
437
|
-
};
|
|
438
|
-
}
|
|
439
|
-
let contract;
|
|
440
|
-
try {
|
|
441
|
-
contract = JSON.parse(fs.readFileSync(livePath, 'utf8'));
|
|
442
|
-
}
|
|
443
|
-
catch (err) {
|
|
444
|
-
return { applied: false, ruleId: 'CONTRACT_JSON_CORRUPT', message: `Failed to read .ai/${fileName}: ${err.message}` };
|
|
445
|
-
}
|
|
446
|
-
const patch = { taskId, ...candidate };
|
|
447
|
-
const result = runRules(patch, contract);
|
|
448
|
-
if (!result.approved || result.blastRadius !== 'LOW_ADDITIVE') {
|
|
449
|
-
return { applied: false, ruleId: result.ruleId, message: result.message };
|
|
450
|
-
}
|
|
451
|
-
const written = atomicWriteJson(livePath, applyPatch(contract, patch));
|
|
452
|
-
appendAuditLog(targetDir, {
|
|
453
|
-
timestamp: new Date().toISOString(),
|
|
454
|
-
taskId,
|
|
455
|
-
target: candidate.target,
|
|
456
|
-
operation: candidate.operation,
|
|
457
|
-
path: candidate.path,
|
|
458
|
-
blastRadius: 'LOW_ADDITIVE',
|
|
459
|
-
ruleId: 'SELF_HEALING_PROPOSAL_APPLIED',
|
|
460
|
-
reason: `${proposal.proposalId} (${proposal.strategy}): ${candidate.reason}`,
|
|
461
|
-
applied: written,
|
|
462
|
-
});
|
|
463
|
-
if (written)
|
|
464
|
-
CircuitBreaker.recordSuccess(targetDir, taskId);
|
|
465
|
-
return {
|
|
466
|
-
applied: written,
|
|
467
|
-
ruleId: written ? 'SELF_HEALING_PROPOSAL_APPLIED' : 'CONTRACT_WRITE_FAILED',
|
|
468
|
-
message: written ? `Applied ${proposal.strategy} to .ai/${fileName}` : `Could not write .ai/${fileName}`,
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
const TEST_CODE_EXTENSION = /\.(?:[cm]?[jt]sx?|py|go|rb|java|kt|cs|php)$/i;
|
|
472
|
-
export function isTestFile(file) {
|
|
473
|
-
const p = file.replace(/\\/g, '/');
|
|
474
|
-
if (!TEST_CODE_EXTENSION.test(p) || /(^|\/)node_modules\//.test(p))
|
|
475
|
-
return false;
|
|
476
|
-
return (/(^|\/)(?:__tests__|tests?|specs?)\//i.test(p) ||
|
|
477
|
-
/[._-](?:test|spec)\.[a-z]+$/i.test(p) ||
|
|
478
|
-
/(^|\/)test[_-][^/]+$/i.test(p));
|
|
479
|
-
}
|
|
480
|
-
/** Heuristic, language-agnostic counts; only deltas against the baseline matter. */
|
|
481
|
-
export function measureTestSuite(source) {
|
|
482
|
-
const count = (re) => (source.match(re) || []).length;
|
|
483
|
-
return {
|
|
484
|
-
assertions: count(/(?<![.\w])(?:assert(?:\.[A-Za-z]+)*|expect|self\.assert[A-Za-z]*|t\.(?:is|ok|not|truthy|falsy|equal|deepEqual|true|false|throws|throwsAsync|notThrows|regex|snapshot|like))\s*\(/g) +
|
|
485
|
-
count(/^\s*assert\s+(?!\()/gm),
|
|
486
|
-
testCases: count(/(?<![.\w])(?:it|test|describe|suite|context)(?:\.(?:each|concurrent|serial))?\s*\(/g) +
|
|
487
|
-
count(/^\s*(?:async\s+)?def\s+test_/gm) +
|
|
488
|
-
count(/^func\s+Test[A-Z_]/gm),
|
|
489
|
-
skipped: count(/(?<![.\w])(?:it|test|describe|suite|context)\.(?:skip|todo)\s*\(|(?<![.\w])x(?:it|test|describe)\s*\(|@pytest\.mark\.skip|\bt\.Skip(?:Now|f)?\(/g),
|
|
490
|
-
focused: count(/(?<![.\w])(?:it|test|describe|suite|context)\.only\s*\(|(?<![.\w])f(?:it|describe)\s*\(/g),
|
|
491
|
-
};
|
|
492
|
-
}
|
|
493
|
-
function git(targetDir, args) {
|
|
494
|
-
const res = spawnSync('git', args, {
|
|
495
|
-
cwd: targetDir,
|
|
496
|
-
encoding: 'utf8',
|
|
497
|
-
windowsHide: true,
|
|
498
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
499
|
-
});
|
|
500
|
-
return { ok: res.status === 0, stdout: res.stdout ?? '' };
|
|
501
|
-
}
|
|
502
|
-
/** Current commit of the checkout at `targetDir`, or null outside git / before the first commit. */
|
|
503
|
-
export function resolveGitHead(targetDir) {
|
|
504
|
-
const res = git(targetDir, ['rev-parse', '--verify', 'HEAD']);
|
|
505
|
-
return res.ok ? res.stdout.trim() || null : null;
|
|
506
|
-
}
|
|
507
|
-
/** Test files and test-named scripts referenced by `package.json` test scripts. */
|
|
508
|
-
function testScriptReferences(packageJson) {
|
|
509
|
-
const scripts = new Set();
|
|
510
|
-
const files = new Set();
|
|
511
|
-
try {
|
|
512
|
-
const pkg = JSON.parse(packageJson);
|
|
513
|
-
for (const [name, command] of Object.entries(pkg?.scripts ?? {})) {
|
|
514
|
-
if (!/^(?:pre|post)?test(?:[:_-]|$)/.test(name) || typeof command !== 'string')
|
|
515
|
-
continue;
|
|
516
|
-
scripts.add(name);
|
|
517
|
-
for (const token of command.split(/[\s;&|'"]+/)) {
|
|
518
|
-
if (token && isTestFile(token))
|
|
519
|
-
files.add(token.replace(/\\/g, '/').replace(/^\.\//, ''));
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
catch {
|
|
524
|
-
// Unparseable package.json: nothing to compare.
|
|
525
|
-
}
|
|
526
|
-
return { scripts, files };
|
|
527
|
-
}
|
|
528
|
-
function quoteArg(value) {
|
|
529
|
-
return /^[\w./@:-]+$/.test(value) ? value : `"${value.replace(/"/g, '\\"')}"`;
|
|
530
|
-
}
|
|
531
|
-
/**
|
|
532
|
-
* Enforces that a task never deletes or weakens the test suites that existed
|
|
533
|
-
* when it started: no deleted test files, no fewer assertions or cases, no new
|
|
534
|
-
* skip/only markers, and no test files dropped from package.json test scripts.
|
|
535
|
-
*/
|
|
536
|
-
export class TestIntegrityGuard {
|
|
537
|
-
static evaluate(targetDir, taskId) {
|
|
538
|
-
const pass = (skippedReason, baselineRef = null, baselineRecorded = false) => ({
|
|
539
|
-
approved: true,
|
|
540
|
-
blastRadius: 'LOW_ADDITIVE',
|
|
541
|
-
ruleId: skippedReason ? 'TEST_INTEGRITY_SKIPPED' : 'TEST_INTEGRITY_OK',
|
|
542
|
-
message: skippedReason ?? 'Existing test suites are intact.',
|
|
543
|
-
violations: [],
|
|
544
|
-
baselineRef,
|
|
545
|
-
baselineRecorded,
|
|
546
|
-
findings: [],
|
|
547
|
-
...(skippedReason ? { skippedReason } : {}),
|
|
548
|
-
});
|
|
549
|
-
if (!git(targetDir, ['rev-parse', '--is-inside-work-tree']).ok)
|
|
550
|
-
return pass('Not a git repository; test integrity cannot be checked.');
|
|
551
|
-
const recorded = CircuitBreaker.getBaseline(targetDir, taskId);
|
|
552
|
-
const recordedValid = recorded ? git(targetDir, ['cat-file', '-e', `${recorded}^{commit}`]).ok : false;
|
|
553
|
-
const baseline = recordedValid ? recorded : resolveGitHead(targetDir);
|
|
554
|
-
if (!baseline)
|
|
555
|
-
return pass('Repository has no commits yet; nothing to protect.');
|
|
556
|
-
const diff = git(targetDir, ['diff', '--name-status', '-M', '--relative', baseline, '--']);
|
|
557
|
-
if (!diff.ok)
|
|
558
|
-
return pass(`Could not diff against ${baseline}.`, baseline, recordedValid);
|
|
559
|
-
const findings = [];
|
|
560
|
-
const baselineSource = (file) => {
|
|
561
|
-
const res = git(targetDir, ['show', `${baseline}:./${file}`]);
|
|
562
|
-
return res.ok ? res.stdout : null;
|
|
563
|
-
};
|
|
564
|
-
for (const line of diff.stdout.split('\n')) {
|
|
565
|
-
const [status, ...paths] = line.trim().split('\t');
|
|
566
|
-
if (!status || !paths.length)
|
|
567
|
-
continue;
|
|
568
|
-
const kind = status[0];
|
|
569
|
-
const from = paths[0];
|
|
570
|
-
const to = paths[paths.length - 1];
|
|
571
|
-
if (kind === 'D' && isTestFile(from)) {
|
|
572
|
-
findings.push({ rule: 'TEST_SUITE_DELETED', path: from, detail: `Test file '${from}' was deleted.` });
|
|
573
|
-
continue;
|
|
574
|
-
}
|
|
575
|
-
if (kind === 'R' && isTestFile(from) && !isTestFile(to)) {
|
|
576
|
-
findings.push({ rule: 'TEST_SUITE_DELETED', path: from, detail: `Test file '${from}' was moved out of the suite to '${to}'.` });
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
if ((kind === 'M' || kind === 'R') && isTestFile(to)) {
|
|
580
|
-
const before = baselineSource(from);
|
|
581
|
-
let after = null;
|
|
582
|
-
try {
|
|
583
|
-
after = fs.readFileSync(path.join(targetDir, to), 'utf8');
|
|
584
|
-
}
|
|
585
|
-
catch {
|
|
586
|
-
// Unreadable now: treat like a deletion.
|
|
587
|
-
}
|
|
588
|
-
if (before === null)
|
|
589
|
-
continue;
|
|
590
|
-
if (after === null) {
|
|
591
|
-
findings.push({ rule: 'TEST_SUITE_DELETED', path: to, detail: `Test file '${to}' can no longer be read.` });
|
|
592
|
-
continue;
|
|
593
|
-
}
|
|
594
|
-
const was = measureTestSuite(before);
|
|
595
|
-
const now = measureTestSuite(after);
|
|
596
|
-
if (now.assertions < was.assertions) {
|
|
597
|
-
findings.push({ rule: 'TEST_ASSERTIONS_REMOVED', path: to, detail: `'${to}' went from ${was.assertions} to ${now.assertions} assertions.` });
|
|
598
|
-
}
|
|
599
|
-
if (now.testCases < was.testCases) {
|
|
600
|
-
findings.push({ rule: 'TEST_CASES_REMOVED', path: to, detail: `'${to}' went from ${was.testCases} to ${now.testCases} test cases.` });
|
|
601
|
-
}
|
|
602
|
-
if (now.skipped > was.skipped) {
|
|
603
|
-
findings.push({ rule: 'TESTS_SKIPPED', path: to, detail: `'${to}' added ${now.skipped - was.skipped} skip/todo marker(s).` });
|
|
604
|
-
}
|
|
605
|
-
if (now.focused > was.focused) {
|
|
606
|
-
findings.push({ rule: 'TESTS_FOCUSED', path: to, detail: `'${to}' added ${now.focused - was.focused} .only marker(s), silencing the rest of the suite.` });
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
if (kind === 'M' && to === 'package.json') {
|
|
610
|
-
const before = baselineSource('package.json');
|
|
611
|
-
let after = '';
|
|
612
|
-
try {
|
|
613
|
-
after = fs.readFileSync(path.join(targetDir, 'package.json'), 'utf8');
|
|
614
|
-
}
|
|
615
|
-
catch {
|
|
616
|
-
// Treated as an empty package.json below.
|
|
617
|
-
}
|
|
618
|
-
if (before !== null) {
|
|
619
|
-
const was = testScriptReferences(before);
|
|
620
|
-
const now = testScriptReferences(after);
|
|
621
|
-
const droppedScripts = [...was.scripts].filter((s) => !now.scripts.has(s));
|
|
622
|
-
const droppedFiles = [...was.files].filter((f) => !now.files.has(f));
|
|
623
|
-
if (droppedScripts.length || droppedFiles.length) {
|
|
624
|
-
findings.push({
|
|
625
|
-
rule: 'TEST_SCRIPT_WEAKENED',
|
|
626
|
-
path: 'package.json',
|
|
627
|
-
detail: `package.json test scripts dropped ${[...droppedScripts.map((s) => `script '${s}'`), ...droppedFiles].join(', ')}.`,
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
if (!findings.length)
|
|
634
|
-
return pass(undefined, baseline, recordedValid);
|
|
635
|
-
return {
|
|
636
|
-
approved: false,
|
|
637
|
-
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
638
|
-
ruleId: 'TEST_INTEGRITY_VIOLATION',
|
|
639
|
-
message: `Task changes would delete or weaken ${findings.length === 1 ? 'an existing test suite' : `existing test suites (${findings.length} findings)`}.`,
|
|
640
|
-
violations: findings.map((f) => `${f.rule}: ${f.detail}`),
|
|
641
|
-
baselineRef: baseline,
|
|
642
|
-
baselineRecorded: recordedValid,
|
|
643
|
-
findings,
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Proposal to restore every flagged file from the baseline, with proof that
|
|
648
|
-
* each baseline blob exists and what the restored suite measures.
|
|
649
|
-
*/
|
|
650
|
-
static proposeRestoration(targetDir, report) {
|
|
651
|
-
if (report.approved || !report.baselineRef)
|
|
652
|
-
return null;
|
|
653
|
-
const started = Date.now();
|
|
654
|
-
const files = [...new Set(report.findings.map((f) => f.path))];
|
|
655
|
-
const baseline = report.baselineRef;
|
|
656
|
-
const checks = files.map((file) => {
|
|
657
|
-
const blob = git(targetDir, ['show', `${baseline}:./${file}`]);
|
|
658
|
-
if (!blob.ok)
|
|
659
|
-
return { name: `baseline:${file}`, passed: false, detail: `No copy of '${file}' at ${baseline.slice(0, 12)}` };
|
|
660
|
-
if (!isTestFile(file))
|
|
661
|
-
return { name: `baseline:${file}`, passed: true, detail: `Baseline copy available (${blob.stdout.length} bytes)` };
|
|
662
|
-
const m = measureTestSuite(blob.stdout);
|
|
663
|
-
return {
|
|
664
|
-
name: `baseline:${file}`,
|
|
665
|
-
passed: true,
|
|
666
|
-
detail: `Restores ${m.assertions} assertions and ${m.testCases} test cases (${m.skipped} skipped, ${m.focused} focused)`,
|
|
667
|
-
};
|
|
668
|
-
});
|
|
669
|
-
return {
|
|
670
|
-
kind: 'restore_tests',
|
|
671
|
-
proposalId: `heal-${crypto.randomUUID().slice(0, 8)}`,
|
|
672
|
-
strategy: 'restore_test_suite',
|
|
673
|
-
rationale: 'Restore the flagged test files from the task baseline, then make the implementation pass the original suite.',
|
|
674
|
-
requiresHumanApproval: true,
|
|
675
|
-
candidatesEvaluated: 1,
|
|
676
|
-
verificationProof: {
|
|
677
|
-
isolated: true,
|
|
678
|
-
method: 'baseline_blob_check',
|
|
679
|
-
passed: checks.length > 0 && checks.every((c) => c.passed),
|
|
680
|
-
checks,
|
|
681
|
-
verifiedAt: new Date().toISOString(),
|
|
682
|
-
durationMs: Date.now() - started,
|
|
683
|
-
},
|
|
684
|
-
generatedAt: new Date().toISOString(),
|
|
685
|
-
baselineRef: baseline,
|
|
686
|
-
files,
|
|
687
|
-
commands: [`git checkout ${baseline} -- ${files.map(quoteArg).join(' ')}`],
|
|
688
|
-
};
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
12
|
export class ContractGovernor {
|
|
692
13
|
static evaluate(targetDir, patch) {
|
|
693
14
|
const heal = (result) => () => generateSelfHealingProposal(targetDir, patch, result);
|
|
@@ -788,8 +109,22 @@ export class ContractGovernor {
|
|
|
788
109
|
const evalResult = patch.target === 'db_schema'
|
|
789
110
|
? evaluateDbPatch(patch, contractData)
|
|
790
111
|
: evaluateApiPatch(patch, contractData);
|
|
112
|
+
// Check governor mode: auto_adopt allows non-destructive or prototype changes without blocking
|
|
113
|
+
let isAutoAdopt = false;
|
|
114
|
+
try {
|
|
115
|
+
const cfgPath = path.join(targetDir, '.nativ', 'config.json');
|
|
116
|
+
if (fs.existsSync(cfgPath)) {
|
|
117
|
+
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
118
|
+
if (raw.governorMode === 'auto_adopt' || raw.profile === 'prototype') {
|
|
119
|
+
isAutoAdopt = true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Non-fatal
|
|
125
|
+
}
|
|
791
126
|
// 5. Handle approval vs rejection
|
|
792
|
-
if (evalResult.approved && evalResult.blastRadius === 'LOW_ADDITIVE') {
|
|
127
|
+
if ((evalResult.approved && evalResult.blastRadius === 'LOW_ADDITIVE') || (isAutoAdopt && evalResult.blastRadius !== 'HIGH_DESTRUCTIVE')) {
|
|
793
128
|
// Apply patch
|
|
794
129
|
const patchedData = patch.target === 'db_schema'
|
|
795
130
|
? applyDbPatch(contractData, patch)
|