backend-skeleton 1.7.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,6 +34,7 @@ check for a specific failure mode found the same way — see `DECISIONS.md` for
34
34
  - [Quickstart](#quickstart)
35
35
  - [Try it in 10 seconds](#try-it-in-10-seconds)
36
36
  - [The gated workflow](#the-gated-workflow)
37
+ - [Pull-request gate checks](#pull-request-gate-checks)
37
38
  - [Starting from nothing (greenfield)](#starting-from-nothing-greenfield)
38
39
  - [Publishing a feature's contract as OpenAPI (optional)](#publishing-a-features-contract-as-openapi-optional)
39
40
  - [A CSV table of a feature's contract (optional)](#a-csv-table-of-a-features-contract-optional)
@@ -105,6 +106,10 @@ bskel scan # zero flags: every module/controller/entity/e
105
106
  # files written, no gate touched
106
107
  ```
107
108
 
109
+ Rails projects are scanned statically by default. On a trusted Rails checkout, add
110
+ `--runtime-routes` to boot the application and use `bin/rails routes --expanded` as the route
111
+ source; initializers and application boot code will run.
112
+
108
113
  That's a read-only look, not the gated workflow — for real feature work (collision-checked against
109
114
  a specific idea, contract-gated, codegen), see below.
110
115
 
@@ -139,6 +144,45 @@ bskel verify --feature 001-organization-management --build
139
144
  # target repo's own build wrapper (gradlew/mvnw/npm), if present
140
145
  ```
141
146
 
147
+ ### Pull-request gate checks
148
+
149
+ `bskel ci check` is the read-only CI counterpart to `verify`: it computes the merge-base with a
150
+ PR base ref, selects changed active features, and runs the same gate/artifact/build calculation
151
+ and `bskel next` remediation. It never refreshes preflight, passes a gate, or runs remediation.
152
+
153
+ ```bash
154
+ bskel ci check --base origin/main --build \
155
+ --summary-file "$GITHUB_STEP_SUMMARY" \
156
+ --sarif-file bskel.sarif
157
+ ```
158
+
159
+ `--feature 001-a,002-b` overrides automatic selection. Without it, changes confined to one or
160
+ more `specs/<feature-id>/` directories select those active features; a non-document change outside
161
+ feature specs checks all active features; documentation-only diffs are an explicit successful
162
+ no-op. `--json` writes exactly one report document even when checks fail. SARIF generation only
163
+ creates a file; uploading it is your workflow's policy.
164
+
165
+ The repository root is also a composite action:
166
+
167
+ ```yaml
168
+ - uses: actions/checkout@v4
169
+ with:
170
+ fetch-depth: 0 # ci check needs the PR base and merge-base locally
171
+ - uses: popixoxipop-collab/backend-skeleton@main
172
+ id: bskel
173
+ with:
174
+ build: 'true'
175
+
176
+ # Optional: the action output is only a local file. Uploading requires this explicit policy.
177
+ - uses: github/codeql-action/upload-sarif@v3
178
+ if: always() && steps.bskel.outputs.sarif-file != ''
179
+ with:
180
+ sarif_file: ${{ steps.bskel.outputs.sarif-file }}
181
+ ```
182
+
183
+ Use this action only on GitHub-hosted or otherwise trusted runners for pull requests; it executes
184
+ the checked-out repository's configured build command when `build: 'true'` is selected.
185
+
142
186
  `bskel status`/`bskel next` are what you actually run over and over — real output, captured against
143
187
  a fixture repo partway through the flow above, not written by hand:
144
188
 
@@ -595,6 +639,11 @@ string for anything missing.
595
639
  `D-adapter-registry` in `DECISIONS.md`):
596
640
  - `java-spring` — Spring Boot (`build.gradle`/`pom.xml` + `src/main/java`). Full capability set:
597
641
  operation extraction, request-body detection, and a real codegen provider for `handles emit`.
642
+ - `ruby-rails` — Rails 8 (`Gemfile` + `config/application.rb` + `config/routes.rb`). Statically
643
+ expands conventional routes/resources and extracts ActiveRecord table/primary-key metadata.
644
+ Operation ids are deterministic bskel syntheses, not source declarations; `--runtime-routes`
645
+ explicitly boots the trusted application for authoritative framework routing. Scanner only:
646
+ request-shape extraction and handles codegen are not supported.
598
647
  - `python-fastapi` — FastAPI + SQLModel. Real codegen provider for `handles emit`; contract-grade
599
648
  operation extraction is not supported (FastAPI generates operation ids at runtime) — pass a real
600
649
  OpenAPI document via `--openapi-file` for a trustworthy contract.
package/action.yml ADDED
@@ -0,0 +1,76 @@
1
+ name: backend-skeleton PR check
2
+ description: Run read-only backend-skeleton feature gates for the changed pull-request surface.
3
+ inputs:
4
+ base:
5
+ description: Base commit or ref. Defaults to the GitHub PR base SHA/ref, then origin/HEAD.
6
+ required: false
7
+ feature:
8
+ description: Optional comma-separated active feature IDs; overrides changed-file selection.
9
+ required: false
10
+ build:
11
+ description: Run the existing bskel build check for every selected feature.
12
+ required: false
13
+ default: 'false'
14
+ allow-skip-build:
15
+ description: Permit --build when no recognized build tool exists.
16
+ required: false
17
+ default: 'false'
18
+ sarif:
19
+ description: Emit a SARIF 2.1.0 file. Upload policy remains the caller's responsibility.
20
+ required: false
21
+ default: 'true'
22
+ outputs:
23
+ result:
24
+ description: One of pass, fail, or noop.
25
+ value: ${{ steps.check.outputs.result }}
26
+ sarif-file:
27
+ description: Absolute path to the generated SARIF file when sarif=true; empty otherwise.
28
+ value: ${{ steps.check.outputs.sarif-file }}
29
+ runs:
30
+ using: composite
31
+ steps:
32
+ - name: Install action dependencies
33
+ shell: bash
34
+ run: npm ci --ignore-scripts --prefix "$GITHUB_ACTION_PATH"
35
+ - id: check
36
+ shell: bash
37
+ env:
38
+ INPUT_BASE: ${{ inputs.base }}
39
+ INPUT_FEATURE: ${{ inputs.feature }}
40
+ INPUT_BUILD: ${{ inputs.build }}
41
+ INPUT_ALLOW_SKIP_BUILD: ${{ inputs.allow-skip-build }}
42
+ INPUT_SARIF: ${{ inputs.sarif }}
43
+ EVENT_NAME: ${{ github.event_name }}
44
+ RUNNER_ENVIRONMENT: ${{ runner.environment }}
45
+ run: |
46
+ set -uo pipefail
47
+ if [ "$EVENT_NAME" = "pull_request" ] && [ "$RUNNER_ENVIRONMENT" = "self-hosted" ]; then
48
+ printf '%s\n' '::error title=backend-skeleton PR check::Refusing untrusted pull_request code on a self-hosted runner.'
49
+ exit 1
50
+ fi
51
+ summary_file="$RUNNER_TEMP/bskel-pr-check-summary.md"
52
+ result_file="$RUNNER_TEMP/bskel-pr-check-result.json"
53
+ sarif_file="$RUNNER_TEMP/bskel-pr-check.sarif"
54
+ args=(ci check --summary-file "$summary_file" --json)
55
+ if [ -n "$INPUT_BASE" ]; then args+=(--base "$INPUT_BASE"); fi
56
+ if [ -n "$INPUT_FEATURE" ]; then args+=(--feature "$INPUT_FEATURE"); fi
57
+ if [ "$INPUT_BUILD" = "true" ]; then args+=(--build); fi
58
+ if [ "$INPUT_ALLOW_SKIP_BUILD" = "true" ]; then args+=(--allow-skip-build); fi
59
+ if [ "$INPUT_SARIF" = "true" ]; then args+=(--sarif-file "$sarif_file"); else sarif_file=''; fi
60
+
61
+ set +e
62
+ node "$GITHUB_ACTION_PATH/bin/bskel.mjs" "${args[@]}" > "$result_file"
63
+ status=$?
64
+ set -e
65
+
66
+ cat "$summary_file" >> "$GITHUB_STEP_SUMMARY"
67
+ result="$(node -e 'const fs=require("fs"); console.log(JSON.parse(fs.readFileSync(process.argv[1], "utf8")).outcome || "fail")' "$result_file")"
68
+ printf 'result=%s\n' "$result" >> "$GITHUB_OUTPUT"
69
+ printf 'sarif-file<<EOF\n%s\nEOF\n' "$sarif_file" >> "$GITHUB_OUTPUT"
70
+
71
+ if [ "$status" -ne 0 ]; then
72
+ # Keep workflow-command data fixed: no changed filename, diagnostic, or user input is
73
+ # interpolated here. This avoids command injection through %/CR/LF in untrusted paths.
74
+ printf '%s\n' '::error title=backend-skeleton PR check::Blocking bskel issues found; see the job summary.'
75
+ exit "$status"
76
+ fi
package/bin/bskel.mjs CHANGED
@@ -80,7 +80,8 @@ import { plan as planPythonFastApi } from '../handles/providers/python-fastapi/p
80
80
  import { emitObservePythonFastApi } from '../handles/providers/python-fastapi/observe.mjs';
81
81
  import { plan as planTypeScriptExpress } from '../handles/providers/typescript-express/plan.mjs';
82
82
  import { emitObserveTypeScriptExpress } from '../handles/providers/typescript-express/observe.mjs';
83
- import { collectGateStatuses, runBuildCheck, checkArtifacts, checkResolverConflicts } from '../lib/verify.mjs';
83
+ import { evaluateFeatureVerification } from '../lib/verify.mjs';
84
+ import { runCiCheck, renderCiSummary, toSarif } from '../lib/ci-check.mjs';
84
85
  import { computeWorkflowState } from '../lib/workflow.mjs';
85
86
  import { computeDoctorChecks, WORKFLOWS as DOCTOR_WORKFLOWS, binaryAvailable } from '../lib/doctor.mjs';
86
87
  import { parseCommand, renderCommandHelp, diagnostic } from '../lib/cli.mjs';
@@ -98,7 +99,7 @@ function usage() {
98
99
  bskel pattern show <pattern_id> --pattern-database-url-env <NAME> [--json]
99
100
  bskel pattern suggest --stack spring|fastapi --pattern-database-url-env <NAME> [--json]
100
101
  bskel preflight [--max-behind N] [--offline|--no-fetch] [--allow-dirty] [--max-age-minutes N] [--fetch-timeout-seconds N] [--json]
101
- bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
102
+ bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--runtime-routes] [--db [--database-url-env <NAME>] [--schema public]]
102
103
  bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
103
104
  bskel scan explain <module> --feature <id> [--json]
104
105
  bskel scan repair --feature <id> [--json]
@@ -120,6 +121,7 @@ function usage() {
120
121
  bskel contract tool-schema --feature <id> --operation <operationId>
121
122
  bskel contract waive --feature <id> --code <CODE> (--subject "VERB /path"|--all) --reason "..." [--expires <Nd>]
122
123
  bskel contract unwaive --feature <id> --code <CODE> --subject "VERB /path" --reason "..." [--json]
124
+ bskel ci check [--base <ref>] [--feature <id[,id...]>] [--build] [--allow-skip-build] [--summary-file <path>] [--sarif-file <path>] [--json]
123
125
  bskel dependency declare --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..." [--memo "..."]
124
126
  bskel dependency remove --feature <id> --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."
125
127
  bskel dependency list --feature <id> [--json]
@@ -711,8 +713,10 @@ async function cmdScan(args) {
711
713
  }
712
714
  let report;
713
715
  try {
714
- report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable });
716
+ report = runScan({ repoRoot: root, terms, includeDb: flags.db, dbSchema, rgAvailable, runtimeRoutes: flags['runtime-routes'] });
715
717
  } catch (err) {
718
+ if (err.code === 'RUNTIME_ROUTES_UNSUPPORTED') fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', err.message);
719
+ if (err.code === 'RUNTIME_ROUTES_FAILED') fail(EXIT_CODES.REFRESH_FAILED, 'REFRESH_FAILED', err.message);
716
720
  // Unreachable with the two shipped adapters (generic-grep's specificity-0 detect() is
717
721
  // unconditional) -- becomes reachable the moment a future adapter's detect() is
718
722
  // conditional, or two adapters tie at the same specificity. See scanners/index.mjs.
@@ -4273,15 +4277,9 @@ function cmdVerify(args) {
4273
4277
  if (flags.help) { console.log(renderCommandHelp('verify')); process.exit(0); }
4274
4278
  setContext('verify', flags);
4275
4279
  const root = requireRepoRoot();
4276
- const gates = collectGateStatuses(root, flags.feature, { getGate, requireNamedGate });
4277
- const artifacts = checkArtifacts(root, flags.feature, gates);
4278
- const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
4279
- const conflicts = checkResolverConflicts(root, flags.feature, handlesRan);
4280
- const build = flags.build ? runBuildCheck(root) : null;
4281
4280
  const allowSkipBuild = flags['allow-skip-build'];
4282
-
4283
- const gatesOk = gates.every((g) => !g.blocking);
4284
- const artifactsPresent = artifacts.every((a) => a.exists);
4281
+ const verification = evaluateFeatureVerification(root, flags.feature, { build: flags.build, allowSkipBuild });
4282
+ const { gates, artifacts, conflicts, build, pass: overallPass } = verification;
4285
4283
  // S6 (D-verify-integrity): `conflicts` is deliberately NON-BLOCKING, same "detect and warn,
4286
4284
  // never gate" precedent as A1 §7's path-prefix signals and A4's DB drift reporting -- and the
4287
4285
  // SAME reasoning D-gate-precision (S2) already used to keep generated content OUT of the
@@ -4295,9 +4293,6 @@ function cmdVerify(args) {
4295
4293
  // used to be silently treated as "doesn't block" -- confirmed live that this let `bskel verify
4296
4294
  // --build` report an overall PASS even though the build assurance the user explicitly asked
4297
4295
  // for never actually ran. Now only acceptable with the explicit --allow-skip-build opt-out.
4298
- const buildOk = !build || build.ok || (!build.ran && allowSkipBuild);
4299
- const overallPass = gatesOk && artifactsPresent && buildOk;
4300
-
4301
4296
  if (flags.json) {
4302
4297
  console.log(JSON.stringify({ feature: flags.feature, pass: overallPass, gates, artifacts, conflicts, build }, null, 2));
4303
4298
  } else if (!flags.quiet) {
@@ -4309,6 +4304,44 @@ function cmdVerify(args) {
4309
4304
  process.exit(overallPass ? 0 : 1);
4310
4305
  }
4311
4306
 
4307
+ function cmdCiCheck(args) {
4308
+ const flags = parseCommand('ci check', args);
4309
+ if (flags.help) { console.log(renderCommandHelp('ci check')); process.exit(0); }
4310
+ setContext('ci check', flags);
4311
+ const root = requireRepoRoot();
4312
+ const report = runCiCheck(root, {
4313
+ base: flags.base,
4314
+ feature: flags.feature,
4315
+ build: flags.build,
4316
+ allowSkipBuild: flags['allow-skip-build'],
4317
+ });
4318
+ if (report.badBase || report.badArgs) {
4319
+ if (flags['summary-file']) writeFileAtomic(path.resolve(process.cwd(), flags['summary-file']), renderCiSummary({ ...report, outcome: 'error' }));
4320
+ if (flags['sarif-file']) writeFileAtomic(path.resolve(process.cwd(), flags['sarif-file']), `${JSON.stringify(toSarif(report), null, 2)}\n`);
4321
+ fail(EXIT_CODES.BAD_ARGS, report.badBase ? 'BAD_BASE' : 'BAD_ARGS', report.message);
4322
+ }
4323
+ if (flags['summary-file']) {
4324
+ writeFileAtomic(path.resolve(process.cwd(), flags['summary-file']), renderCiSummary(report));
4325
+ }
4326
+ if (flags['sarif-file']) {
4327
+ writeFileAtomic(path.resolve(process.cwd(), flags['sarif-file']), `${JSON.stringify(toSarif(report), null, 2)}\n`);
4328
+ }
4329
+ if (flags.json) {
4330
+ console.log(JSON.stringify(report, null, 2));
4331
+ } else if (!flags.quiet) {
4332
+ console.log(`CI CHECK: ${report.outcome.toUpperCase()} — ${report.selected_features.length} feature(s), ${report.changed_file_count} changed file(s) [${report.selection_reason}]`);
4333
+ for (const feature of report.features) {
4334
+ console.log(`- [${feature.pass ? 'PASS' : 'FAIL'}] ${feature.feature}${feature.remediation ? ` — next: ${feature.remediation.command}` : ''}`);
4335
+ }
4336
+ if (report.outcome === 'noop') console.log('- no active feature requires verification');
4337
+ if (report.issues.length > 0) console.log(`- blocking issues: ${report.issues.length}`);
4338
+ }
4339
+ // CI reports can include a large changed-file list and many feature reports. Do not force an
4340
+ // immediate process exit after writing JSON: Node may truncate a still-buffered pipe, violating
4341
+ // the command's one-document JSON contract. Let the event loop flush like contract export does.
4342
+ process.exitCode = report.ok ? EXIT_CODES.OK : EXIT_CODES.CHECK_FAILED;
4343
+ }
4344
+
4312
4345
  // D1: same per-gate line shape renderVerifyReport uses (reusing describeStale), but framed as
4313
4346
  // "where am I" rather than a pass/fail verdict -- no VERIFY: PASS/FAIL line, and blocked_by/
4314
4347
  // next_actions/optional_not_run are appended so a human doesn't have to re-derive them by eye.
@@ -4991,6 +5024,11 @@ async function dispatchCommand(cmd, rest) {
4991
5024
  case 'verify':
4992
5025
  cmdVerify(rest);
4993
5026
  break;
5027
+ case 'ci':
5028
+ if (rest[0] === 'check') return cmdCiCheck(rest.slice(1));
5029
+ usage();
5030
+ process.exit(14);
5031
+ break;
4994
5032
  case 'status':
4995
5033
  cmdStatus(rest);
4996
5034
  break;
@@ -135,7 +135,7 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
135
135
  let operationId = ep.operationId;
136
136
  let verb = ep.verb;
137
137
  let route = ep.path;
138
- let provenance = 'scan';
138
+ let provenance = ep.operationIdSource === 'bskel-synthesized' ? 'scan-synthesized' : 'scan';
139
139
  let openapiAttempted = false;
140
140
  let openapiReason = null;
141
141
  // A2/A3: only ever set for matched/adopted (contracts/openapi.mjs's applyRequestBodySchema/
@@ -225,7 +225,7 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
225
225
  descriptionUnresolvedReason = res.descriptionUnresolvedReason ?? null;
226
226
  warnings.push(makeWarning('CONTRACT_OPENAPI_DERIVED_OPERATION_ID', {
227
227
  subject: operationId,
228
- message: `operationId "${operationId}" for ${res.verb} ${res.path} was not found in the source (no @Operation(operationId=...)) -- adopted directly from the OpenAPI document instead`,
228
+ message: `operationId "${operationId}" for ${res.verb} ${res.path} was not source-pinned by the scanner -- adopted directly from the OpenAPI document instead`,
229
229
  detail: { verb: res.verb, path: res.path, scan_verb: ep.verb, scan_path: ep.path },
230
230
  }));
231
231
  break;
@@ -263,6 +263,13 @@ export function buildContract({ featureId, featureUid, scanReport, module: modul
263
263
  // recording that OpenAPI reconciliation was attempted and why it didn't help.
264
264
  openapiAttempted = true;
265
265
  openapiReason = res.reason;
266
+ if (ep.operationIdSource === 'bskel-synthesized') {
267
+ warnings.push(makeWarning('CONTRACT_OPENAPI_MISSING_OPERATION', {
268
+ subject: ep.operationId,
269
+ message: `bskel-synthesized operationId "${ep.operationId}" (${ep.verb} ${ep.path}) could not be reconciled to a unique OpenAPI operation (${res.reason}) -- keeping the synthesized id and scan path`,
270
+ detail: { verb: ep.verb, path: ep.path, reason: res.reason },
271
+ }));
272
+ }
266
273
  break;
267
274
  default:
268
275
  break;
@@ -1768,7 +1768,7 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1768
1768
  const anchorDeltas = [];
1769
1769
  for (const controller of module.controllers) {
1770
1770
  for (const ep of controller.endpoints) {
1771
- if (!ep.operationId) continue;
1771
+ if (!ep.operationId || ep.operationIdSource === 'bskel-synthesized') continue;
1772
1772
  const docEntry = index.byOperationId.get(ep.operationId);
1773
1773
  if (!docEntry || docEntry.verb !== ep.verb) continue; // verb mismatch => not a safe anchor, surfaces as drift below
1774
1774
  const delta = computeDelta(ep.path, docEntry.path);
@@ -1828,7 +1828,7 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1828
1828
  const key = endpointKey(ci, ei);
1829
1829
  let result;
1830
1830
 
1831
- if (ep.operationId) {
1831
+ if (ep.operationId && ep.operationIdSource !== 'bskel-synthesized') {
1832
1832
  const docEntry = index.byOperationId.get(ep.operationId);
1833
1833
  if (!docEntry) {
1834
1834
  result = { kind: 'missing', scanVerb: ep.verb, scanPath: ep.path };
@@ -1868,14 +1868,18 @@ export function reconcileModule({ index, module, pathPrefix = null, includeDescr
1868
1868
  stats.drift++;
1869
1869
  }
1870
1870
  }
1871
- } else if (prefix.value == null) {
1872
- result = { kind: 'unresolved', reason: 'prefix-inconclusive', scanVerb: ep.verb, scanPath: ep.path };
1873
- stats.unresolved++;
1874
1871
  } else {
1875
- const candidates = prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
1872
+ // An explicitly bskel-synthesized operation id is useful when no source document
1873
+ // exists, but it must never be treated as if an OpenAPI producer authored the same
1874
+ // identifier. Reconcile it exactly like an unpinned endpoint: route first, then adopt
1875
+ // the document's own operationId. Exact-path matching remains safe even when no
1876
+ // prefix could be inferred; a caller only needs --path-prefix when exact matching fails.
1877
+ const candidates = prefix.value == null
1878
+ ? [ep.path]
1879
+ : prefix.value === '' ? [ep.path] : [...new Set([prefix.value + ep.path, ep.path])];
1876
1880
  const hits = candidates.flatMap((c) => index.byRoute.get(`${ep.verb} ${canonicalRouteShape(c)}`) ?? []);
1877
1881
  if (hits.length === 0) {
1878
- result = { kind: 'unresolved', reason: 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
1882
+ result = { kind: 'unresolved', reason: prefix.value == null ? 'prefix-inconclusive' : 'no-candidate', scanVerb: ep.verb, scanPath: ep.path };
1879
1883
  stats.unresolved++;
1880
1884
  } else if (hits.length === 1 && hits[0].operationId) {
1881
1885
  result = {
@@ -0,0 +1,195 @@
1
+ // PR-native, read-only verification. This is intentionally a composition layer over the
2
+ // existing verify/workflow primitives: it chooses features from a git diff, then reports their
3
+ // normal gate/artifact/build verdicts and the normal `bskel next` remediation. It never passes,
4
+ // refreshes, or otherwise changes a gate.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { localDefaultBranch, headSha } from './repo.mjs';
9
+ import { listFeatures } from './featurelifecycle.mjs';
10
+ import { evaluateFeatureVerification } from './verify.mjs';
11
+ import { computeWorkflowState } from './workflow.mjs';
12
+ import { requireValidFeatureId } from './featureid.mjs';
13
+
14
+ function git(root, args) {
15
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
16
+ }
17
+
18
+ function tryGit(root, args) {
19
+ try { return git(root, args); } catch { return null; }
20
+ }
21
+
22
+ export function defaultCiBase(root, env = process.env) {
23
+ // GitHub gives the immutable PR base SHA when available; it wins over a movable branch name.
24
+ // `GITHUB_BASE_REF` is useful on minimally configured CI and local `origin/HEAD` is the
25
+ // deterministic non-CI fallback. We deliberately never assume `main`.
26
+ let eventBaseSha = null;
27
+ if (env.GITHUB_EVENT_PATH) {
28
+ try { eventBaseSha = JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8'))?.pull_request?.base?.sha ?? null; } catch { /* the env variable is advisory */ }
29
+ }
30
+ return env.GITHUB_EVENT_PULL_REQUEST_BASE_SHA
31
+ || eventBaseSha
32
+ || env.GITHUB_BASE_REF
33
+ || localDefaultBranch(root)
34
+ || null;
35
+ }
36
+
37
+ export function resolveCiBase(root, requestedBase, env = process.env) {
38
+ const requested = requestedBase || defaultCiBase(root, env);
39
+ if (!requested) {
40
+ return { ok: false, message: 'could not determine a base ref: pass --base <ref>, set GITHUB_EVENT_PULL_REQUEST_BASE_SHA/GITHUB_BASE_REF, or configure origin/HEAD (bskel never guesses main)' };
41
+ }
42
+ const sha = tryGit(root, ['rev-parse', '--verify', '--quiet', `${requested}^{commit}`])
43
+ || (!String(requested).startsWith('origin/') ? tryGit(root, ['rev-parse', '--verify', '--quiet', `origin/${requested}^{commit}`]) : null);
44
+ if (!sha) return { ok: false, message: `cannot resolve base ref "${requested}" locally -- fetch it first or pass an existing commit/ref with --base` };
45
+ return { ok: true, requested, sha };
46
+ }
47
+
48
+ function changedFiles(root, mergeBase) {
49
+ try {
50
+ const raw = execFileSync('git', ['diff', '--name-only', '-z', `${mergeBase}...HEAD`], { cwd: root, encoding: 'utf8' });
51
+ return raw.split('\0').filter(Boolean).sort((a, b) => a.localeCompare(b));
52
+ } catch (err) {
53
+ throw new Error(`could not list changed files from ${mergeBase}...HEAD: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ function isDocumentationPath(file) {
58
+ return file === 'README.md'
59
+ || file === 'SKILL.md'
60
+ || file === 'DECISIONS.md'
61
+ || file === 'ROADMAP.md'
62
+ || file === 'CATALOG.md'
63
+ || file === 'LICENSE'
64
+ || file === 'COMMERCIAL-LICENSE.md'
65
+ || file === 'COMMERCIAL-LICENSE-AGREEMENT.md'
66
+ || file.startsWith('docs/');
67
+ }
68
+
69
+ function featureIdForPath(file, activeIds) {
70
+ const match = /^specs\/([^/]+)\//.exec(file);
71
+ return match && activeIds.has(match[1]) ? match[1] : null;
72
+ }
73
+
74
+ export function selectCiFeatures({ activeFeatures, changed, requestedFeatures = null }) {
75
+ const activeIds = activeFeatures.map((f) => f.feature_id).sort((a, b) => a.localeCompare(b));
76
+ const activeSet = new Set(activeIds);
77
+ if (requestedFeatures !== null) {
78
+ const requested = [...new Set(requestedFeatures)].sort((a, b) => a.localeCompare(b));
79
+ const unknown = requested.filter((id) => !activeSet.has(id));
80
+ if (unknown.length) throw new Error(`--feature names no active feature: ${unknown.join(', ')} (active: ${activeIds.join(', ') || '(none)'})`);
81
+ return { selectedFeatures: requested, selectionReason: 'explicit_feature' };
82
+ }
83
+
84
+ const scoped = new Set();
85
+ let sharedProductChange = false;
86
+ for (const file of changed) {
87
+ const featureId = featureIdForPath(file, activeSet);
88
+ if (featureId) scoped.add(featureId);
89
+ else if (!isDocumentationPath(file)) sharedProductChange = true;
90
+ }
91
+ if (sharedProductChange) return { selectedFeatures: activeIds, selectionReason: 'shared_product_change_all_active' };
92
+ if (scoped.size > 0) return { selectedFeatures: [...scoped].sort((a, b) => a.localeCompare(b)), selectionReason: 'feature_scoped_changes' };
93
+ return { selectedFeatures: [], selectionReason: changed.length === 0 ? 'no_changes' : 'documentation_only_or_no_active_feature_change' };
94
+ }
95
+
96
+ function parseFeatureList(value) {
97
+ if (value == null) return null;
98
+ const values = value.split(',').map((v) => v.trim()).filter(Boolean);
99
+ if (values.length === 0) throw new Error('--feature must name at least one comma-separated feature id');
100
+ for (const id of values) requireValidFeatureId(id);
101
+ return values;
102
+ }
103
+
104
+ export function runCiCheck(root, { base = null, feature = null, build = false, allowSkipBuild = false, env = process.env } = {}) {
105
+ const baseResult = resolveCiBase(root, base, env);
106
+ if (!baseResult.ok) return { ok: false, badBase: true, message: baseResult.message };
107
+ const mergeBase = tryGit(root, ['merge-base', baseResult.sha, 'HEAD']);
108
+ if (!mergeBase) return { ok: false, badBase: true, message: `cannot compute a merge-base between ${baseResult.requested} (${baseResult.sha}) and HEAD -- pass a related --base ref` };
109
+ const changed = changedFiles(root, mergeBase);
110
+ const activeFeatures = listFeatures(root);
111
+ let selection;
112
+ try {
113
+ selection = selectCiFeatures({ activeFeatures, changed, requestedFeatures: parseFeatureList(feature) });
114
+ } catch (err) {
115
+ return { ok: false, badArgs: true, message: err.message };
116
+ }
117
+ const common = {
118
+ schema: 'sbf.ci-check/1',
119
+ base: { requested: baseResult.requested, sha: baseResult.sha },
120
+ merge_base: mergeBase,
121
+ head: headSha(root),
122
+ changed_files: changed,
123
+ changed_file_count: changed.length,
124
+ selection_reason: selection.selectionReason,
125
+ selected_features: selection.selectedFeatures,
126
+ };
127
+ if (selection.selectedFeatures.length === 0) {
128
+ return { ...common, ok: true, outcome: 'noop', features: [], issues: [] };
129
+ }
130
+ const features = selection.selectedFeatures.map((featureId) => {
131
+ const verification = evaluateFeatureVerification(root, featureId, { build, allowSkipBuild });
132
+ const workflow = computeWorkflowState(root, featureId);
133
+ return { ...verification, remediation: workflow.next_actions[0] ?? null };
134
+ });
135
+ const issues = collectIssues(features);
136
+ return { ...common, ok: issues.length === 0, outcome: issues.length === 0 ? 'pass' : 'fail', features, issues };
137
+ }
138
+
139
+ function featureLocation(featureId) {
140
+ return `specs/${featureId}/feature.json`;
141
+ }
142
+
143
+ export function collectIssues(features) {
144
+ const issues = [];
145
+ for (const report of features) {
146
+ for (const gate of report.gates.filter((g) => g.blocking)) {
147
+ issues.push({ kind: 'gate', feature: report.feature, name: gate.gate, path: featureLocation(report.feature), message: `${report.feature}: ${gate.gate} gate is ${gate.status}` });
148
+ }
149
+ for (const artifact of report.artifacts.filter((a) => !a.exists)) {
150
+ issues.push({ kind: 'artifact', feature: report.feature, name: artifact.artifact, path: artifact.path, message: `${report.feature}: missing ${artifact.artifact} (${artifact.path})` });
151
+ }
152
+ if (report.build_blocking) {
153
+ issues.push({ kind: 'build', feature: report.feature, name: report.build.tool ?? 'build', path: 'package.json', message: `${report.feature}: ${report.build.message ?? `${report.build.tool} build failed`}` });
154
+ }
155
+ }
156
+ return issues.sort((a, b) => [a.feature, a.kind, a.name, a.path].join('\0').localeCompare([b.feature, b.kind, b.name, b.path].join('\0')));
157
+ }
158
+
159
+ export function renderCiSummary(report) {
160
+ const lines = ['# backend-skeleton PR check', '', `- Outcome: **${report.outcome ?? 'error'}**`];
161
+ if (report.base) lines.push(`- Base: \`${report.base.requested}\` (${report.base.sha})`, `- Merge base: \`${report.merge_base}\``, `- Head: \`${report.head}\``);
162
+ if (report.changed_file_count != null) lines.push(`- Changed files: ${report.changed_file_count}`, `- Selection: \`${report.selection_reason}\``);
163
+ if (report.selected_features) lines.push(`- Selected features: ${report.selected_features.length ? report.selected_features.map((id) => `\`${id}\``).join(', ') : '(none)'}`);
164
+ if (report.outcome === 'noop') return `${lines.concat(['', 'No active feature requires verification for this diff.']).join('\n')}\n`;
165
+ lines.push('', '## Features');
166
+ for (const feature of report.features ?? []) {
167
+ lines.push(`- [${feature.pass ? 'PASS' : 'FAIL'}] \`${feature.feature}\`${feature.remediation ? ` — next: \`${feature.remediation.command}\`` : ''}`);
168
+ }
169
+ if (report.issues?.length) {
170
+ lines.push('', '## Blocking issues');
171
+ for (const issue of report.issues) lines.push(`- \`${issue.feature}\`: ${issue.message}`);
172
+ }
173
+ return `${lines.join('\n')}\n`;
174
+ }
175
+
176
+ export function toSarif(report) {
177
+ const results = (report.issues ?? []).map((issue) => ({
178
+ ruleId: `bskel/${issue.kind}`,
179
+ level: 'error',
180
+ message: { text: issue.message },
181
+ locations: issue.path ? [{ physicalLocation: { artifactLocation: { uri: issue.path.replaceAll(path.sep, '/') } } }] : [],
182
+ }));
183
+ return {
184
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
185
+ version: '2.1.0',
186
+ runs: [{
187
+ tool: { driver: { name: 'backend-skeleton', informationUri: 'https://github.com/popixoxipop-collab/backend-skeleton', rules: [
188
+ { id: 'bskel/gate', shortDescription: { text: 'A required bskel gate is not passing' } },
189
+ { id: 'bskel/artifact', shortDescription: { text: 'A required bskel artifact is missing' } },
190
+ { id: 'bskel/build', shortDescription: { text: 'The requested build check did not pass' } },
191
+ ] } },
192
+ results,
193
+ }],
194
+ };
195
+ }
package/lib/cli.mjs CHANGED
@@ -120,7 +120,7 @@ export const COMMANDS = {
120
120
  },
121
121
  },
122
122
  scan: {
123
- usage: 'bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]',
123
+ usage: 'bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--runtime-routes] [--db [--database-url-env <NAME>] [--schema public]]',
124
124
  options: {
125
125
  feature: { type: 'string', default: null },
126
126
  terms: { type: 'string', default: '' },
@@ -132,6 +132,7 @@ export const COMMANDS = {
132
132
  schema: { type: 'string', default: 'public' },
133
133
  json: { type: 'boolean', default: false },
134
134
  'accept-low-confidence': { type: 'boolean', default: false },
135
+ 'runtime-routes': { type: 'boolean', default: false },
135
136
  },
136
137
  },
137
138
  'scan disposition': {
@@ -770,6 +771,18 @@ export const COMMANDS = {
770
771
  json: { type: 'boolean', default: false },
771
772
  },
772
773
  },
774
+ 'ci check': {
775
+ usage: 'bskel ci check [--base <ref>] [--feature <id[,id...]>] [--build] [--allow-skip-build] [--summary-file <path>] [--sarif-file <path>] [--json]',
776
+ options: {
777
+ base: { type: 'string', default: null },
778
+ feature: { type: 'string', default: null },
779
+ build: { type: 'boolean', default: false },
780
+ 'allow-skip-build': { type: 'boolean', default: false },
781
+ 'summary-file': { type: 'string', default: null },
782
+ 'sarif-file': { type: 'string', default: null },
783
+ json: { type: 'boolean', default: false },
784
+ },
785
+ },
773
786
  status: {
774
787
  usage: 'bskel status [--feature <id>] [--json]',
775
788
  options: {
@@ -819,10 +832,10 @@ export const COMMANDS = {
819
832
  };
820
833
 
821
834
  function describeParseArgsError(err, spec) {
822
- const known = [
835
+ const known = [...new Set([
823
836
  ...Object.keys(spec.options).filter((f) => !spec.options[f].hidden),
824
837
  'help', 'json', 'quiet',
825
- ].sort().map((f) => `--${f}`).join(', ');
838
+ ])].sort().map((f) => `--${f}`).join(', ');
826
839
  return `${err.message}\nusage: ${spec.usage}\nknown flags: ${known}`;
827
840
  }
828
841
 
@@ -887,7 +900,10 @@ export function renderCommandHelp(name) {
887
900
  if (def.required) parts.push('required');
888
901
  lines.push(` --${flag} (${parts.join(', ')})`);
889
902
  }
890
- lines.push(' --json (boolean)');
903
+ // `--json` is global, but most command specs also list it explicitly so their usage/default
904
+ // snapshots are self-describing. Render the global row only when the command did not already
905
+ // render its explicit row; otherwise help incorrectly printed it twice.
906
+ if (!Object.hasOwn(spec.options, 'json')) lines.push(' --json (boolean)');
891
907
  lines.push(' --quiet (boolean)');
892
908
  lines.push(' --help (boolean)');
893
909
  lines.push('', "see 'bskel --help' for the full command list");
package/lib/verify.mjs CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { GATE_NAMES, GATE_DEFINITIONS, VERIFY_POLICY, gateScopeId } from './gate-definitions.mjs';
5
- import { EXIT } from './gates.mjs';
5
+ import { EXIT, requireNamedGate } from './gates.mjs';
6
+ import { getGate } from './state.mjs';
6
7
  import { specPath } from './paths.mjs';
7
8
  import { loadManifest, manifestPath } from './handles-manifest.mjs';
8
9
  import { resolveWithinRoot } from './fsutil.mjs';
@@ -81,6 +82,30 @@ export function runBuildCheck(repoRoot) {
81
82
  }
82
83
  }
83
84
 
85
+ // The complete read-only verification calculation. `bskel verify` and the PR-oriented
86
+ // `bskel ci check` deliberately share this instead of maintaining two subtly different ideas of
87
+ // what it means for a feature to pass. This function never writes gate state; the only optional
88
+ // side effect is the build command explicitly requested by the caller.
89
+ export function evaluateFeatureVerification(root, featureId, { build = false, allowSkipBuild = false } = {}) {
90
+ const gates = collectGateStatuses(root, featureId, { getGate, requireNamedGate });
91
+ const artifacts = checkArtifacts(root, featureId, gates);
92
+ const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
93
+ const conflicts = checkResolverConflicts(root, featureId, handlesRan);
94
+ const buildResult = build ? runBuildCheck(root) : null;
95
+ const gatesOk = gates.every((g) => !g.blocking);
96
+ const artifactsPresent = artifacts.every((a) => a.exists);
97
+ const buildOk = !buildResult || buildResult.ok || (!buildResult.ran && allowSkipBuild);
98
+ return {
99
+ feature: featureId,
100
+ pass: gatesOk && artifactsPresent && buildOk,
101
+ gates,
102
+ artifacts,
103
+ conflicts,
104
+ build: buildResult,
105
+ build_blocking: Boolean(buildResult && (!buildResult.ran ? !allowSkipBuild : buildResult.ok === false)),
106
+ };
107
+ }
108
+
84
109
  // G4: which spec-scoped output files a `handles` gate is expected to have produced -- provider-
85
110
  // aware, via the same scan report -> adapter -> provider chain bin/bskel.mjs's handles commands
86
111
  // use. Falls back to the pre-G4 single migration.sql expectation whenever the scan report is