backend-skeleton 1.0.0-beta.9 → 1.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/README.md +122 -12
- package/bin/bskel.mjs +564 -15
- package/contracts/emit.mjs +5 -1
- package/contracts/export.mjs +26 -3
- package/contracts/openapi.mjs +29 -3
- package/handles/_engine.mjs +79 -29
- package/handles/providers/java-spring/plan.mjs +22 -9
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
- package/handles/providers/typescript-express/emit.mjs +23 -23
- package/handles/providers/typescript-express/observe.mjs +101 -0
- package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
- package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
- package/lib/attest.mjs +40 -0
- package/lib/cli.mjs +125 -3
- package/lib/cross-feature-collisions.mjs +286 -0
- package/lib/diff.mjs +35 -0
- package/lib/fsutil.mjs +7 -2
- package/lib/gate-definitions.mjs +85 -1
- package/lib/gates.mjs +5 -1
- package/lib/http-server.mjs +192 -6
- package/lib/lock.mjs +68 -15
- package/lib/patch-kinds.mjs +52 -0
- package/lib/patch-transactions.mjs +206 -0
- package/lib/serve-ui.html +211 -0
- package/lib/workflow.mjs +31 -3
- package/package.json +5 -2
- package/scanners/adapters/java-spring.mjs +6 -0
- package/scanners/adapters/python-fastapi.mjs +9 -1
- package/scanners/adapters/typescript-express.mjs +6 -0
- package/scanners/db/ddl-apply.mjs +253 -0
- package/scanners/db/introspect.mjs +61 -32
- package/scanners/db/migrations.mjs +73 -18
- package/schemas/cross-feature-report.schema.json +66 -0
- package/schemas/cross-feature-resolution.schema.json +28 -0
- package/schemas/gate-attestation.schema.json +22 -0
- package/schemas/gate-export.schema.json +58 -0
- package/schemas/patch-transaction.schema.json +182 -0
- package/schemas/scan-report.schema.json +6 -4
- package/schemas/stack-choice.schema.json +12 -1
- package/stack/apply.mjs +4 -1
- package/stack/catalog/ngrok.yml +8 -2
- package/stack/config-apply.mjs +168 -0
|
@@ -67,21 +67,23 @@
|
|
|
67
67
|
"migrations": {
|
|
68
68
|
"type": "object",
|
|
69
69
|
"additionalProperties": false,
|
|
70
|
-
"required": ["tool", "files", "tables"],
|
|
70
|
+
"required": ["tool", "files", "tables", "generated_at"],
|
|
71
71
|
"properties": {
|
|
72
72
|
"tool": { "enum": ["flyway", "liquibase", "none"] },
|
|
73
73
|
"files": { "type": "array", "items": { "type": "string" } },
|
|
74
|
-
"tables": { "type": "array" }
|
|
74
|
+
"tables": { "type": "array" },
|
|
75
|
+
"generated_at": { "description": "D-cross-feature-fk-inference (staleness/freshness token): when this Plane A migration-file scan ran.", "type": "string", "format": "date-time" }
|
|
75
76
|
}
|
|
76
77
|
},
|
|
77
78
|
"live": {
|
|
78
79
|
"type": ["object", "null"],
|
|
79
80
|
"additionalProperties": false,
|
|
80
|
-
"required": ["schema", "tables", "schema_hash"],
|
|
81
|
+
"required": ["schema", "tables", "schema_hash", "generated_at"],
|
|
81
82
|
"properties": {
|
|
82
83
|
"schema": { "type": "string" },
|
|
83
84
|
"tables": { "type": "array" },
|
|
84
|
-
"schema_hash": { "type": "string" }
|
|
85
|
+
"schema_hash": { "type": "string" },
|
|
86
|
+
"generated_at": { "description": "D-cross-feature-fk-inference (staleness/freshness token): when this Plane C live introspection ran.", "type": "string", "format": "date-time" }
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
89
|
}
|
|
@@ -50,11 +50,22 @@
|
|
|
50
50
|
"type": "array",
|
|
51
51
|
"items": {
|
|
52
52
|
"type": "object",
|
|
53
|
+
"additionalProperties": false,
|
|
53
54
|
"required": ["target", "externalized_pattern", "note"],
|
|
54
55
|
"properties": {
|
|
55
56
|
"target": { "type": "string" },
|
|
56
57
|
"externalized_pattern": { "type": "string" },
|
|
57
|
-
"note": { "type": "string" }
|
|
58
|
+
"note": { "type": "string" },
|
|
59
|
+
"apply": {
|
|
60
|
+
"type": "object",
|
|
61
|
+
"additionalProperties": false,
|
|
62
|
+
"required": ["key_path", "value_template"],
|
|
63
|
+
"description": "D-patch-transactions: optional, additive -- a catalog entry without this still behaves exactly as before (config_check reports needs-manual-patch only, `bskel patch propose` refuses naming this note). key_path addresses a plain scalar value via YAML.Document#getIn(); value_template's one literal token, {{CURRENT}}, is replaced with the value's current string content -- deliberately NOT lib/template.mjs's own {{PORT}} renderer, a different, unrelated templating purpose.",
|
|
64
|
+
"properties": {
|
|
65
|
+
"key_path": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
|
|
66
|
+
"value_template": { "type": "string" }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
58
69
|
}
|
|
59
70
|
}
|
|
60
71
|
}
|
package/stack/apply.mjs
CHANGED
|
@@ -27,7 +27,10 @@ export function listCatalogChoices() {
|
|
|
27
27
|
// paths (must stay under STACK_ROOT) and generated-file target paths (must stay under the
|
|
28
28
|
// caller's repoRoot). A catalog entry is data (currently only ships with this skill, but the
|
|
29
29
|
// mechanism doesn't assume that), so every path it names is treated as untrusted input.
|
|
30
|
-
|
|
30
|
+
// D-patch-transactions: exported once stack/config-apply.mjs (a second real consumer, checking
|
|
31
|
+
// the SAME catalog-declared config_check.target path) needed the identical containment check --
|
|
32
|
+
// previously only used inside this file.
|
|
33
|
+
export function assertContained(root, target, label) {
|
|
31
34
|
const resolvedRoot = path.resolve(root);
|
|
32
35
|
const resolvedTarget = path.resolve(target);
|
|
33
36
|
const rel = path.relative(resolvedRoot, resolvedTarget);
|
package/stack/catalog/ngrok.yml
CHANGED
|
@@ -39,8 +39,14 @@ static:
|
|
|
39
39
|
note: >
|
|
40
40
|
allowed-origins must be environment-variable-driven for the tunnel URL to actually take
|
|
41
41
|
effect (e.g. `allowed-origins: ${AUTH_LOGIN_ALLOWED_ORIGINS:default-value}`). If this
|
|
42
|
-
check reports needs-manual-patch,
|
|
43
|
-
|
|
42
|
+
check reports needs-manual-patch, run `bskel patch propose --feature <id> --choice ngrok
|
|
43
|
+
--target src/main/resources/application.yaml` for a machine-applicable fix (see
|
|
44
|
+
D-patch-transactions in DECISIONS.md), or add it yourself -- backend-skeleton never
|
|
45
|
+
auto-edits an application config file without an explicit propose/approve/apply sequence
|
|
46
|
+
(see D-config-patch in DECISIONS.md).
|
|
47
|
+
apply:
|
|
48
|
+
key_path: [auth, login, allowed-origins]
|
|
49
|
+
value_template: "${AUTH_LOGIN_ALLOWED_ORIGINS:{{CURRENT}}}"
|
|
44
50
|
|
|
45
51
|
runtime:
|
|
46
52
|
script: scripts/dev-tunnel.sh
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// D-patch-transactions: the "config-apply" kind planner for lib/patch-transactions.mjs -- turns a
|
|
2
|
+
// catalog `config_check` entry's new, additive `apply` block into a real, comment-preserving edit
|
|
3
|
+
// via the `yaml` package's Document API (already a repo dependency, used elsewhere in this file's
|
|
4
|
+
// own sibling apply.mjs for read-only parsing). `propose`/`approve`/`apply` in bin/bskel.mjs all
|
|
5
|
+
// call planConfigApply() FRESH every time -- never trusting a stored render -- which is what makes
|
|
6
|
+
// "re-verify preimage at every step" real rather than assumed.
|
|
7
|
+
//
|
|
8
|
+
// Two things were verified live against yaml@2.9.0 before this module was written, not assumed:
|
|
9
|
+
// (1) doc.getIn(keyPath, true) and a mapping pair's key/value nodes carry real byte-offset
|
|
10
|
+
// `.range` tuples today -- the "preimage hash of the specific target region" primitive this
|
|
11
|
+
// feature needs. (2) the Document API does NOT byte-for-byte round-trip untouched lines elsewhere
|
|
12
|
+
// in the same file (comment spacing collapses, flow-collection spacing normalizes, trailing
|
|
13
|
+
// whitespace strips) -- a postcondition-only check would silently ship cosmetic reformatting of a
|
|
14
|
+
// hand-tuned config file, exactly the failure mode D-config-patch's own WHY was written to avoid.
|
|
15
|
+
// The collateral-diff check below is the direct fix for that, not an afterthought.
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { parseDocument, isScalar } from 'yaml';
|
|
19
|
+
import { sha256File, sha256String } from '../lib/fsutil.mjs';
|
|
20
|
+
import { unifiedDiff } from '../lib/diff.mjs';
|
|
21
|
+
import { assertContained } from './apply.mjs';
|
|
22
|
+
import { readBlob } from '../lib/patch-transactions.mjs';
|
|
23
|
+
|
|
24
|
+
class ConfigApplyPlanError extends Error {}
|
|
25
|
+
|
|
26
|
+
function findConfigCheck(entry, targetPath) {
|
|
27
|
+
return (entry.static?.config_check ?? []).find((c) => c.target === targetPath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 0-indexed line number of a byte offset -- local, tiny, and 0-indexed (matching this module's own
|
|
31
|
+
// line-array comparisons below) rather than reusing scanners/text-util.mjs's 1-indexed
|
|
32
|
+
// lineNumberAt(), which would introduce a new stack/ -> scanners/ import direction for four lines
|
|
33
|
+
// of logic.
|
|
34
|
+
function lineIndexAt(text, offset) {
|
|
35
|
+
let line = 0;
|
|
36
|
+
for (let i = 0; i < offset; i++) if (text[i] === '\n') line++;
|
|
37
|
+
return line;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Refuses (throws ConfigApplyPlanError) unless EVERY line that differs between `before` and
|
|
41
|
+
// `after` falls within [targetStartLine, targetEndLine] (inclusive, 0-indexed) -- the direct
|
|
42
|
+
// mitigation for yaml's own confirmed collateral-reformatting behavior on UNTOUCHED lines
|
|
43
|
+
// elsewhere in the same document. A total line-count mismatch is refused outright (Slice 1 only
|
|
44
|
+
// ever targets a single-line scalar; a line appearing/disappearing anywhere is unexpected either
|
|
45
|
+
// way and safer to refuse than to reason about).
|
|
46
|
+
function assertNoCollateralChanges(relPath, before, after, targetStartLine, targetEndLine) {
|
|
47
|
+
const beforeLines = before.split('\n');
|
|
48
|
+
const afterLines = after.split('\n');
|
|
49
|
+
if (beforeLines.length !== afterLines.length) {
|
|
50
|
+
throw new ConfigApplyPlanError(
|
|
51
|
+
`the proposed edit to "${relPath}" changes the file's total line count (${beforeLines.length} -> ${afterLines.length}), which this tool refuses to apply automatically -- review the diff and patch it by hand:\n${unifiedDiff(relPath, before, after)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const collateral = [];
|
|
55
|
+
for (let i = 0; i < beforeLines.length; i++) {
|
|
56
|
+
if (i >= targetStartLine && i <= targetEndLine) continue;
|
|
57
|
+
if (beforeLines[i] !== afterLines[i]) collateral.push(i + 1);
|
|
58
|
+
}
|
|
59
|
+
if (collateral.length > 0) {
|
|
60
|
+
throw new ConfigApplyPlanError(
|
|
61
|
+
`the proposed edit to "${relPath}" would also change line(s) ${collateral.join(', ')} outside the target key -- refusing to risk silently reformatting unrelated content. Review the diff and patch it by hand:\n${unifiedDiff(relPath, before, after)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `catalogEntry` is an already-schema-validated stack/apply.mjs `loadCatalogEntry()` result;
|
|
67
|
+
// `targetPath` is the catalog-declared `config_check.target` (repo-relative) to act on. Returns a
|
|
68
|
+
// plan object shaped for lib/patch-transactions.mjs's propose/approve/apply, or throws
|
|
69
|
+
// ConfigApplyPlanError with a message safe to print directly to a human.
|
|
70
|
+
export function planConfigApply(repoRoot, catalogEntry, targetPath) {
|
|
71
|
+
const check = findConfigCheck(catalogEntry, targetPath);
|
|
72
|
+
if (!check) {
|
|
73
|
+
throw new ConfigApplyPlanError(`no config_check entry for target "${targetPath}" in catalog choice "${catalogEntry.id}"`);
|
|
74
|
+
}
|
|
75
|
+
if (!check.apply) {
|
|
76
|
+
throw new ConfigApplyPlanError(`no machine-applicable fix declared for "${targetPath}" in catalog choice "${catalogEntry.id}" -- see its note: "${check.note}"`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const targetAbs = path.join(repoRoot, targetPath);
|
|
80
|
+
assertContained(repoRoot, targetAbs, 'catalog config_check target path');
|
|
81
|
+
if (!fs.existsSync(targetAbs)) {
|
|
82
|
+
throw new ConfigApplyPlanError(`"${targetPath}" does not exist -- nothing to patch`);
|
|
83
|
+
}
|
|
84
|
+
const text = fs.readFileSync(targetAbs, 'utf8');
|
|
85
|
+
|
|
86
|
+
const doc = parseDocument(text);
|
|
87
|
+
const { key_path: keyPath, value_template: valueTemplate } = check.apply;
|
|
88
|
+
const node = doc.getIn(keyPath, true);
|
|
89
|
+
if (node === undefined) {
|
|
90
|
+
throw new ConfigApplyPlanError(`"${targetPath}" has no value at ${JSON.stringify(keyPath)} -- the catalog entry's "apply.key_path" doesn't match this file's real structure`);
|
|
91
|
+
}
|
|
92
|
+
if (!isScalar(node)) {
|
|
93
|
+
throw new ConfigApplyPlanError(`"${targetPath}"'s value at ${JSON.stringify(keyPath)} is not a plain scalar (it's a mapping or list) -- config_apply only ever targets a single scalar value`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The parent mapping's own pair for this key, so the region span can include the key AND its
|
|
97
|
+
// trailing comment (pair.value.range[2]), not just the bare value -- a reformatted comment on
|
|
98
|
+
// the TARGET's own line is expected (part of the edit), only OTHER lines' comments/spacing
|
|
99
|
+
// changing is collateral damage.
|
|
100
|
+
const parentPath = keyPath.slice(0, -1);
|
|
101
|
+
const parentNode = parentPath.length === 0 ? doc.contents : doc.getIn(parentPath, true);
|
|
102
|
+
const lastKey = keyPath[keyPath.length - 1];
|
|
103
|
+
const pair = parentNode.items.find((p) => String(p.key.value) === lastKey);
|
|
104
|
+
if (!pair) {
|
|
105
|
+
throw new ConfigApplyPlanError(`internal error: resolved a value at ${JSON.stringify(keyPath)} but could not locate its own key/value pair -- refusing`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const currentValue = String(node.value);
|
|
109
|
+
const proposedValue = valueTemplate.replaceAll('{{CURRENT}}', currentValue);
|
|
110
|
+
|
|
111
|
+
const regionStart = pair.key.range[0];
|
|
112
|
+
const regionEnd = pair.value.range[2];
|
|
113
|
+
const regionText = text.slice(regionStart, regionEnd);
|
|
114
|
+
const regionHash = sha256String(regionText);
|
|
115
|
+
|
|
116
|
+
doc.setIn(keyPath, proposedValue);
|
|
117
|
+
const renderedContent = String(doc);
|
|
118
|
+
|
|
119
|
+
const targetStartLine = lineIndexAt(text, regionStart);
|
|
120
|
+
// regionEnd (pair.value.range[2]) includes the target line's OWN trailing newline when the
|
|
121
|
+
// value has a line comment or is the last token before one -- verified live against yaml@2.9.0.
|
|
122
|
+
// Using regionEnd directly would make lineIndexAt count that newline as already passed, shifting
|
|
123
|
+
// targetEndLine one line too far and silently exempting the FOLLOWING line from the collateral
|
|
124
|
+
// check (confirmed live: without this -1, a flow-list reformatted on the very next line was
|
|
125
|
+
// wrongly treated as "inside the target region" and the collateral check never fired).
|
|
126
|
+
// Math.max guards the degenerate regionStart===regionEnd case (an empty span) from going negative.
|
|
127
|
+
const targetEndLine = lineIndexAt(text, Math.max(regionStart, regionEnd - 1));
|
|
128
|
+
assertNoCollateralChanges(targetPath, text, renderedContent, targetStartLine, targetEndLine);
|
|
129
|
+
|
|
130
|
+
if (!new RegExp(check.externalized_pattern).test(renderedContent)) {
|
|
131
|
+
throw new ConfigApplyPlanError(`the proposed edit to "${targetPath}" does not satisfy this catalog entry's own "externalized_pattern" -- the "apply" block is likely misconfigured, refusing rather than applying an edit that wouldn't even fix the check it's for`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
target: { file: targetPath, key_path: keyPath },
|
|
136
|
+
preimage: { region_hash: regionHash, file_hash: sha256String(text) },
|
|
137
|
+
current_value: currentValue,
|
|
138
|
+
proposed_value: proposedValue,
|
|
139
|
+
postcondition: { kind: 'regex-match', pattern: check.externalized_pattern },
|
|
140
|
+
originalContent: text,
|
|
141
|
+
renderedContent,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// D-ddl-apply: the "config-apply" kind's apply/rollback executors, injected into
|
|
146
|
+
// lib/patch-transactions.mjs's applyTransaction()/rollbackTransaction() via lib/patch-kinds.mjs.
|
|
147
|
+
// Bodies are moved verbatim from what used to live directly inside applyTransaction()/
|
|
148
|
+
// rollbackTransaction() -- a pure refactor, not a behavior change (both event this project's own
|
|
149
|
+
// existing patch-transactions.test.mjs / patch-config-apply-cli.test.mjs suites re-verify pass
|
|
150
|
+
// unchanged).
|
|
151
|
+
export async function executeConfigApply(root, featureId, txn, freshKindPlan) {
|
|
152
|
+
const targetAbs = path.join(root, txn.target.file);
|
|
153
|
+
fs.writeFileSync(targetAbs, freshKindPlan.renderedContent);
|
|
154
|
+
return { postimage_file_hash: sha256String(freshKindPlan.renderedContent) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function executeConfigRollback(root, featureId, txn, { force = false } = {}) {
|
|
158
|
+
const targetAbs = path.join(root, txn.target.file);
|
|
159
|
+
const currentHash = sha256File(targetAbs);
|
|
160
|
+
if (currentHash !== txn.apply.postimage_file_hash && !force) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`"${txn.target.file}" has changed since transaction "${txn.transaction_id}" applied -- rolling back would silently clobber that change; pass --force --reason if intentional`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const original = readBlob(root, featureId, txn.preimage.file_hash);
|
|
166
|
+
fs.writeFileSync(targetAbs, original);
|
|
167
|
+
return {};
|
|
168
|
+
}
|