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
@@ -0,0 +1,147 @@
1
+ // D-ajv-runtime (see DECISIONS.md): unlike archify, ajv here is a real runtime dependency of
2
+ // `bskel` itself, not a devDependency used only to pre-compile a fixed set of schemas at build
3
+ // time. Archify's 5 diagram schemas are fixed at package-build time, so standalone-compiling
4
+ // them once makes sense; a per-feature contract's operation schemas don't exist until `bskel
5
+ // contract emit` runs for THAT feature, so there is nothing to standalone-compile in advance.
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
+ // P1 (D-npm-packaging): `import.meta.dirname` (Node >=20.11) was this codebase's only call site
13
+ // requiring a Node floor above what package.json declares (>=18) -- every other file already
14
+ // uses this portable pattern. Fixing the one outlier, not raising the floor, since nothing else
15
+ // in the runtime code needs anything newer than plain ES2022/Node 18.
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+
18
+ let _ajv = null;
19
+ function ajv() {
20
+ if (!_ajv) {
21
+ _ajv = new Ajv2020({ allErrors: true, strict: false });
22
+ try {
23
+ addFormats(_ajv);
24
+ } catch {
25
+ // ajv-formats not installed -- format keywords (uuid, date-time) become no-ops rather
26
+ // than a hard failure; validate() below still catches everything else.
27
+ }
28
+ }
29
+ return _ajv;
30
+ }
31
+
32
+ function loadEnvelopeSchema() {
33
+ const schemaPath = path.join(__dirname, '..', 'schemas', 'agent-envelope.schema.json');
34
+ return JSON.parse(fs.readFileSync(schemaPath, 'utf8'));
35
+ }
36
+
37
+ export function validateEnvelopeStructure(envelope) {
38
+ const schema = loadEnvelopeSchema();
39
+ const validateFn = ajv().getSchema(schema.$id) ?? ajv().compile(schema);
40
+ const ok = validateFn(envelope);
41
+ return { ok, errors: ok ? [] : (validateFn.errors ?? []) };
42
+ }
43
+
44
+ // A2: `requestBodySchema` (projected from a real OpenAPI document, contracts/openapi.mjs's
45
+ // inlineSchema()) replaces the bare {type:'object'} placeholder when present -- every branch is
46
+ // byte-identical to pre-A2 output when it's absent (the common case: openapi=null, or an
47
+ // operation that isn't matched/adopted). Requiredness of the `body` KEY in the envelope is
48
+ // decided by the SAME `body===true` condition as before, not by the document's
49
+ // `requestBody.required` -- the scan remains the oracle for whether an operation takes a body at
50
+ // all (A1's provenance split); `requestBodySchema` only ever tightens what's INSIDE that body.
51
+ // `additionalProperties:false` is deliberately never added to `bodySchema` itself -- see
52
+ // D-openapi-request-schema: Team-IZ-Backend has no Jackson customization, so the real endpoints
53
+ // accept and ignore unknown body fields (Spring Boot's default), and a contract that rejects what
54
+ // the real API accepts is a false negative, not a safety improvement.
55
+ //
56
+ // A3: `direction` selects which of the operation's three projected schemas this payload is
57
+ // checked against. `request` behavior is completely unchanged (byte-identical, that branch always
58
+ // returns a schema). `response`/`error` return `null` -- unconstrained, exactly as before A3 --
59
+ // when the operation has no projected responseSchema/errorSchema; when it does, the payload must
60
+ // be `{body: <the actual response/error body>}`, NOT the bare body. The wrapper (rather than
61
+ // payload being the response body directly) keeps a future status-code field additive
62
+ // (`payload.status`) instead of requiring a breaking `sbf` bump, and keeps this function's return
63
+ // shape uniform across all three directions ("a named-parts object, additionalProperties:false")
64
+ // -- see D-openapi-response-schema. An unrecognized `direction` also returns null (unconstrained),
65
+ // matching the envelope schema's own enum being the actual gate on valid direction values.
66
+ export function operationPayloadSchema(opContract, direction = 'request') {
67
+ if (direction === 'request') {
68
+ const properties = { pathParams: opContract.pathParams };
69
+ const required = ['pathParams'];
70
+ const bodySchema = opContract.requestBodySchema ?? { type: 'object' };
71
+ if (opContract.body === true) {
72
+ properties.body = bodySchema;
73
+ required.push('body');
74
+ } else if (opContract.body === 'unknown') {
75
+ properties.body = bodySchema;
76
+ }
77
+ // body === false: deliberately absent from `properties` -- with additionalProperties:false
78
+ // below, a payload that includes a body for a known-bodyless operation is rejected outright.
79
+ return { type: 'object', additionalProperties: false, properties, required };
80
+ }
81
+ if (direction === 'response' || direction === 'error') {
82
+ const schema = direction === 'response' ? opContract.responseSchema : opContract.errorSchema;
83
+ if (!schema) return null;
84
+ return { type: 'object', additionalProperties: false, properties: { body: schema }, required: ['body'] };
85
+ }
86
+ return null;
87
+ }
88
+
89
+ // Validates a full envelope against a specific feature's contract: feature_id/feature_uid must
90
+ // match the contract exactly (not just be well-formed), operation_id must be one the contract
91
+ // actually knows about, and payload must satisfy that operation's specific pathParams/body
92
+ // shape -- this is what makes "wrong feature" and "wrong operation" and "right operation but
93
+ // missing a required path param" all fail differently and traceably, not just "invalid JSON".
94
+ export function validateAgainstContract(envelope, contract) {
95
+ const errors = [];
96
+ if (envelope.feature_id !== contract.feature_id) {
97
+ errors.push(`feature_id mismatch: envelope has "${envelope.feature_id}", contract is for "${contract.feature_id}"`);
98
+ }
99
+ if (envelope.feature_uid !== contract.feature_uid) {
100
+ errors.push(`feature_uid mismatch: envelope has "${envelope.feature_uid}", contract is for "${contract.feature_uid}" (a stale payload from a renamed/recreated feature would land here)`);
101
+ }
102
+ // D-security-1: Object.hasOwn, not a plain `[key]` lookup -- `contract.operations` is a
103
+ // plain object, so `operation_id: "constructor"` (or "toString", "__proto__", etc.) would
104
+ // otherwise resolve an inherited Object.prototype property and pass as if it were a real,
105
+ // defined operation. Found by the Codex security review, verified against this exact code.
106
+ const opContract = Object.hasOwn(contract.operations, envelope.operation_id)
107
+ ? contract.operations[envelope.operation_id]
108
+ : undefined;
109
+ if (!opContract) {
110
+ errors.push(`operation_id "${envelope.operation_id}" is not defined in this feature's contract (known operations: ${Object.keys(contract.operations).join(', ') || '(none)'})`);
111
+ return { ok: false, errors };
112
+ }
113
+ // A3: direction-agnostic -- operationPayloadSchema() returns null for response/error when
114
+ // nothing was projected (unconstrained, exactly as every direction behaved before A2/A3), and a
115
+ // real schema otherwise. request behavior is unchanged (that branch always returns a schema).
116
+ const payloadSchema = operationPayloadSchema(opContract, envelope.direction);
117
+ if (payloadSchema) {
118
+ // A2: before A2, payloadSchema was always 100% synthesized by this codebase, so
119
+ // ajv().compile() never threw. Now it can embed a projected schema, and the contract file
120
+ // itself is hand-editable on disk (the `contract` gate would go stale, but this function
121
+ // doesn't consult gates) -- a malformed schema must fail cleanly, not crash.
122
+ let validateFn;
123
+ try {
124
+ validateFn = ajv().compile(payloadSchema);
125
+ } catch (err) {
126
+ errors.push(`this operation's contract payload schema could not be compiled: ${err.message} -- the contract file may have been hand-edited (re-run \`bskel contract emit\`)`);
127
+ return { ok: false, errors };
128
+ }
129
+ const ok = validateFn(envelope.payload);
130
+ if (!ok) {
131
+ for (const e of validateFn.errors ?? []) {
132
+ errors.push(`payload${e.instancePath} ${e.message}`);
133
+ }
134
+ }
135
+ }
136
+ // No payloadSchema (unknown direction, or a known direction with nothing projected for this
137
+ // operation): not constrained beyond the envelope's own structure -- see D-contract-scope.
138
+ return { ok: errors.length === 0, errors };
139
+ }
140
+
141
+ export function validateEnvelope(envelope, contract) {
142
+ const structural = validateEnvelopeStructure(envelope);
143
+ if (!structural.ok) {
144
+ return { ok: false, errors: structural.errors.map((e) => `${e.instancePath || '(root)'} ${e.message}`) };
145
+ }
146
+ return validateAgainstContract(envelope, contract);
147
+ }
@@ -0,0 +1,281 @@
1
+ // G4: shared, safety-critical emit machinery -- extracted from what was originally handles/
2
+ // emit.mjs (Java-only, pre-G4) so any codegen provider's own emit() can reuse the exact same
3
+ // conflict/manifest/force/orphan logic instead of duplicating it. This is pure code motion --
4
+ // the ownership/conflict semantics themselves are unchanged from O2's D-handles-ownership. See
5
+ // D-handles-providers (G4) in DECISIONS.md. `handles/providers/java-spring/emit.mjs` is the
6
+ // reference caller to compare against if this file's behavior is ever in question.
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { execFileSync } from 'node:child_process';
11
+ import { sha256File, sha256String } from '../lib/fsutil.mjs';
12
+ import { loadManifest, saveManifest, classifyFile, extractResolverOwnerFeatureId, BSKEL_GENERATED_MARKER } from '../lib/handles-manifest.mjs';
13
+
14
+ function readIfExists(target) {
15
+ return fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null;
16
+ }
17
+
18
+ function writeUnit(target, content) {
19
+ fs.mkdirSync(path.dirname(target), { recursive: true });
20
+ fs.writeFileSync(target, content);
21
+ }
22
+
23
+ // D4 (D-handles-dryrun): a real unified diff via `git diff --no-index`, not a hand-rolled diff
24
+ // algorithm -- `git` is already a hard dependency (isDirtyOrUntracked below already shells out to
25
+ // it on every emit), so this adds zero new dependencies. `cwd: tmpDir` + relative `a/<relPath>`/
26
+ // `b/<relPath>` paths (rather than absolute temp paths) keep the diff header clean and
27
+ // reproducible -- the random tmpdir name never leaks into the output. `git diff --no-index` exits
28
+ // 1 when the two sides differ (the expected, common case here, not a failure) -- only a status
29
+ // other than 0/1 is a genuine error worth throwing.
30
+ export function unifiedDiff(relPath, before, after) {
31
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bskel-handles-diff-'));
32
+ try {
33
+ const beforeAbs = path.join(tmpDir, 'a', relPath);
34
+ const afterAbs = path.join(tmpDir, 'b', relPath);
35
+ fs.mkdirSync(path.dirname(beforeAbs), { recursive: true });
36
+ fs.mkdirSync(path.dirname(afterAbs), { recursive: true });
37
+ fs.writeFileSync(beforeAbs, before ?? '');
38
+ fs.writeFileSync(afterAbs, after ?? '');
39
+ try {
40
+ return execFileSync('git', ['diff', '--no-index', '--no-color', '--', `a/${relPath}`, `b/${relPath}`], { cwd: tmpDir, encoding: 'utf8' });
41
+ } catch (err) {
42
+ if (err.status === 1 && typeof err.stdout === 'string') return err.stdout;
43
+ throw err;
44
+ }
45
+ } finally {
46
+ fs.rmSync(tmpDir, { recursive: true, force: true });
47
+ }
48
+ }
49
+
50
+ const DIFFABLE_ACTIONS = new Set(['update', 'conflict', 'adopt-update']);
51
+
52
+ // O2: refuses --force on a target that isn't safely recoverable from git history -- a --force
53
+ // overwrite is only ever reversible if the content it destroys is already committed. Fails
54
+ // closed (treats git errors, or a repo where the path can't be resolved, as "dirty") since the
55
+ // whole point is to never make an irreversible action look safe by default.
56
+ function isDirtyOrUntracked(repoRoot, absPath) {
57
+ try {
58
+ const out = execFileSync('git', ['status', '--porcelain', '--', absPath], { cwd: repoRoot, encoding: 'utf8' });
59
+ return out.trim().length > 0;
60
+ } catch {
61
+ return true;
62
+ }
63
+ }
64
+
65
+ // The provider-neutral core of `bskel handles emit`. Each provider computes its own render/paths,
66
+ // then calls this once to do the actual conflict-safe write. See DECISIONS.md D-handles-ownership
67
+ // for the full design this preserves unchanged.
68
+ //
69
+ // infraUnits: [{ id, templatePath, targetAbs, rendered }] -- repo-owned, all-or-nothing
70
+ // resolverUnits: [{ id, resourceType, module, templatePath, targetAbs, rendered,
71
+ // pristineRenderFor(ownerId) => string }] -- feature-owned, independent
72
+ // orphanScan: { dir, module, matchesFile(filename) => bool,
73
+ // resourceTypeOf(filename, content) => string|null } | null
74
+ // -- null disables orphan detection entirely (mirrors --resource narrowing)
75
+ // provider: string, written into every manifest entry this call creates/updates
76
+ // dryRun: D4 (D-handles-dryrun) -- when true, every actual write (writeUnit/saveManifest)
77
+ // is skipped, but the exact same classification runs and `written`/`forced`
78
+ // still record what WOULD have been written. Nothing on disk changes.
79
+ // computeDiff: D4 -- when true, attaches a real unified diff (git diff --no-index) to every
80
+ // 'update'/'conflict'/'adopt-update' action -- the only 3 where content actually
81
+ // differs. Off by default since it shells out to git per diffable file.
82
+ export function emitUnits({ repoRoot, featureId, provider, force = false, reason = '', infraUnits, resolverUnits, orphanScan, dryRun = false, computeDiff = false }) {
83
+ const manifest = loadManifest(repoRoot);
84
+ const nowIso = new Date().toISOString();
85
+
86
+ const written = [];
87
+ const conflicts = [];
88
+ const orphans = [];
89
+ const notes = [];
90
+ const resolverStubs = [];
91
+ const forced = [];
92
+ const actions = [];
93
+ let manifestChanged = false;
94
+
95
+ function recordAction({ relPath, kind, action, resourceType, diskContent, rendered }) {
96
+ const entry = { path: relPath, kind, action };
97
+ if (resourceType) entry.resourceType = resourceType;
98
+ if (computeDiff && DIFFABLE_ACTIONS.has(action)) entry.diff = unifiedDiff(relPath, diskContent, rendered);
99
+ actions.push(entry);
100
+ }
101
+
102
+ // ---- infra: repo-owned, all-or-nothing (a half-upgraded infra set is worse than either
103
+ // extreme, so one conflict blocks the whole set unless --force). ----
104
+ const infraPlans = infraUnits.map((u) => {
105
+ const relPath = path.relative(repoRoot, u.targetAbs);
106
+ const diskContent = readIfExists(u.targetAbs);
107
+ const exists = diskContent !== null;
108
+ const diskHash = exists ? sha256String(diskContent) : null;
109
+ const freshRenderHash = sha256String(u.rendered);
110
+ const entry = manifest.files[relPath];
111
+ // Infra is feature-independent, so a pristine render IS the fresh render (no owner-recovery
112
+ // step needed, unlike a resolver's baked-in FEATURE_ID).
113
+ const matchesPristineRender = exists && diskContent === u.rendered;
114
+ const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
115
+ return { ...u, relPath, diskContent, freshRenderHash, action };
116
+ });
117
+
118
+ const infraHasConflict = infraPlans.some((u) => u.action === 'conflict');
119
+ if (infraHasConflict && !force) {
120
+ for (const u of infraPlans) {
121
+ conflicts.push({ path: u.relPath, kind: 'infra', reason: 'diverged from the last content backend-skeleton generated -- see notes for remediation' });
122
+ recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
123
+ }
124
+ } else {
125
+ for (const u of infraPlans) {
126
+ if (u.action === 'conflict') {
127
+ if (isDirtyOrUntracked(repoRoot, u.targetAbs)) {
128
+ conflicts.push({ path: u.relPath, kind: 'infra', reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
129
+ recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
130
+ continue;
131
+ }
132
+ if (!dryRun) {
133
+ manifest.files[u.relPath] = {
134
+ kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
135
+ template_hash: sha256File(u.templatePath), generated_hash: u.freshRenderHash,
136
+ updated_at: nowIso, last_force: { reason, at: nowIso },
137
+ };
138
+ manifestChanged = true;
139
+ writeUnit(u.targetAbs, u.rendered);
140
+ }
141
+ written.push(u.relPath);
142
+ forced.push(u.relPath);
143
+ recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
144
+ continue;
145
+ }
146
+ if (u.action === 'unchanged') {
147
+ recordAction({ relPath: u.relPath, kind: 'infra', action: u.action });
148
+ continue;
149
+ }
150
+ // 'adopt-unchanged' means disk content already IS the correct bytes (a pristine,
151
+ // no-manifest-entry file) -- record the manifest entry so future runs see it as
152
+ // 'unchanged', but don't rewrite bytes that are already correct, and don't claim we
153
+ // "wrote" a file whose content didn't actually change.
154
+ if (u.action !== 'adopt-unchanged') {
155
+ if (!dryRun) writeUnit(u.targetAbs, u.rendered);
156
+ written.push(u.relPath);
157
+ }
158
+ if (!dryRun) {
159
+ manifest.files[u.relPath] = {
160
+ kind: 'infra', ownership: 'repo', owner: '_repo', provider, template: u.id,
161
+ template_hash: sha256File(u.templatePath), generated_hash: u.freshRenderHash,
162
+ updated_at: nowIso,
163
+ };
164
+ manifestChanged = true;
165
+ }
166
+ recordAction({ relPath: u.relPath, kind: 'infra', action: u.action, diskContent: u.diskContent, rendered: u.rendered });
167
+ }
168
+ }
169
+
170
+ // ---- resolvers: feature-owned, independent per file. "Regenerate when provably untouched"
171
+ // rather than "create once" -- a live-derived value (e.g. a required-authority string) is
172
+ // re-derived every run, and "once" would strand a stale value in a security-relevant file. ----
173
+ const generatedTypesThisRun = new Set();
174
+
175
+ for (const u of resolverUnits) {
176
+ resolverStubs.push(u.resourceType);
177
+
178
+ const relPath = path.relative(repoRoot, u.targetAbs);
179
+ generatedTypesThisRun.add(u.resourceType);
180
+
181
+ const diskContent = readIfExists(u.targetAbs);
182
+ const exists = diskContent !== null;
183
+ const diskHash = exists ? sha256String(diskContent) : null;
184
+ const freshRenderHash = sha256String(u.rendered);
185
+ const entry = manifest.files[relPath];
186
+
187
+ let matchesPristineRender = false;
188
+ let recoveredOwner = null;
189
+ if (exists) {
190
+ recoveredOwner = extractResolverOwnerFeatureId(diskContent);
191
+ if (recoveredOwner) {
192
+ matchesPristineRender = u.pristineRenderFor(recoveredOwner) === diskContent;
193
+ }
194
+ }
195
+ const action = classifyFile({ exists, diskHash, manifestEntryHash: entry?.generated_hash ?? null, freshRenderHash, matchesPristineRender });
196
+
197
+ if (action === 'conflict') {
198
+ if (force) {
199
+ if (isDirtyOrUntracked(repoRoot, u.targetAbs)) {
200
+ conflicts.push({ path: relPath, kind: 'resolver', resourceType: u.resourceType, reason: 'refusing --force: this file has uncommitted/untracked changes -- commit or stash it first so the overwrite is recoverable' });
201
+ recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
202
+ continue;
203
+ }
204
+ if (!dryRun) {
205
+ writeUnit(u.targetAbs, u.rendered);
206
+ manifest.files[relPath] = {
207
+ kind: 'resolver', ownership: 'feature', owner: featureId, resource_type: u.resourceType, module: u.module, provider,
208
+ template: u.id, template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
209
+ updated_at: nowIso, last_force: { reason, at: nowIso, overwritten_hash: diskHash },
210
+ };
211
+ manifestChanged = true;
212
+ }
213
+ written.push(relPath);
214
+ forced.push(relPath);
215
+ recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
216
+ continue;
217
+ }
218
+ conflicts.push({
219
+ path: relPath, kind: 'resolver', resourceType: u.resourceType,
220
+ reason: 'diverged from the last content backend-skeleton generated -- if you have not edited this file, this may be expected after a template upgrade or a security-relevant source change. If you HAVE edited it (e.g. finished a stubbed-out method), leave it -- nothing else in this run depends on it.',
221
+ });
222
+ recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
223
+ continue;
224
+ }
225
+
226
+ const priorOwner = entry?.owner ?? recoveredOwner;
227
+ if (priorOwner && priorOwner !== featureId) {
228
+ notes.push(`ownership transfer: ${relPath} was generated by feature "${priorOwner}", now generated by "${featureId}"`);
229
+ }
230
+
231
+ if (action !== 'unchanged') {
232
+ if (action !== 'adopt-unchanged') {
233
+ if (!dryRun) writeUnit(u.targetAbs, u.rendered);
234
+ written.push(relPath);
235
+ }
236
+ if (!dryRun) {
237
+ manifest.files[relPath] = {
238
+ kind: 'resolver', ownership: 'feature', owner: featureId, resource_type: u.resourceType, module: u.module, provider,
239
+ template: u.id, template_hash: sha256File(u.templatePath), generated_hash: freshRenderHash,
240
+ updated_at: nowIso,
241
+ };
242
+ manifestChanged = true;
243
+ }
244
+ }
245
+ recordAction({ relPath, kind: 'resolver', action, resourceType: u.resourceType, diskContent, rendered: u.rendered });
246
+ }
247
+
248
+ // ---- orphan detection: a resolver this feature's CURRENT plan no longer generates, left
249
+ // untouched and never deleted -- same conservative bias as D-migration-scope/D-config-patch.
250
+ // Suppressed entirely under --resource (orphanScan === null), since every resource outside the
251
+ // filter would otherwise look orphaned. ----
252
+ if (orphanScan) {
253
+ const seenOrphanPaths = new Set();
254
+ for (const [relPath, entry] of Object.entries(manifest.files)) {
255
+ // `entry.provider` is absent on a manifest written before G4 -- treat that as
256
+ // "java-spring" so orphan detection for existing target repos keeps working exactly as
257
+ // before this item, rather than silently going blind on their first post-G4 run.
258
+ const entryProvider = entry.provider ?? 'java-spring';
259
+ if (entry.kind !== 'resolver' || entry.module !== orphanScan.module || entryProvider !== provider || generatedTypesThisRun.has(entry.resource_type)) continue;
260
+ orphans.push({ path: relPath, resourceType: entry.resource_type, reason: 'manifest tracks this resolver but the current plan no longer generates it -- left on disk untouched' });
261
+ seenOrphanPaths.add(relPath);
262
+ }
263
+ if (fs.existsSync(orphanScan.dir)) {
264
+ for (const file of fs.readdirSync(orphanScan.dir)) {
265
+ if (!orphanScan.matchesFile(file)) continue;
266
+ const absPath = path.join(orphanScan.dir, file);
267
+ const relPath = path.relative(repoRoot, absPath);
268
+ if (seenOrphanPaths.has(relPath)) continue;
269
+ const content = readIfExists(absPath);
270
+ if (!content || !content.includes(BSKEL_GENERATED_MARKER)) continue;
271
+ const resourceType = orphanScan.resourceTypeOf(file, content);
272
+ if (!resourceType || generatedTypesThisRun.has(resourceType)) continue;
273
+ orphans.push({ path: relPath, resourceType, reason: 'file carries the backend-skeleton marker but the current plan no longer generates it -- left on disk untouched' });
274
+ }
275
+ }
276
+ }
277
+
278
+ if (!dryRun && manifestChanged) saveManifest(repoRoot, manifest);
279
+
280
+ return { written, resolverStubs, conflicts, orphans, notes, forced, blocked: conflicts.length > 0, actions };
281
+ }
@@ -0,0 +1,119 @@
1
+ // D5/D6 (DECISIONS.md): the "handle" is a composite address -- kind:type:uuid[:pointer],
2
+ // base64url-encoded with an `sbf1_` prefix -- extending Relay's `base64(Type:id)` global-ID
3
+ // pattern with an RFC 6901 JSON Pointer for field-level addressing. This is the JS reference
4
+ // implementation; handles/providers/java-spring/templates/HandleCodec.java.tmpl and
5
+ // handles/providers/python-fastapi/templates/codec.py.tmpl must stay byte-identical in behavior --
6
+ // executed, both directions, cross-checked against test/handles-java-codec.test.mjs and
7
+ // test/handles-python-codec.test.mjs respectively (test/handles-codec.test.mjs only self-tests
8
+ // this file's own JS-side behavior, it does not cross-check either other language).
9
+ import { createHash } from 'node:crypto';
10
+
11
+ // Fixed namespace UUID for this skill's field-handle derivation (arbitrary but permanent --
12
+ // changing it would silently re-derive every existing field_uid to a different value).
13
+ export const NS_SBF_FIELD = 'a3f1c2e0-8b4d-4f1a-9c3e-1d2b3a4c5d6e';
14
+
15
+ const UUID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
16
+ const HANDLE_RE = new RegExp(`^([rfo]):([^:]+):(${UUID_RE})(?::(.*))?$`, 'i');
17
+ const BASE64URL_CHARSET_RE = /^[A-Za-z0-9_-]*$/;
18
+
19
+ // D-security-10: no upper bound on token length before attempting to decode -- a defense-in-
20
+ // depth cap, not a functional requirement (real handles are well under this). Found by the
21
+ // Codex security review as part of the "other requested checks" pass.
22
+ const MAX_HANDLE_TOKEN_LENGTH = 2048;
23
+
24
+ function base64url(buf) {
25
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
26
+ }
27
+
28
+ // D-security-10: Node's `Buffer.from(str, 'base64')` silently IGNORES characters outside the
29
+ // base64 alphabet instead of rejecting them, while Java's `Base64.getUrlDecoder().decode()`
30
+ // throws on the same input -- the two implementations' "byte-identical behavior" claim (D5/D6)
31
+ // didn't actually hold for malformed input. Found by the Codex security review. The explicit
32
+ // charset check below makes the JS side reject exactly what the Java side rejects, before either
33
+ // one gets a chance to decode it differently.
34
+ function base64urlDecode(str) {
35
+ if (!BASE64URL_CHARSET_RE.test(str)) {
36
+ throw new Error('not valid base64url after the sbf1_ prefix');
37
+ }
38
+ const pad = (4 - (str.length % 4)) % 4;
39
+ const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(pad);
40
+ return Buffer.from(padded, 'base64');
41
+ }
42
+
43
+ export function encodeHandle({ kind, type, uuid, pointer = null }) {
44
+ if (!['r', 'f', 'o'].includes(kind)) throw new Error(`invalid handle kind "${kind}" (expected r, f, or o)`);
45
+ if (!type || !uuid) throw new Error('encodeHandle requires both type and uuid');
46
+ if (kind === 'f' && !pointer) throw new Error('field handles (kind=f) require a JSON Pointer');
47
+ // D-security-10: the symmetric case was previously unchecked -- a non-field handle silently
48
+ // carrying a pointer would encode fine and only cause confusion downstream (e.g. patch()
49
+ // deciding "field handle" purely from pointer-presence, not kind). Found by the Codex
50
+ // security review.
51
+ if (kind !== 'f' && pointer) throw new Error(`handle kind "${kind}" must not carry a JSON Pointer (only kind=f field handles do)`);
52
+ const raw = `${kind}:${type}:${uuid}${pointer ? `:${pointer}` : ''}`;
53
+ return `sbf1_${base64url(Buffer.from(raw, 'utf8'))}`;
54
+ }
55
+
56
+ export function decodeHandle(token) {
57
+ if (typeof token !== 'string' || !token.startsWith('sbf1_')) {
58
+ throw new Error('not an sbf1 handle (missing "sbf1_" prefix)');
59
+ }
60
+ if (token.length > MAX_HANDLE_TOKEN_LENGTH) {
61
+ throw new Error(`handle token exceeds the maximum length of ${MAX_HANDLE_TOKEN_LENGTH} characters`);
62
+ }
63
+ const raw = base64urlDecode(token.slice('sbf1_'.length)).toString('utf8');
64
+ const match = raw.match(HANDLE_RE);
65
+ if (!match) throw new Error(`malformed handle payload after decoding: "${raw}"`);
66
+ const [, kind, type, uuid, pointer] = match;
67
+ return { kind: kind.toLowerCase(), type, uuid: uuid.toLowerCase(), pointer: pointer ?? null };
68
+ }
69
+
70
+ function uuidToBytes(uuid) {
71
+ return Buffer.from(uuid.replace(/-/g, ''), 'hex');
72
+ }
73
+
74
+ function bytesToUuid(bytes) {
75
+ const hex = bytes.toString('hex');
76
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
77
+ }
78
+
79
+ // RFC 4122 UUIDv5 (name-based, SHA-1). Implemented directly rather than pulling in the `uuid`
80
+ // package -- this is the only place a v5 is needed and the algorithm is ~10 lines. Verified
81
+ // against the standard test vector in test/handles-codec.test.mjs (NAMESPACE_DNS + "example.com").
82
+ export function uuidv5(namespaceUuid, name) {
83
+ const hash = createHash('sha1')
84
+ .update(Buffer.concat([uuidToBytes(namespaceUuid), Buffer.from(name, 'utf8')]))
85
+ .digest();
86
+ const bytes = Buffer.from(hash.subarray(0, 16));
87
+ bytes[6] = (bytes[6] & 0x0f) | 0x50; // version 5
88
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
89
+ return bytesToUuid(bytes);
90
+ }
91
+
92
+ // The plain-UUID identity of a handle (for use as a DB primary key / foreign key), derivable
93
+ // offline without a DB round-trip: kind=r handles ARE the resource's own uuid (no derivation
94
+ // needed -- a resource handle and the entity's own PK are the same identity); kind=f handles
95
+ // derive a UUIDv5 from type+uuid+pointer, so the same field always gets the same handle_uid
96
+ // without ever needing to look it up first.
97
+ export function deriveHandleUid({ kind, type, uuid, pointer }) {
98
+ if (kind === 'r') return uuid;
99
+ if (kind === 'f') {
100
+ if (!pointer) throw new Error('field handles require a pointer to derive handle_uid');
101
+ return uuidv5(NS_SBF_FIELD, `${type}:${uuid}:${pointer}`);
102
+ }
103
+ if (kind === 'o') return uuidv5(NS_SBF_FIELD, `${type}:${uuid}:o`);
104
+ throw new Error(`invalid handle kind "${kind}"`);
105
+ }
106
+
107
+ // RFC 6901 JSON Pointer resolution -- used by the `fetch` verb to extract a field's value from
108
+ // a resource's serialized shape once the resolver has fetched the whole resource.
109
+ export function resolveJsonPointer(obj, pointer) {
110
+ if (pointer == null || pointer === '') return obj;
111
+ if (!pointer.startsWith('/')) throw new Error(`invalid JSON Pointer "${pointer}" -- must start with "/"`);
112
+ const parts = pointer.split('/').slice(1).map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~'));
113
+ let current = obj;
114
+ for (const part of parts) {
115
+ if (current == null) return undefined;
116
+ current = Array.isArray(current) ? current[Number(part)] : current[part];
117
+ }
118
+ return current;
119
+ }
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import Ajv2020 from 'ajv/dist/2020.js';
5
+
6
+ const CONFORMANCE_ROOT = path.dirname(fileURLToPath(import.meta.url));
7
+ const SCHEMAS_ROOT = path.join(CONFORMANCE_ROOT, '..', 'schemas');
8
+
9
+ let _ajv = null;
10
+ function ajv() {
11
+ if (!_ajv) _ajv = new Ajv2020({ allErrors: true, strict: false });
12
+ return _ajv;
13
+ }
14
+
15
+ function loadHandlesPlanSchema() {
16
+ return JSON.parse(fs.readFileSync(path.join(SCHEMAS_ROOT, 'handles-plan.schema.json'), 'utf8'));
17
+ }
18
+
19
+ // P4 (D-extension-conformance): schemas/handles-plan.schema.json existed since G4 (multi-provider
20
+ // handles codegen) but had no real consumer anywhere in the codebase -- bin/bskel.mjs renders a
21
+ // provider's plan() output directly, never validating it against this schema. This is that
22
+ // schema's first real use: proof a third-party provider's plan() actually produces the shape
23
+ // schemas/handles-plan.schema.json (and this project's own CLI renderer) expect, plus an emit()
24
+ // idempotence check equivalent to stack/apply.mjs's applyPlan() re-apply guarantee -- a second
25
+ // emit() against files the first emit() already wrote must report nothing new to write.
26
+ //
27
+ // Found live while grounding this against the real java-spring provider: `provider.outputs.spec`
28
+ // (e.g. `handles/migration.sql`, under `specs/<featureId>/`) is BY DESIGN regenerated
29
+ // unconditionally on every emit() call, unlike the manifest-tracked generated-code files --
30
+ // handles/providers/java-spring/emit.mjs's own comment documents this as pre-existing, intentional
31
+ // behavior, not a bug this item should flag. `provider.outputs.spec` is exactly the schema field
32
+ // (schemas/handles-provider.schema.json, required) that already distinguishes these two
33
+ // categories, so the idempotence check excludes those declared paths instead of hardcoding
34
+ // per-provider knowledge into this harness.
35
+ export function checkProviderConformance(provider, { repoRoot, scanReport, module = null, resourceFilter = null, featureId = 'zz-conformance-check' } = {}) {
36
+ const errors = [];
37
+ let plan;
38
+ try {
39
+ plan = provider.plan({ repoRoot, scanReport, module, resourceFilter });
40
+ } catch (err) {
41
+ errors.push(`plan() threw: ${err.message}`);
42
+ return { provider: provider.id, ok: false, errors };
43
+ }
44
+
45
+ const schema = loadHandlesPlanSchema();
46
+ const validateFn = ajv().getSchema(schema.$id) ?? ajv().compile(schema);
47
+ if (!validateFn(plan)) {
48
+ const details = (validateFn.errors ?? []).map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ');
49
+ errors.push(`plan() output does not match schemas/handles-plan.schema.json: ${details}`);
50
+ }
51
+
52
+ let first;
53
+ try {
54
+ first = provider.emit({ repoRoot, featureId, plan, resourceFilter, force: false, reason: '' });
55
+ } catch (err) {
56
+ errors.push(`emit() threw: ${err.message}`);
57
+ return { provider: provider.id, ok: false, errors };
58
+ }
59
+
60
+ let second;
61
+ try {
62
+ second = provider.emit({ repoRoot, featureId, plan, resourceFilter, force: false, reason: '' });
63
+ } catch (err) {
64
+ errors.push(`emit() threw on its second, idempotent call: ${err.message}`);
65
+ return { provider: provider.id, ok: false, errors };
66
+ }
67
+ const specOwnedPaths = new Set((provider.outputs?.spec ?? []).map((relPath) => path.join('specs', featureId, relPath)));
68
+ const unexpectedWrites = (second.written ?? []).filter((w) => !specOwnedPaths.has(w));
69
+ if (!Array.isArray(second.written) || unexpectedWrites.length !== 0) {
70
+ errors.push(`emit() is not idempotent -- a second call against files the first call already wrote reported written: ${JSON.stringify(second.written)} (expected only provider.outputs.spec entries, if any: ${JSON.stringify([...specOwnedPaths])})`);
71
+ }
72
+
73
+ return { provider: provider.id, ok: errors.length === 0, errors, firstEmitWritten: first.written };
74
+ }