backend-skeleton 1.0.0-beta.1
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/LICENSE +21 -0
- package/README.md +284 -0
- package/bin/bskel.mjs +2384 -0
- package/contracts/completeness.mjs +176 -0
- package/contracts/emit.mjs +287 -0
- package/contracts/export.mjs +325 -0
- package/contracts/openapi.mjs +869 -0
- package/contracts/validate.mjs +147 -0
- package/handles/_engine.mjs +281 -0
- package/handles/codec.mjs +119 -0
- package/handles/conformance.mjs +74 -0
- package/handles/providers/java-spring/ast-bridge.mjs +59 -0
- package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/handles/providers/java-spring/ast-helper/gradlew +248 -0
- package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
- package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
- package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
- package/handles/providers/java-spring/emit.mjs +232 -0
- package/handles/providers/java-spring/patch-strategy.mjs +229 -0
- package/handles/providers/java-spring/plan.mjs +377 -0
- package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
- package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
- package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
- package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
- package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
- package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
- package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
- package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
- package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
- package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
- package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
- package/handles/providers/java-spring.mjs +21 -0
- package/handles/providers/python-fastapi/emit.mjs +171 -0
- package/handles/providers/python-fastapi/plan.mjs +186 -0
- package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
- package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
- package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
- package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
- package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
- package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
- package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
- package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
- package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
- package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
- package/handles/providers/python-fastapi.mjs +22 -0
- package/handles/providers/typescript-express/emit.mjs +128 -0
- package/handles/providers/typescript-express/plan.mjs +234 -0
- package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
- package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
- package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
- package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
- package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
- package/handles/providers/typescript-express.mjs +20 -0
- package/handles/registry.mjs +90 -0
- package/lib/cli.mjs +430 -0
- package/lib/doctor.mjs +200 -0
- package/lib/exit-codes.mjs +67 -0
- package/lib/featureid.mjs +55 -0
- package/lib/featurelifecycle.mjs +205 -0
- package/lib/fsutil.mjs +50 -0
- package/lib/gate-definitions.mjs +293 -0
- package/lib/gates.mjs +263 -0
- package/lib/handles-manifest.mjs +92 -0
- package/lib/lock.mjs +68 -0
- package/lib/patch-approvals.mjs +56 -0
- package/lib/paths.mjs +21 -0
- package/lib/repo.mjs +44 -0
- package/lib/schema-validate.mjs +56 -0
- package/lib/state.mjs +124 -0
- package/lib/template.mjs +35 -0
- package/lib/verify.mjs +206 -0
- package/lib/workflow.mjs +142 -0
- package/new/fastapi.mjs +165 -0
- package/new/index.mjs +62 -0
- package/new/params.mjs +233 -0
- package/new/spring.mjs +198 -0
- package/new/templates/fastapi/README.md +26 -0
- package/new/templates/fastapi/app/__init__.py +0 -0
- package/new/templates/fastapi/app/main.py +8 -0
- package/new/templates/fastapi/gitignore +6 -0
- package/new/templates/fastapi/pyproject.toml +14 -0
- package/package.json +50 -0
- package/scanners/adapters/_express-shared.mjs +238 -0
- package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
- package/scanners/adapters/generic-grep.mjs +128 -0
- package/scanners/adapters/java-spring.mjs +301 -0
- package/scanners/adapters/javascript-express.mjs +422 -0
- package/scanners/adapters/python-fastapi.mjs +348 -0
- package/scanners/adapters/typescript-express.mjs +299 -0
- package/scanners/capabilities.mjs +90 -0
- package/scanners/conformance.mjs +59 -0
- package/scanners/db/introspect.mjs +109 -0
- package/scanners/db/migrations.mjs +126 -0
- package/scanners/index.mjs +281 -0
- package/scanners/registry.mjs +130 -0
- package/scanners/render.mjs +136 -0
- package/scanners/text-util.mjs +8 -0
- package/schemas/adapter.schema.json +23 -0
- package/schemas/agent-envelope.schema.json +21 -0
- package/schemas/contract-resolution.schema.json +28 -0
- package/schemas/feature-contract.schema.json +78 -0
- package/schemas/feature-index.schema.json +25 -0
- package/schemas/feature.schema.json +17 -0
- package/schemas/gate-event.schema.json +19 -0
- package/schemas/handles-plan.schema.json +31 -0
- package/schemas/handles-provider.schema.json +26 -0
- package/schemas/patch-approvals.schema.json +28 -0
- package/schemas/scan-report.schema.json +102 -0
- package/schemas/stack-choice.schema.json +89 -0
- package/schemas/stack-record.schema.json +20 -0
- package/schemas/state.schema.json +43 -0
- package/scripts/preflight-base-ref.sh +226 -0
- package/stack/apply.mjs +159 -0
- package/stack/bootstrap/_lib.sh +73 -0
- package/stack/bootstrap/ngrok.sh +90 -0
- package/stack/catalog/ngrok.yml +63 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// S1: the single declared source of what a gate IS -- its scope, whether `bskel verify` treats
|
|
2
|
+
// it as required, and how its token is recomputed from current repo state.
|
|
3
|
+
//
|
|
4
|
+
// WHY this module exists: before it, `bin/bskel.mjs`'s GATE_RECOMPUTERS (which gates get
|
|
5
|
+
// written and how their tokens are computed) and `lib/verify.mjs`'s GATE_SPECS (which gates
|
|
6
|
+
// `verify` reads and aggregates) were two hand-maintained lists. They drifted: `stack` was
|
|
7
|
+
// registered in GATE_RECOMPUTERS but missing from GATE_SPECS, so `stack apply` could pass a
|
|
8
|
+
// repo-scoped `stack` gate that `bskel verify` would never even look at. One list, consumed by
|
|
9
|
+
// both the write side (lib/gates.mjs's passNamedGate/awaitNamedGateDisposition) and the read
|
|
10
|
+
// side (lib/verify.mjs's collectGateStatuses), makes that specific class of drift structurally
|
|
11
|
+
// impossible -- there is no second list left to fall out of sync.
|
|
12
|
+
//
|
|
13
|
+
// Each gate's token must be computed from the SAME function both when the gate is written
|
|
14
|
+
// (pass) and when it's re-verified (require) -- otherwise "require" degenerates into comparing
|
|
15
|
+
// stored data against itself, which can never detect drift. If HEAD moved, or spec.md changed,
|
|
16
|
+
// or anything else in the gate's declared input set changed, the token mismatches and the gate
|
|
17
|
+
// reports `stale` -- it cannot be satisfied once and then silently drift out of sync with
|
|
18
|
+
// reality.
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { headSha, localDefaultBranch, remoteTrackingTip } from './repo.mjs';
|
|
21
|
+
import { sha256File, fileMode, readJsonIfExists, resolveWithinRoot } from './fsutil.mjs';
|
|
22
|
+
import { specPath, sbfPath } from './paths.mjs';
|
|
23
|
+
import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
|
|
24
|
+
import { loadManifest } from './handles-manifest.mjs';
|
|
25
|
+
|
|
26
|
+
// S2: prefix for stack's per-applied-file input keys -- lib/gates.mjs's diffInputs() compares
|
|
27
|
+
// top-level keys only, so a manifest-shaped input (one hash per applied file) has to flatten
|
|
28
|
+
// itself into distinctly-named keys here to let a stale report name the exact file that drifted.
|
|
29
|
+
const APPLIED_FILE_PREFIX = 'applied_file:';
|
|
30
|
+
// S6 (D-verify-integrity): content hashing alone is blind to a chmod-only change -- a
|
|
31
|
+
// `chmod -x scripts/dev-tunnel.sh` leaves sha256File byte-identical, so the gate stayed `pass`
|
|
32
|
+
// even though the script no longer runs. Tracked as a distinct key (not merged into the content
|
|
33
|
+
// hash) so diffInputs() can name it specifically -- "the mode drifted" is a different, more
|
|
34
|
+
// actionable report than a generic "stale".
|
|
35
|
+
const APPLIED_FILE_MODE_PREFIX = 'applied_file_mode:';
|
|
36
|
+
// S2 (D-gate-precision, continued): same flattened-manifest convention as APPLIED_FILE_PREFIX --
|
|
37
|
+
// one key per file the adapter actually reads, so diffInputs() can name the exact file that
|
|
38
|
+
// changed instead of a generic "stale".
|
|
39
|
+
const SOURCE_FILE_PREFIX = 'source_file:';
|
|
40
|
+
// S2 (D-gate-precision, part 2): same flattened-manifest convention -- one key per file belonging
|
|
41
|
+
// to the DISPOSED module specifically (a narrower set than SOURCE_FILE_PREFIX's whole-adapter
|
|
42
|
+
// read-set), so the `contract` gate stops being sensitive to every Java file in the repo.
|
|
43
|
+
const MODULE_FILE_PREFIX = 'module_file:';
|
|
44
|
+
|
|
45
|
+
// The preflight and stack gates are repo-scoped, not feature-scoped -- preflight runs before a
|
|
46
|
+
// feature_id exists at all, and a stack choice is a project-wide decision, not per-feature.
|
|
47
|
+
// Both are stored under the same per-feature state-file mechanism using this reserved id.
|
|
48
|
+
export const REPO_GATE_ID = '_repo';
|
|
49
|
+
|
|
50
|
+
export const SCOPE = Object.freeze({ REPO: 'repo', FEATURE: 'feature' });
|
|
51
|
+
|
|
52
|
+
// required : `not_run` or `stale` (or anything else short of `pass`) always fails
|
|
53
|
+
// `bskel verify`'s overall verdict.
|
|
54
|
+
// required-when-present : a gate that's never been run does not block verify (not every
|
|
55
|
+
// feature needs UUID handles or a stack-choice decision) -- but once it
|
|
56
|
+
// HAS run, every non-pass status (stale, awaiting_disposition, ...) is
|
|
57
|
+
// still blocking. "Optional" means "not every feature needs this", not
|
|
58
|
+
// "once run, its correctness stops mattering".
|
|
59
|
+
export const VERIFY_POLICY = Object.freeze({
|
|
60
|
+
REQUIRED: 'required',
|
|
61
|
+
REQUIRED_WHEN_PRESENT: 'required-when-present',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const GATE_DEFINITIONS = Object.freeze({
|
|
65
|
+
preflight: {
|
|
66
|
+
name: 'preflight',
|
|
67
|
+
scope: SCOPE.REPO,
|
|
68
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED,
|
|
69
|
+
// D-preflight-freshness (S3): `origin_tip_sha` added alongside (not instead of) `head_sha`/
|
|
70
|
+
// `default_branch` -- D-gate-precision (S2) already decided this gate keeps `head_sha`, and
|
|
71
|
+
// removing it here would break the "a commit stales preflight too" assumption
|
|
72
|
+
// test/contract-cli.test.mjs and test/handles-ownership-cli.test.mjs already depend on.
|
|
73
|
+
// `remoteTrackingTip()` is purely local (`git rev-parse`, no fetch) -- `require` noticing
|
|
74
|
+
// this value moved only means something ELSE already fetched into this repo since the
|
|
75
|
+
// gate last passed; if nothing has, the value is unchanged and this check is a no-op. See
|
|
76
|
+
// D-preflight-freshness in DECISIONS.md for why that limited scope is honest, not a bug.
|
|
77
|
+
recompute: (root) => {
|
|
78
|
+
const defaultBranch = localDefaultBranch(root);
|
|
79
|
+
return { head_sha: headSha(root), default_branch: defaultBranch, origin_tip_sha: remoteTrackingTip(root, defaultBranch) };
|
|
80
|
+
},
|
|
81
|
+
// D-preflight-freshness (S3): the one "freshness policy" slot S1's own catalog entry asked
|
|
82
|
+
// for but never had a gate to fill. 30 minutes is not a guess -- derived from Team-IZ-
|
|
83
|
+
// Backend's actual `origin/develop` commit cadence (109 commits over 35.8 days: p10=3m,
|
|
84
|
+
// p25=25m, median=100m, p75=264m, p90=17h). Treating "P(the remote tip moves within the
|
|
85
|
+
// TTL window)" as an upper bound on "this pass is silently wrong by the time it's used",
|
|
86
|
+
// 30m -> 5.1%, 60m -> 9.4%, 24h -> 59.0% (making a day-long TTL meaningless). Re-derive for
|
|
87
|
+
// any repo with:
|
|
88
|
+
// git log --first-parent --format=%ct origin/<default> | awk 'NR==1{p=$1;next}
|
|
89
|
+
// {d=p-$1;if(d>=0)g[n++]=d;p=$1}END{s=0;for(i=0;i<n;i++)s+=g[i];ttl=1800;num=0;
|
|
90
|
+
// for(i=0;i<n;i++)num+=(g[i]<ttl?g[i]:ttl);printf "P(moved within %dmin)=%.3f\n",
|
|
91
|
+
// ttl/60,num/s}'
|
|
92
|
+
// See DECISIONS.md's D-preflight-freshness for the full table and reasoning.
|
|
93
|
+
freshness: { defaultMaxAgeMinutes: 30 },
|
|
94
|
+
},
|
|
95
|
+
// S2 (D-gate-precision, continued): head_sha is GONE -- it was a repo-wide commit proxy for
|
|
96
|
+
// "has the codebase moved since scanning?" that was simultaneously too broad (staled on any
|
|
97
|
+
// unrelated commit, e.g. a doc or Python edit when the adapter is java-spring) and too narrow
|
|
98
|
+
// (blind to an UNCOMMITTED edit -- confirmed live before this fix: editing a real controller's
|
|
99
|
+
// content without committing left `scan` reporting `pass`). Replaced with a precise, per-file
|
|
100
|
+
// content fingerprint of the adapter's own real read-set. Deliberately RE-DERIVED fresh via
|
|
101
|
+
// `adapter.listReadSet(root)` on every check, not re-hashed from the report's OWN persisted
|
|
102
|
+
// `files_read` list -- a stale persisted list would stay blind to a brand-NEW file the same way
|
|
103
|
+
// D-gate-precision's own rejected alternative (ii) already named ("a manifest built from the
|
|
104
|
+
// scan report's own matched files is structurally blind to a newly added colliding
|
|
105
|
+
// controller"). Still covers the scan report's own content hash (has disposition/re-scan
|
|
106
|
+
// changed it?) and spec.md's content hash if it exists yet (scan can run before a spec is
|
|
107
|
+
// written, so this may be null).
|
|
108
|
+
scan: {
|
|
109
|
+
name: 'scan',
|
|
110
|
+
scope: SCOPE.FEATURE,
|
|
111
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED,
|
|
112
|
+
recompute: (root, featureId) => {
|
|
113
|
+
const reportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
114
|
+
const report = readJsonIfExists(reportPath);
|
|
115
|
+
const inputs = {
|
|
116
|
+
scan_report_hash: sha256File(reportPath),
|
|
117
|
+
spec_hash: sha256File(specPath(root, featureId, 'spec.md')),
|
|
118
|
+
};
|
|
119
|
+
// No report yet, an adapter this build no longer has, or one that predates
|
|
120
|
+
// listReadSet() (a hypothetical third-party adapter) -- fall back to the two hashes
|
|
121
|
+
// above only, same graceful-degradation shape lib/verify.mjs's checkResolverConflicts
|
|
122
|
+
// already established: never crash, never a false pass.
|
|
123
|
+
const adapter = report ? adapterById(ADAPTERS, report.adapter) : null;
|
|
124
|
+
if (adapter?.listReadSet) {
|
|
125
|
+
// Found live, not assumed: a generated resolver (O2) lives INSIDE the same
|
|
126
|
+
// src/main/java tree java-spring's listReadSet() globs -- without this exclusion,
|
|
127
|
+
// `handles emit` writing its own output would make `scan` newly "see" a file that
|
|
128
|
+
// didn't exist at scan time, indistinguishable from a human adding a real new
|
|
129
|
+
// controller, staling `scan` on every single `handles emit` run. O2's own
|
|
130
|
+
// generated-file registry (.sbf/handles-manifest.json) is the authoritative
|
|
131
|
+
// answer to "did backend-skeleton itself write this" -- reused here rather than a
|
|
132
|
+
// directory-name/filename-pattern guess. try/catch mirrors handlesManifestChecks():
|
|
133
|
+
// an unreadable manifest must not crash gate recomputation.
|
|
134
|
+
let generatedPaths;
|
|
135
|
+
try {
|
|
136
|
+
generatedPaths = new Set(Object.keys(loadManifest(root).files ?? {}));
|
|
137
|
+
} catch {
|
|
138
|
+
generatedPaths = new Set();
|
|
139
|
+
}
|
|
140
|
+
for (const rel of adapter.listReadSet(root)) {
|
|
141
|
+
if (generatedPaths.has(rel)) continue;
|
|
142
|
+
const abs = resolveWithinRoot(root, rel);
|
|
143
|
+
if (!abs) continue;
|
|
144
|
+
inputs[`${SOURCE_FILE_PREFIX}${rel}`] = sha256File(abs);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return inputs;
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
// The contract gate's token covers the emitted contract file's own hash (re-emitting after
|
|
151
|
+
// a re-scan invalidates it) and head_sha -- NOT the scan report's hash directly, since the
|
|
152
|
+
// contract is a derived artifact; if the scan changes but the contract hasn't been
|
|
153
|
+
// re-emitted, that should surface as "contract is out of date with scan", which is a
|
|
154
|
+
// judgment call for `bskel contract emit` to re-run, not something require silently papers
|
|
155
|
+
// over by trusting the old contract.
|
|
156
|
+
//
|
|
157
|
+
// A5: also covers the resolution (waiver) file's hash. A waiver is a human decision that lets
|
|
158
|
+
// a `partial` contract's outstanding warnings stop blocking `bskel verify` -- if that file is
|
|
159
|
+
// deleted or hand-edited, the gate must go stale the same way it would if the contract itself
|
|
160
|
+
// were tampered with; a gate that stays green after its waivers disappear underneath it is
|
|
161
|
+
// exactly the "pass in name only" failure mode this whole gate mechanism exists to prevent
|
|
162
|
+
// (see D1 in DECISIONS.md). `sha256File` returns null when the file doesn't exist, which is
|
|
163
|
+
// the common no-waivers case -- stable and fine as a token input.
|
|
164
|
+
//
|
|
165
|
+
// A1: also covers the OpenAPI reconciliation snapshot's hash, for the identical reason --
|
|
166
|
+
// path correction against a real OpenAPI document is only trustworthy for as long as the
|
|
167
|
+
// snapshot that recorded it is intact. `null` when `bskel contract emit` was never run with
|
|
168
|
+
// `--openapi-file` (the common case), which is stable and requires no special-casing here.
|
|
169
|
+
//
|
|
170
|
+
// S2 (D-gate-precision, part 2): head_sha is GONE, replaced by two PRECISE signals instead
|
|
171
|
+
// of the single opaque "upstream_token" this item's own earlier EXIT sketched -- an
|
|
172
|
+
// upstream_token equal to the scan gate's own token would inherit scan's deliberately
|
|
173
|
+
// whole-Java-tree-sensitive design (Part 1), reintroducing "any Java file anywhere stales
|
|
174
|
+
// this feature's contract" right back, which is exactly what per-feature narrowing means to
|
|
175
|
+
// avoid. `scan_report_hash` catches an explicit re-scan/re-disposition (including this
|
|
176
|
+
// module's own membership changing); `module_file:<relpath>` per-file hashes catch a content
|
|
177
|
+
// edit to an ALREADY-KNOWN file belonging to the DISPOSED module specifically (not the whole
|
|
178
|
+
// adapter read-set) -- the same "uncommitted change must be visible" property Part 1 already
|
|
179
|
+
// established for `scan`, one level down. Known, accepted limitation: a brand-new file added
|
|
180
|
+
// to the SAME already-disposed module, before the next explicit `bskel scan` re-run, is not
|
|
181
|
+
// caught by the per-file hashes (only `scan_report_hash`, at the next re-scan, catches it) --
|
|
182
|
+
// closing that precisely would mean re-running full module-assignment logic on every
|
|
183
|
+
// verify/gate-require call, real added cost for an edge case, not the common one.
|
|
184
|
+
contract: {
|
|
185
|
+
name: 'contract',
|
|
186
|
+
scope: SCOPE.FEATURE,
|
|
187
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED,
|
|
188
|
+
recompute: (root, featureId) => {
|
|
189
|
+
const reportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
190
|
+
const report = readJsonIfExists(reportPath);
|
|
191
|
+
const inputs = {
|
|
192
|
+
scan_report_hash: sha256File(reportPath),
|
|
193
|
+
contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
|
|
194
|
+
resolution_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.resolution.json`)),
|
|
195
|
+
openapi_snapshot_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.openapi.snapshot.json`)),
|
|
196
|
+
};
|
|
197
|
+
const moduleName = report?.disposition?.module ?? report?.related_modules?.[0]?.module;
|
|
198
|
+
const mod = report?.related_modules?.find((m) => m.module === moduleName);
|
|
199
|
+
// DTOs deliberately excluded -- scanJavaSpring() stores them as bare class-name
|
|
200
|
+
// strings today, no `.file` field (Part 1's own named, out-of-scope gap).
|
|
201
|
+
for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? [])]) {
|
|
202
|
+
if (!item.file) continue;
|
|
203
|
+
// related_modules[].{controllers,entities,enums}[].file are stored ABSOLUTE
|
|
204
|
+
// (unlike Part 1's own repo-relative files_read) -- confirmed live against a real
|
|
205
|
+
// scan report before writing this.
|
|
206
|
+
const rel = path.relative(root, item.file);
|
|
207
|
+
inputs[`${MODULE_FILE_PREFIX}${rel}`] = sha256File(item.file);
|
|
208
|
+
}
|
|
209
|
+
return inputs;
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
// Staleness = the generated Java (or the contract it was generated from) has moved since
|
|
213
|
+
// emit -- NOT "does the migration still match the DB schema" (unknowable without a live DB
|
|
214
|
+
// connection this tool deliberately never opens on its own, see D-migration-scope). Note
|
|
215
|
+
// this token does NOT cover specs/<id>/handles/migration.sql itself -- lib/verify.mjs's
|
|
216
|
+
// checkArtifacts() is the only thing that notices if that file goes missing (S6).
|
|
217
|
+
//
|
|
218
|
+
// S2 (D-gate-precision, part 2): head_sha dropped, no replacement needed -- contract_hash
|
|
219
|
+
// alone is already sufficient. `handles` is derived from the CONTRACT, not directly from
|
|
220
|
+
// source; now that `contract`'s own token is precise (above), contract_hash transitively
|
|
221
|
+
// carries that precision. `contract` is a REQUIRED gate, so bskel verify's overall verdict is
|
|
222
|
+
// already blocked whenever contract itself is stale -- `handles` reporting "pass" (its
|
|
223
|
+
// emitted Java still matches the currently-emitted contract) stays accurate even then.
|
|
224
|
+
handles: {
|
|
225
|
+
name: 'handles',
|
|
226
|
+
scope: SCOPE.FEATURE,
|
|
227
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
|
|
228
|
+
recompute: (root, featureId) => ({
|
|
229
|
+
contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
// Repo-scoped like preflight (a stack choice is a project-wide decision, not per-feature).
|
|
233
|
+
// Staleness here means "the applied files or the choice-of-catalog-entry are gone/changed",
|
|
234
|
+
// not "re-verify the tunnel is currently running" -- that's a runtime concern, not a gate.
|
|
235
|
+
// S2: the token now covers the CONTENT of every file the stack record says `stack apply` wrote,
|
|
236
|
+
// not just the record's own bytes. Before this, deleting or editing scripts/dev-tunnel.sh left
|
|
237
|
+
// stack.json byte-identical, so stack_record_hash never moved and the gate stayed `pass`
|
|
238
|
+
// forever -- while the comment right above claimed applied-file drift was covered. It now is.
|
|
239
|
+
// `head_sha` is deliberately gone: this gate's input set is precisely enumerated on disk (via
|
|
240
|
+
// `applied_files`), so a repo-wide "something, somewhere, moved" proxy adds nothing except
|
|
241
|
+
// staling the gate on every unrelated commit (D-gate-precision). The other four gates keep
|
|
242
|
+
// head_sha -- their real read-sets are not enumerable yet, see D-gate-precision's EXIT.
|
|
243
|
+
stack: {
|
|
244
|
+
name: 'stack',
|
|
245
|
+
scope: SCOPE.REPO,
|
|
246
|
+
verifyPolicy: VERIFY_POLICY.REQUIRED_WHEN_PRESENT,
|
|
247
|
+
recompute: (root) => {
|
|
248
|
+
const recordPath = sbfPath(root, 'stack.json');
|
|
249
|
+
const record = readJsonIfExists(recordPath);
|
|
250
|
+
const inputs = { stack_record_hash: sha256File(recordPath) };
|
|
251
|
+
for (const rel of record?.applied_files ?? []) {
|
|
252
|
+
const abs = resolveWithinRoot(root, rel);
|
|
253
|
+
if (!abs) continue; // a path escaping the repo is not something `stack apply` wrote
|
|
254
|
+
inputs[`${APPLIED_FILE_PREFIX}${rel}`] = sha256File(abs); // null == deleted -> stale
|
|
255
|
+
inputs[`${APPLIED_FILE_MODE_PREFIX}${rel}`] = fileMode(abs); // null == deleted -> stale
|
|
256
|
+
}
|
|
257
|
+
return inputs;
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Explicit order (= workflow order), not `Object.keys(GATE_DEFINITIONS)` insertion order --
|
|
263
|
+
// test/gate-definitions.test.mjs asserts this stays exactly in sync with GATE_DEFINITIONS' own
|
|
264
|
+
// key set, so a gate added to one and not the other fails loudly instead of silently vanishing
|
|
265
|
+
// from `bskel verify` the way `stack` did before this module existed.
|
|
266
|
+
export const GATE_NAMES = Object.freeze(['preflight', 'scan', 'contract', 'handles', 'stack']);
|
|
267
|
+
|
|
268
|
+
export function getGateDefinition(name) {
|
|
269
|
+
return Object.hasOwn(GATE_DEFINITIONS, name) ? GATE_DEFINITIONS[name] : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The typo-defense point. Before this, `bskel gate require scna` silently reported `not_run`
|
|
273
|
+
// (exit 2) -- indistinguishable from "a real gate that just hasn't run yet" -- so a typo reads
|
|
274
|
+
// as "not done" instead of "this gate doesn't exist", and a caller could wait forever for a
|
|
275
|
+
// gate that will never pass because it was never real.
|
|
276
|
+
export function requireGateDefinition(name) {
|
|
277
|
+
const def = getGateDefinition(name);
|
|
278
|
+
if (!def) {
|
|
279
|
+
throw new Error(`unknown gate "${name}" -- known gates: ${GATE_NAMES.join(', ')}`);
|
|
280
|
+
}
|
|
281
|
+
return def;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Scope comes from the definition, not from whatever a caller happened to pass as --feature --
|
|
285
|
+
// closes off the class of bug where a repo-scoped gate's write/read path depends on the caller
|
|
286
|
+
// remembering to pass (or not pass) the right --feature value.
|
|
287
|
+
export function gateScopeId(name, featureId) {
|
|
288
|
+
return requireGateDefinition(name).scope === SCOPE.REPO ? REPO_GATE_ID : featureId;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function gateInputs(root, name, featureId) {
|
|
292
|
+
return requireGateDefinition(name).recompute(root, featureId);
|
|
293
|
+
}
|
package/lib/gates.mjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Gate primitives shared by every `bskel <verb>` that needs to read or write a gate.
|
|
2
|
+
// A gate is "passed" only if a token recomputed from the CURRENT inputs matches the token
|
|
3
|
+
// that was stored when the gate last passed. If HEAD moved, or spec.md changed, or anything
|
|
4
|
+
// else in the gate's declared input set changed, the token mismatches and the gate reports
|
|
5
|
+
// `stale` -- it cannot be satisfied once and then silently drift out of sync with reality.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { getGate, setGate } from './state.mjs';
|
|
8
|
+
import { getGateDefinition, gateScopeId, gateInputs } from './gate-definitions.mjs';
|
|
9
|
+
import { EXIT_CODES } from './exit-codes.mjs';
|
|
10
|
+
|
|
11
|
+
// D2: re-exported under these names (unchanged) from the single exit-code table in
|
|
12
|
+
// lib/exit-codes.mjs -- every existing `EXIT.*` reference across bin/bskel.mjs and the test
|
|
13
|
+
// suite keeps working unmodified.
|
|
14
|
+
export const EXIT = Object.freeze({
|
|
15
|
+
PASS: EXIT_CODES.OK,
|
|
16
|
+
NOT_PASSED: EXIT_CODES.NOT_PASSED,
|
|
17
|
+
AWAITING_DISPOSITION: EXIT_CODES.AWAITING_DISPOSITION,
|
|
18
|
+
STALE: EXIT_CODES.STALE,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// S2: why a stale gate couldn't say WHICH input changed until now -- see diffInputs()/
|
|
22
|
+
// explainStaleness() below.
|
|
23
|
+
export const STALE_REASON = Object.freeze({
|
|
24
|
+
INPUTS_CHANGED: 'inputs_changed',
|
|
25
|
+
NO_RECORDED_INPUTS: 'no_recorded_inputs',
|
|
26
|
+
RECORDED_INPUTS_MISMATCH: 'recorded_inputs_mismatch',
|
|
27
|
+
UNKNOWN: 'unknown',
|
|
28
|
+
// D-preflight-freshness (S3): a gate whose token still matches current inputs, but whose pass
|
|
29
|
+
// is simply too OLD -- see checkFreshness() below.
|
|
30
|
+
TTL_EXPIRED: 'ttl_expired',
|
|
31
|
+
INVALID_TIMESTAMP: 'invalid_timestamp',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export function computeToken(inputs) {
|
|
35
|
+
const canonical = JSON.stringify(sortKeysDeep(inputs));
|
|
36
|
+
return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sortKeysDeep(value) {
|
|
40
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
41
|
+
if (value && typeof value === 'object') {
|
|
42
|
+
return Object.fromEntries(
|
|
43
|
+
Object.keys(value).sort().map((key) => [key, sortKeysDeep(value[key])]),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// S2: `computeToken()` collapses an entire inputs object to one sha256 string -- once both sides
|
|
50
|
+
// of a comparison are hashed down like that, there is nothing left to diff. Reporting exactly
|
|
51
|
+
// which input changed requires comparing the PRE-hash objects, key by key, which is what this
|
|
52
|
+
// does (given both objects, not gate state -- kept pure/fs-free so it's directly unit-testable).
|
|
53
|
+
// Top-level keys only, by design: a gate whose real inputs are file-manifest-shaped (e.g. one
|
|
54
|
+
// hash per applied file) must flatten them into prefixed keys in its own recompute() (see
|
|
55
|
+
// `stack`'s `applied_file:<relpath>` convention in lib/gate-definitions.mjs) -- that's what lets
|
|
56
|
+
// this diff name an exact file instead of just "some nested value changed".
|
|
57
|
+
const ABSENT = Symbol('absent');
|
|
58
|
+
|
|
59
|
+
export function diffInputs(storedInputs, currentInputs) {
|
|
60
|
+
const stored = storedInputs ?? {};
|
|
61
|
+
const current = currentInputs ?? {};
|
|
62
|
+
const keys = [...new Set([...Object.keys(stored), ...Object.keys(current)])].sort();
|
|
63
|
+
return keys.filter((key) => {
|
|
64
|
+
// A key present on only one side (a gate DEFINITION change -- an input added/removed by a
|
|
65
|
+
// bskel upgrade) must count as changed even when the other side's value is `null` (the
|
|
66
|
+
// common sha256File-on-a-missing-file case) -- comparing against `undefined` directly would
|
|
67
|
+
// let that collapse to "no diff" and report an empty changed_inputs list on a real change.
|
|
68
|
+
const a = Object.hasOwn(stored, key) ? JSON.stringify(sortKeysDeep(stored[key])) : ABSENT;
|
|
69
|
+
const b = Object.hasOwn(current, key) ? JSON.stringify(sortKeysDeep(current[key])) : ABSENT;
|
|
70
|
+
return a !== b;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function explainStaleness(record, currentInputs) {
|
|
75
|
+
if (!record.inputs || typeof record.inputs !== 'object') {
|
|
76
|
+
// A gate record written before S2 shipped -- still definitively stale (the token really did
|
|
77
|
+
// move), just without a pre-hash snapshot to diff against. The next real re-run of the
|
|
78
|
+
// underlying command writes a record WITH inputs, so this heals itself; there is no attempt
|
|
79
|
+
// to fabricate a retroactive snapshot (that would just fail the mismatch check below).
|
|
80
|
+
return { changed_inputs: null, stale_reason: STALE_REASON.NO_RECORDED_INPUTS };
|
|
81
|
+
}
|
|
82
|
+
if (computeToken(record.inputs) !== record.token) {
|
|
83
|
+
// The stored inputs snapshot doesn't even reproduce the token stored right next to it --
|
|
84
|
+
// e.g. .sbf state was hand-edited. Diffing against an unverified snapshot here would be a
|
|
85
|
+
// confident-sounding lie, so report the integrity failure instead of a key list.
|
|
86
|
+
return { changed_inputs: null, stale_reason: STALE_REASON.RECORDED_INPUTS_MISMATCH };
|
|
87
|
+
}
|
|
88
|
+
const changed = diffInputs(record.inputs, currentInputs);
|
|
89
|
+
// Unreachable by construction -- computeToken(record.inputs) matching record.token, plus
|
|
90
|
+
// currentToken having already failed to match record.token (the only place this is called
|
|
91
|
+
// from), means diffInputs must find at least one changed key. Kept as a defensive fallback so
|
|
92
|
+
// a future bug in either function degrades to "I don't know" instead of a confident empty list.
|
|
93
|
+
if (changed.length === 0) return { changed_inputs: [], stale_reason: STALE_REASON.UNKNOWN };
|
|
94
|
+
return { changed_inputs: changed, stale_reason: STALE_REASON.INPUTS_CHANGED };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// D-preflight-freshness (S3): a pass whose token still matches current inputs can still be too
|
|
98
|
+
// OLD to trust -- e.g. preflight's `origin_tip_sha` input only moves if something else already
|
|
99
|
+
// fetched into this repo; nothing forces that to happen on its own. Judged entirely separately
|
|
100
|
+
// from diffInputs()/explainStaleness() above, which only ever compare input VALUES -- age lives
|
|
101
|
+
// in the gate record's own `at` timestamp, never inside `inputs` itself (putting a timestamp
|
|
102
|
+
// there would make every single require() call report a change, since "now" is never equal to
|
|
103
|
+
// the recorded instant -- see D-preflight-freshness in DECISIONS.md, confirmed by direct
|
|
104
|
+
// execution before this was written).
|
|
105
|
+
//
|
|
106
|
+
// S4 (D-gate-history): a FORCED record never inherits the underlying gate's own `def.freshness`
|
|
107
|
+
// policy (e.g. preflight's 30-minute default) -- that policy exists to judge naturally-earned
|
|
108
|
+
// evidence, not a human's explicit override. A forced pass only gets a TTL when the human asked
|
|
109
|
+
// for one (`bskel gate force <name> --max-age-minutes N`, recorded the same way passGate already
|
|
110
|
+
// records `evidence.freshness.max_age_minutes`) -- same "explicit, auditable" philosophy as
|
|
111
|
+
// `--max-age-minutes 0` disabling preflight's own TTL.
|
|
112
|
+
//
|
|
113
|
+
// Returns null (fresh, or freshness not applicable) or `{ stale_reason, age_seconds,
|
|
114
|
+
// max_age_seconds }`. Reads the max age from the PASS's own recorded evidence first (so a gate
|
|
115
|
+
// that changes its default later doesn't retroactively judge an already-passed gate by a
|
|
116
|
+
// different policy than the one it was passed under), falling back to the gate definition's
|
|
117
|
+
// `defaultMaxAgeMinutes`. A max age of 0 (either source) disables the TTL entirely -- an explicit,
|
|
118
|
+
// auditable choice (`bskel preflight --max-age-minutes 0`), not a silent bypass.
|
|
119
|
+
function checkFreshness(def, record) {
|
|
120
|
+
const maxAgeMinutes = record.forced
|
|
121
|
+
? record.evidence?.freshness?.max_age_minutes
|
|
122
|
+
: (record.evidence?.freshness?.max_age_minutes ?? def?.freshness?.defaultMaxAgeMinutes);
|
|
123
|
+
if (!(maxAgeMinutes > 0)) return null;
|
|
124
|
+
const maxAgeSeconds = maxAgeMinutes * 60;
|
|
125
|
+
const passedAt = Date.parse(record.at);
|
|
126
|
+
if (!Number.isFinite(passedAt)) {
|
|
127
|
+
return { stale_reason: STALE_REASON.INVALID_TIMESTAMP, age_seconds: null, max_age_seconds: maxAgeSeconds };
|
|
128
|
+
}
|
|
129
|
+
// Clock rewind (a passed-at instant in the future) clamps to 0 rather than going negative --
|
|
130
|
+
// treated as "as fresh as possible", never as extra-stale.
|
|
131
|
+
const ageSeconds = Math.max(0, Math.round((Date.now() - passedAt) / 1000));
|
|
132
|
+
if (ageSeconds <= maxAgeSeconds) return null;
|
|
133
|
+
return { stale_reason: STALE_REASON.TTL_EXPIRED, age_seconds: ageSeconds, max_age_seconds: maxAgeSeconds };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// S2: `inputs` is stored alongside `token` (canonically sorted, same form the token was computed
|
|
137
|
+
// from) -- purely additive, so this invariant holds for every record this module ever writes:
|
|
138
|
+
// computeToken(record.inputs) === record.token. That invariant is what makes explainStaleness()'s
|
|
139
|
+
// integrity check meaningful rather than a guess.
|
|
140
|
+
export function passGate(repoRoot, featureId, gateName, inputs, evidence = {}) {
|
|
141
|
+
const token = computeToken(inputs);
|
|
142
|
+
return setGate(repoRoot, featureId, gateName, {
|
|
143
|
+
status: 'pass',
|
|
144
|
+
token,
|
|
145
|
+
inputs: sortKeysDeep(inputs),
|
|
146
|
+
at: new Date().toISOString(),
|
|
147
|
+
evidence,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function awaitDispositionGate(repoRoot, featureId, gateName, inputs, evidence = {}) {
|
|
152
|
+
const token = computeToken(inputs);
|
|
153
|
+
return setGate(repoRoot, featureId, gateName, {
|
|
154
|
+
status: 'awaiting_disposition',
|
|
155
|
+
token,
|
|
156
|
+
inputs: sortKeysDeep(inputs),
|
|
157
|
+
at: new Date().toISOString(),
|
|
158
|
+
evidence,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// S4 (D-gate-history): `currentInputs` is now the gate's REAL current inputs (same shape
|
|
163
|
+
// passGate() would bind to), not a synthetic {forced,reason} placeholder -- token/inputs bind to
|
|
164
|
+
// what was actually true at force time, so a LATER input change (including HEAD moving, since
|
|
165
|
+
// every gate but `stack` includes head_sha in its own inputs -- see D-gate-precision) makes this
|
|
166
|
+
// record go stale exactly the way a real pass would, closing "a forced gate passes forever
|
|
167
|
+
// regardless of changed inputs" without a separate commit-tracking mechanism. `maxAgeMinutes`
|
|
168
|
+
// (opt-in, `bskel gate force <name> --max-age-minutes N`) is the OTHER independent axis --
|
|
169
|
+
// checkFreshness() above never lets a forced record inherit the underlying gate's own TTL policy.
|
|
170
|
+
export function forceGate(repoRoot, featureId, gateName, reason, currentInputs, { maxAgeMinutes = null } = {}) {
|
|
171
|
+
if (!reason || !reason.trim()) {
|
|
172
|
+
throw new Error('gate force requires --reason "..." -- every bypass must be auditable');
|
|
173
|
+
}
|
|
174
|
+
return setGate(repoRoot, featureId, gateName, {
|
|
175
|
+
status: 'pass',
|
|
176
|
+
token: computeToken(currentInputs),
|
|
177
|
+
inputs: sortKeysDeep(currentInputs),
|
|
178
|
+
at: new Date().toISOString(),
|
|
179
|
+
forced: true,
|
|
180
|
+
reason,
|
|
181
|
+
evidence: maxAgeMinutes != null ? { freshness: { max_age_minutes: maxAgeMinutes } } : {},
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// S4 (D-gate-history): un-passes a gate with an explicit, auditable reason -- distinct from
|
|
186
|
+
// "never ran" (`not_run`) so a human/agent reading `bskel gate show` can tell "this was
|
|
187
|
+
// deliberately pulled back" from "nobody has checked this yet". `currentInputs`' token is stored
|
|
188
|
+
// for schema consistency with every other record shape (state.schema.json requires `token`), not
|
|
189
|
+
// because anything ever compares against it -- requireGate() short-circuits on `status !==
|
|
190
|
+
// 'pass'`/`'awaiting_disposition'` before token comparison would even run.
|
|
191
|
+
export function revokeGate(repoRoot, featureId, gateName, reason, currentInputs) {
|
|
192
|
+
if (!reason || !reason.trim()) {
|
|
193
|
+
throw new Error('gate revoke requires --reason "..." -- every revocation must be auditable');
|
|
194
|
+
}
|
|
195
|
+
return setGate(repoRoot, featureId, gateName, {
|
|
196
|
+
status: 'revoked',
|
|
197
|
+
token: computeToken(currentInputs),
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
reason,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Recomputes the token from `currentInputs` and compares it to what's on disk.
|
|
204
|
+
// Returns { code, status, record } where code is one of EXIT.*.
|
|
205
|
+
export function requireGate(repoRoot, featureId, gateName, currentInputs) {
|
|
206
|
+
const record = getGate(repoRoot, featureId, gateName);
|
|
207
|
+
if (!record) {
|
|
208
|
+
return { code: EXIT.NOT_PASSED, status: 'not_run', record: null };
|
|
209
|
+
}
|
|
210
|
+
if (record.status === 'awaiting_disposition') {
|
|
211
|
+
return { code: EXIT.AWAITING_DISPOSITION, status: 'awaiting_disposition', record };
|
|
212
|
+
}
|
|
213
|
+
if (record.status !== 'pass') {
|
|
214
|
+
// S4: covers 'revoked' (and anything else non-pass) generically -- no separate branch
|
|
215
|
+
// needed, `record.status` is already the right string and NOT_PASSED the right code.
|
|
216
|
+
return { code: EXIT.NOT_PASSED, status: record.status, record };
|
|
217
|
+
}
|
|
218
|
+
// S4: forced records now flow through the SAME staleness/freshness pipeline as a real pass --
|
|
219
|
+
// no more early-return special case. `token`/`inputs` were bound to the real current inputs at
|
|
220
|
+
// force time (forceGate() above), so this naturally catches "the input this was forced past
|
|
221
|
+
// has since changed"; checkFreshness() separately refuses to apply the underlying gate's own
|
|
222
|
+
// TTL policy to a forced record (see its own comment) so this doesn't silently start expiring
|
|
223
|
+
// forces that were never given an explicit --max-age-minutes.
|
|
224
|
+
const currentToken = computeToken(currentInputs);
|
|
225
|
+
if (currentToken !== record.token) {
|
|
226
|
+
return { code: EXIT.STALE, status: 'stale', record, currentToken, ...explainStaleness(record, currentInputs) };
|
|
227
|
+
}
|
|
228
|
+
// D-preflight-freshness (S3): checked only once the token has ALREADY matched -- an input
|
|
229
|
+
// that actually changed is always the more specific, more actionable answer, and takes
|
|
230
|
+
// priority over "also, it happens to be old".
|
|
231
|
+
const freshness = checkFreshness(getGateDefinition(gateName), record);
|
|
232
|
+
if (freshness) {
|
|
233
|
+
return { code: EXIT.STALE, status: 'stale', record, currentToken, changed_inputs: [], ...freshness };
|
|
234
|
+
}
|
|
235
|
+
return { code: EXIT.PASS, status: record.forced ? 'pass (forced)' : 'pass', record };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// S1: name-based layer over the primitives above. A caller that only knows a gate's NAME
|
|
239
|
+
// (not its scope or its token inputs) can pass/await/require it without reaching into
|
|
240
|
+
// lib/gate-definitions.mjs itself -- the scope and recomputed inputs come from the gate's own
|
|
241
|
+
// declaration, so a call site can no longer hand-assemble the wrong inputs or write to the
|
|
242
|
+
// wrong scope by mistake.
|
|
243
|
+
export function passNamedGate(repoRoot, gateName, featureId, evidence = {}) {
|
|
244
|
+
return passGate(repoRoot, gateScopeId(gateName, featureId), gateName, gateInputs(repoRoot, gateName, featureId), evidence);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function awaitNamedGateDisposition(repoRoot, gateName, featureId, evidence = {}) {
|
|
248
|
+
return awaitDispositionGate(repoRoot, gateScopeId(gateName, featureId), gateName, gateInputs(repoRoot, gateName, featureId), evidence);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function requireNamedGate(repoRoot, gateName, featureId) {
|
|
252
|
+
return requireGate(repoRoot, gateScopeId(gateName, featureId), gateName, gateInputs(repoRoot, gateName, featureId));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// S4: same named-layer pattern -- forceGate/revokeGate now need the gate's real current inputs
|
|
256
|
+
// too (to bind the token), so they get the same treatment passGate/requireGate already have.
|
|
257
|
+
export function forceNamedGate(repoRoot, gateName, featureId, reason, options = {}) {
|
|
258
|
+
return forceGate(repoRoot, gateScopeId(gateName, featureId), gateName, reason, gateInputs(repoRoot, gateName, featureId), options);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function revokeNamedGate(repoRoot, gateName, featureId, reason) {
|
|
262
|
+
return revokeGate(repoRoot, gateScopeId(gateName, featureId), gateName, reason, gateInputs(repoRoot, gateName, featureId));
|
|
263
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// O2: generated-file ownership manifest for `bskel handles emit`.
|
|
2
|
+
// WHY: emitHandles() used to write every generated file unconditionally, which could erase a
|
|
3
|
+
// hand-completed patchField() (D-resolver-scope) on a rerun, or silently hand a resolver
|
|
4
|
+
// to a second feature. See DECISIONS.md D-handles-ownership for the full design.
|
|
5
|
+
// Safety is CONTENT-derived, not manifest-derived: classifyFile() below only ever produces a
|
|
6
|
+
// false CONFLICT when the manifest is missing/stale, never a false overwrite -- the manifest is
|
|
7
|
+
// an optimization that reduces false-positive conflicts, not the safety mechanism itself.
|
|
8
|
+
import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
|
|
9
|
+
import { sbfPath } from './paths.mjs';
|
|
10
|
+
|
|
11
|
+
const MANIFEST_SCHEMA = 'sbf.handles-manifest/1';
|
|
12
|
+
|
|
13
|
+
export function manifestPath(root) {
|
|
14
|
+
return sbfPath(root, 'handles-manifest.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function loadManifest(root) {
|
|
18
|
+
const parsed = readJsonIfExists(manifestPath(root));
|
|
19
|
+
if (!parsed) return { schema: MANIFEST_SCHEMA, files: {} };
|
|
20
|
+
if (parsed.schema !== MANIFEST_SCHEMA) {
|
|
21
|
+
throw new Error(`${manifestPath(root)}: unrecognized handles-manifest schema "${parsed.schema}" (expected ${MANIFEST_SCHEMA})`);
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Sorted keys for stable diffs if a target repo commits this file. Callers are responsible for
|
|
27
|
+
// only calling this when something in `manifest.files` actually changed -- classifyFile()'s
|
|
28
|
+
// 'unchanged' action means "don't touch the manifest entry", not "resave it unchanged", so a
|
|
29
|
+
// true no-op re-emit leaves this file byte-identical (see test/handles-manifest.test.mjs and the
|
|
30
|
+
// e2e regression in test/handles-ownership-cli.test.mjs).
|
|
31
|
+
export function saveManifest(root, manifest) {
|
|
32
|
+
const sortedFiles = Object.fromEntries(Object.keys(manifest.files).sort().map((k) => [k, manifest.files[k]]));
|
|
33
|
+
writeFileAtomic(manifestPath(root), `${JSON.stringify({ schema: manifest.schema, files: sortedFiles }, null, 2)}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Every resolver stub's doc comment carries "... for feature <id>." -- ResourceResolverStub.java
|
|
37
|
+
// .tmpl's javadoc says "({@code bskel handles emit})", resolver.py.tmpl's docstring (python-
|
|
38
|
+
// fastapi, G4) says the same thing without the javadoc {@code} wrapper -- the `(?:...)?` groups
|
|
39
|
+
// below make both optional so one regex matches either language's own doc-comment convention.
|
|
40
|
+
// Recovering the feature id lets the adoption path (no manifest entry) re-render with the file's
|
|
41
|
+
// OWN original feature id instead of the current run's, which is what makes a second feature
|
|
42
|
+
// touching the same resource type a content-derived ownership-transfer WARN instead of a
|
|
43
|
+
// guaranteed false CONFLICT.
|
|
44
|
+
const RESOLVER_OWNER_MARKER_RE = /Generated by backend-skeleton \((?:\{@code )?bskel handles emit\}?\) for feature (.+?)\./;
|
|
45
|
+
|
|
46
|
+
export function extractResolverOwnerFeatureId(content) {
|
|
47
|
+
if (typeof content !== 'string') return null;
|
|
48
|
+
const m = content.match(RESOLVER_OWNER_MARKER_RE);
|
|
49
|
+
return m ? m[1] : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Shared prefix of every provider's resolver doc-comment marker -- used only to decide "did bskel
|
|
53
|
+
// plausibly generate this file" for orphan detection (never for the safety-critical conflict
|
|
54
|
+
// decision itself, which is classifyFile()'s job). Deliberately short (G4: no longer includes the
|
|
55
|
+
// javadoc-specific "({@code bskel handles emit})" suffix) so it matches every provider's own
|
|
56
|
+
// resolver doc-comment convention via plain substring inclusion, without needing a per-language
|
|
57
|
+
// variant here.
|
|
58
|
+
export const BSKEL_GENERATED_MARKER = 'Generated by backend-skeleton';
|
|
59
|
+
|
|
60
|
+
// Pure decision logic -- fs-free, so it's directly unit-testable (test/handles-manifest.test.mjs)
|
|
61
|
+
// without a CLI/git fixture. handles/emit.mjs does every actual fs read/hash and calls this once
|
|
62
|
+
// per candidate file.
|
|
63
|
+
//
|
|
64
|
+
// exists -- does the target file exist on disk right now
|
|
65
|
+
// diskHash -- sha256 of current disk content, or null (must be null iff !exists)
|
|
66
|
+
// manifestEntryHash -- manifest.files[relPath]?.generated_hash, or null if no entry
|
|
67
|
+
// freshRenderHash -- sha256 of rendering the template with THIS run's inputs
|
|
68
|
+
// matchesPristineRender -- true if disk content equals SOME valid pristine render of this
|
|
69
|
+
// file (for infra: the fresh render, since infra has no per-feature
|
|
70
|
+
// input; for a resolver: a render using the owner recovered from its
|
|
71
|
+
// own marker, which may differ from this run's featureId)
|
|
72
|
+
//
|
|
73
|
+
// Returns one of: 'create' | 'unchanged' | 'update' | 'adopt-unchanged' | 'adopt-update' | 'conflict'
|
|
74
|
+
export function classifyFile({ exists, diskHash, manifestEntryHash, freshRenderHash, matchesPristineRender }) {
|
|
75
|
+
if (!exists) return 'create';
|
|
76
|
+
if (manifestEntryHash && diskHash === manifestEntryHash) {
|
|
77
|
+
// Provably untouched since bskel last wrote it -- safe to regenerate even if the fresh
|
|
78
|
+
// render differs (propagates a template upgrade or a live-derived value like
|
|
79
|
+
// requiredAuthority() picking up a controller's @PreAuthorize change; see D-security-7).
|
|
80
|
+
return freshRenderHash === diskHash ? 'unchanged' : 'update';
|
|
81
|
+
}
|
|
82
|
+
if (!manifestEntryHash) {
|
|
83
|
+
// No provenance record (first encounter after this feature shipped, a fresh checkout, or
|
|
84
|
+
// a repo that doesn't commit .sbf/) -- fall back to content comparison. This can only ever
|
|
85
|
+
// produce a false CONFLICT (fail-closed), never a false overwrite.
|
|
86
|
+
if (!matchesPristineRender) return 'conflict';
|
|
87
|
+
return freshRenderHash === diskHash ? 'adopt-unchanged' : 'adopt-update';
|
|
88
|
+
}
|
|
89
|
+
// A manifest entry exists but disk content has moved away from what it recorded -- a human
|
|
90
|
+
// (or something else) touched this file since bskel last wrote it.
|
|
91
|
+
return 'conflict';
|
|
92
|
+
}
|