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 +49 -0
- package/action.yml +76 -0
- package/bin/bskel.mjs +52 -14
- package/contracts/emit.mjs +9 -2
- package/contracts/openapi.mjs +11 -7
- package/lib/ci-check.mjs +195 -0
- package/lib/cli.mjs +20 -4
- package/lib/verify.mjs +26 -1
- package/package.json +4 -2
- package/scanners/adapters/ruby-rails.mjs +578 -0
- package/scanners/capabilities.mjs +2 -2
- package/scanners/index.mjs +17 -2
- package/scanners/registry.mjs +4 -1
- package/scanners/render.mjs +3 -0
- package/scanners/text-util.mjs +5 -2
- package/schemas/oracle-manifest.schema.json +1 -1
- package/schemas/scan-report.schema.json +12 -0
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 {
|
|
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
|
|
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;
|
package/contracts/emit.mjs
CHANGED
|
@@ -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
|
|
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;
|
package/contracts/openapi.mjs
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
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
|
@@ -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
|
-
|
|
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
|