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 +40 -0
- package/action.yml +76 -0
- package/bin/bskel.mjs +48 -12
- package/lib/ci-check.mjs +195 -0
- package/lib/cli.mjs +18 -3
- package/lib/verify.mjs +26 -1
- package/package.json +2 -1
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 {
|
|
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
|
|
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;
|
package/lib/ci-check.mjs
ADDED
|
@@ -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
|
-
|
|
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.
|
|
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"
|