@njeybe/nativ 2.0.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 +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export function evaluateApiPatch(patch, currentContracts) {
|
|
2
|
+
const violations = [];
|
|
3
|
+
const { operation, path, value } = patch;
|
|
4
|
+
const normalizedPath = path.trim().toLowerCase();
|
|
5
|
+
const endpoints = currentContracts?.endpoints || [];
|
|
6
|
+
// Parse path: e.g. "endpoints.user-login" or "endpoints./users.get.responses.200.body.avatar"
|
|
7
|
+
const segments = normalizedPath.split('.').filter(Boolean);
|
|
8
|
+
const endpointIdOrPath = segments[0] === 'endpoints' ? segments[1] : segments[0];
|
|
9
|
+
const existingEndpoint = endpoints.find((ep) => ep.id?.toLowerCase() === endpointIdOrPath?.toLowerCase() ||
|
|
10
|
+
ep.path?.toLowerCase() === endpointIdOrPath?.toLowerCase() ||
|
|
11
|
+
`${ep.method?.toLowerCase()} ${ep.path?.toLowerCase()}` === endpointIdOrPath?.toLowerCase());
|
|
12
|
+
// Rule 1: Path Collision & Target Checks
|
|
13
|
+
if (operation === 'ADD') {
|
|
14
|
+
const isNewEndpoint = segments.length <= 2 && (!existingEndpoint || (value?.method && value?.path));
|
|
15
|
+
if (isNewEndpoint && existingEndpoint && value?.method?.toUpperCase() === existingEndpoint.method?.toUpperCase()) {
|
|
16
|
+
violations.push(`PATH_COLLISION: Endpoint '${existingEndpoint.method} ${existingEndpoint.path}' already exists. Use ALTER.`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else if (operation === 'ALTER' || operation === 'DROP') {
|
|
20
|
+
if (!existingEndpoint && segments[0] === 'endpoints' && segments.length <= 2) {
|
|
21
|
+
violations.push(`TARGET_NOT_FOUND: Cannot ${operation.toLowerCase()} non-existent endpoint '${endpointIdOrPath}'.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (violations.length > 0) {
|
|
25
|
+
return {
|
|
26
|
+
approved: false,
|
|
27
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
28
|
+
ruleId: 'API_COLLISION_OR_TARGET_MISMATCH',
|
|
29
|
+
message: violations[0],
|
|
30
|
+
violations,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Rule 2: DROP operations
|
|
34
|
+
if (operation === 'DROP') {
|
|
35
|
+
const isDroppingEndpoint = segments.length <= 2;
|
|
36
|
+
const msg = isDroppingEndpoint
|
|
37
|
+
? `CANNOT_DROP_ENDPOINT: Dropping endpoint '${existingEndpoint?.method || ''} ${existingEndpoint?.path || endpointIdOrPath}' causes immediate 404 client breakage.`
|
|
38
|
+
: `CANNOT_DROP_RESPONSE_FIELD: Dropping field on '${path}' causes client runtime deserialization failures.`;
|
|
39
|
+
violations.push(msg);
|
|
40
|
+
return {
|
|
41
|
+
approved: false,
|
|
42
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
43
|
+
ruleId: 'API_DROP_FORBIDDEN',
|
|
44
|
+
message: msg,
|
|
45
|
+
violations,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// Rule 3: RENAME operations
|
|
49
|
+
if (operation === 'RENAME') {
|
|
50
|
+
const msg = `CANNOT_RENAME_API_ROUTE: Renaming route/path '${endpointIdOrPath}' breaks consumer integration.`;
|
|
51
|
+
violations.push(msg);
|
|
52
|
+
return {
|
|
53
|
+
approved: false,
|
|
54
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
55
|
+
ruleId: 'API_RENAME_FORBIDDEN',
|
|
56
|
+
message: msg,
|
|
57
|
+
violations,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// Rule 4: ALTER operations
|
|
61
|
+
if (operation === 'ALTER') {
|
|
62
|
+
// Auth policy mutation (unauthorized client break)
|
|
63
|
+
if (segments.includes('auth') && existingEndpoint) {
|
|
64
|
+
if (existingEndpoint.auth === false && (value === true || value?.auth === true)) {
|
|
65
|
+
violations.push(`CANNOT_RESTRICT_AUTH: Changing endpoint from public to authenticated breaks unauthenticated consumers.`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Changing HTTP method
|
|
69
|
+
if (segments.includes('method') && existingEndpoint && value && value.toUpperCase() !== existingEndpoint.method?.toUpperCase()) {
|
|
70
|
+
violations.push(`CANNOT_ALTER_HTTP_METHOD: Changing HTTP method from ${existingEndpoint.method} to ${value} breaks client calls.`);
|
|
71
|
+
}
|
|
72
|
+
if (violations.length > 0) {
|
|
73
|
+
return {
|
|
74
|
+
approved: false,
|
|
75
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
76
|
+
ruleId: 'API_ALTER_DESTRUCTIVE',
|
|
77
|
+
message: violations[0],
|
|
78
|
+
violations,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Rule 5: ADD operations
|
|
83
|
+
if (operation === 'ADD') {
|
|
84
|
+
// Adding a brand new endpoint
|
|
85
|
+
const isNewEndpoint = (segments.length <= 2 && !existingEndpoint) || (value?.path && value?.method);
|
|
86
|
+
if (isNewEndpoint) {
|
|
87
|
+
return {
|
|
88
|
+
approved: true,
|
|
89
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
90
|
+
ruleId: 'API_ADD_ENDPOINT_APPROVED',
|
|
91
|
+
message: `Approved: Adding new endpoint '${value?.method || 'GET'} ${value?.path || endpointIdOrPath}' is backward-compatible.`,
|
|
92
|
+
violations: [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Adding request parameter/field
|
|
96
|
+
const isRequestMutation = segments.includes('request') || segments.includes('queryparams') || segments.includes('headers') || segments.includes('body');
|
|
97
|
+
if (isRequestMutation) {
|
|
98
|
+
// Check if required
|
|
99
|
+
const isRequired = value?.required === true ||
|
|
100
|
+
(typeof value === 'string' && value.toLowerCase().includes('required')) ||
|
|
101
|
+
(value?.nullable === false && value?.default === undefined);
|
|
102
|
+
if (isRequired) {
|
|
103
|
+
const msg = `CANNOT_ADD_REQUIRED_REQUEST_FIELD: Adding a required parameter or field to '${path}' causes existing client requests to fail with 400 Bad Request.`;
|
|
104
|
+
violations.push(msg);
|
|
105
|
+
return {
|
|
106
|
+
approved: false,
|
|
107
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
108
|
+
ruleId: 'API_ADD_REQUIRED_FORBIDDEN',
|
|
109
|
+
message: msg,
|
|
110
|
+
violations,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
approved: true,
|
|
115
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
116
|
+
ruleId: 'API_ADD_OPTIONAL_REQUEST_FIELD_APPROVED',
|
|
117
|
+
message: `Approved: Adding optional request field to '${path}'.`,
|
|
118
|
+
violations: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// Adding response field or response status code (Postel's Law)
|
|
122
|
+
const isResponseMutation = segments.includes('responses') || segments.includes('response') || segments.includes('body');
|
|
123
|
+
if (isResponseMutation) {
|
|
124
|
+
return {
|
|
125
|
+
approved: true,
|
|
126
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
127
|
+
ruleId: 'API_ADD_RESPONSE_FIELD_APPROVED',
|
|
128
|
+
message: `Approved: Adding response property or status code to '${path}' (conforms to Postel's Law).`,
|
|
129
|
+
violations: [],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
approved: true,
|
|
135
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
136
|
+
ruleId: 'API_DEFAULT_ADDITIVE',
|
|
137
|
+
message: `API operation '${operation}' on '${path}' approved as safe.`,
|
|
138
|
+
violations: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
export function evaluateDbPatch(patch, currentSchema) {
|
|
2
|
+
const violations = [];
|
|
3
|
+
const { operation, path, value } = patch;
|
|
4
|
+
const normalizedPath = path.trim().toLowerCase();
|
|
5
|
+
// Helper: Find table by name
|
|
6
|
+
const tables = currentSchema?.tables || [];
|
|
7
|
+
// Parse path: e.g. "tables.users.columns.bio" or "users.columns.bio" or "users.columns"
|
|
8
|
+
const segments = normalizedPath.split('.').filter(Boolean);
|
|
9
|
+
const tableName = segments[0] === 'tables' ? segments[1] : segments[0];
|
|
10
|
+
const targetType = segments.includes('columns') ? 'column' : (segments.includes('indexes') ? 'index' : (segments.includes('foreignkeys') ? 'foreignKey' : 'table'));
|
|
11
|
+
const propertyName = segments.length > 2 ? segments[segments.length - 1] : undefined;
|
|
12
|
+
const existingTable = tables.find((t) => t.name?.toLowerCase() === tableName?.toLowerCase());
|
|
13
|
+
const existingColumn = existingTable?.columns?.find((c) => c.name?.toLowerCase() === propertyName?.toLowerCase());
|
|
14
|
+
// Rule 1: Path Collision & Existence Checks
|
|
15
|
+
if (operation === 'ADD') {
|
|
16
|
+
if (targetType === 'table' && existingTable) {
|
|
17
|
+
violations.push(`PATH_COLLISION: Table '${tableName}' already exists in .ai/db_schema.json. Use ALTER.`);
|
|
18
|
+
}
|
|
19
|
+
else if (targetType === 'column' && existingColumn) {
|
|
20
|
+
violations.push(`PATH_COLLISION: Column '${propertyName}' already exists on table '${tableName}'. Use ALTER.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else if (operation === 'ALTER' || operation === 'DROP') {
|
|
24
|
+
if (targetType === 'table' && !existingTable) {
|
|
25
|
+
violations.push(`TARGET_NOT_FOUND: Cannot ${operation.toLowerCase()} non-existent table '${tableName}'.`);
|
|
26
|
+
}
|
|
27
|
+
else if (targetType === 'column' && !existingColumn) {
|
|
28
|
+
violations.push(`TARGET_NOT_FOUND: Cannot ${operation.toLowerCase()} non-existent column '${propertyName}' on table '${tableName}'.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// If path collision or missing target detected, exit immediately with High Destructive
|
|
32
|
+
if (violations.length > 0) {
|
|
33
|
+
return {
|
|
34
|
+
approved: false,
|
|
35
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
36
|
+
ruleId: 'DB_COLLISION_OR_TARGET_MISMATCH',
|
|
37
|
+
message: violations[0],
|
|
38
|
+
violations,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Rule 2: DROP operations are always HIGH_DESTRUCTIVE
|
|
42
|
+
if (operation === 'DROP') {
|
|
43
|
+
const msg = targetType === 'table'
|
|
44
|
+
? `CANNOT_DROP_TABLE: Dropping table '${tableName}' results in permanent data loss and catastrophic runtime failures.`
|
|
45
|
+
: `CANNOT_DROP_COLUMN: Dropping column '${propertyName}' on table '${tableName}' causes data loss and breaks existing queries.`;
|
|
46
|
+
violations.push(msg);
|
|
47
|
+
return {
|
|
48
|
+
approved: false,
|
|
49
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
50
|
+
ruleId: 'DB_DROP_FORBIDDEN',
|
|
51
|
+
message: msg,
|
|
52
|
+
violations,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// Rule 3: RENAME operations are HIGH_DESTRUCTIVE
|
|
56
|
+
if (operation === 'RENAME') {
|
|
57
|
+
const msg = `CANNOT_RENAME_${targetType.toUpperCase()}: Renaming '${propertyName || tableName}' breaks existing queries and ORM mappings.`;
|
|
58
|
+
violations.push(msg);
|
|
59
|
+
return {
|
|
60
|
+
approved: false,
|
|
61
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
62
|
+
ruleId: 'DB_RENAME_FORBIDDEN',
|
|
63
|
+
message: msg,
|
|
64
|
+
violations,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Rule 4: ALTER operations
|
|
68
|
+
if (operation === 'ALTER') {
|
|
69
|
+
if (targetType === 'column') {
|
|
70
|
+
// Nullability tightening: nullable -> NOT NULL without default
|
|
71
|
+
if (existingColumn && existingColumn.nullable !== false && value?.nullable === false && value?.default === undefined && existingColumn.default === undefined) {
|
|
72
|
+
violations.push(`CANNOT_ENFORCE_NOT_NULL: Changing column '${propertyName}' to NOT NULL without a default value will cause runtime failures on existing rows.`);
|
|
73
|
+
}
|
|
74
|
+
// Column type change (narrowing or altering)
|
|
75
|
+
if (existingColumn && value?.type && value.type.toUpperCase() !== existingColumn.type?.toUpperCase()) {
|
|
76
|
+
violations.push(`CANNOT_MUTATE_COLUMN_TYPE: Altering column type from '${existingColumn.type}' to '${value.type}' carries data corruption or truncation risk.`);
|
|
77
|
+
}
|
|
78
|
+
if (violations.length > 0) {
|
|
79
|
+
return {
|
|
80
|
+
approved: false,
|
|
81
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
82
|
+
ruleId: 'DB_ALTER_DESTRUCTIVE',
|
|
83
|
+
message: violations[0],
|
|
84
|
+
violations,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Rule 5: ADD operations
|
|
90
|
+
if (operation === 'ADD') {
|
|
91
|
+
// ADD TABLE
|
|
92
|
+
if (targetType === 'table') {
|
|
93
|
+
return {
|
|
94
|
+
approved: true,
|
|
95
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
96
|
+
ruleId: 'DB_ADD_TABLE_APPROVED',
|
|
97
|
+
message: `Approved: Creating new table '${value?.name || tableName}' is backward-compatible.`,
|
|
98
|
+
violations: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ADD COLUMN
|
|
102
|
+
if (targetType === 'column') {
|
|
103
|
+
const isNullable = value?.nullable !== false;
|
|
104
|
+
const hasDefault = value?.default !== undefined && value?.default !== null;
|
|
105
|
+
if (!isNullable && !hasDefault) {
|
|
106
|
+
const msg = `CANNOT_ADD_REQUIRED_COLUMN_WITHOUT_DEFAULT: Column '${value?.name || propertyName}' cannot be NOT NULL without a default value.`;
|
|
107
|
+
violations.push(msg);
|
|
108
|
+
return {
|
|
109
|
+
approved: false,
|
|
110
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
111
|
+
ruleId: 'DB_ADD_NOT_NULL_FORBIDDEN',
|
|
112
|
+
message: msg,
|
|
113
|
+
violations,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
approved: true,
|
|
118
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
119
|
+
ruleId: 'DB_ADD_COLUMN_APPROVED',
|
|
120
|
+
message: `Approved: Adding additive column '${value?.name || propertyName}' to table '${tableName}'.`,
|
|
121
|
+
violations: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// ADD FOREIGN KEY (Referential integrity DAG check)
|
|
125
|
+
if (targetType === 'foreignKey') {
|
|
126
|
+
const targetTable = value?.targetTable || value?.referenceTable;
|
|
127
|
+
const targetCol = value?.targetColumn || value?.referenceColumn;
|
|
128
|
+
const refTableExists = tables.some((t) => t.name?.toLowerCase() === targetTable?.toLowerCase());
|
|
129
|
+
if (!refTableExists) {
|
|
130
|
+
const msg = `ORPHANED_FOREIGN_KEY: Target table '${targetTable}' does not exist in .ai/db_schema.json.`;
|
|
131
|
+
violations.push(msg);
|
|
132
|
+
return {
|
|
133
|
+
approved: false,
|
|
134
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
135
|
+
ruleId: 'DB_ORPHANED_FK_FORBIDDEN',
|
|
136
|
+
message: msg,
|
|
137
|
+
violations,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Check circular dependency: does targetTable have an existing FK back to this tableName?
|
|
141
|
+
const targetTableObj = tables.find((t) => t.name?.toLowerCase() === targetTable?.toLowerCase());
|
|
142
|
+
const hasCircularFk = targetTableObj?.foreignKeys?.some((fk) => (fk.targetTable?.toLowerCase() === tableName?.toLowerCase() || fk.referenceTable?.toLowerCase() === tableName?.toLowerCase()));
|
|
143
|
+
if (hasCircularFk && value?.nullable === false) {
|
|
144
|
+
const msg = `CIRCULAR_DEPENDENCY: Circular non-nullable foreign key between '${tableName}' and '${targetTable}'.`;
|
|
145
|
+
violations.push(msg);
|
|
146
|
+
return {
|
|
147
|
+
approved: false,
|
|
148
|
+
blastRadius: 'HIGH_DESTRUCTIVE',
|
|
149
|
+
ruleId: 'DB_CIRCULAR_FK_FORBIDDEN',
|
|
150
|
+
message: msg,
|
|
151
|
+
violations,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
approved: true,
|
|
156
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
157
|
+
ruleId: 'DB_ADD_FOREIGN_KEY_APPROVED',
|
|
158
|
+
message: `Approved: Foreign key on '${tableName}' referencing '${targetTable}' verified.`,
|
|
159
|
+
violations: [],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ADD INDEX
|
|
163
|
+
if (targetType === 'index') {
|
|
164
|
+
return {
|
|
165
|
+
approved: true,
|
|
166
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
167
|
+
ruleId: 'DB_ADD_INDEX_APPROVED',
|
|
168
|
+
message: `Approved: Adding index to table '${tableName}'.`,
|
|
169
|
+
violations: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
approved: true,
|
|
175
|
+
blastRadius: 'LOW_ADDITIVE',
|
|
176
|
+
ruleId: 'DB_DEFAULT_ADDITIVE',
|
|
177
|
+
message: `Operation '${operation}' on '${path}' approved as safe.`,
|
|
178
|
+
violations: [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EscalationFile, EscalationRecord } from '../scanner/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared on-disk state of the governor: `.ai/.governor_ledger.json` and `.ai/escalation.json`.
|
|
4
|
+
* Parallel agents in separate worktrees share one `.ai/`, so every read-modify-write here holds
|
|
5
|
+
* a file lock and replaces the file atomically.
|
|
6
|
+
*/
|
|
7
|
+
export interface LedgerEntry {
|
|
8
|
+
consecutiveFailures: number;
|
|
9
|
+
lastFailureTime: string;
|
|
10
|
+
lastRuleId?: string;
|
|
11
|
+
lastViolation?: string;
|
|
12
|
+
patchCount: number;
|
|
13
|
+
/** Commit recorded at `nativ task start`; the test-integrity guard diffs against it. */
|
|
14
|
+
baselineRef?: string;
|
|
15
|
+
baselineRecordedAt?: string;
|
|
16
|
+
}
|
|
17
|
+
export type TaskFailureLedger = Record<string, LedgerEntry>;
|
|
18
|
+
export declare function ledgerPath(targetDir: string): string;
|
|
19
|
+
export declare function escalationPath(targetDir: string): string;
|
|
20
|
+
export declare function loadLedger(targetDir: string): TaskFailureLedger;
|
|
21
|
+
/** Locked read-modify-write of the ledger; the mutator's return value is passed through. */
|
|
22
|
+
export declare function mutateLedger<T>(targetDir: string, mutator: (ledger: TaskFailureLedger) => T): T;
|
|
23
|
+
export declare function loadEscalationFile(targetDir: string, projectName: string): EscalationFile;
|
|
24
|
+
export type EscalationIdStyle = 'sequential' | 'unique';
|
|
25
|
+
/**
|
|
26
|
+
* Appends an escalation under the file lock. The id is assigned inside the lock, so concurrent
|
|
27
|
+
* writers can neither collide nor overwrite each other's records. Returns the id.
|
|
28
|
+
*/
|
|
29
|
+
export declare function appendEscalation(targetDir: string, build: (id: string) => EscalationRecord, options: {
|
|
30
|
+
style: EscalationIdStyle;
|
|
31
|
+
position?: 'first' | 'last';
|
|
32
|
+
projectName?: string;
|
|
33
|
+
}): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { withFileLockSync, writeJsonAtomicSync } from '../core/lock-manager.js';
|
|
5
|
+
export function ledgerPath(targetDir) {
|
|
6
|
+
return path.join(targetDir, '.ai', '.governor_ledger.json');
|
|
7
|
+
}
|
|
8
|
+
export function escalationPath(targetDir) {
|
|
9
|
+
return path.join(targetDir, '.ai', 'escalation.json');
|
|
10
|
+
}
|
|
11
|
+
function readJson(file, fallback) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
14
|
+
// The lock helpers create an empty placeholder before the first write.
|
|
15
|
+
return raw.trim() ? JSON.parse(raw) : fallback;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function loadLedger(targetDir) {
|
|
22
|
+
return readJson(ledgerPath(targetDir), {});
|
|
23
|
+
}
|
|
24
|
+
/** Locked read-modify-write of the ledger; the mutator's return value is passed through. */
|
|
25
|
+
export function mutateLedger(targetDir, mutator) {
|
|
26
|
+
const file = ledgerPath(targetDir);
|
|
27
|
+
return withFileLockSync(file, () => {
|
|
28
|
+
const ledger = readJson(file, {});
|
|
29
|
+
const result = mutator(ledger);
|
|
30
|
+
writeJsonAtomicSync(file, ledger);
|
|
31
|
+
return result;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function loadEscalationFile(targetDir, projectName) {
|
|
35
|
+
const empty = {
|
|
36
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
projectName,
|
|
39
|
+
lastUpdated: new Date().toISOString(),
|
|
40
|
+
escalations: [],
|
|
41
|
+
};
|
|
42
|
+
const file = readJson(escalationPath(targetDir), empty);
|
|
43
|
+
if (!Array.isArray(file.escalations))
|
|
44
|
+
file.escalations = [];
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Picks an id no existing record uses. `sequential` keeps the human-friendly `esc-NN` of
|
|
49
|
+
* `nativ task escalate`; `unique` (circuit-breaker trips) adds random bits to a timestamp so two
|
|
50
|
+
* trips in the same millisecond still get different ids.
|
|
51
|
+
*/
|
|
52
|
+
function nextEscalationId(existing, style) {
|
|
53
|
+
if (style === 'sequential') {
|
|
54
|
+
const numbers = [...existing].map((id) => /^esc-(\d+)$/.exec(id)?.[1]).filter(Boolean).map(Number);
|
|
55
|
+
let n = numbers.length ? Math.max(...numbers) + 1 : 1;
|
|
56
|
+
while (existing.has(`esc-${String(n).padStart(2, '0')}`))
|
|
57
|
+
n++;
|
|
58
|
+
return `esc-${String(n).padStart(2, '0')}`;
|
|
59
|
+
}
|
|
60
|
+
for (;;) {
|
|
61
|
+
const id = `esc-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
62
|
+
if (!existing.has(id))
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Appends an escalation under the file lock. The id is assigned inside the lock, so concurrent
|
|
68
|
+
* writers can neither collide nor overwrite each other's records. Returns the id.
|
|
69
|
+
*/
|
|
70
|
+
export function appendEscalation(targetDir, build, options) {
|
|
71
|
+
const file = escalationPath(targetDir);
|
|
72
|
+
return withFileLockSync(file, () => {
|
|
73
|
+
const data = loadEscalationFile(targetDir, options.projectName ?? path.basename(targetDir));
|
|
74
|
+
const id = nextEscalationId(new Set(data.escalations.map((e) => e?.id).filter(Boolean)), options.style);
|
|
75
|
+
const record = build(id);
|
|
76
|
+
if (options.position === 'last')
|
|
77
|
+
data.escalations.push(record);
|
|
78
|
+
else
|
|
79
|
+
data.escalations.unshift(record);
|
|
80
|
+
data.lastUpdated = new Date().toISOString();
|
|
81
|
+
writeJsonAtomicSync(file, data);
|
|
82
|
+
return id;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export type BlastRadius = 'LOW_ADDITIVE' | 'HIGH_DESTRUCTIVE';
|
|
2
|
+
export type PatchOperation = 'ADD' | 'ALTER' | 'DROP' | 'RENAME';
|
|
3
|
+
export type ContractTarget = 'db_schema' | 'api_contracts';
|
|
4
|
+
export interface ContractPatch {
|
|
5
|
+
taskId: string;
|
|
6
|
+
target: ContractTarget;
|
|
7
|
+
operation: PatchOperation;
|
|
8
|
+
path: string;
|
|
9
|
+
value?: any;
|
|
10
|
+
before?: any;
|
|
11
|
+
reason: string;
|
|
12
|
+
baseHash?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface AuditLogEntry {
|
|
15
|
+
timestamp: string;
|
|
16
|
+
taskId: string;
|
|
17
|
+
target: ContractTarget;
|
|
18
|
+
operation: PatchOperation;
|
|
19
|
+
path: string;
|
|
20
|
+
blastRadius: BlastRadius;
|
|
21
|
+
ruleId: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
applied: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface CircuitBreakerState {
|
|
26
|
+
active: boolean;
|
|
27
|
+
consecutiveFailures: number;
|
|
28
|
+
maxThreshold: number;
|
|
29
|
+
tripped: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface EscalationDiagnosticBundle {
|
|
32
|
+
escalationId: string;
|
|
33
|
+
taskId: string;
|
|
34
|
+
circuitBreakerTripped: boolean;
|
|
35
|
+
consecutiveFailures: number;
|
|
36
|
+
intent: string;
|
|
37
|
+
invariantCollision: string;
|
|
38
|
+
proposedPatch: {
|
|
39
|
+
target: ContractTarget;
|
|
40
|
+
operation: PatchOperation;
|
|
41
|
+
path: string;
|
|
42
|
+
value?: any;
|
|
43
|
+
reason: string;
|
|
44
|
+
};
|
|
45
|
+
recommendedActions: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface GovernorVerdict {
|
|
48
|
+
approved: boolean;
|
|
49
|
+
blastRadius: BlastRadius;
|
|
50
|
+
ruleId: string;
|
|
51
|
+
message: string;
|
|
52
|
+
violations: string[];
|
|
53
|
+
patchApplied: boolean;
|
|
54
|
+
circuitBreaker: CircuitBreakerState;
|
|
55
|
+
auditEntry?: AuditLogEntry;
|
|
56
|
+
diagnosticBundle?: EscalationDiagnosticBundle;
|
|
57
|
+
}
|
|
58
|
+
export interface RuleEvaluationResult {
|
|
59
|
+
approved: boolean;
|
|
60
|
+
blastRadius: BlastRadius;
|
|
61
|
+
ruleId: string;
|
|
62
|
+
message: string;
|
|
63
|
+
violations: string[];
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED