backend-skeleton 1.8.0 → 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)
@@ -143,6 +144,45 @@ bskel verify --feature 001-organization-management --build
143
144
  # target repo's own build wrapper (gradlew/mvnw/npm), if present
144
145
  ```
145
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
+
146
186
  `bskel status`/`bskel next` are what you actually run over and over — real output, captured against
147
187
  a fixture repo partway through the flow above, not written by hand:
148
188
 
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';
@@ -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]
@@ -4275,15 +4277,9 @@ function cmdVerify(args) {
4275
4277
  if (flags.help) { console.log(renderCommandHelp('verify')); process.exit(0); }
4276
4278
  setContext('verify', flags);
4277
4279
  const root = requireRepoRoot();
4278
- const gates = collectGateStatuses(root, flags.feature, { getGate, requireNamedGate });
4279
- const artifacts = checkArtifacts(root, flags.feature, gates);
4280
- const handlesRan = gates.find((g) => g.gate === 'handles')?.ran ?? false;
4281
- const conflicts = checkResolverConflicts(root, flags.feature, handlesRan);
4282
- const build = flags.build ? runBuildCheck(root) : null;
4283
4280
  const allowSkipBuild = flags['allow-skip-build'];
4284
-
4285
- const gatesOk = gates.every((g) => !g.blocking);
4286
- 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;
4287
4283
  // S6 (D-verify-integrity): `conflicts` is deliberately NON-BLOCKING, same "detect and warn,
4288
4284
  // never gate" precedent as A1 §7's path-prefix signals and A4's DB drift reporting -- and the
4289
4285
  // SAME reasoning D-gate-precision (S2) already used to keep generated content OUT of the
@@ -4297,9 +4293,6 @@ function cmdVerify(args) {
4297
4293
  // used to be silently treated as "doesn't block" -- confirmed live that this let `bskel verify
4298
4294
  // --build` report an overall PASS even though the build assurance the user explicitly asked
4299
4295
  // for never actually ran. Now only acceptable with the explicit --allow-skip-build opt-out.
4300
- const buildOk = !build || build.ok || (!build.ran && allowSkipBuild);
4301
- const overallPass = gatesOk && artifactsPresent && buildOk;
4302
-
4303
4296
  if (flags.json) {
4304
4297
  console.log(JSON.stringify({ feature: flags.feature, pass: overallPass, gates, artifacts, conflicts, build }, null, 2));
4305
4298
  } else if (!flags.quiet) {
@@ -4311,6 +4304,44 @@ function cmdVerify(args) {
4311
4304
  process.exit(overallPass ? 0 : 1);
4312
4305
  }
4313
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
+
4314
4345
  // D1: same per-gate line shape renderVerifyReport uses (reusing describeStale), but framed as
4315
4346
  // "where am I" rather than a pass/fail verdict -- no VERIFY: PASS/FAIL line, and blocked_by/
4316
4347
  // next_actions/optional_not_run are appended so a human doesn't have to re-derive them by eye.
@@ -4993,6 +5024,11 @@ async function dispatchCommand(cmd, rest) {
4993
5024
  case 'verify':
4994
5025
  cmdVerify(rest);
4995
5026
  break;
5027
+ case 'ci':
5028
+ if (rest[0] === 'check') return cmdCiCheck(rest.slice(1));
5029
+ usage();
5030
+ process.exit(14);
5031
+ break;
4996
5032
  case 'status':
4997
5033
  cmdStatus(rest);
4998
5034
  break;
@@ -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
@@ -771,6 +771,18 @@ export const COMMANDS = {
771
771
  json: { type: 'boolean', default: false },
772
772
  },
773
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
+ },
774
786
  status: {
775
787
  usage: 'bskel status [--feature <id>] [--json]',
776
788
  options: {
@@ -820,10 +832,10 @@ export const COMMANDS = {
820
832
  };
821
833
 
822
834
  function describeParseArgsError(err, spec) {
823
- const known = [
835
+ const known = [...new Set([
824
836
  ...Object.keys(spec.options).filter((f) => !spec.options[f].hidden),
825
837
  'help', 'json', 'quiet',
826
- ].sort().map((f) => `--${f}`).join(', ');
838
+ ])].sort().map((f) => `--${f}`).join(', ');
827
839
  return `${err.message}\nusage: ${spec.usage}\nknown flags: ${known}`;
828
840
  }
829
841
 
@@ -888,7 +900,10 @@ export function renderCommandHelp(name) {
888
900
  if (def.required) parts.push('required');
889
901
  lines.push(` --${flag} (${parts.join(', ')})`);
890
902
  }
891
- 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)');
892
907
  lines.push(' --quiet (boolean)');
893
908
  lines.push(' --help (boolean)');
894
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scans Spring, Rails, FastAPI, and Express; scaffolding codegen included for selected stacks.",
6
6
  "license": "AGPL-3.0-or-later",
@@ -30,6 +30,7 @@
30
30
  "rules/",
31
31
  "schemas/",
32
32
  "scripts/preflight-base-ref.sh",
33
+ "action.yml",
33
34
  "LICENSE",
34
35
  "COMMERCIAL-LICENSE.md",
35
36
  "COMMERCIAL-LICENSE-AGREEMENT.md"