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/doctor.mjs ADDED
@@ -0,0 +1,200 @@
1
+ // D5: `bskel doctor`'s check computation, separated from CLI glue (bin/bskel.mjs) the same way
2
+ // D1's lib/workflow.mjs separates `computeWorkflowState()` from its cmdStatus/cmdNext callers --
3
+ // this stays pure enough to unit test without spawning the CLI, and bin/bskel.mjs just renders
4
+ // whatever this returns.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { detectBuildCommand } from './verify.mjs';
7
+ import { listCatalogChoices, loadCatalogEntry } from '../stack/apply.mjs';
8
+ import { detectAstHelperAvailable } from '../handles/providers/java-spring/ast-bridge.mjs';
9
+
10
+ // D5: the three workflows that have tool requirements beyond "git + a supported Node runtime"
11
+ // (every workflow needs those two -- preflight/contract don't need anything ELSE, so they're
12
+ // deliberately not `--workflow` choices; see D-doctor-workflow in DECISIONS.md).
13
+ export const WORKFLOWS = Object.freeze(['scan', 'handles', 'stack']);
14
+
15
+ // P1 (D-npm-packaging): this used to be {major:20, minor:11} for contracts/validate.mjs's
16
+ // `import.meta.dirname` (Node >=20.11.0 backported / >=21.2.0) -- the only thing in this
17
+ // codebase's runtime code that needed anything above plain ES2022/Node 18. P1 fixed that one call
18
+ // site to use the portable `path.dirname(fileURLToPath(import.meta.url))` pattern every other
19
+ // file already used, so the real floor dropped back to package.json's own declared ">=18" --
20
+ // confirmed by grepping the whole runtime tree (lib/, bin/, contracts/, scanners/, handles/,
21
+ // stack/) for every other recent-ES-addition pattern (structuredClone, Object.groupBy,
22
+ // .toSorted/.toReversed/.toSpliced/.with, Array.fromAsync, Promise.withResolvers, global fetch,
23
+ // node:sqlite, using/await using, import.meta.resolve, AbortSignal.timeout/.any) -- zero hits.
24
+ // `Object.hasOwn` (used throughout) is ES2022/Node 16.9+, and top-level await (scanners/
25
+ // registry.mjs, handles/registry.mjs) is ESM/Node 14.8+ -- both already well under 18.
26
+ // P3 (D-fixture-corpus): exported so test/ci-workflow.test.mjs can assert the CI Node matrix
27
+ // never drifts below this floor -- the single source of truth for "what Node version does this
28
+ // tool actually need", not a second hand-copied number in the workflow file.
29
+ export const MIN_NODE = { major: 18, minor: 0 };
30
+
31
+ function nodeVersionOk(versionString) {
32
+ const [major, minor] = versionString.split('.').map(Number);
33
+ return major > MIN_NODE.major || (major === MIN_NODE.major && minor >= MIN_NODE.minor);
34
+ }
35
+
36
+ function binaryCheck(name, { required, remediation }) {
37
+ let ok = true;
38
+ let detail = '';
39
+ try {
40
+ execFileSync(name, ['--version'], { stdio: 'pipe' });
41
+ } catch {
42
+ ok = false;
43
+ detail = 'not found on PATH';
44
+ }
45
+ return { name: `binary: ${name}`, required, ok, detail, remediation: ok ? null : remediation };
46
+ }
47
+
48
+ function nodeVersionCheck() {
49
+ const version = process.versions.node;
50
+ const ok = nodeVersionOk(version);
51
+ return {
52
+ name: 'Node version',
53
+ required: true,
54
+ ok,
55
+ detail: `running v${version}`,
56
+ remediation: ok ? null : (
57
+ `this Node runtime (v${version}) is older than what backend-skeleton needs ` +
58
+ `(>=${MIN_NODE.major}.${MIN_NODE.minor}.0, matching package.json's declared engines floor). Upgrade Node.`
59
+ ),
60
+ };
61
+ }
62
+
63
+ // D5: not a PATH-binary check -- `bskel` itself never invokes `java`/`gradle`/`mvn` directly.
64
+ // `handles emit`/`handles plan` only WRITE .java files, never compile them; the only place this
65
+ // project runs a build at all is `bskel verify --build`, which needs a recognized WRAPPER SCRIPT
66
+ // present in the target repo (see lib/verify.mjs's detectBuildCommand -- reused here, not
67
+ // reimplemented, so this check and `verify --build` can never disagree about what "found" means).
68
+ function buildWrapperCheck(root) {
69
+ const build = detectBuildCommand(root);
70
+ return {
71
+ name: 'build wrapper',
72
+ required: false,
73
+ ok: Boolean(build),
74
+ detail: build ? `${build.tool} (${build.cmd})` : 'no gradlew, pom.xml, or package.json found at repo root',
75
+ remediation: build ? null : (
76
+ 'no recognized build wrapper found -- `bskel verify --build` will have nothing to run. ' +
77
+ 'Not required for `handles emit` itself, which only writes .java files and never compiles them.'
78
+ ),
79
+ };
80
+ }
81
+
82
+ // A2 Phase 2 (D-java-ast-helper): optional -- `bskel handles plan` works fully without this,
83
+ // `--ast` is the one thing that needs it. Reuses the exact same detection function `--ast`'s own
84
+ // upfront check calls, so `doctor` and the real command can never disagree about availability.
85
+ function astHelperCheck() {
86
+ const detection = detectAstHelperAvailable();
87
+ return {
88
+ name: 'AST helper (java-spring --ast)',
89
+ required: false,
90
+ ok: detection.available,
91
+ detail: detection.available ? 'ready' : detection.reason,
92
+ remediation: detection.available ? null : `${detection.reason} -- only needed for \`bskel handles plan --ast\`, never for the base install.`,
93
+ };
94
+ }
95
+
96
+ // D5: sourced from stack/catalog/*.yml's own `runtime.requires` field (schemas/stack-choice.
97
+ // schema.json) rather than a hardcoded ["curl", "ngrok"] list here -- a future catalog entry
98
+ // declares its own runtime binaries and doctor picks them up with zero code changes, the same
99
+ // "fill a schema field, it applies globally" pattern D7/G1 already established for this project.
100
+ function stackToolChecks(root) {
101
+ const neededBy = new Map(); // binary -> [choiceId, ...]
102
+ for (const id of listCatalogChoices()) {
103
+ let entry;
104
+ try {
105
+ entry = loadCatalogEntry(id);
106
+ } catch {
107
+ continue; // a malformed catalog entry is `stack apply`'s problem to report, not doctor's
108
+ }
109
+ for (const bin of entry.runtime?.requires ?? []) {
110
+ if (!neededBy.has(bin)) neededBy.set(bin, []);
111
+ neededBy.get(bin).push(id);
112
+ }
113
+ }
114
+ return [...neededBy.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([bin, ids]) => (
115
+ binaryCheck(bin, {
116
+ required: false,
117
+ remediation: `needed by the bootstrap script \`bskel stack apply --apply\` writes for: ${ids.join(', ')} ` +
118
+ '-- not by `bskel stack apply` itself. Install before running the generated script.',
119
+ })
120
+ ));
121
+ }
122
+
123
+ export function computeDoctorChecks(root, { workflow = null } = {}) {
124
+ if (workflow !== null && !WORKFLOWS.includes(workflow)) {
125
+ throw new Error(`unknown workflow "${workflow}" -- known workflows: ${WORKFLOWS.join(', ')}`);
126
+ }
127
+
128
+ const checks = [
129
+ {
130
+ name: 'inside a git repo',
131
+ required: true,
132
+ ok: Boolean(root),
133
+ detail: root ?? 'not a git repo',
134
+ remediation: root ? null : 'run this from inside a git repository',
135
+ },
136
+ binaryCheck('git', { required: true, remediation: 'install git (https://git-scm.com) and ensure it is on PATH' }),
137
+ nodeVersionCheck(),
138
+ ];
139
+
140
+ // gh: only ever consulted by preflight's 3-way default-branch cross-check, and that script
141
+ // already `command -v gh`-guards it -- preflight does not hard-fail without it, so this is
142
+ // never workflow-scoped (there's no `--workflow preflight`) and never required. Shown only in
143
+ // the unscoped "everything" view.
144
+ if (workflow === null) {
145
+ checks.push(binaryCheck('gh', {
146
+ required: false,
147
+ remediation: 'optional -- only used for the 3-way default-branch cross-check in `bskel preflight` (already soft-guarded there); install with `gh` CLI (https://cli.github.com) if you want that extra check.',
148
+ }));
149
+ }
150
+
151
+ // rg: both scanner adapters use it directly for scan, and the java-spring handles provider's
152
+ // detectBasePackage() (handles/providers/java-spring/plan.mjs) shells out to it independently
153
+ // during `handles plan`/`handles emit` -- required for both, even though only java-spring.mjs's
154
+ // own call site lacks a try/catch (a separate, known, out-of-scope issue surfaced via that
155
+ // adapter's own diagnostics()).
156
+ if (workflow === null || workflow === 'scan' || workflow === 'handles') {
157
+ checks.push(binaryCheck('rg', { required: true, remediation: 'install ripgrep (`brew install ripgrep`) -- required for `bskel scan` and `bskel handles emit`.' }));
158
+ }
159
+
160
+ // G4: not required for bskel itself (the python-fastapi provider only ever WRITES .py files,
161
+ // never executes them) -- this is purely for a human/CI wanting to run this project's own
162
+ // test/handles-python-codec.test.mjs, which DOES require it to round-trip-verify the generated
163
+ // codec.py against the JS reference implementation.
164
+ if (workflow === null || workflow === 'handles') {
165
+ checks.push(binaryCheck('python3', {
166
+ required: false,
167
+ remediation: 'only needed for this project\'s own cross-language codec test (test/handles-python-codec.test.mjs) -- `bskel handles emit` itself never invokes python3.',
168
+ }));
169
+ }
170
+
171
+ // D-handles-providers (G4) follow-up: same asymmetry as python3 above -- not required for
172
+ // bskel itself (`handles emit` never invokes javac; the java-spring provider only ever WRITES
173
+ // .java files), purely for a human/CI wanting to run this project's own
174
+ // test/handles-java-codec.test.mjs, which DOES require it to round-trip-verify the rendered
175
+ // HandleCodec.java against the JS reference implementation. That test is mandatory, unlike
176
+ // this doctor check -- see its own header comment for why.
177
+ if (workflow === null || workflow === 'handles') {
178
+ checks.push(binaryCheck('javac', {
179
+ required: false,
180
+ remediation: 'only needed for this project\'s own cross-language codec test (test/handles-java-codec.test.mjs) -- `bskel handles emit` itself never invokes javac.',
181
+ }));
182
+ }
183
+
184
+ if (root && (workflow === null || workflow === 'handles')) {
185
+ checks.push(buildWrapperCheck(root));
186
+ }
187
+ if (workflow === null || workflow === 'handles') {
188
+ checks.push(astHelperCheck());
189
+ }
190
+ if (root && (workflow === null || workflow === 'stack')) {
191
+ checks.push(...stackToolChecks(root));
192
+ }
193
+
194
+ // The G1 adapter-diagnostics block (specificity/capabilities/detect result/diagnostics) is
195
+ // scan's own readiness story, and handles' codegen readiness depends on exactly the same
196
+ // adapter capabilities (resource.fetch/codegen.handles) -- not relevant to stack.
197
+ const showAdapters = workflow === null || workflow === 'scan' || workflow === 'handles';
198
+
199
+ return { checks, showAdapters };
200
+ }
@@ -0,0 +1,67 @@
1
+ // D2 (D-cli-contract): the single, definitive table of every exit code `bskel` (and the bash
2
+ // scripts it shells out to) can produce. Existed before this only as 4 values in `lib/gates.mjs`'s
3
+ // own `EXIT` plus ~6 more scattered as literals across `bin/bskel.mjs`, plus 3 more (11/12/13)
4
+ // defined only inside `scripts/preflight-base-ref.sh` with no JS-side reference at all.
5
+ //
6
+ // Numbers are NOT renumbered here -- they are already a public contract: SKILL.md documents them
7
+ // in several places and existing tests assert specific values (`gate require`/`scan`/`handles
8
+ // emit`/`contract validate` etc across 6+ test files). This table only gives the existing numbers
9
+ // one name each and one place to look them up. See D-cli-contract in DECISIONS.md.
10
+ export const EXIT_CODES = Object.freeze({
11
+ OK: 0,
12
+ CHECK_FAILED: 1,
13
+ NOT_PASSED: 2,
14
+ AWAITING_DISPOSITION: 3,
15
+ STALE: 4,
16
+ NOT_A_REPO: 10,
17
+ STALE_BASE: 11,
18
+ WRONG_DEFAULT: 12,
19
+ DIRTY: 13,
20
+ BAD_ARGS: 14,
21
+ HANDLES_CONFLICT: 15,
22
+ LOW_CONFIDENCE_SCAN: 16,
23
+ MISSING_CAPABILITY: 17,
24
+ REFRESH_FAILED: 18,
25
+ });
26
+
27
+ // `reason` values a `sbf.cli-diagnostic/1` envelope (lib/cli.mjs) can carry. Deliberately does
28
+ // NOT introduce new exit codes for the two different things exit 2 has always meant ("a gate
29
+ // this command depends on hasn't passed" vs "a referenced resource/adapter/provider doesn't
30
+ // exist") -- the number is the stable public contract (see above); `reason` is the "stable but
31
+ // supplementary precision" layer on top of it. See D-cli-contract's WHY in DECISIONS.md for why
32
+ // renumbering exit 2 was rejected.
33
+ export const EXIT_REASONS = Object.freeze({
34
+ BAD_ARGS: EXIT_CODES.BAD_ARGS,
35
+ NOT_A_REPO: EXIT_CODES.NOT_A_REPO,
36
+ MISSING_CAPABILITY: EXIT_CODES.MISSING_CAPABILITY,
37
+ GATE_AWAITING_DISPOSITION: EXIT_CODES.AWAITING_DISPOSITION,
38
+ GATE_STALE: EXIT_CODES.STALE,
39
+ // D-preflight-freshness (S3): `bskel preflight` itself failed to refresh from the remote and
40
+ // no --offline/--no-fetch was given -- a distinct failure class from GATE_STALE (which means
41
+ // "we successfully checked and it IS stale"), so it gets its own exit code rather than reusing
42
+ // STALE_BASE(11) or WRONG_DEFAULT(12), which would blur "we don't know" into "we know, and
43
+ // it's bad".
44
+ REFRESH_FAILED: EXIT_CODES.REFRESH_FAILED,
45
+ // all of the below share exit code NOT_PASSED (2) -- the reason is what tells them apart
46
+ GATE_NOT_PASSED: EXIT_CODES.NOT_PASSED,
47
+ MISSING_ARTIFACT: EXIT_CODES.NOT_PASSED,
48
+ // S5 (D-persistence-integrity): MISSING_ARTIFACT's sibling -- the file exists but fails its
49
+ // own declared schema (hand-edited or externally corrupted), a distinct case from "not there
50
+ // at all". Only used at the CLI-layer read helpers (loadScanReportOrExit/loadContract) that
51
+ // already own a fail() call for the sibling MISSING_ARTIFACT case; lib/state.mjs's own
52
+ // equivalent check throws a plain Error instead (an existing, separate convention -- see its
53
+ // own comment).
54
+ INVALID_ARTIFACT: EXIT_CODES.NOT_PASSED,
55
+ ADAPTER_UNAVAILABLE: EXIT_CODES.NOT_PASSED,
56
+ PROVIDER_UNAVAILABLE: EXIT_CODES.NOT_PASSED,
57
+ UNKNOWN_OPERATION: EXIT_CODES.NOT_PASSED,
58
+ SCAN_FAILED: EXIT_CODES.NOT_PASSED,
59
+ PLAN_FAILED: EXIT_CODES.NOT_PASSED,
60
+ });
61
+
62
+ // `scripts/preflight-base-ref.sh` defines STALE_BASE(11)/WRONG_DEFAULT(12)/DIRTY(13)/
63
+ // REFRESH_FAILED(18, D-preflight-freshness/S3) itself (a standalone bash script, "reusable
64
+ // outside this skill" per its own header comment -- it cannot import this module). Documented
65
+ // here only so this table stays the one place a human looks up what a `bskel preflight` exit
66
+ // code means; the script's own literals are the actual source of truth for these four values and
67
+ // are not re-derived from here.
@@ -0,0 +1,55 @@
1
+ import fs from 'node:fs';
2
+
3
+ export const FEATURE_ID_RE = /^[0-9]{3}-[a-z0-9]+(-[a-z0-9]+)*$/;
4
+
5
+ export function isValidFeatureId(id) {
6
+ return typeof id === 'string' && FEATURE_ID_RE.test(id);
7
+ }
8
+
9
+ export function requireValidFeatureId(id) {
10
+ if (!isValidFeatureId(id)) {
11
+ throw new Error(`invalid feature_id "${id}" -- expected NNN-slug-words (e.g. 001-organization-management)`);
12
+ }
13
+ return id;
14
+ }
15
+
16
+ // The searchable words a feature_id itself contributes to a scan's term set, independent of
17
+ // whatever spec.md may or may not say yet (scan can run before a spec exists).
18
+ export function slugWords(featureId) {
19
+ return featureId.replace(/^[0-9]{3}-/, '').split('-').filter(Boolean);
20
+ }
21
+
22
+ // P2b (D-greenfield-parameters): exported (was module-private) so `new/params.mjs`'s
23
+ // `--artifact-id` validator reuses the exact grammar `--slug` already enforces, rather than
24
+ // declaring a second, subtly different one for the value `--artifact-id` defaults to.
25
+ export const SLUG_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
26
+
27
+ // D-security-3: `gate require/force/show` accept `--feature <anything>` and pass it straight
28
+ // into a path.join() (see lib/state.mjs) -- every OTHER feature-scoped command validates via
29
+ // requireValidFeatureId() first, but the three gate commands originally didn't, so
30
+ // `--feature ../../evil` could read/write a state file outside .sbf/. This accepts either the
31
+ // repo-scoped sentinel or a real feature_id, so gate commands (the only ones that operate on
32
+ // both scopes) can validate too. Found by the Codex security review.
33
+ export function requireValidFeatureOrRepoId(id, repoSentinel) {
34
+ if (id === repoSentinel) return id;
35
+ return requireValidFeatureId(id);
36
+ }
37
+
38
+ export function requireValidSlug(slug) {
39
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug)) {
40
+ throw new Error(`invalid slug "${slug}" -- expected lowercase-hyphenated words (e.g. organization-management)`);
41
+ }
42
+ return slug;
43
+ }
44
+
45
+ // Next NNN- prefix, one past whatever's already under specs/ (spec-kit's own numbering
46
+ // convention -- reused rather than inventing a second one) -- 001 if specs/ doesn't exist yet.
47
+ export function nextFeatureNumber(specsDir) {
48
+ if (!fs.existsSync(specsDir)) return '001';
49
+ const nums = fs.readdirSync(specsDir)
50
+ .map((name) => name.match(/^([0-9]{3})-/))
51
+ .filter(Boolean)
52
+ .map((m) => Number.parseInt(m[1], 10));
53
+ const next = nums.length > 0 ? Math.max(...nums) + 1 : 1;
54
+ return String(next).padStart(3, '0');
55
+ }
@@ -0,0 +1,205 @@
1
+ // D6 (D-feature-lifecycle): everything beyond `feature init` -- list/show/rename/link/archive
2
+ // over specs/<feature_id>/ and .sbf/feature-index.json. See DECISIONS.md for the full rename
3
+ // blast-radius grounding and why `link` is index-only, never a state merge.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
7
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
8
+ import { specDir } from './paths.mjs';
9
+ import { statePath, historyPath } from './state.mjs';
10
+ import { loadManifest, saveManifest } from './handles-manifest.mjs';
11
+
12
+ const FEATURE_SCHEMA = 'sbf.feature/1';
13
+ const FEATURE_INDEX_SCHEMA = 'sbf.feature-index/1';
14
+
15
+ export function featureIndexPath(root) {
16
+ return path.join(root, '.sbf', 'feature-index.json');
17
+ }
18
+
19
+ export function loadFeatureIndex(root) {
20
+ const file = featureIndexPath(root);
21
+ const parsed = readJsonIfExists(file);
22
+ if (!parsed) return { schema: FEATURE_INDEX_SCHEMA, by_uid: {} };
23
+ if (parsed.schema !== FEATURE_INDEX_SCHEMA) {
24
+ throw new Error(`${file}: unrecognized feature-index schema "${parsed.schema}" (expected ${FEATURE_INDEX_SCHEMA})`);
25
+ }
26
+ const { ok, errors } = validateAgainstSchema('feature-index.schema.json', parsed);
27
+ if (!ok) {
28
+ throw new Error(`${file}: does not match schemas/feature-index.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
29
+ }
30
+ return parsed;
31
+ }
32
+
33
+ export function saveFeatureIndex(root, index) {
34
+ const { ok, errors } = validateAgainstSchema('feature-index.schema.json', index);
35
+ if (!ok) {
36
+ throw new Error(`refusing to write an invalid feature-index record:\n${formatSchemaErrors(errors).join('\n')}`);
37
+ }
38
+ writeFileAtomic(featureIndexPath(root), `${JSON.stringify(index, null, 2)}\n`);
39
+ }
40
+
41
+ function featureFilePath(root, featureId) {
42
+ return path.join(specDir(root, featureId), 'feature.json');
43
+ }
44
+
45
+ export function loadFeatureFile(root, featureId) {
46
+ const file = featureFilePath(root, featureId);
47
+ const parsed = readJsonIfExists(file);
48
+ if (!parsed) return null;
49
+ if (parsed.schema !== FEATURE_SCHEMA) {
50
+ throw new Error(`${file}: unrecognized feature schema "${parsed.schema}" (expected ${FEATURE_SCHEMA})`);
51
+ }
52
+ const { ok, errors } = validateAgainstSchema('feature.schema.json', parsed);
53
+ if (!ok) {
54
+ throw new Error(`${file}: does not match schemas/feature.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
55
+ }
56
+ return parsed;
57
+ }
58
+
59
+ export function saveFeatureFile(root, featureId, record) {
60
+ const { ok, errors } = validateAgainstSchema('feature.schema.json', record);
61
+ if (!ok) {
62
+ throw new Error(`refusing to write an invalid feature record for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
63
+ }
64
+ writeFileAtomic(featureFilePath(root, featureId), `${JSON.stringify(record, null, 2)}\n`);
65
+ }
66
+
67
+ // Scans specs/*/feature.json directly, not the index -- by_uid never held more than one id per
68
+ // uid until this item, so it was never a "list every feature" source (lib/workflow.mjs's own
69
+ // comment already established this). O6-style determinism: sorted by feature_id.
70
+ export function listFeatures(root, { includeArchived = false } = {}) {
71
+ const specsRoot = path.join(root, 'specs');
72
+ if (!fs.existsSync(specsRoot)) return [];
73
+ const ids = fs.readdirSync(specsRoot, { withFileTypes: true })
74
+ .filter((d) => d.isDirectory())
75
+ .map((d) => d.name)
76
+ .sort();
77
+ const records = [];
78
+ for (const id of ids) {
79
+ let record;
80
+ try {
81
+ record = loadFeatureFile(root, id);
82
+ } catch {
83
+ continue; // a corrupt/foreign feature.json shouldn't take down `feature list` -- bskel status already surfaces read errors for the feature a user actually asked about
84
+ }
85
+ if (!record) continue; // a specs/ dir with no feature.json isn't a real feature
86
+ if (record.archived_at && !includeArchived) continue;
87
+ records.push(record);
88
+ }
89
+ return records;
90
+ }
91
+
92
+ export function currentFeatureIdForUid(index, uid) {
93
+ const ids = index.by_uid[uid];
94
+ return ids && ids.length > 0 ? ids[ids.length - 1] : null;
95
+ }
96
+
97
+ export function uidForFeatureId(index, featureId) {
98
+ for (const [uid, ids] of Object.entries(index.by_uid)) {
99
+ if (ids.includes(featureId)) return uid;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ // True if `id` is already a real specs/ directory OR already appears anywhere in the index
105
+ // (including a retired id from an earlier rename) -- a rename target must collide with neither.
106
+ export function featureIdInUse(root, index, id) {
107
+ return fs.existsSync(specDir(root, id)) || uidForFeatureId(index, id) !== null;
108
+ }
109
+
110
+ function rewriteFeatureIdField(jsonPath, oldId, newId) {
111
+ const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
112
+ if (parsed.feature_id !== oldId) return;
113
+ parsed.feature_id = newId;
114
+ writeFileAtomic(jsonPath, `${JSON.stringify(parsed, null, 2)}\n`);
115
+ }
116
+
117
+ // D6: the full migration a rename needs -- every featureId-keyed persisted artifact, traced by
118
+ // direct exploration before writing this, not assumed: specs/<id>/ (the whole directory, plus 3
119
+ // featureId-PREFIXED filenames inside contracts/ -- brownfield-scan.{json,md} use a fixed name,
120
+ // not prefixed, so they move for free with the directory rename and need no rename here),
121
+ // .sbf/<id>.json (filename AND its own feature_id field), .sbf/<id>.history.jsonl (filename
122
+ // only -- gate-event lines never carry feature_id), .sbf/handles-manifest.json's resolver-entry
123
+ // `owner` fields (`owner:'_repo'` infra entries are untouched -- not a feature id at all).
124
+ //
125
+ // Deliberately does NOT rewrite already-generated application code (a resolver .java/.py file's
126
+ // own doc-comment keeps the OLD feature id baked in, and specs/<id>/handles/migration.sql keeps
127
+ // the old id in its rendered SQL) -- cosmetic staleness, not a safety issue: classifyFile()'s
128
+ // real conflict check is the manifest's content-hash, which IS updated here, and rewriting
129
+ // already-generated files outside the normal emit path is exactly the class of thing this
130
+ // project has repeatedly chosen not to do (D-migration-scope, D-config-patch).
131
+ export function renameFeatureArtifacts(root, oldId, newId) {
132
+ const oldDir = specDir(root, oldId);
133
+ const newDir = specDir(root, newId);
134
+ fs.renameSync(oldDir, newDir);
135
+
136
+ // Rename featureId-prefixed filenames under contracts/ -- FILENAME ONLY, content is left
137
+ // byte-identical on purpose. Found live, not designed in from the start: an early draft also
138
+ // rewrote each file's own `feature_id` field, and a real `handles emit --check` against the
139
+ // just-renamed id immediately reported the contract gate as stale -- lib/gate-definitions.mjs's
140
+ // contract/handles gates hash these files' FULL CONTENT for their token (contract_hash/
141
+ // resolution_hash/openapi_snapshot_hash), so rewriting even one field inside them silently
142
+ // invalidates an already-passed gate's stored token, forcing a phantom re-verification of
143
+ // content that never actually changed. Same "cosmetic staleness accepted" principle already
144
+ // applied to a resolver's own doc-comment and migration.sql -- extended here to every
145
+ // gate-token-hashed artifact, not just already-generated application code.
146
+ const contractsDir = path.join(newDir, 'contracts');
147
+ if (fs.existsSync(contractsDir)) {
148
+ for (const name of fs.readdirSync(contractsDir)) {
149
+ if (!name.startsWith(`${oldId}.`)) continue;
150
+ fs.renameSync(path.join(contractsDir, name), path.join(contractsDir, newId + name.slice(oldId.length)));
151
+ }
152
+ }
153
+ // brownfield-scan.{json,md} use a fixed name (not featureId-prefixed) -- they move for free
154
+ // with the directory rename above and are, for the identical reason, never rewritten either
155
+ // (scan_report_hash hashes brownfield-scan.json's full content too).
156
+
157
+ // feature.json is the one specs/ artifact that is NOT hashed as input to any gate token --
158
+ // it's the authoritative CURRENT-identity record, safe (and correct) to rewrite in place.
159
+ rewriteFeatureIdField(path.join(newDir, 'feature.json'), oldId, newId);
160
+
161
+ const oldStatePath = statePath(root, oldId);
162
+ if (fs.existsSync(oldStatePath)) {
163
+ const newStatePath = statePath(root, newId);
164
+ fs.renameSync(oldStatePath, newStatePath);
165
+ rewriteFeatureIdField(newStatePath, oldId, newId);
166
+ }
167
+ const oldHistoryPath = historyPath(root, oldId);
168
+ if (fs.existsSync(oldHistoryPath)) {
169
+ fs.renameSync(oldHistoryPath, historyPath(root, newId));
170
+ }
171
+
172
+ const manifest = loadManifest(root);
173
+ let manifestChanged = false;
174
+ for (const entry of Object.values(manifest.files)) {
175
+ if (entry.owner === oldId) {
176
+ entry.owner = newId;
177
+ manifestChanged = true;
178
+ }
179
+ }
180
+ if (manifestChanged) saveManifest(root, manifest);
181
+ }
182
+
183
+ // D6: soft-delete only -- sets archived_at/archived_reason on feature.json in place, no
184
+ // filesystem move. Every other command still works unmodified against an archived feature if a
185
+ // human explicitly targets it; only listFeatures()'s default view hides it.
186
+ export function archiveFeature(root, featureId, reason) {
187
+ const record = loadFeatureFile(root, featureId);
188
+ if (!record) return null;
189
+ const updated = { ...record, archived_at: new Date().toISOString(), archived_reason: reason };
190
+ saveFeatureFile(root, featureId, updated);
191
+ return updated;
192
+ }
193
+
194
+ // D6: index-only -- records that `aliasId` (its own feature, its own feature_uid, created via
195
+ // its own `feature init`) should be treated as an alias for `keepId` going forward. Deliberately
196
+ // separate from by_uid (which tracks ONE feature_uid's own rename history) -- aliasId's uid is
197
+ // genuinely different from keepId's, this is a cross-reference, not a rename record. Does NOT
198
+ // touch aliasId's own specs/.sbf/ artifacts or attempt to merge scan/contract/handles state --
199
+ // genuinely ambiguous which side should win, the same never-auto-resolve-ambiguity discipline
200
+ // D-config-patch already established for config patching. A human decides what to do with the
201
+ // two features' actual content; this only records the cross-reference.
202
+ export function linkFeature(index, keepId, aliasId) {
203
+ index.merged_into = { ...(index.merged_into ?? {}), [aliasId]: keepId };
204
+ return index;
205
+ }
package/lib/fsutil.mjs ADDED
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ export function sha256File(filePath) {
6
+ if (!fs.existsSync(filePath)) return null;
7
+ return createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
8
+ }
9
+
10
+ // S6 (D-verify-integrity): sha256File's own permission-bit sibling -- content hashing is
11
+ // deliberately blind to a chmod-only change (bytes are unchanged), so a gate that wants to notice
12
+ // e.g. an executable script losing its executable bit needs a separate fingerprint. Same
13
+ // null-means-missing convention as sha256File, for the same reason (a caller checking gate
14
+ // staleness treats "gone" and "changed" the same way).
15
+ export function fileMode(filePath) {
16
+ if (!fs.existsSync(filePath)) return null;
17
+ return (fs.statSync(filePath).mode & 0o777).toString(8);
18
+ }
19
+
20
+ export function sha256String(content) {
21
+ return createHash('sha256').update(content).digest('hex');
22
+ }
23
+
24
+ export function readJsonIfExists(filePath) {
25
+ if (!fs.existsSync(filePath)) return null;
26
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
27
+ }
28
+
29
+ // Atomic write (temp + rename), same technique as lib/state.mjs -- reused by every command that
30
+ // writes a durable artifact under specs/<feature_id>/ so a mid-write crash can't leave a
31
+ // half-written file that a later gate check would treat as valid.
32
+ export function writeFileAtomic(filePath, content) {
33
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
+ const tmp = `${filePath}.${process.pid}.tmp`;
35
+ fs.writeFileSync(tmp, content);
36
+ fs.renameSync(tmp, filePath);
37
+ }
38
+
39
+ // S2: non-throwing sibling of stack/apply.mjs's assertContained() -- same containment check
40
+ // (D-security-4's class of defense), but for callers reading a repo-relative path OUT OF untrusted
41
+ // JSON (an O2 handles-manifest entry, a stack.json applied_files entry) where "this path escapes
42
+ // the repo" should be treated as "not a file we generated" and skipped, not a thrown error that
43
+ // would take down a `bskel verify` report over one bad entry.
44
+ export function resolveWithinRoot(root, relPath) {
45
+ const resolvedRoot = path.resolve(root);
46
+ const resolvedTarget = path.resolve(root, relPath);
47
+ const rel = path.relative(resolvedRoot, resolvedTarget);
48
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
49
+ return resolvedTarget;
50
+ }