backend-skeleton 1.5.0 → 1.7.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/README.md +113 -8
- package/bin/bskel.mjs +549 -63
- package/contracts/completeness.mjs +12 -1
- package/contracts/openapi.mjs +125 -18
- package/handles/providers/java-spring/ast-bridge.mjs +85 -1
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +407 -0
- package/handles/providers/java-spring/emit.mjs +126 -6
- package/handles/providers/java-spring/plan.mjs +220 -74
- package/handles/providers/java-spring/source-splice.mjs +477 -0
- package/handles/providers/java-spring/templates/AuthorizationPolicyStub.java.tmpl +30 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +21 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +26 -0
- package/handles/providers/java-spring/templates/ResourceResolverPolicyStub.java.tmpl +9 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +3 -3
- package/lib/attest.mjs +59 -1
- package/lib/cli.mjs +125 -7
- package/lib/decision-log.mjs +58 -0
- package/lib/doctor.mjs +23 -0
- package/lib/exit-codes.mjs +17 -0
- package/lib/gate-definitions.mjs +65 -2
- package/lib/gate-export.mjs +250 -0
- package/lib/impact-export-graphify.mjs +145 -0
- package/lib/impact-graph.mjs +194 -0
- package/lib/impact-surface.mjs +158 -0
- package/lib/impact.mjs +334 -0
- package/lib/patch-kinds.mjs +24 -0
- package/lib/repo.mjs +46 -0
- package/lib/workflow.mjs +16 -0
- package/package.json +1 -1
- package/scanners/adapters/_java-spring-analyzer.mjs +6 -0
- package/schemas/decision-event.schema.json +46 -0
- package/schemas/gate-attestation.schema.json +6 -1
- package/schemas/gate-export.schema.json +606 -22
- package/schemas/handles-plan.schema.json +32 -0
- package/schemas/impact-baseline.schema.json +59 -0
- package/schemas/impact-graph.schema.json +53 -0
- package/schemas/impact-report.schema.json +86 -0
- package/schemas/impact-resolution.schema.json +33 -0
- package/schemas/java-source-splice.schema.json +84 -0
- package/schemas/patch-transaction.schema.json +87 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// D-cross-feature-impact-graph (D3): a feature's own public-surface projection + field-level change
|
|
2
|
+
// detection, entirely git-independent (specs/ is commonly gitignored -- see D-contract-history's
|
|
3
|
+
// own finding, which is why `bskel contract history`'s git-diff approach cannot be reused here).
|
|
4
|
+
// *_shape_hash reuses lib/attest.mjs's canonicalization (K1) verbatim -- one implementation of
|
|
5
|
+
// "what does it mean for two JSON values to be the same", not a second one that could disagree.
|
|
6
|
+
import { readJsonIfExists, sha256File, sha256String, writeFileAtomic } from './fsutil.mjs';
|
|
7
|
+
import { specPath } from './paths.mjs';
|
|
8
|
+
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
9
|
+
import { canonicalize, assertCanonicalizable } from './attest.mjs';
|
|
10
|
+
import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
|
|
11
|
+
import { loadFieldDependencies, listDownstreamDependents } from './field-dependencies.mjs';
|
|
12
|
+
|
|
13
|
+
const BASELINE_SCHEMA = 'sbf.impact-baseline/1';
|
|
14
|
+
|
|
15
|
+
function shapeHash(schema) {
|
|
16
|
+
if (schema === undefined || schema === null || schema === false) return null;
|
|
17
|
+
assertCanonicalizable(schema);
|
|
18
|
+
return sha256String(canonicalize(schema));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ownDisposedModule(root, featureId) {
|
|
22
|
+
const report = hydrateScanReportFilePaths(readJsonIfExists(specPath(root, featureId, 'brownfield-scan.json')), root);
|
|
23
|
+
if (!report) return null;
|
|
24
|
+
const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
|
|
25
|
+
if (!moduleName) return null;
|
|
26
|
+
return report.related_modules?.find((m) => m.module === moduleName) ?? null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// D-cross-feature-impact-graph (D3): the current-moment surface for one feature, built entirely
|
|
30
|
+
// from the already-emitted contract + already-persisted scan report + already-declared
|
|
31
|
+
// dependencies.json -- zero new source scanning, zero live DB, zero LLM.
|
|
32
|
+
export function computeSurface(root, featureId) {
|
|
33
|
+
const contract = readJsonIfExists(specPath(root, featureId, 'contracts', `${featureId}.schema.json`));
|
|
34
|
+
const operations = {};
|
|
35
|
+
for (const [opId, op] of Object.entries(contract?.operations ?? {})) {
|
|
36
|
+
operations[opId] = {
|
|
37
|
+
verb: op.verb ?? null,
|
|
38
|
+
path: op.path ?? null,
|
|
39
|
+
request_shape_hash: shapeHash(op.requestBodySchema),
|
|
40
|
+
response_shape_hash: shapeHash(op.responseSchema),
|
|
41
|
+
error_shape_hash: shapeHash(op.errorSchema),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const mod = ownDisposedModule(root, featureId);
|
|
46
|
+
const classes = mod ? [...(mod.entities ?? []), ...(mod.dtos ?? [])] : [];
|
|
47
|
+
const resources = {};
|
|
48
|
+
for (const cls of classes) {
|
|
49
|
+
resources[cls.className] = {
|
|
50
|
+
file_sha256: cls.file ? sha256File(cls.file) : null,
|
|
51
|
+
table: cls.table ?? null,
|
|
52
|
+
table_source: cls.tableSource ?? null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Deliberately sparse (D3's own stated limitation, mirroring D-field-dependency's EXIT): only
|
|
57
|
+
// fields SOMETHING has already named -- there is no per-adapter field enumerator to draw the
|
|
58
|
+
// full set from. Two directions, both needed: (a) fields THIS feature's own dependencies.json
|
|
59
|
+
// names as a TARGET (so removing/moving the file it depends on shows up on ITS OWN surface, for
|
|
60
|
+
// symmetry/debugging) and (b) fields OTHER features' dependencies.json name as THIS feature's
|
|
61
|
+
// SOURCE (via listDownstreamDependents() -- the exact reverse lookup describeDownstreamImpact()
|
|
62
|
+
// already uses) -- (b) is the one that actually matters for impact detection: it is what lets a
|
|
63
|
+
// change on the UPSTREAM/source feature's own `impact check` see "a field of mine that a
|
|
64
|
+
// downstream feature depends on just moved", not just the downstream feature seeing its own
|
|
65
|
+
// dependency go stale. Missing (b) would mean `field_source_moved` could only ever appear on the
|
|
66
|
+
// declaring (downstream) feature's own surface -- structurally unable to catch the upstream
|
|
67
|
+
// change this whole item exists to gate on. Found and fixed while writing this module's own
|
|
68
|
+
// headline test.
|
|
69
|
+
const deps = loadFieldDependencies(root, featureId);
|
|
70
|
+
const fields = {};
|
|
71
|
+
for (const dep of deps.dependencies) {
|
|
72
|
+
const resolved = classes.find((c) => c.className === dep.target.resourceType);
|
|
73
|
+
fields[`${dep.target.resourceType}.${dep.target.fieldName}`] = {
|
|
74
|
+
source_file_sha256: resolved?.file ? sha256File(resolved.file) : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
for (const { dep } of listDownstreamDependents(root, featureId)) {
|
|
78
|
+
const key = `${dep.source.resourceType}.${dep.source.fieldName}`;
|
|
79
|
+
if (key in fields) continue;
|
|
80
|
+
const resolved = classes.find((c) => c.className === dep.source.resourceType);
|
|
81
|
+
fields[key] = { source_file_sha256: resolved?.file ? sha256File(resolved.file) : null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { operations, resources, fields };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function impactBaselinePath(root, featureId) {
|
|
88
|
+
return specPath(root, featureId, 'impact-baseline.json');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadBaseline(root, featureId) {
|
|
92
|
+
const p = impactBaselinePath(root, featureId);
|
|
93
|
+
const parsed = readJsonIfExists(p);
|
|
94
|
+
if (parsed === null) return null;
|
|
95
|
+
const { ok, errors } = validateAgainstSchema('impact-baseline.schema.json', parsed);
|
|
96
|
+
if (!ok) throw new Error(`${p}: does not match schemas/impact-baseline.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function saveBaseline(root, featureId, surface, { capturedAt = new Date().toISOString() } = {}) {
|
|
101
|
+
const doc = { schema: BASELINE_SCHEMA, feature_id: featureId, captured_at: capturedAt, surface };
|
|
102
|
+
const { ok, errors } = validateAgainstSchema('impact-baseline.schema.json', doc);
|
|
103
|
+
if (!ok) throw new Error(`refusing to write an invalid impact baseline for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
104
|
+
writeFileAtomic(impactBaselinePath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
|
|
105
|
+
return doc;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// D-cross-feature-impact-graph (D3): field-level change KINDS, not a full structural diff --
|
|
109
|
+
// naming what changed inside a subtree would need a real schema differ this project doesn't have
|
|
110
|
+
// (see D3's own EXIT, framing a future structural differ as an additive refinement). Deliberately
|
|
111
|
+
// no field_added/field_removed for the SAME reason computeSurface()'s `fields` is sparse: this
|
|
112
|
+
// project cannot honestly enumerate a resource's full field set, so it cannot honestly claim one
|
|
113
|
+
// was added or removed -- only that the file backing an ALREADY-DECLARED field moved
|
|
114
|
+
// (`field_source_moved`).
|
|
115
|
+
export function diffSurface(before, after) {
|
|
116
|
+
const b = before ?? { operations: {}, resources: {}, fields: {} };
|
|
117
|
+
const changes = [];
|
|
118
|
+
|
|
119
|
+
const opIds = new Set([...Object.keys(b.operations ?? {}), ...Object.keys(after.operations ?? {})]);
|
|
120
|
+
for (const opId of opIds) {
|
|
121
|
+
const prev = b.operations?.[opId];
|
|
122
|
+
const next = after.operations?.[opId];
|
|
123
|
+
if (!next) { changes.push({ kind: 'operation_removed', subject: opId, from: prev, to: null }); continue; }
|
|
124
|
+
if (!prev) { changes.push({ kind: 'operation_added', subject: opId, from: null, to: next }); continue; }
|
|
125
|
+
if (prev.verb !== next.verb) changes.push({ kind: 'operation_verb_changed', subject: opId, from: prev.verb, to: next.verb });
|
|
126
|
+
if (prev.path !== next.path) changes.push({ kind: 'operation_path_changed', subject: opId, from: prev.path, to: next.path });
|
|
127
|
+
if (prev.request_shape_hash !== next.request_shape_hash) changes.push({ kind: 'operation_request_shape_changed', subject: opId, from: prev.request_shape_hash, to: next.request_shape_hash });
|
|
128
|
+
if (prev.response_shape_hash !== next.response_shape_hash) changes.push({ kind: 'operation_response_shape_changed', subject: opId, from: prev.response_shape_hash, to: next.response_shape_hash });
|
|
129
|
+
if (prev.error_shape_hash !== next.error_shape_hash) changes.push({ kind: 'operation_error_shape_changed', subject: opId, from: prev.error_shape_hash, to: next.error_shape_hash });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const resourceTypes = new Set([...Object.keys(b.resources ?? {}), ...Object.keys(after.resources ?? {})]);
|
|
133
|
+
for (const type of resourceTypes) {
|
|
134
|
+
const prev = b.resources?.[type];
|
|
135
|
+
const next = after.resources?.[type];
|
|
136
|
+
if (!next) { changes.push({ kind: 'resource_removed', subject: type, from: prev, to: null }); continue; }
|
|
137
|
+
if (!prev) continue; // a brand-new resource has no downstream yet (nothing could depend on it before it existed)
|
|
138
|
+
if (prev.table !== next.table) changes.push({ kind: 'resource_table_changed', subject: type, from: prev.table, to: next.table });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fieldKeys = new Set([...Object.keys(b.fields ?? {}), ...Object.keys(after.fields ?? {})]);
|
|
142
|
+
for (const key of fieldKeys) {
|
|
143
|
+
const prev = b.fields?.[key];
|
|
144
|
+
const next = after.fields?.[key];
|
|
145
|
+
if (!prev || !next) continue; // add/remove of a declared-dependency field is covered by the dependency's own gate, not this one
|
|
146
|
+
if (prev.source_file_sha256 !== next.source_file_sha256) changes.push({ kind: 'field_source_moved', subject: key, from: prev.source_file_sha256, to: next.source_file_sha256 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return changes.map((c) => ({ ...c, change_key: changeKey(c) }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The anti-rubber-stamp key: embeds the NEW hash, so a disposition recorded for one shape can
|
|
153
|
+
// never cover a LATER, different change to the same subject -- there is no wildcard by
|
|
154
|
+
// construction, matching cross-feature-resolution.schema.json's own stated discipline.
|
|
155
|
+
export function changeKey(change) {
|
|
156
|
+
const digest = sha256String(canonicalize({ kind: change.kind, subject: change.subject, to: change.to }));
|
|
157
|
+
return `${change.kind}:${change.subject}:${digest.slice(0, 12)}`;
|
|
158
|
+
}
|
package/lib/impact.mjs
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// D-cross-feature-impact-graph (D5/D6): `bskel impact check` (report, read-mostly) / `bskel impact
|
|
2
|
+
// accept` (decide, advances the baseline) / `bskel impact disposition` (compatible|migrate|waive) /
|
|
3
|
+
// `bskel impact ack` (clears a migrate obligation on the DOWNSTREAM side). Mirrors
|
|
4
|
+
// lib/cross-feature-collisions.mjs's own check/waive split and lib/field-dependencies.mjs's
|
|
5
|
+
// DependencyOperationError pattern exactly -- one shared error vocabulary a CLI caller (and a
|
|
6
|
+
// future HTTP caller, per D-http-serving-layer) both derive their own response shape from.
|
|
7
|
+
import { readJsonIfExists, sha256File, writeFileAtomic } from './fsutil.mjs';
|
|
8
|
+
import { specPath } from './paths.mjs';
|
|
9
|
+
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
10
|
+
import { listFeatures } from './featurelifecycle.mjs';
|
|
11
|
+
import { withLockSync } from './lock.mjs';
|
|
12
|
+
import { requireValidFeatureId } from './featureid.mjs';
|
|
13
|
+
import { EXIT_CODES } from './exit-codes.mjs';
|
|
14
|
+
import { buildImpactGraph } from './impact-graph.mjs';
|
|
15
|
+
import { computeSurface, loadBaseline, saveBaseline, diffSurface } from './impact-surface.mjs';
|
|
16
|
+
import { appendDecisionEvent } from './decision-log.mjs';
|
|
17
|
+
|
|
18
|
+
const REPORT_SCHEMA = 'sbf.impact-report/1';
|
|
19
|
+
const RESOLUTION_SCHEMA = 'sbf.impact-resolution/1';
|
|
20
|
+
|
|
21
|
+
export class ImpactOperationError extends Error {
|
|
22
|
+
constructor(message, { httpStatus = 400, exitCode = EXIT_CODES.BAD_ARGS, reasonCode = 'BAD_ARGS' } = {}) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'ImpactOperationError';
|
|
25
|
+
this.httpStatus = httpStatus;
|
|
26
|
+
this.exitCode = exitCode;
|
|
27
|
+
this.reasonCode = reasonCode;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireValidOr400(id) {
|
|
32
|
+
try {
|
|
33
|
+
requireValidFeatureId(id);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new ImpactOperationError(err.message, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function impactReportPath(root, featureId) {
|
|
40
|
+
return specPath(root, featureId, 'impact-report.json');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function impactResolutionPath(root, featureId) {
|
|
44
|
+
return specPath(root, featureId, 'impact-resolution.json');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadImpactResolution(root, featureId) {
|
|
48
|
+
const p = impactResolutionPath(root, featureId);
|
|
49
|
+
const parsed = readJsonIfExists(p);
|
|
50
|
+
if (parsed === null) return { schema: RESOLUTION_SCHEMA, feature_id: featureId, dispositions: [] };
|
|
51
|
+
const { ok, errors } = validateAgainstSchema('impact-resolution.schema.json', parsed);
|
|
52
|
+
if (!ok) throw new Error(`${p}: does not match schemas/impact-resolution.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function saveImpactResolution(root, featureId, doc) {
|
|
57
|
+
const { ok, errors } = validateAgainstSchema('impact-resolution.schema.json', doc);
|
|
58
|
+
if (!ok) throw new Error(`refusing to write an invalid impact resolution for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
59
|
+
writeFileAtomic(impactResolutionPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
|
|
60
|
+
return doc;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function loadImpactReport(root, featureId) {
|
|
64
|
+
return readJsonIfExists(impactReportPath(root, featureId));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveImpactReport(root, featureId, doc) {
|
|
68
|
+
const { ok, errors } = validateAgainstSchema('impact-report.schema.json', doc);
|
|
69
|
+
if (!ok) throw new Error(`refusing to write an invalid impact report for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
|
|
70
|
+
writeFileAtomic(impactReportPath(root, featureId), `${JSON.stringify(doc, null, 2)}\n`);
|
|
71
|
+
return doc;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Every OTHER feature's owner set for a graph node -- a table node's owner is derived from whoever
|
|
75
|
+
// maps_to_table it (reverse lookup); every other node type already carries its own feature_id.
|
|
76
|
+
function ownerFeaturesOf(graph, nodeId) {
|
|
77
|
+
const node = graph.nodes.find((n) => n.id === nodeId);
|
|
78
|
+
if (!node) return new Set();
|
|
79
|
+
if (node.feature_id) return new Set([node.feature_id]);
|
|
80
|
+
if (node.type === 'table') {
|
|
81
|
+
const owners = new Set();
|
|
82
|
+
for (const e of graph.edges) {
|
|
83
|
+
if (e.relation === 'maps_to_table' && e.target === nodeId) {
|
|
84
|
+
const r = graph.nodes.find((n) => n.id === e.source);
|
|
85
|
+
if (r?.feature_id) owners.add(r.feature_id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return owners;
|
|
89
|
+
}
|
|
90
|
+
return new Set();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function changeSubjectNodeId(featureId, change) {
|
|
94
|
+
if (change.kind.startsWith('operation_')) return `${featureId}#${change.subject}`;
|
|
95
|
+
if (change.kind === 'field_source_moved') return `${featureId}::${change.subject}`;
|
|
96
|
+
// resource_removed / resource_table_changed -- subject is a bare resourceType
|
|
97
|
+
return `${featureId}::${change.subject}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// D-cross-feature-impact-graph: walks the graph from ONE change's own node outward one hop, over
|
|
101
|
+
// the three relations that can name a real downstream consumer. `derives_from` only counts when
|
|
102
|
+
// the changed node is the DEPENDED-ON side (edge.target) -- something reading FROM it, not the
|
|
103
|
+
// other way around.
|
|
104
|
+
function findOutboundImpacts(graph, subjectNodeId, ownFeatureId) {
|
|
105
|
+
const impacts = [];
|
|
106
|
+
for (const e of graph.edges) {
|
|
107
|
+
let counterpartId = null;
|
|
108
|
+
if (e.relation === 'derives_from' && e.target === subjectNodeId) counterpartId = e.source;
|
|
109
|
+
else if (e.relation === 'fk_references' && (e.source === subjectNodeId || e.target === subjectNodeId)) counterpartId = e.source === subjectNodeId ? e.target : e.source;
|
|
110
|
+
else if (e.relation === 'name_collides_with' && (e.source === subjectNodeId || e.target === subjectNodeId)) counterpartId = e.source === subjectNodeId ? e.target : e.source;
|
|
111
|
+
else continue;
|
|
112
|
+
for (const feat of ownerFeaturesOf(graph, counterpartId)) {
|
|
113
|
+
if (feat === ownFeatureId) continue;
|
|
114
|
+
impacts.push({ downstream_feature: feat, via: e.relation, basis: e.basis, confidence: e.confidence });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return impacts;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isDispositionActive(d, nowMs) {
|
|
121
|
+
if (d.mode === 'waive') {
|
|
122
|
+
if (!d.expires_at) return false;
|
|
123
|
+
return Date.parse(d.expires_at) > nowMs;
|
|
124
|
+
}
|
|
125
|
+
if (d.mode === 'migrate') return true; // migrate stays "recorded" indefinitely -- clearing the BLOCK is acknowledged, tracked separately
|
|
126
|
+
return true; // compatible
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findDisposition(resolution, changeKey, downstreamFeature, nowMs) {
|
|
130
|
+
return (resolution.dispositions ?? []).find((d) => d.change_key === changeKey && d.downstream_feature === downstreamFeature && isDispositionActive(d, nowMs));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// D-cross-feature-impact-graph (D5): read-mostly -- computes the current surface, diffs it against
|
|
134
|
+
// impact-baseline.json, walks the graph for each change, and writes impact-report.json. Never
|
|
135
|
+
// advances the baseline (only acceptImpact() does).
|
|
136
|
+
export function checkImpact(root, featureId, { now = new Date() } = {}) {
|
|
137
|
+
requireValidOr400(featureId);
|
|
138
|
+
const graph = buildImpactGraph(root, { nowIso: now.toISOString() });
|
|
139
|
+
const baseline = loadBaseline(root, featureId);
|
|
140
|
+
const currentSurface = computeSurface(root, featureId);
|
|
141
|
+
const changes = diffSurface(baseline?.surface ?? null, currentSurface);
|
|
142
|
+
const resolution = loadImpactResolution(root, featureId);
|
|
143
|
+
|
|
144
|
+
const outbound = [];
|
|
145
|
+
for (const change of changes) {
|
|
146
|
+
const subjectNodeId = changeSubjectNodeId(featureId, change);
|
|
147
|
+
for (const impact of findOutboundImpacts(graph, subjectNodeId, featureId)) {
|
|
148
|
+
const disp = findDisposition(resolution, change.change_key, impact.downstream_feature, now.getTime());
|
|
149
|
+
outbound.push({ change_key: change.change_key, downstream_feature: impact.downstream_feature, via: impact.via, basis: impact.basis, confidence: impact.confidence, disposition: disp?.mode ?? null });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// inbound: every OTHER feature's own resolution naming THIS feature as downstream, mode migrate, not yet acknowledged
|
|
154
|
+
const inbound = [];
|
|
155
|
+
for (const other of listFeatures(root)) {
|
|
156
|
+
if (other.feature_id === featureId) continue;
|
|
157
|
+
const otherResolution = loadImpactResolution(root, other.feature_id);
|
|
158
|
+
for (const d of otherResolution.dispositions ?? []) {
|
|
159
|
+
if (d.mode === 'migrate' && d.downstream_feature === featureId) {
|
|
160
|
+
inbound.push({ upstream_feature: other.feature_id, change_key: d.change_key, mode: 'migrate', tracked_by: d.tracked_by, acknowledged: Boolean(d.acknowledged) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const unknowns = [];
|
|
166
|
+
if (!baseline) unknowns.push(`no impact-baseline.json for "${featureId}" yet -- every operation/resource reports as newly added, run \`bskel impact accept --feature ${featureId}\` to capture the first baseline`);
|
|
167
|
+
|
|
168
|
+
const report = {
|
|
169
|
+
schema: REPORT_SCHEMA,
|
|
170
|
+
feature_id: featureId,
|
|
171
|
+
generated_at: now.toISOString(),
|
|
172
|
+
baseline: { present: Boolean(baseline), captured_at: baseline?.captured_at ?? null },
|
|
173
|
+
changes,
|
|
174
|
+
outbound,
|
|
175
|
+
inbound,
|
|
176
|
+
unknowns,
|
|
177
|
+
};
|
|
178
|
+
saveImpactReport(root, featureId, report);
|
|
179
|
+
return { report, evaluation: evaluateImpacts(report) };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// D-cross-feature-impact-graph (D6): only a PROVEN, undisposed outbound impact blocks -- the
|
|
183
|
+
// gate-fatigue control Codex's own review named as the risk to answer directly. An unacknowledged
|
|
184
|
+
// inbound migrate obligation also blocks (the two-sided handshake).
|
|
185
|
+
export function evaluateImpacts(report) {
|
|
186
|
+
const blockingOutbound = report.outbound.filter((o) => o.confidence === 'proven' && !o.disposition);
|
|
187
|
+
const blockingInbound = report.inbound.filter((i) => !i.acknowledged);
|
|
188
|
+
return {
|
|
189
|
+
blocking: blockingOutbound.length > 0 || blockingInbound.length > 0,
|
|
190
|
+
blockingOutbound,
|
|
191
|
+
blockingInbound,
|
|
192
|
+
heuristicOutbound: report.outbound.filter((o) => o.confidence === 'heuristic'),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// D-cross-feature-impact-graph (D5): the DECIDE half -- refuses (AWAITING_DISPOSITION) if any
|
|
197
|
+
// proven outbound impact is undisposed or any inbound migration is unacknowledged; otherwise
|
|
198
|
+
// atomically rewrites impact-baseline.json from the CURRENT surface. On a feature with no prior
|
|
199
|
+
// baseline, `checkImpact` above already reports every operation as `operation_added` with zero
|
|
200
|
+
// downstream impacts (nothing can depend on what didn't exist), so this captures trivially -- no
|
|
201
|
+
// bootstrap special case.
|
|
202
|
+
export function acceptImpact(root, featureId, { now = new Date() } = {}) {
|
|
203
|
+
requireValidOr400(featureId);
|
|
204
|
+
return withLockSync(root, 'state', () => {
|
|
205
|
+
const { report, evaluation } = checkImpact(root, featureId, { now });
|
|
206
|
+
if (evaluation.blocking) {
|
|
207
|
+
throw new ImpactOperationError(
|
|
208
|
+
`"${featureId}" has ${evaluation.blockingOutbound.length} undisposed proven outbound impact(s) and ${evaluation.blockingInbound.length} unacknowledged inbound migration(s) -- run \`bskel impact disposition\`/\`bskel impact ack\` first, or \`bskel gate force impact --feature ${featureId} --reason "..."\` to override (recorded in signed attestations)`,
|
|
209
|
+
{ httpStatus: 409, exitCode: EXIT_CODES.AWAITING_DISPOSITION, reasonCode: 'GATE_AWAITING_DISPOSITION' },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
const surface = computeSurface(root, featureId);
|
|
213
|
+
const baseline = saveBaseline(root, featureId, surface, { capturedAt: now.toISOString() });
|
|
214
|
+
return { baseline, report };
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// D-cross-feature-impact-graph (D6): records one of {compatible, migrate, waive} for exactly one
|
|
219
|
+
// {change_key, downstream_feature} pair -- never a wildcard. `migrate` requires --tracked-by
|
|
220
|
+
// (non-empty); `waive` requires --expires-days (a positive integer), reusing the SAME decay
|
|
221
|
+
// posture D-waiver-expiry already ships for `contract waive`.
|
|
222
|
+
export function recordDisposition(root, { feature, changeKey, downstreamFeature, mode, reason, trackedBy = null, expiresDays = null, now = new Date() }) {
|
|
223
|
+
requireValidOr400(feature);
|
|
224
|
+
requireValidOr400(downstreamFeature);
|
|
225
|
+
if (!reason || !reason.trim()) {
|
|
226
|
+
throw new ImpactOperationError('bskel impact disposition requires --reason "..." -- every disposition must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
227
|
+
}
|
|
228
|
+
if (!['compatible', 'migrate', 'waive'].includes(mode)) {
|
|
229
|
+
throw new ImpactOperationError(`--mode must be one of compatible|migrate|waive (got "${mode}")`, { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
230
|
+
}
|
|
231
|
+
if (mode === 'migrate' && (!trackedBy || !trackedBy.trim())) {
|
|
232
|
+
throw new ImpactOperationError('--mode migrate requires --tracked-by "<issue/PR reference>" -- a migrate disposition with nothing to track is a no-op that looks like a decision', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
233
|
+
}
|
|
234
|
+
let expiresAt = null;
|
|
235
|
+
if (mode === 'waive') {
|
|
236
|
+
if (!(Number.isInteger(expiresDays) && expiresDays > 0)) {
|
|
237
|
+
throw new ImpactOperationError('--mode waive requires --expires-days <N> (a positive integer) -- same decay posture as `contract waive --expires`', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
238
|
+
}
|
|
239
|
+
expiresAt = new Date(now.getTime() + expiresDays * 24 * 60 * 60 * 1000).toISOString();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return withLockSync(root, 'state', () => {
|
|
243
|
+
const current = loadImpactResolution(root, feature);
|
|
244
|
+
const at = now.toISOString();
|
|
245
|
+
const next = {
|
|
246
|
+
schema: RESOLUTION_SCHEMA,
|
|
247
|
+
feature_id: feature,
|
|
248
|
+
dispositions: [
|
|
249
|
+
...current.dispositions.filter((d) => !(d.change_key === changeKey && d.downstream_feature === downstreamFeature)),
|
|
250
|
+
{
|
|
251
|
+
change_key: changeKey,
|
|
252
|
+
downstream_feature: downstreamFeature,
|
|
253
|
+
mode,
|
|
254
|
+
reason,
|
|
255
|
+
tracked_by: mode === 'migrate' ? trackedBy : null,
|
|
256
|
+
expires_at: expiresAt,
|
|
257
|
+
acknowledged: false,
|
|
258
|
+
acknowledged_at: null,
|
|
259
|
+
acknowledged_reason: null,
|
|
260
|
+
at,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
};
|
|
264
|
+
const saved = saveImpactResolution(root, feature, next);
|
|
265
|
+
appendDecisionEvent(root, feature, {
|
|
266
|
+
kind: 'impact_disposition', action: 'record', at, reason, feature_id: feature,
|
|
267
|
+
subject: { change_key: changeKey, downstream_feature: downstreamFeature },
|
|
268
|
+
mode, expires_at: expiresAt, tracked_by: mode === 'migrate' ? trackedBy : null,
|
|
269
|
+
});
|
|
270
|
+
return saved;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// D-cross-feature-impact-graph (D6): the DOWNSTREAM feature's own acknowledgement of a `migrate`
|
|
275
|
+
// obligation another feature recorded against it -- flips `acknowledged` on the UPSTREAM feature's
|
|
276
|
+
// own resolution record (that's where the obligation lives; ack doesn't create a new file).
|
|
277
|
+
export function acknowledgeInbound(root, { feature, from, changeKey, reason, now = new Date() }) {
|
|
278
|
+
requireValidOr400(feature);
|
|
279
|
+
requireValidOr400(from);
|
|
280
|
+
if (!reason || !reason.trim()) {
|
|
281
|
+
throw new ImpactOperationError('bskel impact ack requires --reason "..." -- every acknowledgement must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
282
|
+
}
|
|
283
|
+
return withLockSync(root, 'state', () => {
|
|
284
|
+
const upstreamResolution = loadImpactResolution(root, from);
|
|
285
|
+
const match = upstreamResolution.dispositions.find((d) => d.mode === 'migrate' && d.downstream_feature === feature && d.change_key === changeKey);
|
|
286
|
+
if (!match) {
|
|
287
|
+
throw new ImpactOperationError(`no "migrate" disposition from "${from}" targeting "${feature}" for change_key "${changeKey}" -- run \`bskel impact check --feature ${feature}\` to see current inbound obligations`, { httpStatus: 404, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' });
|
|
288
|
+
}
|
|
289
|
+
match.acknowledged = true;
|
|
290
|
+
match.acknowledged_at = now.toISOString();
|
|
291
|
+
match.acknowledged_reason = reason;
|
|
292
|
+
const saved = saveImpactResolution(root, from, upstreamResolution);
|
|
293
|
+
appendDecisionEvent(root, from, {
|
|
294
|
+
kind: 'impact_disposition', action: 'acknowledge', at: match.acknowledged_at, reason, feature_id: from,
|
|
295
|
+
subject: { change_key: changeKey, downstream_feature: feature },
|
|
296
|
+
mode: match.mode, expires_at: match.expires_at ?? null, tracked_by: match.tracked_by ?? null,
|
|
297
|
+
});
|
|
298
|
+
return saved;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// D-decision-event-log (D6): forward-only retraction, mirroring `gate revoke`'s own precedent --
|
|
303
|
+
// removes the entry (its absence IS the state, matching every other withdraw in this item) and
|
|
304
|
+
// appends a `withdraw` event carrying the reason. Never a snapshot restore: a disposition has no
|
|
305
|
+
// "previous value" worth restoring once the change it covered has moved on -- see
|
|
306
|
+
// D-decision-event-log's own Q4 answer in DECISIONS.md for why this is the correct
|
|
307
|
+
// generalization of `gate revoke`, not of `lib/patch-transactions.mjs`'s preimage-blob rollback.
|
|
308
|
+
export function withdrawDisposition(root, { feature, changeKey, downstreamFeature, reason, now = new Date() }) {
|
|
309
|
+
requireValidOr400(feature);
|
|
310
|
+
requireValidOr400(downstreamFeature);
|
|
311
|
+
if (!reason || !reason.trim()) {
|
|
312
|
+
throw new ImpactOperationError('bskel impact disposition --withdraw requires --reason "..." -- every withdrawal must be auditable', { httpStatus: 400, exitCode: EXIT_CODES.BAD_ARGS, reasonCode: 'BAD_ARGS' });
|
|
313
|
+
}
|
|
314
|
+
return withLockSync(root, 'state', () => {
|
|
315
|
+
const current = loadImpactResolution(root, feature);
|
|
316
|
+
const match = current.dispositions.find((d) => d.change_key === changeKey && d.downstream_feature === downstreamFeature);
|
|
317
|
+
if (!match) {
|
|
318
|
+
throw new ImpactOperationError(`no disposition recorded for change_key "${changeKey}" -> "${downstreamFeature}" on feature "${feature}" -- nothing to withdraw`, { httpStatus: 404, exitCode: EXIT_CODES.NOT_PASSED, reasonCode: 'MISSING_ARTIFACT' });
|
|
319
|
+
}
|
|
320
|
+
const at = now.toISOString();
|
|
321
|
+
const next = {
|
|
322
|
+
schema: RESOLUTION_SCHEMA,
|
|
323
|
+
feature_id: feature,
|
|
324
|
+
dispositions: current.dispositions.filter((d) => !(d.change_key === changeKey && d.downstream_feature === downstreamFeature)),
|
|
325
|
+
};
|
|
326
|
+
const saved = saveImpactResolution(root, feature, next);
|
|
327
|
+
appendDecisionEvent(root, feature, {
|
|
328
|
+
kind: 'impact_disposition', action: 'withdraw', at, reason, feature_id: feature,
|
|
329
|
+
subject: { change_key: changeKey, downstream_feature: downstreamFeature },
|
|
330
|
+
mode: match.mode, expires_at: match.expires_at ?? null, tracked_by: match.tracked_by ?? null,
|
|
331
|
+
});
|
|
332
|
+
return saved;
|
|
333
|
+
});
|
|
334
|
+
}
|
package/lib/patch-kinds.mjs
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
import { loadCatalogEntry } from '../stack/apply.mjs';
|
|
10
10
|
import { planConfigApply, executeConfigApply, executeConfigRollback } from '../stack/config-apply.mjs';
|
|
11
11
|
import { planDdlApply, executeDdlApply, executeDdlRollback, requiredConfirmValue } from '../scanners/db/ddl-apply.mjs';
|
|
12
|
+
import {
|
|
13
|
+
planJavaSourceSplice,
|
|
14
|
+
executeJavaSpliceApply,
|
|
15
|
+
executeJavaSpliceRollback,
|
|
16
|
+
requiredConfirmValue as javaSpliceRequiredConfirmValue,
|
|
17
|
+
describeStaleness as javaSpliceDescribeStaleness,
|
|
18
|
+
} from '../handles/providers/java-spring/source-splice.mjs';
|
|
12
19
|
|
|
13
20
|
const PATCH_KINDS = {
|
|
14
21
|
'config-apply': {
|
|
@@ -32,6 +39,23 @@ const PATCH_KINDS = {
|
|
|
32
39
|
// for one that drops a table -- see requiredConfirmValue()'s own comment for why.
|
|
33
40
|
requiredConfirmValue,
|
|
34
41
|
},
|
|
42
|
+
// D-java-source-splice: params: {file, edits}. planFresh re-derives everything (node identity,
|
|
43
|
+
// hashes, rendered content) from the current on-disk file every time -- never trusts a stored
|
|
44
|
+
// render, exactly like the other two kinds.
|
|
45
|
+
'java-source-splice': {
|
|
46
|
+
planFresh: (root, params) => planJavaSourceSplice(root, params),
|
|
47
|
+
paramsFromTxn: (txn) => ({
|
|
48
|
+
file: txn.target.file,
|
|
49
|
+
edits: txn.target.edits.map(({ op, locator, replacement, statements, imports }) => ({ op, locator, replacement, statements, imports })),
|
|
50
|
+
}),
|
|
51
|
+
apply: executeJavaSpliceApply,
|
|
52
|
+
rollback: executeJavaSpliceRollback,
|
|
53
|
+
requiredConfirmValue: javaSpliceRequiredConfirmValue,
|
|
54
|
+
// Optional per-kind hook (absent on the other two kinds): consulted by bin/bskel.mjs's
|
|
55
|
+
// cmdPatchApprove/cmdPatchApply only when replanTransaction() throws StaleTransactionError,
|
|
56
|
+
// to name which edit moved and why -- never changes the engine's own pass/fail decision.
|
|
57
|
+
describeStaleness: javaSpliceDescribeStaleness,
|
|
58
|
+
},
|
|
35
59
|
};
|
|
36
60
|
|
|
37
61
|
export const PATCH_KIND_NAMES = Object.freeze(Object.keys(PATCH_KINDS));
|
package/lib/repo.mjs
CHANGED
|
@@ -98,3 +98,49 @@ export function isDirty(cwd = process.cwd()) {
|
|
|
98
98
|
return null;
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
// D-attestation-payload-completeness (K2): the CONTENT identity of HEAD, distinct from its commit
|
|
103
|
+
// identity (headSha() above) -- `git rev-parse HEAD^{tree}`. Two commits with identical tree
|
|
104
|
+
// content (e.g. one only changes the commit message, or is an empty --amend) share this value,
|
|
105
|
+
// which is the right identity for "what code was this attestation actually about". null on
|
|
106
|
+
// failure, same convention as every other helper in this file.
|
|
107
|
+
export function headTreeSha(cwd = process.cwd()) {
|
|
108
|
+
try {
|
|
109
|
+
return git(['rev-parse', 'HEAD^{tree}'], cwd);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// D-attestation-payload-completeness (K2): a sorted, capped, structured view of
|
|
116
|
+
// `git status --porcelain=v1 -z` -- makes `isDirty()`'s bare boolean actionable inside a signed
|
|
117
|
+
// payload (D4's dirty-tree refusal needs to SHOW what's dirty, not just assert that it is).
|
|
118
|
+
// Returns null on git failure, matching isDirty()'s own null-on-failure posture. `-z` + NUL-split
|
|
119
|
+
// is used (not the newline-delimited default) specifically because a renamed/copied path's
|
|
120
|
+
// porcelain line legitimately contains no separator between old and new path other than NUL.
|
|
121
|
+
export function worktreeStatus(cwd = process.cwd(), { cap = 200 } = {}) {
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = execFileSync('git', ['status', '--porcelain=v1', '-z'], { cwd, encoding: 'utf8' });
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const fields = raw.split('\0').filter((f) => f.length > 0);
|
|
129
|
+
const entries = [];
|
|
130
|
+
for (let i = 0; i < fields.length; i++) {
|
|
131
|
+
const field = fields[i];
|
|
132
|
+
const status = field.slice(0, 2);
|
|
133
|
+
const path = field.slice(3);
|
|
134
|
+
// A rename/copy status ('R'/'C' in either column) is followed by a SEPARATE NUL-delimited
|
|
135
|
+
// field holding the ORIGINAL path -- consumed here (and dropped) so it isn't misread as its
|
|
136
|
+
// own status line; only the new path is reported, since `status` already discloses the move.
|
|
137
|
+
if (status[0] === 'R' || status[0] === 'C' || status[1] === 'R' || status[1] === 'C') i++;
|
|
138
|
+
entries.push({ status, path });
|
|
139
|
+
}
|
|
140
|
+
// Locale-independent, deterministic ordering -- Array.prototype.sort()'s default (UTF-16 code
|
|
141
|
+
// unit comparison) rather than localeCompare(), which varies by ICU data/host locale and would
|
|
142
|
+
// make the same repo state canonicalize to different signed bytes on different machines.
|
|
143
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
144
|
+
const truncated = entries.length > cap;
|
|
145
|
+
return { count: entries.length, truncated, entries: entries.slice(0, cap) };
|
|
146
|
+
}
|
package/lib/workflow.mjs
CHANGED
|
@@ -44,6 +44,10 @@ const ESTABLISH_COMMAND = {
|
|
|
44
44
|
// directly on the first real stale occurrence and throws a raw TypeError instead of a clean
|
|
45
45
|
// stale report.
|
|
46
46
|
dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
|
|
47
|
+
// D-cross-feature-impact-graph (IG4): wired in the SAME commit as the `impact` gate itself,
|
|
48
|
+
// per every warning above in this file -- this exact gap has now been found live at least four
|
|
49
|
+
// times for other gates and is the single most reliably-recurring mistake in this module.
|
|
50
|
+
impact: (id) => `bskel impact check --feature ${id} # then: bskel impact accept --feature ${id}`,
|
|
47
51
|
// D-business-rules (R7): wired in the SAME commit as the `rules` gate itself, per the repeated
|
|
48
52
|
// warnings above -- this exact gap has now been found live three times (dependencies,
|
|
49
53
|
// conformance, and the cross_feature near-miss) and is the single most reliably-recurring
|
|
@@ -72,6 +76,13 @@ function awaitingDispositionCommand(gateName, featureId) {
|
|
|
72
76
|
if (gateName === 'cross_feature') {
|
|
73
77
|
return `bskel scan cross-feature-waive --feature ${featureId} --signal resource_type|table|operation_id --identifier <name> --other-feature <id> --reason "..." # or: bskel gate force cross_feature --feature ${featureId} --reason "..." if intentional`;
|
|
74
78
|
}
|
|
79
|
+
// D-cross-feature-impact-graph (IG5/IG6): `impact` reaches awaiting_disposition from
|
|
80
|
+
// `checkImpact()` finding an undisposed proven outbound impact or an unacknowledged inbound
|
|
81
|
+
// migration -- `bskel impact check`'s own output (not `bskel gate show`) names the exact
|
|
82
|
+
// change_key/downstream_feature pairs a human needs to pass to `disposition`/`ack`.
|
|
83
|
+
if (gateName === 'impact') {
|
|
84
|
+
return `bskel impact check --feature ${featureId} --json # then: bskel impact disposition --feature ${featureId} --change <change_key> --downstream <id> --mode compatible|migrate|waive --reason "..." # or: bskel gate force impact --feature ${featureId} --reason "..." if intentional`;
|
|
85
|
+
}
|
|
75
86
|
return `bskel gate force ${gateName} --feature ${featureId} --reason "..."`;
|
|
76
87
|
}
|
|
77
88
|
|
|
@@ -98,6 +109,11 @@ const MUTATING_PREFIXES = [
|
|
|
98
109
|
// `rules emit` writes generated source. `rules list`/`rules explain` are read-only and
|
|
99
110
|
// correctly absent -- the same per-verb split `pattern list|show|suggest` was checked against.
|
|
100
111
|
'bskel rules check', 'bskel rules emit',
|
|
112
|
+
// D-cross-feature-impact-graph (IG4/IG5/IG6): `impact check` writes impact-report.json and can
|
|
113
|
+
// pass the gate; `impact accept` writes impact-baseline.json; `disposition`/`ack` write
|
|
114
|
+
// impact-resolution.json. `impact export` is deliberately absent -- read-only, spawns nothing,
|
|
115
|
+
// touches no gate (see IG8/IG9's own "never read back by a gate" guarantee).
|
|
116
|
+
'bskel impact check', 'bskel impact accept', 'bskel impact disposition', 'bskel impact ack',
|
|
101
117
|
];
|
|
102
118
|
|
|
103
119
|
// Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -311,6 +311,12 @@ export function findMappingAnnotations(text) {
|
|
|
311
311
|
argsText: argsStart >= 0 ? text.slice(argsStart, argsEnd) : '',
|
|
312
312
|
methodName: sig.methodName,
|
|
313
313
|
methodLine: lineNumberAt(text, atIndex),
|
|
314
|
+
// D-resolver-policy-contract (PC8): the position right after ALL of this method's own
|
|
315
|
+
// annotations end (already computed above as `afterAnnotations`, just returned here too)
|
|
316
|
+
// -- the only way to carve a method region that contains every annotation regardless of
|
|
317
|
+
// source order (e.g. `@GetMapping(...) @PreAuthorize(...)`, where @PreAuthorize follows
|
|
318
|
+
// the mapping annotation instead of preceding it).
|
|
319
|
+
signatureIndex: afterAnnotations,
|
|
314
320
|
});
|
|
315
321
|
}
|
|
316
322
|
return results;
|