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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/bin/bskel.mjs +2384 -0
  4. package/contracts/completeness.mjs +176 -0
  5. package/contracts/emit.mjs +287 -0
  6. package/contracts/export.mjs +325 -0
  7. package/contracts/openapi.mjs +869 -0
  8. package/contracts/validate.mjs +147 -0
  9. package/handles/_engine.mjs +281 -0
  10. package/handles/codec.mjs +119 -0
  11. package/handles/conformance.mjs +74 -0
  12. package/handles/providers/java-spring/ast-bridge.mjs +59 -0
  13. package/handles/providers/java-spring/ast-helper/build.gradle +34 -0
  14. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.jar +0 -0
  15. package/handles/providers/java-spring/ast-helper/gradle/wrapper/gradle-wrapper.properties +9 -0
  16. package/handles/providers/java-spring/ast-helper/gradlew +248 -0
  17. package/handles/providers/java-spring/ast-helper/gradlew.bat +82 -0
  18. package/handles/providers/java-spring/ast-helper/settings.gradle +1 -0
  19. package/handles/providers/java-spring/ast-helper/src/main/java/com/backendskeleton/asthelper/Main.java +178 -0
  20. package/handles/providers/java-spring/emit.mjs +232 -0
  21. package/handles/providers/java-spring/patch-strategy.mjs +229 -0
  22. package/handles/providers/java-spring/plan.mjs +377 -0
  23. package/handles/providers/java-spring/templates/HandleAspect.java.tmpl +125 -0
  24. package/handles/providers/java-spring/templates/HandleCodec.java.tmpl +150 -0
  25. package/handles/providers/java-spring/templates/HandleController.java.tmpl +177 -0
  26. package/handles/providers/java-spring/templates/HandleRegistry.java.tmpl +107 -0
  27. package/handles/providers/java-spring/templates/HandleRegistryRepository.java.tmpl +8 -0
  28. package/handles/providers/java-spring/templates/HandleService.java.tmpl +95 -0
  29. package/handles/providers/java-spring/templates/HandleSnapshot.java.tmpl +75 -0
  30. package/handles/providers/java-spring/templates/HandleSnapshotRepository.java.tmpl +20 -0
  31. package/handles/providers/java-spring/templates/RecordHandleSnapshot.java.tmpl +50 -0
  32. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +50 -0
  33. package/handles/providers/java-spring/templates/ResourceResolverStub.java.tmpl +77 -0
  34. package/handles/providers/java-spring/templates/migration.sql.tmpl +34 -0
  35. package/handles/providers/java-spring.mjs +21 -0
  36. package/handles/providers/python-fastapi/emit.mjs +171 -0
  37. package/handles/providers/python-fastapi/plan.mjs +186 -0
  38. package/handles/providers/python-fastapi/templates/__init__.py.tmpl +1 -0
  39. package/handles/providers/python-fastapi/templates/codec.py.tmpl +122 -0
  40. package/handles/providers/python-fastapi/templates/handle_service.py.tmpl +96 -0
  41. package/handles/providers/python-fastapi/templates/migration.sql.tmpl +35 -0
  42. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +155 -0
  43. package/handles/providers/python-fastapi/templates/registry.py.tmpl +37 -0
  44. package/handles/providers/python-fastapi/templates/resolver.py.tmpl +59 -0
  45. package/handles/providers/python-fastapi/templates/resolvers_init.py.tmpl +13 -0
  46. package/handles/providers/python-fastapi/templates/router.py.tmpl +140 -0
  47. package/handles/providers/python-fastapi/templates/tables.py.tmpl +66 -0
  48. package/handles/providers/python-fastapi.mjs +22 -0
  49. package/handles/providers/typescript-express/emit.mjs +128 -0
  50. package/handles/providers/typescript-express/plan.mjs +234 -0
  51. package/handles/providers/typescript-express/templates/codec.ts.tmpl +116 -0
  52. package/handles/providers/typescript-express/templates/registry.ts.tmpl +39 -0
  53. package/handles/providers/typescript-express/templates/resolver.ts.tmpl +55 -0
  54. package/handles/providers/typescript-express/templates/resolvers_index.ts.tmpl +11 -0
  55. package/handles/providers/typescript-express/templates/router.ts.tmpl +122 -0
  56. package/handles/providers/typescript-express.mjs +20 -0
  57. package/handles/registry.mjs +90 -0
  58. package/lib/cli.mjs +430 -0
  59. package/lib/doctor.mjs +200 -0
  60. package/lib/exit-codes.mjs +67 -0
  61. package/lib/featureid.mjs +55 -0
  62. package/lib/featurelifecycle.mjs +205 -0
  63. package/lib/fsutil.mjs +50 -0
  64. package/lib/gate-definitions.mjs +293 -0
  65. package/lib/gates.mjs +263 -0
  66. package/lib/handles-manifest.mjs +92 -0
  67. package/lib/lock.mjs +68 -0
  68. package/lib/patch-approvals.mjs +56 -0
  69. package/lib/paths.mjs +21 -0
  70. package/lib/repo.mjs +44 -0
  71. package/lib/schema-validate.mjs +56 -0
  72. package/lib/state.mjs +124 -0
  73. package/lib/template.mjs +35 -0
  74. package/lib/verify.mjs +206 -0
  75. package/lib/workflow.mjs +142 -0
  76. package/new/fastapi.mjs +165 -0
  77. package/new/index.mjs +62 -0
  78. package/new/params.mjs +233 -0
  79. package/new/spring.mjs +198 -0
  80. package/new/templates/fastapi/README.md +26 -0
  81. package/new/templates/fastapi/app/__init__.py +0 -0
  82. package/new/templates/fastapi/app/main.py +8 -0
  83. package/new/templates/fastapi/gitignore +6 -0
  84. package/new/templates/fastapi/pyproject.toml +14 -0
  85. package/package.json +50 -0
  86. package/scanners/adapters/_express-shared.mjs +238 -0
  87. package/scanners/adapters/_java-spring-analyzer.mjs +273 -0
  88. package/scanners/adapters/generic-grep.mjs +128 -0
  89. package/scanners/adapters/java-spring.mjs +301 -0
  90. package/scanners/adapters/javascript-express.mjs +422 -0
  91. package/scanners/adapters/python-fastapi.mjs +348 -0
  92. package/scanners/adapters/typescript-express.mjs +299 -0
  93. package/scanners/capabilities.mjs +90 -0
  94. package/scanners/conformance.mjs +59 -0
  95. package/scanners/db/introspect.mjs +109 -0
  96. package/scanners/db/migrations.mjs +126 -0
  97. package/scanners/index.mjs +281 -0
  98. package/scanners/registry.mjs +130 -0
  99. package/scanners/render.mjs +136 -0
  100. package/scanners/text-util.mjs +8 -0
  101. package/schemas/adapter.schema.json +23 -0
  102. package/schemas/agent-envelope.schema.json +21 -0
  103. package/schemas/contract-resolution.schema.json +28 -0
  104. package/schemas/feature-contract.schema.json +78 -0
  105. package/schemas/feature-index.schema.json +25 -0
  106. package/schemas/feature.schema.json +17 -0
  107. package/schemas/gate-event.schema.json +19 -0
  108. package/schemas/handles-plan.schema.json +31 -0
  109. package/schemas/handles-provider.schema.json +26 -0
  110. package/schemas/patch-approvals.schema.json +28 -0
  111. package/schemas/scan-report.schema.json +102 -0
  112. package/schemas/stack-choice.schema.json +89 -0
  113. package/schemas/stack-record.schema.json +20 -0
  114. package/schemas/state.schema.json +43 -0
  115. package/scripts/preflight-base-ref.sh +226 -0
  116. package/stack/apply.mjs +159 -0
  117. package/stack/bootstrap/_lib.sh +73 -0
  118. package/stack/bootstrap/ngrok.sh +90 -0
  119. package/stack/catalog/ngrok.yml +63 -0
package/lib/lock.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // S5 (D-persistence-integrity): closes the lost-update race in lib/state.mjs's setGate() (and
2
+ // contracts/completeness.mjs's saveResolution()) -- both do load -> modify -> save with no
3
+ // synchronization, confirmed live during this item's own grounding (two processes racing a
4
+ // load-modify-save cycle silently drop one write). mkdir-based advisory lock: `fs.mkdirSync` is
5
+ // atomic on both POSIX and Windows (fails with EEXIST if the dir already exists), needs no new
6
+ // dependency, and needs no cleanup daemon -- a crashed holder just leaves a directory a human can
7
+ // `rm -rf`, which the timeout error message below points at directly.
8
+ //
9
+ // Deliberately SYNCHRONOUS, not Promise-based: setGate() (and every passGate/awaitDispositionGate/
10
+ // forceGate/passNamedGate caller above it, all the way up through bin/bskel.mjs's cmdXxx functions
11
+ // and main()) is synchronous today. Making the lock async would force `async`/`await` through that
12
+ // entire call chain for a correctness property that doesn't need it -- this is a short-lived CLI
13
+ // process, not a server, so blocking the (single) event loop for up to a few seconds while polling
14
+ // for a lock is not a real cost. `Atomics.wait` gives a genuine synchronous blocking sleep on
15
+ // Node's main thread (confirmed directly: not restricted to worker threads).
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ const RETRY_INTERVAL_MS = 20;
20
+ const DEFAULT_TIMEOUT_MS = 5000;
21
+
22
+ const _sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
23
+ function sleepSync(ms) {
24
+ Atomics.wait(_sleepBuffer, 0, 0, ms);
25
+ }
26
+
27
+ function locksDir(repoRoot) {
28
+ return path.join(repoRoot, '.sbf', '.locks');
29
+ }
30
+
31
+ function tryAcquire(lockPath) {
32
+ try {
33
+ fs.mkdirSync(lockPath, { recursive: false });
34
+ return true;
35
+ } catch (err) {
36
+ if (err.code === 'EEXIST') return false;
37
+ throw err;
38
+ }
39
+ }
40
+
41
+ // Runs `fn` with an exclusive lock named `lockName`, scoped to this repo. Retries acquisition
42
+ // with a short fixed backoff until `timeoutMs` elapses, then throws with a message naming the
43
+ // exact stale-lock path to remove -- this tool is a short-lived CLI, not a daemon, so "another
44
+ // bskel process is stuck or crashed" is the only realistic cause, and the fix is always the same
45
+ // (confirm nothing else is running, then delete the lock directory).
46
+ export function withLockSync(repoRoot, lockName, fn, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
47
+ const dir = locksDir(repoRoot);
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const lockPath = path.join(dir, `${lockName}.lock`);
50
+
51
+ const deadline = Date.now() + timeoutMs;
52
+ while (!tryAcquire(lockPath)) {
53
+ if (Date.now() >= deadline) {
54
+ throw new Error(
55
+ `could not acquire lock "${lockName}" within ${timeoutMs}ms (${lockPath} already exists) -- ` +
56
+ 'another bskel process may be running against this repo, or a previous run crashed and left ' +
57
+ `this lock behind. If nothing else is running, remove ${lockPath} and try again.`,
58
+ );
59
+ }
60
+ sleepSync(RETRY_INTERVAL_MS);
61
+ }
62
+
63
+ try {
64
+ return fn();
65
+ } finally {
66
+ fs.rmSync(lockPath, { recursive: true, force: true });
67
+ }
68
+ }
@@ -0,0 +1,56 @@
1
+ // A3 (D-patch-strategy): the explicit human gate before ANY patchField() switch-case gets
2
+ // generated. Mirrors contracts/completeness.mjs's loadResolution()/saveResolution() shape
3
+ // exactly (same "documentation-file, validated at both read and write, no built-in locking of its
4
+ // own" contract) -- feature-scoped like contract-resolution.schema.json, not repo-scoped like
5
+ // .sbf/handles-manifest.json, because approving a field's patch strategy is a human DECISION made
6
+ // in the context of one feature's actual need, not a file-safety fact about the repo. Per-field,
7
+ // never a wildcard -- matches `bskel contract waive`'s own "no --all covering future warnings"
8
+ // precedent (A5): approving Organization.name today must never silently also approve a field
9
+ // added to that DTO next month.
10
+ import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
11
+ import { specPath } from './paths.mjs';
12
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
13
+
14
+ const APPROVALS_SCHEMA = 'sbf.patch-approvals/1';
15
+
16
+ export function patchApprovalsPath(root, featureId) {
17
+ return specPath(root, featureId, 'handles', 'patch-approvals.json');
18
+ }
19
+
20
+ export function loadPatchApprovals(root, featureId) {
21
+ const path = patchApprovalsPath(root, featureId);
22
+ const parsed = readJsonIfExists(path);
23
+ if (parsed === null) {
24
+ return { schema: APPROVALS_SCHEMA, feature_id: featureId, approvals: [] };
25
+ }
26
+ const { ok, errors } = validateAgainstSchema('patch-approvals.schema.json', parsed);
27
+ if (!ok) {
28
+ throw new Error(`${path}: does not match schemas/patch-approvals.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
29
+ }
30
+ return parsed;
31
+ }
32
+
33
+ export function savePatchApprovals(root, featureId, approvals) {
34
+ const { ok, errors } = validateAgainstSchema('patch-approvals.schema.json', approvals);
35
+ if (!ok) {
36
+ throw new Error(`refusing to write invalid patch approvals for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
37
+ }
38
+ writeFileAtomic(patchApprovalsPath(root, featureId), `${JSON.stringify(approvals, null, 2)}\n`);
39
+ return approvals;
40
+ }
41
+
42
+ export function approvalKey(resource, field) {
43
+ return `${resource}::${field}`;
44
+ }
45
+
46
+ // The single lookup emit.mjs's codegen needs: is {resource, field} approved, and if so for
47
+ // exactly which strategy? A stale approval (the DTO changed since approval, the classifier now
48
+ // computes a different bucket) must never silently generate against a strategy that no longer
49
+ // matches reality -- callers compare the returned strategy against the CURRENT classifier output
50
+ // themselves and fall back to the stub on any mismatch (fail-closed, same principle as
51
+ // D-resolver-scope's willGenerateResolver check).
52
+ export function approvedStrategyFor(approvals, resource, field) {
53
+ const key = approvalKey(resource, field);
54
+ const match = (approvals.approvals ?? []).find((a) => approvalKey(a.resource, a.field) === key);
55
+ return match ? match.strategy : null;
56
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,21 @@
1
+ // Shared path-building helpers for anything under specs/<feature_id>/ or .sbf/ -- pulled out of
2
+ // bin/bskel.mjs so lib/gate-definitions.mjs and lib/verify.mjs don't each grow their own copy
3
+ // (that kind of duplication is exactly what let lib/verify.mjs's GATE_SPECS drift out of sync
4
+ // with bin/bskel.mjs's GATE_RECOMPUTERS -- see lib/gate-definitions.mjs).
5
+ //
6
+ // This module only joins paths. It does NOT validate `featureId` -- that's the CLI boundary's
7
+ // job (lib/featureid.mjs's requireValidFeatureId/requireValidFeatureOrRepoId), and duplicating
8
+ // that check in here would just create a second place for it to go stale.
9
+ import path from 'node:path';
10
+
11
+ export function specDir(root, featureId) {
12
+ return path.join(root, 'specs', featureId);
13
+ }
14
+
15
+ export function specPath(root, featureId, ...segments) {
16
+ return path.join(specDir(root, featureId), ...segments);
17
+ }
18
+
19
+ export function sbfPath(root, ...segments) {
20
+ return path.join(root, '.sbf', ...segments);
21
+ }
package/lib/repo.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ function git(args, cwd) {
4
+ return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
5
+ }
6
+
7
+ export function repoRoot(cwd = process.cwd()) {
8
+ try {
9
+ return git(['rev-parse', '--show-toplevel'], cwd);
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ export function headSha(cwd = process.cwd()) {
16
+ return git(['rev-parse', 'HEAD'], cwd);
17
+ }
18
+
19
+ // Cheap, local-only re-check of the default branch (no network) -- used to build the
20
+ // `preflight` gate's re-verifiable token inputs, NOT as a replacement for the full 3-way
21
+ // cross-check scripts/preflight-base-ref.sh does at actual preflight time.
22
+ export function localDefaultBranch(cwd = process.cwd()) {
23
+ try {
24
+ return git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd).replace(/^origin\//, '');
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // D-preflight-freshness (S3): the SHA the LOCAL `origin/<branch>` remote-tracking ref currently
31
+ // points at -- purely local (`git rev-parse`, no network), same "cheap, local-only re-check"
32
+ // class as `localDefaultBranch()` above. Lets `require` notice when something else (an IDE's
33
+ // auto-fetch, a manual `git fetch`) has already pulled a newer remote tip into this local repo,
34
+ // without `require` itself ever fetching -- see D-preflight-freshness in DECISIONS.md for why
35
+ // this deliberately does NOT mean "the remote tip is guaranteed current": if nothing has fetched
36
+ // since the ref was last updated, this returns the same stale value it always did.
37
+ export function remoteTrackingTip(cwd = process.cwd(), branch) {
38
+ if (!branch) return null;
39
+ try {
40
+ return git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], cwd);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
@@ -0,0 +1,56 @@
1
+ // S5 (D-persistence-integrity): validates this tool's own persisted JSON documents against their
2
+ // declared schemas at read/write boundaries. Separate singleton from contracts/validate.mjs's own
3
+ // ajv() -- that one validates runtime AGENT ENVELOPES against a per-feature contract (a different
4
+ // concern), and lib/ importing from contracts/ would be a backwards dependency direction (contracts/
5
+ // already imports from lib/, see bin/bskel.mjs's import graph).
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import Ajv2020 from 'ajv/dist/2020.js';
10
+ import addFormats from 'ajv-formats';
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const SCHEMAS_ROOT = path.join(__dirname, '..', 'schemas');
14
+
15
+ let _ajv = null;
16
+ function ajv() {
17
+ if (!_ajv) {
18
+ _ajv = new Ajv2020({ allErrors: true, strict: false });
19
+ try {
20
+ addFormats(_ajv);
21
+ } catch {
22
+ // ajv-formats not installed -- format keywords (uuid, date-time) become no-ops rather
23
+ // than a hard failure; structural validation below still catches everything else.
24
+ }
25
+ }
26
+ return _ajv;
27
+ }
28
+
29
+ const _schemaCache = new Map();
30
+ function loadSchema(schemaFileName) {
31
+ let schema = _schemaCache.get(schemaFileName);
32
+ if (!schema) {
33
+ schema = JSON.parse(fs.readFileSync(path.join(SCHEMAS_ROOT, schemaFileName), 'utf8'));
34
+ _schemaCache.set(schemaFileName, schema);
35
+ }
36
+ return schema;
37
+ }
38
+
39
+ // Validates `data` against `schemas/<schemaFileName>`. Returns {ok, errors} (mirrors
40
+ // contracts/validate.mjs's validateEnvelopeStructure() return shape) rather than throwing --
41
+ // callers decide how to surface a failure (a plain Error for lib-style read functions, a
42
+ // fail()/EXIT_CODES call for CLI-layer ones), matching this codebase's existing split between
43
+ // "throws, main()'s catch-all translates it" and "already-CLI-code calls fail() directly".
44
+ export function validateAgainstSchema(schemaFileName, data) {
45
+ const schema = loadSchema(schemaFileName);
46
+ const validateFn = ajv().getSchema(schema.$id) ?? ajv().compile(schema);
47
+ const ok = validateFn(data);
48
+ return { ok, errors: ok ? [] : (validateFn.errors ?? []) };
49
+ }
50
+
51
+ // Renders ajv errors into the same "path message" shape contracts/validate.mjs's callers already
52
+ // build inline (envelope validation errors) -- centralized here so every call site formats
53
+ // consistently instead of re-deriving `${e.instancePath} ${e.message}` on its own.
54
+ export function formatSchemaErrors(errors) {
55
+ return errors.map((e) => `${e.instancePath || '(root)'} ${e.message}`);
56
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,124 @@
1
+ // D1: gate state lives on disk as content-hash tokens, not as prose the agent is asked to honor.
2
+ // WHY: the spec-kit trial proved agent-honored steps are luck; spec-kit's own `optional:false`
3
+ // hooks are just text injected into the agent's context, not a process gate.
4
+ // COST: extra files per feature; stale-token friction when the spec changes mid-session.
5
+ // EXIT: `bskel gate force <name> --reason "..."` records the bypass so it's auditable, not silent.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
9
+ import { withLockSync } from './lock.mjs';
10
+
11
+ const STATE_SCHEMA = 'sbf.state/1';
12
+
13
+ export function sbfDir(repoRoot) {
14
+ return path.join(repoRoot, '.sbf');
15
+ }
16
+
17
+ export function statePath(repoRoot, featureId) {
18
+ return path.join(sbfDir(repoRoot), `${featureId}.json`);
19
+ }
20
+
21
+ // S4 (D-gate-history): sibling to statePath's own .sbf/<featureId>.json -- the append-only event
22
+ // log for the same scope, same naming convention (suffix swapped, not a different directory).
23
+ export function historyPath(repoRoot, featureId) {
24
+ return path.join(sbfDir(repoRoot), `${featureId}.history.jsonl`);
25
+ }
26
+
27
+ export function loadState(repoRoot, featureId) {
28
+ const file = statePath(repoRoot, featureId);
29
+ if (!fs.existsSync(file)) {
30
+ return { schema: STATE_SCHEMA, feature_id: featureId, gates: {} };
31
+ }
32
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
33
+ if (parsed.schema !== STATE_SCHEMA) {
34
+ throw new Error(`${file}: unrecognized state schema "${parsed.schema}" (expected ${STATE_SCHEMA})`);
35
+ }
36
+ // S5: the schema-const check above already covered "is this even a state file"; this covers
37
+ // everything else the schema declares (gate record shape, token format, etc.) -- catches a
38
+ // hand-edited or externally-corrupted .sbf/<feature>.json that still happens to carry the
39
+ // right `schema` value. A plain Error, same as the check above -- main()'s catch-all in
40
+ // bin/bskel.mjs already treats "a malformed-state read" as its own documented case (exit 14).
41
+ const { ok, errors } = validateAgainstSchema('state.schema.json', parsed);
42
+ if (!ok) {
43
+ throw new Error(`${file}: does not match schemas/state.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
44
+ }
45
+ return parsed;
46
+ }
47
+
48
+ // Atomic write (temp + rename) so a mid-write crash never leaves a half-written state.json
49
+ // that a later `gate require` would parse as valid — same technique as archify's validator writer.
50
+ export function saveState(repoRoot, featureId, state) {
51
+ // S5: validated before it ever touches disk -- a bskel bug producing an invalid state object
52
+ // should fail loudly right here, not get persisted and surface later as a confusing read-side
53
+ // error somewhere else entirely.
54
+ const { ok, errors } = validateAgainstSchema('state.schema.json', state);
55
+ if (!ok) {
56
+ throw new Error(`refusing to write an invalid state record for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
57
+ }
58
+ const dir = sbfDir(repoRoot);
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ const file = statePath(repoRoot, featureId);
61
+ const tmp = `${file}.${process.pid}.tmp`;
62
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
63
+ fs.renameSync(tmp, file);
64
+ return file;
65
+ }
66
+
67
+ // S4 (D-gate-history): derives which of the 4 real write-time events `gateRecord` represents --
68
+ // no separate event-type parameter needed, the record's own shape already says which one it is.
69
+ // Deliberately no "stale" event: staleness is derived at READ time (requireGate(), never
70
+ // written to disk -- see state.schema.json's own status-enum comment), so there is nothing to
71
+ // log an event for; logging on every `require`/`verify` read would turn this into read-path
72
+ // noise, not a history of state CHANGES.
73
+ function gateEventType(gateRecord) {
74
+ if (gateRecord.status === 'awaiting_disposition') return 'awaiting_disposition';
75
+ if (gateRecord.status === 'revoked') return 'revoke';
76
+ if (gateRecord.forced) return 'force';
77
+ return 'pass';
78
+ }
79
+
80
+ // S4: validated the same way every other persistence boundary is (S5's lib/schema-validate.mjs)
81
+ // before it touches disk. Append-only -- fs.appendFileSync, never rewritten -- so a single line's
82
+ // corruption (a partial write from a crash mid-append) can't take down the rest of the log; the
83
+ // reader (bin/bskel.mjs's cmdGateHistory) is expected to skip an unparseable/invalid line with a
84
+ // warning rather than fail the whole read, matching JSONL's own resilience rationale.
85
+ function appendGateEvent(repoRoot, featureId, gateName, gateRecord) {
86
+ const line = {
87
+ schema: 'sbf.gate-event/1',
88
+ event: gateEventType(gateRecord),
89
+ gate: gateName,
90
+ at: gateRecord.at,
91
+ status: gateRecord.status,
92
+ token: gateRecord.token ?? null,
93
+ forced: gateRecord.forced ?? false,
94
+ reason: gateRecord.reason ?? null,
95
+ };
96
+ const { ok, errors } = validateAgainstSchema('gate-event.schema.json', line);
97
+ if (!ok) {
98
+ throw new Error(`refusing to append an invalid gate-history event for "${featureId}"/"${gateName}":\n${formatSchemaErrors(errors).join('\n')}`);
99
+ }
100
+ fs.mkdirSync(sbfDir(repoRoot), { recursive: true });
101
+ fs.appendFileSync(historyPath(repoRoot, featureId), `${JSON.stringify(line)}\n`);
102
+ }
103
+
104
+ // S5: load -> modify -> save under an exclusive per-repo lock -- passGate/awaitDispositionGate/
105
+ // forceGate/revokeGate (lib/gates.mjs) all funnel through this one function, so this single
106
+ // change closes the lost-update race for every gate write in the codebase. Confirmed live
107
+ // before this fix existed: two processes racing this exact load-modify-save sequence silently
108
+ // dropped one process's gate write. See lib/lock.mjs for why the lock itself is synchronous.
109
+ // S4: also appends the same write to the per-feature history log, inside the same lock, so the
110
+ // snapshot and the log can never observe each other's writes out of order.
111
+ export function setGate(repoRoot, featureId, gateName, gateRecord) {
112
+ return withLockSync(repoRoot, 'state', () => {
113
+ const state = loadState(repoRoot, featureId);
114
+ state.gates[gateName] = gateRecord;
115
+ saveState(repoRoot, featureId, state);
116
+ appendGateEvent(repoRoot, featureId, gateName, gateRecord);
117
+ return state;
118
+ });
119
+ }
120
+
121
+ export function getGate(repoRoot, featureId, gateName) {
122
+ const state = loadState(repoRoot, featureId);
123
+ return state.gates[gateName] ?? null;
124
+ }
@@ -0,0 +1,35 @@
1
+ // P2b (D-greenfield-parameters): the `{{VAR}}` substitution `stack/apply.mjs` has performed since
2
+ // D7 and `new/fastapi.mjs` performed as a one-variable special case (`text.replaceAll('{{SLUG}}',
3
+ // slug)`), extracted verbatim so both consume ONE implementation. Pure code motion -- the loop body
4
+ // below is character-for-character what `stack/apply.mjs::renderTemplate()` ran before, which is why
5
+ // `test/stack-cli.test.mjs` passes completely unmodified across this extraction (the same bar
6
+ // `D-handles-providers`' own extraction met). Same precedent as `scanners/text-util.mjs` and
7
+ // `scanners/adapters/_express-shared.mjs`: a helper two real call sites already duplicate gets its
8
+ // own module, rather than a third private copy.
9
+ import fs from 'node:fs';
10
+
11
+ // P4 (D-extension-conformance) originally defined this inside `bin/bskel.mjs` for `catalog lint`.
12
+ // It moved here when P2b gave it a second consumer: `new/fastapi.mjs` runs it over every rendered
13
+ // file and fails the scaffold CLOSED, which is coverage `new/templates/**` never had (the P4 lint
14
+ // only ever looked at `stack/catalog/`). Deliberately the SAME regex, not a similar one -- a
15
+ // template variable this project's own renderer would never substitute looks identical whichever
16
+ // template tree it is sitting in.
17
+ export const RESIDUAL_TEMPLATE_VAR_RE = /\{\{[A-Z_][A-Z0-9_]*\}\}/g;
18
+
19
+ export function renderTemplateText(text, vars) {
20
+ let content = text;
21
+ for (const [key, value] of Object.entries(vars)) {
22
+ content = content.replaceAll(`{{${key}}}`, String(value));
23
+ }
24
+ return content;
25
+ }
26
+
27
+ export function renderTemplateFile(templatePath, vars) {
28
+ return renderTemplateText(fs.readFileSync(templatePath, 'utf8'), vars);
29
+ }
30
+
31
+ // De-duplicated, in first-appearance order -- callers report these to a human, and the same
32
+ // unfilled token appearing four times in one file is one problem, not four.
33
+ export function findResidualTemplateVars(text) {
34
+ return [...new Set(text.match(RESIDUAL_TEMPLATE_VAR_RE) ?? [])];
35
+ }
package/lib/verify.mjs ADDED
@@ -0,0 +1,206 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { GATE_NAMES, GATE_DEFINITIONS, VERIFY_POLICY, gateScopeId } from './gate-definitions.mjs';
5
+ import { EXIT } from './gates.mjs';
6
+ import { specPath } from './paths.mjs';
7
+ import { loadManifest, manifestPath } from './handles-manifest.mjs';
8
+ import { resolveWithinRoot } from './fsutil.mjs';
9
+ import { PROVIDERS, providerById } from '../handles/registry.mjs';
10
+ import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
11
+
12
+ // S6: iterates the single shared gate definition list (lib/gate-definitions.mjs) instead of a
13
+ // local GATE_SPECS -- before this fix, GATE_SPECS was a second, hand-maintained gate list that
14
+ // drifted from bin/bskel.mjs's GATE_RECOMPUTERS: `stack` was registered as a real, writable gate
15
+ // but simply absent from GATE_SPECS, so `stack apply` could pass a repo-scoped `stack` gate that
16
+ // `bskel verify` would never even look at. There is only one list left to consult now.
17
+ export function collectGateStatuses(root, featureId, { getGate, requireNamedGate }) {
18
+ return GATE_NAMES.map((name) => {
19
+ const def = GATE_DEFINITIONS[name];
20
+ const scopeId = gateScopeId(name, featureId);
21
+ const record = getGate(root, scopeId, name);
22
+ const result = requireNamedGate(root, name, featureId);
23
+ return {
24
+ gate: name,
25
+ scope: def.scope,
26
+ policy: def.verifyPolicy,
27
+ required: def.verifyPolicy === VERIFY_POLICY.REQUIRED, // kept for existing JSON consumers
28
+ blocking: isBlockingGateResult(def, result),
29
+ ran: record !== null,
30
+ ...result,
31
+ };
32
+ });
33
+ }
34
+
35
+ // Whether one gate's current result should block the overall verify verdict. Policy
36
+ // interpretation lives in exactly this one place.
37
+ export function isBlockingGateResult(def, result) {
38
+ if (result.code === EXIT.PASS) return false;
39
+ if (def.verifyPolicy === VERIFY_POLICY.REQUIRED) return true;
40
+ // required-when-present: a gate that has never run (not_run) does not block -- but once it
41
+ // HAS run, every non-pass status (stale, awaiting_disposition, ...) still blocks. "Optional"
42
+ // means "not every feature needs this", not "once run, correctness stops mattering".
43
+ return result.status !== 'not_run';
44
+ }
45
+
46
+ // D5: exported so lib/doctor.mjs can reuse the exact same detection logic (does this repo have a
47
+ // recognized build wrapper at all) instead of re-implementing it -- `bskel doctor --workflow
48
+ // handles` and `bskel verify --build` must never disagree about what counts as "a build tool was
49
+ // found" for the same repo.
50
+ export function detectBuildCommand(repoRoot) {
51
+ if (fs.existsSync(path.join(repoRoot, 'gradlew'))) {
52
+ return { tool: 'gradle', cmd: './gradlew', args: ['compileJava', '--console=plain'] };
53
+ }
54
+ if (fs.existsSync(path.join(repoRoot, 'pom.xml'))) {
55
+ return { tool: 'maven', cmd: './mvnw', args: ['compile', '-q'] };
56
+ }
57
+ if (fs.existsSync(path.join(repoRoot, 'package.json'))) {
58
+ return { tool: 'npm', cmd: 'npm', args: ['run', 'build', '--if-present'] };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ export function runBuildCheck(repoRoot) {
64
+ const build = detectBuildCommand(repoRoot);
65
+ if (!build) return { ran: false, ok: null, tool: null, message: 'no recognized build tool (gradlew/pom.xml/package.json) found' };
66
+ try {
67
+ execFileSync(build.cmd, build.args, { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
68
+ return { ran: true, ok: true, tool: build.tool };
69
+ } catch (err) {
70
+ // S6 (D-verify-integrity): a failing build's most useful diagnostic text sometimes lands
71
+ // entirely on stderr (confirmed live -- npm's own generic "> pkg build\n> cmd" banner goes
72
+ // to stdout, the actual fatal error to stderr). Capturing stdout alone silently dropped it.
73
+ // Each stream gets its OWN last-30-lines window, not one combined window -- a long stdout
74
+ // must not crowd out a short stderr message.
75
+ const stdout = (err.stdout || '').toString().trim();
76
+ const stderr = (err.stderr || '').toString().trim();
77
+ const parts = [];
78
+ if (stdout) parts.push(`--- stdout (last 30 lines) ---\n${stdout.split('\n').slice(-30).join('\n')}`);
79
+ if (stderr) parts.push(`--- stderr (last 30 lines) ---\n${stderr.split('\n').slice(-30).join('\n')}`);
80
+ return { ran: true, ok: false, tool: build.tool, message: parts.join('\n\n') || (err.message || '').toString() };
81
+ }
82
+ }
83
+
84
+ // G4: which spec-scoped output files a `handles` gate is expected to have produced -- provider-
85
+ // aware, via the same scan report -> adapter -> provider chain bin/bskel.mjs's handles commands
86
+ // use. Falls back to the pre-G4 single migration.sql expectation whenever the scan report is
87
+ // missing/unreadable, or names a provider that isn't (or is no longer) loaded -- this is exactly
88
+ // the java-spring behavior every existing test/real repo already depends on, unchanged.
89
+ const DEFAULT_HANDLES_OUTPUTS = ['handles/migration.sql'];
90
+
91
+ function handlesOutputsFor(root, featureId) {
92
+ const scanReportPath = specPath(root, featureId, 'brownfield-scan.json');
93
+ if (!fs.existsSync(scanReportPath)) return DEFAULT_HANDLES_OUTPUTS;
94
+ let scanReport;
95
+ try {
96
+ scanReport = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
97
+ } catch {
98
+ return DEFAULT_HANDLES_OUTPUTS;
99
+ }
100
+ const provider = providerById(PROVIDERS, scanReport.adapter);
101
+ return provider ? provider.outputs.spec : DEFAULT_HANDLES_OUTPUTS;
102
+ }
103
+
104
+ // S6: `handles/migration.sql` used to only become a check item when the file already existed,
105
+ // so a `handles` gate that had passed and then had its migration.sql deleted or moved could
106
+ // never fail this check -- the `exists:false` item was never created in the first place. The
107
+ // `handles` gate's own token (lib/gate-definitions.mjs) covers head_sha + the contract's hash,
108
+ // NOT migration.sql's content, so this artifact check is the ONLY thing that notices that file
109
+ // going missing. `gates` (the result of collectGateStatuses) tells us whether the handles gate
110
+ // has ever run at all, independent of its current pass/stale status -- a stale or forced handles
111
+ // gate still implies every one of its expected outputs should exist. A provider with zero
112
+ // spec-scoped outputs simply produces no artifact items here at all, which is correct: there is
113
+ // nothing to check (no provider currently declares an empty outputs.spec -- java-spring and
114
+ // python-fastapi both emit migration.sql, G4's D-handles-providers follow-up -- but the code
115
+ // path stays general for whatever provider comes next).
116
+ export function checkArtifacts(root, featureId, gates = []) {
117
+ const checks = [];
118
+ const contractPath = specPath(root, featureId, 'contracts', `${featureId}.schema.json`);
119
+ checks.push({ artifact: 'contract', path: path.relative(root, contractPath), exists: fs.existsSync(contractPath) });
120
+
121
+ const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
122
+ for (const relOutput of handlesOutputsFor(root, featureId)) {
123
+ const outputPath = specPath(root, featureId, ...relOutput.split('/'));
124
+ const outputExists = fs.existsSync(outputPath);
125
+ if (handlesRan || outputExists) {
126
+ const label = path.basename(relOutput, path.extname(relOutput));
127
+ checks.push({ artifact: `handles ${label}`, path: path.relative(root, outputPath), exists: outputExists });
128
+ }
129
+ }
130
+ checks.push(...handlesManifestChecks(root, featureId, handlesRan));
131
+ return checks;
132
+ }
133
+
134
+ // S2 (c): the `handles` gate's token deliberately does NOT hash the CONTENT of the Java it
135
+ // generated -- ResourceResolverStub.java.tmpl's patchField() is MEANT to be hand-finished
136
+ // (D-resolver-scope), so hashing generated content into the token would report every intentional
137
+ // human edit as `stale`, exactly backwards. What is never legitimate is the file being GONE:
138
+ // nothing regenerates it implicitly, and the feature doesn't compile without it. Same mechanism
139
+ // and reasoning as the migration.sql check above (S6) -- existence only, at verify time, entirely
140
+ // outside the gate token.
141
+ function handlesManifestChecks(root, featureId, handlesRan) {
142
+ let manifest;
143
+ try {
144
+ manifest = loadManifest(root);
145
+ } catch {
146
+ // A manifest that exists but can't be parsed/recognized is itself a finding -- report it
147
+ // instead of letting `bskel verify` die with a stack trace mid-report.
148
+ return [{ artifact: 'handles manifest (unreadable)', path: path.relative(root, manifestPath(root)), exists: false }];
149
+ }
150
+ const entries = Object.entries(manifest.files ?? {});
151
+ const owned = entries.filter(([, e]) => e.owner === featureId);
152
+ // A feature that never ran `handles emit` must produce NO items -- otherwise another feature's
153
+ // resolvers (and the repo-owned infra, which every feature's entry below would also match)
154
+ // would show up in THIS feature's verify report as if they were its own.
155
+ if (!handlesRan && owned.length === 0) return [];
156
+ // Repo-owned infra (global/handle/*) is included whenever handles ran for this feature at all,
157
+ // because this feature's resolvers do not compile without it -- a deleted HandleCodec.java
158
+ // breaks every feature that ever emitted handles, not just the one that happened to write it.
159
+ const relevant = new Map([...owned, ...entries.filter(([, e]) => e.ownership === 'repo')]);
160
+ return [...relevant.entries()]
161
+ .sort(([a], [b]) => a.localeCompare(b))
162
+ .map(([relPath, e]) => {
163
+ const abs = resolveWithinRoot(root, relPath);
164
+ return {
165
+ artifact: e.kind === 'infra' ? 'handles infra' : 'handles resolver',
166
+ path: relPath,
167
+ exists: abs !== null && fs.existsSync(abs),
168
+ };
169
+ });
170
+ }
171
+
172
+ // S6 (D-verify-integrity): O2's real, content-derived conflict detection (classifyFile(), see
173
+ // lib/handles-manifest.mjs) already runs on every `handles emit`/`handles plan --diff` -- but
174
+ // `bskel verify` never invoked it, so a resolver that has genuinely diverged into a `conflict`
175
+ // state (not the sanctioned "hand-finished patchField()" case classifyFile() already
176
+ // distinguishes -- see the `handles` gate's own token comment above) passed `verify` silently.
177
+ // This reuses the EXACT dry-run call `handles plan`'s own D4 preview makes
178
+ // (provider.plan() -> provider.emit({dryRun:true})), never re-implementing classifyFile()'s
179
+ // semantics here. Every precondition mirrors handlesManifestChecks()'s own graceful-skip
180
+ // philosophy -- verify must never crash or false-block just because handle codegen doesn't apply
181
+ // to this feature; that gating is `handles plan`/`handles emit`'s own job to enforce loudly, not
182
+ // verify's job to duplicate.
183
+ export function checkResolverConflicts(root, featureId, handlesRan) {
184
+ if (!handlesRan) return [];
185
+ const scanReportPath = specPath(root, featureId, 'brownfield-scan.json');
186
+ try {
187
+ const scanReport = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
188
+ const adapter = adapterById(ADAPTERS, scanReport.adapter);
189
+ if (!adapter || !adapter.capabilities['codegen.handles']) return [];
190
+ const provider = providerById(PROVIDERS, scanReport.adapter);
191
+ if (!provider) return [];
192
+ for (const capability of provider.requiresCapabilities ?? []) {
193
+ if (!adapter.capabilities[capability]) return [];
194
+ }
195
+ const plan = provider.plan({ repoRoot: root, scanReport, module: null, resourceFilter: null });
196
+ const { conflicts } = provider.emit({
197
+ repoRoot: root, featureId, plan, resourceFilter: null, force: false, reason: '', dryRun: true, computeDiff: false,
198
+ });
199
+ return conflicts ?? [];
200
+ } catch {
201
+ // A provider-internal error here is that command's own concern (handles plan/emit already
202
+ // report it loudly) -- not something verify should crash on, and not something it should
203
+ // silently promote to a false "no conflicts" claim beyond returning no findings this run.
204
+ return [];
205
+ }
206
+ }