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.
Files changed (45) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +564 -15
  3. package/contracts/emit.mjs +5 -1
  4. package/contracts/export.mjs +26 -3
  5. package/contracts/openapi.mjs +29 -3
  6. package/handles/_engine.mjs +79 -29
  7. package/handles/providers/java-spring/plan.mjs +22 -9
  8. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  9. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  10. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  11. package/handles/providers/typescript-express/emit.mjs +23 -23
  12. package/handles/providers/typescript-express/observe.mjs +101 -0
  13. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  14. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  15. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  16. package/lib/attest.mjs +40 -0
  17. package/lib/cli.mjs +125 -3
  18. package/lib/cross-feature-collisions.mjs +286 -0
  19. package/lib/diff.mjs +35 -0
  20. package/lib/fsutil.mjs +7 -2
  21. package/lib/gate-definitions.mjs +85 -1
  22. package/lib/gates.mjs +5 -1
  23. package/lib/http-server.mjs +192 -6
  24. package/lib/lock.mjs +68 -15
  25. package/lib/patch-kinds.mjs +52 -0
  26. package/lib/patch-transactions.mjs +206 -0
  27. package/lib/serve-ui.html +211 -0
  28. package/lib/workflow.mjs +31 -3
  29. package/package.json +5 -2
  30. package/scanners/adapters/java-spring.mjs +6 -0
  31. package/scanners/adapters/python-fastapi.mjs +9 -1
  32. package/scanners/adapters/typescript-express.mjs +6 -0
  33. package/scanners/db/ddl-apply.mjs +253 -0
  34. package/scanners/db/introspect.mjs +61 -32
  35. package/scanners/db/migrations.mjs +73 -18
  36. package/schemas/cross-feature-report.schema.json +66 -0
  37. package/schemas/cross-feature-resolution.schema.json +28 -0
  38. package/schemas/gate-attestation.schema.json +22 -0
  39. package/schemas/gate-export.schema.json +58 -0
  40. package/schemas/patch-transaction.schema.json +182 -0
  41. package/schemas/scan-report.schema.json +6 -4
  42. package/schemas/stack-choice.schema.json +12 -1
  43. package/stack/apply.mjs +4 -1
  44. package/stack/catalog/ngrok.yml +8 -2
  45. 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
- function assertContained(root, target, label) {
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);
@@ -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, add that yourself -- backend-skeleton deliberately
43
- does not auto-edit application config files (see D-config-patch in DECISIONS.md).
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
+ }