backend-skeleton 1.0.0-beta.8 → 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 (48) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +738 -12
  3. package/contracts/completeness.mjs +10 -0
  4. package/contracts/emit.mjs +5 -1
  5. package/contracts/export.mjs +26 -3
  6. package/contracts/openapi.mjs +29 -3
  7. package/handles/_engine.mjs +79 -29
  8. package/handles/providers/java-spring/plan.mjs +22 -9
  9. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  10. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  11. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  12. package/handles/providers/typescript-express/emit.mjs +23 -23
  13. package/handles/providers/typescript-express/observe.mjs +101 -0
  14. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  15. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  16. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  17. package/lib/attest.mjs +40 -0
  18. package/lib/cli.mjs +169 -2
  19. package/lib/cross-feature-collisions.mjs +286 -0
  20. package/lib/diff.mjs +35 -0
  21. package/lib/field-dependencies.mjs +355 -0
  22. package/lib/fsutil.mjs +7 -2
  23. package/lib/gate-definitions.mjs +117 -1
  24. package/lib/gates.mjs +5 -1
  25. package/lib/http-server.mjs +358 -0
  26. package/lib/lock.mjs +68 -15
  27. package/lib/patch-kinds.mjs +52 -0
  28. package/lib/patch-transactions.mjs +206 -0
  29. package/lib/serve-ui.html +328 -0
  30. package/lib/workflow.mjs +39 -3
  31. package/package.json +5 -2
  32. package/scanners/adapters/java-spring.mjs +6 -0
  33. package/scanners/adapters/python-fastapi.mjs +9 -1
  34. package/scanners/adapters/typescript-express.mjs +6 -0
  35. package/scanners/db/ddl-apply.mjs +253 -0
  36. package/scanners/db/introspect.mjs +61 -32
  37. package/scanners/db/migrations.mjs +73 -18
  38. package/schemas/cross-feature-report.schema.json +66 -0
  39. package/schemas/cross-feature-resolution.schema.json +28 -0
  40. package/schemas/field-dependency.schema.json +49 -0
  41. package/schemas/gate-attestation.schema.json +22 -0
  42. package/schemas/gate-export.schema.json +58 -0
  43. package/schemas/patch-transaction.schema.json +182 -0
  44. package/schemas/scan-report.schema.json +6 -4
  45. package/schemas/stack-choice.schema.json +12 -1
  46. package/stack/apply.mjs +4 -1
  47. package/stack/catalog/ngrok.yml +8 -2
  48. package/stack/config-apply.mjs +168 -0
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:field-dependency:1",
4
+ "title": "backend-skeleton field-dependency declarations",
5
+ "description": "Validates specs/<feature_id>/dependencies.json, written by `bskel dependency declare`/`bskel dependency remove` -- see D-field-dependency in DECISIONS.md. Each entry declares that ONE field on this feature's own resource is derived from a field on some (possibly the same) feature's resource. Deliberately no synthetic id: an entry is addressed by its own natural compound key (target.resourceType/fieldName + source.feature/resourceType/fieldName), matching contract-resolution.schema.json's {code,subject} and patch-approvals.schema.json's {resource,field} precedent. This is a data-model-only slice -- nothing yet reads this file to generate code; the `dependencies` gate (lib/gate-definitions.mjs) only tracks whether it and what it points at have moved.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "feature_id", "dependencies"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.field-dependency/1" },
11
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
+ "dependencies": {
13
+ "description": "One entry per declared edge. `target` is always a resource on THIS file's own feature_id (never repeated per-entry); `source` names its own feature explicitly since it may differ (cross-feature) or be the same (same-feature dependencies are supported by construction, not a special case).",
14
+ "type": "array",
15
+ "items": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "required": ["target", "source", "reason", "at"],
19
+ "properties": {
20
+ "target": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "required": ["resourceType", "fieldName"],
24
+ "properties": {
25
+ "resourceType": { "type": "string", "minLength": 1 },
26
+ "fieldName": { "type": "string", "minLength": 1 }
27
+ }
28
+ },
29
+ "source": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["feature", "resourceType", "fieldName"],
33
+ "properties": {
34
+ "feature": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
35
+ "resourceType": { "type": "string", "minLength": 1 },
36
+ "fieldName": { "type": "string", "minLength": 1 }
37
+ }
38
+ },
39
+ "reason": { "type": "string" },
40
+ "memo": {
41
+ "description": "Optional free-text intent, e.g. captured from a future UI's own resolve-decision flow. Inert documentation only in this slice -- nothing reads it yet.",
42
+ "type": "string"
43
+ },
44
+ "at": { "type": "string", "format": "date-time" }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:gate-attestation:1",
4
+ "title": "backend-skeleton signed gate attestation (bskel gate export --sign / bskel attest verify)",
5
+ "description": "D-gate-attestation-signing: the combined envelope `bskel gate export --sign` writes -- a gate-export report (schemas/gate-export.schema.json's own shape) plus a detached Ed25519 signature over that report's canonical (deep-key-sorted, whitespace-free) JSON serialization. Signature validity is the ONLY thing `bskel attest verify`'s exit code reflects -- whether the gates inside `report` themselves passed is a separate, printed-but-not-exit-code-driving question (a legitimately-signed, all-failing report must not look like a tool error).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "report", "signature"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.gate-attestation/1" },
11
+ "report": { "type": "object" },
12
+ "signature": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "required": ["algorithm", "value"],
16
+ "properties": {
17
+ "algorithm": { "const": "ed25519" },
18
+ "value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over canonicalize(report)." }
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,58 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:gate-export:1",
4
+ "title": "backend-skeleton gate export report (bskel gate export)",
5
+ "description": "D-gate-attestation-signing: this schema was written FROM cmdGateExport's real, already-shipped report shape (bin/bskel.mjs) -- it did not exist before this item and the report itself is unchanged by adding it. `current` mirrors schemas/state.schema.json's own per-gate record shape exactly (the same raw, stored record getGate() returns -- status is constrained to the 3 values ever WRITTEN to disk, never the read-time-derived not_run/stale/pass (forced)). `additionalProperties` is intentionally open on `gates` itself (keyed by gate name) so this schema never needs editing when a new gate is added -- GATE_NAMES in lib/gate-definitions.mjs is the one place that list lives.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "feature_id", "generated_at", "git", "gates"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.gate-export/1" },
11
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
12
+ "generated_at": { "type": "string", "format": "date-time" },
13
+ "git": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": ["branch", "head_sha", "dirty"],
17
+ "properties": {
18
+ "branch": { "type": ["string", "null"], "description": "lib/repo.mjs's currentBranch() -- null on failure, or the literal string \"HEAD\" for a detached HEAD (not a real branch name)." },
19
+ "head_sha": { "type": "string" },
20
+ "dirty": { "type": ["boolean", "null"], "description": "lib/repo.mjs's isDirty() -- null on failure. true covers BOTH modified tracked files and untracked files (git status --porcelain, no -uno)." }
21
+ }
22
+ },
23
+ "gates": {
24
+ "type": "object",
25
+ "additionalProperties": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "required": ["scope", "current", "history"],
29
+ "properties": {
30
+ "scope": { "type": "string", "description": "the resolved scope id this gate was read from -- REPO_GATE_ID (\"_repo\") for a repo-scoped gate, or the feature_id itself." },
31
+ "current": {
32
+ "anyOf": [
33
+ { "type": "null" },
34
+ {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["status", "token", "at"],
38
+ "properties": {
39
+ "status": { "enum": ["pass", "awaiting_disposition", "revoked"] },
40
+ "token": { "type": "string" },
41
+ "at": { "type": "string" },
42
+ "forced": { "type": "boolean" },
43
+ "reason": { "type": "string" },
44
+ "evidence": { "type": "object" },
45
+ "inputs": { "type": "object" }
46
+ }
47
+ }
48
+ ]
49
+ },
50
+ "history": {
51
+ "type": "array",
52
+ "items": { "type": "object" }
53
+ }
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,182 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:sbf:patch-transaction:1",
4
+ "title": "backend-skeleton content-addressed patch transaction (specs/<feature_id>/patch-transactions/<transaction_id>.json)",
5
+ "description": "D-patch-transactions: generalizes A3/D-patch-strategy's per-{resource,field} patch-approvals.json shape into a kind-agnostic propose/approve/apply/rollback transaction. One file per transaction (not one growing array) -- each record carries a lifecycle with real state transitions and references a content-addressed rollback blob under this same feature's patch-transactions/blobs/ directory. D-ddl-apply added a second kind ('ddl-apply') -- kind-specific source/target/postcondition/apply shapes are enforced by the allOf/if-then branches below, keyed on `kind`. The config-apply branch is byte-identical to this schema's pre-D-ddl-apply shape (existing config-apply records validate unchanged).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "transaction_id", "feature_id", "kind", "source", "target", "preimage", "proposed_value", "postcondition", "status", "created_at"],
9
+ "properties": {
10
+ "schema": { "const": "sbf.patch-transaction/1" },
11
+ "transaction_id": { "type": "string", "pattern": "^pt-[0-9a-f-]{36}$" },
12
+ "feature_id": { "type": "string", "pattern": "^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$" },
13
+ "kind": { "enum": ["config-apply", "ddl-apply"] },
14
+ "source": {
15
+ "type": "object",
16
+ "description": "Kind-specific opaque context needed to re-plan this transaction fresh at approve/apply time. Shape enforced per-kind by the allOf/if-then branches below."
17
+ },
18
+ "target": {
19
+ "type": "object",
20
+ "description": "Kind-specific description of what this transaction acts on. Shape enforced per-kind by the allOf/if-then branches below."
21
+ },
22
+ "preimage": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["region_hash", "file_hash"],
26
+ "properties": {
27
+ "region_hash": { "type": "string" },
28
+ "file_hash": { "type": "string" }
29
+ }
30
+ },
31
+ "current_value": { "type": "string" },
32
+ "proposed_value": { "type": "string" },
33
+ "postcondition": {
34
+ "type": "object",
35
+ "description": "Kind-specific re-check applied before the record is allowed to transition to 'applied'. Shape enforced per-kind by the allOf/if-then branches below."
36
+ },
37
+ "status": { "enum": ["proposed", "approved", "applied", "rolled_back"] },
38
+ "created_at": { "type": "string", "format": "date-time" },
39
+ "approval": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "required": ["reason", "at"],
43
+ "properties": {
44
+ "reason": { "type": "string" },
45
+ "at": { "type": "string", "format": "date-time" }
46
+ }
47
+ },
48
+ "apply": {
49
+ "type": "object",
50
+ "description": "Kind-specific record of what actually happened at apply time. Shape enforced per-kind by the allOf/if-then branches below."
51
+ },
52
+ "rollback": {
53
+ "type": "object",
54
+ "additionalProperties": false,
55
+ "required": ["reason", "at"],
56
+ "properties": {
57
+ "reason": { "type": "string" },
58
+ "at": { "type": "string", "format": "date-time" },
59
+ "forced": { "type": "boolean" }
60
+ }
61
+ }
62
+ },
63
+ "allOf": [
64
+ {
65
+ "if": { "properties": { "kind": { "const": "config-apply" } } },
66
+ "then": {
67
+ "properties": {
68
+ "source": {
69
+ "additionalProperties": false,
70
+ "required": ["choice"],
71
+ "properties": {
72
+ "choice": { "type": "string" }
73
+ }
74
+ },
75
+ "target": {
76
+ "additionalProperties": false,
77
+ "required": ["file", "key_path"],
78
+ "properties": {
79
+ "file": { "type": "string" },
80
+ "key_path": { "type": "array", "items": { "type": "string" }, "minItems": 1 }
81
+ }
82
+ },
83
+ "postcondition": {
84
+ "additionalProperties": false,
85
+ "required": ["kind", "pattern"],
86
+ "properties": {
87
+ "kind": { "const": "regex-match" },
88
+ "pattern": { "type": "string" }
89
+ }
90
+ },
91
+ "apply": {
92
+ "additionalProperties": false,
93
+ "required": ["at", "postimage_file_hash"],
94
+ "properties": {
95
+ "at": { "type": "string", "format": "date-time" },
96
+ "postimage_file_hash": { "type": "string" }
97
+ }
98
+ }
99
+ }
100
+ }
101
+ },
102
+ {
103
+ "if": { "properties": { "kind": { "const": "ddl-apply" } } },
104
+ "then": {
105
+ "properties": {
106
+ "source": {
107
+ "additionalProperties": false,
108
+ "required": ["database_url_env", "schema"],
109
+ "properties": {
110
+ "database_url_env": { "type": "string" },
111
+ "schema": { "type": "string" }
112
+ }
113
+ },
114
+ "target": {
115
+ "additionalProperties": false,
116
+ "required": ["database_url_env", "schema", "sql_text"],
117
+ "properties": {
118
+ "database_url_env": { "type": "string" },
119
+ "schema": { "type": "string" },
120
+ "sql_text": { "type": "string" }
121
+ }
122
+ },
123
+ "postcondition": {
124
+ "additionalProperties": false,
125
+ "required": ["kind", "schema", "expected_tables"],
126
+ "properties": {
127
+ "kind": { "const": "db-schema-diff" },
128
+ "schema": { "type": "string" },
129
+ "expected_tables": {
130
+ "type": "array",
131
+ "items": {
132
+ "type": "object",
133
+ "additionalProperties": false,
134
+ "required": ["name", "expect"],
135
+ "properties": {
136
+ "name": { "type": "string" },
137
+ "expect": { "enum": ["present", "absent"] }
138
+ }
139
+ }
140
+ },
141
+ "expected_indexes": {
142
+ "description": "D-ddl-apply (INDEX/SCHEMA postcondition precision): optional, not required -- absent on any ddl-apply record persisted before this field existed, and empty for a statement batch with no CREATE/DROP INDEX in it.",
143
+ "type": "array",
144
+ "items": {
145
+ "type": "object",
146
+ "additionalProperties": false,
147
+ "required": ["name", "expect"],
148
+ "properties": {
149
+ "name": { "type": "string" },
150
+ "expect": { "enum": ["present", "absent"] }
151
+ }
152
+ }
153
+ },
154
+ "expected_schemas": {
155
+ "description": "D-ddl-apply (INDEX/SCHEMA postcondition precision): optional, not required -- same reasoning as expected_indexes, for CREATE/DROP SCHEMA.",
156
+ "type": "array",
157
+ "items": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "required": ["name", "expect"],
161
+ "properties": {
162
+ "name": { "type": "string" },
163
+ "expect": { "enum": ["present", "absent"] }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ },
169
+ "apply": {
170
+ "additionalProperties": false,
171
+ "required": ["at", "postimage_schema_hash", "executed_statements"],
172
+ "properties": {
173
+ "at": { "type": "string", "format": "date-time" },
174
+ "postimage_schema_hash": { "type": "string" },
175
+ "executed_statements": { "type": "array", "items": { "type": "string" } }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ]
182
+ }
@@ -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
+ }